From 944604adad829f4821fff0677437de2ca8a1142a Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Fri, 27 Feb 2026 19:09:01 +0700 Subject: [PATCH 1/4] fix: first push need support testing, and implemented fifo v2 to all modules --- cmd/delete-adjustments/main.go | 11 +- cmd/reflow-adjustments/main.go | 15 +- .../common/service/fifo_stock_v2/allocate.go | 141 ++++-- .../common/service/fifo_stock_v2/gather.go | 6 + .../common/service/fifo_stock_v2/types.go | 11 +- .../services/adjustment.service.go | 90 ++-- .../transfers/services/transfer.service.go | 212 ++++----- internal/modules/marketing/module.go | 25 +- .../services/deliveryorder.service.go | 94 ++-- .../services/fifo_stock_v2_helper.go | 97 +++++ .../marketing/services/salesorder.service.go | 62 +-- .../modules/production/chickins/module.go | 42 +- .../chickins/services/chickin.service.go | 135 +++--- .../chickins/services/fifo_stock_v2_helper.go | 87 ++++ .../modules/production/recordings/module.go | 78 +--- .../services/fifo_stock_v2_helper.go | 137 ++++++ .../recordings/services/recording.service.go | 6 +- .../services/recording_fifo.service.go | 405 ++++++++++-------- internal/modules/purchases/module.go | 18 +- .../services/fifo_stock_v2_helper.go | 93 ++++ .../purchases/services/purchase.service.go | 150 ++----- 21 files changed, 1105 insertions(+), 810 deletions(-) create mode 100644 internal/modules/marketing/services/fifo_stock_v2_helper.go create mode 100644 internal/modules/production/chickins/services/fifo_stock_v2_helper.go create mode 100644 internal/modules/production/recordings/services/fifo_stock_v2_helper.go create mode 100644 internal/modules/purchases/services/fifo_stock_v2_helper.go 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 { From dd61b66af06d1e612954652b9f66669860acbd41 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Sat, 28 Feb 2026 21:35:07 +0700 Subject: [PATCH 2/4] fix: adjusment module depletion, chickin, recording refactor --- cmd/reflow-project-flock-kandang/main.go | 381 ++++++ .../common/service/fifo_stock_v2/allocate.go | 6 +- .../common/service/fifo_stock_v2/gather.go | 27 +- ..._disable_chickin_fifo_consumption.down.sql | 13 + ...07_disable_chickin_fifo_consumption.up.sql | 151 +++ .../services/adjustment.service.go | 250 +++- .../chickins/services/chickin.service.go | 101 +- .../repositories/recording.repository.go | 73 + .../recordings/services/recording.service.go | 1057 ++++++++++++++- .../services/recording_fifo.service.go | 1188 ----------------- internal/utils/constant.go | 2 +- 11 files changed, 1906 insertions(+), 1343 deletions(-) create mode 100644 cmd/reflow-project-flock-kandang/main.go create mode 100644 internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.down.sql create mode 100644 internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.up.sql delete mode 100644 internal/modules/production/recordings/services/recording_fifo.service.go diff --git a/cmd/reflow-project-flock-kandang/main.go b/cmd/reflow-project-flock-kandang/main.go new file mode 100644 index 00000000..8e797bf7 --- /dev/null +++ b/cmd/reflow-project-flock-kandang/main.go @@ -0,0 +1,381 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type productWarehouseScopeRow struct { + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + ProductID uint `gorm:"column:product_id"` + WarehouseID uint `gorm:"column:warehouse_id"` + ProjectFlockKandangID *uint `gorm:"column:project_flock_kandang_id"` +} + +type reflowTarget struct { + ProductWarehouseID uint + ProductID uint + WarehouseID uint + ProjectFlockKandangID *uint + FlagGroupCode string +} + +func main() { + var ( + projectFlockKandangID uint + apply bool + asOfRaw string + includeShared bool + ) + + flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Project flock kandang ID (required)") + flag.BoolVar(&apply, "apply", false, "Apply reflow. If false, run as dry-run") + flag.StringVar(&asOfRaw, "as-of", "", "Optional AsOf boundary. Format: RFC3339 or YYYY-MM-DD") + flag.BoolVar(&includeShared, "include-shared", true, "Include product warehouses referenced by transactions in this PFK scope (including shared/non-bound product warehouses)") + flag.Parse() + + if projectFlockKandangID == 0 { + log.Fatal("--project-flock-kandang-id is required") + } + + asOf, err := parseAsOf(asOfRaw) + if err != nil { + log.Fatalf("invalid --as-of: %v", err) + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil) + + exists, err := projectFlockKandangExists(ctx, db, projectFlockKandangID) + if err != nil { + log.Fatalf("failed to check project flock kandang: %v", err) + } + if !exists { + log.Fatalf("project_flock_kandang_id %d not found", projectFlockKandangID) + } + + scopedPWs, err := loadScopedProductWarehouses(ctx, db, projectFlockKandangID, includeShared) + if err != nil { + log.Fatalf("failed to load scoped product warehouses: %v", err) + } + if len(scopedPWs) == 0 { + fmt.Printf("Mode: %s\n", modeLabel(apply)) + fmt.Printf("Scope: project_flock_kandang_id=%d\n", projectFlockKandangID) + fmt.Println("No product warehouse found in scope") + return + } + + targets := make([]reflowTarget, 0, len(scopedPWs)) + skippedPW := 0 + failedResolve := 0 + + for _, pw := range scopedPWs { + flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, db, pw.ProductWarehouseID) + if err != nil { + fmt.Printf("FAIL pw=%d error=resolve flag groups: %v\n", pw.ProductWarehouseID, err) + failedResolve++ + continue + } + if len(flagGroups) == 0 { + fmt.Printf("SKIP pw=%d reason=no active fifo v2 route by product flag\n", pw.ProductWarehouseID) + skippedPW++ + continue + } + for _, group := range flagGroups { + targets = append(targets, reflowTarget{ + ProductWarehouseID: pw.ProductWarehouseID, + ProductID: pw.ProductID, + WarehouseID: pw.WarehouseID, + ProjectFlockKandangID: pw.ProjectFlockKandangID, + FlagGroupCode: group, + }) + } + } + + sort.Slice(targets, func(i, j int) bool { + if targets[i].ProductWarehouseID == targets[j].ProductWarehouseID { + return targets[i].FlagGroupCode < targets[j].FlagGroupCode + } + return targets[i].ProductWarehouseID < targets[j].ProductWarehouseID + }) + + fmt.Printf("Mode: %s\n", modeLabel(apply)) + fmt.Printf("Scope: project_flock_kandang_id=%d include_shared=%t\n", projectFlockKandangID, includeShared) + if asOf != nil { + fmt.Printf("AsOf: %s\n", asOf.UTC().Format(time.RFC3339)) + } else { + fmt.Println("AsOf: (full timeline)") + } + fmt.Printf("Product warehouses in scope: %d\n", len(scopedPWs)) + fmt.Printf("Planned reflow targets: %d\n\n", len(targets)) + + for _, target := range targets { + fmt.Printf( + "PLAN pw=%d product=%d warehouse=%d pw_pfk=%s flag_group=%s\n", + target.ProductWarehouseID, + target.ProductID, + target.WarehouseID, + displayOptionalUint(target.ProjectFlockKandangID), + target.FlagGroupCode, + ) + } + + if !apply { + fmt.Println() + fmt.Printf("Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=0 failed_apply=0\n", len(targets), skippedPW, failedResolve) + if failedResolve > 0 { + os.Exit(1) + } + return + } + + successApply := 0 + failedApply := 0 + for idx, target := range targets { + req := commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: target.FlagGroupCode, + ProductWarehouseID: target.ProductWarehouseID, + AsOf: asOf, + IdempotencyKey: fmt.Sprintf( + "manual-pfk-reflow-%d-%d-%s-%d-%d", + projectFlockKandangID, + target.ProductWarehouseID, + strings.ToUpper(strings.TrimSpace(target.FlagGroupCode)), + time.Now().UnixNano(), + idx, + ), + } + + res, err := fifoStockV2Svc.Reflow(ctx, req) + if err != nil { + fmt.Printf("FAIL pw=%d flag_group=%s error=%v\n", target.ProductWarehouseID, target.FlagGroupCode, err) + failedApply++ + continue + } + + fmt.Printf( + "DONE pw=%d flag_group=%s rollback=%.3f allocate=%.3f pending=%.3f processed_usable=%d\n", + target.ProductWarehouseID, + target.FlagGroupCode, + res.Rollback.ReleasedQty, + res.Allocate.AllocatedQty, + res.Allocate.PendingQty, + res.ProcessedUsables, + ) + successApply++ + } + + fmt.Println() + fmt.Printf( + "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d\n", + len(targets), + skippedPW, + failedResolve, + successApply, + failedApply, + ) + if failedResolve > 0 || failedApply > 0 { + os.Exit(1) + } +} + +func parseAsOf(raw string) (*time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + + for _, layout := range layouts { + parsed, err := time.Parse(layout, raw) + if err != nil { + continue + } + if layout == "2006-01-02" { + endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + return &endOfDay, nil + } + asOf := parsed.UTC() + return &asOf, nil + } + + return nil, fmt.Errorf("unsupported format %q", raw) +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func displayOptionalUint(v *uint) string { + if v == nil { + return "NULL" + } + return fmt.Sprintf("%d", *v) +} + +func projectFlockKandangExists(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (bool, error) { + var count int64 + err := db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("id = ?", projectFlockKandangID). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func loadScopedProductWarehouses(ctx context.Context, db *gorm.DB, projectFlockKandangID uint, includeShared bool) ([]productWarehouseScopeRow, error) { + if !includeShared { + var rows []productWarehouseScopeRow + err := db.WithContext(ctx). + Table("product_warehouses"). + Select("id AS product_warehouse_id, product_id, warehouse_id, project_flock_kandang_id"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Order("id ASC"). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rows, nil + } + + query := ` + WITH scoped_pw AS ( + SELECT pw.id AS product_warehouse_id + FROM product_warehouses pw + WHERE pw.project_flock_kandang_id = ? + + UNION + SELECT pc.product_warehouse_id + FROM project_chickins pc + WHERE pc.project_flock_kandang_id = ? + AND pc.deleted_at IS NULL + + UNION + SELECT rs.product_warehouse_id + FROM recordings r + JOIN recording_stocks rs ON rs.recording_id = r.id + WHERE r.project_flock_kandangs_id = ? + AND r.deleted_at IS NULL + + UNION + SELECT rd.product_warehouse_id + FROM recordings r + JOIN recording_depletions rd ON rd.recording_id = r.id + WHERE r.project_flock_kandangs_id = ? + AND r.deleted_at IS NULL + + UNION + SELECT rd.source_product_warehouse_id + FROM recordings r + JOIN recording_depletions rd ON rd.recording_id = r.id + WHERE r.project_flock_kandangs_id = ? + AND r.deleted_at IS NULL + AND rd.source_product_warehouse_id IS NOT NULL + + UNION + SELECT re.product_warehouse_id + FROM recordings r + JOIN recording_eggs re ON re.recording_id = r.id + WHERE r.project_flock_kandangs_id = ? + AND r.deleted_at IS NULL + + UNION + SELECT lts.product_warehouse_id + FROM laying_transfer_sources lts + WHERE lts.source_project_flock_kandang_id = ? + AND lts.deleted_at IS NULL + AND lts.product_warehouse_id IS NOT NULL + + UNION + SELECT ltt.product_warehouse_id + FROM laying_transfer_targets ltt + WHERE ltt.target_project_flock_kandang_id = ? + AND ltt.deleted_at IS NULL + AND ltt.product_warehouse_id IS NOT NULL + + UNION + SELECT pi.product_warehouse_id + FROM purchase_items pi + WHERE pi.project_flock_kandang_id = ? + AND pi.product_warehouse_id IS NOT NULL + ) + SELECT DISTINCT + pw.id AS product_warehouse_id, + pw.product_id, + pw.warehouse_id, + pw.project_flock_kandang_id + FROM scoped_pw s + JOIN product_warehouses pw ON pw.id = s.product_warehouse_id + ORDER BY pw.id ASC + ` + + var rows []productWarehouseScopeRow + err := db.WithContext(ctx). + Raw( + query, + projectFlockKandangID, + projectFlockKandangID, + projectFlockKandangID, + projectFlockKandangID, + projectFlockKandangID, + projectFlockKandangID, + projectFlockKandangID, + projectFlockKandangID, + projectFlockKandangID, + ). + Scan(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func resolveFlagGroupsByProductWarehouse(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]string, error) { + var groups []string + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("DISTINCT 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(` + 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.flag_group_code ASC"). + Scan(&groups).Error + if err != nil { + return nil, err + } + return groups, nil +} diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index 6a3a5d45..a7bfe3d7 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -487,16 +487,12 @@ func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*Re 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: usableRow.Ref, NeedQty: desiredQty, - AsOf: &asOf, + AsOf: nil, }) if allocateErr != nil { err = allocateErr diff --git a/internal/common/service/fifo_stock_v2/gather.go b/internal/common/service/fifo_stock_v2/gather.go index 3812bfae..a1f4c4ae 100644 --- a/internal/common/service/fifo_stock_v2/gather.go +++ b/internal/common/service/fifo_stock_v2/gather.go @@ -151,19 +151,29 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule usedExpr := "0::numeric" pendingExpr := "0::numeric" availableExpr := baseQtyExpr - extraArgs := make([]any, 0, 1) + extraArgs := make([]any, 0, 2) + whereExtraArgs := make([]any, 0, 1) if req.Lane == LaneStockable { if rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" { usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol) usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol) } else { + // NOTE: + // usedExpr is referenced twice in the generated SELECT: + // 1) as used_quantity + // 2) inside available_quantity = base - usedExpr + // plus once in stockable WHERE clause via availableExpr > 0. + // We split the args because the WHERE placeholder order appears + // after product/flag filter placeholders in the final SQL. usedExpr = fmt.Sprintf( "(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s')", sourceIDCol, activeAllocationStatus(), ) extraArgs = append(extraArgs, rule.LegacyTypeKey) + extraArgs = append(extraArgs, rule.LegacyTypeKey) + whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey) } availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr) } else { @@ -179,6 +189,12 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule return "", nil, err } + functionCodeExpr := "?::text" + functionCodeArgs := []any{rule.FunctionCode} + if rule.SourceTable == "adjustment_stocks" { + functionCodeExpr = "COALESCE(NULLIF(src.function_code,''), ?::text)" + } + whereParts := []string{ fmt.Sprintf("src.%s = ?", productWarehouseCol), fmt.Sprintf(`EXISTS ( @@ -209,7 +225,7 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule SELECT ?::text AS source_table, ?::text AS legacy_type_key, - ?::text AS function_code, + %s AS function_code, src.%s AS source_id, src.%s AS product_warehouse_id, %s AS sort_at, @@ -221,20 +237,21 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule FROM %s src %s WHERE %s - `, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND ")) + `, functionCodeExpr, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND ")) args := []any{ rule.SourceTable, rule.LegacyTypeKey, - rule.FunctionCode, - trait.SortPriority, } + args = append(args, functionCodeArgs...) + args = append(args, trait.SortPriority) args = append(args, extraArgs...) args = append(args, req.ProductWarehouseID, entity.FlagableTypeProduct, req.FlagGroupCode, ) + args = append(args, whereExtraArgs...) if req.AsOf != nil { args = append(args, *req.AsOf) diff --git a/internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.down.sql b/internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.down.sql new file mode 100644 index 00000000..ee662a07 --- /dev/null +++ b/internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.down.sql @@ -0,0 +1,13 @@ +BEGIN; + +-- Restore CHICKIN route if rollback is required. +-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration. +UPDATE fifo_stock_v2_route_rules +SET is_active = TRUE, + updated_at = NOW() +WHERE flag_group_code = 'AYAM' + AND lane = 'USABLE' + AND function_code = 'CHICKIN_OUT' + AND source_table = 'project_chickins'; + +COMMIT; diff --git a/internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.up.sql b/internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.up.sql new file mode 100644 index 00000000..43936c01 --- /dev/null +++ b/internal/database/migrations/20260228143207_disable_chickin_fifo_consumption.up.sql @@ -0,0 +1,151 @@ +BEGIN; + +-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion, +-- not physical stock consumption. +UPDATE fifo_stock_v2_route_rules +SET is_active = FALSE, + updated_at = NOW() +WHERE flag_group_code = 'AYAM' + AND lane = 'USABLE' + AND function_code = 'CHICKIN_OUT' + AND source_table = 'project_chickins' + AND is_active = TRUE; + +-- Release existing active allocations created by PROJECT_CHICKIN +-- and return warehouse qty back. +WITH released AS ( + UPDATE stock_allocations + SET status = 'RELEASED', + released_at = COALESCE(released_at, NOW()), + updated_at = NOW(), + note = CASE + WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release' + ELSE note || '; fifo_v2_chickin_conversion_release' + END + WHERE usable_type = 'PROJECT_CHICKIN' + AND status = 'ACTIVE' + RETURNING product_warehouse_id, qty +), +pw_delta AS ( + SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta + FROM released + GROUP BY product_warehouse_id +) +UPDATE product_warehouses pw +SET qty = COALESCE(pw.qty, 0) + d.qty_delta +FROM pw_delta d +WHERE pw.id = d.product_warehouse_id; + +-- Resync stockable total_used columns from remaining ACTIVE allocations. + +-- purchase_items (PURCHASE_ITEMS) +UPDATE purchase_items pi +SET total_used = COALESCE(a.used, 0) +FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE status = 'ACTIVE' + AND stockable_type = 'PURCHASE_ITEMS' + GROUP BY stockable_id +) a +WHERE pi.id = a.stockable_id; + +UPDATE purchase_items pi +SET total_used = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.status = 'ACTIVE' + AND sa.stockable_type = 'PURCHASE_ITEMS' + AND sa.stockable_id = pi.id +); + +-- stock_transfer_details (STOCK_TRANSFER_IN) +UPDATE stock_transfer_details std +SET total_used = COALESCE(a.used, 0) +FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE status = 'ACTIVE' + AND stockable_type = 'STOCK_TRANSFER_IN' + GROUP BY stockable_id +) a +WHERE std.id = a.stockable_id; + +UPDATE stock_transfer_details std +SET total_used = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.status = 'ACTIVE' + AND sa.stockable_type = 'STOCK_TRANSFER_IN' + AND sa.stockable_id = std.id +); + +-- adjustment_stocks (ADJUSTMENT_IN) +UPDATE adjustment_stocks ast +SET total_used = COALESCE(a.used, 0) +FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE status = 'ACTIVE' + AND stockable_type = 'ADJUSTMENT_IN' + GROUP BY stockable_id +) a +WHERE ast.id = a.stockable_id; + +UPDATE adjustment_stocks ast +SET total_used = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.status = 'ACTIVE' + AND sa.stockable_type = 'ADJUSTMENT_IN' + AND sa.stockable_id = ast.id +); + +-- laying_transfer_targets (TRANSFERTOLAYING_IN) +UPDATE laying_transfer_targets ltt +SET total_used = COALESCE(a.used, 0) +FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE status = 'ACTIVE' + AND stockable_type = 'TRANSFERTOLAYING_IN' + GROUP BY stockable_id +) a +WHERE ltt.id = a.stockable_id; + +UPDATE laying_transfer_targets ltt +SET total_used = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.status = 'ACTIVE' + AND sa.stockable_type = 'TRANSFERTOLAYING_IN' + AND sa.stockable_id = ltt.id +); + +-- recording_eggs (RECORDING_EGG) +UPDATE recording_eggs re +SET total_used = COALESCE(a.used, 0) +FROM ( + SELECT stockable_id, SUM(qty) AS used + FROM stock_allocations + WHERE status = 'ACTIVE' + AND stockable_type = 'RECORDING_EGG' + GROUP BY stockable_id +) a +WHERE re.id = a.stockable_id; + +UPDATE recording_eggs re +SET total_used = 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.status = 'ACTIVE' + AND sa.stockable_type = 'RECORDING_EGG' + AND sa.stockable_id = re.id +); + +COMMIT; diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index db36e730..261b7b2f 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -46,6 +46,7 @@ type adjustmentService struct { const ( adjustmentLaneStockable = "STOCKABLE" adjustmentLaneUsable = "USABLE" + flagGroupAyam = "AYAM" ) func NewAdjustmentService( @@ -129,8 +130,11 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if functionCode == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required") } - if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) { - functionCode = string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) + if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) { + return nil, fiber.NewError( + fiber.StatusBadRequest, + "RECORDING_DEPLETION_OUT tidak boleh diinput manual. Gunakan RECORDING_DEPLETION_IN, sistem akan otomatis membuat depletion-out AYAM", + ) } warehouseID, err := s.resolveWarehouseID(c.Context(), req) @@ -211,6 +215,133 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } + if functionCode == string(utils.AdjustmentTransactionSubtypeRecordingDepletionIn) { + if routeMeta.Lane != adjustmentLaneStockable { + return fiber.NewError(fiber.StatusBadRequest, "Transaction subtype depletion in harus lane STOCKABLE") + } + if projectFlockKandangID == nil || *projectFlockKandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id aktif wajib tersedia untuk depletion conversion") + } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } + + sourcePW, err := s.resolveAyamSourceProductWarehouse(ctx, tx, warehouseID, *projectFlockKandangID) + if err != nil { + return err + } + if err := common.EnsureProjectFlockNotClosedForProductWarehouses( + ctx, + tx, + []uint{productWarehouse.Id, sourcePW.Id}, + ); err != nil { + return err + } + + sourceRoute, err := s.resolveRouteByFunctionCode( + ctx, + sourcePW.ProductId, + string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut), + ) + if err != nil { + return err + } + if sourceRoute.Lane != adjustmentLaneUsable { + return fiber.NewError(fiber.StatusBadRequest, "Route depletion out untuk produk AYAM tidak valid") + } + + sourceCode, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) + if err != nil { + return err + } + sourceAdjustment := &entity.AdjustmentStock{ + ProductWarehouseId: sourcePW.Id, + TransactionType: transactionType, + FunctionCode: sourceRoute.FunctionCode, + UsageQty: qty, + Price: req.Price, + GrandTotal: grandTotal, + AdjNumber: sourceCode, + } + if err := adjustmentStockRepoTX.CreateOne(ctx, sourceAdjustment, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion source adjustment stock record") + } + + destCode, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) + if err != nil { + return err + } + destinationAdjustment := &entity.AdjustmentStock{ + ProductWarehouseId: productWarehouse.Id, + TransactionType: transactionType, + FunctionCode: routeMeta.FunctionCode, + TotalQty: qty, + Price: req.Price, + GrandTotal: grandTotal, + AdjNumber: destCode, + } + if err := adjustmentStockRepoTX.CreateOne(ctx, destinationAdjustment, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create depletion destination adjustment stock record") + } + + sourceAsOf := sourceAdjustment.CreatedAt + if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ + FlagGroupCode: sourceRoute.FlagGroupCode, + ProductWarehouseID: sourcePW.Id, + AsOf: &sourceAsOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to auto depletion-out AYAM via FIFO v2: %v", err)) + } + + destinationAsOf := destinationAdjustment.CreatedAt + if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ + FlagGroupCode: routeMeta.FlagGroupCode, + ProductWarehouseID: destinationAdjustment.ProductWarehouseId, + AsOf: &destinationAsOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to auto depletion-in destination via FIFO v2: %v", err)) + } + + refreshedSource, err := adjustmentStockRepoTX.GetByID(ctx, sourceAdjustment.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion source adjustment stock") + } + refreshedDestination, err := adjustmentStockRepoTX.GetByID(ctx, destinationAdjustment.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh depletion destination adjustment stock") + } + + if err := s.createAdjustmentStockLog( + ctx, + stockLogRepoTX, + refreshedSource.Id, + refreshedSource.ProductWarehouseId, + note, + actorID, + 0, + refreshedSource.UsageQty+refreshedSource.PendingQty, + ); err != nil { + return err + } + if err := s.createAdjustmentStockLog( + ctx, + stockLogRepoTX, + refreshedDestination.Id, + refreshedDestination.ProductWarehouseId, + note, + actorID, + refreshedDestination.TotalQty, + 0, + ); err != nil { + return err + } + + createdAdjustmentStockId = destinationAdjustment.Id + return nil + } + adjustmentStock := &entity.AdjustmentStock{ ProductWarehouseId: productWarehouse.Id, TransactionType: transactionType, @@ -264,29 +395,16 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e decreaseQty = refreshedAdjustment.UsageQty } - stockLogs, err := stockLogRepoTX.GetByProductWarehouse(ctx, productWarehouse.Id, 1) - if err != nil { - s.Log.Errorf("Failed to get stock logs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - - currentStock := 0.0 - if len(stockLogs) > 0 { - currentStock = stockLogs[0].Stock - } - - newLog := &entity.StockLog{ - LoggableType: string(utils.StockLogTypeAdjustment), - LoggableId: adjustmentStock.Id, - Notes: note, - ProductWarehouseId: productWarehouse.Id, - CreatedBy: actorID, - Increase: increaseQty, - Decrease: decreaseQty, - Stock: currentStock + increaseQty - decreaseQty, - } - - if err := stockLogRepoTX.CreateOne(ctx, newLog, nil); err != nil { + if err := s.createAdjustmentStockLog( + ctx, + stockLogRepoTX, + adjustmentStock.Id, + productWarehouse.Id, + note, + actorID, + increaseQty, + decreaseQty, + ); err != nil { return err } @@ -417,6 +535,88 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, return uint(projectFlockKandang.Id), nil } +func (s *adjustmentService) resolveAyamSourceProductWarehouse( + ctx context.Context, + tx *gorm.DB, + warehouseID uint, + projectFlockKandangID uint, +) (*entity.ProductWarehouse, error) { + if tx == nil { + return nil, fmt.Errorf("transaction is required") + } + if projectFlockKandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid untuk depletion conversion") + } + + var sourcePW entity.ProductWarehouse + err := tx.WithContext(ctx). + Model(&entity.ProductWarehouse{}). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + 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 = product_warehouses.product_id + AND fm.flag_group_code = ? + ) + `, entity.FlagableTypeProduct, flagGroupAyam). + Order(gorm.Expr("CASE WHEN warehouse_id = ? THEN 0 ELSE 1 END ASC", warehouseID)). + Order("id ASC"). + Take(&sourcePW).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Produk sumber AYAM pada project flock kandang yang sama tidak ditemukan") + } + return nil, err + } + + return &sourcePW, nil +} + +func (s *adjustmentService) createAdjustmentStockLog( + ctx context.Context, + stockLogRepo stockLogsRepo.StockLogRepository, + adjustmentID uint, + productWarehouseID uint, + note string, + actorID uint, + increaseQty float64, + decreaseQty float64, +) error { + if stockLogRepo == nil || adjustmentID == 0 || productWarehouseID == 0 { + return nil + } + if increaseQty == 0 && decreaseQty == 0 { + return nil + } + + stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + + currentStock := 0.0 + if len(stockLogs) > 0 { + currentStock = stockLogs[0].Stock + } + + newLog := &entity.StockLog{ + LoggableType: string(utils.StockLogTypeAdjustment), + LoggableId: adjustmentID, + Notes: note, + ProductWarehouseId: productWarehouseID, + CreatedBy: actorID, + Increase: increaseQty, + Decrease: decreaseQty, + Stock: currentStock + increaseQty - decreaseQty, + } + + return stockLogRepo.CreateOne(ctx, newLog, nil) +} + func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 7c0be659..45ab0905 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -605,59 +605,11 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, 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") } - if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, desiredQty, 0); err != nil { - 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 - } - - 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: refreshed.UsageQty, - LoggableType: string(utils.StockLogTypeChikin), - LoggableId: refreshed.Id, - ProductWarehouseId: refreshed.ProductWarehouseId, - CreatedBy: actorID, - Notes: fmt.Sprintf("Chickin #%d", refreshed.Id), - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, refreshed.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Stock -= decreaseLog.Decrease - } else { - decreaseLog.Stock -= decreaseLog.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil); err != nil { - return err - } - } - - return nil + return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, desiredQty, 0) } func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, targetPW *entity.ProductWarehouse, population *entity.ProjectFlockPopulation, actorID uint) error { @@ -667,9 +619,6 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB if tx == nil { return errors.New("transaction is required") } - if s.FifoStockV2Svc == nil { - return errors.New("fifo v2 service is not available") - } if err := tx.WithContext(ctx). Model(&entity.ProjectFlockPopulation{}). @@ -678,11 +627,7 @@ func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB return err } - asOf := chickin.ChickInDate - if asOf.IsZero() { - asOf = chickin.CreatedAt - } - return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf) + return nil } func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { @@ -692,53 +637,11 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, 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 { - return err - } if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil { 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, - LoggableType: string(utils.StockLogTypeChikin), - LoggableId: chickin.Id, - ProductWarehouseId: chickin.ProductWarehouseId, - CreatedBy: actorID, - Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - increaseLog.Stock = latestStockLog.Stock - increaseLog.Stock += increaseLog.Increase - } else { - increaseLog.Stock += increaseLog.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil); err != nil { - return err - } - } - return nil } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 0f93d0a7..9d4791b6 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -25,20 +25,27 @@ type RecordingRepository interface { GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) ListByProjectFlockKandangID(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from *time.Time) ([]entity.Recording, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) + CreateRecording(tx *gorm.DB, recording *entity.Recording) error CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error + CreateStock(tx *gorm.DB, stock *entity.RecordingStock) error DeleteStocks(tx *gorm.DB, recordingID uint) error + DeleteStocksByIDs(tx *gorm.DB, ids []uint) error ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) + GetStockByID(tx *gorm.DB, stockID uint) (*entity.RecordingStock, error) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error + UpdateDepletionQuantities(tx *gorm.DB, depletionID uint, qty, usageQty, pendingQty float64) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error DeleteDepletions(tx *gorm.DB, recordingID uint) error ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error) + GetDepletionByID(tx *gorm.DB, depletionID uint) (*entity.RecordingDepletion, error) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error DeleteEggs(tx *gorm.DB, recordingID uint) error ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) + UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) @@ -272,6 +279,18 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda return nextRecordingDay(days), nil } +func (r *RecordingRepositoryImpl) CreateRecording(tx *gorm.DB, recording *entity.Recording) error { + if recording == nil { + return nil + } + return tx.Select( + "ProjectFlockKandangId", + "RecordDatetime", + "Day", + "CreatedBy", + ).Create(recording).Error +} + func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error { if len(stocks) == 0 { return nil @@ -279,10 +298,24 @@ func (r *RecordingRepositoryImpl) CreateStocks(tx *gorm.DB, stocks []entity.Reco return tx.Create(&stocks).Error } +func (r *RecordingRepositoryImpl) CreateStock(tx *gorm.DB, stock *entity.RecordingStock) error { + if stock == nil { + return nil + } + return tx.Create(stock).Error +} + func (r *RecordingRepositoryImpl) DeleteStocks(tx *gorm.DB, recordingID uint) error { return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error } +func (r *RecordingRepositoryImpl) DeleteStocksByIDs(tx *gorm.DB, ids []uint) error { + if len(ids) == 0 { + return nil + } + return tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error +} + func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) { var items []entity.RecordingStock if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { @@ -291,6 +324,18 @@ func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]e return items, nil } +func (r *RecordingRepositoryImpl) GetStockByID(tx *gorm.DB, stockID uint) (*entity.RecordingStock, error) { + if stockID == 0 { + return nil, gorm.ErrRecordNotFound + } + + var stock entity.RecordingStock + if err := tx.Where("id = ?", stockID).Take(&stock).Error; err != nil { + return nil, err + } + return &stock, nil +} + func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error { return tx.Model(&entity.RecordingStock{}). Where("id = ?", stockID). @@ -306,6 +351,16 @@ func (r *RecordingRepositoryImpl) UpdateDepletionPending(tx *gorm.DB, depletionI Update("pending_qty", pendingQty).Error } +func (r *RecordingRepositoryImpl) UpdateDepletionQuantities(tx *gorm.DB, depletionID uint, qty, usageQty, pendingQty float64) error { + return tx.Model(&entity.RecordingDepletion{}). + Where("id = ?", depletionID). + Updates(map[string]any{ + "qty": qty, + "usage_qty": usageQty, + "pending_qty": pendingQty, + }).Error +} + func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { if len(depletions) == 0 { return nil @@ -325,6 +380,18 @@ func (r *RecordingRepositoryImpl) ListDepletions(tx *gorm.DB, recordingID uint) return items, nil } +func (r *RecordingRepositoryImpl) GetDepletionByID(tx *gorm.DB, depletionID uint) (*entity.RecordingDepletion, error) { + if depletionID == 0 { + return nil, gorm.ErrRecordNotFound + } + + var depletion entity.RecordingDepletion + if err := tx.Where("id = ?", depletionID).Take(&depletion).Error; err != nil { + return nil, err + } + return &depletion, nil +} + func (r *RecordingRepositoryImpl) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error { if len(eggs) == 0 { return nil @@ -344,6 +411,12 @@ func (r *RecordingRepositoryImpl) ListEggs(tx *gorm.DB, recordingID uint) ([]ent return items, nil } +func (r *RecordingRepositoryImpl) UpdateEggTotalQty(tx *gorm.DB, eggID uint, totalQty float64) error { + return tx.Model(&entity.RecordingEgg{}). + Where("id = ?", eggID). + Update("total_qty", totalQty).Error +} + func (r *RecordingRepositoryImpl) GetRecordingEggByID( ctx context.Context, id uint, diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index c477fd64..21ff718a 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -351,13 +351,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent CreatedBy: actorID, } - createTx := tx.WithContext(ctx).Select( - "ProjectFlockKandangId", - "RecordDatetime", - "Day", - "CreatedBy", - ) - if err := createTx.Create(&createdRecording).Error; err != nil { + if err := s.Repository.CreateRecording(tx, &createdRecording); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return fiber.NewError( fiber.StatusBadRequest, @@ -385,7 +379,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent mappedStocks[i].PendingQty = &pending } note := recordingutil.RecordingNote("Create", createdRecording.Id) - if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil { + if err := s.reflowApplyRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil { return err } @@ -408,10 +402,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } applyDepletionDesiredQuantities(mappedDepletions, depletionDesired) - if err := s.replenishRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + if err := s.reflowApplyRecordingDepletionsOut(ctx, tx, mappedDepletions, note, actorID); err != nil { return err } - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { + if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil { return err } @@ -420,7 +414,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to persist eggs: %+v", err) return err } - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { + if err := s.reflowApplyRecordingEggsIn(ctx, tx, mappedEggs, note, actorID); err != nil { return err } @@ -515,7 +509,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesByFlags(ctx, feedIDs, []string{"PAKAN", "OVK"}, "feed"); err != nil { return err } - if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { + if err := s.reflowSyncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil { return err } } @@ -544,10 +538,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.ensureProductWarehousesByFlags(ctx, depletionIDs, []string{"AYAM-AFKIR", "AYAM-CULLING", "AYAM-MATI"}, "depletion"); err != nil { return err } - if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil { + if err := s.reflowResetRecordingDepletionsOut(ctx, tx, existingDepletions, note, actorID); err != nil { return err } - if err := s.reduceRecordingDepletions(ctx, tx, existingDepletions); err != nil { + if err := s.reflowResetRecordingDepletionsIn(ctx, tx, existingDepletions); err != nil { return err } @@ -575,10 +569,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } applyDepletionDesiredQuantities(mappedDepletions, depletionDesired) - if err := s.replenishRecordingDepletions(ctx, tx, mappedDepletions); err != nil { + if err := s.reflowApplyRecordingDepletionsOut(ctx, tx, mappedDepletions, note, actorID); err != nil { return err } - if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil { + if err := s.reflowApplyRecordingDepletionsIn(ctx, tx, mappedDepletions); err != nil { return err } } @@ -622,7 +616,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin if err := s.logRecordingEggUsage(ctx, tx, existingEggs, note, actorID); err != nil { return err } - if err := s.reduceRecordingEggs(ctx, tx, existingEggs); err != nil { + if err := s.reflowResetRecordingEggsIn(ctx, tx, existingEggs); err != nil { return err } @@ -637,7 +631,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return err } - if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil { + if err := s.reflowApplyRecordingEggsIn(ctx, tx, mappedEggs, note, actorID); err != nil { return err } } @@ -800,7 +794,7 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent if action == entity.ApprovalActionRejected { note := recordingutil.RecordingNote("Reject", id) - if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { + if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } recording, err := repoTx.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { @@ -871,7 +865,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if err := s.rollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { + if err := s.reflowRollbackRecordingInventory(ctx, tx, id, note, actorID); err != nil { return err } @@ -1232,3 +1226,1026 @@ func (s *recordingService) createRecordingApproval( _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowRecording, recordingID, step, &action, actorID, notes) return err } + +// ---- Reflow Inventory Helpers (moved from split files) ---- + +func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) { + if s == nil || s.Log == nil { + return + } + usage := 0.0 + if stock.UsageQty != nil { + usage = *stock.UsageQty + } + pending := 0.0 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + s.Log.Infof( + "[recording-stock] action=%s recording_id=%d stock_id=%d pw=%d usage=%.3f pending=%.3f %s", + action, + stock.RecordingId, + stock.Id, + stock.ProductWarehouseId, + usage, + pending, + extra, + ) +} + +func (s *recordingService) logEggTrace(action string, egg entity.RecordingEgg, extra string) { + if s == nil || s.Log == nil { + return + } + weight := 0.0 + if egg.Weight != nil { + weight = *egg.Weight + } + s.Log.Infof( + "[recording-egg] action=%s recording_id=%d egg_id=%d pw=%d qty=%d weight=%.3f total_qty=%.3f total_used=%.3f %s", + action, + egg.RecordingId, + egg.Id, + egg.ProductWarehouseId, + egg.Qty, + weight, + egg.TotalQty, + egg.TotalUsed, + extra, + ) +} + +func (s *recordingService) logDepletionTrace(action string, dep entity.RecordingDepletion, extra string) { + if s == nil || s.Log == nil { + return + } + sourceWarehouseID := uint(0) + if dep.SourceProductWarehouseId != nil { + sourceWarehouseID = *dep.SourceProductWarehouseId + } + s.Log.Infof( + "[recording-depletion] action=%s recording_id=%d depletion_id=%d source_pw=%d dest_pw=%d qty=%.3f usage=%.3f pending=%.3f %s", + action, + dep.RecordingId, + dep.Id, + sourceWarehouseID, + dep.ProductWarehouseId, + dep.Qty, + dep.UsageQty, + dep.PendingQty, + extra, + ) +} + +type recordingStockLogState struct { + latestByWarehouse map[uint]float64 + loaded map[uint]bool +} + +func newRecordingStockLogState() *recordingStockLogState { + return &recordingStockLogState{ + latestByWarehouse: make(map[uint]float64), + loaded: make(map[uint]bool), + } +} + +func shouldWriteRecordingStockLog(note string, actorID uint) bool { + return strings.TrimSpace(note) != "" && actorID != 0 +} + +func (s *recordingService) appendRecordingStockLog( + ctx context.Context, + tx *gorm.DB, + state *recordingStockLogState, + log *entity.StockLog, +) error { + if log == nil || log.ProductWarehouseId == 0 { + return nil + } + if s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + if state == nil { + state = newRecordingStockLogState() + } + + pwID := log.ProductWarehouseId + if !state.loaded[pwID] { + repoTx := s.StockLogRepo + if tx != nil { + repoTx = rStockLogs.NewStockLogRepository(tx) + } + stockLogs, err := repoTx.GetByProductWarehouse(ctx, pwID, 1) + if err != nil { + if s.Log != nil { + s.Log.Errorf("Failed to get stock logs for product_warehouse_id=%d: %+v", pwID, err) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + if len(stockLogs) > 0 { + state.latestByWarehouse[pwID] = stockLogs[0].Stock + } else { + state.latestByWarehouse[pwID] = 0 + } + state.loaded[pwID] = true + } + + baseStock := state.latestByWarehouse[pwID] + log.Stock = baseStock + log.Increase - log.Decrease + + if tx != nil { + if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { + return err + } + } else { + if err := s.StockLogRepo.CreateOne(ctx, log, nil); err != nil { + return err + } + } + + state.latestByWarehouse[pwID] = log.Stock + return nil +} + +func (s *recordingService) logRecordingEggUsage( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.StockLogRepo == nil { + return nil + } + if !shouldWriteRecordingStockLog(note, actorID) { + return nil + } + + logState := newRecordingStockLogState() + for _, egg := range eggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) logRecordingEggRollback( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 || s.StockLogRepo == nil { + return nil + } + if !shouldWriteRecordingStockLog(note, actorID) { + return nil + } + + logState := newRecordingStockLogState() + for _, egg := range eggs { + if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Decrease: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) reflowApplyRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + if len(stocks) == 0 { + return nil + } + 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") + } + shouldWriteLog := shouldWriteRecordingStockLog(note, actorID) + if shouldWriteLog && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + logState := newRecordingStockLogState() + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + s.logStockTrace("reflow_apply:start", stock, "") + + var desired float64 + if stock.UsageQty != nil { + desired = *stock.UsageQty + } + var pending float64 + if stock.PendingQty != nil { + pending = *stock.PendingQty + } + desiredTotal := desired + pending + + 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 + } + + refreshed, err := s.Repository.GetStockByID(tx, stock.Id) + if err != nil { + return err + } + actualUsage := 0.0 + actualPending := 0.0 + if refreshed.UsageQty != nil { + actualUsage = *refreshed.UsageQty + } + if refreshed.PendingQty != nil { + actualPending = *refreshed.PendingQty + } + s.logStockTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, actualUsage, actualPending)) + + logDecrease := actualUsage + if actualPending > 0 { + logDecrease += actualPending + } + if logDecrease > 0 && shouldWriteLog { + log := &entity.StockLog{ + ProductWarehouseId: refreshed.ProductWarehouseId, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: refreshed.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) reflowResetRecordingStocks( + ctx context.Context, + tx *gorm.DB, + stocks []entity.RecordingStock, + note string, + actorID uint, +) error { + if len(stocks) == 0 { + return nil + } + 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") + } + shouldWriteLog := shouldWriteRecordingStockLog(note, actorID) + if shouldWriteLog && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + logState := newRecordingStockLogState() + + for _, stock := range stocks { + if stock.Id == 0 { + continue + } + + currentUsage := 0.0 + if stock.UsageQty != nil { + currentUsage = *stock.UsageQty + } + s.logStockTrace("reflow_reset:start", stock, "") + + 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 rollback for recording stock %d: %+v", stock.Id, err) + return err + } + s.logStockTrace("reflow_reset:done", stock, "") + + if currentUsage > 0 && shouldWriteLog { + log := &entity.StockLog{ + ProductWarehouseId: stock.ProductWarehouseId, + CreatedBy: actorID, + Increase: currentUsage, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: stock.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + } + + return nil +} + +type desiredStock struct { + Usage float64 + Pending float64 +} + +func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock) []desiredStock { + desired := make([]desiredStock, len(stocks)) + for i := range stocks { + if stocks[i].UsageQty != nil { + desired[i].Usage = *stocks[i].UsageQty + } + if stocks[i].PendingQty != nil { + desired[i].Pending = *stocks[i].PendingQty + } + zero := 0.0 + stocks[i].UsageQty = &zero + stocks[i].PendingQty = &zero + } + return desired +} + +func (s *recordingService) reflowSyncRecordingStocks( + ctx context.Context, + tx *gorm.DB, + recordingID uint, + existing []entity.RecordingStock, + incoming []validation.Stock, + note string, + actorID uint, +) error { + 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) + for _, stock := range existing { + existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) + } + + stocksToApply := make([]entity.RecordingStock, 0, len(incoming)) + for _, item := range incoming { + list := existingByWarehouse[item.ProductWarehouseId] + var stock entity.RecordingStock + if len(list) > 0 { + stock = list[0] + existingByWarehouse[item.ProductWarehouseId] = list[1:] + } else { + zero := 0.0 + stock = entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: &zero, + PendingQty: &zero, + } + if err := s.Repository.CreateStock(tx, &stock); err != nil { + return err + } + } + + desired := item.Qty + stock.UsageQty = &desired + zero := 0.0 + stock.PendingQty = &zero + stocksToApply = append(stocksToApply, stock) + } + + var leftovers []entity.RecordingStock + for _, list := range existingByWarehouse { + leftovers = append(leftovers, list...) + } + if len(leftovers) > 0 { + if err := s.reflowResetRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { + return err + } + ids := make([]uint, 0, len(leftovers)) + for _, stock := range leftovers { + if stock.Id != 0 { + ids = append(ids, stock.Id) + } + } + if len(ids) > 0 { + if err := s.Repository.DeleteStocksByIDs(tx, ids); err != nil { + return err + } + } + } + + if len(stocksToApply) == 0 { + return nil + } + return s.reflowApplyRecordingStocks(ctx, tx, stocksToApply, note, actorID) +} + +func (s *recordingService) reflowApplyRecordingDepletionsOut( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { + if len(depletions) == 0 { + return nil + } + 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") + } + shouldWriteLog := shouldWriteRecordingStockLog(note, actorID) + if shouldWriteLog && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + logState := newRecordingStockLogState() + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + s.logDepletionTrace("reflow_apply:start", depletion, "") + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + desired := depletion.Qty + depletion.PendingQty + if err := s.Repository.UpdateDepletionQuantities(tx, depletion.Id, desired, desired, 0); 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 + } + + refreshed, err := s.Repository.GetDepletionByID(tx, depletion.Id) + if err != nil { + return err + } + s.logDepletionTrace("reflow_apply:done", *refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, refreshed.UsageQty, refreshed.PendingQty)) + + logDecrease := refreshed.UsageQty + if refreshed.PendingQty > 0 { + logDecrease += refreshed.PendingQty + } + if logDecrease > 0 && shouldWriteLog { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Decrease: logDecrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: refreshed.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + + destDelta := refreshed.Qty + refreshed.PendingQty + if refreshed.ProductWarehouseId != 0 && destDelta > 0 && shouldWriteLog { + if refreshed.ProductWarehouseId == sourceWarehouseID { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: refreshed.ProductWarehouseId, + CreatedBy: actorID, + Increase: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: refreshed.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) reflowResetRecordingDepletionsOut( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, + note string, + actorID uint, +) error { + if len(depletions) == 0 { + return nil + } + 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") + } + shouldWriteLog := shouldWriteRecordingStockLog(note, actorID) + if shouldWriteLog && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + logState := newRecordingStockLogState() + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + s.logDepletionTrace("reflow_reset:start", depletion, "") + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") + } + + logIncrease := depletion.Qty + depletion.PendingQty + destDelta := depletion.Qty + depletion.PendingQty + + if err := s.Repository.UpdateDepletionQuantities(tx, depletion.Id, 0, 0, 0); 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 source rollback 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 rollback for recording depletion %d: %+v", depletion.Id, err) + return err + } + } + s.logDepletionTrace("reflow_reset:done", depletion, "") + + if logIncrease > 0 && shouldWriteLog { + log := &entity.StockLog{ + ProductWarehouseId: sourceWarehouseID, + CreatedBy: actorID, + Increase: logIncrease, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + + if depletion.ProductWarehouseId != 0 && destDelta > 0 && shouldWriteLog { + if depletion.ProductWarehouseId == sourceWarehouseID { + continue + } + log := &entity.StockLog{ + ProductWarehouseId: depletion.ProductWarehouseId, + CreatedBy: actorID, + Decrease: destDelta, + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: depletion.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) reflowApplyRecordingDepletionsIn( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, +) error { + if len(depletions) == 0 { + return nil + } + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for applying recording depletion reflow") + return errors.New("fifo v2 service is not available") + } + + for _, depletion := range depletions { + if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { + continue + } + s.logDepletionTrace("reflow_apply:start", depletion, "") + 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("reflow_apply:done", depletion, "") + } + + return nil +} + +func (s *recordingService) reflowResetRecordingDepletionsIn( + ctx context.Context, + tx *gorm.DB, + depletions []entity.RecordingDepletion, +) error { + if len(depletions) == 0 { + return nil + } + 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 { + if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { + continue + } + s.logDepletionTrace("reflow_reset:start", depletion, "") + + if err := s.Repository.UpdateDepletionQuantities(tx, depletion.Id, 0, 0, 0); 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("reflow_reset:done", depletion, "") + } + + return nil +} + +type desiredDepletion struct { + Qty float64 + Pending float64 +} + +func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion) []desiredDepletion { + desired := make([]desiredDepletion, len(depletions)) + for i := range depletions { + desired[i].Qty = depletions[i].Qty + desired[i].Pending = depletions[i].PendingQty + depletions[i].Qty = 0 + depletions[i].UsageQty = 0 + depletions[i].PendingQty = 0 + } + return desired +} + +func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion) { + for i := range depletions { + if i >= len(desired) { + break + } + depletions[i].Qty = desired[i].Qty + depletions[i].PendingQty = desired[i].Pending + } +} + +func sumDepletionQty(items []entity.RecordingDepletion) float64 { + var total float64 + for _, item := range items { + if item.Qty > 0 { + total += item.Qty + } + } + return total +} + +func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error { + if projectFlockKandangId == 0 || newTotal <= 0 { + return nil + } + totalChick, err := s.Repository.GetTotalChick(tx, projectFlockKandangId) + if err != nil { + return err + } + // totalChick already reflects existing depletions; add them back to compare the delta. + available := float64(totalChick) + existingTotal + if newTotal > available { + return fiber.NewError(fiber.StatusBadRequest, "Depletion melebihi populasi yang tersedia") + } + return nil +} + +func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + if projectFlockKandangID == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") + } + + // Prioritize populations that still have effective remaining qty. + for _, pop := range populations { + if pop.ProductWarehouseId == 0 { + continue + } + remaining := pop.TotalQty - pop.TotalUsedQty + if remaining > 0 { + return pop.ProductWarehouseId, nil + } + } + + for _, pop := range populations { + if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { + return pop.ProductWarehouseId, nil + } + } + for _, pop := range populations { + if pop.ProductWarehouseId > 0 { + return pop.ProductWarehouseId, nil + } + } + return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") +} + +func (s *recordingService) reflowApplyRecordingEggsIn( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, + note string, + actorID uint, +) error { + if len(eggs) == 0 { + return nil + } + if s.FifoStockV2Svc == nil { + s.Log.Errorf("FIFO v2 service is not available for applying recording egg reflow") + return errors.New("fifo v2 service is not available") + } + shouldWriteLog := shouldWriteRecordingStockLog(note, actorID) + if shouldWriteLog && s.StockLogRepo == nil { + return errors.New("stock log repository is not available") + } + logState := newRecordingStockLogState() + + for _, egg := range eggs { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + s.logEggTrace("reflow_apply:start", egg, "") + + if err := s.Repository.UpdateEggTotalQty(tx, egg.Id, float64(egg.Qty)); 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("reflow_apply:done", egg, "") + + if shouldWriteLog { + log := &entity.StockLog{ + ProductWarehouseId: egg.ProductWarehouseId, + CreatedBy: actorID, + Increase: float64(egg.Qty), + LoggableType: string(utils.StockLogTypeRecording), + LoggableId: egg.RecordingId, + Notes: note, + } + if err := s.appendRecordingStockLog(ctx, tx, logState, log); err != nil { + return err + } + } + } + + return nil +} + +func (s *recordingService) reflowResetRecordingEggsIn( + ctx context.Context, + tx *gorm.DB, + eggs []entity.RecordingEgg, +) error { + if len(eggs) == 0 { + return nil + } + 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 { + if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { + continue + } + s.logEggTrace("reflow_reset:start", egg, "") + if err := s.Repository.UpdateEggTotalQty(tx, egg.Id, 0); 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("reflow_reset:done", egg, "") + } + + return nil +} + +func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { + for _, egg := range eggs { + if egg.TotalUsed > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") + } + } + return nil +} + +func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error { + if tx == nil || projectFlockKandangId == 0 || from.IsZero() { + return nil + } + + fromUTC := from.UTC() + records, err := s.Repository.ListByProjectFlockKandangID(ctx, tx, projectFlockKandangId, &fromUTC) + if err != nil { + return err + } + + for i := range records { + if err := s.computeAndUpdateMetrics(ctx, tx, &records[i]); err != nil { + return err + } + } + + return nil +} + +func (s *recordingService) reflowRollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { + if recordingID == 0 || tx == nil { + return nil + } + if err := s.requireFIFO(); err != nil { + return err + } + + oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list depletions: %+v", err) + return err + } + + oldEggs, err := s.Repository.ListEggs(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list eggs: %+v", err) + return err + } + if err := ensureRecordingEggsUnused(oldEggs); err != nil { + return err + } + if err := s.reflowResetRecordingDepletionsOut(ctx, tx, oldDepletions, note, actorID); err != nil { + return err + } + + oldStocks, err := s.Repository.ListStocks(tx, recordingID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list stocks: %+v", err) + return err + } + if err := s.reflowResetRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { + return err + } + + if err := s.reflowResetRecordingDepletionsIn(ctx, tx, oldDepletions); err != nil { + return err + } + if err := s.reflowResetRecordingEggsIn(ctx, tx, oldEggs); err != nil { + return err + } + + if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { + return err + } + + return nil +} + +func (s *recordingService) requireFIFO() error { + 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/production/recordings/services/recording_fifo.service.go b/internal/modules/production/recordings/services/recording_fifo.service.go deleted file mode 100644 index 0405036d..00000000 --- a/internal/modules/production/recordings/services/recording_fifo.service.go +++ /dev/null @@ -1,1188 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "math" - "strings" - "time" - - 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" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" - - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" -) - -const depletionUsageTolerance = 0.000001 - -func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) { - if s == nil || s.Log == nil { - return - } - usage := 0.0 - if stock.UsageQty != nil { - usage = *stock.UsageQty - } - pending := 0.0 - if stock.PendingQty != nil { - pending = *stock.PendingQty - } - s.Log.Infof( - "[recording-stock] action=%s recording_id=%d stock_id=%d pw=%d usage=%.3f pending=%.3f %s", - action, - stock.RecordingId, - stock.Id, - stock.ProductWarehouseId, - usage, - pending, - extra, - ) -} - -func (s *recordingService) logEggTrace(action string, egg entity.RecordingEgg, extra string) { - if s == nil || s.Log == nil { - return - } - weight := 0.0 - if egg.Weight != nil { - weight = *egg.Weight - } - s.Log.Infof( - "[recording-egg] action=%s recording_id=%d egg_id=%d pw=%d qty=%d weight=%.3f total_qty=%.3f total_used=%.3f %s", - action, - egg.RecordingId, - egg.Id, - egg.ProductWarehouseId, - egg.Qty, - weight, - egg.TotalQty, - egg.TotalUsed, - extra, - ) -} - -func (s *recordingService) logDepletionTrace(action string, dep entity.RecordingDepletion, extra string) { - if s == nil || s.Log == nil { - return - } - sourceWarehouseID := uint(0) - if dep.SourceProductWarehouseId != nil { - sourceWarehouseID = *dep.SourceProductWarehouseId - } - s.Log.Infof( - "[recording-depletion] action=%s recording_id=%d depletion_id=%d source_pw=%d dest_pw=%d qty=%.3f usage=%.3f pending=%.3f %s", - action, - dep.RecordingId, - dep.Id, - sourceWarehouseID, - dep.ProductWarehouseId, - dep.Qty, - dep.UsageQty, - dep.PendingQty, - extra, - ) -} - -func (s *recordingService) consumeRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - if len(stocks) == 0 { - return nil - } - 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") - } - - for _, stock := range stocks { - if stock.Id == 0 { - continue - } - s.logStockTrace("consume:start", stock, "") - - var desired float64 - if stock.UsageQty != nil { - desired = *stock.UsageQty - } - var pending float64 - if stock.PendingQty != nil { - pending = *stock.PendingQty - } - desiredTotal := desired + pending - - 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 - } - - var refreshed entity.RecordingStock - if err := tx.WithContext(ctx). - Where("id = ?", stock.Id). - Take(&refreshed).Error; err != nil { - return err - } - 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 := actualUsage - if actualPending > 0 { - logDecrease += actualPending - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: refreshed.ProductWarehouseId, - CreatedBy: actorID, - Decrease: logDecrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: refreshed.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, refreshed.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) consumeRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, - note string, - actorID uint, -) error { - if len(depletions) == 0 { - return nil - } - 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") - } - - for _, depletion := range depletions { - if depletion.Id == 0 { - continue - } - s.logDepletionTrace("consume:start", depletion, "") - - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } - - desired := depletion.Qty + depletion.PendingQty - 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 - } - - var refreshed entity.RecordingDepletion - if err := tx.WithContext(ctx). - Where("id = ?", depletion.Id). - Take(&refreshed).Error; err != nil { - return err - } - s.logDepletionTrace("consume:done", refreshed, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, refreshed.UsageQty, refreshed.PendingQty)) - - logDecrease := refreshed.UsageQty - if refreshed.PendingQty > 0 { - logDecrease += refreshed.PendingQty - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - CreatedBy: actorID, - Decrease: logDecrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: refreshed.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - 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: refreshed.ProductWarehouseId, - CreatedBy: actorID, - Increase: destDelta, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: refreshed.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, refreshed.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) releaseRecordingStocks( - ctx context.Context, - tx *gorm.DB, - stocks []entity.RecordingStock, - note string, - actorID uint, -) error { - if len(stocks) == 0 { - return nil - } - 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") - } - - for _, stock := range stocks { - if stock.Id == 0 { - continue - } - - currentUsage := 0.0 - if stock.UsageQty != nil { - currentUsage = *stock.UsageQty - } - s.logStockTrace("release:start", stock, "") - - 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 currentUsage > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, - CreatedBy: actorID, - Increase: currentUsage, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: stock.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) releaseRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, - note string, - actorID uint, -) error { - if len(depletions) == 0 { - return nil - } - 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") - } - - for _, depletion := range depletions { - if depletion.Id == 0 { - continue - } - s.logDepletionTrace("release:start", depletion, "") - - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } - - 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.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, "") - - if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - CreatedBy: actorID, - Increase: logIncrease, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - if depletion.ProductWarehouseId == sourceWarehouseID { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: depletion.ProductWarehouseId, - CreatedBy: actorID, - Decrease: destDelta, - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: depletion.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock -= log.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func validateDepletionUsage(depletion entity.RecordingDepletion) error { - desired := depletion.Qty + depletion.PendingQty - if math.Abs(depletion.UsageQty-desired) <= depletionUsageTolerance { - return nil - } - return fiber.NewError( - fiber.StatusConflict, - fmt.Sprintf("FIFO depletion mismatch (id=%d): qty=%.3f usage=%.3f pending=%.3f", depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty), - ) -} - -func (s *recordingService) logRecordingEggUsage( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, - note string, - actorID uint, -) error { - if len(eggs) == 0 || s.StockLogRepo == nil { - return nil - } - if strings.TrimSpace(note) == "" || actorID == 0 { - return nil - } - - logs := make([]*entity.StockLog, 0, len(eggs)) - for _, egg := range eggs { - if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) - if err != nil { - s.Log.Errorf("Failed to get stock logs: %+v", err) - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - latestStockLog := &entity.StockLog{} - if len(stockLogs) > 0 { - latestStockLog = stockLogs[0] - } else { - latestStockLog.Stock = 0 - } - logs = append(logs, &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Decrease: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: egg.RecordingId, - Notes: note, - Stock: latestStockLog.Stock - float64(egg.Qty), - }) - } - if len(logs) == 0 { - return nil - } - - return s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil) -} - -func (s *recordingService) logRecordingEggRollback( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, - note string, - actorID uint, -) error { - if len(eggs) == 0 || s.StockLogRepo == nil { - return nil - } - if strings.TrimSpace(note) == "" || actorID == 0 { - return nil - } - - for _, egg := range eggs { - if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Decrease: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: egg.RecordingId, - Notes: note, - } - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - return nil -} - -func (s *recordingService) replenishRecordingEggs( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, - note string, - actorID uint, -) error { - if len(eggs) == 0 { - return nil - } - 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") - } - - for _, egg := range eggs { - if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - s.logEggTrace("replenish:start", egg, "") - - 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, "") - - if strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: egg.ProductWarehouseId, - CreatedBy: actorID, - Increase: float64(egg.Qty), - LoggableType: string(utils.StockLogTypeRecording), - LoggableId: egg.RecordingId, - Notes: note, - } - stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - log.Stock = latestStockLog.Stock - log.Stock += log.Increase - } else { - log.Stock += log.Increase - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - } - - return nil -} - -func (s *recordingService) replenishRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, -) error { - if len(depletions) == 0 { - return nil - } - 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 { - if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { - continue - } - s.logDepletionTrace("replenish:start", depletion, "") - 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, "") - } - - return nil -} - -func (s *recordingService) reduceRecordingDepletions( - ctx context.Context, - tx *gorm.DB, - depletions []entity.RecordingDepletion, -) error { - if len(depletions) == 0 { - return nil - } - 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 { - if depletion.Id == 0 || depletion.ProductWarehouseId == 0 || depletion.Qty <= 0 { - continue - } - s.logDepletionTrace("reduce:start", depletion, "") - - 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, "") - } - - return nil -} - -func (s *recordingService) reduceRecordingEggs( - ctx context.Context, - tx *gorm.DB, - eggs []entity.RecordingEgg, -) error { - if len(eggs) == 0 { - return nil - } - 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 { - if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { - continue - } - s.logEggTrace("reduce:start", egg, "") - 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, "") - } - - return nil -} - -func (s *recordingService) ensureActiveAllocations( - ctx context.Context, - tx *gorm.DB, - usableKey fifo.UsableKey, - usableID uint, -) error { - if usableID == 0 { - return nil - } - var count int64 - if err := tx.WithContext(ctx). - Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). - Count(&count).Error; err != nil { - return err - } - if count == 0 { - return fiber.NewError(fiber.StatusConflict, fmt.Sprintf("no active allocations for usable %s id=%d", usableKey, usableID)) - } - return nil -} - -func (s *recordingService) countActiveAllocations( - ctx context.Context, - tx *gorm.DB, - usableKey fifo.UsableKey, - usableID uint, -) (int64, error) { - if usableID == 0 { - return 0, nil - } - var count int64 - if err := tx.WithContext(ctx). - Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). - Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -func (s *recordingService) resyncStockableUsageFromAllocations( - ctx context.Context, - tx *gorm.DB, - usableKey fifo.UsableKey, - usableID uint, -) error { - if usableID == 0 { - return nil - } - - type stockableRef struct { - StockableType string - StockableID uint - } - - var refs []stockableRef - if err := tx.WithContext(ctx). - Model(&entity.StockAllocation{}). - Select("stockable_type, stockable_id"). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableKey, usableID, entity.StockAllocationStatusActive). - Group("stockable_type, stockable_id"). - Scan(&refs).Error; err != nil { - return err - } - if len(refs) == 0 { - return nil - } - - for _, ref := range refs { - var total float64 - if err := tx.WithContext(ctx). - Model(&entity.StockAllocation{}). - Select("COALESCE(SUM(qty),0)"). - Where("stockable_type = ? AND stockable_id = ? AND status = ?", ref.StockableType, ref.StockableID, entity.StockAllocationStatusActive). - Scan(&total).Error; err != nil { - return err - } - - switch ref.StockableType { - case string(fifo.StockableKeyProjectFlockPopulation): - if err := tx.WithContext(ctx). - Table("project_flock_populations"). - Where("id = ?", ref.StockableID). - Update("total_used_qty", total).Error; err != nil { - return err - } - case string(fifo.StockableKeyPurchaseItems): - if err := tx.WithContext(ctx). - Table("purchase_items"). - Where("id = ?", ref.StockableID). - Update("total_used", total).Error; err != nil { - return err - } - default: - // no-op for other stockables - } - } - - return nil -} - -type desiredStock struct { - Usage float64 - Pending float64 -} - -type desiredDepletion struct { - Qty float64 - Pending float64 -} - -func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock) []desiredStock { - desired := make([]desiredStock, len(stocks)) - for i := range stocks { - if stocks[i].UsageQty != nil { - desired[i].Usage = *stocks[i].UsageQty - } - if stocks[i].PendingQty != nil { - desired[i].Pending = *stocks[i].PendingQty - } - zero := 0.0 - stocks[i].UsageQty = &zero - stocks[i].PendingQty = &zero - } - return desired -} - -func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion) []desiredDepletion { - desired := make([]desiredDepletion, len(depletions)) - for i := range depletions { - desired[i].Qty = depletions[i].Qty - desired[i].Pending = depletions[i].PendingQty - depletions[i].Qty = 0 - depletions[i].UsageQty = 0 - depletions[i].PendingQty = 0 - } - return desired -} - -func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion) { - for i := range depletions { - if i >= len(desired) { - break - } - depletions[i].Qty = desired[i].Qty - depletions[i].PendingQty = desired[i].Pending - } -} - -func (s *recordingService) syncRecordingStocks( - ctx context.Context, - tx *gorm.DB, - recordingID uint, - existing []entity.RecordingStock, - incoming []validation.Stock, - note string, - actorID uint, -) error { - 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) - for _, stock := range existing { - existingByWarehouse[stock.ProductWarehouseId] = append(existingByWarehouse[stock.ProductWarehouseId], stock) - } - - stocksToConsume := make([]entity.RecordingStock, 0, len(incoming)) - for _, item := range incoming { - list := existingByWarehouse[item.ProductWarehouseId] - var stock entity.RecordingStock - if len(list) > 0 { - stock = list[0] - existingByWarehouse[item.ProductWarehouseId] = list[1:] - } else { - zero := 0.0 - stock = entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: item.ProductWarehouseId, - UsageQty: &zero, - PendingQty: &zero, - } - if err := tx.Create(&stock).Error; err != nil { - return err - } - } - - desired := item.Qty - stock.UsageQty = &desired - zero := 0.0 - stock.PendingQty = &zero - stocksToConsume = append(stocksToConsume, stock) - } - - var leftovers []entity.RecordingStock - for _, list := range existingByWarehouse { - leftovers = append(leftovers, list...) - } - if len(leftovers) > 0 { - if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil { - return err - } - ids := make([]uint, 0, len(leftovers)) - for _, stock := range leftovers { - if stock.Id != 0 { - ids = append(ids, stock.Id) - } - } - if len(ids) > 0 { - if err := tx.Where("id IN ?", ids).Delete(&entity.RecordingStock{}).Error; err != nil { - return err - } - } - } - - if len(stocksToConsume) == 0 { - return nil - } - return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID) -} - -func sumDepletionQty(items []entity.RecordingDepletion) float64 { - var total float64 - for _, item := range items { - if item.Qty > 0 { - total += item.Qty - } - } - return total -} - -func (s *recordingService) ensureDepletionWithinPopulation(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, newTotal float64, existingTotal float64) error { - if projectFlockKandangId == 0 || newTotal <= 0 { - return nil - } - totalChick, err := s.Repository.GetTotalChick(tx, projectFlockKandangId) - if err != nil { - return err - } - // totalChick already reflects existing depletions; add them back to compare the delta. - available := float64(totalChick) + existingTotal - if newTotal > available { - return fiber.NewError(fiber.StatusBadRequest, "Depletion melebihi populasi yang tersedia") - } - return nil -} - -func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error { - for _, egg := range eggs { - if egg.TotalUsed > 0 { - return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah") - } - } - return nil -} - -func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { - if projectFlockKandangID == 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") - } - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") - } - for _, pop := range populations { - if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 { - return pop.ProductWarehouseId, nil - } - } - for _, pop := range populations { - if pop.ProductWarehouseId > 0 { - return pop.ProductWarehouseId, nil - } - } - return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") -} - -func (s *recordingService) recalculateFrom(ctx context.Context, tx *gorm.DB, projectFlockKandangId uint, from time.Time) error { - if tx == nil || projectFlockKandangId == 0 || from.IsZero() { - return nil - } - - fromUTC := from.UTC() - records, err := s.Repository.ListByProjectFlockKandangID(ctx, tx, projectFlockKandangId, &fromUTC) - if err != nil { - return err - } - - for i := range records { - if err := s.computeAndUpdateMetrics(ctx, tx, &records[i]); err != nil { - return err - } - } - - return nil -} - -func (s *recordingService) rollbackRecordingInventory(ctx context.Context, tx *gorm.DB, recordingID uint, note string, actorID uint) error { - if recordingID == 0 || tx == nil { - return nil - } - if err := s.requireFIFO(); err != nil { - return err - } - - oldDepletions, err := s.Repository.ListDepletions(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list depletions: %+v", err) - return err - } - - oldEggs, err := s.Repository.ListEggs(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list eggs: %+v", err) - return err - } - if err := ensureRecordingEggsUnused(oldEggs); err != nil { - return err - } - if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, note, actorID); err != nil { - return err - } - - oldStocks, err := s.Repository.ListStocks(tx, recordingID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - s.Log.Errorf("Failed to list stocks: %+v", err) - return err - } - if err := s.releaseRecordingStocks(ctx, tx, oldStocks, note, actorID); err != nil { - return err - } - - if err := s.reduceRecordingDepletions(ctx, tx, oldDepletions); err != nil { - return err - } - if err := s.reduceRecordingEggs(ctx, tx, oldEggs); err != nil { - return err - } - - if err := s.logRecordingEggRollback(ctx, tx, oldEggs, note, actorID); err != nil { - return err - } - - return nil -} - -func (s *recordingService) requireFIFO() error { - 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/utils/constant.go b/internal/utils/constant.go index 1829b941..02b09692 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -233,7 +233,7 @@ var adjustmentSubtypesByType = map[AdjustmentTransactionType][]string{ } var hiddenAdjustmentSubtypesForFrontend = map[string]struct{}{ - string(AdjustmentTransactionSubtypeRecordingDepletionIn): {}, + string(AdjustmentTransactionSubtypeRecordingDepletionOut): {}, } var adjustmentSubtypeToType = func() map[string]AdjustmentTransactionType { From d5a1751868c76a705978b55b5fb2b2239ecb46a9 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Mon, 2 Mar 2026 12:44:20 +0700 Subject: [PATCH 3/4] fix: all implemented fifo v2 --- cmd/delete-adjustments/main.go | 72 ++---- cmd/reflow-project-flock-kandang/main.go | 86 ++++++- .../modules/inventory/adjustments/module.go | 38 --- .../services/adjustment.service.go | 3 - .../modules/inventory/transfers/module.go | 37 +-- .../transfers/services/transfer.service.go | 4 +- .../chickins/services/chickin.service.go | 51 +++- .../production/transfer_layings/module.go | 44 +--- .../services/fifo_stock_v2_helper.go | 18 +- .../services/transfer_laying.service.go | 110 ++++----- .../purchases/services/purchase.service.go | 225 +++++++++--------- 11 files changed, 319 insertions(+), 369 deletions(-) rename internal/modules/production/{chickins => transfer_layings}/services/fifo_stock_v2_helper.go (75%) diff --git a/cmd/delete-adjustments/main.go b/cmd/delete-adjustments/main.go index e07a7ae3..6555749b 100644 --- a/cmd/delete-adjustments/main.go +++ b/cmd/delete-adjustments/main.go @@ -11,12 +11,10 @@ import ( "strings" "time" - 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/config" "gitlab.com/mbugroup/lti-api.git/internal/database" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -61,13 +59,7 @@ func main() { ctx := context.Background() db := database.Connect(config.DBHost, config.DBName) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, nil) fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil) - if err := registerAdjustmentFIFO(fifoSvc); err != nil { - log.Fatalf("failed to register adjustment fifo config: %v", err) - } adjustments, err := loadAdjustments(ctx, db, ids) if err != nil { @@ -185,7 +177,7 @@ func main() { } fmt.Printf( - "PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reverse_stock+delete\n", + "PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n", adj.ID, route.FunctionCode, adj.TotalQty, @@ -198,16 +190,25 @@ func main() { } err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if removeQty > 0 { - if err := fifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyAdjustmentIn, - StockableID: adj.ID, - ProductWarehouseID: adj.ProductWarehouseID, - Quantity: -removeQty, - Tx: tx, - }); err != nil { - return fmt.Errorf("reverse stockable quantity: %w", err) - } + if err := tx.WithContext(ctx). + Table("adjustment_stocks"). + Where("id = ?", adj.ID). + Updates(map[string]any{ + "total_qty": 0, + "total_used": 0, + }).Error; err != nil { + return fmt.Errorf("set stockable qty to zero: %w", err) + } + + reflowReq := commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: route.FlagGroupCode, + ProductWarehouseID: adj.ProductWarehouseID, + AsOf: &adj.CreatedAt, + IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()), + Tx: tx, + } + if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil { + return fmt.Errorf("reflow stockable to zero: %w", err) } if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil { @@ -243,39 +244,6 @@ func main() { } } -func registerAdjustmentFIFO(fifoSvc commonSvc.FifoService) error { - if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyAdjustmentIn, - Table: "adjustment_stocks", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { - return err - } - - if err := fifoSvc.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyAdjustmentOut, - Table: "adjustment_stocks", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { - return err - } - return nil -} - func modeLabel(apply bool) string { if apply { return "APPLY" diff --git a/cmd/reflow-project-flock-kandang/main.go b/cmd/reflow-project-flock-kandang/main.go index 8e797bf7..973026a9 100644 --- a/cmd/reflow-project-flock-kandang/main.go +++ b/cmd/reflow-project-flock-kandang/main.go @@ -178,14 +178,35 @@ func main() { successApply++ } + orphanPopulationRows := int64(0) + syncedPopulationQtyRows := int64(0) + syncedPopulationUsedRows := int64(0) + if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil { + fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err) + failedApply++ + } else { + orphanPopulationRows = rowsOrphan + syncedPopulationQtyRows = rowsQty + syncedPopulationUsedRows = rowsUsed + fmt.Printf( + "SYNC project_flock_populations orphan_marked=%d qty_synced=%d used_synced=%d\n", + orphanPopulationRows, + syncedPopulationQtyRows, + syncedPopulationUsedRows, + ) + } + fmt.Println() fmt.Printf( - "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d\n", + "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d\n", len(targets), skippedPW, failedResolve, successApply, failedApply, + orphanPopulationRows, + syncedPopulationQtyRows, + syncedPopulationUsedRows, ) if failedResolve > 0 || failedApply > 0 { os.Exit(1) @@ -379,3 +400,66 @@ func resolveFlagGroupsByProductWarehouse(ctx context.Context, db *gorm.DB, produ } return groups, nil } + +func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, int64, int64, error) { + if projectFlockKandangID == 0 { + return 0, 0, 0, nil + } + + orphanResult := db.WithContext(ctx).Exec(` + UPDATE project_flock_populations pfp + SET deleted_at = NOW(), + updated_at = NOW() + FROM project_chickins pc + WHERE pfp.project_chickin_id = pc.id + AND pc.project_flock_kandang_id = ? + AND pc.deleted_at IS NOT NULL + AND pfp.deleted_at IS NULL + `, projectFlockKandangID) + if orphanResult.Error != nil { + return 0, 0, 0, orphanResult.Error + } + + qtyResult := db.WithContext(ctx).Exec(` + UPDATE project_flock_populations p + SET total_qty = GREATEST(COALESCE(pc.usage_qty, 0), 0), + updated_at = NOW() + FROM project_chickins pc + WHERE p.project_chickin_id = pc.id + AND pc.project_flock_kandang_id = ? + AND pc.deleted_at IS NULL + AND p.deleted_at IS NULL + `, projectFlockKandangID) + if qtyResult.Error != nil { + return 0, 0, 0, qtyResult.Error + } + + usedResult := db.WithContext(ctx).Exec(` + WITH scoped AS ( + SELECT pfp.id, pfp.total_qty + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + AND pc.deleted_at IS NULL + AND pfp.deleted_at IS NULL + ), + alloc AS ( + SELECT sa.stockable_id, SUM(sa.qty) AS used_qty + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + GROUP BY sa.stockable_id + ) + UPDATE project_flock_populations p + SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)), + updated_at = NOW() + FROM scoped s + LEFT JOIN alloc a ON a.stockable_id = s.id + WHERE p.id = s.id + `, projectFlockKandangID) + if usedResult.Error != nil { + return 0, 0, 0, usedResult.Error + } + + return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil +} diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index b1522c0f..42c8332d 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -5,7 +5,6 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" @@ -17,7 +16,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 AdjustmentModule struct{} @@ -31,50 +29,14 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - - fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) - err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyAdjustmentIn, - Table: "adjustment_stocks", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error()) - } - - err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyAdjustmentOut, - Table: "adjustment_stocks", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error()) - } - adjustmentService := sAdjustment.NewAdjustmentService( productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, adjustmentStockRepo, - fifoService, fifoStockV2Service, validate, projectFlockKandangRepo, diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 261b7b2f..8e92f036 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -39,7 +39,6 @@ type adjustmentService struct { ProductRepo productRepo.ProductRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository - FifoSvc common.FifoService FifoStockV2Svc common.FifoStockV2Service } @@ -55,7 +54,6 @@ func NewAdjustmentService( warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, - fifoSvc common.FifoService, fifoStockV2Svc common.FifoStockV2Service, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, @@ -69,7 +67,6 @@ func NewAdjustmentService( ProductRepo: productRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, AdjustmentStockRepository: adjustmentStockRepo, - FifoSvc: fifoSvc, FifoStockV2Svc: fifoStockV2Svc, } } diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 8e1aae94..50498493 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -24,7 +24,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 TransferModule struct{} @@ -43,7 +42,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate kandangRepo := rKandang.NewKandangRepository(db) nonstockRepo := rNonstock.NewNonstockRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) @@ -69,7 +67,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate validate, ) - fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) expenseBridge := sTransfer.NewTransferExpenseBridge( db, @@ -79,39 +76,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseServiceInstance, ) - err = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyStockTransferIn, - Table: "stock_transfer_details", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "dest_product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic(err) - } - - err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyStockTransferOut, - Table: "stock_transfer_details", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "source_product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic(err) - } - - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, fifoStockV2Service, expenseBridge) + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoStockV2Service, expenseBridge) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 4f88e0ec..fd7749ee 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -44,12 +44,11 @@ type transferService struct { WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository DocumentSvc commonSvc.DocumentService - FifoSvc commonSvc.FifoService FifoStockV2Svc commonSvc.FifoStockV2Service ExpenseBridge TransferExpenseBridge } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -63,7 +62,6 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, DocumentSvc: documentSvc, - FifoSvc: fifoSvc, FifoStockV2Svc: fifoStockV2Svc, ExpenseBridge: expenseBridge, } diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 45ab0905..3a54f3ba 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -257,7 +257,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti for idx, chickin := range newChikins { desiredQty := chickinQtyMap[uint(idx)] - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { + if err := s.StageChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { return err } } @@ -368,7 +368,7 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if chickin.UsageQty > 0 { + if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { return err } @@ -479,6 +479,20 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, chickin := range chickins { + approvedQty := chickin.UsageQty + if approvedQty <= 0 { + approvedQty = chickin.PendingUsageQty + } + if approvedQty < 0 { + approvedQty = 0 + } + + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, &chickin, approvedQty, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to finalize usage qty for chickin %d", chickin.Id)) + } + chickin.UsageQty = approvedQty + chickin.PendingUsageQty = 0 + populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) @@ -510,19 +524,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create population for chickin %d", chickin.Id)) } - if err := chickinRepoTx.PatchOne(c.Context(), chickin.Id, map[string]any{ - "pending_usage_qty": 0, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset pending usage qty for chickin %d", chickin.Id)) - } - if err := s.ReplenishChickinStocks(c.Context(), dbTransaction, &chickin, sourcePW, population, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock for chickin %d", chickin.Id)) } } } if action == entity.ApprovalActionRejected { - chickins, err := chickinRepoTx.GetPendingByProjectFlockKandangID(c.Context(), approvableID) + chickins, err := chickinRepoTx.GetByProjectFlockKandangID(c.Context(), approvableID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID)) } @@ -532,6 +540,17 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, chickin := range chickins { + populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) + } + if populationExists { + continue + } + + if chickin.UsageQty <= 0 && chickin.PendingUsageQty <= 0 { + continue + } if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) @@ -612,6 +631,20 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, desiredQty, 0) } +func (s *chickinService) StageChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { + if chickin == nil { + return nil + } + if tx == nil { + return errors.New("transaction is required") + } + if desiredQty < 0 { + return errors.New("desired quantity must be zero or greater") + } + + return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, desiredQty) +} + 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 { return nil diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index dfe2ad44..f7661034 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -2,7 +2,6 @@ package transfer_layings import ( "fmt" - "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -14,7 +13,6 @@ import ( rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" @@ -34,45 +32,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rInventory.NewProductWarehouseRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) - - stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - - // daftarin jadi stockable - if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyTransferToLayingIn, - Table: "laying_transfer_targets", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - 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 transfer to laying stockable workflow: %v", err)) - } - } - - // daftarin jadi usable - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyTransferToLayingOut, - Table: "laying_transfer_sources", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_usage_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 transfer to laying usable workflow: %v", err)) - } - } + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) @@ -90,7 +50,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val productWarehouseRepo, warehouseRepo, approvalService, - fifoService, + fifoStockV2Service, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/chickins/services/fifo_stock_v2_helper.go b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go similarity index 75% rename from internal/modules/production/chickins/services/fifo_stock_v2_helper.go rename to internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go index a931e43e..3df37dbf 100644 --- a/internal/modules/production/chickins/services/fifo_stock_v2_helper.go +++ b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go @@ -12,12 +12,12 @@ import ( ) const ( - chickinOutFunctionCode = "CHICKIN_OUT" - chickinUsableLane = "USABLE" - chickinSourceTable = "project_chickins" + transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN" + transferLayingStockableLane = "STOCKABLE" + transferLayingSourceTable = "laying_transfer_targets" ) -func reflowChickinScope( +func reflowTransferLayingScope( ctx context.Context, fifoStockV2Svc commonSvc.FifoStockV2Service, tx *gorm.DB, @@ -34,7 +34,7 @@ func reflowChickinScope( return fmt.Errorf("product warehouse id is required") } - flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + flagGroupCode, err := resolveTransferLayingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) if err != nil { return err } @@ -51,7 +51,7 @@ func reflowChickinScope( return err } -func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { +func resolveTransferLayingFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { type row struct { FlagGroupCode string `gorm:"column:flag_group_code"` } @@ -62,9 +62,9 @@ func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, 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("rr.lane = ?", transferLayingStockableLane). + Where("rr.function_code = ?", transferLayingInFunctionCode). + Where("rr.source_table = ?", transferLayingSourceTable). Where(` EXISTS ( SELECT 1 diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 15351e56..3eb94a74 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -19,7 +19,6 @@ import ( validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations" 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" @@ -51,7 +50,7 @@ type transferLayingService struct { WarehouseRepo rWarehouse.WarehouseRepository StockLogRepo rStockLogs.StockLogRepository ApprovalService commonSvc.ApprovalService - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service } func NewTransferLayingService( @@ -64,7 +63,7 @@ func NewTransferLayingService( productWarehouseRepo rInventory.ProductWarehouseRepository, warehouseRepo rWarehouse.WarehouseRepository, approvalService commonSvc.ApprovalService, - fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, validate *validator.Validate, ) TransferLayingService { return &transferLayingService{ @@ -80,7 +79,7 @@ func NewTransferLayingService( WarehouseRepo: warehouseRepo, StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), ApprovalService: approvalService, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, } } @@ -744,7 +743,6 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(dbTransaction) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction) @@ -771,6 +769,9 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } if action == entity.ApprovalActionApproved { + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) if err != nil { @@ -792,58 +793,70 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( totalSourceRequested += source.RequestedQty } + sourceBeforeUsage := make(map[uint]float64, len(sources)) + affectedPW := make(map[uint]struct{}) + for _, source := range sources { if source.ProductWarehouseId == nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID)) } - sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty - - consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyTransferToLayingOut, - UsableID: source.Id, - ProductWarehouseID: *source.ProductWarehouseId, - Quantity: sourceShare, - AllowPending: false, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal consume FIFO stock: %v", err)) + sourceShare := 0.0 + if totalSourceRequested > 0 { + sourceShare = (source.RequestedQty / totalSourceRequested) * totalTargetQty } + sourceBeforeUsage[source.Id] = source.UsageQty + if err := sourceRepoTx.PatchOne(c.Context(), source.Id, map[string]interface{}{ - "usage_qty": source.UsageQty + consumeResult.UsageQuantity, - "pending_usage_qty": consumeResult.PendingQuantity, + "usage_qty": sourceShare, + "pending_usage_qty": 0, }, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") } - targetShares := distributeProportionalWithRounding(targets, totalTargetQty, sourceShare) + affectedPW[*source.ProductWarehouseId] = struct{}{} + } - for i, target := range targets { - roundedQty := math.Round(targetShares[i]) - if roundedQty <= 0 { - continue - } - mappingAllocation := &entity.StockAllocation{ - StockableType: fifo.UsableKeyTransferToLayingOut.String(), - StockableId: source.Id, - UsableType: fifo.StockableKeyTransferToLayingIn.String(), - UsableId: target.Id, - ProductWarehouseId: *source.ProductWarehouseId, - Qty: roundedQty, - Status: entity.StockAllocationStatusActive, - } - if err := stockAllocationRepo.CreateOne(c.Context(), mappingAllocation, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target") - } + for _, target := range targets { + if target.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) + } + + if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ + "total_qty": target.TotalQty, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") + } + affectedPW[*target.ProductWarehouseId] = struct{}{} + } + + for pwID := range affectedPW { + asOfCopy := transfer.TransferDate + if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, pwID, &asOfCopy); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO stock transfer laying (pw=%d): %v", pwID, err)) + } + } + + for _, source := range sources { + if source.ProductWarehouseId == nil { + continue + } + refreshedSource, err := sourceRepoTx.GetByID(c.Context(), source.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh source transfer setelah reflow") + } + + usageDelta := refreshedSource.UsageQty - sourceBeforeUsage[source.Id] + if usageDelta <= 0 { + continue } stockLogDecrease := &entity.StockLog{ ProductWarehouseId: *source.ProductWarehouseId, CreatedBy: actorID, Increase: 0, - Decrease: sourceShare, + Decrease: usageDelta, LoggableType: string(utils.StockLogTypeTransferLaying), LoggableId: approvableID, Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), @@ -867,26 +880,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( for _, target := range targets { if target.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) - } - - note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) - _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyTransferToLayingIn, - StockableID: target.Id, - ProductWarehouseID: *target.ProductWarehouseId, - Quantity: target.TotalQty, - Note: ¬e, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) - } - - if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ - "total_qty": target.TotalQty, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") + continue } stockLogIncrease := &entity.StockLog{ diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index ba5f7384..313c4b7f 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -256,35 +256,13 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } if len(purchase.Items) > 0 { - itemIDs := make([]uint, 0, len(purchase.Items)) - for i := range purchase.Items { - if purchase.Items[i].Id == 0 { - continue - } - itemIDs = append(itemIDs, purchase.Items[i].Id) + lockedIDs, err := s.resolveChickinLockedItemIDs(c.Context(), s.PurchaseRepo.DB(), purchase.Items) + if err != nil { + return nil, err } - if len(itemIDs) > 0 { - var usedIDs []uint - if err := s.PurchaseRepo.DB().WithContext(c.Context()). - Model(&entity.StockAllocation{}). - Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Pluck("stockable_id", &usedIDs).Error; err != nil { - return nil, err - } - usedSet := make(map[uint]struct{}, len(usedIDs)) - for _, id := range usedIDs { - usedSet[id] = struct{}{} - } - for i := range purchase.Items { - if _, ok := usedSet[purchase.Items[i].Id]; ok { - purchase.Items[i].HasChickin = true - } + for i := range purchase.Items { + if _, ok := lockedIDs[purchase.Items[i].Id]; ok { + purchase.Items[i].HasChickin = true } } } @@ -532,48 +510,31 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid } if action == entity.ApprovalActionApproved { - itemIDs := make([]uint, 0, len(purchase.Items)) itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { if purchase.Items[i].Id == 0 { continue } - itemIDs = append(itemIDs, purchase.Items[i].Id) itemByID[purchase.Items[i].Id] = purchase.Items[i] } - if len(itemIDs) > 0 { - var usedIDs []uint - if err := s.PurchaseRepo.DB().WithContext(ctx). - Model(&entity.StockAllocation{}). - Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Pluck("stockable_id", &usedIDs).Error; err != nil { - return nil, err - } - if len(usedIDs) > 0 { - usedSet := make(map[uint]struct{}, len(usedIDs)) - for _, id := range usedIDs { - usedSet[id] = struct{}{} + lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items) + if err != nil { + return nil, err + } + if len(lockedIDs) > 0 { + for _, payload := range req.Items { + if payload.PurchaseItemID == 0 || payload.Qty == nil { + continue } - for _, payload := range req.Items { - if payload.PurchaseItemID == 0 || payload.Qty == nil { - continue - } - if _, used := usedSet[payload.PurchaseItemID]; !used { - continue - } - item, ok := itemByID[payload.PurchaseItemID] - if !ok { - continue - } - if *payload.Qty != item.SubQty { - return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah") - } + if _, locked := lockedIDs[payload.PurchaseItemID]; !locked { + continue + } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + if *payload.Qty != item.SubQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah") } } } @@ -827,49 +788,32 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } } if action == entity.ApprovalActionApproved { - itemIDs := make([]uint, 0, len(purchase.Items)) itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { if purchase.Items[i].Id == 0 { continue } - itemIDs = append(itemIDs, purchase.Items[i].Id) itemByID[purchase.Items[i].Id] = purchase.Items[i] } - if len(itemIDs) > 0 { - var usedIDs []uint - if err := s.PurchaseRepo.DB().WithContext(ctx). - Model(&entity.StockAllocation{}). - Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Pluck("stockable_id", &usedIDs).Error; err != nil { - return nil, err - } - if len(usedIDs) > 0 { - usedSet := make(map[uint]struct{}, len(usedIDs)) - for _, id := range usedIDs { - usedSet[id] = struct{}{} + lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items) + if err != nil { + return nil, err + } + if len(lockedIDs) > 0 { + for _, payload := range req.Items { + if _, used := lockedIDs[payload.PurchaseItemID]; !used { + continue } - for _, payload := range req.Items { - if _, used := usedSet[payload.PurchaseItemID]; !used { - continue - } - item, ok := itemByID[payload.PurchaseItemID] - if !ok { - continue - } - receivedQty := item.SubQty - if payload.ReceivedQty != nil { - receivedQty = *payload.ReceivedQty - } - if receivedQty != item.TotalQty { - return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah") - } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + receivedQty := item.SubQty + if payload.ReceivedQty != nil { + receivedQty = *payload.ReceivedQty + } + if receivedQty != item.TotalQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah") } } } @@ -1437,28 +1381,12 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - itemIDs := make([]uint, 0, len(itemsToDelete)) - for _, item := range itemsToDelete { - if item.Id == 0 { - continue - } - itemIDs = append(itemIDs, item.Id) + lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, tx, itemsToDelete) + if err != nil { + return err } - if len(itemIDs) > 0 { - var count int64 - if err := tx.Model(&entity.StockAllocation{}). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Count(&count).Error; err != nil { - return err - } - if count > 0 { - return utils.BadRequest("Purchase already chickin, failed to delete purchase") - } + if len(lockedIDs) > 0 { + return utils.BadRequest("Purchase already chickin, failed to delete purchase") } if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { @@ -1957,6 +1885,67 @@ func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase } } +func collectPurchaseItemIDs(items []entity.PurchaseItem) []uint { + itemIDs := make([]uint, 0, len(items)) + for i := range items { + if items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, items[i].Id) + } + return itemIDs +} + +func (s *purchaseService) resolveChickinLockedItemIDs(ctx context.Context, db *gorm.DB, items []entity.PurchaseItem) (map[uint]struct{}, error) { + itemIDs := collectPurchaseItemIDs(items) + return s.resolveChickinLockedItemIDsByItemID(ctx, db, itemIDs) +} + +func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Context, db *gorm.DB, itemIDs []uint) (map[uint]struct{}, error) { + locked := make(map[uint]struct{}) + if len(itemIDs) == 0 { + return locked, nil + } + if db == nil { + return nil, errors.New("database is required") + } + + var allocationLockedIDs []uint + if err := db.WithContext(ctx). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + ). + Pluck("stockable_id", &allocationLockedIDs).Error; err != nil { + return nil, err + } + for _, itemID := range allocationLockedIDs { + locked[itemID] = struct{}{} + } + + var conversionLockedIDs []uint + if err := db.WithContext(ctx). + Table("purchase_items pi"). + Distinct("pi.id"). + Joins("JOIN project_chickins pc ON pc.product_warehouse_id = pi.product_warehouse_id AND pc.deleted_at IS NULL"). + Joins("JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id AND pfp.deleted_at IS NULL"). + Where("pi.id IN ?", itemIDs). + Where("pi.project_flock_kandang_id IS NOT NULL"). + Where("pc.project_flock_kandang_id = pi.project_flock_kandang_id"). + Pluck("pi.id", &conversionLockedIDs).Error; err != nil { + return nil, err + } + for _, itemID := range conversionLockedIDs { + locked[itemID] = struct{}{} + } + + return locked, nil +} + func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { seen := make(map[uint]struct{}) ids := make([]uint, 0) From f6e25be76be783c9ba480e4b2fd028f7ad0c4382 Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Tue, 3 Mar 2026 10:36:48 +0700 Subject: [PATCH 4/4] fix: chickin include stock allocation, fix calculation hpp --- cmd/delete-adjustments/main.go | 6 +- cmd/reflow-adjustments/main.go | 1 + cmd/reflow-project-flock-kandang/main.go | 186 +++++++++++++- cmd/validate-chickin-trace/main.go | 122 ++++++++++ .../repository/common.hpp.repository.go | 22 +- .../common.stock_allocation.repository.go | 4 +- .../common/service/common.fifo.service.go | 31 +-- .../common/service/fifo_stock_v2/allocate.go | 4 +- .../common/service/fifo_stock_v2/gather.go | 12 +- .../common/service/fifo_stock_v2/service.go | 14 +- .../common/service/fifo_stock_v2/types.go | 2 + ...tion_purpose_to_stock_allocations.down.sql | 13 + ...cation_purpose_to_stock_allocations.up.sql | 33 +++ internal/entities/stock_allocation.go | 6 +- .../repositories/closing.repository.go | 9 +- .../chickins/services/chickin.service.go | 228 +++++++++++++++++- .../repositories/recording.repository.go | 18 +- .../purchases/services/purchase.service.go | 3 +- .../hpp_per_kandang.repository.go | 10 +- 19 files changed, 665 insertions(+), 59 deletions(-) create mode 100644 cmd/validate-chickin-trace/main.go create mode 100644 internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql create mode 100644 internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql diff --git a/cmd/delete-adjustments/main.go b/cmd/delete-adjustments/main.go index 6555749b..4f01d0a2 100644 --- a/cmd/delete-adjustments/main.go +++ b/cmd/delete-adjustments/main.go @@ -366,6 +366,7 @@ func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType s Table("stock_allocations"). Where("usable_type = ? AND usable_id = ?", usableType, usableID). Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). Count(&count).Error return count, err } @@ -376,19 +377,20 @@ func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockable Table("stock_allocations"). Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). Count(&count).Error return count, err } func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error { return tx.WithContext(ctx). - Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ?", usableType, usableID). + Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume). Error } func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error { return tx.WithContext(ctx). - Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume). Error } diff --git a/cmd/reflow-adjustments/main.go b/cmd/reflow-adjustments/main.go index 9e2a351b..fe7cd54d 100644 --- a/cmd/reflow-adjustments/main.go +++ b/cmd/reflow-adjustments/main.go @@ -324,6 +324,7 @@ func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, Table("stock_allocations"). Where("usable_type = ? AND usable_id = ?", usableType, usableID). Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). Count(&count).Error if err != nil { return 0, err diff --git a/cmd/reflow-project-flock-kandang/main.go b/cmd/reflow-project-flock-kandang/main.go index 973026a9..45ca5621 100644 --- a/cmd/reflow-project-flock-kandang/main.go +++ b/cmd/reflow-project-flock-kandang/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log" + "math" "os" "sort" "strings" @@ -14,6 +15,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/database" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -181,6 +183,8 @@ func main() { orphanPopulationRows := int64(0) syncedPopulationQtyRows := int64(0) syncedPopulationUsedRows := int64(0) + traceReleasedRows := int64(0) + traceInsertedRows := int64(0) if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil { fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err) failedApply++ @@ -196,9 +200,22 @@ func main() { ) } + if released, inserted, err := resyncChickinTraceByProjectFlockKandang(ctx, db, fifoStockV2Svc, projectFlockKandangID); err != nil { + fmt.Printf("FAIL chickin_trace_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err) + failedApply++ + } else { + traceReleasedRows = released + traceInsertedRows = inserted + fmt.Printf( + "SYNC chickin_trace released=%d inserted=%d\n", + traceReleasedRows, + traceInsertedRows, + ) + } + fmt.Println() fmt.Printf( - "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d\n", + "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d trace_released=%d trace_inserted=%d\n", len(targets), skippedPW, failedResolve, @@ -207,6 +224,8 @@ func main() { orphanPopulationRows, syncedPopulationQtyRows, syncedPopulationUsedRows, + traceReleasedRows, + traceInsertedRows, ) if failedResolve > 0 || failedApply > 0 { os.Exit(1) @@ -448,6 +467,7 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock FROM stock_allocations sa WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' GROUP BY sa.stockable_id ) UPDATE project_flock_populations p @@ -463,3 +483,167 @@ func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlock return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil } + +func resyncChickinTraceByProjectFlockKandang( + ctx context.Context, + db *gorm.DB, + fifoStockV2Svc commonSvc.FifoStockV2Service, + projectFlockKandangID uint, +) (int64, int64, error) { + if projectFlockKandangID == 0 { + return 0, 0, nil + } + + var productWarehouseIDs []uint + if err := db.WithContext(ctx). + Table("project_chickins"). + Distinct("product_warehouse_id"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("deleted_at IS NULL"). + Order("product_warehouse_id ASC"). + Pluck("product_warehouse_id", &productWarehouseIDs).Error; err != nil { + return 0, 0, err + } + if len(productWarehouseIDs) == 0 { + return 0, 0, nil + } + + totalReleased := int64(0) + totalInserted := int64(0) + + for _, productWarehouseID := range productWarehouseIDs { + var releasedRows int64 + var insertedRows int64 + + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if len(flagGroups) == 0 { + return nil + } + flagGroupCode := strings.TrimSpace(flagGroups[0]) + if flagGroupCode == "" { + return nil + } + + released := tx.WithContext(ctx). + Table("stock_allocations"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Where("status = ?", entity.StockAllocationStatusActive). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": time.Now(), + "updated_at": time.Now(), + "note": "chickin_trace_reflow_reset", + }) + if released.Error != nil { + return released.Error + } + releasedRows = released.RowsAffected + + type chickinRow struct { + ID uint `gorm:"column:id"` + UsageQty float64 `gorm:"column:usage_qty"` + ChickIn time.Time `gorm:"column:chick_in_date"` + } + chickins := make([]chickinRow, 0) + if err := tx.WithContext(ctx). + Table("project_chickins"). + Select("id, usage_qty, chick_in_date"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("deleted_at IS NULL"). + Where("usage_qty > 0"). + Order("chick_in_date ASC, id ASC"). + Scan(&chickins).Error; err != nil { + return err + } + if len(chickins) == 0 { + return nil + } + + gatherRows, err := fifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: "STOCKABLE", + AllocationPurpose: entity.StockAllocationPurposeTraceChickin, + IgnoreSourceUsed: true, + ProductWarehouseID: productWarehouseID, + Limit: 50000, + Tx: tx, + }) + if err != nil { + return err + } + if len(gatherRows) == 0 { + return nil + } + + type lotKey struct { + StockableType string + StockableID uint + } + remainingByLot := make(map[lotKey]float64, len(gatherRows)) + for _, row := range gatherRows { + key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID} + remainingByLot[key] = row.AvailableQuantity + } + + now := time.Now() + lotIndex := 0 + for _, chickinRow := range chickins { + remaining := chickinRow.UsageQty + for remaining > 1e-6 && lotIndex < len(gatherRows) { + lot := gatherRows[lotIndex] + key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID} + available := remainingByLot[key] + if available <= 1e-6 { + lotIndex++ + continue + } + + portion := math.Min(remaining, available) + if portion <= 1e-6 { + lotIndex++ + continue + } + + insert := map[string]any{ + "product_warehouse_id": productWarehouseID, + "stockable_type": lot.Ref.LegacyTypeKey, + "stockable_id": lot.Ref.ID, + "usable_type": fifo.UsableKeyProjectChickin.String(), + "usable_id": chickinRow.ID, + "qty": portion, + "status": entity.StockAllocationStatusActive, + "allocation_purpose": entity.StockAllocationPurposeTraceChickin, + "engine_version": "v2", + "flag_group_code": flagGroupCode, + "function_code": "CHICKIN_TRACE", + "created_at": now, + "updated_at": now, + } + if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil { + return err + } + + insertedRows++ + remaining -= portion + remainingByLot[key] = available - portion + } + } + + return nil + }) + if err != nil { + return totalReleased, totalInserted, err + } + + totalReleased += releasedRows + totalInserted += insertedRows + } + + return totalReleased, totalInserted, nil +} diff --git a/cmd/validate-chickin-trace/main.go b/cmd/validate-chickin-trace/main.go new file mode 100644 index 00000000..81d04d63 --- /dev/null +++ b/cmd/validate-chickin-trace/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "math" + "os" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type mismatchRow struct { + ChickinID uint `gorm:"column:chickin_id"` + ProjectFlockKandang uint `gorm:"column:project_flock_kandang_id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + UsageQty float64 `gorm:"column:usage_qty"` + TraceQty float64 `gorm:"column:trace_qty"` +} + +func main() { + var projectFlockKandangID uint + flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Optional project flock kandang scope") + flag.Parse() + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + rows, err := loadTraceMismatches(ctx, db, projectFlockKandangID) + if err != nil { + log.Fatalf("failed to load trace mismatches: %v", err) + } + + activeConsumeRows, err := countActiveConsumeProjectChickin(ctx, db, projectFlockKandangID) + if err != nil { + log.Fatalf("failed to count active consume rows: %v", err) + } + + fmt.Printf("Scope project_flock_kandang_id=%d\n", projectFlockKandangID) + fmt.Printf("Mismatched chickin trace rows: %d\n", len(rows)) + fmt.Printf("Active PROJECT_CHICKIN consume rows: %d\n", activeConsumeRows) + + if len(rows) > 0 { + for _, row := range rows { + fmt.Printf( + "MISMATCH chickin_id=%d pfk=%d pw=%d usage=%.3f trace=%.3f diff=%.3f\n", + row.ChickinID, + row.ProjectFlockKandang, + row.ProductWarehouseID, + row.UsageQty, + row.TraceQty, + row.TraceQty-row.UsageQty, + ) + } + } + + if len(rows) > 0 || activeConsumeRows > 0 { + os.Exit(1) + } +} + +func loadTraceMismatches(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) ([]mismatchRow, error) { + query := db.WithContext(ctx). + Table("project_chickins pc"). + Select(` + pc.id AS chickin_id, + pc.project_flock_kandang_id, + pc.product_warehouse_id, + COALESCE(pc.usage_qty, 0) AS usage_qty, + COALESCE(SUM(sa.qty), 0) AS trace_qty + `). + Joins(` + LEFT JOIN stock_allocations sa + ON sa.usable_type = ? + AND sa.usable_id = pc.id + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'TRACE_CHICKIN' + `, fifo.UsableKeyProjectChickin.String()). + Where("pc.deleted_at IS NULL"). + Where("COALESCE(pc.usage_qty,0) > 0"). + Group("pc.id, pc.project_flock_kandang_id, pc.product_warehouse_id, pc.usage_qty") + + if projectFlockKandangID > 0 { + query = query.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID) + } + + rows := make([]mismatchRow, 0) + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + out := make([]mismatchRow, 0, len(rows)) + for _, row := range rows { + if math.Abs(row.TraceQty-row.UsageQty) > 1e-3 { + out = append(out, row) + } + } + return out, nil +} + +func countActiveConsumeProjectChickin(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, error) { + q := db.WithContext(ctx). + Table("stock_allocations sa"). + Joins("JOIN project_chickins pc ON pc.id = sa.usable_id"). + Where("sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("sa.status = 'ACTIVE'"). + Where("sa.allocation_purpose = 'CONSUME'") + + if projectFlockKandangID > 0 { + q = q.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID) + } + + var count int64 + if err := q.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index d1dc51f0..260e78de 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -51,8 +51,8 @@ func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangI var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). - Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Scan(&total).Error @@ -103,11 +103,11 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). @@ -136,10 +136,10 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). @@ -175,15 +175,15 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select(` - COALESCE(SUM(pc.usage_qty * CASE + COALESCE(SUM(sa.qty * CASE WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) ELSE 0 END), 0)`, stockablePurchase, stockableTransferIn). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). - Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase). + Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id"). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). Scan(&total).Error @@ -245,9 +245,11 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI `). Joins("JOIN recording_eggs re ON re.recording_id = r.id"). Joins( - "JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?", + "JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.StockableKeyRecordingEgg.String(), fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, ). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go index 466fbe4a..08ca3236 100644 --- a/internal/common/repository/common.stock_allocation.repository.go +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable( var allocations []entity.StockAllocation q := r.DB().WithContext(ctx). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume) if modifier != nil { q = modifier(q) @@ -70,7 +70,7 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable( q := baseDB.WithContext(ctx). Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume) return q.Updates(updates).Error } diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 100c8fcc..dafefbdd 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -528,6 +528,7 @@ func (s *fifoService) allocateFromStock( UsableType: usableKey.String(), UsableId: usableID, Qty: portion, + AllocationPurpose: entities.StockAllocationPurposeConsume, Status: entities.StockAllocationStatusActive, }) @@ -890,22 +891,22 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p query = query.Order(order) } - if err := query.Find(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - if row.Pending <= 0 { - continue + if err := query.Find(&rows).Error; err != nil { + return nil, err } - candidates = append(candidates, pendingCandidate{ - UsableKey: key, - Config: cfg, - UsableID: row.ID, - Pending: row.Pending, - CreatedAt: time.Unix(0, row.CreatedAt), - }) - } - } else { + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: time.Unix(0, row.CreatedAt), + }) + } + } else { var rows []struct { ID uint Pending float64 `gorm:"column:pending_qty"` diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go index a7bfe3d7..02f1815e 100644 --- a/internal/common/service/fifo_stock_v2/allocate.go +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -157,6 +157,7 @@ func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, "usable_id": req.Usable.ID, "qty": portion, "status": activeAllocationStatus(), + "allocation_purpose": defaultAllocationPurpose(), "created_at": now, "updated_at": now, "engine_version": "v2", @@ -591,7 +592,7 @@ func (s *fifoStockV2Service) loadActiveAllocations( ) ([]allocationRow, error) { query := tx.Table("stock_allocations"). Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at"). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, activeAllocationStatus()) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose()) if productWarehouseID > 0 { query = query.Where("product_warehouse_id = ?", productWarehouseID) } @@ -690,6 +691,7 @@ func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *g Select("flag_group_code"). Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID). Where("engine_version = 'v2'"). + Where("allocation_purpose = ?", defaultAllocationPurpose()). Where("flag_group_code IS NOT NULL AND flag_group_code <> ''"). Order("id DESC"). Limit(1). diff --git a/internal/common/service/fifo_stock_v2/gather.go b/internal/common/service/fifo_stock_v2/gather.go index a1f4c4ae..f3733d31 100644 --- a/internal/common/service/fifo_stock_v2/gather.go +++ b/internal/common/service/fifo_stock_v2/gather.go @@ -48,6 +48,8 @@ func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]G } func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) { + req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose) + rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane) if err != nil { return nil, err @@ -155,7 +157,7 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule whereExtraArgs := make([]any, 0, 1) if req.Lane == LaneStockable { - if rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" { + if !req.IgnoreSourceUsed && rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" { usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol) usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol) } else { @@ -167,13 +169,13 @@ func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule // We split the args because the WHERE placeholder order appears // after product/flag filter placeholders in the final SQL. usedExpr = fmt.Sprintf( - "(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s')", + "(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s' AND sa.allocation_purpose = ?)", sourceIDCol, activeAllocationStatus(), ) - extraArgs = append(extraArgs, rule.LegacyTypeKey) - extraArgs = append(extraArgs, rule.LegacyTypeKey) - whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey) + extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose) + extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose) + whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose) } availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr) } else { diff --git a/internal/common/service/fifo_stock_v2/service.go b/internal/common/service/fifo_stock_v2/service.go index 25578d30..0642b31c 100644 --- a/internal/common/service/fifo_stock_v2/service.go +++ b/internal/common/service/fifo_stock_v2/service.go @@ -238,7 +238,7 @@ func nearlyZero(v float64) bool { } func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error { - checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key"} + checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key", "allocation_purpose"} for _, col := range checkCols { var count int64 err := tx.Raw(` @@ -263,3 +263,15 @@ func activeAllocationStatus() string { func releasedAllocationStatus() string { return entity.StockAllocationStatusReleased } + +func defaultAllocationPurpose() string { + return entity.StockAllocationPurposeConsume +} + +func normalizeAllocationPurpose(purpose string) string { + purpose = strings.TrimSpace(strings.ToUpper(purpose)) + if purpose == "" { + return defaultAllocationPurpose() + } + return purpose +} diff --git a/internal/common/service/fifo_stock_v2/types.go b/internal/common/service/fifo_stock_v2/types.go index 701274c4..abb7cc5b 100644 --- a/internal/common/service/fifo_stock_v2/types.go +++ b/internal/common/service/fifo_stock_v2/types.go @@ -33,6 +33,8 @@ type Ref struct { type GatherRequest struct { FlagGroupCode string Lane Lane + AllocationPurpose string + IgnoreSourceUsed bool ProductWarehouseID uint From *time.Time AsOf *time.Time diff --git a/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql new file mode 100644 index 00000000..b6eb5a76 --- /dev/null +++ b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql @@ -0,0 +1,13 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_stock_allocations_purpose_stockable_active; +DROP INDEX IF EXISTS idx_stock_allocations_purpose_usable_active; +DROP INDEX IF EXISTS idx_stock_allocations_purpose_status; + +ALTER TABLE stock_allocations + DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check; + +ALTER TABLE stock_allocations + DROP COLUMN IF EXISTS allocation_purpose; + +COMMIT; diff --git a/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql new file mode 100644 index 00000000..3b63e37b --- /dev/null +++ b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql @@ -0,0 +1,33 @@ +BEGIN; + +ALTER TABLE stock_allocations + ADD COLUMN IF NOT EXISTS allocation_purpose VARCHAR(32); + +UPDATE stock_allocations +SET allocation_purpose = 'CONSUME' +WHERE allocation_purpose IS NULL + OR BTRIM(allocation_purpose) = ''; + +ALTER TABLE stock_allocations + ALTER COLUMN allocation_purpose SET DEFAULT 'CONSUME', + ALTER COLUMN allocation_purpose SET NOT NULL; + +ALTER TABLE stock_allocations + DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check; + +ALTER TABLE stock_allocations + ADD CONSTRAINT stock_allocations_allocation_purpose_check + CHECK (allocation_purpose IN ('CONSUME', 'TRACE_CHICKIN')); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_status + ON stock_allocations (allocation_purpose, status); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_usable_active + ON stock_allocations (allocation_purpose, usable_type, usable_id) + WHERE status = 'ACTIVE'; + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_stockable_active + ON stock_allocations (allocation_purpose, stockable_type, stockable_id) + WHERE status = 'ACTIVE'; + +COMMIT; diff --git a/internal/entities/stock_allocation.go b/internal/entities/stock_allocation.go index 614762a1..c3aa1c28 100644 --- a/internal/entities/stock_allocation.go +++ b/internal/entities/stock_allocation.go @@ -10,6 +10,9 @@ const ( StockAllocationStatusPending = "PENDING" StockAllocationStatusActive = "ACTIVE" StockAllocationStatusReleased = "RELEASED" + + StockAllocationPurposeConsume = "CONSUME" + StockAllocationPurposeTraceChickin = "TRACE_CHICKIN" ) // StockAllocation links a usable record (consumption) with an incoming stock record. @@ -22,7 +25,8 @@ type StockAllocation struct { UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"` UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"` Qty float64 `gorm:"type:numeric(15,3);not null"` - Status string `gorm:"size:20;not null;default:ACTIVE"` + AllocationPurpose string `gorm:"size:32;not null;default:CONSUME;index:stock_allocations_purpose_status,priority:1"` + Status string `gorm:"size:20;not null;default:ACTIVE;index:stock_allocations_purpose_status,priority:2"` Note *string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index ecd96b0a..b475dab0 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1031,6 +1031,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` @@ -1236,6 +1237,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). @@ -1327,6 +1329,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). @@ -1358,6 +1361,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). @@ -1393,6 +1397,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") @@ -1419,9 +1424,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?", + Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyMarketingDelivery.String(), entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, ). Where("mdp.usage_qty > 0"). Where("sa.id IS NULL"). @@ -1481,6 +1487,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id"). Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 3a54f3ba..a198b51a 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "math" "strings" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -19,6 +21,7 @@ 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" @@ -350,7 +353,18 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } - return s.GetOne(c, id) + updated, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + + if updated.UsageQty > 0 { + if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync chickin stock trace") + } + } + + return updated, nil } func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { @@ -368,15 +382,31 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } - if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + chickinRepoTx := repository.NewChickinRepository(tx) + + if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil { + return err + } + } + + if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } return err } - } - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, chickin.ProductWarehouseId); err != nil { + return err + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr } return err } @@ -439,6 +469,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + touchedProductWarehouseIDs := make(map[uint]struct{}) for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( @@ -492,6 +523,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } chickin.UsageQty = approvedQty chickin.PendingUsageQty = 0 + touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { @@ -555,6 +587,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } + touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -564,6 +597,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } } } + + for productWarehouseID := range touchedProductWarehouseIDs { + if err := s.syncChickinTraceForProductWarehouse(c.Context(), dbTransaction, productWarehouseID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync chickin trace for product warehouse %d", productWarehouseID)) + } + } + return nil }) @@ -678,6 +718,180 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, return nil } +func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error { + if productWarehouseID == 0 { + return nil + } + if s.FifoStockV2Svc == nil { + return nil + } + + if tx == nil { + return s.Repository.DB().WithContext(ctx).Transaction(func(innerTx *gorm.DB) error { + return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID) + }) + } + + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return nil + } + + now := time.Now() + if err := tx.WithContext(ctx). + Table("stock_allocations"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Where("status = ?", entity.StockAllocationStatusActive). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "updated_at": now, + "note": "chickin_trace_reflow_reset", + }).Error; err != nil { + return err + } + + type chickinTraceRow struct { + ID uint `gorm:"column:id"` + UsageQty float64 `gorm:"column:usage_qty"` + ChickIn time.Time `gorm:"column:chick_in_date"` + } + chickins := make([]chickinTraceRow, 0) + if err := tx.WithContext(ctx). + Table("project_chickins"). + Select("id, usage_qty, chick_in_date"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("deleted_at IS NULL"). + Where("usage_qty > 0"). + Order("chick_in_date ASC, id ASC"). + Scan(&chickins).Error; err != nil { + return err + } + if len(chickins) == 0 { + return nil + } + + gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: "STOCKABLE", + AllocationPurpose: entity.StockAllocationPurposeTraceChickin, + IgnoreSourceUsed: true, + ProductWarehouseID: productWarehouseID, + Limit: 50000, + Tx: tx, + }) + if err != nil { + return err + } + if len(gatherRows) == 0 { + return nil + } + + type lotKey struct { + StockableType string + StockableID uint + } + remainingByLot := make(map[lotKey]float64, len(gatherRows)) + for _, row := range gatherRows { + key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID} + remainingByLot[key] = row.AvailableQuantity + } + + lotIndex := 0 + traceNow := time.Now() + for _, chickin := range chickins { + remaining := chickin.UsageQty + for remaining > 1e-6 && lotIndex < len(gatherRows) { + lot := gatherRows[lotIndex] + key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID} + available := remainingByLot[key] + if available <= 1e-6 { + lotIndex++ + continue + } + + portion := math.Min(remaining, available) + if portion <= 1e-6 { + lotIndex++ + continue + } + + insert := map[string]any{ + "product_warehouse_id": productWarehouseID, + "stockable_type": lot.Ref.LegacyTypeKey, + "stockable_id": lot.Ref.ID, + "usable_type": fifo.UsableKeyProjectChickin.String(), + "usable_id": chickin.ID, + "qty": portion, + "status": entity.StockAllocationStatusActive, + "allocation_purpose": entity.StockAllocationPurposeTraceChickin, + "engine_version": "v2", + "flag_group_code": flagGroupCode, + "function_code": "CHICKIN_TRACE", + "created_at": traceNow, + "updated_at": traceNow, + } + if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil { + return err + } + + remaining -= portion + remainingByLot[key] = available - portion + } + + if remaining > 1e-6 { + s.Log.Warnf( + "chickin trace partial allocation for product_warehouse_id=%d chickin_id=%d: remaining=%.3f", + productWarehouseID, + chickin.ID, + remaining, + ) + } + } + + return nil +} + +func (s *chickinService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + 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 = 'STOCKABLE'"). + 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("fg.priority ASC, rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + return selected.FlagGroupCode, nil +} + func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error { if projectFlockKandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 9d4791b6..3010eca1 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -894,6 +894,7 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. FROM stock_allocations WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' AND status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' GROUP BY stockable_id ) a WHERE p.id = a.stockable_id @@ -904,14 +905,15 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. UPDATE project_flock_populations p SET total_used_qty = 0 WHERE p.id IN (` + idsSubquery + `) - AND NOT EXISTS ( - SELECT 1 - FROM stock_allocations sa - WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' - AND sa.status = 'ACTIVE' - AND sa.stockable_id = p.id - ) - ` + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.stockable_id = p.id + ) + ` db := r.DB().WithContext(ctx) if tx != nil { diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 313c4b7f..48f31bf0 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -1914,11 +1914,12 @@ func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Contex if err := db.WithContext(ctx). Model(&entity.StockAllocation{}). Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ? AND allocation_purpose = ?", fifo.StockableKeyPurchaseItems.String(), itemIDs, fifo.UsableKeyProjectChickin.String(), []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + entity.StockAllocationPurposeConsume, ). Pluck("stockable_id", &allocationLockedIDs).Error; err != nil { return nil, err diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index e13d3f17..7655fcdb 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -147,15 +147,16 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Table("project_chickins AS pc"). Select(` pfk.id AS project_flock_kandang_id, - COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, - COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, + COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS doc_cost, + COALESCE(SUM(sa.qty), 0) AS doc_qty, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias`). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). @@ -221,13 +222,14 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Table("recordings AS r"). Select(` r.project_flock_kandangs_id AS project_flock_kandang_id, + COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias`). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").