From 4107cf19ec433a64056c9695e9ef9fd4b8056ae8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 15 Oct 2025 22:25:50 +0700 Subject: [PATCH] feat(BE-59,60,61): build stock transfer API with validation and audit log - Implement CreateOne for stock transfer with multi-delivery and validation - Preload warehouse, location, and area relations in transfer response - Add audit log for transfer - Improve transaction handling and error management --- ...24642_create_stock_transfer_details.up.sql | 1 - ...56_create_stock_transfer_deliveries.up.sql | 1 - internal/database/seed/seeder.go | 84 ++++++++++++++++- internal/entities/stock_log.go | 1 - internal/entities/stock_transfer_delivery.go | 1 - internal/entities/stock_transfer_detail.go | 28 +++--- .../controllers/transfer.controller.go | 13 ++- .../inventory/transfers/dto/transfer.dto.go | 93 +++++++++++++------ .../transfers/services/transfer.service.go | 72 ++++++++------ .../validations/transfer.validation.go | 2 +- 10 files changed, 219 insertions(+), 77 deletions(-) diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql index 8ff8858c..090014ff 100644 --- a/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql @@ -9,7 +9,6 @@ CREATE TABLE IF NOT EXISTS stock_transfer_details ( quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0), before_quantity NUMERIC(15, 3), after_quantity NUMERIC(15, 3), - note TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql index f5887b16..52e5b5c2 100644 --- a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql @@ -12,7 +12,6 @@ CREATE TABLE IF NOT EXISTS stock_transfer_deliveries ( document_path TEXT, shipping_cost_item NUMERIC(15,3), shipping_cost_total NUMERIC(15,3), - note TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index b321a784..11717d2c 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -78,6 +78,10 @@ func Run(db *gorm.DB) error { return err } + if err := seedTransferStock(tx, adminID); err != nil { + return err + } + fmt.Println("✅ Master data seeding completed") return nil }) @@ -775,7 +779,7 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { }{ {ProductID: 1, WarehouseID: 1, Quantity: 100}, {ProductID: 2, WarehouseID: 2, Quantity: 200}, - {ProductID: 1, WarehouseID: 1, Quantity: 300}, + {ProductID: 2, WarehouseID: 1, Quantity: 300}, } for _, seed := range seeds { @@ -799,6 +803,84 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { return nil } +func seedTransferStock(tx *gorm.DB, createdBy uint) error { + // Seeder Transfer Stock + // 1. Insert StockTransfer (header) + transfer := entity.StockTransfer{ + FromWarehouseId: 1, + ToWarehouseId: 2, + Reason: "Seed transfer stock", + TransferDate: time.Now(), + MovementNumber: "SEED-TRF-00001", + CreatedBy: 1, + } + if err := tx.Create(&transfer).Error; err != nil { + return err + } + + // 2. Insert StockTransferDetail (detail) + details := []entity.StockTransferDetail{ + { + StockTransferId: transfer.Id, + ProductId: 1, + Quantity: 10, + }, + { + StockTransferId: transfer.Id, + ProductId: 2, + Quantity: 5, + }, + } + for i := range details { + if err := tx.Create(&details[i]).Error; err != nil { + return err + } + } + + // 3. Insert StockTransferDelivery (delivery) + deliveries := []entity.StockTransferDelivery{ + { + StockTransferId: transfer.Id, + SupplierId: 1, + VehiclePlate: "B 1234 XYZ", + DriverName: "Driver Seed", + DocumentPath: "seed.pdf", + ShippingCostItem: 1000, + ShippingCostTotal: 2000, + }, + } + for i := range deliveries { + if err := tx.Create(&deliveries[i]).Error; err != nil { + return err + } + } + + detailMap := make(map[uint64]uint64) + for _, d := range details { + detailMap[d.ProductId] = d.Id + } + + deliveryItems := []entity.StockTransferDeliveryItem{ + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[1], + Quantity: 50, + }, + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[2], + Quantity: 30, + }, + } + for i := range deliveryItems { + if err := tx.Create(&deliveryItems[i]).Error; err != nil { + return err + } + } + + return nil +} + func ptr[T any](v T) *T { return &v } diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 6546e790..21e86bd4 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -8,7 +8,6 @@ import ( const ( LogTypeAdjustment = "ADJUSTMENT" - LogTypeTransfer = "TRANSFER" ) const ( diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go index bd156389..3a7562ea 100644 --- a/internal/entities/stock_transfer_delivery.go +++ b/internal/entities/stock_transfer_delivery.go @@ -13,7 +13,6 @@ type StockTransferDelivery struct { DocumentPath string ShippingCostItem float64 ShippingCostTotal float64 - Note string CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time `gorm:"index"` diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go index 2a3f2fcf..253a3bf8 100644 --- a/internal/entities/stock_transfer_detail.go +++ b/internal/entities/stock_transfer_detail.go @@ -2,21 +2,17 @@ package entities import "time" - // DETAIL PRODUK type StockTransferDetail struct { - Id uint64 `gorm:"primaryKey;autoIncrement"` - StockTransferId uint64 - ProductId uint64 - Quantity float64 - BeforeQuantity float64 - AfterQuantity float64 - Note string - CreatedAt time.Time - UpdatedAt time.Time - DeletedAt *time.Time `gorm:"index"` - // Relations - StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` - Product *Product `gorm:"foreignKey:ProductId"` - DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` -} \ No newline at end of file + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + ProductId uint64 + Quantity float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` +} diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go index d499639e..b53d6e9a 100644 --- a/internal/modules/inventory/transfers/controllers/transfer.controller.go +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "math" "strconv" @@ -72,11 +73,21 @@ func (u *TransferController) GetOne(c *fiber.Ctx) error { } func (u *TransferController) CreateOne(c *fiber.Ctx) error { + data := c.FormValue("data") + var req validation.TransferRequest - if err := c.BodyParser(&req); err != nil { + if err := json.Unmarshal([]byte(data), &req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") } + // ambil file + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + _ = form.File["documents"] + // todo: tunggu ada aws baru proses + result, err := u.TransferService.CreateOne(c, &req) if err != nil { return err diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go index 10bc820b..e7f50781 100644 --- a/internal/modules/inventory/transfers/dto/transfer.dto.go +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -13,8 +13,8 @@ type TransferBaseDTO struct { 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"` + SourceWarehouse *WarehouseDetailDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` } // Only id and name for warehouse simple view @@ -23,6 +23,24 @@ type WarehouseSimpleDTO struct { Name string `json:"name"` } +type AreaDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Area *AreaDTO `json:"area"` +} + +type WarehouseDetailDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Location *LocationDTO `json:"location"` + Area *AreaDTO `json:"area"` +} + type TransferListDTO struct { TransferBaseDTO CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` @@ -45,7 +63,6 @@ type TransferDetailItemDTO struct { Quantity float64 `json:"quantity"` BeforeQuantity float64 `json:"before_quantity"` AfterQuantity float64 `json:"after_quantity"` - Note string `json:"note"` } // Delivery ekspedisi @@ -58,7 +75,6 @@ type TransferDeliveryDTO struct { 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"` } @@ -71,19 +87,14 @@ type TransferDeliveryItemDTO struct { // === Mapper Functions === func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { - var sourceWarehouse *WarehouseSimpleDTO + + var sourceWarehouse *WarehouseDetailDTO if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { - sourceWarehouse = &WarehouseSimpleDTO{ - Id: e.FromWarehouse.Id, - Name: e.FromWarehouse.Name, - } + sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) } - var destinationWarehouse *WarehouseSimpleDTO + var destinationWarehouse *WarehouseDetailDTO if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { - destinationWarehouse = &WarehouseSimpleDTO{ - Id: e.ToWarehouse.Id, - Name: e.ToWarehouse.Name, - } + destinationWarehouse = toWarehouseDetailDTO(e.ToWarehouse) } return TransferBaseDTO{ Id: e.Id, @@ -94,6 +105,40 @@ func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { } } +func toAreaDTO(a *entity.Area) *AreaDTO { + if a == nil { + return nil + } + return &AreaDTO{ + Id: a.Id, + Name: a.Name, + } +} + +func toLocationDTO(l *entity.Location) *LocationDTO { + if l == nil { + return nil + } + // Area selalu diisi jika l.Area ada + return &LocationDTO{ + Id: l.Id, + Name: l.Name, + Area: toAreaDTO(&l.Area), + } +} + +func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { + if w == nil { + return nil + } + return &WarehouseDetailDTO{ + Id: w.Id, + Name: w.Name, + Location: toLocationDTO(w.Location), + Area: toAreaDTO(&w.Area), + } +} + func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { var createdUser *userDTO.UserBaseDTO if e.CreatedUser != nil { @@ -104,12 +149,9 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { 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, + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, }) } // Map deliveries @@ -133,7 +175,6 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Note: del.Note, Items: items, }) } @@ -160,12 +201,9 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { 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, + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, }) } // Map deliveries @@ -180,7 +218,6 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { DocumentPath: del.DocumentPath, ShippingCostItem: del.ShippingCostItem, ShippingCostTotal: del.ShippingCostTotal, - Note: del.Note, }) } return TransferDetailDTO{ diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 80267e0c..5e3b778e 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -51,7 +51,11 @@ func (s transferService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("FromWarehouse"). + Preload("FromWarehouse.Location"). + Preload("FromWarehouse.Area"). Preload("ToWarehouse"). + Preload("ToWarehouse.Location"). + Preload("ToWarehouse.Area"). Preload("Details"). Preload("Deliveries.Items") } @@ -64,7 +68,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit offset := (params.Page - 1) * params.Limit 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") + db = s.withRelations(db) if params.Search != "" { db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") } @@ -83,8 +87,10 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { var transfer entity.StockTransfer + db := s.StockTransferRepo.DB().WithContext(c.Context()) - db = db.Preload("CreatedUser").Preload("FromWarehouse").Preload("ToWarehouse").Preload("Details").Preload("Deliveries.Items") + db = s.withRelations(db) + if err := db.First(&transfer, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") @@ -95,11 +101,8 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - // Validasi stok di gudang asal + // Validasi stok di gudang asal harus exist dan mencukupi for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), @@ -115,6 +118,22 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid + deliveryQtyMap := make(map[uint]float64) + for _, delivery := range req.Deliveries { + for _, prod := range delivery.Products { + deliveryQtyMap[prod.ProductID] += prod.ProductQty + } + } + + // Cek: qty delivery tidak boleh melebihi qty di root + for _, product := range req.Products { + if deliveryQtyMap[product.ProductID] > product.ProductQty { + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Total qty delivery untuk produk %d (%v) melebihi qty transfer (%v)", product.ProductID, deliveryQtyMap[product.ProductID], product.ProductQty)) + } + } + // Generate movement number // Format: PND-MBU-00001 seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) @@ -167,7 +186,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: delivery.Document, + DocumentPath: "dummy duls", // todo: tunggu ada aws baru proses ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) @@ -176,8 +195,7 @@ 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 - + // tambahkan insert ke delivery items sebagai pivot detailMap := make(map[uint64]uint64) for _, d := range details { detailMap[d.ProductId] = d.Id @@ -185,22 +203,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques var deliveryItems []*entity.StockTransferDeliveryItem - 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, - }) - } + for i, delivery := range deliveries { + item := req.Deliveries[i] + 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 { @@ -250,6 +264,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) } + // Update stok di gudang tujuan destPW.Quantity += product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { s.Log.Errorf("Failed to update destination product warehouse: %+v", err) @@ -257,9 +272,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) - } + return nil }) @@ -268,5 +283,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") } - return entityTransfer, nil + // Ambil data lengkap hasil create dengan GetOne (agar preload relasi sama dengan GetOne) + result, err := s.GetOne(c, uint(entityTransfer.Id)) + if err != nil { + return nil, err + } + return result, nil } diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go index 778bdef4..c64077ff 100644 --- a/internal/modules/inventory/transfers/validations/transfer.validation.go +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -23,7 +23,7 @@ type TransferDeliveryProduct struct { type TransferDelivery struct { DeliveryCost float64 `json:"delivery_cost" validate:"required"` DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` - Document string `json:"document"` + DocumentIndex int `json:"document_index" validate:"min=0"` DriverName string `json:"driver_name" validate:"required"` VehiclePlate string `json:"vehicle_plate" validate:"required"` SupplierID uint `json:"supplier_id" validate:"required"`