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
This commit is contained in:
aguhh18
2025-10-15 22:25:50 +07:00
parent d1b377ddac
commit 4107cf19ec
10 changed files with 219 additions and 77 deletions
@@ -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
@@ -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
+83 -1
View File
@@ -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
}
-1
View File
@@ -8,7 +8,6 @@ import (
const (
LogTypeAdjustment = "ADJUSTMENT"
LogTypeTransfer = "TRANSFER"
)
const (
@@ -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"`
+12 -16
View File
@@ -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"`
}
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"`
}
@@ -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
@@ -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{
@@ -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
}
@@ -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"`