feat(BE-58,59,60,61): implement stock transfer API, validation, audit log, and schema update

- Build stock transfer API with nested details, deliveries, and items
- Extend DB schema for stock transfers
- Implement validation for transfer request and stock
- Prepare/implement transfer audit log structure
- Preload all relations for complete response
- Update DTOs for nested response
- Remove redundant root fields, use relation objects
This commit is contained in:
aguhh18
2025-10-15 11:20:32 +07:00
parent 9b016dc30a
commit d1b377ddac
6 changed files with 210 additions and 44 deletions
@@ -4,48 +4,146 @@ import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
// === DTO Structs ===
type TransferBaseDTO struct {
Id uint64 `json:"id"`
TransferReason string `json:"transfer_reason"`
TransferDate string `json:"transfer_date"`
SourceWarehouseId uint64 `json:"source_warehouse_id"`
DestinationWarehouseId uint64 `json:"destination_warehouse_id"`
Id uint64 `json:"id"`
TransferReason string `json:"transfer_reason"`
TransferDate string `json:"transfer_date"`
SourceWarehouse *WarehouseSimpleDTO `json:"source_warehouse,omitempty"`
DestinationWarehouse *WarehouseSimpleDTO `json:"destination_warehouse,omitempty"`
}
// Only id and name for warehouse simple view
type WarehouseSimpleDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type TransferListDTO struct {
TransferBaseDTO
CreatedBy uint64 `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Details []TransferDetailItemDTO `json:"details"`
Deliveries []TransferDeliveryDTO `json:"deliveries"`
}
type TransferDetailDTO struct {
TransferListDTO
// Tambahkan detail produk, deliveries, dsb jika perlu
Details []TransferDetailItemDTO `json:"details"`
Deliveries []TransferDeliveryDTO `json:"deliveries"`
}
// Detail produk
type TransferDetailItemDTO struct {
Id uint64 `json:"id"`
ProductId uint64 `json:"product_id"`
Quantity float64 `json:"quantity"`
BeforeQuantity float64 `json:"before_quantity"`
AfterQuantity float64 `json:"after_quantity"`
Note string `json:"note"`
}
// Delivery ekspedisi
type TransferDeliveryDTO struct {
Id uint64 `json:"id"`
SupplierId uint64 `json:"supplier_id"`
VehiclePlate string `json:"vehicle_plate"`
DriverName string `json:"driver_name"`
DocumentNumber string `json:"document_number"`
DocumentPath string `json:"document_path"`
ShippingCostItem float64 `json:"shipping_cost_item"`
ShippingCostTotal float64 `json:"shipping_cost_total"`
Note string `json:"note"`
Items []TransferDeliveryItemDTO `json:"items"`
}
type TransferDeliveryItemDTO struct {
Id uint64 `json:"id"`
StockTransferDetailId uint64 `json:"stock_transfer_detail_id"`
Quantity float64 `json:"quantity"`
}
// === Mapper Functions ===
func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO {
var sourceWarehouse *WarehouseSimpleDTO
if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 {
sourceWarehouse = &WarehouseSimpleDTO{
Id: e.FromWarehouse.Id,
Name: e.FromWarehouse.Name,
}
}
var destinationWarehouse *WarehouseSimpleDTO
if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 {
destinationWarehouse = &WarehouseSimpleDTO{
Id: e.ToWarehouse.Id,
Name: e.ToWarehouse.Name,
}
}
return TransferBaseDTO{
Id: e.Id,
TransferReason: e.Reason, // atau field lain sesuai entity
TransferDate: e.CreatedAt.Format("2006-01-02"),
SourceWarehouseId: e.FromWarehouseId,
DestinationWarehouseId: e.ToWarehouseId,
Id: e.Id,
TransferReason: e.Reason,
TransferDate: e.CreatedAt.Format("2006-01-02"),
SourceWarehouse: sourceWarehouse,
DestinationWarehouse: destinationWarehouse,
}
}
func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
var createdUser *userDTO.UserBaseDTO
if e.CreatedUser != nil {
mapped := userDTO.ToUserBaseDTO(*e.CreatedUser)
createdUser = &mapped
}
// Map details
var details []TransferDetailItemDTO
for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{
Id: d.Id,
ProductId: d.ProductId,
Quantity: d.Quantity,
BeforeQuantity: d.BeforeQuantity,
AfterQuantity: d.AfterQuantity,
Note: d.Note,
})
}
// Map deliveries
var deliveries []TransferDeliveryDTO
for _, del := range e.Deliveries {
// Map delivery items
var items []TransferDeliveryItemDTO
for _, item := range del.Items {
items = append(items, TransferDeliveryItemDTO{
Id: item.Id,
StockTransferDetailId: item.StockTransferDetailId,
Quantity: item.Quantity,
})
}
deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id,
SupplierId: del.SupplierId,
VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber,
DocumentPath: del.DocumentPath,
ShippingCostItem: del.ShippingCostItem,
ShippingCostTotal: del.ShippingCostTotal,
Note: del.Note,
Items: items,
})
}
return TransferListDTO{
TransferBaseDTO: ToTransferBaseDTO(e),
CreatedBy: e.CreatedBy,
CreatedUser: createdUser,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Details: details,
Deliveries: deliveries,
}
}
@@ -58,7 +156,36 @@ func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO {
}
func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
// Map details
var details []TransferDetailItemDTO
for _, d := range e.Details {
details = append(details, TransferDetailItemDTO{
Id: d.Id,
ProductId: d.ProductId,
Quantity: d.Quantity,
BeforeQuantity: d.BeforeQuantity,
AfterQuantity: d.AfterQuantity,
Note: d.Note,
})
}
// Map deliveries
var deliveries []TransferDeliveryDTO
for _, del := range e.Deliveries {
deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id,
SupplierId: del.SupplierId,
VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber,
DocumentPath: del.DocumentPath,
ShippingCostItem: del.ShippingCostItem,
ShippingCostTotal: del.ShippingCostTotal,
Note: del.Note,
})
}
return TransferDetailDTO{
TransferListDTO: ToTransferListDTO(e),
Details: details,
Deliveries: deliveries,
}
}
@@ -1,6 +1,10 @@
// Find all details by StockTransferId
package repositories
import (
"context"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
@@ -8,7 +12,7 @@ import (
type StockTransferDetailRepository interface {
repository.BaseRepository[entity.StockTransferDetail]
// Tambahkan custom method jika perlu
FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error
}
type StockTransferDetailRepositoryImpl struct {
@@ -20,3 +24,6 @@ func NewStockTransferDetailRepository(db *gorm.DB) StockTransferDetailRepository
BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDetail](db),
}
}
func (r *StockTransferDetailRepositoryImpl) FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error {
return r.DB().WithContext(ctx).Where("stock_transfer_id = ?", transferId).Find(out).Error
}
@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
@@ -46,9 +47,13 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
ProductWarehouseRepo: productWarehouseRepo,
}
}
func (s transferService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser")
return db.
Preload("CreatedUser").
Preload("FromWarehouse").
Preload("ToWarehouse").
Preload("Details").
Preload("Deliveries.Items")
}
func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) {
@@ -56,26 +61,37 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
return nil, 0, err
}
// offset := (params.Page - 1) * params.Limit
offset := (params.Page - 1) * params.Limit
// transfers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
// db = s.withRelations(db)
// if params.Search != "" {
// return db.Where("name LIKE ?", "%"+params.Search+"%")
// }
// return db.Order("created_at DESC").Order("updated_at DESC")
// })
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = db.Preload("CreatedUser").Preload("FromWarehouse").Preload("ToWarehouse").Preload("Details").Preload("Deliveries.Items")
if params.Search != "" {
db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%")
}
return db.Order("created_at DESC").Order("updated_at DESC")
})
if err != nil {
return nil, 0, err
}
s.Log.Infof("Retrieved %d transfers", len(transfers))
return transfers, total, nil
// if err != nil {
// s.Log.Errorf("Failed to get transfers: %+v", err)
// return nil, 0, err
// }
return nil, 0, nil
}
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
return nil, nil
var transfer entity.StockTransfer
db := s.StockTransferRepo.DB().WithContext(c.Context())
db = db.Preload("CreatedUser").Preload("FromWarehouse").Preload("ToWarehouse").Preload("Details").Preload("Deliveries.Items")
if err := db.First(&transfer, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer")
}
return &transfer, nil
}
func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) {
@@ -160,16 +176,31 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err)
return err
}
// tambahkan insert ke delivery items sebagai fivot
detailMap := make(map[uint64]uint64)
for _, d := range details {
detailMap[d.ProductId] = d.Id
}
var deliveryItems []*entity.StockTransferDeliveryItem
for i, delivery := range req.Deliveries {
for _, item := range delivery.Products {
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
StockTransferDeliveryId: deliveries[i].Id,
StockTransferDetailId: uint64(item.ProductID),
Quantity: item.ProductQty,
})
for _, delivery := range deliveries {
for _, item := range req.Deliveries {
if item.Document == delivery.DocumentPath {
for _, prod := range item.Products {
detailID, ok := detailMap[uint64(prod.ProductID)]
if !ok {
return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID)
}
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
StockTransferDeliveryId: delivery.Id,
StockTransferDetailId: detailID,
Quantity: prod.ProductQty,
})
}
}
}
}
if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil {
@@ -225,8 +256,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return err
}
s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id)
}
}
return nil
})
@@ -25,7 +25,6 @@ type TransferDelivery struct {
DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"`
Document string `json:"document"`
DriverName string `json:"driver_name" validate:"required"`
DeliveryNoteNumber string `json:"delivery_note_number" validate:"required"`
VehiclePlate string `json:"vehicle_plate" validate:"required"`
SupplierID uint `json:"supplier_id" validate:"required"`
Products []TransferDeliveryProduct `json:"products" validate:"required,dive"`