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 {