implement fifo-v2 to transfer stock pakan

This commit is contained in:
giovanni
2026-02-23 11:05:39 +07:00
parent c3930ab555
commit 0b35012413
4 changed files with 229 additions and 21 deletions
@@ -199,18 +199,18 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule
} }
if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" { if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" {
whereParts = append(whereParts, fmt.Sprintf("(%s)", strings.TrimSpace(*rule.ScopeSQL))) whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL)))
} }
subquery := fmt.Sprintf(` subquery := fmt.Sprintf(`
SELECT SELECT
? AS source_table, ?::text AS source_table,
? AS legacy_type_key, ?::text AS legacy_type_key,
? AS function_code, ?::text AS function_code,
src.%s AS source_id, src.%s AS source_id,
src.%s AS product_warehouse_id, src.%s AS product_warehouse_id,
%s AS sort_at, %s AS sort_at,
? AS sort_priority, ?::int AS sort_priority,
%s AS quantity, %s AS quantity,
%s AS used_quantity, %s AS used_quantity,
%s AS pending_quantity, %s AS pending_quantity,
@@ -0,0 +1,100 @@
package fifo_stock_v2
import "strings"
func normalizeScopeSQL(scopeSQL string) string {
scopeSQL = strings.TrimSpace(scopeSQL)
if scopeSQL == "" {
return scopeSQL
}
var out strings.Builder
out.Grow(len(scopeSQL) + 16)
inSingleQuote := false
inDoubleQuote := false
for i := 0; i < len(scopeSQL); {
ch := scopeSQL[i]
if inSingleQuote {
out.WriteByte(ch)
i++
if ch == '\'' {
if i < len(scopeSQL) && scopeSQL[i] == '\'' {
out.WriteByte(scopeSQL[i])
i++
} else {
inSingleQuote = false
}
}
continue
}
if inDoubleQuote {
out.WriteByte(ch)
i++
if ch == '"' {
inDoubleQuote = false
}
continue
}
if ch == '\'' {
inSingleQuote = true
out.WriteByte(ch)
i++
continue
}
if ch == '"' {
inDoubleQuote = true
out.WriteByte(ch)
i++
continue
}
if isIdentifierStart(ch) {
start := i
i++
for i < len(scopeSQL) && isIdentifierPart(scopeSQL[i]) {
i++
}
token := scopeSQL[start:i]
if strings.EqualFold(token, "deleted_at") && !hasAliasQualifier(scopeSQL, start) {
out.WriteString("src.deleted_at")
} else {
out.WriteString(token)
}
continue
}
out.WriteByte(ch)
i++
}
return out.String()
}
func hasAliasQualifier(scopeSQL string, tokenStart int) bool {
for i := tokenStart - 1; i >= 0; i-- {
switch scopeSQL[i] {
case ' ', '\t', '\n', '\r':
continue
case '.':
return true
default:
return false
}
}
return false
}
func isIdentifierStart(ch byte) bool {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
}
func isIdentifierPart(ch byte) bool {
return isIdentifierStart(ch) || (ch >= '0' && ch <= '9')
}
@@ -52,7 +52,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(err) panic(err)
} }
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalSvc := commonSvc.NewApprovalService(approvalRepo) approvalSvc := commonSvc.NewApprovalService(approvalRepo)
if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil {
@@ -71,6 +70,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
) )
fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log)
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
expenseBridge := sTransfer.NewTransferExpenseBridge( expenseBridge := sTransfer.NewTransferExpenseBridge(
db, db,
stockTransferRepo, stockTransferRepo,
@@ -111,7 +111,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(err) panic(err)
} }
transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, expenseBridge) transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, fifoStockV2Service, expenseBridge)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
TransferRoutes(router, userService, transferService) TransferRoutes(router, userService, transferService)
@@ -46,10 +46,11 @@ type transferService struct {
ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
DocumentSvc commonSvc.DocumentService DocumentSvc commonSvc.DocumentService
FifoSvc commonSvc.FifoService FifoSvc commonSvc.FifoService
FifoStockV2Svc commonSvc.FifoStockV2Service
ExpenseBridge TransferExpenseBridge ExpenseBridge TransferExpenseBridge
} }
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, expenseBridge TransferExpenseBridge) TransferService { func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
return &transferService{ return &transferService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
@@ -64,6 +65,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
DocumentSvc: documentSvc, DocumentSvc: documentSvc,
FifoSvc: fifoSvc, FifoSvc: fifoSvc,
FifoStockV2Svc: fifoStockV2Svc,
ExpenseBridge: expenseBridge, ExpenseBridge: expenseBridge,
} }
} }
@@ -442,26 +444,73 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
} }
pakanProducts := map[uint]bool{}
if s.FifoStockV2Svc != nil && len(req.Products) > 0 {
pakanProducts, err = s.resolvePakanProducts(c.Context(), tx, req.Products)
if err != nil {
return err
}
}
for _, product := range req.Products { for _, product := range req.Products {
detail := detailMap[uint64(product.ProductID)] detail := detailMap[uint64(product.ProductID)]
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ outUsageQty := 0.0
UsableKey: fifo.UsableKeyStockTransferOut, outPendingQty := 0.0
UsableID: uint(detail.Id), useFifoV2 := s.FifoStockV2Svc != nil && pakanProducts[uint(product.ProductID)]
ProductWarehouseID: uint(*detail.SourceProductWarehouseID), if useFifoV2 {
Quantity: product.ProductQty, s.Log.Infof(
AllowPending: false, "[fifo-v2][transfer] use reflow movement=%s detail_id=%d product_id=%d source_pw=%d qty=%.3f",
Tx: tx, entityTransfer.MovementNumber,
}) detail.Id,
if err != nil { product.ProductID,
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) *detail.SourceProductWarehouseID,
product.ProductQty,
)
reflowResult, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
FlagGroupCode: "PAKAN",
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Usable: commonSvc.FifoStockV2Ref{
ID: uint(detail.Id),
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
FunctionCode: "STOCK_TRANSFER_OUT",
},
DesiredQty: product.ProductQty,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
outUsageQty = reflowResult.Allocate.AllocatedQty
outPendingQty = reflowResult.Allocate.PendingQty
s.Log.Infof(
"[fifo-v2][transfer] reflow result movement=%s detail_id=%d usage=%.3f pending=%.3f",
entityTransfer.MovementNumber,
detail.Id,
outUsageQty,
outPendingQty,
)
} else {
consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{
UsableKey: fifo.UsableKeyStockTransferOut,
UsableID: uint(detail.Id),
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
Quantity: product.ProductQty,
AllowPending: false,
Tx: tx,
})
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
}
outUsageQty = consumeResult.UsageQuantity
outPendingQty = consumeResult.PendingQuantity
} }
if err := tx.Model(&entity.StockTransferDetail{}). if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id). Where("id = ?", detail.Id).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"usage_qty": consumeResult.UsageQuantity, "usage_qty": outUsageQty,
"pending_qty": consumeResult.PendingQuantity, "pending_qty": outPendingQty,
}).Error; err != nil { }).Error; err != nil {
s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
@@ -493,6 +542,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
inAddedQty := 0.0
if useFifoV2 {
s.Log.Infof(
"[fifo-v2][transfer] stock-in uses replenish path movement=%s detail_id=%d product_id=%d dest_pw=%d qty=%.3f",
entityTransfer.MovementNumber,
detail.Id,
product.ProductID,
*detail.DestProductWarehouseID,
product.ProductQty,
)
}
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyStockTransferIn, StockableKey: fifo.StockableKeyStockTransferIn,
StockableID: uint(detail.Id), StockableID: uint(detail.Id),
@@ -505,11 +565,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err) s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan") return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan")
} }
inAddedQty = replenishResult.AddedQuantity
if err := tx.Model(&entity.StockTransferDetail{}). if err := tx.Model(&entity.StockTransferDetail{}).
Where("id = ?", detail.Id). Where("id = ?", detail.Id).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity, "total_qty": inAddedQty,
}).Error; err != nil { }).Error; err != nil {
s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
@@ -596,6 +657,53 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return result, nil return result, nil
} }
func (s *transferService) resolvePakanProducts(
ctx context.Context,
tx *gorm.DB,
products []validation.TransferProduct,
) (map[uint]bool, error) {
out := make(map[uint]bool, len(products))
if len(products) == 0 {
return out, nil
}
productIDs := make([]uint, 0, len(products))
seen := make(map[uint]struct{}, len(products))
for _, product := range products {
if product.ProductID == 0 {
continue
}
if _, ok := seen[product.ProductID]; ok {
continue
}
seen[product.ProductID] = struct{}{}
productIDs = append(productIDs, product.ProductID)
}
if len(productIDs) == 0 {
return out, nil
}
type row struct {
ProductID uint `gorm:"column:product_id"`
}
var rows []row
err := tx.WithContext(ctx).
Table("flags f").
Select("DISTINCT f.flagable_id AS product_id").
Where("f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", []string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"}).
Where("f.flagable_id IN ?", productIDs).
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
out[row.ProductID] = true
}
return out, nil
}
func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error { func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error {
if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 { if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 {
return nil return nil