From 0b350124131d2e64c2aab010b25d0a9e0ceee648 Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 23 Feb 2026 11:05:39 +0700 Subject: [PATCH] implement fifo-v2 to transfer stock pakan --- .../common/service/fifo_stock_v2/gather.go | 10 +- .../common/service/fifo_stock_v2/scope_sql.go | 100 +++++++++++++ .../modules/inventory/transfers/module.go | 4 +- .../transfers/services/transfer.service.go | 136 ++++++++++++++++-- 4 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 internal/common/service/fifo_stock_v2/scope_sql.go diff --git a/internal/common/service/fifo_stock_v2/gather.go b/internal/common/service/fifo_stock_v2/gather.go index dd794d12..0fb064b1 100644 --- a/internal/common/service/fifo_stock_v2/gather.go +++ b/internal/common/service/fifo_stock_v2/gather.go @@ -199,18 +199,18 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule } 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(` SELECT - ? AS source_table, - ? AS legacy_type_key, - ? AS function_code, + ?::text AS source_table, + ?::text AS legacy_type_key, + ?::text AS function_code, src.%s AS source_id, src.%s AS product_warehouse_id, %s AS sort_at, - ? AS sort_priority, + ?::int AS sort_priority, %s AS quantity, %s AS used_quantity, %s AS pending_quantity, diff --git a/internal/common/service/fifo_stock_v2/scope_sql.go b/internal/common/service/fifo_stock_v2/scope_sql.go new file mode 100644 index 00000000..a611a4e5 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/scope_sql.go @@ -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') +} diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index fde5e55a..8e1aae94 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -52,7 +52,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(err) } - approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) 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) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) expenseBridge := sTransfer.NewTransferExpenseBridge( db, stockTransferRepo, @@ -111,7 +111,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate 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) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index fa4cb8ca..b377958b 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -46,10 +46,11 @@ type transferService struct { ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository DocumentSvc commonSvc.DocumentService FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service 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{ Log: utils.Log, Validate: validate, @@ -64,6 +65,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, 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 { detail := detailMap[uint64(product.ProductID)] - 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 := 0.0 + outPendingQty := 0.0 + useFifoV2 := s.FifoStockV2Svc != nil && pakanProducts[uint(product.ProductID)] + if useFifoV2 { + s.Log.Infof( + "[fifo-v2][transfer] use reflow movement=%s detail_id=%d product_id=%d source_pw=%d qty=%.3f", + entityTransfer.MovementNumber, + detail.Id, + product.ProductID, + *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{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ - "usage_qty": consumeResult.UsageQuantity, - "pending_qty": consumeResult.PendingQuantity, + "usage_qty": outUsageQty, + "pending_qty": outPendingQty, }).Error; err != nil { 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") @@ -493,6 +542,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } 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{ StockableKey: fifo.StockableKeyStockTransferIn, 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) return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan") } + inAddedQty = replenishResult.AddedQuantity if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ - "total_qty": replenishResult.AddedQuantity, + "total_qty": inAddedQty, }).Error; err != nil { 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") @@ -596,6 +657,53 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques 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 { if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 { return nil