diff --git a/cmd/delete-adjustments/main.go b/cmd/delete-adjustments/main.go index 072db41c..e07a7ae3 100644 --- a/cmd/delete-adjustments/main.go +++ b/cmd/delete-adjustments/main.go @@ -134,14 +134,9 @@ func main() { reflowReq := commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: route.FlagGroupCode, ProductWarehouseID: adj.ProductWarehouseID, - Usable: commonSvc.FifoStockV2Ref{ - ID: adj.ID, - LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(), - FunctionCode: route.FunctionCode, - }, - DesiredQty: 0, - IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()), - Tx: tx, + AsOf: &adj.CreatedAt, + IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()), + Tx: tx, } if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil { return fmt.Errorf("reflow usable to zero: %w", err) diff --git a/cmd/reflow-adjustments/main.go b/cmd/reflow-adjustments/main.go index 0246e542..9e2a351b 100644 --- a/cmd/reflow-adjustments/main.go +++ b/cmd/reflow-adjustments/main.go @@ -121,12 +121,7 @@ func main() { continue } - usableType := fifo.UsableKeyAdjustmentOut.String() - if route.SourceTable == "adjustment_stocks" && strings.TrimSpace(route.LegacyTypeKey) != "" { - usableType = strings.TrimSpace(route.LegacyTypeKey) - } - - activeAllocationCount, err := countActiveAllocations(ctx, db, usableType, adj.ID) + activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID) if err != nil { fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err) failed++ @@ -142,13 +137,7 @@ func main() { reflowReq := commonSvc.FifoStockV2ReflowRequest{ FlagGroupCode: route.FlagGroupCode, ProductWarehouseID: adj.ProductWarehouseID, - Usable: commonSvc.FifoStockV2Ref{ - ID: adj.ID, - LegacyTypeKey: usableType, - FunctionCode: route.FunctionCode, - }, - DesiredQty: desiredQty, - IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()), + IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()), } if asOfCreatedAt { asOf := adj.CreatedAt diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index 2fb45090..6a3a5d45 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -401,12 +401,9 @@ func (s *fifoStockV2Service) rollbackInternal( } func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) { - if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 || req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" { + if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 { return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest) } - if req.DesiredQty < 0 { - return nil, fmt.Errorf("%w: desired qty must be >= 0", ErrInvalidRequest) - } result := &ReflowResult{} err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { @@ -420,11 +417,7 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re hash := requestHash(map[string]any{ "flag_group_code": req.FlagGroupCode, "product_warehouse_id": req.ProductWarehouseID, - "usable_type": req.Usable.LegacyTypeKey, - "usable_id": req.Usable.ID, - "desired_qty": req.DesiredQty, "as_of": req.AsOf, - "allow_over_consume": req.AllowOverConsume, }) logRow, reused, err := s.beginOperation( tx, @@ -433,8 +426,8 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re hash, req.ProductWarehouseID, req.FlagGroupCode, - req.Usable.LegacyTypeKey, - req.Usable.ID, + "", + 0, ) if err != nil { return err @@ -456,32 +449,82 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re }() } - rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{ + usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{ + FlagGroupCode: req.FlagGroupCode, + Lane: LaneUsable, ProductWarehouseID: req.ProductWarehouseID, - Usable: req.Usable, - ReleaseQty: nil, - Reason: "reflow reset", - }, req.FlagGroupCode) - if rollbackErr != nil { - err = rollbackErr - return rollbackErr + Limit: s.defaultGatherLimit, + }) + if gatherErr != nil { + err = gatherErr + return gatherErr } - result.Rollback = *rollbackRes + result.ProcessedUsables = len(usableRows) - if req.DesiredQty > 0 { + for _, usableRow := range usableRows { + desiredQty := usableRow.Quantity + usableRow.PendingQuantity + + rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{ + ProductWarehouseID: req.ProductWarehouseID, + Usable: usableRow.Ref, + ReleaseQty: nil, + Reason: "reflow reset", + }, req.FlagGroupCode) + if rollbackErr != nil { + err = rollbackErr + return rollbackErr + } + result.Rollback.ReleasedQty += rollbackRes.ReleasedQty + if len(rollbackRes.Details) > 0 { + result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...) + } + minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity + if desiredQty < minDesired { + desiredQty = minDesired + } + + if desiredQty <= 0 { + continue + } + + asOf := usableRow.SortAt + if req.AsOf != nil && asOf.Before(*req.AsOf) { + asOf = *req.AsOf + } allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{ FlagGroupCode: req.FlagGroupCode, ProductWarehouseID: req.ProductWarehouseID, - Usable: req.Usable, - NeedQty: req.DesiredQty, - AllowOverConsume: req.AllowOverConsume, - AsOf: req.AsOf, + Usable: usableRow.Ref, + NeedQty: desiredQty, + AsOf: &asOf, }) if allocateErr != nil { err = allocateErr return allocateErr } - result.Allocate = *allocateRes + result.Allocate.AllocatedQty += allocateRes.AllocatedQty + result.Allocate.PendingQty += allocateRes.PendingQty + if len(allocateRes.Details) > 0 { + result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...) + } + } + + expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil) + if calcErr != nil { + err = calcErr + return calcErr + } + actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID) + if loadErr != nil { + err = loadErr + return loadErr + } + drift := expectedQty - actualQty + if math.Abs(drift) >= 1e-6 { + if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil { + err = adjustErr + return adjustErr + } } if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil { @@ -496,6 +539,54 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re return result, nil } +func (s *fifoStockV2Service) gatherAllRows( + ctx context.Context, + tx *gorm.DB, + req GatherRequest, +) ([]GatherRow, error) { + limit := req.Limit + if limit <= 0 { + limit = s.defaultGatherLimit + } + if limit <= 0 { + limit = 1000 + } + + req.Limit = limit + out := make([]GatherRow, 0, limit) + + var cursorSortAt *time.Time + cursorSourceTable := "" + var cursorSourceID uint + + for { + req.AfterSortAt = cursorSortAt + req.AfterSourceTable = cursorSourceTable + req.AfterSourceID = cursorSourceID + + rows, err := s.gatherRows(ctx, tx, req) + if err != nil { + return nil, err + } + if len(rows) == 0 { + break + } + + out = append(out, rows...) + if len(rows) < limit { + break + } + + last := rows[len(rows)-1] + lastSortAt := last.SortAt + cursorSortAt = &lastSortAt + cursorSourceTable = last.SourceTable + cursorSourceID = last.SourceID + } + + return out, nil +} + func (s *fifoStockV2Service) loadActiveAllocations( tx *gorm.DB, usableType string, diff --git a/internal/common/service/fifo_stock_v2/gather.go b/internal/common/service/fifo_stock_v2/gather.go index 0fb064b1..3812bfae 100644 --- a/internal/common/service/fifo_stock_v2/gather.go +++ b/internal/common/service/fifo_stock_v2/gather.go @@ -197,6 +197,9 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule if req.AsOf != nil { whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr)) } + if req.From != nil { + whereParts = append(whereParts, fmt.Sprintf("%s >= ?", sortExpr)) + } if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" { whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL))) @@ -236,6 +239,9 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule if req.AsOf != nil { args = append(args, *req.AsOf) } + if req.From != nil { + args = append(args, *req.From) + } return subquery, args, nil } diff --git a/internal/common/service/fifo_stock_v2/types.go b/internal/common/service/fifo_stock_v2/types.go index 3879201e..701274c4 100644 --- a/internal/common/service/fifo_stock_v2/types.go +++ b/internal/common/service/fifo_stock_v2/types.go @@ -34,6 +34,7 @@ type GatherRequest struct { FlagGroupCode string Lane Lane ProductWarehouseID uint + From *time.Time AsOf *time.Time Limit int AfterSortAt *time.Time @@ -98,17 +99,15 @@ type RollbackResult struct { type ReflowRequest struct { FlagGroupCode string ProductWarehouseID uint - Usable Ref - DesiredQty float64 - AllowOverConsume *bool - IdempotencyKey string AsOf *time.Time + IdempotencyKey string Tx *gorm.DB } type ReflowResult struct { - Rollback RollbackResult - Allocate AllocateResult + ProcessedUsables int + Rollback RollbackResult + Allocate AllocateResult } type RecalculateRequest struct { diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 2020dbec..db36e730 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -21,7 +21,6 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -167,15 +166,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e transactionType := utils.ResolveAdjustmentTransactionType(routeMeta.FunctionCode) - allowPending := false - if routeMeta.Lane == adjustmentLaneUsable { - allowPending, err = s.resolveOverconsumePolicy(ctx, routeMeta) - if err != nil { - s.Log.Errorf("Failed to resolve overconsume rule: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO policy") - } - } - var createdAdjustmentStockId uint var projectFlockKandangID *uint @@ -228,6 +218,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e Price: req.Price, GrandTotal: grandTotal, } + switch routeMeta.Lane { + case adjustmentLaneStockable: + adjustmentStock.TotalQty = qty + case adjustmentLaneUsable: + adjustmentStock.UsageQty = qty + } code, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) if err != nil { return err @@ -240,60 +236,32 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e var increaseQty float64 var decreaseQty float64 + if routeMeta.Lane != adjustmentLaneStockable && routeMeta.Lane != adjustmentLaneUsable { + return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane") + } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } + + asOf := adjustmentStock.CreatedAt + if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ + FlagGroupCode: routeMeta.FlagGroupCode, + ProductWarehouseID: productWarehouse.Id, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) + } + + refreshedAdjustment, err := adjustmentStockRepoTX.GetByID(ctx, adjustmentStock.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh adjustment stock") + } switch routeMeta.Lane { case adjustmentLaneStockable: - fifoNote := fmt.Sprintf("Stock Adjustment %s #%s", routeMeta.FunctionCode, adjustmentStock.AdjNumber) - result, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ - StockableKey: fifo.StockableKeyAdjustmentIn, - StockableID: adjustmentStock.Id, - ProductWarehouseID: productWarehouse.Id, - Quantity: qty, - Note: &fifoNote, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) - } - increaseQty = result.AddedQuantity + increaseQty = refreshedAdjustment.TotalQty case adjustmentLaneUsable: - if s.FifoStockV2Svc != nil { - usableLegacyTypeKey := fifo.UsableKeyAdjustmentOut.String() - if routeMeta.SourceTable == "adjustment_stocks" && strings.TrimSpace(routeMeta.LegacyTypeKey) != "" { - usableLegacyTypeKey = strings.TrimSpace(routeMeta.LegacyTypeKey) - } - - reflowResult, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ - FlagGroupCode: routeMeta.FlagGroupCode, - ProductWarehouseID: productWarehouse.Id, - Usable: common.FifoStockV2Ref{ - ID: adjustmentStock.Id, - LegacyTypeKey: usableLegacyTypeKey, - FunctionCode: routeMeta.FunctionCode, - }, - DesiredQty: qty, - AllowOverConsume: &allowPending, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO v2: %v", err)) - } - decreaseQty = reflowResult.Allocate.AllocatedQty - } else { - result, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ - UsableKey: fifo.UsableKeyAdjustmentOut, - UsableID: adjustmentStock.Id, - ProductWarehouseID: productWarehouse.Id, - Quantity: qty, - AllowPending: allowPending, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) - } - decreaseQty = result.UsageQuantity - } - default: - return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane") + decreaseQty = refreshedAdjustment.UsageQty } stockLogs, err := stockLogRepoTX.GetByProductWarehouse(ctx, productWarehouse.Id, 1) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index b377958b..4f88e0ec 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -21,7 +21,6 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -444,83 +443,79 @@ 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 - } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } + flagGroupByProduct := make(map[uint]string, len(req.Products)) for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] + if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid") + } - 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, - }) + flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)] + if !ok { + flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID)) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %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 + flagGroupByProduct[uint(product.ProductID)] = flagGroupCode } if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ - "usage_qty": outUsageQty, - "pending_qty": outPendingQty, + "usage_qty": product.ProductQty, + "pending_qty": 0, + "total_qty": product.ProductQty, }).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 transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } + asOf := transferDate + if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) + } + if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: uint(*detail.DestProductWarehouseID), + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err)) + } + + type usageSnapshot struct { + UsageQty float64 `gorm:"column:usage_qty"` + PendingQty float64 `gorm:"column:pending_qty"` + } + var usage usageSnapshot + if err := tx.WithContext(c.Context()). + Table("stock_transfer_details"). + Select("usage_qty, pending_qty"). + Where("id = ?", detail.Id). + Take(&usage).Error; err != nil { + s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking") + } + outUsageQty := usage.UsageQty + outPendingQty := usage.PendingQty + if outPendingQty > 1e-6 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) + } + stockLogDecrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.SourceProductWarehouseID), CreatedBy: uint(actorID), Increase: 0, - Decrease: product.ProductQty, + Decrease: outUsageQty, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), Notes: "", @@ -541,45 +536,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } - 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), - ProductWarehouseID: uint(*detail.DestProductWarehouseID), - Quantity: product.ProductQty, - Note: ¬e, - Tx: tx, - }) - if err != nil { - 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": 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") - } + inAddedQty := outUsageQty stockLogIncrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.DestProductWarehouseID), CreatedBy: uint(actorID), - Increase: product.ProductQty, + Increase: inAddedQty, Decrease: 0, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), @@ -657,51 +619,45 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return result, nil } -func (s *transferService) resolvePakanProducts( +func (s *transferService) resolveTransferFlagGroup( 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 + productID uint, +) (string, error) { + if productID == 0 { + return "", fmt.Errorf("product id is required") } type row struct { - ProductID uint `gorm:"column:product_id"` + FlagGroupCode string `gorm:"column:flag_group_code"` } - var rows []row + var selected 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 + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", "USABLE"). + Where("rr.function_code = ?", "STOCK_TRANSFER_OUT"). + Where("rr.source_table = ?", "stock_transfer_details"). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE f.flagable_type = ? + AND f.flagable_id = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, entity.FlagableTypeProduct, productID). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error if err != nil { - return nil, err + return "", err } - for _, row := range rows { - out[row.ProductID] = true - } - return out, nil + return strings.TrimSpace(selected.FlagGroupCode), nil } func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error { diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 2dde163f..649c0363 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -2,7 +2,6 @@ package marketing import ( "fmt" - "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -20,7 +19,6 @@ import ( rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type MarketingModule struct{} @@ -35,24 +33,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockLogRepo := rShared.NewStockLogRepository(db) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyMarketingDelivery, - Table: "marketing_delivery_products", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) - } - } + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) @@ -64,8 +45,8 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoService, warehouseRepo, projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoStockV2Service, validate) userService := sUser.NewUserService(userRepo, validate) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 677ef965..c90e2873 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -15,7 +15,6 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -36,7 +35,7 @@ type deliveryOrdersService struct { MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository StockLogRepo rShared.StockLogRepository ApprovalSvc commonSvc.ApprovalService - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service } func NewDeliveryOrdersService( @@ -45,7 +44,7 @@ func NewDeliveryOrdersService( marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, stockLogRepo rShared.StockLogRepository, approvalSvc commonSvc.ApprovalService, - fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ @@ -55,7 +54,7 @@ func NewDeliveryOrdersService( MarketingDeliveryProductRepo: marketingDeliveryProductRepo, StockLogRepo: stockLogRepo, ApprovalSvc: approvalSvc, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, } } @@ -549,33 +548,42 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor return fiber.NewError(fiber.StatusInternalServerError, "Delivery product not found") } - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - ProductWarehouseID: marketingProduct.ProductWarehouseId, - Quantity: requestedQty, - AllowPending: false, - Tx: tx, - }) - - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) - } - deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + previousUsage := deliveryProduct.UsageQty + deliveryProduct.UsageQty = requestedQty + deliveryProduct.PendingQty = 0 - if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, 0); err != nil { + if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") } - if actorID > 0 && result.UsageQuantity > 0 { + if err := reflowMarketingScope( + ctx, + s.FifoStockV2Svc, + tx, + marketingProduct.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) + } + + refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product") + } + deliveryProduct.UsageQty = refreshed.UsageQty + deliveryProduct.PendingQty = refreshed.PendingQty + deliveryProduct.CreatedAt = refreshed.CreatedAt + + allocatedDelta := deliveryProduct.UsageQty - previousUsage + if actorID > 0 && allocatedDelta > 0 { decreaseLog := &entity.StockLog{ - Decrease: result.UsageQuantity, + Decrease: allocatedDelta, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, ProductWarehouseId: marketingProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), + Notes: fmt.Sprintf("FIFO v2 reflow consume (%.2f)", allocatedDelta), } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) @@ -604,35 +612,45 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor } deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) - if err != nil { - currentUsage = 0 - } - - if currentUsage == 0 { + currentUsage := deliveryProduct.UsageQty + currentPending := deliveryProduct.PendingQty + if currentUsage <= 0 && currentPending <= 0 { return nil } - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - Tx: tx, - }); err != nil { - return err + deliveryProduct.UsageQty = 0 + deliveryProduct.PendingQty = 0 + if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset delivery product") } - if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { - return err + if err := reflowMarketingScope( + ctx, + s.FifoStockV2Svc, + tx, + marketingProduct.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) } - if actorID > 0 && currentUsage > 0 { + refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product") + } + deliveryProduct.UsageQty = refreshed.UsageQty + deliveryProduct.PendingQty = refreshed.PendingQty + deliveryProduct.CreatedAt = refreshed.CreatedAt + + releasedUsage := currentUsage - deliveryProduct.UsageQty + if actorID > 0 && releasedUsage > 0 { increaseLog := &entity.StockLog{ - Increase: currentUsage, + Increase: releasedUsage, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, ProductWarehouseId: marketingProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: fmt.Sprintf("Release delivery stock (%.2f)", currentUsage), + Notes: fmt.Sprintf("FIFO v2 reflow release (%.2f)", releasedUsage), } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) if err != nil { diff --git a/internal/modules/marketing/services/fifo_stock_v2_helper.go b/internal/modules/marketing/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..6cdced5e --- /dev/null +++ b/internal/modules/marketing/services/fifo_stock_v2_helper.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + marketingOutFunctionCode = "MARKETING_OUT" + marketingUsableLane = "USABLE" + marketingSourceTable = "marketing_delivery_products" +) + +func reflowMarketingScope( + ctx context.Context, + fifoStockV2Svc commonSvc.FifoStockV2Service, + tx *gorm.DB, + productWarehouseID uint, + asOf *time.Time, +) error { + if fifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolveMarketingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + _, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolveMarketingFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", marketingUsableLane). + Where("rr.function_code = ?", marketingOutFunctionCode). + Where("rr.source_table = ?", marketingSourceTable). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + +func resolveMarketingAsOf(deliveryDate, createdAt *time.Time) *time.Time { + if deliveryDate != nil { + asOf := *deliveryDate + return &asOf + } + if createdAt != nil { + asOf := *createdAt + return &asOf + } + asOf := time.Now() + return &asOf +} diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index eb2e4f5b..6eba8ada 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -20,7 +20,6 @@ import ( userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -43,12 +42,12 @@ type salesOrdersService struct { ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository UserRepo userRepo.UserRepository ApprovalSvc commonSvc.ApprovalService - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, warehouseRepo warehouseRepo.WarehouseRepository, +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ Log: utils.Log, @@ -58,7 +57,7 @@ func NewSalesOrdersService(marketingRepo repository.MarketingRepository, custome ProductWarehouseRepo: productWarehouseRepo, UserRepo: userRepo, ApprovalSvc: approvalSvc, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, } @@ -376,15 +375,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if qtyDiff < 0 { return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.") } else if qtyDiff > 0 { - _, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - ProductWarehouseID: rp.ProductWarehouseId, - Quantity: qtyDiff, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Insufficient stock for additional quantity: %v", err)) + nextRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + qtyDiff + if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, nextRequestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing delivery fifo fields") + } + if err := reflowMarketingScope( + c.Context(), + s.FifoStockV2Svc, + dbTransaction, + rp.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) } } } @@ -439,12 +441,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id)) } - if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock: %v", err)) + if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, 0, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset marketing delivery fifo fields") + } + if err := reflowMarketingScope( + c.Context(), + s.FifoStockV2Svc, + dbTransaction, + deliveryProduct.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) } if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil { @@ -523,12 +530,17 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id) if err == nil && len(deliveryProducts) > 0 { for _, dp := range deliveryProducts { - if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: dp.Id, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for delivery product %d: %v", dp.Id, err)) + if err := marketingDeliveryProductRepoTx.UpdateFifoFields(c.Context(), dp.Id, 0, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset fifo fields for delivery product %d", dp.Id)) + } + if err := reflowMarketingScope( + c.Context(), + s.FifoStockV2Svc, + dbTransaction, + dp.ProductWarehouseId, + resolveMarketingAsOf(dp.DeliveryDate, dp.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2 for delivery product %d: %v", dp.Id, err)) } } } diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 09514f0d..5eb8f36b 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,7 +2,6 @@ package chickins import ( "fmt" - "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -10,7 +9,6 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -40,45 +38,9 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productRepo := rProduct.NewProductRepository(db) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) userRepo := rUser.NewUserRepository(db) - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyProjectChickin, - Table: "project_chickins", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_usage_qty", - CreatedAt: "created_at", - }, - - ExcludedStockables: []fifo.StockableKey{fifo.StockableKeyProjectFlockPopulation}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) - } - } - - if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyProjectFlockPopulation, - Table: "project_flock_populations", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used_qty", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register project flock population stockable workflow: %v", err)) - } - } - approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { @@ -96,7 +58,7 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockpopulationrepo, chickinDetailRepo, validate, - fifoService) + fifoStockV2Service) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 7d2e7a7f..7c0be659 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -19,7 +19,6 @@ import ( rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -27,8 +26,6 @@ import ( "gorm.io/gorm" ) -var chickinUsableKey = fifo.UsableKeyProjectChickin - type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -51,11 +48,11 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoStockV2Svc commonSvc.FifoStockV2Service) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -68,7 +65,7 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -372,18 +369,9 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { } if chickin.UsageQty > 0 { - - currentUsageQty := chickin.UsageQty - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } - - warehouseDeltas := make(map[uint]float64) - warehouseDeltas[chickin.ProductWarehouseId] += currentUsageQty - if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { - return err - } } if err := s.Repository.DeleteOne(c.Context(), id); err != nil { @@ -549,12 +537,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } - warehouseDeltas := make(map[uint]float64) - warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty - if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - return err - } - if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to delete rejected chickin %d", chickin.Id)) @@ -617,36 +599,48 @@ func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, } func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { - if chickin == nil || s.FifoSvc == nil { + if chickin == nil { return nil } + if tx == nil { + return errors.New("transaction is required") + } + if s.FifoStockV2Svc == nil { + return errors.New("fifo v2 service is not available") + } + if desiredQty < 0 { + return errors.New("desired quantity must be zero or greater") + } - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: chickinUsableKey, - UsableID: chickin.Id, - ProductWarehouseID: chickin.ProductWarehouseId, - Quantity: desiredQty, - AllowPending: true, - Tx: tx, - }) - if err != nil { + if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, desiredQty, 0); err != nil { return err } - if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + asOf := chickin.ChickInDate + if asOf.IsZero() { + asOf = chickin.CreatedAt + } + if err := reflowChickinScope(ctx, s.FifoStockV2Svc, tx, chickin.ProductWarehouseId, &asOf); err != nil { return err } - if result.UsageQuantity > 0 { + var refreshed entity.ProjectChickin + if err := tx.WithContext(ctx). + Where("id = ?", chickin.Id). + Take(&refreshed).Error; err != nil { + return err + } + + if refreshed.UsageQty > 0 { decreaseLog := &entity.StockLog{ - Decrease: result.UsageQuantity, + Decrease: refreshed.UsageQty, LoggableType: string(utils.StockLogTypeChikin), - LoggableId: chickin.Id, - ProductWarehouseId: chickin.ProductWarehouseId, + LoggableId: refreshed.Id, + ProductWarehouseId: refreshed.ProductWarehouseId, CreatedBy: actorID, - Notes: fmt.Sprintf("Chickin #%d", chickin.Id), + Notes: fmt.Sprintf("Chickin #%d", refreshed.Id), } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1) + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, refreshed.ProductWarehouseId, 1) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } @@ -658,46 +652,52 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, decreaseLog.Stock -= decreaseLog.Decrease } - s.StockLogRepo.CreateOne(ctx, decreaseLog, nil) + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil); err != nil { + return err + } } return nil } func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, targetPW *entity.ProductWarehouse, population *entity.ProjectFlockPopulation, actorID uint) error { - if chickin == nil || targetPW == nil || population == nil || s.FifoSvc == nil { + if chickin == nil || targetPW == nil || population == nil { return nil } + if tx == nil { + return errors.New("transaction is required") + } + if s.FifoStockV2Svc == nil { + return errors.New("fifo v2 service is not available") + } - _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: targetPW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", population.Id). + Update("total_qty", chickin.UsageQty).Error; err != nil { return err } - return nil + asOf := chickin.ChickInDate + if asOf.IsZero() { + asOf = chickin.CreatedAt + } + return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf) } func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { - if chickin == nil || s.FifoSvc == nil { + if chickin == nil { return nil } + if tx == nil { + return errors.New("transaction is required") + } + if s.FifoStockV2Svc == nil { + return errors.New("fifo v2 service is not available") + } var currentUsage float64 if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { - - } - - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: chickinUsableKey, - UsableID: chickin.Id, - Tx: tx, - }); err != nil { return err } @@ -705,6 +705,14 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return err } + asOf := chickin.ChickInDate + if asOf.IsZero() { + asOf = chickin.CreatedAt + } + if err := reflowChickinScope(ctx, s.FifoStockV2Svc, tx, chickin.ProductWarehouseId, &asOf); err != nil { + return err + } + if currentUsage > 0 { increaseLog := &entity.StockLog{ Increase: currentUsage, @@ -726,7 +734,9 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, increaseLog.Stock += increaseLog.Increase } - s.StockLogRepo.CreateOne(ctx, increaseLog, nil) + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil); err != nil { + return err + } } return nil @@ -755,10 +765,3 @@ func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKan return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } - -func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { - if len(deltas) == 0 { - return nil - } - return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) -} diff --git a/internal/modules/production/chickins/services/fifo_stock_v2_helper.go b/internal/modules/production/chickins/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..a931e43e --- /dev/null +++ b/internal/modules/production/chickins/services/fifo_stock_v2_helper.go @@ -0,0 +1,87 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + chickinOutFunctionCode = "CHICKIN_OUT" + chickinUsableLane = "USABLE" + chickinSourceTable = "project_chickins" +) + +func reflowChickinScope( + ctx context.Context, + fifoStockV2Svc commonSvc.FifoStockV2Service, + tx *gorm.DB, + productWarehouseID uint, + asOf *time.Time, +) error { + if fifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if tx == nil { + return fmt.Errorf("transaction is required") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + _, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", chickinUsableLane). + Where("rr.function_code = ?", chickinOutFunctionCode). + Where("rr.source_table = ?", chickinSourceTable). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 6dd74a1b..0c130369 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -2,7 +2,6 @@ package recordings import ( "fmt" - "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -26,7 +25,6 @@ import ( sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -48,7 +46,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate productRepo := rProduct.NewProductRepository(db) chickinRepo := rChickin.NewChickinRepository(db) chickinDetailRepo := rChickin.NewChickinDetailRepository(db) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) @@ -61,76 +58,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate validate, ) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyRecordingEgg, - Table: "recording_eggs", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)", - }, - OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) - } - } - if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyRecordingDepletion, - Table: "recording_depletions", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "qty", - TotalUsedQuantity: "total_used_qty", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", - }, - OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err)) - } - } - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyRecordingStock, - Table: "recording_stocks", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)", - }, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) - } - } - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyRecordingDepletion, - Table: "recording_depletions", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "source_product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", - }, - ExcludedStockables: []fifo.StockableKey{ - fifo.StockableKeyTransferToLayingIn, - fifo.StockableKeyStockTransferIn, - fifo.StockableKeyAdjustmentIn, - fifo.StockableKeyPurchaseItems, - fifo.StockableKeyRecordingEgg, - }, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err)) - } - } + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) @@ -169,7 +97,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo, chickinDetailRepo, validate, - fifoService, + fifoStockV2Service, ) recordingService := sRecording.NewRecordingService( @@ -179,7 +107,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo, approvalRepo, approvalService, - fifoService, + fifoStockV2Service, stockLogRepo, productionStandardService, projectFlockService, diff --git a/internal/modules/production/recordings/services/fifo_stock_v2_helper.go b/internal/modules/production/recordings/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..68aea209 --- /dev/null +++ b/internal/modules/production/recordings/services/fifo_stock_v2_helper.go @@ -0,0 +1,137 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + recordingLaneUsable = "USABLE" + recordingLaneStockable = "STOCKABLE" + + recordingFunctionStockOut = "RECORDING_STOCK_OUT" + recordingFunctionDepletionOut = "RECORDING_DEPLETION_OUT" + recordingFunctionDepletionIn = "RECORDING_DEPLETION_IN" + recordingFunctionEggIn = "RECORDING_EGG_IN" + + recordingSourceStocks = "recording_stocks" + recordingSourceDepletions = "recording_depletions" + recordingSourceEggs = "recording_eggs" +) + +func (s *recordingService) reflowRecordingScope( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + recordingID uint, + lane string, + functionCode string, + sourceTable string, +) error { + if s == nil || s.FifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if tx == nil { + return fmt.Errorf("transaction is required") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolveRecordingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID, lane, functionCode, sourceTable) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + asOf, err := resolveRecordingAsOf(ctx, tx, recordingID) + if err != nil { + return err + } + + _, err = s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolveRecordingFlagGroupByProductWarehouse( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + lane string, + functionCode string, + sourceTable string, +) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + q := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", lane). + Where("rr.source_table = ?", sourceTable) + + if strings.TrimSpace(functionCode) != "" { + q = q.Where("rr.function_code = ?", functionCode) + } + + err := q. + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + +func resolveRecordingAsOf(ctx context.Context, tx *gorm.DB, recordingID uint) (*time.Time, error) { + if recordingID == 0 { + asOf := time.Now().UTC() + return &asOf, nil + } + + type row struct { + RecordDatetime time.Time `gorm:"column:record_datetime"` + } + var selected row + if err := tx.WithContext(ctx). + Table("recordings"). + Select("record_datetime"). + Where("id = ?", recordingID). + Limit(1). + Take(&selected).Error; err != nil { + return nil, err + } + + asOf := selected.RecordDatetime.UTC() + return &asOf, nil +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5fd387bf..c477fd64 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -52,7 +52,7 @@ type recordingService struct { ProductionStandardSvc sProductionStandard.ProductionStandardService ProjectFlockSvc sProjectFlock.ProjectflockService ChickinSvc sChickin.ChickinService - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository } @@ -63,7 +63,7 @@ func NewRecordingService( projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, - fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, stockLogRepo rStockLogs.StockLogRepository, productionStandardSvc sProductionStandard.ProductionStandardService, projectFlockSvc sProjectFlock.ProjectflockService, @@ -82,7 +82,7 @@ func NewRecordingService( ProductionStandardSvc: productionStandardSvc, ProjectFlockSvc: projectFlockSvc, ChickinSvc: chickinSvc, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: stockLogRepo, } } diff --git a/internal/modules/production/recordings/services/recording_fifo.service.go b/internal/modules/production/recordings/services/recording_fifo.service.go index eb9e5094..0405036d 100644 --- a/internal/modules/production/recordings/services/recording_fifo.service.go +++ b/internal/modules/production/recordings/services/recording_fifo.service.go @@ -8,7 +8,6 @@ import ( "strings" "time" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -18,9 +17,6 @@ import ( "gorm.io/gorm" ) -var recordingStockUsableKey = fifo.UsableKeyRecordingStock -var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion - const depletionUsageTolerance = 0.000001 func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) { @@ -101,9 +97,9 @@ func (s *recordingService) consumeRecordingStocks( if len(stocks) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for consuming recording stocks") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for consuming recording stocks") + return errors.New("fifo v2 service is not available") } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") @@ -125,38 +121,52 @@ func (s *recordingService) consumeRecordingStocks( } desiredTotal := desired + pending - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - ProductWarehouseID: stock.ProductWarehouseId, - Quantity: desiredTotal, - AllowPending: true, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) + if err := s.Repository.UpdateStockUsage(tx, stock.Id, desiredTotal, 0); err != nil { + return err + } + if err := s.reflowRecordingScope( + ctx, + tx, + stock.ProductWarehouseId, + stock.RecordingId, + recordingLaneUsable, + recordingFunctionStockOut, + recordingSourceStocks, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 stock for recording stock %d: %+v", stock.Id, err) return err } - if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { + var refreshed entity.RecordingStock + if err := tx.WithContext(ctx). + Where("id = ?", stock.Id). + Take(&refreshed).Error; err != nil { return err } - s.logStockTrace("consume:done", stock, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, result.UsageQuantity, result.PendingQuantity)) + actualUsage := 0.0 + actualPending := 0.0 + if refreshed.UsageQty != nil { + actualUsage = *refreshed.UsageQty + } + if refreshed.PendingQty != nil { + actualPending = *refreshed.PendingQty + } + s.logStockTrace("consume:done", refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, actualUsage, actualPending)) - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity + logDecrease := actualUsage + if actualPending > 0 { + logDecrease += actualPending } if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, + ProductWarehouseId: refreshed.ProductWarehouseId, CreatedBy: actorID, Decrease: logDecrease, LoggableType: string(utils.StockLogTypeRecording), - LoggableId: stock.RecordingId, + LoggableId: refreshed.RecordingId, Notes: note, } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, refreshed.ProductWarehouseId, 1) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } @@ -187,9 +197,9 @@ func (s *recordingService) consumeRecordingDepletions( if len(depletions) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for consuming recording depletions") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for consuming recording depletions") + return errors.New("fifo v2 service is not available") } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") @@ -210,27 +220,40 @@ func (s *recordingService) consumeRecordingDepletions( } desired := depletion.Qty + depletion.PendingQty - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - ProductWarehouseID: sourceWarehouseID, - Quantity: desired, - AllowPending: false, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) + if err := tx.WithContext(ctx). + Model(&entity.RecordingDepletion{}). + Where("id = ?", depletion.Id). + Updates(map[string]any{ + "qty": desired, + "usage_qty": desired, + "pending_qty": 0, + }).Error; err != nil { + return err + } + if err := s.reflowRecordingScope( + ctx, + tx, + sourceWarehouseID, + depletion.RecordingId, + recordingLaneUsable, + recordingFunctionDepletionOut, + recordingSourceDepletions, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 stock for recording depletion %d: %+v", depletion.Id, err) return err } - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { + var refreshed entity.RecordingDepletion + if err := tx.WithContext(ctx). + Where("id = ?", depletion.Id). + Take(&refreshed).Error; err != nil { return err } - s.logDepletionTrace("consume:done", depletion, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, result.UsageQuantity, result.PendingQuantity)) + s.logDepletionTrace("consume:done", refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, refreshed.UsageQty, refreshed.PendingQty)) - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity + logDecrease := refreshed.UsageQty + if refreshed.PendingQty > 0 { + logDecrease += refreshed.PendingQty } if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ @@ -238,7 +261,7 @@ func (s *recordingService) consumeRecordingDepletions( CreatedBy: actorID, Decrease: logDecrease, LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, + LoggableId: refreshed.RecordingId, Notes: note, } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) @@ -258,20 +281,20 @@ func (s *recordingService) consumeRecordingDepletions( } } - destDelta := depletion.Qty + depletion.PendingQty - if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - if depletion.ProductWarehouseId == sourceWarehouseID { + destDelta := refreshed.Qty + refreshed.PendingQty + if refreshed.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if refreshed.ProductWarehouseId == sourceWarehouseID { continue } log := &entity.StockLog{ - ProductWarehouseId: depletion.ProductWarehouseId, + ProductWarehouseId: refreshed.ProductWarehouseId, CreatedBy: actorID, Increase: destDelta, LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, + LoggableId: refreshed.RecordingId, Notes: note, } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) + stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, refreshed.ProductWarehouseId, 1) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") } @@ -302,9 +325,9 @@ func (s *recordingService) releaseRecordingStocks( if len(stocks) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for releasing recording stocks") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for releasing recording stocks") + return errors.New("fifo v2 service is not available") } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") @@ -314,45 +337,35 @@ func (s *recordingService) releaseRecordingStocks( if stock.Id == 0 { continue } - if stock.UsageQty != nil && *stock.UsageQty > 0 { - activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id) - if err != nil { - return err - } - if activeCount == 0 { - s.Log.Warnf("recording-stock release: no active allocations, forcing usage/pending to 0 (stock_id=%d)", stock.Id) - if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { - return err - } - continue - } - if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { - return err - } - if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { - return err - } + + currentUsage := 0.0 + if stock.UsageQty != nil { + currentUsage = *stock.UsageQty } s.logStockTrace("release:start", stock, "") - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) - return err - } if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { return err } + if err := s.reflowRecordingScope( + ctx, + tx, + stock.ProductWarehouseId, + stock.RecordingId, + recordingLaneUsable, + recordingFunctionStockOut, + recordingSourceStocks, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 release for recording stock %d: %+v", stock.Id, err) + return err + } s.logStockTrace("release:done", stock, "") - if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { + if currentUsage > 0 && strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ ProductWarehouseId: stock.ProductWarehouseId, CreatedBy: actorID, - Increase: *stock.UsageQty, + Increase: currentUsage, LoggableType: string(utils.StockLogTypeRecording), LoggableId: stock.RecordingId, Notes: note, @@ -388,9 +401,9 @@ func (s *recordingService) releaseRecordingDepletions( if len(depletions) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for releasing recording depletions") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for releasing recording depletions") + return errors.New("fifo v2 service is not available") } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") @@ -400,36 +413,7 @@ func (s *recordingService) releaseRecordingDepletions( if depletion.Id == 0 { continue } - if depletion.UsageQty > 0 { - activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id) - if err != nil { - return err - } - if activeCount == 0 { - s.Log.Warnf("recording-depletion release: no active allocations, forcing usage/pending to 0 (depletion_id=%d)", depletion.Id) - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { - return err - } - if err := tx.WithContext(ctx). - Table("recording_depletions"). - Where("id = ?", depletion.Id). - Update("usage_qty", 0).Error; err != nil { - return err - } - continue - } - if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { - return err - } - if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { - return err - } - } s.logDepletionTrace("release:start", depletion, "") - if err := validateDepletionUsage(depletion); err != nil { - s.Log.Errorf("FIFO depletion mismatch for recording %d (depletion %d): qty=%.3f usage=%.3f pending=%.3f", depletion.RecordingId, depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty) - return err - } sourceWarehouseID := uint(0) if depletion.SourceProductWarehouseId != nil { @@ -438,24 +422,49 @@ func (s *recordingService) releaseRecordingDepletions( if sourceWarehouseID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") } - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) + + logIncrease := depletion.Qty + depletion.PendingQty + destDelta := depletion.Qty + depletion.PendingQty + + if err := tx.WithContext(ctx). + Model(&entity.RecordingDepletion{}). + Where("id = ?", depletion.Id). + Updates(map[string]any{ + "qty": 0, + "usage_qty": 0, + "pending_qty": 0, + }).Error; err != nil { return err } - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { + if err := s.reflowRecordingScope( + ctx, + tx, + sourceWarehouseID, + depletion.RecordingId, + recordingLaneUsable, + recordingFunctionDepletionOut, + recordingSourceDepletions, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 source release for recording depletion %d: %+v", depletion.Id, err) return err } + if depletion.ProductWarehouseId != 0 { + if err := s.reflowRecordingScope( + ctx, + tx, + depletion.ProductWarehouseId, + depletion.RecordingId, + recordingLaneStockable, + recordingFunctionDepletionIn, + recordingSourceDepletions, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 destination release for recording depletion %d: %+v", depletion.Id, err) + return err + } + } s.logDepletionTrace("release:done", depletion, "") - logIncrease := depletion.Qty - if depletion.PendingQty > 0 { - logIncrease += depletion.PendingQty - } if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { log := &entity.StockLog{ ProductWarehouseId: sourceWarehouseID, @@ -482,7 +491,6 @@ func (s *recordingService) releaseRecordingDepletions( } } - destDelta := depletion.Qty + depletion.PendingQty if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { if depletion.ProductWarehouseId == sourceWarehouseID { continue @@ -618,9 +626,9 @@ func (s *recordingService) replenishRecordingEggs( if len(eggs) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for replenishing recording eggs") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for replenishing recording eggs") + return errors.New("fifo v2 service is not available") } if strings.TrimSpace(note) != "" && s.StockLogRepo == nil { return errors.New("stock log repository is not available") @@ -631,14 +639,23 @@ func (s *recordingService) replenishRecordingEggs( continue } s.logEggTrace("replenish:start", egg, "") - if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, - StockableID: egg.Id, - ProductWarehouseID: egg.ProductWarehouseId, - Quantity: float64(egg.Qty), - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) + + if err := tx.WithContext(ctx). + Model(&entity.RecordingEgg{}). + Where("id = ?", egg.Id). + Update("total_qty", float64(egg.Qty)).Error; err != nil { + return err + } + if err := s.reflowRecordingScope( + ctx, + tx, + egg.ProductWarehouseId, + egg.RecordingId, + recordingLaneStockable, + recordingFunctionEggIn, + recordingSourceEggs, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 stock for recording egg %d: %+v", egg.Id, err) return err } s.logEggTrace("replenish:done", egg, "") @@ -681,9 +698,9 @@ func (s *recordingService) replenishRecordingDepletions( if len(depletions) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for replenishing recording depletions") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for replenishing recording depletions") + return errors.New("fifo v2 service is not available") } for _, depletion := range depletions { @@ -691,14 +708,16 @@ func (s *recordingService) replenishRecordingDepletions( continue } s.logDepletionTrace("replenish:start", depletion, "") - if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingDepletion, - StockableID: depletion.Id, - ProductWarehouseID: depletion.ProductWarehouseId, - Quantity: depletion.Qty, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to replenish FIFO stock for recording depletion %d: %+v", depletion.Id, err) + if err := s.reflowRecordingScope( + ctx, + tx, + depletion.ProductWarehouseId, + depletion.RecordingId, + recordingLaneStockable, + recordingFunctionDepletionIn, + recordingSourceDepletions, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 stock for recording depletion %d: %+v", depletion.Id, err) return err } s.logDepletionTrace("replenish:done", depletion, "") @@ -715,9 +734,9 @@ func (s *recordingService) reduceRecordingDepletions( if len(depletions) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for reducing recording depletions") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for reducing recording depletions") + return errors.New("fifo v2 service is not available") } for _, depletion := range depletions { @@ -725,16 +744,44 @@ func (s *recordingService) reduceRecordingDepletions( continue } s.logDepletionTrace("reduce:start", depletion, "") - if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyRecordingDepletion, - StockableID: depletion.Id, - ProductWarehouseID: depletion.ProductWarehouseId, - Quantity: -depletion.Qty, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to reduce FIFO stock for recording depletion %d: %+v", depletion.Id, err) + + if err := tx.WithContext(ctx). + Model(&entity.RecordingDepletion{}). + Where("id = ?", depletion.Id). + Updates(map[string]any{ + "qty": 0, + "usage_qty": 0, + "pending_qty": 0, + }).Error; err != nil { return err } + if depletion.SourceProductWarehouseId != nil && *depletion.SourceProductWarehouseId != 0 { + if err := s.reflowRecordingScope( + ctx, + tx, + *depletion.SourceProductWarehouseId, + depletion.RecordingId, + recordingLaneUsable, + recordingFunctionDepletionOut, + recordingSourceDepletions, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 source stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + } + if err := s.reflowRecordingScope( + ctx, + tx, + depletion.ProductWarehouseId, + depletion.RecordingId, + recordingLaneStockable, + recordingFunctionDepletionIn, + recordingSourceDepletions, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 destination stock for recording depletion %d: %+v", depletion.Id, err) + return err + } + s.logDepletionTrace("reduce:done", depletion, "") } @@ -749,9 +796,9 @@ func (s *recordingService) reduceRecordingEggs( if len(eggs) == 0 { return nil } - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for reducing recording eggs") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for reducing recording eggs") + return errors.New("fifo v2 service is not available") } for _, egg := range eggs { @@ -759,14 +806,22 @@ func (s *recordingService) reduceRecordingEggs( continue } s.logEggTrace("reduce:start", egg, "") - if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, - StockableID: egg.Id, - ProductWarehouseID: egg.ProductWarehouseId, - Quantity: -float64(egg.Qty), - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to reduce FIFO stock for recording egg %d: %+v", egg.Id, err) + if err := tx.WithContext(ctx). + Model(&entity.RecordingEgg{}). + Where("id = ?", egg.Id). + Update("total_qty", 0).Error; err != nil { + return err + } + if err := s.reflowRecordingScope( + ctx, + tx, + egg.ProductWarehouseId, + egg.RecordingId, + recordingLaneStockable, + recordingFunctionEggIn, + recordingSourceEggs, + ); err != nil { + s.Log.Errorf("Failed to reflow FIFO v2 stock for recording egg %d: %+v", egg.Id, err) return err } s.logEggTrace("reduce:done", egg, "") @@ -934,9 +989,9 @@ func (s *recordingService) syncRecordingStocks( note string, actorID uint, ) error { - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for syncing recording stocks") - return errors.New("fifo service is not available") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for syncing recording stocks") + return errors.New("fifo v2 service is not available") } existingByWarehouse := make(map[uint][]entity.RecordingStock) @@ -1125,9 +1180,9 @@ func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *g } func (s *recordingService) requireFIFO() error { - if s.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for recording operations") - return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is required for recording operations") + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for recording operations") + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is required for recording operations") } return nil } diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index fae714fb..dbd7f772 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -23,7 +23,6 @@ import ( rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -40,7 +39,6 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) @@ -73,19 +71,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseServiceInstance, ) - fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - _ = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyPurchaseItems, - Table: "purchase_items", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "id", - }, - OrderBy: []string{"id ASC"}, - }) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) purchaseService := service.NewPurchaseService( validate, @@ -97,7 +83,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepository, approvalService, expenseBridge, - fifoService, + fifoStockV2Service, documentSvc, ) diff --git a/internal/modules/purchases/services/fifo_stock_v2_helper.go b/internal/modules/purchases/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..e0b619a9 --- /dev/null +++ b/internal/modules/purchases/services/fifo_stock_v2_helper.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + purchaseInFunctionCode = "PURCHASE_IN" + purchaseStockableLane = "STOCKABLE" + purchaseSourceTable = "purchase_items" +) + +func reflowPurchaseScope( + ctx context.Context, + fifoStockV2Svc commonSvc.FifoStockV2Service, + tx *gorm.DB, + productWarehouseID uint, + asOf *time.Time, +) error { + if fifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolvePurchaseFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + _, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolvePurchaseFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", purchaseStockableLane). + Where("rr.function_code = ?", purchaseInFunctionCode). + Where("rr.source_table = ?", purchaseSourceTable). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + +func assignEarliestAsOf(m map[uint]time.Time, productWarehouseID uint, asOf time.Time) { + if productWarehouseID == 0 { + return + } + if current, ok := m[productWarehouseID]; !ok || asOf.Before(current) { + m[productWarehouseID] = asOf + } +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 50e891f5..ba5f7384 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -57,7 +57,7 @@ type purchaseService struct { ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service DocumentSvc commonSvc.DocumentService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -77,7 +77,7 @@ func NewPurchaseService( projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, - fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, documentSvc commonSvc.DocumentService, ) PurchaseService { return &purchaseService{ @@ -91,7 +91,7 @@ func NewPurchaseService( ProjectFlockKandangRepo: projectFlockKandangRepo, ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, DocumentSvc: documentSvc, approvalWorkflow: utils.ApprovalWorkflowPurchase, } @@ -1026,22 +1026,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) - deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) totalQtyDeltas := make(map[uint]float64) - fifoAdds := make([]struct { - itemID uint - pwID uint - qty float64 - }, 0, len(prepared)) - fifoSubs := make([]struct { - itemID uint - pwID uint - qty float64 - }, 0, len(prepared)) - resolvePendingIDs := make(map[uint]struct{}) + reflowAsOfByPW := make(map[uint]time.Time) logEntries := make([]struct { itemID uint pwID uint @@ -1083,35 +1072,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation delta float64 }{itemID: item.Id, pwID: *newPWID, delta: deltaQty}) } - switch { - case deltaQty > 0 && newPWID != nil: - if s.FifoSvc != nil { - fifoAdds = append(fifoAdds, struct { - itemID uint - pwID uint - qty float64 - }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) - resolvePendingIDs[*newPWID] = struct{}{} - } else { - deltas[*newPWID] += deltaQty - totalQtyDeltas[item.Id] += deltaQty - } - case deltaQty < 0 && newPWID != nil: - if s.FifoSvc != nil { - fifoSubs = append(fifoSubs, struct { - itemID uint - pwID uint - qty float64 - }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) - affected[*newPWID] = struct{}{} - resolvePendingIDs[*newPWID] = struct{}{} - } else { - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} - totalQtyDeltas[item.Id] += deltaQty - } - case newPWID != nil: - resolvePendingIDs[*newPWID] = struct{}{} + if newPWID != nil { + assignEarliestAsOf(reflowAsOfByPW, *newPWID, prep.receivedDate.UTC()) + } + if deltaQty != 0 { + totalQtyDeltas[item.Id] += deltaQty + } + if deltaQty < 0 && newPWID != nil { + affected[*newPWID] = struct{}{} } dateCopy := prep.receivedDate @@ -1147,10 +1115,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := pwRepoTx.AdjustQuantities(c.Context(), deltas, nil); err != nil { - return err - } - if len(priceUpdates) > 0 { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { return err @@ -1180,48 +1144,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } } - if s.FifoSvc != nil { - for _, adj := range fifoAdds { - if adj.pwID == 0 || adj.qty <= 0 { - continue - } - if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyPurchaseItems, - StockableID: adj.itemID, - ProductWarehouseID: adj.pwID, - Quantity: adj.qty, - Tx: tx, - }); err != nil { - return err - } + if len(reflowAsOfByPW) > 0 { + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - for _, adj := range fifoSubs { - if adj.pwID == 0 || adj.qty >= 0 { - continue - } - if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyPurchaseItems, - StockableID: adj.itemID, - ProductWarehouseID: adj.pwID, - Quantity: adj.qty, - Tx: tx, - }); err != nil { + for pwID, asOf := range reflowAsOfByPW { + asOfCopy := asOf + if err := reflowPurchaseScope(c.Context(), s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil { return err } } - for pwID := range resolvePendingIDs { - if pwID == 0 { - continue - } - resolved, err := s.FifoSvc.ResolvePending(c.Context(), commonSvc.PendingResolveRequest{ - ProductWarehouseID: pwID, - Tx: tx, - }) - if err != nil { - return err - } - s.Log.Infof("ResolvePending purchase=%d pw=%d resolved=%d", purchase.Id, pwID, len(resolved)) - } } if len(logEntries) > 0 { @@ -1577,10 +1509,9 @@ func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB return nil } - pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) - deltas := make(map[uint]float64) affected := make(map[uint]struct{}) + reflowAsOfByPW := make(map[uint]time.Time) logEntries := make([]struct { pwID uint qty float64 @@ -1596,42 +1527,43 @@ func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB pwID := *item.ProductWarehouseId qty := item.TotalQty - if s.FifoSvc != nil { - if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyPurchaseItems, - StockableID: item.Id, - ProductWarehouseID: pwID, - Quantity: -qty, - Tx: tx, - }); err != nil { - return err - } - logEntries = append(logEntries, struct { - pwID uint - qty float64 - }{pwID: pwID, qty: qty}) - continue + if err := tx.WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("id = ?", item.Id). + Update("total_qty", 0).Error; err != nil { + return err } - deltas[pwID] -= qty affected[pwID] = struct{}{} + if item.ReceivedDate != nil { + assignEarliestAsOf(reflowAsOfByPW, pwID, item.ReceivedDate.UTC()) + } else { + assignEarliestAsOf(reflowAsOfByPW, pwID, time.Now().UTC()) + } logEntries = append(logEntries, struct { pwID uint qty float64 }{pwID: pwID, qty: qty}) } - if s.FifoSvc == nil && len(deltas) > 0 { - if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { - return err + if len(reflowAsOfByPW) > 0 { + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - if len(affected) > 0 { - if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { + for pwID, asOf := range reflowAsOfByPW { + asOfCopy := asOf + if err := reflowPurchaseScope(ctx, s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil { return err } } } + if len(affected) > 0 { + if err := rProductWarehouse.NewProductWarehouseRepository(tx).CleanupEmpty(ctx, affected); err != nil { + return err + } + } + if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 { logs := make([]*entity.StockLog, 0, len(logEntries)) for _, entry := range logEntries {