diff --git a/cmd/delete-adjustments/main.go b/cmd/delete-adjustments/main.go new file mode 100644 index 00000000..4f01d0a2 --- /dev/null +++ b/cmd/delete-adjustments/main.go @@ -0,0 +1,407 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "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" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type adjustmentRow struct { + ID uint `gorm:"column:id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + ProductID uint `gorm:"column:product_id"` + FunctionCode string `gorm:"column:function_code"` + TotalQty float64 `gorm:"column:total_qty"` + UsageQty float64 `gorm:"column:usage_qty"` + PendingQty float64 `gorm:"column:pending_qty"` + StockLogIncrease float64 `gorm:"column:stock_log_increase"` + StockLogDecrease float64 `gorm:"column:stock_log_decrease"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +type routeResolution struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + Lane string `gorm:"column:lane"` + FunctionCode string `gorm:"column:function_code"` +} + +func main() { + var ( + idsRaw string + apply bool + ) + + flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2") + flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run") + flag.Parse() + + ids, err := parseIDs(idsRaw) + if err != nil { + log.Fatalf("invalid --ids: %v", err) + } + if len(ids) == 0 { + log.Fatal("--ids is required") + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil) + + adjustments, err := loadAdjustments(ctx, db, ids) + if err != nil { + log.Fatalf("failed to load adjustments: %v", err) + } + if len(adjustments) == 0 { + log.Fatal("no adjustments found for provided IDs") + } + + sort.Slice(adjustments, func(i, j int) bool { + return adjustments[i].ID < adjustments[j].ID + }) + + fmt.Printf("Mode: %s\n", modeLabel(apply)) + fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments)) + + success := 0 + failed := 0 + skipped := 0 + + for _, adj := range adjustments { + if strings.TrimSpace(adj.FunctionCode) == "" { + fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID) + skipped++ + continue + } + + route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode))) + if err != nil { + fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err) + failed++ + continue + } + + switch route.Lane { + case "USABLE": + desiredQty := adj.UsageQty + adj.PendingQty + if desiredQty <= 0 && adj.StockLogDecrease > 0 { + desiredQty = adj.StockLogDecrease + } + + activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID) + if err != nil { + fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err) + failed++ + continue + } + + fmt.Printf( + "PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n", + adj.ID, + route.FunctionCode, + adj.UsageQty, + adj.PendingQty, + activeAlloc, + ) + + if !apply { + skipped++ + continue + } + + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + reflowReq := commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: route.FlagGroupCode, + ProductWarehouseID: adj.ProductWarehouseID, + AsOf: &adj.CreatedAt, + IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()), + Tx: tx, + } + if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil { + return fmt.Errorf("reflow usable to zero: %w", err) + } + + if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil { + return err + } + if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil { + return err + } + if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil { + return err + } + return nil + }) + if err != nil { + fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err) + failed++ + continue + } + + fmt.Printf("DONE adj=%d deleted\n", adj.ID) + success++ + + case "STOCKABLE": + removeQty := adj.TotalQty + if removeQty <= 0 && adj.StockLogIncrease > 0 { + removeQty = adj.StockLogIncrease + } + + activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID) + if err != nil { + fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err) + failed++ + continue + } + if activeAlloc > 0 { + fmt.Printf( + "FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n", + adj.ID, + activeAlloc, + ) + failed++ + continue + } + + fmt.Printf( + "PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n", + adj.ID, + route.FunctionCode, + adj.TotalQty, + removeQty, + ) + + if !apply { + skipped++ + continue + } + + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx). + Table("adjustment_stocks"). + Where("id = ?", adj.ID). + Updates(map[string]any{ + "total_qty": 0, + "total_used": 0, + }).Error; err != nil { + return fmt.Errorf("set stockable qty to zero: %w", err) + } + + reflowReq := commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: route.FlagGroupCode, + ProductWarehouseID: adj.ProductWarehouseID, + AsOf: &adj.CreatedAt, + IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()), + Tx: tx, + } + if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil { + return fmt.Errorf("reflow stockable to zero: %w", err) + } + + if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil { + return err + } + if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil { + return err + } + if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil { + return err + } + return nil + }) + if err != nil { + fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err) + failed++ + continue + } + + fmt.Printf("DONE adj=%d deleted\n", adj.ID) + success++ + + default: + fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane) + skipped++ + } + } + + fmt.Println() + fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped) + if failed > 0 { + os.Exit(1) + } +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func parseIDs(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + parts := strings.Split(raw, ",") + out := make([]uint, 0, len(parts)) + seen := map[uint]struct{}{} + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + v, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid id %q", part) + } + if v == 0 { + return nil, fmt.Errorf("id must be > 0: %q", part) + } + id := uint(v) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out, nil +} + +func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) { + var rows []adjustmentRow + err := db.WithContext(ctx). + Table("adjustment_stocks a"). + Select(` + a.id, + a.product_warehouse_id, + pw.product_id, + a.function_code, + COALESCE(a.total_qty, 0) AS total_qty, + COALESCE(a.usage_qty, 0) AS usage_qty, + COALESCE(a.pending_qty, 0) AS pending_qty, + COALESCE(( + SELECT sl.increase + FROM stock_logs sl + WHERE sl.loggable_type = 'ADJUSTMENT' + AND sl.loggable_id = a.id + ORDER BY sl.id DESC + LIMIT 1 + ), 0) AS stock_log_increase, + COALESCE(( + SELECT sl.decrease + FROM stock_logs sl + WHERE sl.loggable_type = 'ADJUSTMENT' + AND sl.loggable_id = a.id + ORDER BY sl.id DESC + LIMIT 1 + ), 0) AS stock_log_decrease, + a.created_at + `). + Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id"). + Where("a.id IN ?", ids). + Find(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) { + var rows []routeResolution + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code, rr.lane, rr.function_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.function_code = ?", functionCode). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE f.flagable_type = ? + AND f.flagable_id = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, entity.FlagableTypeProduct, productID). + Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC"). + Order("rr.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode) + } + + selected := rows[0] + for _, row := range rows { + if row.Lane != selected.Lane { + return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode) + } + } + selected.FunctionCode = functionCode + return &selected, nil +} + +func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) { + var count int64 + err := db.WithContext(ctx). + Table("stock_allocations"). + Where("usable_type = ? AND usable_id = ?", usableType, usableID). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&count).Error + return count, err +} + +func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) { + var count int64 + err := db.WithContext(ctx). + Table("stock_allocations"). + Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&count).Error + return count, err +} + +func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error { + return tx.WithContext(ctx). + Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume). + Error +} + +func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error { + return tx.WithContext(ctx). + Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume). + Error +} + +func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error { + return tx.WithContext(ctx). + Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID). + Error +} + +func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error { + return tx.WithContext(ctx). + Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID). + Error +} diff --git a/cmd/reflow-adjustments/main.go b/cmd/reflow-adjustments/main.go new file mode 100644 index 00000000..fe7cd54d --- /dev/null +++ b/cmd/reflow-adjustments/main.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "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" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type adjustmentRow struct { + ID uint `gorm:"column:id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + ProductID uint `gorm:"column:product_id"` + FunctionCode string `gorm:"column:function_code"` + UsageQty float64 `gorm:"column:usage_qty"` + PendingQty float64 `gorm:"column:pending_qty"` + StockLogIncrease float64 `gorm:"column:stock_log_increase"` + StockLogDecrease float64 `gorm:"column:stock_log_decrease"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +type routeResolution struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + Lane string `gorm:"column:lane"` + FunctionCode string `gorm:"column:function_code"` + SourceTable string `gorm:"column:source_table"` + LegacyTypeKey string `gorm:"column:legacy_type_key"` +} + +func main() { + var ( + idsRaw string + apply bool + asOfCreatedAt bool + compensateMissingAlloc bool + ) + + flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2") + flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run") + flag.BoolVar(&asOfCreatedAt, "as-of-created-at", true, "Use adjustment created_at as reflow AsOf boundary") + flag.BoolVar(&compensateMissingAlloc, "compensate-missing-alloc", true, "When active allocations are missing and usage_qty > 0, temporarily add back usage_qty before reflow") + flag.Parse() + + ids, err := parseIDs(idsRaw) + if err != nil { + log.Fatalf("invalid --ids: %v", err) + } + if len(ids) == 0 { + log.Fatal("--ids is required") + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil) + + adjustments, err := loadAdjustments(ctx, db, ids) + if err != nil { + log.Fatalf("failed to load adjustments: %v", err) + } + if len(adjustments) == 0 { + log.Fatal("no adjustments found for provided IDs") + } + + sort.Slice(adjustments, func(i, j int) bool { + return adjustments[i].ID < adjustments[j].ID + }) + + fmt.Printf("Mode: %s\n", modeLabel(apply)) + fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments)) + + success := 0 + failed := 0 + skipped := 0 + + for _, adj := range adjustments { + if strings.TrimSpace(adj.FunctionCode) == "" { + fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID) + skipped++ + continue + } + + route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode))) + if err != nil { + fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err) + failed++ + continue + } + if route.Lane != "USABLE" { + fmt.Printf("SKIP adj=%d reason=lane=%s (not USABLE)\n", adj.ID, route.Lane) + skipped++ + continue + } + + desiredQty := adj.UsageQty + adj.PendingQty + desiredQtySource := "usage+pending" + if desiredQty <= 0 && adj.StockLogDecrease > 0 { + desiredQty = adj.StockLogDecrease + desiredQtySource = "stock_log.decrease" + } + if desiredQty <= 0 { + fmt.Printf( + "SKIP adj=%d reason=no usable qty (usage=%.3f pending=%.3f stock_log.decrease=%.3f)\n", + adj.ID, + adj.UsageQty, + adj.PendingQty, + adj.StockLogDecrease, + ) + skipped++ + continue + } + + activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID) + if err != nil { + fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err) + failed++ + continue + } + + compensateQty := adj.UsageQty + if compensateQty <= 0 && desiredQtySource == "stock_log.decrease" { + compensateQty = adj.StockLogDecrease + } + shouldCompensate := compensateMissingAlloc && activeAllocationCount == 0 && compensateQty > 0 + + reflowReq := commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: route.FlagGroupCode, + ProductWarehouseID: adj.ProductWarehouseID, + IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()), + } + if asOfCreatedAt { + asOf := adj.CreatedAt + reflowReq.AsOf = &asOf + } + + fmt.Printf( + "PLAN adj=%d pw=%d product=%d function=%s group=%s desired=%.3f source=%s active_alloc=%d compensate=%t\n", + adj.ID, + adj.ProductWarehouseID, + adj.ProductID, + route.FunctionCode, + route.FlagGroupCode, + desiredQty, + desiredQtySource, + activeAllocationCount, + shouldCompensate, + ) + + if !apply { + skipped++ + continue + } + + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if shouldCompensate { + if err := tx.Table("product_warehouses"). + Where("id = ?", adj.ProductWarehouseID). + Update("qty", gorm.Expr("COALESCE(qty,0) + ?", compensateQty)).Error; err != nil { + return fmt.Errorf("compensate product_warehouse qty: %w", err) + } + } + + reflowReq.Tx = tx + res, err := fifoStockV2Svc.Reflow(ctx, reflowReq) + if err != nil { + return err + } + + fmt.Printf( + "DONE adj=%d rollback=%.3f allocate=%.3f pending=%.3f\n", + adj.ID, + res.Rollback.ReleasedQty, + res.Allocate.AllocatedQty, + res.Allocate.PendingQty, + ) + return nil + }) + if err != nil { + fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err) + failed++ + continue + } + + success++ + } + + fmt.Println() + fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped) + if failed > 0 { + os.Exit(1) + } +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func parseIDs(raw string) ([]uint, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + ids := make([]uint, 0, len(parts)) + seen := map[uint]struct{}{} + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + value, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid id %q", part) + } + if value == 0 { + return nil, fmt.Errorf("id must be > 0: %q", part) + } + id := uint(value) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + + return ids, nil +} + +func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) { + var rows []adjustmentRow + err := db.WithContext(ctx). + Table("adjustment_stocks a"). + Select(` + a.id, + a.product_warehouse_id, + pw.product_id, + a.function_code, + COALESCE(a.usage_qty, 0) AS usage_qty, + COALESCE(a.pending_qty, 0) AS pending_qty, + COALESCE(( + SELECT sl.increase + FROM stock_logs sl + WHERE sl.loggable_type = 'ADJUSTMENT' + AND sl.loggable_id = a.id + ORDER BY sl.id DESC + LIMIT 1 + ), 0) AS stock_log_increase, + COALESCE(( + SELECT sl.decrease + FROM stock_logs sl + WHERE sl.loggable_type = 'ADJUSTMENT' + AND sl.loggable_id = a.id + ORDER BY sl.id DESC + LIMIT 1 + ), 0) AS stock_log_decrease, + a.created_at + `). + Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id"). + Where("a.id IN ?", ids). + Find(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) { + var rows []routeResolution + err := db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.function_code = ?", functionCode). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE f.flagable_type = ? + AND f.flagable_id = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, entity.FlagableTypeProduct, productID). + Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC"). + Order("rr.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode) + } + + selected := rows[0] + for _, row := range rows { + if row.Lane != selected.Lane { + return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode) + } + } + + selected.FunctionCode = functionCode + return &selected, nil +} + +func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) { + var count int64 + err := db.WithContext(ctx). + Table("stock_allocations"). + Where("usable_type = ? AND usable_id = ?", usableType, usableID). + Where("status = ?", entity.StockAllocationStatusActive). + Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Count(&count).Error + if err != nil { + return 0, err + } + return count, nil +} diff --git a/cmd/reflow-project-flock-kandang/main.go b/cmd/reflow-project-flock-kandang/main.go new file mode 100644 index 00000000..e2629bee --- /dev/null +++ b/cmd/reflow-project-flock-kandang/main.go @@ -0,0 +1,648 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "math" + "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" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "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++ + } + + orphanPopulationRows := int64(0) + syncedPopulationQtyRows := int64(0) + syncedPopulationUsedRows := int64(0) + traceReleasedRows := int64(0) + traceInsertedRows := int64(0) + if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil { + fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err) + failedApply++ + } else { + orphanPopulationRows = rowsOrphan + syncedPopulationQtyRows = rowsQty + syncedPopulationUsedRows = rowsUsed + fmt.Printf( + "SYNC project_flock_populations orphan_marked=%d qty_synced=%d used_synced=%d\n", + orphanPopulationRows, + syncedPopulationQtyRows, + syncedPopulationUsedRows, + ) + } + + if released, inserted, err := resyncChickinTraceByProjectFlockKandang(ctx, db, fifoStockV2Svc, projectFlockKandangID); err != nil { + fmt.Printf("FAIL chickin_trace_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err) + failedApply++ + } else { + traceReleasedRows = released + traceInsertedRows = inserted + fmt.Printf( + "SYNC chickin_trace released=%d inserted=%d\n", + traceReleasedRows, + traceInsertedRows, + ) + } + + fmt.Println() + fmt.Printf( + "Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d trace_released=%d trace_inserted=%d\n", + len(targets), + skippedPW, + failedResolve, + successApply, + failedApply, + orphanPopulationRows, + syncedPopulationQtyRows, + syncedPopulationUsedRows, + traceReleasedRows, + traceInsertedRows, + ) + 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 +} + +func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, int64, int64, error) { + if projectFlockKandangID == 0 { + return 0, 0, 0, nil + } + + orphanResult := db.WithContext(ctx).Exec(` + UPDATE project_flock_populations pfp + SET deleted_at = NOW(), + updated_at = NOW() + FROM project_chickins pc + WHERE pfp.project_chickin_id = pc.id + AND pc.project_flock_kandang_id = ? + AND pc.deleted_at IS NOT NULL + AND pfp.deleted_at IS NULL + `, projectFlockKandangID) + if orphanResult.Error != nil { + return 0, 0, 0, orphanResult.Error + } + + qtyResult := db.WithContext(ctx).Exec(` + UPDATE project_flock_populations p + SET total_qty = GREATEST(COALESCE(pc.usage_qty, 0), 0), + updated_at = NOW() + FROM project_chickins pc + WHERE p.project_chickin_id = pc.id + AND pc.project_flock_kandang_id = ? + AND pc.deleted_at IS NULL + AND p.deleted_at IS NULL + `, projectFlockKandangID) + if qtyResult.Error != nil { + return 0, 0, 0, qtyResult.Error + } + + usedResult := db.WithContext(ctx).Exec(` + WITH scoped AS ( + SELECT pfp.id, pfp.total_qty + FROM project_flock_populations pfp + JOIN project_chickins pc ON pc.id = pfp.project_chickin_id + WHERE pc.project_flock_kandang_id = ? + AND pc.deleted_at IS NULL + AND pfp.deleted_at IS NULL + ), + alloc AS ( + SELECT sa.stockable_id, SUM(sa.qty) AS used_qty + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + GROUP BY sa.stockable_id + ) + UPDATE project_flock_populations p + SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)), + updated_at = NOW() + FROM scoped s + LEFT JOIN alloc a ON a.stockable_id = s.id + WHERE p.id = s.id + `, projectFlockKandangID) + if usedResult.Error != nil { + return 0, 0, 0, usedResult.Error + } + + return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil +} +func resyncChickinTraceByProjectFlockKandang( + ctx context.Context, + db *gorm.DB, + fifoStockV2Svc commonSvc.FifoStockV2Service, + projectFlockKandangID uint, +) (int64, int64, error) { + if projectFlockKandangID == 0 { + return 0, 0, nil + } + + var productWarehouseIDs []uint + if err := db.WithContext(ctx). + Table("project_chickins"). + Distinct("product_warehouse_id"). + Where("project_flock_kandang_id = ?", projectFlockKandangID). + Where("deleted_at IS NULL"). + Order("product_warehouse_id ASC"). + Pluck("product_warehouse_id", &productWarehouseIDs).Error; err != nil { + return 0, 0, err + } + if len(productWarehouseIDs) == 0 { + return 0, 0, nil + } + + totalReleased := int64(0) + totalInserted := int64(0) + + for _, productWarehouseID := range productWarehouseIDs { + var releasedRows int64 + var insertedRows int64 + + err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if len(flagGroups) == 0 { + return nil + } + flagGroupCode := strings.TrimSpace(flagGroups[0]) + if flagGroupCode == "" { + return nil + } + + released := tx.WithContext(ctx). + Table("stock_allocations"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Where("status = ?", entity.StockAllocationStatusActive). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": time.Now(), + "updated_at": time.Now(), + "note": "chickin_trace_reflow_reset", + }) + if released.Error != nil { + return released.Error + } + releasedRows = released.RowsAffected + + type chickinRow struct { + ID uint `gorm:"column:id"` + UsageQty float64 `gorm:"column:usage_qty"` + ChickIn time.Time `gorm:"column:chick_in_date"` + } + chickins := make([]chickinRow, 0) + if err := tx.WithContext(ctx). + Table("project_chickins"). + Select("id, usage_qty, chick_in_date"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("deleted_at IS NULL"). + Where("usage_qty > 0"). + Order("chick_in_date ASC, id ASC"). + Scan(&chickins).Error; err != nil { + return err + } + if len(chickins) == 0 { + return nil + } + + gatherRows, err := fifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: "STOCKABLE", + AllocationPurpose: entity.StockAllocationPurposeTraceChickin, + IgnoreSourceUsed: true, + ProductWarehouseID: productWarehouseID, + Limit: 50000, + Tx: tx, + }) + if err != nil { + return err + } + if len(gatherRows) == 0 { + return nil + } + + type lotKey struct { + StockableType string + StockableID uint + } + remainingByLot := make(map[lotKey]float64, len(gatherRows)) + for _, row := range gatherRows { + key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID} + remainingByLot[key] = row.AvailableQuantity + } + + now := time.Now() + lotIndex := 0 + for _, chickinRow := range chickins { + remaining := chickinRow.UsageQty + for remaining > 1e-6 && lotIndex < len(gatherRows) { + lot := gatherRows[lotIndex] + key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID} + available := remainingByLot[key] + if available <= 1e-6 { + lotIndex++ + continue + } + + portion := math.Min(remaining, available) + if portion <= 1e-6 { + lotIndex++ + continue + } + + insert := map[string]any{ + "product_warehouse_id": productWarehouseID, + "stockable_type": lot.Ref.LegacyTypeKey, + "stockable_id": lot.Ref.ID, + "usable_type": fifo.UsableKeyProjectChickin.String(), + "usable_id": chickinRow.ID, + "qty": portion, + "status": entity.StockAllocationStatusActive, + "allocation_purpose": entity.StockAllocationPurposeTraceChickin, + "engine_version": "v2", + "flag_group_code": flagGroupCode, + "function_code": "CHICKIN_TRACE", + "created_at": now, + "updated_at": now, + } + if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil { + return err + } + + insertedRows++ + remaining -= portion + remainingByLot[key] = available - portion + } + } + + return nil + }) + if err != nil { + return totalReleased, totalInserted, err + } + + totalReleased += releasedRows + totalInserted += insertedRows + } + + return totalReleased, totalInserted, nil +} diff --git a/cmd/validate-chickin-trace/main.go b/cmd/validate-chickin-trace/main.go new file mode 100644 index 00000000..81d04d63 --- /dev/null +++ b/cmd/validate-chickin-trace/main.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "math" + "os" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +type mismatchRow struct { + ChickinID uint `gorm:"column:chickin_id"` + ProjectFlockKandang uint `gorm:"column:project_flock_kandang_id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + UsageQty float64 `gorm:"column:usage_qty"` + TraceQty float64 `gorm:"column:trace_qty"` +} + +func main() { + var projectFlockKandangID uint + flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Optional project flock kandang scope") + flag.Parse() + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + rows, err := loadTraceMismatches(ctx, db, projectFlockKandangID) + if err != nil { + log.Fatalf("failed to load trace mismatches: %v", err) + } + + activeConsumeRows, err := countActiveConsumeProjectChickin(ctx, db, projectFlockKandangID) + if err != nil { + log.Fatalf("failed to count active consume rows: %v", err) + } + + fmt.Printf("Scope project_flock_kandang_id=%d\n", projectFlockKandangID) + fmt.Printf("Mismatched chickin trace rows: %d\n", len(rows)) + fmt.Printf("Active PROJECT_CHICKIN consume rows: %d\n", activeConsumeRows) + + if len(rows) > 0 { + for _, row := range rows { + fmt.Printf( + "MISMATCH chickin_id=%d pfk=%d pw=%d usage=%.3f trace=%.3f diff=%.3f\n", + row.ChickinID, + row.ProjectFlockKandang, + row.ProductWarehouseID, + row.UsageQty, + row.TraceQty, + row.TraceQty-row.UsageQty, + ) + } + } + + if len(rows) > 0 || activeConsumeRows > 0 { + os.Exit(1) + } +} + +func loadTraceMismatches(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) ([]mismatchRow, error) { + query := db.WithContext(ctx). + Table("project_chickins pc"). + Select(` + pc.id AS chickin_id, + pc.project_flock_kandang_id, + pc.product_warehouse_id, + COALESCE(pc.usage_qty, 0) AS usage_qty, + COALESCE(SUM(sa.qty), 0) AS trace_qty + `). + Joins(` + LEFT JOIN stock_allocations sa + ON sa.usable_type = ? + AND sa.usable_id = pc.id + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'TRACE_CHICKIN' + `, fifo.UsableKeyProjectChickin.String()). + Where("pc.deleted_at IS NULL"). + Where("COALESCE(pc.usage_qty,0) > 0"). + Group("pc.id, pc.project_flock_kandang_id, pc.product_warehouse_id, pc.usage_qty") + + if projectFlockKandangID > 0 { + query = query.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID) + } + + rows := make([]mismatchRow, 0) + if err := query.Scan(&rows).Error; err != nil { + return nil, err + } + + out := make([]mismatchRow, 0, len(rows)) + for _, row := range rows { + if math.Abs(row.TraceQty-row.UsageQty) > 1e-3 { + out = append(out, row) + } + } + return out, nil +} + +func countActiveConsumeProjectChickin(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, error) { + q := db.WithContext(ctx). + Table("stock_allocations sa"). + Joins("JOIN project_chickins pc ON pc.id = sa.usable_id"). + Where("sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("sa.status = 'ACTIVE'"). + Where("sa.allocation_purpose = 'CONSUME'") + + if projectFlockKandangID > 0 { + q = q.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID) + } + + var count int64 + if err := q.Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/internal/common/repository/common.hpp.repository.go b/internal/common/repository/common.hpp.repository.go index d1dc51f0..bc5037ec 100644 --- a/internal/common/repository/common.hpp.repository.go +++ b/internal/common/repository/common.hpp.repository.go @@ -51,8 +51,8 @@ func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangI var total float64 err := r.db.WithContext(ctx). Table("project_chickins AS pc"). - Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs). Scan(&total).Error @@ -103,11 +103,11 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). @@ -136,10 +136,10 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan var total float64 err := r.db.WithContext(ctx). Table("recordings AS r"). - Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)"). + Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)"). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). Where("r.record_datetime <= ?", *date). @@ -175,15 +175,15 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda err := r.db.WithContext(ctx). Table("project_chickins AS pc"). Select(` - COALESCE(SUM(pc.usage_qty * CASE + COALESCE(SUM(sa.qty * CASE WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0) WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0) ELSE 0 END), 0)`, stockablePurchase, stockableTransferIn). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase). - Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase). + Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id"). Where("pc.project_flock_kandang_id = ?", projectFlockKandangId). Scan(&total).Error @@ -245,9 +245,11 @@ func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangI `). Joins("JOIN recording_eggs re ON re.recording_id = r.id"). Joins( - "JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?", + "JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.StockableKeyRecordingEgg.String(), fifo.UsableKeyMarketingDelivery.String(), + entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, ). Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id"). Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs). @@ -297,6 +299,9 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec Table("laying_transfer_targets AS ltt"). Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty"). Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id"). + Where("lt.deleted_at IS NULL"). + Where("ltt.deleted_at IS NULL"). + Where("lt.executed_at IS NOT NULL"). Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId). Group("lt.from_project_flock_id"). Scan(&summary).Error diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go index 466fbe4a..08ca3236 100644 --- a/internal/common/repository/common.stock_allocation.repository.go +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable( var allocations []entity.StockAllocation q := r.DB().WithContext(ctx). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume) if modifier != nil { q = modifier(q) @@ -70,7 +70,7 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable( q := baseDB.WithContext(ctx). Model(&entity.StockAllocation{}). - Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume) return q.Updates(updates).Error } diff --git a/internal/common/service/common.fifo.service.go b/internal/common/service/common.fifo.service.go index 100c8fcc..dafefbdd 100644 --- a/internal/common/service/common.fifo.service.go +++ b/internal/common/service/common.fifo.service.go @@ -528,6 +528,7 @@ func (s *fifoService) allocateFromStock( UsableType: usableKey.String(), UsableId: usableID, Qty: portion, + AllocationPurpose: entities.StockAllocationPurposeConsume, Status: entities.StockAllocationStatusActive, }) @@ -890,22 +891,22 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p query = query.Order(order) } - if err := query.Find(&rows).Error; err != nil { - return nil, err - } - for _, row := range rows { - if row.Pending <= 0 { - continue + if err := query.Find(&rows).Error; err != nil { + return nil, err } - candidates = append(candidates, pendingCandidate{ - UsableKey: key, - Config: cfg, - UsableID: row.ID, - Pending: row.Pending, - CreatedAt: time.Unix(0, row.CreatedAt), - }) - } - } else { + for _, row := range rows { + if row.Pending <= 0 { + continue + } + candidates = append(candidates, pendingCandidate{ + UsableKey: key, + Config: cfg, + UsableID: row.ID, + Pending: row.Pending, + CreatedAt: time.Unix(0, row.CreatedAt), + }) + } + } else { var rows []struct { ID uint Pending float64 `gorm:"column:pending_qty"` diff --git a/internal/common/service/common.fifo_stock_v2.service.go b/internal/common/service/common.fifo_stock_v2.service.go new file mode 100644 index 00000000..a1b51e9e --- /dev/null +++ b/internal/common/service/common.fifo_stock_v2.service.go @@ -0,0 +1,41 @@ +package service + +import ( + "github.com/sirupsen/logrus" + fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" + "gorm.io/gorm" +) + +type FifoStockV2Service = fifoStockV2.Service + +type FifoStockV2Lane = fifoStockV2.Lane + +type FifoStockV2Ref = fifoStockV2.Ref + +type FifoStockV2GatherRequest = fifoStockV2.GatherRequest + +type FifoStockV2GatherRow = fifoStockV2.GatherRow + +type FifoStockV2AllocateRequest = fifoStockV2.AllocateRequest + +type FifoStockV2AllocateResult = fifoStockV2.AllocateResult + +type FifoStockV2AllocationDetail = fifoStockV2.AllocationDetail + +type FifoStockV2RollbackRequest = fifoStockV2.RollbackRequest + +type FifoStockV2RollbackResult = fifoStockV2.RollbackResult + +type FifoStockV2ReflowRequest = fifoStockV2.ReflowRequest + +type FifoStockV2ReflowResult = fifoStockV2.ReflowResult + +type FifoStockV2RecalculateRequest = fifoStockV2.RecalculateRequest + +type FifoStockV2RecalculateResult = fifoStockV2.RecalculateResult + +type FifoStockV2WarehouseDrift = fifoStockV2.WarehouseDrift + +func NewFifoStockV2Service(db *gorm.DB, logger *logrus.Logger) FifoStockV2Service { + return fifoStockV2.NewService(db, logger) +} diff --git a/internal/common/service/fifo_stock_v2/RFC.md b/internal/common/service/fifo_stock_v2/RFC.md new file mode 100644 index 00000000..3c10ffd7 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/RFC.md @@ -0,0 +1,58 @@ +# RFC Ringkas: FIFO Stock V2 + +## Tujuan +`fifo_stock_v2` adalah engine FIFO baru berbasis konfigurasi `Flag Group + Jalur` yang berjalan paralel dengan v1 tanpa memutus kompatibilitas `stock_allocations`, HPP, dan closing/reporting existing. + +## Prinsip +- V1 tidak dihapus, V2 jalan paralel. +- Semua operasi transactional. +- FIFO sorting deterministic lintas tabel. +- Default over-consume `ALLOW` (pending), exception dapat `BLOCK`. +- Reflow idempotent. +- Recalculate bisa memperbaiki drift `product_warehouses.qty`. + +## Komponen +- `fifo_stock_v2_flag_groups`: master grouping flag produk. +- `fifo_stock_v2_flag_members`: pemetaan flag -> group. +- `fifo_stock_v2_traits`: trait sort per `table:date_column` (+ optional join date source). +- `fifo_stock_v2_route_rules`: rule per `flag_group + lane + function + table`. +- `fifo_stock_v2_overconsume_rules`: policy pending/over-consume. +- `fifo_stock_v2_operation_log`: idempotency + audit operasi. +- `fifo_stock_v2_reflow_runs` + checkpoints + shadow allocations: bulk reflow resumable/observable. + +## API Service +- `Gather`: union cross-table berdasarkan route rules + trait sorting. +- `Allocate`: alokasi lot FIFO ke usable. +- `Rollback`: batalkan alokasi aktif. +- `Reflow`: rollback penuh lalu allocate ulang (idempotent). +- `Recalculate`: rekonsiliasi qty warehouse dari ledger FIFO. + +## Deterministic Sorting +Urutan gather: +1. `sort_at ASC` (dari trait `date_column`) +2. `sort_priority ASC` +3. `source_table ASC` +4. `source_id ASC` + +Fallback waktu: `1970-01-01 00:00:00+00` bila tanggal null. + +## Compat Strategy +- Tetap menulis ke `stock_allocations` dengan tambahan metadata: + - `engine_version` (`v1`/`v2`) + - `flag_group_code` + - `function_code` + - `idempotency_key` +- Query lama yang bergantung `stockable_type/usable_type` tetap berjalan. + +## Migration Strategy +1. Deploy schema + seed v2. +2. Aktifkan shadow-run comparator v1 vs v2. +3. Canary cutover per flag group. +4. Full cutover jika parity aman. +5. Jalankan bulk reflow existing data. + +## Acceptance Criteria Singkat +- Parity mismatch terkendali pada aggregate + detail alokasi. +- Tidak ada regression closing/HPP. +- Drift qty warehouse turun signifikan pasca reflow. +- Rollback via feature flag memungkinkan kembali ke v1. diff --git a/internal/common/service/fifo_stock_v2/allocate.go b/internal/common/service/fifo_stock_v2/allocate.go new file mode 100644 index 00000000..475b2172 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/allocate.go @@ -0,0 +1,748 @@ +package fifo_stock_v2 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "strings" + "time" + + "gorm.io/gorm" +) + +type allocationRow struct { + ID uint `gorm:"column:id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + StockableType string `gorm:"column:stockable_type"` + StockableID uint `gorm:"column:stockable_id"` + UsableType string `gorm:"column:usable_type"` + UsableID uint `gorm:"column:usable_id"` + Qty float64 `gorm:"column:qty"` + Status string `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +type usableQtySnapshot struct { + Usage float64 `gorm:"column:usage_qty"` + Pending float64 `gorm:"column:pending_qty"` +} + +func (s *fifoStockV2Service) Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) { + if err := s.validateAllocateRequest(req); err != nil { + return nil, err + } + + result := &AllocateResult{} + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.ensureStockAllocationColumns(tx); err != nil { + return err + } + if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil { + return err + } + + hash := requestHash(map[string]any{ + "flag_group_code": req.FlagGroupCode, + "product_warehouse_id": req.ProductWarehouseID, + "usable_type": req.Usable.LegacyTypeKey, + "usable_id": req.Usable.ID, + "need_qty": req.NeedQty, + "as_of": req.AsOf, + "allow_over_consume": req.AllowOverConsume, + }) + logRow, reused, err := s.beginOperation( + tx, + OperationAllocate, + req.IdempotencyKey, + hash, + req.ProductWarehouseID, + req.FlagGroupCode, + req.Usable.LegacyTypeKey, + req.Usable.ID, + ) + if err != nil { + return err + } + if reused { + if len(logRow.ResultPayload) == 0 { + return fmt.Errorf("idempotent allocate has empty payload") + } + if err := json.Unmarshal(logRow.ResultPayload, result); err != nil { + return err + } + return nil + } + if logRow != nil { + defer func() { + if err != nil { + s.failOperation(tx, logRow, err) + } + }() + } + + allocated, allocErr := s.allocateInternal(ctx, tx, req) + if allocErr != nil { + err = allocErr + return allocErr + } + *result = *allocated + + if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil { + err = finishErr + return finishErr + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, req AllocateRequest) (*AllocateResult, error) { + usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, req.FlagGroupCode, req.Usable.LegacyTypeKey) + if err != nil { + return nil, err + } + + allowOverConsume := usableRule.AllowPendingDefault + if req.AllowOverConsume != nil { + allowOverConsume = *req.AllowOverConsume + } else { + allowOverConsume, err = s.resolveOverConsume(tx, req.FlagGroupCode, req.Usable.FunctionCode, LaneUsable, allowOverConsume) + if err != nil { + return nil, err + } + } + + gatherRows, err := s.gatherRows(ctx, tx, GatherRequest{ + FlagGroupCode: req.FlagGroupCode, + Lane: LaneStockable, + ProductWarehouseID: req.ProductWarehouseID, + AsOf: req.AsOf, + Limit: s.defaultGatherLimit, + }) + if err != nil { + return nil, err + } + + stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, req.FlagGroupCode) + if err != nil { + return nil, err + } + + now := time.Now() + remaining := req.NeedQty + result := &AllocateResult{Details: make([]AllocationDetail, 0)} + + for _, lot := range gatherRows { + if remaining <= 0 { + break + } + if lot.AvailableQuantity <= 0 { + continue + } + portion := math.Min(remaining, lot.AvailableQuantity) + if nearlyZero(portion) { + continue + } + + allocationInsert := map[string]any{ + "product_warehouse_id": req.ProductWarehouseID, + "stockable_type": lot.Ref.LegacyTypeKey, + "stockable_id": lot.Ref.ID, + "usable_type": req.Usable.LegacyTypeKey, + "usable_id": req.Usable.ID, + "qty": portion, + "status": activeAllocationStatus(), + "allocation_purpose": defaultAllocationPurpose(), + "created_at": now, + "updated_at": now, + "engine_version": "v2", + "flag_group_code": req.FlagGroupCode, + "function_code": req.Usable.FunctionCode, + } + if strings.TrimSpace(req.IdempotencyKey) != "" { + allocationInsert["idempotency_key"] = req.IdempotencyKey + } + if err := tx.Table("stock_allocations").Create(allocationInsert).Error; err != nil { + return nil, err + } + + rule, ok := stockableRuleMap[lot.Ref.LegacyTypeKey] + if !ok { + return nil, fmt.Errorf("missing stockable route rule for type %s", lot.Ref.LegacyTypeKey) + } + if err := s.adjustStockableUsedQuantity(tx, rule, lot.Ref.ID, portion); err != nil { + return nil, err + } + + result.Details = append(result.Details, AllocationDetail{ + StockableType: lot.Ref.LegacyTypeKey, + StockableID: lot.Ref.ID, + Qty: portion, + SortAt: lot.SortAt, + }) + + remaining -= portion + result.AllocatedQty += portion + } + + if remaining > 0 { + if !allowOverConsume { + return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty) + } + result.PendingQty = remaining + } + + if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil { + return nil, err + } + if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, -result.AllocatedQty); err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) { + if err := s.validateRollbackRequest(req); err != nil { + return nil, err + } + + result := &RollbackResult{} + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.ensureStockAllocationColumns(tx); err != nil { + return err + } + + flagGroupCode, err := s.resolveRollbackFlagGroup(ctx, tx, req) + if err != nil { + return err + } + if err := s.lockShard(tx, flagGroupCode, req.ProductWarehouseID); err != nil { + return err + } + + hash := requestHash(map[string]any{ + "product_warehouse_id": req.ProductWarehouseID, + "usable_type": req.Usable.LegacyTypeKey, + "usable_id": req.Usable.ID, + "release_qty": req.ReleaseQty, + "reason": req.Reason, + "flag_group_code": flagGroupCode, + }) + logRow, reused, beginErr := s.beginOperation( + tx, + OperationRollback, + req.IdempotencyKey, + hash, + req.ProductWarehouseID, + flagGroupCode, + req.Usable.LegacyTypeKey, + req.Usable.ID, + ) + if beginErr != nil { + return beginErr + } + if reused { + if len(logRow.ResultPayload) == 0 { + return fmt.Errorf("idempotent rollback has empty payload") + } + if err := json.Unmarshal(logRow.ResultPayload, result); err != nil { + return err + } + return nil + } + if logRow != nil { + defer func() { + if err != nil { + s.failOperation(tx, logRow, err) + } + }() + } + + rolled, rollbackErr := s.rollbackInternal(ctx, tx, req, flagGroupCode) + if rollbackErr != nil { + err = rollbackErr + return rollbackErr + } + *result = *rolled + + if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil { + err = finishErr + return finishErr + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *fifoStockV2Service) rollbackInternal( + ctx context.Context, + tx *gorm.DB, + req RollbackRequest, + flagGroupCode string, +) (*RollbackResult, error) { + usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, flagGroupCode, req.Usable.LegacyTypeKey) + if err != nil { + return nil, err + } + + allocations, err := s.loadActiveAllocations(tx, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID) + if err != nil { + return nil, err + } + if len(allocations) == 0 { + if req.ReleaseQty == nil { + if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil { + return nil, err + } + } + return &RollbackResult{}, nil + } + + stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, flagGroupCode) + if err != nil { + return nil, err + } + + target := 0.0 + for _, alloc := range allocations { + target += alloc.Qty + } + if req.ReleaseQty != nil { + if *req.ReleaseQty < 0 { + return nil, fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest) + } + target = *req.ReleaseQty + } + if nearlyZero(target) { + return &RollbackResult{}, nil + } + + result := &RollbackResult{Details: make([]AllocationDetail, 0)} + now := time.Now() + remaining := target + + for _, alloc := range allocations { + if remaining <= 0 { + break + } + portion := math.Min(remaining, alloc.Qty) + if nearlyZero(portion) { + continue + } + + if nearlyZero(alloc.Qty - portion) { + updates := map[string]any{ + "status": releasedAllocationStatus(), + "released_at": now, + "updated_at": now, + } + if strings.TrimSpace(req.Reason) != "" { + updates["note"] = req.Reason + } + if err := tx.Table("stock_allocations").Where("id = ?", alloc.ID).Updates(updates).Error; err != nil { + return nil, err + } + } else { + if err := tx.Table("stock_allocations"). + Where("id = ?", alloc.ID). + Updates(map[string]any{ + "qty": alloc.Qty - portion, + "updated_at": now, + }).Error; err != nil { + return nil, err + } + } + + stockableRule, ok := stockableRuleMap[alloc.StockableType] + if !ok { + return nil, fmt.Errorf("missing stockable route rule for type %s", alloc.StockableType) + } + if err := s.adjustStockableUsedQuantity(tx, stockableRule, alloc.StockableID, -portion); err != nil { + return nil, err + } + + result.ReleasedQty += portion + remaining -= portion + result.Details = append(result.Details, AllocationDetail{ + StockableType: alloc.StockableType, + StockableID: alloc.StockableID, + Qty: portion, + SortAt: alloc.CreatedAt, + }) + } + + if req.ReleaseQty != nil && remaining > 1e-6 { + return nil, fmt.Errorf("unable to release %.3f; only %.3f allocation exists", target, result.ReleasedQty) + } + + if req.ReleaseQty == nil { + if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil { + return nil, err + } + } else { + if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, -result.ReleasedQty, 0); err != nil { + return nil, err + } + } + + if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, result.ReleasedQty); err != nil { + return nil, err + } + + return result, nil +} + +func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) { + if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 { + return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest) + } + + result := &ReflowResult{} + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + if err := s.ensureStockAllocationColumns(tx); err != nil { + return err + } + if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil { + return err + } + + hash := requestHash(map[string]any{ + "flag_group_code": req.FlagGroupCode, + "product_warehouse_id": req.ProductWarehouseID, + "as_of": req.AsOf, + }) + logRow, reused, err := s.beginOperation( + tx, + OperationReflow, + req.IdempotencyKey, + hash, + req.ProductWarehouseID, + req.FlagGroupCode, + "", + 0, + ) + if err != nil { + return err + } + if reused { + if len(logRow.ResultPayload) == 0 { + return fmt.Errorf("idempotent reflow has empty payload") + } + if err := json.Unmarshal(logRow.ResultPayload, result); err != nil { + return err + } + return nil + } + if logRow != nil { + defer func() { + if err != nil { + s.failOperation(tx, logRow, err) + } + }() + } + + usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{ + FlagGroupCode: req.FlagGroupCode, + Lane: LaneUsable, + ProductWarehouseID: req.ProductWarehouseID, + Limit: s.defaultGatherLimit, + }) + if gatherErr != nil { + err = gatherErr + return gatherErr + } + result.ProcessedUsables = len(usableRows) + + for _, usableRow := range usableRows { + desiredQty := usableRow.Quantity + usableRow.PendingQuantity + + rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{ + ProductWarehouseID: req.ProductWarehouseID, + Usable: usableRow.Ref, + ReleaseQty: nil, + Reason: "reflow reset", + }, req.FlagGroupCode) + if rollbackErr != nil { + err = rollbackErr + return rollbackErr + } + result.Rollback.ReleasedQty += rollbackRes.ReleasedQty + if len(rollbackRes.Details) > 0 { + result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...) + } + minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity + if desiredQty < minDesired { + desiredQty = minDesired + } + + if desiredQty <= 0 { + continue + } + allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{ + FlagGroupCode: req.FlagGroupCode, + ProductWarehouseID: req.ProductWarehouseID, + Usable: usableRow.Ref, + NeedQty: desiredQty, + AsOf: nil, + }) + if allocateErr != nil { + err = allocateErr + return allocateErr + } + result.Allocate.AllocatedQty += allocateRes.AllocatedQty + result.Allocate.PendingQty += allocateRes.PendingQty + if len(allocateRes.Details) > 0 { + result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...) + } + } + + expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil) + if calcErr != nil { + err = calcErr + return calcErr + } + actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID) + if loadErr != nil { + err = loadErr + return loadErr + } + drift := expectedQty - actualQty + if math.Abs(drift) >= 1e-6 { + if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil { + err = adjustErr + return adjustErr + } + } + + if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil { + err = finishErr + return finishErr + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *fifoStockV2Service) gatherAllRows( + ctx context.Context, + tx *gorm.DB, + req GatherRequest, +) ([]GatherRow, error) { + limit := req.Limit + if limit <= 0 { + limit = s.defaultGatherLimit + } + if limit <= 0 { + limit = 1000 + } + + req.Limit = limit + out := make([]GatherRow, 0, limit) + + var cursorSortAt *time.Time + cursorSourceTable := "" + var cursorSourceID uint + + for { + req.AfterSortAt = cursorSortAt + req.AfterSourceTable = cursorSourceTable + req.AfterSourceID = cursorSourceID + + rows, err := s.gatherRows(ctx, tx, req) + if err != nil { + return nil, err + } + if len(rows) == 0 { + break + } + + out = append(out, rows...) + if len(rows) < limit { + break + } + + last := rows[len(rows)-1] + lastSortAt := last.SortAt + cursorSortAt = &lastSortAt + cursorSourceTable = last.SourceTable + cursorSourceID = last.SourceID + } + + return out, nil +} + +func (s *fifoStockV2Service) loadActiveAllocations( + tx *gorm.DB, + usableType string, + usableID uint, + productWarehouseID uint, +) ([]allocationRow, error) { + query := tx.Table("stock_allocations"). + Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at"). + Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose()) + if productWarehouseID > 0 { + query = query.Where("product_warehouse_id = ?", productWarehouseID) + } + query = query.Order("created_at DESC, id DESC") + + var rows []allocationRow + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (s *fifoStockV2Service) loadStockableRuleMap(ctx context.Context, tx *gorm.DB, flagGroupCode string) (map[string]routeRule, error) { + rules, err := s.loadRouteRules(ctx, tx, flagGroupCode, LaneStockable) + if err != nil { + return nil, err + } + m := make(map[string]routeRule, len(rules)) + for _, rule := range rules { + m[rule.LegacyTypeKey] = rule + } + return m, nil +} + +func (s *fifoStockV2Service) adjustStockableUsedQuantity(tx *gorm.DB, rule routeRule, sourceID uint, delta float64) error { + if nearlyZero(delta) || sourceID == 0 { + return nil + } + if rule.UsedQuantityCol == nil || strings.TrimSpace(*rule.UsedQuantityCol) == "" { + return nil + } + + usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol) + sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn) + sourceTable, _ := mustSafeIdentifier(rule.SourceTable) + + expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usedCol) + return tx.Table(sourceTable). + Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID). + Update(usedCol, gorm.Expr(expr, delta)).Error +} + +func (s *fifoStockV2Service) applyUsableDeltas(tx *gorm.DB, rule routeRule, sourceID uint, usageDelta, pendingDelta float64) error { + if sourceID == 0 || (nearlyZero(usageDelta) && nearlyZero(pendingDelta)) { + return nil + } + sourceTable, _ := mustSafeIdentifier(rule.SourceTable) + sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn) + usageCol, _ := mustSafeIdentifier(rule.QuantityCol) + + updates := map[string]any{} + if !nearlyZero(usageDelta) { + expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usageCol) + updates[usageCol] = gorm.Expr(expr, usageDelta) + } + if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" && !nearlyZero(pendingDelta) { + pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol) + expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", pendingCol) + updates[pendingCol] = gorm.Expr(expr, pendingDelta) + } + if len(updates) == 0 { + return nil + } + + return tx.Table(sourceTable). + Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID). + Updates(updates).Error +} + +func (s *fifoStockV2Service) resetUsableQuantities(tx *gorm.DB, rule routeRule, sourceID uint) error { + if sourceID == 0 { + return nil + } + sourceTable, _ := mustSafeIdentifier(rule.SourceTable) + sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn) + usageCol, _ := mustSafeIdentifier(rule.QuantityCol) + + updates := map[string]any{usageCol: 0} + if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" { + pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol) + updates[pendingCol] = 0 + } + + return tx.Table(sourceTable). + Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID). + Updates(updates).Error +} + +func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *gorm.DB, req RollbackRequest) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + var latest row + err := tx.WithContext(ctx). + Table("stock_allocations"). + Select("flag_group_code"). + Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID). + Where("engine_version = 'v2'"). + Where("allocation_purpose = ?", defaultAllocationPurpose()). + Where("flag_group_code IS NOT NULL AND flag_group_code <> ''"). + Order("id DESC"). + Limit(1). + Take(&latest).Error + if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" { + return latest.FlagGroupCode, nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + + var rules []routeRule + err = tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Where("is_active = TRUE"). + Where("lane = ?", string(LaneUsable)). + Where("legacy_type_key = ?", req.Usable.LegacyTypeKey). + Find(&rules).Error + if err != nil { + return "", err + } + if len(rules) == 0 { + return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey) + } + if len(rules) > 1 { + return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey) + } + return rules[0].FlagGroupCode, nil +} + +func (s *fifoStockV2Service) validateAllocateRequest(req AllocateRequest) error { + if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 { + return fmt.Errorf("%w: missing flag group or product warehouse", ErrInvalidRequest) + } + if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" { + return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest) + } + if req.NeedQty < 0 { + return fmt.Errorf("%w: need qty must be >= 0", ErrInvalidRequest) + } + return nil +} + +func (s *fifoStockV2Service) validateRollbackRequest(req RollbackRequest) error { + if req.ProductWarehouseID == 0 { + return fmt.Errorf("%w: product warehouse is required", ErrInvalidRequest) + } + if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" { + return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest) + } + if req.ReleaseQty != nil && *req.ReleaseQty < 0 { + return fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest) + } + return nil +} diff --git a/internal/common/service/fifo_stock_v2/config.go b/internal/common/service/fifo_stock_v2/config.go new file mode 100644 index 00000000..b8ce3526 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/config.go @@ -0,0 +1,170 @@ +package fifo_stock_v2 + +import ( + "context" + "fmt" + "strings" + + "gorm.io/gorm" +) + +type routeRule struct { + ID uint `gorm:"column:id"` + FlagGroupCode string `gorm:"column:flag_group_code"` + Lane string `gorm:"column:lane"` + FunctionCode string `gorm:"column:function_code"` + SourceTable string `gorm:"column:source_table"` + SourceIDColumn string `gorm:"column:source_id_column"` + ProductWarehouseCol string `gorm:"column:product_warehouse_col"` + QuantityCol string `gorm:"column:quantity_col"` + UsedQuantityCol *string `gorm:"column:used_quantity_col"` + PendingQuantityCol *string `gorm:"column:pending_quantity_col"` + ScopeSQL *string `gorm:"column:scope_sql"` + LegacyTypeKey string `gorm:"column:legacy_type_key"` + AllowPendingDefault bool `gorm:"column:allow_pending_default"` +} + +type traitRule struct { + ID uint `gorm:"column:id"` + SourceTable string `gorm:"column:source_table"` + Lane string `gorm:"column:lane"` + DateTable *string `gorm:"column:date_table"` + DateJoinLeftCol *string `gorm:"column:date_join_left_col"` + DateJoinRightCol *string `gorm:"column:date_join_right_col"` + DateColumn string `gorm:"column:date_column"` + FallbackDateColumn *string `gorm:"column:fallback_date_column"` + SortPriority int `gorm:"column:sort_priority"` + IDColumn string `gorm:"column:id_column"` +} + +func (s *fifoStockV2Service) loadRouteRules(ctx context.Context, tx *gorm.DB, flagGroupCode string, lane Lane) ([]routeRule, error) { + var rules []routeRule + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Where("is_active = TRUE"). + Where("flag_group_code = ?", flagGroupCode). + Where("lane = ?", string(lane)). + Order("id ASC"). + Find(&rules).Error + if err != nil { + return nil, err + } + for _, rule := range rules { + if err := validateRouteRule(rule); err != nil { + return nil, err + } + } + return rules, nil +} + +func (s *fifoStockV2Service) loadRouteRuleByLegacyType( + ctx context.Context, + tx *gorm.DB, + lane Lane, + flagGroupCode string, + legacyTypeKey string, +) (*routeRule, error) { + var rule routeRule + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules"). + Where("is_active = TRUE"). + Where("lane = ?", string(lane)). + Where("flag_group_code = ?", flagGroupCode). + Where("legacy_type_key = ?", legacyTypeKey). + Order("id ASC"). + Limit(1). + Take(&rule).Error + if err != nil { + return nil, err + } + if err := validateRouteRule(rule); err != nil { + return nil, err + } + return &rule, nil +} + +func (s *fifoStockV2Service) loadTraitMap( + ctx context.Context, + tx *gorm.DB, + lane Lane, + sourceTables []string, +) (map[string]traitRule, error) { + if len(sourceTables) == 0 { + return map[string]traitRule{}, nil + } + + var traits []traitRule + err := tx.WithContext(ctx). + Table("fifo_stock_v2_traits"). + Where("is_active = TRUE"). + Where("lane = ?", string(lane)). + Where("source_table IN ?", sourceTables). + Find(&traits).Error + if err != nil { + return nil, err + } + + out := make(map[string]traitRule, len(traits)) + for _, tr := range traits { + if err := validateTraitRule(tr); err != nil { + return nil, err + } + out[tr.SourceTable] = tr + } + return out, nil +} + +func validateRouteRule(rule routeRule) error { + fields := []string{rule.SourceTable, rule.SourceIDColumn, rule.ProductWarehouseCol, rule.QuantityCol} + for _, value := range fields { + if _, err := mustSafeIdentifier(value); err != nil { + return err + } + } + if rule.UsedQuantityCol != nil { + if _, err := mustSafeIdentifier(*rule.UsedQuantityCol); err != nil { + return err + } + } + if rule.PendingQuantityCol != nil { + if _, err := mustSafeIdentifier(*rule.PendingQuantityCol); err != nil { + return err + } + } + if strings.TrimSpace(rule.LegacyTypeKey) == "" { + return fmt.Errorf("route rule has empty legacy type key") + } + return nil +} + +func validateTraitRule(rule traitRule) error { + if _, err := mustSafeIdentifier(rule.SourceTable); err != nil { + return err + } + if _, err := mustSafeIdentifier(rule.DateColumn); err != nil { + return err + } + if _, err := mustSafeIdentifier(rule.IDColumn); err != nil { + return err + } + if rule.DateTable != nil { + if _, err := mustSafeIdentifier(*rule.DateTable); err != nil { + return err + } + if rule.DateJoinLeftCol == nil || rule.DateJoinRightCol == nil { + return fmt.Errorf("trait %s requires date join columns", rule.SourceTable) + } + if _, err := mustSafeIdentifier(*rule.DateJoinLeftCol); err != nil { + return err + } + if _, err := mustSafeIdentifier(*rule.DateJoinRightCol); err != nil { + return err + } + } + if rule.FallbackDateColumn != nil { + if _, err := mustSafeIdentifier(*rule.FallbackDateColumn); err != nil { + return err + } + } + return nil +} diff --git a/internal/common/service/fifo_stock_v2/errors.go b/internal/common/service/fifo_stock_v2/errors.go new file mode 100644 index 00000000..6c18495b --- /dev/null +++ b/internal/common/service/fifo_stock_v2/errors.go @@ -0,0 +1,8 @@ +package fifo_stock_v2 + +import "errors" + +var ( + ErrInvalidRequest = errors.New("invalid fifo stock v2 request") + ErrInsufficientStock = errors.New("insufficient stock") +) diff --git a/internal/common/service/fifo_stock_v2/gather.go b/internal/common/service/fifo_stock_v2/gather.go new file mode 100644 index 00000000..f3733d31 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/gather.go @@ -0,0 +1,293 @@ +package fifo_stock_v2 + +import ( + "context" + "fmt" + "strings" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type gatherSQLRow struct { + SourceTable string `gorm:"column:source_table"` + LegacyTypeKey string `gorm:"column:legacy_type_key"` + FunctionCode string `gorm:"column:function_code"` + SourceID uint `gorm:"column:source_id"` + ProductWarehouseID uint `gorm:"column:product_warehouse_id"` + SortAt time.Time `gorm:"column:sort_at"` + SortPriority int `gorm:"column:sort_priority"` + Quantity float64 `gorm:"column:quantity"` + UsedQuantity float64 `gorm:"column:used_quantity"` + PendingQuantity float64 `gorm:"column:pending_quantity"` + AvailableQuantity float64 `gorm:"column:available_quantity"` +} + +func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error) { + if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 { + return nil, fmt.Errorf("%w: flag group and product warehouse are required", ErrInvalidRequest) + } + if req.Lane != LaneStockable && req.Lane != LaneUsable { + return nil, fmt.Errorf("%w: unsupported lane %q", ErrInvalidRequest, req.Lane) + } + + var out []GatherRow + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + rows, err := s.gatherRows(ctx, tx, req) + if err != nil { + return err + } + out = rows + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) { + req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose) + + rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane) + if err != nil { + return nil, err + } + if len(rules) == 0 { + return []GatherRow{}, nil + } + + tables := make([]string, 0, len(rules)) + for _, rule := range rules { + tables = append(tables, rule.SourceTable) + } + + traits, err := s.loadTraitMap(ctx, tx, req.Lane, tables) + if err != nil { + return nil, err + } + + subqueries := make([]string, 0, len(rules)) + args := make([]any, 0, len(rules)*10) + + for _, rule := range rules { + trait, ok := traits[rule.SourceTable] + if !ok { + return nil, fmt.Errorf("missing trait for table %s lane %s", rule.SourceTable, req.Lane) + } + subSQL, subArgs, err := s.buildGatherSubquery(rule, trait, req) + if err != nil { + return nil, err + } + subqueries = append(subqueries, subSQL) + args = append(args, subArgs...) + } + + if len(subqueries) == 0 { + return []GatherRow{}, nil + } + + limit := req.Limit + if limit <= 0 { + limit = s.defaultGatherLimit + } + if limit <= 0 { + limit = 1000 + } + + query := "SELECT * FROM (" + strings.Join(subqueries, " UNION ALL ") + ") AS g" + if req.AfterSortAt != nil { + query += ` + WHERE + (g.sort_at > ?) + OR (g.sort_at = ? AND g.source_table > ?) + OR (g.sort_at = ? AND g.source_table = ? AND g.source_id > ?) + ` + args = append(args, + *req.AfterSortAt, + *req.AfterSortAt, req.AfterSourceTable, + *req.AfterSortAt, req.AfterSourceTable, req.AfterSourceID, + ) + } + query += " ORDER BY g.sort_at ASC, g.sort_priority ASC, g.source_table ASC, g.source_id ASC LIMIT ?" + args = append(args, limit) + + var rows []gatherSQLRow + if err := tx.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { + return nil, err + } + + out := make([]GatherRow, 0, len(rows)) + for _, row := range rows { + out = append(out, GatherRow{ + Ref: Ref{ + Table: row.SourceTable, + ID: row.SourceID, + LegacyTypeKey: row.LegacyTypeKey, + FunctionCode: row.FunctionCode, + }, + FlagGroupCode: req.FlagGroupCode, + ProductWarehouseID: row.ProductWarehouseID, + SortAt: row.SortAt, + SortPriority: row.SortPriority, + Quantity: row.Quantity, + UsedQuantity: row.UsedQuantity, + PendingQuantity: row.PendingQuantity, + AvailableQuantity: row.AvailableQuantity, + SourceTable: row.SourceTable, + SourceID: row.SourceID, + }) + } + + return out, nil +} + +func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule, req GatherRequest) (string, []any, error) { + sourceTable, _ := mustSafeIdentifier(rule.SourceTable) + sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn) + productWarehouseCol, _ := mustSafeIdentifier(rule.ProductWarehouseCol) + quantityCol, _ := mustSafeIdentifier(rule.QuantityCol) + + baseQtyExpr := fmt.Sprintf("COALESCE(src.%s,0)::numeric", quantityCol) + usedExpr := "0::numeric" + pendingExpr := "0::numeric" + availableExpr := baseQtyExpr + extraArgs := make([]any, 0, 2) + whereExtraArgs := make([]any, 0, 1) + + if req.Lane == LaneStockable { + if !req.IgnoreSourceUsed && 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' AND sa.allocation_purpose = ?)", + sourceIDCol, + activeAllocationStatus(), + ) + extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose) + extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose) + whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose) + } + availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr) + } else { + if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" { + pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol) + pendingExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", pendingCol) + } + availableExpr = baseQtyExpr + } + + sortExpr, joinClause, err := buildSortExpr(trait) + if err != nil { + 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 ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_type = ? AND 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 = src.%s AND fm.flag_group_code = ? + )`, productWarehouseCol), + } + + if req.Lane == LaneStockable { + whereParts = append(whereParts, fmt.Sprintf("%s > 0", availableExpr)) + } + + if req.AsOf != nil { + whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr)) + } + if req.From != nil { + whereParts = append(whereParts, fmt.Sprintf("%s >= ?", sortExpr)) + } + + if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" { + whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL))) + } + + subquery := fmt.Sprintf(` + SELECT + ?::text AS source_table, + ?::text AS legacy_type_key, + %s AS function_code, + src.%s AS source_id, + src.%s AS product_warehouse_id, + %s AS sort_at, + ?::int AS sort_priority, + %s AS quantity, + %s AS used_quantity, + %s AS pending_quantity, + %s AS available_quantity + FROM %s src + %s + WHERE %s + `, functionCodeExpr, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND ")) + + args := []any{ + rule.SourceTable, + rule.LegacyTypeKey, + } + 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) + } + if req.From != nil { + args = append(args, *req.From) + } + + return subquery, args, nil +} + +func buildSortExpr(trait traitRule) (string, string, error) { + dateCol, _ := mustSafeIdentifier(trait.DateColumn) + idCol, _ := mustSafeIdentifier(trait.IDColumn) + _ = idCol + + joinClause := "" + sortBase := fmt.Sprintf("src.%s", dateCol) + if trait.DateTable != nil && strings.TrimSpace(*trait.DateTable) != "" { + dateTable, _ := mustSafeIdentifier(*trait.DateTable) + if trait.DateJoinLeftCol == nil || trait.DateJoinRightCol == nil { + return "", "", fmt.Errorf("trait %s requires date join columns", trait.SourceTable) + } + leftCol, _ := mustSafeIdentifier(*trait.DateJoinLeftCol) + rightCol, _ := mustSafeIdentifier(*trait.DateJoinRightCol) + joinClause = fmt.Sprintf("LEFT JOIN %s dt ON src.%s = dt.%s", dateTable, leftCol, rightCol) + sortBase = fmt.Sprintf("dt.%s", dateCol) + } + + if trait.FallbackDateColumn != nil && strings.TrimSpace(*trait.FallbackDateColumn) != "" { + fallbackCol, _ := mustSafeIdentifier(*trait.FallbackDateColumn) + sortBase = fmt.Sprintf("COALESCE(%s, src.%s)", sortBase, fallbackCol) + } + + sortExpr := fmt.Sprintf("COALESCE(%s, '1970-01-01 00:00:00+00'::timestamptz)", sortBase) + return sortExpr, joinClause, nil +} diff --git a/internal/common/service/fifo_stock_v2/population_allocation.go b/internal/common/service/fifo_stock_v2/population_allocation.go new file mode 100644 index 00000000..ce961564 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/population_allocation.go @@ -0,0 +1,131 @@ +package fifo_stock_v2 + +import ( + "context" + "errors" + "math" + "sort" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +func ReleasePopulationConsumptionByUsable( + ctx context.Context, + tx *gorm.DB, + usableType string, + usableID uint, +) error { + if tx == nil { + return errors.New("transaction is required") + } + if usableType == "" || usableID == 0 { + return errors.New("usable type and id are required") + } + + stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) + allocations, err := stockAllocationRepo.FindActiveByUsable(ctx, usableType, usableID, nil) + if err != nil { + return err + } + + for _, allocation := range allocations { + if allocation.StockableType != fifo.StockableKeyProjectFlockPopulation.String() || allocation.StockableId == 0 || allocation.Qty <= 0 { + continue + } + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", allocation.StockableId). + Update("total_used_qty", gorm.Expr("GREATEST(total_used_qty - ?, 0)", allocation.Qty)).Error; err != nil { + return err + } + } + + return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil) +} + +func AllocatePopulationConsumption( + ctx context.Context, + tx *gorm.DB, + populations []entity.ProjectFlockPopulation, + productWarehouseID uint, + usableType string, + usableID uint, + consumeQty float64, +) error { + if consumeQty <= 0 { + return nil + } + if tx == nil { + return errors.New("transaction is required") + } + if productWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid") + } + if usableType == "" || usableID == 0 { + return errors.New("usable type and id are required") + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan") + } + + if err := ReleasePopulationConsumptionByUsable(ctx, tx, usableType, usableID); err != nil { + return err + } + + sort.Slice(populations, func(i, j int) bool { + if populations[i].CreatedAt.Equal(populations[j].CreatedAt) { + return populations[i].Id < populations[j].Id + } + return populations[i].CreatedAt.Before(populations[j].CreatedAt) + }) + + stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) + remaining := consumeQty + for _, pop := range populations { + available := pop.TotalQty - pop.TotalUsedQty + if available <= 0 { + continue + } + portion := math.Min(available, remaining) + if portion <= 0 { + continue + } + + allocation := &entity.StockAllocation{ + ProductWarehouseId: productWarehouseID, + StockableType: fifo.StockableKeyProjectFlockPopulation.String(), + StockableId: pop.Id, + UsableType: usableType, + UsableId: usableID, + Qty: portion, + Status: entity.StockAllocationStatusActive, + AllocationPurpose: entity.StockAllocationPurposeConsume, + } + if err := stockAllocationRepo.CreateOne(ctx, allocation, nil); err != nil { + return err + } + + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", pop.Id). + Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil { + return err + } + + remaining -= portion + if remaining <= 1e-6 { + break + } + } + + if remaining > 1e-6 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi") + } + + return nil +} diff --git a/internal/common/service/fifo_stock_v2/recalculate.go b/internal/common/service/fifo_stock_v2/recalculate.go new file mode 100644 index 00000000..a94407e4 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/recalculate.go @@ -0,0 +1,177 @@ +package fifo_stock_v2 + +import ( + "context" + "encoding/json" + "fmt" + "math" + "time" + + "gorm.io/gorm" +) + +func (s *fifoStockV2Service) Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error) { + result := &RecalculateResult{Drifts: make([]WarehouseDrift, 0)} + + err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error { + hash := requestHash(map[string]any{ + "product_warehouse_ids": req.ProductWarehouseIDs, + "flag_group_codes": req.FlagGroupCodes, + "as_of": req.AsOf, + "fix_drift": req.FixDrift, + }) + logRow, reused, err := s.beginOperation( + tx, + OperationRecalculate, + req.IdempotencyKey, + hash, + 0, + "RECALCULATE", + "", + 0, + ) + if err != nil { + return err + } + if reused { + if len(logRow.ResultPayload) == 0 { + return fmt.Errorf("idempotent recalculate has empty payload") + } + if err := json.Unmarshal(logRow.ResultPayload, result); err != nil { + return err + } + return nil + } + if logRow != nil { + defer func() { + if err != nil { + s.failOperation(tx, logRow, err) + } + }() + } + + warehouseIDs, err := s.resolveRecalculateWarehouseIDs(ctx, tx, req.ProductWarehouseIDs) + if err != nil { + return err + } + groupCodes, err := s.resolveRecalculateGroupCodes(ctx, tx, req.FlagGroupCodes) + if err != nil { + return err + } + + for _, warehouseID := range warehouseIDs { + expected := 0.0 + for _, flagGroup := range groupCodes { + available, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, warehouseID, flagGroup, req.AsOf) + if calcErr != nil { + return calcErr + } + expected += available + } + + actual, actualErr := s.loadWarehouseQty(ctx, tx, warehouseID) + if actualErr != nil { + return actualErr + } + + delta := expected - actual + result.Checked++ + if math.Abs(delta) < 1e-6 { + continue + } + + drift := WarehouseDrift{ + ProductWarehouseID: warehouseID, + ExpectedQty: expected, + ActualQty: actual, + Delta: delta, + } + result.Drifts = append(result.Drifts, drift) + + if req.FixDrift { + if err := s.adjustProductWarehouseQty(tx, warehouseID, delta); err != nil { + return err + } + result.Fixed++ + } + } + + if err := s.finishOperation(tx, logRow, result); err != nil { + return err + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *fifoStockV2Service) resolveRecalculateWarehouseIDs(ctx context.Context, tx *gorm.DB, provided []uint) ([]uint, error) { + if len(provided) > 0 { + return provided, nil + } + var ids []uint + err := tx.WithContext(ctx).Table("product_warehouses").Select("id").Order("id ASC").Scan(&ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + +func (s *fifoStockV2Service) resolveRecalculateGroupCodes(ctx context.Context, tx *gorm.DB, provided []string) ([]string, error) { + if len(provided) > 0 { + return provided, nil + } + var groups []string + err := tx.WithContext(ctx). + Table("fifo_stock_v2_flag_groups"). + Select("code"). + Where("is_active = TRUE"). + Order("priority ASC, code ASC"). + Scan(&groups).Error + if err != nil { + return nil, err + } + return groups, nil +} + +func (s *fifoStockV2Service) calculateWarehouseAvailableForGroup( + ctx context.Context, + tx *gorm.DB, + warehouseID uint, + flagGroupCode string, + asOf *time.Time, +) (float64, error) { + rows, err := s.gatherRows(ctx, tx, GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: LaneStockable, + ProductWarehouseID: warehouseID, + AsOf: asOf, + Limit: 50000, + }) + if err != nil { + return 0, err + } + total := 0.0 + for _, row := range rows { + total += row.AvailableQuantity + } + return total, nil +} + +func (s *fifoStockV2Service) loadWarehouseQty(ctx context.Context, tx *gorm.DB, warehouseID uint) (float64, error) { + type row struct { + Qty float64 `gorm:"column:qty"` + } + var out row + err := tx.WithContext(ctx). + Table("product_warehouses"). + Select("COALESCE(qty,0) AS qty"). + Where("id = ?", warehouseID). + Take(&out).Error + if err != nil { + return 0, err + } + return out.Qty, nil +} diff --git a/internal/common/service/fifo_stock_v2/scope_sql.go b/internal/common/service/fifo_stock_v2/scope_sql.go new file mode 100644 index 00000000..a611a4e5 --- /dev/null +++ b/internal/common/service/fifo_stock_v2/scope_sql.go @@ -0,0 +1,100 @@ +package fifo_stock_v2 + +import "strings" + +func normalizeScopeSQL(scopeSQL string) string { + scopeSQL = strings.TrimSpace(scopeSQL) + if scopeSQL == "" { + return scopeSQL + } + + var out strings.Builder + out.Grow(len(scopeSQL) + 16) + + inSingleQuote := false + inDoubleQuote := false + + for i := 0; i < len(scopeSQL); { + ch := scopeSQL[i] + + if inSingleQuote { + out.WriteByte(ch) + i++ + if ch == '\'' { + if i < len(scopeSQL) && scopeSQL[i] == '\'' { + out.WriteByte(scopeSQL[i]) + i++ + } else { + inSingleQuote = false + } + } + continue + } + + if inDoubleQuote { + out.WriteByte(ch) + i++ + if ch == '"' { + inDoubleQuote = false + } + continue + } + + if ch == '\'' { + inSingleQuote = true + out.WriteByte(ch) + i++ + continue + } + + if ch == '"' { + inDoubleQuote = true + out.WriteByte(ch) + i++ + continue + } + + if isIdentifierStart(ch) { + start := i + i++ + for i < len(scopeSQL) && isIdentifierPart(scopeSQL[i]) { + i++ + } + + token := scopeSQL[start:i] + if strings.EqualFold(token, "deleted_at") && !hasAliasQualifier(scopeSQL, start) { + out.WriteString("src.deleted_at") + } else { + out.WriteString(token) + } + continue + } + + out.WriteByte(ch) + i++ + } + + return out.String() +} + +func hasAliasQualifier(scopeSQL string, tokenStart int) bool { + for i := tokenStart - 1; i >= 0; i-- { + switch scopeSQL[i] { + case ' ', '\t', '\n', '\r': + continue + case '.': + return true + default: + return false + } + } + return false +} + +func isIdentifierStart(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_' +} + +func isIdentifierPart(ch byte) bool { + return isIdentifierStart(ch) || (ch >= '0' && ch <= '9') +} diff --git a/internal/common/service/fifo_stock_v2/service.go b/internal/common/service/fifo_stock_v2/service.go new file mode 100644 index 00000000..0642b31c --- /dev/null +++ b/internal/common/service/fifo_stock_v2/service.go @@ -0,0 +1,277 @@ +package fifo_stock_v2 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "math" + "regexp" + "strings" + + "github.com/sirupsen/logrus" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +var identifierPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +type fifoStockV2Service struct { + db *gorm.DB + logger *logrus.Logger + defaultGatherLimit int +} + +func NewService(db *gorm.DB, logger *logrus.Logger) Service { + if logger == nil { + logger = logrus.StandardLogger() + } + + return &fifoStockV2Service{ + db: db, + logger: logger, + defaultGatherLimit: 1000, + } +} + +func (s *fifoStockV2Service) withTransaction( + ctx context.Context, + tx *gorm.DB, + fn func(*gorm.DB) error, +) error { + if tx != nil { + return fn(tx.WithContext(ctx)) + } + return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error { + return fn(inner) + }) +} + +func isSafeIdentifier(v string) bool { + return identifierPattern.MatchString(strings.TrimSpace(v)) +} + +func mustSafeIdentifier(v string) (string, error) { + v = strings.TrimSpace(v) + if !isSafeIdentifier(v) { + return "", fmt.Errorf("unsafe identifier: %s", v) + } + return v, nil +} + +func requestHash(v any) string { + payload, _ := json.Marshal(v) + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +func shardLockKey(flagGroupCode string, productWarehouseID uint) int64 { + h := fnv.New64a() + _, _ = h.Write([]byte(strings.TrimSpace(strings.ToUpper(flagGroupCode)))) + _, _ = h.Write([]byte("|")) + _, _ = h.Write([]byte(fmt.Sprintf("%d", productWarehouseID))) + return int64(h.Sum64()) +} + +func (s *fifoStockV2Service) lockShard(tx *gorm.DB, flagGroupCode string, productWarehouseID uint) error { + if strings.TrimSpace(flagGroupCode) == "" || productWarehouseID == 0 { + return fmt.Errorf("lock shard requires flag group and product warehouse") + } + return tx.Exec("SELECT pg_advisory_xact_lock(?)", shardLockKey(flagGroupCode, productWarehouseID)).Error +} + +type operationLogRow struct { + ID uint `gorm:"column:id"` + Status string `gorm:"column:status"` + RequestHash string `gorm:"column:request_hash"` + ResultPayload json.RawMessage `gorm:"column:result_payload"` +} + +func (s *fifoStockV2Service) beginOperation( + tx *gorm.DB, + op Operation, + idempotencyKey string, + requestHashValue string, + productWarehouseID uint, + flagGroupCode string, + usableType string, + usableID uint, +) (*operationLogRow, bool, error) { + if strings.TrimSpace(idempotencyKey) == "" { + return nil, false, nil + } + + inserted := operationLogRow{} + insertSQL := ` + INSERT INTO fifo_stock_v2_operation_log + (idempotency_key, operation, product_warehouse_id, flag_group_code, usable_type, usable_id, request_hash, status, created_at) + VALUES (?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, 0), ?, 'RUNNING', NOW()) + ON CONFLICT (idempotency_key, operation) DO NOTHING + RETURNING id, status, request_hash + ` + if err := tx.Raw(insertSQL, + idempotencyKey, + string(op), + productWarehouseID, + flagGroupCode, + usableType, + usableID, + requestHashValue, + ).Scan(&inserted).Error; err != nil { + return nil, false, err + } + if inserted.ID != 0 { + return &inserted, false, nil + } + + existing := operationLogRow{} + if err := tx.Table("fifo_stock_v2_operation_log"). + Select("id, status, request_hash, result_payload"). + Where("idempotency_key = ? AND operation = ?", idempotencyKey, string(op)). + Take(&existing).Error; err != nil { + return nil, false, err + } + + if existing.RequestHash != requestHashValue { + return nil, false, fmt.Errorf("idempotency key %s reused with different payload", idempotencyKey) + } + + switch strings.ToUpper(existing.Status) { + case "DONE": + return &existing, true, nil + case "RUNNING": + return nil, false, fmt.Errorf("operation %s with idempotency key %s is still running", op, idempotencyKey) + case "FAILED": + if err := tx.Table("fifo_stock_v2_operation_log"). + Where("id = ?", existing.ID). + Updates(map[string]any{ + "status": "RUNNING", + "error_text": nil, + "finished_at": nil, + }).Error; err != nil { + return nil, false, err + } + existing.Status = "RUNNING" + return &existing, false, nil + default: + return nil, false, fmt.Errorf("unknown operation status: %s", existing.Status) + } +} + +func (s *fifoStockV2Service) finishOperation(tx *gorm.DB, logRow *operationLogRow, payload any) error { + if logRow == nil || logRow.ID == 0 { + return nil + } + + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + + return tx.Table("fifo_stock_v2_operation_log"). + Where("id = ?", logRow.ID). + Updates(map[string]any{ + "status": "DONE", + "result_payload": encoded, + "finished_at": gorm.Expr("NOW()"), + }).Error +} + +func (s *fifoStockV2Service) failOperation(tx *gorm.DB, logRow *operationLogRow, failure error) { + if logRow == nil || logRow.ID == 0 || failure == nil { + return + } + _ = tx.Table("fifo_stock_v2_operation_log"). + Where("id = ?", logRow.ID). + Updates(map[string]any{ + "status": "FAILED", + "error_text": failure.Error(), + "finished_at": gorm.Expr("NOW()"), + }).Error +} + +func (s *fifoStockV2Service) resolveOverConsume( + tx *gorm.DB, + flagGroupCode string, + functionCode string, + lane Lane, + defaultValue bool, +) (bool, error) { + type row struct { + Allow bool `gorm:"column:allow_overconsume"` + } + selected := row{} + err := tx.Table("fifo_stock_v2_overconsume_rules"). + Select("allow_overconsume"). + Where("is_active = TRUE"). + Where("lane = ?", string(lane)). + Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode). + Where("(function_code IS NULL OR function_code = ?)", functionCode). + Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC"). + Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC"). + Order("priority ASC, id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return defaultValue, nil + } + return false, err + } + return selected.Allow, nil +} + +func (s *fifoStockV2Service) adjustProductWarehouseQty(tx *gorm.DB, productWarehouseID uint, delta float64) error { + if productWarehouseID == 0 || delta == 0 { + return nil + } + return tx.Table("product_warehouses"). + Where("id = ?", productWarehouseID). + Update("qty", gorm.Expr("COALESCE(qty,0) + ?", delta)).Error +} + +func nearlyZero(v float64) bool { + return math.Abs(v) < 1e-6 +} + +func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error { + checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key", "allocation_purpose"} + for _, col := range checkCols { + var count int64 + err := tx.Raw(` + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'stock_allocations' AND column_name = ? + `, col).Scan(&count).Error + if err != nil { + return err + } + if count == 0 { + return fmt.Errorf("stock_allocations.%s does not exist, run fifo_stock_v2 migration first", col) + } + } + return nil +} + +func activeAllocationStatus() string { + return entity.StockAllocationStatusActive +} + +func releasedAllocationStatus() string { + return entity.StockAllocationStatusReleased +} + +func defaultAllocationPurpose() string { + return entity.StockAllocationPurposeConsume +} + +func normalizeAllocationPurpose(purpose string) string { + purpose = strings.TrimSpace(strings.ToUpper(purpose)) + if purpose == "" { + return defaultAllocationPurpose() + } + return purpose +} diff --git a/internal/common/service/fifo_stock_v2/types.go b/internal/common/service/fifo_stock_v2/types.go new file mode 100644 index 00000000..abb7cc5b --- /dev/null +++ b/internal/common/service/fifo_stock_v2/types.go @@ -0,0 +1,143 @@ +package fifo_stock_v2 + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +type Lane string + +const ( + LaneStockable Lane = "STOCKABLE" + LaneUsable Lane = "USABLE" +) + +type Operation string + +const ( + OperationAllocate Operation = "ALLOCATE" + OperationRollback Operation = "ROLLBACK" + OperationReflow Operation = "REFLOW" + OperationRecalculate Operation = "RECALCULATE" +) + +type Ref struct { + Table string + ID uint + LegacyTypeKey string + FunctionCode string +} + +type GatherRequest struct { + FlagGroupCode string + Lane Lane + AllocationPurpose string + IgnoreSourceUsed bool + ProductWarehouseID uint + From *time.Time + AsOf *time.Time + Limit int + AfterSortAt *time.Time + AfterSourceTable string + AfterSourceID uint + ForUpdate bool + Tx *gorm.DB +} + +type GatherRow struct { + Ref Ref + FlagGroupCode string + ProductWarehouseID uint + SortAt time.Time + SortPriority int + Quantity float64 + UsedQuantity float64 + PendingQuantity float64 + AvailableQuantity float64 + SourceTable string + SourceID uint +} + +type AllocateRequest struct { + FlagGroupCode string + ProductWarehouseID uint + Usable Ref + NeedQty float64 + AllowOverConsume *bool + IdempotencyKey string + AsOf *time.Time + Tx *gorm.DB +} + +type AllocationDetail struct { + StockableType string + StockableID uint + Qty float64 + SortAt time.Time +} + +type AllocateResult struct { + AllocatedQty float64 + PendingQty float64 + Details []AllocationDetail +} + +type RollbackRequest struct { + ProductWarehouseID uint + Usable Ref + ReleaseQty *float64 + Reason string + IdempotencyKey string + Tx *gorm.DB +} + +type RollbackResult struct { + ReleasedQty float64 + Details []AllocationDetail +} + +type ReflowRequest struct { + FlagGroupCode string + ProductWarehouseID uint + AsOf *time.Time + IdempotencyKey string + Tx *gorm.DB +} + +type ReflowResult struct { + ProcessedUsables int + Rollback RollbackResult + Allocate AllocateResult +} + +type RecalculateRequest struct { + ProductWarehouseIDs []uint + FlagGroupCodes []string + AsOf *time.Time + FixDrift bool + IdempotencyKey string + Tx *gorm.DB +} + +type WarehouseDrift struct { + ProductWarehouseID uint + ExpectedQty float64 + ActualQty float64 + Delta float64 +} + +type RecalculateResult struct { + Checked int + Fixed int + Drifts []WarehouseDrift +} + +type Service interface { + Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error) + Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) + Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) + Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) + Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error) +} diff --git a/internal/config/config.go b/internal/config/config.go index 0c09ee33..e49ed16f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,61 +22,62 @@ type SSOClientConfig struct { } var ( - IsProd bool - AppHost string - Version string - LogLevel string - AppPort int - DBHost string - DBUser string - DBPassword string - DBName string - DBPort int - DBSSLMode string - DBSSLRootCert string - DBSSLCert string - DBSSLKey string - JWTSecret string - JWTAccessExp int - JWTRefreshExp int - JWTResetPasswordExp int - JWTVerifyEmailExp int - RedisURL string - CORSAllowOrigins []string - CORSAllowMethods []string - CORSAllowHeaders []string - CORSExposeHeaders []string - CORSAllowCredentials bool - CORSMaxAge int - SSOIssuer string - SSOJWKSURL string - SSOAllowedAudiences []string - SSOAuthorizeURL string - SSOTokenURL string - SSOGetMeURL string - SSOPortalURL string - SSOClients map[string]SSOClientConfig - SSOAccessCookieName string - SSOAccessCookieFallback []string - SSORefreshCookieName string - SSOCookieDomain string - SSOCookieSecure bool - SSOCookieSameSite string - SSOAccessTokenMaxBytes int - SSOTokenBlacklistPrefix string - SSOPKCETTL time.Duration - SSOUserSyncDrift time.Duration - SSOUserSyncNonceTTL time.Duration - SSOUserSyncMaxBodyBytes int - S3Endpoint string - S3Region string - S3Bucket string - S3AccessKey string - S3SecretKey string - S3ForcePathStyle bool - S3PublicBaseURL string - S3EnvPrefix string - S3DocumentKeyPrefix string + IsProd bool + AppHost string + Version string + LogLevel string + AppPort int + DBHost string + DBUser string + DBPassword string + DBName string + DBPort int + DBSSLMode string + DBSSLRootCert string + DBSSLCert string + DBSSLKey string + JWTSecret string + JWTAccessExp int + JWTRefreshExp int + JWTResetPasswordExp int + JWTVerifyEmailExp int + RedisURL string + CORSAllowOrigins []string + CORSAllowMethods []string + CORSAllowHeaders []string + CORSExposeHeaders []string + CORSAllowCredentials bool + CORSMaxAge int + SSOIssuer string + SSOJWKSURL string + SSOAllowedAudiences []string + SSOAuthorizeURL string + SSOTokenURL string + SSOGetMeURL string + SSOPortalURL string + SSOClients map[string]SSOClientConfig + SSOAccessCookieName string + SSOAccessCookieFallback []string + SSORefreshCookieName string + SSOCookieDomain string + SSOCookieSecure bool + SSOCookieSameSite string + SSOAccessTokenMaxBytes int + SSOTokenBlacklistPrefix string + SSOPKCETTL time.Duration + SSOUserSyncDrift time.Duration + SSOUserSyncNonceTTL time.Duration + SSOUserSyncMaxBodyBytes int + S3Endpoint string + S3Region string + S3Bucket string + S3AccessKey string + S3SecretKey string + S3ForcePathStyle bool + S3PublicBaseURL string + S3EnvPrefix string + S3DocumentKeyPrefix string + TransferToLayingGrowingMaxWeek int ) func init() { @@ -107,7 +108,7 @@ func init() { JWTResetPasswordExp = viper.GetInt("JWT_RESET_PASSWORD_EXP_MINUTES") JWTVerifyEmailExp = viper.GetInt("JWT_VERIFY_EMAIL_EXP_MINUTES") - //Cors + // Cors CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS") CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS") CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With") @@ -118,6 +119,11 @@ func init() { // Redis RedisURL = viper.GetString("REDIS_URL") + TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK") + if TransferToLayingGrowingMaxWeek <= 0 { + TransferToLayingGrowingMaxWeek = 19 + } + // Object storage S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT")) S3Region = strings.TrimSpace(viper.GetString("S3_REGION")) diff --git a/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.down.sql b/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.down.sql new file mode 100644 index 00000000..05786a61 --- /dev/null +++ b/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.down.sql @@ -0,0 +1,37 @@ +BEGIN; + +DELETE FROM fifo_stock_v2_overconsume_rules +WHERE reason IN ( + 'fifo_v2_default_allow', + 'fifo_v2_exception_ayam_depletion_block', + 'fifo_v2_exception_marketing_block', + 'fifo_v2_exception_transfer_block', + 'fifo_v2_exception_adjustment_block', + 'fifo_v2_exception_transfer_laying_block' +); + +DELETE FROM fifo_stock_v2_route_rules +WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); + +DELETE FROM fifo_stock_v2_traits +WHERE source_table IN ( + 'purchase_items', + 'stock_transfer_details', + 'laying_transfer_targets', + 'laying_transfer_sources', + 'adjustment_stocks', + 'recording_stocks', + 'recording_depletions', + 'recording_eggs', + 'marketing_delivery_products', + 'project_chickins', + 'project_flock_populations' +); + +DELETE FROM fifo_stock_v2_flag_members +WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); + +DELETE FROM fifo_stock_v2_flag_groups +WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE'); + +COMMIT; diff --git a/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.up.sql b/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.up.sql new file mode 100644 index 00000000..f791aebf --- /dev/null +++ b/internal/database/migrations/20260218090010_seed_fifo_stock_v2_config.up.sql @@ -0,0 +1,250 @@ +BEGIN; + +INSERT INTO fifo_stock_v2_flag_groups(code, name, priority) +VALUES + ('AYAM', 'AYAM', 10), + ('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20), + ('PAKAN', 'PAKAN', 30), + ('OVK', 'OVK', 40), + ('TELUR', 'TELUR', 50), + ('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60) +ON CONFLICT (code) DO UPDATE +SET + name = EXCLUDED.name, + priority = EXCLUDED.priority, + updated_at = NOW(); + +INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority) +VALUES + ('DOC', 'AYAM', 10), + ('PULLET', 'AYAM', 20), + ('LAYER', 'AYAM', 30), + + ('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10), + ('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20), + ('AYAM-MATI', 'AFKIR_CULLING_MATI', 30), + + ('PAKAN', 'PAKAN', 10), + ('PRE-STARTER', 'PAKAN', 20), + ('STARTER', 'PAKAN', 30), + ('FINISHER', 'PAKAN', 40), + + ('OVK', 'OVK', 10), + ('OBAT', 'OVK', 20), + ('VITAMIN', 'OVK', 30), + ('KIMIA', 'OVK', 40), + + ('TELUR', 'TELUR', 10), + + ('TELUR-UTUH', 'TELUR_GRADE', 10), + ('TELUR-PUTIH', 'TELUR_GRADE', 20), + ('TELUR-RETAK', 'TELUR_GRADE', 30), + ('TELUR-PECAH', 'TELUR_GRADE', 40), + ('TELUR-PAPACAL', 'TELUR_GRADE', 50), + ('TELUR-JUMBO', 'TELUR_GRADE', 60) +ON CONFLICT (flag_name) DO UPDATE +SET + flag_group_code = EXCLUDED.flag_group_code, + priority = EXCLUDED.priority, + updated_at = NOW(); + +INSERT INTO fifo_stock_v2_traits( + source_table, + lane, + date_table, + date_join_left_col, + date_join_right_col, + date_column, + fallback_date_column, + sort_priority, + id_column +) +VALUES + ('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'), + + ('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'), + ('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'), + + ('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'), + ('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'), + + ('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'), + ('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'), + + ('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'), + ('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'), + ('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'), + + ('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'), + + ('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'), + + ('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'), + ('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id') +ON CONFLICT (source_table, lane) DO UPDATE +SET + date_table = EXCLUDED.date_table, + date_join_left_col = EXCLUDED.date_join_left_col, + date_join_right_col = EXCLUDED.date_join_right_col, + date_column = EXCLUDED.date_column, + fallback_date_column = EXCLUDED.fallback_date_column, + sort_priority = EXCLUDED.sort_priority, + id_column = EXCLUDED.id_column, + is_active = TRUE; + +INSERT INTO fifo_stock_v2_route_rules( + flag_group_code, + lane, + function_code, + source_table, + source_id_column, + product_warehouse_col, + quantity_col, + used_quantity_col, + pending_quantity_col, + scope_sql, + legacy_type_key, + allow_pending_default, + is_active +) +VALUES + -- AYAM STOCKABLE + ('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), + ('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE), + ('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE), + + -- AYAM USABLE + ('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE), + ('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE), + ('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + ('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE), + + -- AFKIR/CULLING/MATI STOCKABLE + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE), + + -- AFKIR/CULLING/MATI USABLE + ('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- PAKAN STOCKABLE + ('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), + + -- PAKAN USABLE + ('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE), + ('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- OVK STOCKABLE + ('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE), + + -- OVK USABLE + ('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE), + ('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- TELUR STOCKABLE + ('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE), + + -- TELUR USABLE + ('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE), + + -- TELUR_GRADE STOCKABLE + ('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE), + ('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE), + ('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE), + + -- TELUR_GRADE USABLE + ('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE), + ('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE), + ('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE) +ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE +SET + source_id_column = EXCLUDED.source_id_column, + product_warehouse_col = EXCLUDED.product_warehouse_col, + quantity_col = EXCLUDED.quantity_col, + used_quantity_col = EXCLUDED.used_quantity_col, + pending_quantity_col = EXCLUDED.pending_quantity_col, + scope_sql = EXCLUDED.scope_sql, + legacy_type_key = EXCLUDED.legacy_type_key, + allow_pending_default = EXCLUDED.allow_pending_default, + is_active = EXCLUDED.is_active, + updated_at = NOW(); + +INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active) +SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM fifo_stock_v2_overconsume_rules + WHERE flag_group_code IS NULL + AND function_code IS NULL + AND lane = 'USABLE' + AND reason = 'fifo_v2_default_allow' +); + +INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active) +SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM fifo_stock_v2_overconsume_rules + WHERE flag_group_code = 'AYAM' + AND function_code = 'RECORDING_DEPLETION_OUT' + AND lane = 'USABLE' + AND reason = 'fifo_v2_exception_ayam_depletion_block' +); + +INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active) +SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM fifo_stock_v2_overconsume_rules + WHERE flag_group_code IS NULL + AND function_code = 'MARKETING_OUT' + AND lane = 'USABLE' + AND reason = 'fifo_v2_exception_marketing_block' +); + +INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active) +SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM fifo_stock_v2_overconsume_rules + WHERE flag_group_code IS NULL + AND function_code = 'STOCK_TRANSFER_OUT' + AND lane = 'USABLE' + AND reason = 'fifo_v2_exception_transfer_block' +); + +INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active) +SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM fifo_stock_v2_overconsume_rules + WHERE flag_group_code IS NULL + AND function_code = 'ADJUSTMENT_OUT' + AND lane = 'USABLE' + AND reason = 'fifo_v2_exception_adjustment_block' +); + +INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active) +SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE +WHERE NOT EXISTS ( + SELECT 1 FROM fifo_stock_v2_overconsume_rules + WHERE flag_group_code IS NULL + AND function_code = 'TRANSFER_TO_LAYING_OUT' + AND lane = 'USABLE' + AND reason = 'fifo_v2_exception_transfer_laying_block' +); + +COMMIT; diff --git a/internal/database/migrations/20260225073024_adjustment_stocks_add_fifo_v2_columns.down.sql b/internal/database/migrations/20260225073024_adjustment_stocks_add_fifo_v2_columns.down.sql new file mode 100644 index 00000000..37027112 --- /dev/null +++ b/internal/database/migrations/20260225073024_adjustment_stocks_add_fifo_v2_columns.down.sql @@ -0,0 +1,12 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_adjustment_stocks_function_code; +DROP INDEX IF EXISTS idx_adjustment_stocks_transaction_type; + +ALTER TABLE adjustment_stocks + DROP COLUMN IF EXISTS grand_total, + DROP COLUMN IF EXISTS price, + DROP COLUMN IF EXISTS function_code, + DROP COLUMN IF EXISTS transaction_type; + +COMMIT; diff --git a/internal/database/migrations/20260225073024_adjustment_stocks_add_fifo_v2_columns.up.sql b/internal/database/migrations/20260225073024_adjustment_stocks_add_fifo_v2_columns.up.sql new file mode 100644 index 00000000..b2d979c5 --- /dev/null +++ b/internal/database/migrations/20260225073024_adjustment_stocks_add_fifo_v2_columns.up.sql @@ -0,0 +1,23 @@ +BEGIN; + +ALTER TABLE adjustment_stocks + ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(100) NOT NULL DEFAULT 'LEGACY', + ADD COLUMN IF NOT EXISTS function_code VARCHAR(64), + ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0; + +UPDATE adjustment_stocks +SET function_code = CASE + WHEN COALESCE(total_qty, 0) > 0 THEN 'ADJUSTMENT_IN' + WHEN COALESCE(usage_qty, 0) > 0 THEN 'ADJUSTMENT_OUT' + ELSE 'ADJUSTMENT_IN' +END +WHERE function_code IS NULL OR function_code = ''; + +CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_transaction_type + ON adjustment_stocks(transaction_type); + +CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_function_code + ON adjustment_stocks(function_code); + +COMMIT; 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/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql new file mode 100644 index 00000000..b6eb5a76 --- /dev/null +++ b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.down.sql @@ -0,0 +1,13 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_stock_allocations_purpose_stockable_active; +DROP INDEX IF EXISTS idx_stock_allocations_purpose_usable_active; +DROP INDEX IF EXISTS idx_stock_allocations_purpose_status; + +ALTER TABLE stock_allocations + DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check; + +ALTER TABLE stock_allocations + DROP COLUMN IF EXISTS allocation_purpose; + +COMMIT; diff --git a/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql new file mode 100644 index 00000000..3b63e37b --- /dev/null +++ b/internal/database/migrations/20260302064432_add_allocation_purpose_to_stock_allocations.up.sql @@ -0,0 +1,33 @@ +BEGIN; + +ALTER TABLE stock_allocations + ADD COLUMN IF NOT EXISTS allocation_purpose VARCHAR(32); + +UPDATE stock_allocations +SET allocation_purpose = 'CONSUME' +WHERE allocation_purpose IS NULL + OR BTRIM(allocation_purpose) = ''; + +ALTER TABLE stock_allocations + ALTER COLUMN allocation_purpose SET DEFAULT 'CONSUME', + ALTER COLUMN allocation_purpose SET NOT NULL; + +ALTER TABLE stock_allocations + DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check; + +ALTER TABLE stock_allocations + ADD CONSTRAINT stock_allocations_allocation_purpose_check + CHECK (allocation_purpose IN ('CONSUME', 'TRACE_CHICKIN')); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_status + ON stock_allocations (allocation_purpose, status); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_usable_active + ON stock_allocations (allocation_purpose, usable_type, usable_id) + WHERE status = 'ACTIVE'; + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_stockable_active + ON stock_allocations (allocation_purpose, stockable_type, stockable_id) + WHERE status = 'ACTIVE'; + +COMMIT; diff --git a/internal/database/migrations/20260304033546_create_fifo_stock_v2_core.down.sql b/internal/database/migrations/20260304033546_create_fifo_stock_v2_core.down.sql new file mode 100644 index 00000000..0aee55e2 --- /dev/null +++ b/internal/database/migrations/20260304033546_create_fifo_stock_v2_core.down.sql @@ -0,0 +1,24 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_stock_allocations_idempotency; +DROP INDEX IF EXISTS idx_stock_allocations_flag_group; +DROP INDEX IF EXISTS idx_stock_allocations_engine_version; + +ALTER TABLE stock_allocations + DROP COLUMN IF EXISTS idempotency_key, + DROP COLUMN IF EXISTS reflow_run_id, + DROP COLUMN IF EXISTS function_code, + DROP COLUMN IF EXISTS flag_group_code, + DROP COLUMN IF EXISTS engine_version; + +DROP TABLE IF EXISTS fifo_stock_v2_shadow_allocations; +DROP TABLE IF EXISTS fifo_stock_v2_reflow_checkpoints; +DROP TABLE IF EXISTS fifo_stock_v2_reflow_runs; +DROP TABLE IF EXISTS fifo_stock_v2_operation_log; +DROP TABLE IF EXISTS fifo_stock_v2_overconsume_rules; +DROP TABLE IF EXISTS fifo_stock_v2_route_rules; +DROP TABLE IF EXISTS fifo_stock_v2_traits; +DROP TABLE IF EXISTS fifo_stock_v2_flag_members; +DROP TABLE IF EXISTS fifo_stock_v2_flag_groups; + +COMMIT; diff --git a/internal/database/migrations/20260304033546_create_fifo_stock_v2_core.up.sql b/internal/database/migrations/20260304033546_create_fifo_stock_v2_core.up.sql new file mode 100644 index 00000000..24ae2412 --- /dev/null +++ b/internal/database/migrations/20260304033546_create_fifo_stock_v2_core.up.sql @@ -0,0 +1,151 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_groups ( + code VARCHAR(64) PRIMARY KEY, + name VARCHAR(128) NOT NULL, + priority INT NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_members ( + flag_name VARCHAR(64) PRIMARY KEY, + flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code), + priority INT NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_traits ( + id BIGSERIAL PRIMARY KEY, + source_table VARCHAR(64) NOT NULL, + lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')), + date_table VARCHAR(64) NULL, + date_join_left_col VARCHAR(64) NULL, + date_join_right_col VARCHAR(64) NULL, + date_column VARCHAR(64) NOT NULL, + fallback_date_column VARCHAR(64) NULL, + sort_priority INT NOT NULL DEFAULT 100, + id_column VARCHAR(64) NOT NULL DEFAULT 'id', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE (source_table, lane) +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_route_rules ( + id BIGSERIAL PRIMARY KEY, + flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code), + lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')), + function_code VARCHAR(64) NOT NULL, + source_table VARCHAR(64) NOT NULL, + source_id_column VARCHAR(64) NOT NULL DEFAULT 'id', + product_warehouse_col VARCHAR(64) NOT NULL, + quantity_col VARCHAR(64) NOT NULL, + used_quantity_col VARCHAR(64) NULL, + pending_quantity_col VARCHAR(64) NULL, + scope_sql TEXT NULL, + legacy_type_key VARCHAR(100) NOT NULL, + allow_pending_default BOOLEAN NOT NULL DEFAULT TRUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (flag_group_code, lane, function_code, source_table) +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_overconsume_rules ( + id BIGSERIAL PRIMARY KEY, + flag_group_code VARCHAR(64) NULL REFERENCES fifo_stock_v2_flag_groups(code), + function_code VARCHAR(64) NULL, + lane VARCHAR(16) NOT NULL DEFAULT 'USABLE' CHECK (lane IN ('STOCKABLE', 'USABLE')), + allow_overconsume BOOLEAN NOT NULL, + priority INT NOT NULL DEFAULT 100, + reason TEXT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_operation_log ( + id BIGSERIAL PRIMARY KEY, + idempotency_key VARCHAR(128) NOT NULL, + operation VARCHAR(16) NOT NULL CHECK (operation IN ('ALLOCATE', 'ROLLBACK', 'REFLOW', 'RECALCULATE')), + product_warehouse_id BIGINT NOT NULL, + flag_group_code VARCHAR(64) NOT NULL, + usable_type VARCHAR(100) NULL, + usable_id BIGINT NULL, + request_hash VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'DONE', 'FAILED')), + result_payload JSONB NULL, + error_text TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ NULL, + UNIQUE (idempotency_key, operation) +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_runs ( + id BIGSERIAL PRIMARY KEY, + mode VARCHAR(16) NOT NULL CHECK (mode IN ('DRY_RUN', 'APPLY')), + status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED')), + as_of TIMESTAMPTZ NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ NULL, + total_shards INT NOT NULL DEFAULT 0, + processed_shards INT NOT NULL DEFAULT 0, + processed_rows BIGINT NOT NULL DEFAULT 0, + mismatch_rows BIGINT NOT NULL DEFAULT 0, + created_by BIGINT NULL, + note TEXT NULL +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_checkpoints ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE, + flag_group_code VARCHAR(64) NOT NULL, + product_warehouse_id BIGINT NOT NULL, + last_sort_at TIMESTAMPTZ NULL, + last_source_table VARCHAR(64) NULL, + last_source_id BIGINT NULL, + status VARCHAR(16) NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')) DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (run_id, flag_group_code, product_warehouse_id) +); + +CREATE TABLE IF NOT EXISTS fifo_stock_v2_shadow_allocations ( + id BIGSERIAL PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE, + product_warehouse_id BIGINT NOT NULL, + stockable_type VARCHAR(100) NOT NULL, + stockable_id BIGINT NOT NULL, + usable_type VARCHAR(100) NOT NULL, + usable_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + sort_at TIMESTAMPTZ NULL, + source_table VARCHAR(64) NULL, + source_id BIGINT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_usable + ON fifo_stock_v2_shadow_allocations(run_id, usable_type, usable_id); + +CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_stockable + ON fifo_stock_v2_shadow_allocations(run_id, stockable_type, stockable_id); + +ALTER TABLE stock_allocations + ADD COLUMN IF NOT EXISTS engine_version VARCHAR(8) NOT NULL DEFAULT 'v1', + ADD COLUMN IF NOT EXISTS flag_group_code VARCHAR(64) NULL, + ADD COLUMN IF NOT EXISTS function_code VARCHAR(64) NULL, + ADD COLUMN IF NOT EXISTS reflow_run_id BIGINT NULL, + ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128) NULL; + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_engine_version + ON stock_allocations(engine_version); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_flag_group + ON stock_allocations(flag_group_code); + +CREATE INDEX IF NOT EXISTS idx_stock_allocations_idempotency + ON stock_allocations(idempotency_key); + +COMMIT; diff --git a/internal/database/migrations/20260304033605_add_deferred_execution_to_laying_transfers.down.sql b/internal/database/migrations/20260304033605_add_deferred_execution_to_laying_transfers.down.sql new file mode 100644 index 00000000..6b9143f2 --- /dev/null +++ b/internal/database/migrations/20260304033605_add_deferred_execution_to_laying_transfers.down.sql @@ -0,0 +1,15 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_laying_transfers_executed_by; +DROP INDEX IF EXISTS idx_laying_transfers_executed_at; +DROP INDEX IF EXISTS idx_laying_transfers_effective_move_date; + +ALTER TABLE laying_transfers + DROP CONSTRAINT IF EXISTS fk_laying_transfers_executed_by; + +ALTER TABLE laying_transfers + DROP COLUMN IF EXISTS executed_by, + DROP COLUMN IF EXISTS executed_at, + DROP COLUMN IF EXISTS effective_move_date; + +COMMIT; diff --git a/internal/database/migrations/20260304033605_add_deferred_execution_to_laying_transfers.up.sql b/internal/database/migrations/20260304033605_add_deferred_execution_to_laying_transfers.up.sql new file mode 100644 index 00000000..b90c7a3a --- /dev/null +++ b/internal/database/migrations/20260304033605_add_deferred_execution_to_laying_transfers.up.sql @@ -0,0 +1,50 @@ +BEGIN; + +ALTER TABLE laying_transfers + ADD COLUMN IF NOT EXISTS effective_move_date DATE, + ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS executed_by BIGINT; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') + AND NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_laying_transfers_executed_by' + ) THEN + ALTER TABLE laying_transfers + ADD CONSTRAINT fk_laying_transfers_executed_by + FOREIGN KEY (executed_by) + REFERENCES users(id) + ON DELETE SET NULL + ON UPDATE CASCADE; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_effective_move_date + ON laying_transfers(effective_move_date); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_at + ON laying_transfers(executed_at); + +CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_by + ON laying_transfers(executed_by); + +-- Backfill historical approved transfers. Before deferred execution, +-- approved transfers were executed immediately during approval. +UPDATE laying_transfers lt +SET + effective_move_date = COALESCE(lt.effective_move_date, lt.transfer_date), + executed_at = COALESCE(lt.executed_at, lt.updated_at), + executed_by = COALESCE(lt.executed_by, lt.created_by) +WHERE ( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = 'TRANSFER_TO_LAYINGS' + AND a.approvable_id = lt.id + ORDER BY a.id DESC + LIMIT 1 +) = 'APPROVED'; + +COMMIT; diff --git a/internal/database/migrations/20260304063215_fix_fifo_chickin_out.down.sql b/internal/database/migrations/20260304063215_fix_fifo_chickin_out.down.sql new file mode 100644 index 00000000..26e637ff --- /dev/null +++ b/internal/database/migrations/20260304063215_fix_fifo_chickin_out.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +DELETE FROM fifo_stock_v2_route_rules +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/20260304063215_fix_fifo_chickin_out.up.sql b/internal/database/migrations/20260304063215_fix_fifo_chickin_out.up.sql new file mode 100644 index 00000000..3967da36 --- /dev/null +++ b/internal/database/migrations/20260304063215_fix_fifo_chickin_out.up.sql @@ -0,0 +1,34 @@ +BEGIN; + +INSERT INTO fifo_stock_v2_route_rules( + flag_group_code, + lane, + function_code, + source_table, + source_id_column, + product_warehouse_col, + quantity_col, + used_quantity_col, + pending_quantity_col, + scope_sql, + legacy_type_key, + allow_pending_default, + is_active +) +VALUES + ('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE) +ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE +SET + source_id_column = EXCLUDED.source_id_column, + product_warehouse_col = EXCLUDED.product_warehouse_col, + quantity_col = EXCLUDED.quantity_col, + used_quantity_col = EXCLUDED.used_quantity_col, + pending_quantity_col = EXCLUDED.pending_quantity_col, + scope_sql = EXCLUDED.scope_sql, + legacy_type_key = EXCLUDED.legacy_type_key, + allow_pending_default = EXCLUDED.allow_pending_default, + updated_at = NOW(), + -- Keep existing is_active (do not override disable migration if it was intentional). + is_active = fifo_stock_v2_route_rules.is_active; + +COMMIT; diff --git a/internal/database/migrations/20260305105044_add_ayam_flag_member_fifo_v2.down.sql b/internal/database/migrations/20260305105044_add_ayam_flag_member_fifo_v2.down.sql new file mode 100644 index 00000000..52747c92 --- /dev/null +++ b/internal/database/migrations/20260305105044_add_ayam_flag_member_fifo_v2.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +DELETE FROM fifo_stock_v2_flag_members +WHERE flag_name = 'AYAM' + AND flag_group_code = 'AYAM'; + +COMMIT; diff --git a/internal/database/migrations/20260305105044_add_ayam_flag_member_fifo_v2.up.sql b/internal/database/migrations/20260305105044_add_ayam_flag_member_fifo_v2.up.sql new file mode 100644 index 00000000..28c495ed --- /dev/null +++ b/internal/database/migrations/20260305105044_add_ayam_flag_member_fifo_v2.up.sql @@ -0,0 +1,13 @@ +BEGIN; + +INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority, is_active, created_at, updated_at) +VALUES + ('AYAM', 'AYAM', 5, TRUE, NOW(), NOW()) +ON CONFLICT (flag_name) DO UPDATE +SET + flag_group_code = EXCLUDED.flag_group_code, + priority = EXCLUDED.priority, + is_active = TRUE, + updated_at = NOW(); + +COMMIT; diff --git a/internal/entities/adjustment_stock.go b/internal/entities/adjustment_stock.go index 9ccf9246..ec3326d8 100644 --- a/internal/entities/adjustment_stock.go +++ b/internal/entities/adjustment_stock.go @@ -5,10 +5,14 @@ import "time" type AdjustmentStock struct { Id uint `gorm:"primaryKey"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"` + FunctionCode string `gorm:"column:function_code;type:varchar(64)"` TotalQty float64 `gorm:"column:total_qty;default:0"` TotalUsed float64 `gorm:"column:total_used;default:0"` UsageQty float64 `gorm:"column:usage_qty;default:0"` PendingQty float64 `gorm:"column:pending_qty;default:0"` + Price float64 `gorm:"column:price;type:numeric(15,3);default:0"` + GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);default:0"` CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"` UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"` AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"` diff --git a/internal/entities/laying_transfer.go b/internal/entities/laying_transfer.go index f983519f..db5ca775 100644 --- a/internal/entities/laying_transfer.go +++ b/internal/entities/laying_transfer.go @@ -12,16 +12,20 @@ type LayingTransfer struct { FromProjectFlockId uint `gorm:"not null"` ToProjectFlockId uint `gorm:"not null"` TransferDate time.Time `gorm:"type:date;not null"` + EffectiveMoveDate *time.Time `gorm:"type:date"` + ExecutedAt *time.Time `gorm:"type:timestamptz"` + ExecutedBy *uint `gorm:"index"` Notes string `gorm:"type:text"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt gorm.DeletedAt `gorm:"index"` - FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` - ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` - CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` - Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` - Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` - LatestApproval *Approval `gorm:"-" json:"-"` + FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"` + ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"` + Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` + Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"` + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/stock_allocation.go b/internal/entities/stock_allocation.go index 614762a1..c3aa1c28 100644 --- a/internal/entities/stock_allocation.go +++ b/internal/entities/stock_allocation.go @@ -10,6 +10,9 @@ const ( StockAllocationStatusPending = "PENDING" StockAllocationStatusActive = "ACTIVE" StockAllocationStatusReleased = "RELEASED" + + StockAllocationPurposeConsume = "CONSUME" + StockAllocationPurposeTraceChickin = "TRACE_CHICKIN" ) // StockAllocation links a usable record (consumption) with an incoming stock record. @@ -22,7 +25,8 @@ type StockAllocation struct { UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"` UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"` Qty float64 `gorm:"type:numeric(15,3);not null"` - Status string `gorm:"size:20;not null;default:ACTIVE"` + AllocationPurpose string `gorm:"size:32;not null;default:CONSUME;index:stock_allocations_purpose_status,priority:1"` + Status string `gorm:"size:20;not null;default:ACTIVE;index:stock_allocations_purpose_status,priority:2"` Note *string `gorm:"type:text"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 12aec564..077d4046 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1098,6 +1098,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.C Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id"). Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw_pc.product_id, pw.product_id)"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). Where("f.name IN ?", sapronakFlagsAll). Where(` @@ -1307,6 +1308,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("f.name IN ?", sapronakFlagsAll). Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)). @@ -1401,6 +1403,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id"). Joins("JOIN products p ON p.id = std.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). @@ -1433,6 +1436,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("w.kandang_id = ?", kandangID). Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)"). Where("f.name IN ?", sapronakFlagsAll). @@ -1469,6 +1473,7 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Where("sa.status = ?", entity.StockAllocationStatusActive). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price") @@ -1496,9 +1501,10 @@ func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectF Joins("JOIN marketings m ON m.id = mp.marketing_id"). Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). - Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?", + Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyMarketingDelivery.String(), entity.StockAllocationStatusActive, + entity.StockAllocationPurposeConsume, ). Where("mdp.usage_qty > 0"). Where("sa.id IS NULL"). @@ -1613,8 +1619,9 @@ func (r *ClosingRepositoryImpl) FetchSapronakSalesAllocatedDetails(ctx context.C Joins("LEFT JOIN product_warehouses pw_pc ON pw_pc.id = pc.product_warehouse_id"). Joins(fmt.Sprintf("LEFT JOIN products p_resolve ON p_resolve.id = CASE WHEN sa.stockable_type = '%s' THEN pw_pc.product_id ELSE COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id) END", pfpType)). Where("sa.status = ?", entity.StockAllocationStatusActive). - Where("sa.stockable_type <> ?", fifo.StockableKeyRecordingEgg.String()). - Where("pw_sales.project_flock_kandang_id = ?", projectFlockKandangID). + Where("sa.allocation_purpose = ?", entity.StockAllocationPurposeConsume). + Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()). + Where("pw.project_flock_kandang_id = ?", projectFlockKandangID). Where("f.name IN ?", sapronakFlagsAll). Group(` p_resolve.id, p_resolve.name, f.name, @@ -1640,7 +1647,6 @@ func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, p Preload("Flags"). Where("id IN ?", productIDs). Find(&products).Error - if err != nil { return nil, err } diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index b9c9cc48..3d67bd71 100644 --- a/internal/modules/constants/repositories/constant.repository.go +++ b/internal/modules/constants/repositories/constant.repository.go @@ -12,7 +12,7 @@ import ( ) type ConstantRepository interface { - GetConstants() map[string]interface{} + GetConstants() (map[string]interface{}, error) } type ConstantRepositoryImpl struct { @@ -25,13 +25,51 @@ func NewConstantRepository(db *gorm.DB) ConstantRepository { } } -func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { +func (r *ConstantRepositoryImpl) GetConstants() (map[string]interface{}, error) { flagList := make([]string, 0) for f := range utils.AllFlagTypes() { flagList = append(flagList, string(f)) } sort.Strings(flagList) + productMainFlags := utils.ProductMainFlags() + productMainFlagValues := make([]string, len(productMainFlags)) + for i, flag := range productMainFlags { + productMainFlagValues[i] = string(flag) + } + + type productFlagOption struct { + Flag string `json:"flag"` + SubFlags []string `json:"sub_flags"` + AllowWithoutSubFlag bool `json:"allow_without_sub_flag"` + } + + productOptions := utils.ProductFlagOptions() + productFlagOptions := make([]productFlagOption, 0, len(productOptions)) + for _, option := range productOptions { + subFlags := make([]string, len(option.SubFlags)) + for i, subFlag := range option.SubFlags { + subFlags[i] = string(subFlag) + } + productFlagOptions = append(productFlagOptions, productFlagOption{ + Flag: string(option.Flag), + SubFlags: subFlags, + AllowWithoutSubFlag: option.AllowWithoutSubFlag, + }) + } + + productSubFlagToFlagRaw := utils.ProductSubFlagToFlag() + productSubFlagToFlag := make(map[string]string, len(productSubFlagToFlagRaw)) + for subFlag, flag := range productSubFlagToFlagRaw { + productSubFlagToFlag[string(subFlag)] = string(flag) + } + + legacyAliasesRaw := utils.LegacyFlagTypeAliases() + legacyAliases := make(map[string]string, len(legacyAliasesRaw)) + for legacy, canonical := range legacyAliasesRaw { + legacyAliases[string(legacy)] = string(canonical) + } + type approvalStepConstant struct { StepNumber uint16 `json:"step_number"` StepName string `json:"step_name"` @@ -75,6 +113,8 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { }) } + adjustmentSubtypesByType := utils.AdjustmentTransactionSubtypesByTypeForFrontend() + return map[string]interface{}{ "flags": flagList, "warehouse_types": []string{ @@ -94,6 +134,15 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { "BISNIS", "INDIVIDUAL", }, - "approval_workflows": approvalWorkflows, - } + "adjustment": map[string]interface{}{ + "transaction_subtypes": adjustmentSubtypesByType, + }, + "legacy_flag_aliases": legacyAliases, + "product_flag_mapping": map[string]interface{}{ + "flags": productMainFlagValues, + "options": productFlagOptions, + "sub_flag_to_flag": productSubFlagToFlag, + }, + "approval_workflows": approvalWorkflows, + }, nil } diff --git a/internal/modules/constants/services/constant.service.go b/internal/modules/constants/services/constant.service.go index e32fc7d4..d2fd4f93 100644 --- a/internal/modules/constants/services/constant.service.go +++ b/internal/modules/constants/services/constant.service.go @@ -22,5 +22,5 @@ func NewConstantService(repo repository.ConstantRepository, validate *validator. } func (s constantService) GetAll(c *fiber.Ctx) (map[string]interface{}, error) { - return s.Repository.GetConstants(), nil + return s.Repository.GetConstants() } diff --git a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go index 617a1b5f..e3f46b9f 100644 --- a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go +++ b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go @@ -47,11 +47,13 @@ func (u *AdjustmentController) Adjustment(c *fiber.Ctx) error { func (u *AdjustmentController) AdjustmentHistory(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - ProductID: uint(c.QueryInt("product_id", 0)), - WarehouseID: uint(c.QueryInt("warehouse_id", 0)), - TransactionType: c.Query("transaction_type", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProductID: uint(c.QueryInt("product_id", 0)), + WarehouseID: uint(c.QueryInt("warehouse_id", 0)), + TransactionType: c.Query("transaction_type", ""), + TransactionSubtype: c.Query("transaction_subtype", ""), + FunctionCode: c.Query("function_code", ""), } result, totalResults, err := u.AdjustmentService.AdjustmentHistory(c, query) diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index c07f84f9..f6753848 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -17,27 +17,49 @@ type ProductRelationDTO struct { ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` } -type WarehouseRelationDTO struct { +type LocationRelationDTO struct { Id uint `json:"id"` Name string `json:"name"` } +type ProjectFlockRelationDTO struct { + Id uint `json:"id"` + FlockName string `json:"flock_name"` + Period int `json:"period"` +} + +type WarehouseRelationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Location *LocationRelationDTO `json:"location,omitempty"` +} + type ProductWarehouseDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - WarehouseId uint `json:"warehouse_id"` - Quantity float64 `json:"quantity"` - Product *ProductRelationDTO `json:"product,omitempty"` - Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + Quantity float64 `json:"quantity"` + Product *ProductRelationDTO `json:"product,omitempty"` + Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"` + ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"` } type AdjustmentRelationDTO struct { - Id uint `json:"id"` - Increase float64 `json:"increase"` - Decrease float64 `json:"decrease"` - Note string `json:"note,omitempty"` - ProductWarehouseId uint `json:"product_warehouse_id"` - ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"` + Id uint `json:"id"` + AdjNumber string `json:"adj_number"` + TransactionType string `json:"transaction_type"` + TransactionSubtype string `json:"transaction_subtype"` + FunctionCode string `json:"function_code"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + GrandTotal float64 `json:"grand_total"` + Increase float64 `json:"increase"` + Decrease float64 `json:"decrease"` + Notes string `json:"notes,omitempty"` + Location *LocationRelationDTO `json:"location,omitempty"` + ProjectFlock *ProjectFlockRelationDTO `json:"project_flock,omitempty"` + ProductWarehouseId uint `json:"product_warehouse_id"` + ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"` } type AdjustmentListDTO struct { @@ -81,31 +103,80 @@ func ToWarehouseRelationDTO(e *entity.Warehouse) *WarehouseRelationDTO { return nil } return &WarehouseRelationDTO{ + Id: e.Id, + Name: e.Name, + Location: ToLocationRelationDTO(e.Location), + } +} + +func ToLocationRelationDTO(e *entity.Location) *LocationRelationDTO { + if e == nil { + return nil + } + return &LocationRelationDTO{ Id: e.Id, Name: e.Name, } } +func ToProjectFlockRelationDTO(e *entity.ProjectFlockKandang) *ProjectFlockRelationDTO { + if e == nil || e.ProjectFlock.Id == 0 { + return nil + } + return &ProjectFlockRelationDTO{ + Id: e.ProjectFlock.Id, + FlockName: e.ProjectFlock.FlockName, + Period: e.Period, + } +} + func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { if e == nil { return nil } return &ProductWarehouseDTO{ - Id: e.Id, - ProductId: e.ProductId, - WarehouseId: e.WarehouseId, - Quantity: e.Quantity, - Product: ToProductRelationDTO(&e.Product), - Warehouse: ToWarehouseRelationDTO(&e.Warehouse), + Id: e.Id, + ProductId: e.ProductId, + WarehouseId: e.WarehouseId, + Quantity: e.Quantity, + Product: ToProductRelationDTO(&e.Product), + Warehouse: ToWarehouseRelationDTO(&e.Warehouse), + ProjectFlock: ToProjectFlockRelationDTO(e.ProjectFlockKandang), } } func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { + note := "" + if e.StockLog != nil { + note = e.StockLog.Notes + } + + qty := e.TotalQty + if qty <= 0 { + qty = e.UsageQty + e.PendingQty + } + + var location *LocationRelationDTO + var projectFlock *ProjectFlockRelationDTO + if e.ProductWarehouse != nil { + location = ToLocationRelationDTO(e.ProductWarehouse.Warehouse.Location) + projectFlock = ToProjectFlockRelationDTO(e.ProductWarehouse.ProjectFlockKandang) + } + return AdjustmentRelationDTO{ Id: e.Id, - Note: "", + AdjNumber: e.AdjNumber, + TransactionType: e.TransactionType, + TransactionSubtype: e.FunctionCode, + FunctionCode: e.FunctionCode, + Qty: qty, + Price: e.Price, + GrandTotal: e.GrandTotal, Increase: e.TotalQty, Decrease: e.UsageQty, + Notes: note, + Location: location, + ProjectFlock: projectFlock, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 6b137902..42c8332d 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -5,7 +5,6 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rAdjustmentStock "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/repositories" sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" @@ -17,7 +16,6 @@ import ( rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type AdjustmentModule struct{} @@ -31,41 +29,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) adjustmentStockRepo := rAdjustmentStock.NewAdjustmentStockRepository(db) - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) - - fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - - err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyAdjustmentIn, - Table: "adjustment_stocks", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic("Failed to register ADJUSTMENT_IN as Stockable: " + err.Error()) - } - - err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyAdjustmentOut, - Table: "adjustment_stocks", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic("Failed to register ADJUSTMENT_OUT as Usable: " + err.Error()) - } + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) adjustmentService := sAdjustment.NewAdjustmentService( productRepo, @@ -73,7 +37,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat warehouseRepo, productWarehouseRepo, adjustmentStockRepo, - fifoService, + fifoStockV2Service, validate, projectFlockKandangRepo, ) diff --git a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go index 9409fd73..ca3f4ff8 100644 --- a/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go +++ b/internal/modules/inventory/adjustments/repositories/adjustment_stock.repository.go @@ -2,6 +2,7 @@ package repositories import ( "context" + "errors" "fmt" "strconv" "strings" @@ -14,11 +15,35 @@ import ( type AdjustmentStockRepository interface { CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) + FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) + FindRoutesByFunctionCode(ctx context.Context, productID uint, functionCode string) ([]AdjustmentRouteResolution, error) + FindOverconsumeRule(ctx context.Context, lane, flagGroupCode, functionCode string) (*bool, error) + FindHistory(ctx context.Context, filter AdjustmentHistoryFilter, modifier func(*gorm.DB) *gorm.DB) ([]*entity.AdjustmentStock, int64, error) WithTx(tx *gorm.DB) AdjustmentStockRepository DB() *gorm.DB GenerateSequentialNumber(ctx context.Context, prefix string) (string, error) } +type AdjustmentRouteResolution struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + Lane string `gorm:"column:lane"` + FunctionCode string `gorm:"column:function_code"` + SourceTable string `gorm:"column:source_table"` + LegacyTypeKey string `gorm:"column:legacy_type_key"` + AllowPendingDefault bool `gorm:"column:allow_pending_default"` +} + +type AdjustmentHistoryFilter struct { + ProductID uint + WarehouseID uint + TransactionType string + FunctionCode string + ScopeRestrict bool + ScopeIDs []uint + Offset int + Limit int +} + type adjustmentStockRepositoryImpl struct { db *gorm.DB } @@ -48,6 +73,151 @@ func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, mo return &record, nil } +func (r *adjustmentStockRepositoryImpl) FindKandangIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (uint, error) { + type pfkRow struct { + KandangID uint `gorm:"column:kandang_id"` + } + + var pfk pfkRow + err := r.db.WithContext(ctx). + Table("project_flock_kandangs pfk"). + Select("pfk.kandang_id"). + Where("pfk.id = ?", projectFlockKandangID). + Where("pfk.closed_at IS NULL"). + Take(&pfk).Error + if err != nil { + return 0, err + } + return pfk.KandangID, nil +} + +func (r *adjustmentStockRepositoryImpl) FindRoutesByFunctionCode( + ctx context.Context, + productID uint, + functionCode string, +) ([]AdjustmentRouteResolution, error) { + var rows []AdjustmentRouteResolution + err := r.db.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key, rr.allow_pending_default"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.function_code = ?", functionCode). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE f.flagable_type = ? + AND f.flagable_id = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, entity.FlagableTypeProduct, productID). + Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC"). + Order("rr.id ASC"). + Find(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func (r *adjustmentStockRepositoryImpl) FindOverconsumeRule( + ctx context.Context, + lane string, + flagGroupCode string, + functionCode string, +) (*bool, error) { + type selectedRow struct { + AllowOverconsume bool `gorm:"column:allow_overconsume"` + } + + var selected selectedRow + err := r.db.WithContext(ctx). + Table("fifo_stock_v2_overconsume_rules"). + Select("allow_overconsume"). + Where("is_active = TRUE"). + Where("lane = ?", lane). + Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode). + Where("(function_code IS NULL OR function_code = ?)", functionCode). + Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC"). + Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC"). + Order("priority ASC, id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + + return &selected.AllowOverconsume, nil +} + +func (r *adjustmentStockRepositoryImpl) FindHistory( + ctx context.Context, + filter AdjustmentHistoryFilter, + modifier func(*gorm.DB) *gorm.DB, +) ([]*entity.AdjustmentStock, int64, error) { + q := r.db.WithContext(ctx).Model(&entity.AdjustmentStock{}). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse"). + Preload("ProductWarehouse.Warehouse.Location"). + Preload("ProductWarehouse.ProjectFlockKandang"). + Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock"). + Preload("StockLog.CreatedUser") + + if modifier != nil { + q = modifier(q) + } + + if filter.ScopeRestrict { + q = q.Joins("JOIN product_warehouses pw_scope ON pw_scope.id = adjustment_stocks.product_warehouse_id"). + Joins("JOIN warehouses w_scope ON w_scope.id = pw_scope.warehouse_id") + if len(filter.ScopeIDs) == 0 { + q = q.Where("1 = 0") + } else { + q = q.Where("w_scope.location_id IN ?", filter.ScopeIDs) + } + } + + if filter.ProductID > 0 || filter.WarehouseID > 0 { + q = q.Joins("JOIN product_warehouses pw_filter ON pw_filter.id = adjustment_stocks.product_warehouse_id") + if filter.ProductID > 0 { + q = q.Where("pw_filter.product_id = ?", filter.ProductID) + } + if filter.WarehouseID > 0 { + q = q.Where("pw_filter.warehouse_id = ?", filter.WarehouseID) + } + } + + if strings.TrimSpace(filter.TransactionType) != "" { + q = q.Where("UPPER(adjustment_stocks.transaction_type) = ?", strings.ToUpper(strings.TrimSpace(filter.TransactionType))) + } + + if strings.TrimSpace(filter.FunctionCode) != "" { + q = q.Where("adjustment_stocks.function_code = ?", strings.ToUpper(strings.TrimSpace(filter.FunctionCode))) + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var rows []entity.AdjustmentStock + if err := q.Offset(filter.Offset).Limit(filter.Limit).Order("created_at DESC").Find(&rows).Error; err != nil { + return nil, 0, err + } + + result := make([]*entity.AdjustmentStock, len(rows)) + for i := range rows { + result[i] = &rows[i] + } + return result, total, nil +} + func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepository { return &adjustmentStockRepositoryImpl{db: tx} } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index a763a6c6..8e92f036 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "strings" "github.com/go-playground/validator/v10" @@ -20,7 +21,6 @@ import ( projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -39,16 +39,22 @@ type adjustmentService struct { ProductRepo productRepo.ProductRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository - FifoSvc common.FifoService + FifoStockV2Svc common.FifoStockV2Service } +const ( + adjustmentLaneStockable = "STOCKABLE" + adjustmentLaneUsable = "USABLE" + flagGroupAyam = "AYAM" +) + func NewAdjustmentService( productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, - fifoSvc common.FifoService, + fifoStockV2Svc common.FifoStockV2Service, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, ) AdjustmentService { @@ -61,7 +67,7 @@ func NewAdjustmentService( ProductRepo: productRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, AdjustmentStockRepository: adjustmentStockRepo, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, } } @@ -70,6 +76,9 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProductWarehouse"). Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Warehouse"). + Preload("ProductWarehouse.Warehouse.Location"). + Preload("ProductWarehouse.ProjectFlockKandang"). + Preload("ProductWarehouse.ProjectFlockKandang.ProjectFlock"). Preload("StockLog.CreatedUser") } @@ -94,47 +103,87 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err := s.Validate.Struct(req); err != nil { return nil, err } + + productID := req.ProductID + if productID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product is required") + } + + qty := req.Qty + if qty <= 0 { + qty = req.Quantity + } + if qty <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") + } + + functionCode := strings.ToUpper(strings.TrimSpace(req.TransactionSubtype)) + if functionCode == "" { + functionCode = strings.ToUpper(strings.TrimSpace(req.TransactionSubType)) + } + if functionCode == "" { + functionCode = strings.ToUpper(strings.TrimSpace(req.FunctionCode)) + } + if functionCode == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype is required") + } + 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) + if err != nil { + return nil, err + } + + note := strings.TrimSpace(req.Notes) + if note == "" { + note = strings.TrimSpace(req.Note) + } + grandTotal := math.Round((qty*req.Price)*1000) / 1000 + ctx := c.Context() actorID, err := m.ActorIDFromContext(c) if err != nil { return nil, err } - if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(req.WarehouseID)); err != nil { + if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), warehouseID); err != nil { return nil, err } - if err := common.EnsureRelations(c.Context(), - common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, - common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, + if err := common.EnsureRelations(ctx, + common.RelationCheck{Name: "Product", ID: &productID, Exists: s.ProductRepo.IdExists}, + common.RelationCheck{Name: "Warehouse", ID: &warehouseID, Exists: s.WarehouseRepo.IdExists}, ); err != nil { return nil, err } - if req.Quantity <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") + routeMeta, err := s.resolveRouteByFunctionCode(ctx, productID, functionCode) + if err != nil { + return nil, err } - transactionType := strings.ToUpper(req.TransactionType) - if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") - } + transactionType := utils.ResolveAdjustmentTransactionType(routeMeta.FunctionCode) var createdAdjustmentStockId uint var projectFlockKandangID *uint - pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) + pfkID, err := s.getActiveProjectFlockKandangID(ctx, warehouseID) if err == nil && pfkID > 0 { projectFlockKandangID = &pfkID } - pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) + pw, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } newPW := &entity.ProductWarehouse{ - ProductId: uint(req.ProductID), - WarehouseId: uint(req.WarehouseID), + ProductId: productID, + WarehouseId: warehouseID, Quantity: 0, ProjectFlockKandangId: projectFlockKandangID, } @@ -153,96 +202,207 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, err } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + productWarehouseRepoTX := ProductWarehouse.NewProductWarehouseRepository(tx) + stockLogRepoTX := stockLogsRepo.NewStockLogRepository(tx) + adjustmentStockRepoTX := s.AdjustmentStockRepository.WithTx(tx) - productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID) + productWarehouse, err := productWarehouseRepoTX.FindByProductWarehouseAndPfk(ctx, productID, warehouseID, projectFlockKandangID) if err != nil { s.Log.Errorf("Failed to get product warehouse: %+v", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } - newLog := &entity.StockLog{ - LoggableType: string(utils.StockLogTypeAdjustment), - LoggableId: 0, - Notes: req.Note, - ProductWarehouseId: productWarehouse.Id, - CreatedBy: actorID, - } - - stockLogs, err := s.StockLogsRepository.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") - } - - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - newLog.Stock = latestStockLog.Stock - } else { - newLog.Stock = 0 - } - - if transactionType == string(utils.StockLogTransactionTypeIncrease) { - newLog.Increase = req.Quantity - newLog.Stock += newLog.Increase - } else { - if productWarehouse.Quantity < req.Quantity { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) + 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") } - newLog.Decrease = req.Quantity - newLog.Stock -= newLog.Decrease - } - if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { + 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 + } - 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, + FunctionCode: routeMeta.FunctionCode, + Price: req.Price, + GrandTotal: grandTotal, } - code, err := s.AdjustmentStockRepository.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) + switch routeMeta.Lane { + case adjustmentLaneStockable: + adjustmentStock.TotalQty = qty + case adjustmentLaneUsable: + adjustmentStock.UsageQty = qty + } + code, err := adjustmentStockRepoTX.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) if err != nil { return err } adjustmentStock.AdjNumber = code - if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { - + if err := adjustmentStockRepoTX.CreateOne(ctx, adjustmentStock, nil); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record") } - newLog.LoggableType = string(utils.StockLogTypeAdjustment) - newLog.LoggableId = adjustmentStock.Id - if err := s.StockLogsRepository.WithTx(tx).UpdateOne(ctx, newLog.Id, newLog, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to link stock log") + var increaseQty float64 + var decreaseQty float64 + + if routeMeta.Lane != adjustmentLaneStockable && routeMeta.Lane != adjustmentLaneUsable { + return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane") + } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - if transactionType == string(utils.StockLogTransactionTypeIncrease) { + asOf := adjustmentStock.CreatedAt + if _, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ + FlagGroupCode: routeMeta.FlagGroupCode, + ProductWarehouseID: productWarehouse.Id, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) + } - note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) - _, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ - StockableKey: fifo.StockableKeyAdjustmentIn, - StockableID: adjustmentStock.Id, - ProductWarehouseID: uint(productWarehouse.Id), - Quantity: req.Quantity, - Note: ¬e, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) - } + refreshedAdjustment, err := adjustmentStockRepoTX.GetByID(ctx, adjustmentStock.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh adjustment stock") + } + switch routeMeta.Lane { + case adjustmentLaneStockable: + increaseQty = refreshedAdjustment.TotalQty + case adjustmentLaneUsable: + decreaseQty = refreshedAdjustment.UsageQty + } - } else { - _, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ - UsableKey: fifo.UsableKeyAdjustmentOut, - UsableID: adjustmentStock.Id, - ProductWarehouseID: uint(productWarehouse.Id), - Quantity: req.Quantity, - AllowPending: false, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) - } + if err := s.createAdjustmentStockLog( + ctx, + stockLogRepoTX, + adjustmentStock.Id, + productWarehouse.Id, + note, + actorID, + increaseQty, + decreaseQty, + ); err != nil { + return err } createdAdjustmentStockId = adjustmentStock.Id @@ -261,6 +421,91 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return s.GetOne(c, createdAdjustmentStockId) } +func (s *adjustmentService) resolveWarehouseID(ctx context.Context, req *validation.Create) (uint, error) { + if req == nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid request") + } + + if req.WarehouseID > 0 { + return req.WarehouseID, nil + } + + if req.ProjectFlockKandangID != nil && *req.ProjectFlockKandangID > 0 { + kandangID, err := s.AdjustmentStockRepository.FindKandangIDByProjectFlockKandangID(ctx, *req.ProjectFlockKandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id tidak valid atau tidak aktif") + } + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project_flock_kandang_id context") + } + + warehouse, err := s.WarehouseRepo.GetLatestByKandangID(ctx, kandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse untuk project_flock_kandang_id %d tidak ditemukan", *req.ProjectFlockKandangID)) + } + return 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve warehouse by project_flock_kandang_id") + } + return warehouse.Id, nil + } + + return 0, fiber.NewError(fiber.StatusBadRequest, "warehouse_id atau project_flock_kandang_id wajib diisi") +} + +func (s *adjustmentService) resolveRouteByFunctionCode( + ctx context.Context, + productID uint, + functionCode string, +) (*adjustmentStockRepo.AdjustmentRouteResolution, error) { + rows, err := s.AdjustmentStockRepository.FindRoutesByFunctionCode(ctx, productID, functionCode) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype tidak kompatibel dengan konfigurasi FIFO v2 produk") + } + + selected := rows[0] + for _, row := range rows { + if row.Lane != selected.Lane { + return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype ambigu: lane FIFO v2 lebih dari satu") + } + } + + selected.FunctionCode = functionCode + switch selected.Lane { + case adjustmentLaneStockable, adjustmentLaneUsable: + return &selected, nil + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "Transaction subtype memiliki lane FIFO v2 yang tidak didukung") + } +} + +func (s *adjustmentService) resolveOverconsumePolicy( + ctx context.Context, + route *adjustmentStockRepo.AdjustmentRouteResolution, +) (bool, error) { + if route == nil { + return false, fmt.Errorf("route is required") + } + + defaultValue := route.AllowPendingDefault + selected, err := s.AdjustmentStockRepository.FindOverconsumeRule( + ctx, + route.Lane, + route.FlagGroupCode, + route.FunctionCode, + ) + if err != nil { + return false, err + } + if selected == nil { + return defaultValue, nil + } + + return *selected, nil +} + func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) if err != nil { @@ -287,10 +532,98 @@ 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 } + if query.Page <= 0 { + query.Page = 1 + } + if query.Limit <= 0 { + query.Limit = 10 + } offset := (query.Page - 1) * query.Limit var isProductsExist bool @@ -313,15 +646,6 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") } - var adjustmentStocks []entity.AdjustmentStock - var total int64 - - q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}). - Preload("ProductWarehouse"). - Preload("ProductWarehouse.Product"). - Preload("ProductWarehouse.Warehouse"). - Preload("StockLog.CreatedUser") - scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB()) if scopeErr != nil { return nil, 0, scopeErr @@ -330,42 +654,32 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu if len(scope.IDs) == 0 { return []*entity.AdjustmentStock{}, 0, nil } - q = q.Joins("JOIN product_warehouses pw_scope ON pw_scope.id = adjustment_stocks.product_warehouse_id"). - Joins("JOIN warehouses w_scope ON w_scope.id = pw_scope.warehouse_id") - q = m.ApplyScopeFilter(q, scope, "w_scope.location_id") } - if query.ProductID > 0 { - q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id"). - Where("product_warehouses.product_id = ?", query.ProductID) + functionCode := strings.ToUpper(strings.TrimSpace(query.TransactionSubtype)) + if functionCode == "" { + functionCode = strings.ToUpper(strings.TrimSpace(query.FunctionCode)) } + transactionType := strings.ToUpper(strings.TrimSpace(query.TransactionType)) - if query.WarehouseID > 0 { - q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id"). - Where("product_warehouses.warehouse_id = ?", query.WarehouseID) - } - - if query.TransactionType != "" { - q = q.Joins("JOIN stock_logs ON stock_logs.loggable_type = ? AND stock_logs.loggable_id = adjustment_stocks.id", "ADJUSTMENT"). - Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType)) - } - - if err = q.Count(&total).Error; err != nil { - s.Log.Errorf("Failed to get adjustments: %+v", err) - return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") - } - - err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error - + adjustmentStocks, total, err := s.AdjustmentStockRepository.FindHistory( + c.Context(), + adjustmentStockRepo.AdjustmentHistoryFilter{ + ProductID: query.ProductID, + WarehouseID: query.WarehouseID, + TransactionType: transactionType, + FunctionCode: functionCode, + ScopeRestrict: scope.Restrict, + ScopeIDs: scope.IDs, + Offset: offset, + Limit: query.Limit, + }, + nil, + ) if err != nil { s.Log.Errorf("Failed to get adjustments: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") } - result := make([]*entity.AdjustmentStock, len(adjustmentStocks)) - for i := range adjustmentStocks { - result[i] = &adjustmentStocks[i] - } - - return result, total, nil + return adjustmentStocks, total, nil } diff --git a/internal/modules/inventory/adjustments/validations/adjustment.validation.go b/internal/modules/inventory/adjustments/validations/adjustment.validation.go index 2e7259f2..8f0abbf7 100644 --- a/internal/modules/inventory/adjustments/validations/adjustment.validation.go +++ b/internal/modules/inventory/adjustments/validations/adjustment.validation.go @@ -1,17 +1,25 @@ package validation type Create struct { - ProductID uint `json:"product_id" validate:"required"` - WarehouseID uint `json:"warehouse_id" validate:"required"` - TransactionType string `json:"transaction_type" validate:"required,oneof=increase decrease"` - Quantity float64 `json:"quantity" validate:"required,gt=0"` - Note string `json:"note" validate:"omitempty,max=255"` + ProjectFlockKandangID *uint `json:"project_flock_kandang_id" validate:"omitempty,min=1"` + WarehouseID uint `json:"warehouse_id" validate:"omitempty,min=1"` + ProductID uint `json:"product_id" validate:"omitempty,min=1"` + TransactionSubtype string `json:"transaction_subtype" validate:"required_without=TransactionSubType,max=64"` + TransactionSubType string `json:"transaction_sub_type" validate:"required_without=TransactionSubtype,max=64"` + FunctionCode string `json:"function_code" validate:"omitempty,max=64"` + Qty float64 `json:"qty" validate:"omitempty,gt=0"` + Quantity float64 `json:"quantity" validate:"omitempty,gt=0"` + Price float64 `json:"price" validate:"required,gte=0"` + Notes string `json:"notes" validate:"omitempty,max=255"` + Note string `json:"note" validate:"omitempty,max=255"` } type Query struct { - Page int `query:"page" validate:"omitempty,min=1"` - Limit int `query:"limit" validate:"omitempty,min=1,max=100"` - ProductID uint `query:"product_id" validate:"omitempty,min=0"` - WarehouseID uint `query:"warehouse_id" validate:"omitempty,min=0"` - TransactionType string `query:"transaction_type" validate:"omitempty,oneof=increase decrease"` + Page int `query:"page" validate:"omitempty,min=1"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100"` + ProductID uint `query:"product_id" validate:"omitempty,min=0"` + WarehouseID uint `query:"warehouse_id" validate:"omitempty,min=0"` + TransactionType string `query:"transaction_type" validate:"omitempty,max=100"` + TransactionSubtype string `query:"transaction_subtype" validate:"omitempty,max=64"` + FunctionCode string `query:"function_code" validate:"omitempty,max=64"` } diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index fde5e55a..e45e0dd9 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -24,7 +24,6 @@ import ( rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type TransferModule struct{} @@ -40,10 +39,10 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db) kandangRepo := rKandang.NewKandangRepository(db) nonstockRepo := rNonstock.NewNonstockRepository(db) documentRepo := commonRepo.NewDocumentRepository(db) - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) @@ -52,7 +51,6 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate panic(err) } - approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowExpense, utils.ExpenseApprovalSteps); err != nil { @@ -70,7 +68,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate validate, ) - fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) expenseBridge := sTransfer.NewTransferExpenseBridge( db, stockTransferRepo, @@ -79,39 +77,7 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseServiceInstance, ) - err = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyStockTransferIn, - Table: "stock_transfer_details", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "dest_product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic(err) - } - - err = fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyStockTransferOut, - Table: "stock_transfer_details", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "source_product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }) - if err != nil { - panic(err) - } - - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, documentSvc, fifoService, expenseBridge) + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo, projectFlockPopulationRepo, documentSvc, fifoStockV2Service, expenseBridge) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index fa4cb8ca..9cf4789e 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -11,6 +11,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -44,12 +45,13 @@ type transferService struct { SupplierRepo rSupplier.SupplierRepository WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + ProjectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository DocumentSvc commonSvc.DocumentService - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service ExpenseBridge TransferExpenseBridge } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, fifoSvc commonSvc.FifoService, expenseBridge TransferExpenseBridge) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -62,8 +64,9 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr SupplierRepo: supplierRepo, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, + ProjectFlockPopulationRepo: projectFlockPopulationRepo, DocumentSvc: documentSvc, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, ExpenseBridge: expenseBridge, } } @@ -442,36 +445,91 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } + flagGroupByProduct := make(map[uint]string, len(req.Products)) + for _, product := range req.Products { detail := detailMap[uint64(product.ProductID)] + if detail == nil || detail.SourceProductWarehouseID == nil || detail.DestProductWarehouseID == nil { + return fiber.NewError(fiber.StatusInternalServerError, "Data transfer detail tidak valid") + } - consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyStockTransferOut, - UsableID: uint(detail.Id), - ProductWarehouseID: uint(*detail.SourceProductWarehouseID), - Quantity: product.ProductQty, - AllowPending: false, - Tx: tx, - }) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) + flagGroupCode, ok := flagGroupByProduct[uint(product.ProductID)] + if !ok { + flagGroupCode, err = s.resolveTransferFlagGroup(c.Context(), tx, uint(product.ProductID)) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", product.ProductID, err)) + } + flagGroupByProduct[uint(product.ProductID)] = flagGroupCode } if err := tx.Model(&entity.StockTransferDetail{}). Where("id = ?", detail.Id). Updates(map[string]interface{}{ - "usage_qty": consumeResult.UsageQuantity, - "pending_qty": consumeResult.PendingQuantity, + "usage_qty": product.ProductQty, + "pending_qty": 0, + "total_qty": product.ProductQty, }).Error; err != nil { - s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) + s.Log.Errorf("Failed to update transfer detail seed fields for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") } + asOf := transferDate + if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: uint(*detail.SourceProductWarehouseID), + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err)) + } + if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: uint(*detail.DestProductWarehouseID), + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan untuk produk %d. Error: %v", product.ProductID, err)) + } + + type usageSnapshot struct { + UsageQty float64 `gorm:"column:usage_qty"` + PendingQty float64 `gorm:"column:pending_qty"` + } + var usage usageSnapshot + if err := tx.WithContext(c.Context()). + Table("stock_transfer_details"). + Select("usage_qty, pending_qty"). + Where("id = ?", detail.Id). + Take(&usage).Error; err != nil { + s.Log.Errorf("Failed to read transfer usage snapshot detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data tracking") + } + outUsageQty := usage.UsageQty + outPendingQty := usage.PendingQty + if outPendingQty > 1e-6 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal", product.ProductID)) + } + + if strings.EqualFold(flagGroupCode, "AYAM") && outUsageQty > 0 { + if err := s.allocatePopulationForStockTransferOut( + c.Context(), + tx, + detail, + uint(*detail.SourceProductWarehouseID), + outUsageQty, + ); err != nil { + return err + } + } + stockLogDecrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.SourceProductWarehouseID), CreatedBy: uint(actorID), Increase: 0, - Decrease: product.ProductQty, + Decrease: outUsageQty, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), Notes: "", @@ -492,33 +550,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") } - note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) - replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyStockTransferIn, - StockableID: uint(detail.Id), - ProductWarehouseID: uint(*detail.DestProductWarehouseID), - Quantity: product.ProductQty, - Note: ¬e, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan") - } - - if err := tx.Model(&entity.StockTransferDetail{}). - Where("id = ?", detail.Id). - Updates(map[string]interface{}{ - "total_qty": replenishResult.AddedQuantity, - }).Error; err != nil { - s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking") - } + inAddedQty := outUsageQty stockLogIncrease := &entity.StockLog{ ProductWarehouseId: uint(*detail.DestProductWarehouseID), CreatedBy: uint(actorID), - Increase: product.ProductQty, + Increase: inAddedQty, Decrease: 0, LoggableType: string(utils.StockLogTypeTransfer), LoggableId: uint(detail.Id), @@ -596,6 +633,98 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return result, nil } +func (s *transferService) allocatePopulationForStockTransferOut( + ctx context.Context, + tx *gorm.DB, + detail *entity.StockTransferDetail, + sourceProductWarehouseID uint, + consumeQty float64, +) error { + if consumeQty <= 0 { + return nil + } + if tx == nil { + return errors.New("transaction is required") + } + if detail == nil || detail.Id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Data transfer detail tidak valid") + } + if sourceProductWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Gudang sumber tidak valid") + } + + pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, sourceProductWarehouseID, nil) + if err != nil { + return err + } + if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 { + return nil + } + + populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID( + ctx, + *pw.ProjectFlockKandangId, + sourceProductWarehouseID, + ) + if err != nil { + return err + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer") + } + + return fifoV2.AllocatePopulationConsumption( + ctx, + tx, + populations, + sourceProductWarehouseID, + fifo.UsableKeyStockTransferOut.String(), + uint(detail.Id), + consumeQty, + ) +} + +func (s *transferService) resolveTransferFlagGroup( + ctx context.Context, + tx *gorm.DB, + productID uint, +) (string, error) { + if productID == 0 { + return "", fmt.Errorf("product id is required") + } + + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", "USABLE"). + Where("rr.function_code = ?", "STOCK_TRANSFER_OUT"). + Where("rr.source_table = ?", "stock_transfer_details"). + Where(` + EXISTS ( + SELECT 1 + FROM flags f + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE f.flagable_type = ? + AND f.flagable_id = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, entity.FlagableTypeProduct, productID). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID uint64, payloads []TransferExpenseReceivingPayload) error { if s.ExpenseBridge == nil || transferID == 0 || len(payloads) == 0 { return nil diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 2dde163f..ce48a06e 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -2,7 +2,6 @@ package marketing import ( "fmt" - "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -20,7 +19,6 @@ import ( rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" ) type MarketingModule struct{} @@ -33,26 +31,10 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate userRepo := rUser.NewUserRepository(db) customerRepo := rCustomer.NewCustomerRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectFlockPopulationRepo := rProjectFlockKandang.NewProjectFlockPopulationRepository(db) stockLogRepo := rShared.NewStockLogRepository(db) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyMarketingDelivery, - Table: "marketing_delivery_products", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "created_at", - }, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register marketing delivery usable workflow: %v", err)) - } - } + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) approvalRepo := commonRepo.NewApprovalRepository(db) approvalSvc := commonSvc.NewApprovalService(approvalRepo) @@ -64,8 +46,8 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo := rWarehouse.NewWarehouseRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoService, warehouseRepo, projectFlockKandangRepo, validate) - deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate) + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, fifoStockV2Service, warehouseRepo, projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, productWarehouseRepo, projectFlockPopulationRepo, approvalSvc, fifoStockV2Service, validate) userService := sUser.NewUserService(userRepo, validate) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index d3edf3b4..0680cf0e 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -6,13 +6,17 @@ import ( "fmt" "strings" "time" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -35,8 +39,10 @@ type deliveryOrdersService struct { MarketingProductRepo marketingRepo.MarketingProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository StockLogRepo rShared.StockLogRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalSvc commonSvc.ApprovalService - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service } func NewDeliveryOrdersService( @@ -44,8 +50,10 @@ func NewDeliveryOrdersService( marketingProductRepo marketingRepo.MarketingProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, stockLogRepo rShared.StockLogRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalSvc commonSvc.ApprovalService, - fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, validate *validator.Validate, ) DeliveryOrdersService { return &deliveryOrdersService{ @@ -54,8 +62,10 @@ func NewDeliveryOrdersService( MarketingProductRepo: marketingProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, StockLogRepo: stockLogRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalSvc: approvalSvc, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, } } @@ -215,7 +225,6 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO } return db.Order("created_at DESC").Order("updated_at DESC") }) - if err != nil { return nil, 0, err } @@ -260,7 +269,6 @@ func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDeta return db.Preload("ActionUser") }) if err != nil { - } else if len(approvals) > 0 { if marketing.LatestApproval == nil { latest := approvals[len(approvals)-1] @@ -312,7 +320,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) @@ -379,7 +386,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery deliveryProduct.VehicleNumber = requestedProduct.VehicleNumber if requestedProduct.Qty > 0 { - if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil { return err } @@ -406,7 +412,6 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery return nil }) - if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr @@ -438,7 +443,6 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingRepoTx := marketingRepo.NewMarketingRepository(dbTransaction) @@ -527,7 +531,6 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO return nil }) - if err != nil { if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiberErr @@ -567,33 +570,45 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor return fiber.NewError(fiber.StatusBadRequest, "Delivery product warehouse mismatch with marketing product") } - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - ProductWarehouseID: deliveryProduct.ProductWarehouseId, - Quantity: requestedQty, - AllowPending: false, - Tx: tx, - }) + deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + previousUsage := deliveryProduct.UsageQty + deliveryProduct.UsageQty = requestedQty + deliveryProduct.PendingQty = 0 - if err != nil { + if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + } + if err := reflowMarketingScope( + ctx, + s.FifoStockV2Svc, + tx, + marketingProduct.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) } - deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) + refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product") + } + deliveryProduct.UsageQty = refreshed.UsageQty + deliveryProduct.PendingQty = refreshed.PendingQty + deliveryProduct.CreatedAt = refreshed.CreatedAt - if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, 0); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") + if err := s.allocatePopulationForMarketingDelivery(ctx, tx, deliveryProduct, marketingProduct.ProductWarehouseId); err != nil { + return err } - if actorID > 0 && result.UsageQuantity > 0 { + allocatedDelta := deliveryProduct.UsageQty - previousUsage + if actorID > 0 && allocatedDelta > 0 { decreaseLog := &entity.StockLog{ - Decrease: result.UsageQuantity, + Decrease: allocatedDelta, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, ProductWarehouseId: deliveryProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: fmt.Sprintf("FIFO consume (%.2f)", result.UsageQuantity), + Notes: fmt.Sprintf("FIFO v2 reflow consume (%.2f)", allocatedDelta), } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, deliveryProduct.ProductWarehouseId, 1) @@ -622,35 +637,49 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor } deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) - currentUsage, err := deliveryProductRepo.GetUsageQty(ctx, deliveryProduct.Id) - if err != nil { - currentUsage = 0 - } - - if currentUsage == 0 { + currentUsage := deliveryProduct.UsageQty + currentPending := deliveryProduct.PendingQty + if currentUsage <= 0 && currentPending <= 0 { return nil } - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - Tx: tx, - }); err != nil { + deliveryProduct.UsageQty = 0 + deliveryProduct.PendingQty = 0 + if err := deliveryProductRepo.UpdateOne(ctx, deliveryProduct.Id, deliveryProduct, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset delivery product") + } + + if err := reflowMarketingScope( + ctx, + s.FifoStockV2Svc, + tx, + marketingProduct.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock for product warehouse %d: %v", marketingProduct.ProductWarehouseId, err)) + } + + refreshed, err := deliveryProductRepo.GetByID(ctx, deliveryProduct.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to refresh delivery product") + } + deliveryProduct.UsageQty = refreshed.UsageQty + deliveryProduct.PendingQty = refreshed.PendingQty + deliveryProduct.CreatedAt = refreshed.CreatedAt + + if err := fifoV2.ReleasePopulationConsumptionByUsable(ctx, tx, fifo.UsableKeyMarketingDelivery.String(), deliveryProduct.Id); err != nil { return err } - if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { - return err - } - - if actorID > 0 && currentUsage > 0 { + releasedUsage := currentUsage - deliveryProduct.UsageQty + if actorID > 0 && releasedUsage > 0 { increaseLog := &entity.StockLog{ - Increase: currentUsage, + Increase: releasedUsage, LoggableType: string(utils.StockLogTypeMarketing), LoggableId: deliveryProduct.Id, ProductWarehouseId: marketingProduct.ProductWarehouseId, CreatedBy: actorID, - Notes: fmt.Sprintf("Release delivery stock (%.2f)", currentUsage), + Notes: fmt.Sprintf("FIFO v2 reflow release (%.2f)", releasedUsage), } stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1) if err != nil { @@ -668,3 +697,57 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor return nil } + +func (s deliveryOrdersService) allocatePopulationForMarketingDelivery( + ctx context.Context, + tx *gorm.DB, + deliveryProduct *entity.MarketingDeliveryProduct, + productWarehouseID uint, +) error { + if deliveryProduct == nil || deliveryProduct.Id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Delivery product tidak valid") + } + if tx == nil { + return errors.New("transaction is required") + } + if deliveryProduct.UsageQty <= 0 { + return nil + } + if productWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak ditemukan") + } + + flagGroupCode, err := resolveMarketingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if !strings.EqualFold(flagGroupCode, "AYAM") { + return nil + } + + pw, err := s.ProductWarehouseRepo.WithTx(tx).GetByID(ctx, productWarehouseID, nil) + if err != nil { + return err + } + if pw.ProjectFlockKandangId == nil || *pw.ProjectFlockKandangId == 0 { + return nil + } + + populations, err := s.ProjectFlockPopulationRepo.WithTx(tx).GetByProjectFlockKandangIDAndProductWarehouseID(ctx, *pw.ProjectFlockKandangId, productWarehouseID) + if err != nil { + return err + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk delivery") + } + + return fifoV2.AllocatePopulationConsumption( + ctx, + tx, + populations, + productWarehouseID, + fifo.UsableKeyMarketingDelivery.String(), + deliveryProduct.Id, + deliveryProduct.UsageQty, + ) +} diff --git a/internal/modules/marketing/services/fifo_stock_v2_helper.go b/internal/modules/marketing/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..6cdced5e --- /dev/null +++ b/internal/modules/marketing/services/fifo_stock_v2_helper.go @@ -0,0 +1,97 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + marketingOutFunctionCode = "MARKETING_OUT" + marketingUsableLane = "USABLE" + marketingSourceTable = "marketing_delivery_products" +) + +func reflowMarketingScope( + ctx context.Context, + fifoStockV2Svc commonSvc.FifoStockV2Service, + tx *gorm.DB, + productWarehouseID uint, + asOf *time.Time, +) error { + if fifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolveMarketingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + _, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolveMarketingFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", marketingUsableLane). + Where("rr.function_code = ?", marketingOutFunctionCode). + Where("rr.source_table = ?", marketingSourceTable). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + +func resolveMarketingAsOf(deliveryDate, createdAt *time.Time) *time.Time { + if deliveryDate != nil { + asOf := *deliveryDate + return &asOf + } + if createdAt != nil { + asOf := *createdAt + return &asOf + } + asOf := time.Now() + return &asOf +} diff --git a/internal/modules/marketing/services/salesorder.service.go b/internal/modules/marketing/services/salesorder.service.go index 7d032c86..d4917759 100644 --- a/internal/modules/marketing/services/salesorder.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -20,7 +20,6 @@ import ( userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -43,12 +42,12 @@ type salesOrdersService struct { ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository UserRepo userRepo.UserRepository ApprovalSvc commonSvc.ApprovalService - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service WarehouseRepo warehouseRepo.WarehouseRepository ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, warehouseRepo warehouseRepo.WarehouseRepository, +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ Log: utils.Log, @@ -58,7 +57,7 @@ func NewSalesOrdersService(marketingRepo repository.MarketingRepository, custome ProductWarehouseRepo: productWarehouseRepo, UserRepo: userRepo, ApprovalSvc: approvalSvc, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, WarehouseRepo: warehouseRepo, ProjectFlockKandangRepo: projectFlockKandangRepo, } @@ -401,15 +400,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u if qtyDiff < 0 { return fiber.NewError(fiber.StatusBadRequest, "Cannot decrease quantity after stock has been allocated. Please delete and create new product.") } else if qtyDiff > 0 { - _, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - ProductWarehouseID: rp.ProductWarehouseId, - Quantity: qtyDiff, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Insufficient stock for additional quantity: %v", err)) + nextRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty + qtyDiff + if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, nextRequestedQty, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to update marketing delivery fifo fields") + } + if err := reflowMarketingScope( + c.Context(), + s.FifoStockV2Svc, + dbTransaction, + rp.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) } } } @@ -464,12 +466,17 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot delete marketing product %d because it has been delivered", old.Id)) } - if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: deliveryProduct.Id, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock: %v", err)) + if err := invDeliveryRepoTx.UpdateFifoFields(c.Context(), deliveryProduct.Id, 0, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset marketing delivery fifo fields") + } + if err := reflowMarketingScope( + c.Context(), + s.FifoStockV2Svc, + dbTransaction, + deliveryProduct.ProductWarehouseId, + resolveMarketingAsOf(deliveryProduct.DeliveryDate, deliveryProduct.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2: %v", err)) } if err := invDeliveryRepoTx.DeleteOne(c.Context(), deliveryProduct.Id); err != nil { @@ -548,12 +555,17 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { deliveryProducts, err := marketingDeliveryProductRepoTx.GetByMarketingId(c.Context(), marketing.Id) if err == nil && len(deliveryProducts) > 0 { for _, dp := range deliveryProducts { - if err := s.FifoSvc.ReleaseUsage(c.Context(), commonSvc.StockReleaseRequest{ - UsableKey: fifo.UsableKeyMarketingDelivery, - UsableID: dp.Id, - Tx: dbTransaction, - }); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for delivery product %d: %v", dp.Id, err)) + if err := marketingDeliveryProductRepoTx.UpdateFifoFields(c.Context(), dp.Id, 0, 0); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset fifo fields for delivery product %d", dp.Id)) + } + if err := reflowMarketingScope( + c.Context(), + s.FifoStockV2Svc, + dbTransaction, + dp.ProductWarehouseId, + resolveMarketingAsOf(dp.DeliveryDate, dp.CreatedAt), + ); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to reflow stock via FIFO v2 for delivery product %d: %v", dp.Id, err)) } } } diff --git a/internal/modules/master/products/controllers/product.controller.go b/internal/modules/master/products/controllers/product.controller.go index 197a6b5f..050dc2d9 100644 --- a/internal/modules/master/products/controllers/product.controller.go +++ b/internal/modules/master/products/controllers/product.controller.go @@ -30,6 +30,14 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error { ProductCategoryID: c.QueryInt("product_category_id", 0), } + if isDepletionParam := c.Query("is_depletion", ""); isDepletionParam != "" { + value, err := strconv.ParseBool(isDepletionParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid is_depletion value") + } + query.IsDepletion = &value + } + if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index d115ad23..dc09bdee 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -7,6 +7,7 @@ import ( productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" ) // === DTO Structs === @@ -17,6 +18,9 @@ type ProductRelationDTO struct { ProductPrice float64 `gorm:"type:numeric(15,3);not null"` SellingPrice *float64 `gorm:"type:numeric(15,3)"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flag *string `json:"flag,omitempty"` + SubFlag *string `json:"sub_flag,omitempty"` + SubFlags *[]string `json:"sub_flags,omitempty"` Flags *[]string `json:"flags,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` Suppliers []ProductSupplierDTO `json:"suppliers"` @@ -31,6 +35,9 @@ type ProductListDTO struct { SellingPrice *float64 `json:"selling_price,omitempty"` Tax *float64 `json:"tax,omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty"` + Flag *string `json:"flag,omitempty"` + SubFlag *string `json:"sub_flag,omitempty"` + SubFlags []string `json:"sub_flags,omitempty"` Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` @@ -59,6 +66,13 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO { for i, f := range e.Flags { flags[i] = f.Name } + flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags) + var subFlagsRef *[]string + if len(subFlags) > 0 { + values := make([]string, len(subFlags)) + copy(values, subFlags) + subFlagsRef = &values + } var uomRef *uomDTO.UomRelationDTO if e.Uom.Id != 0 { @@ -77,6 +91,9 @@ func ToProductRelationDTO(e entity.Product) ProductRelationDTO { Name: e.Name, ProductPrice: e.ProductPrice, SellingPrice: e.SellingPrice, + Flag: flag, + SubFlag: subFlag, + SubFlags: subFlagsRef, Flags: &flags, Uom: uomRef, ProductCategory: categoryRef, @@ -101,6 +118,7 @@ func ToProductListDTO(e entity.Product) ProductListDTO { for i, f := range e.Flags { flags[i] = f.Name } + flag, subFlag, subFlags := resolveProductFlagAndSubFlags(flags) var uomRef *uomDTO.UomRelationDTO if e.Uom.Id != 0 { @@ -111,6 +129,9 @@ func ToProductListDTO(e entity.Product) ProductListDTO { return ProductListDTO{ Id: e.Id, Name: e.Name, + Flag: flag, + SubFlag: subFlag, + SubFlags: subFlags, Flags: flags, Uom: uomRef, Brand: e.Brand, @@ -141,6 +162,58 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO { } } +func resolveProductFlagAndSubFlags(flags []string) (*string, *string, []string) { + normalized := utils.NormalizeFlagTypes(flags) + if len(normalized) == 0 { + return nil, nil, nil + } + + available := make(map[utils.FlagType]struct{}, len(normalized)) + for _, flag := range normalized { + available[flag] = struct{}{} + } + + var selectedFlag utils.FlagType + for _, mainFlag := range utils.ProductMainFlags() { + if _, ok := available[mainFlag]; ok { + selectedFlag = mainFlag + break + } + } + + if selectedFlag == "" { + subToMain := utils.ProductSubFlagToFlag() + for _, flag := range normalized { + if parent, ok := subToMain[flag]; ok { + selectedFlag = parent + break + } + } + } + + if selectedFlag == "" { + return nil, nil, nil + } + + flag := string(selectedFlag) + + var subFlag *string + subFlagValues := make([]string, 0) + subFlagsByMain := utils.ProductSubFlagsByFlag() + for _, sub := range subFlagsByMain[selectedFlag] { + if _, ok := available[sub]; ok { + subFlagValues = append(subFlagValues, string(sub)) + } + } + + if len(subFlagValues) > 0 { + first := subFlagValues[0] + subFlag = &first + } + + return &flag, subFlag, subFlagValues +} + func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO { if len(relations) == 0 { return make([]ProductSupplierDTO, 0) diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index 0aaa0952..454b0a93 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -31,6 +31,12 @@ type productService struct { Repository repository.ProductRepository } +var depletionProductFlags = []string{ + string(utils.FlagAyamAfkir), + string(utils.FlagAyamCulling), + string(utils.FlagAyamMati), +} + func normalizeProductFlags(raw []string) ([]string, error) { normalized, invalid := utils.NormalizeFlagsForGroup(raw, utils.FlagGroupProduct) if len(invalid) > 0 { @@ -41,6 +47,159 @@ func normalizeProductFlags(raw []string) ([]string, error) { return utils.FlagTypesToStrings(normalized), nil } +func productMainFlagOptionsString() []string { + mainFlags := utils.ProductMainFlags() + result := make([]string, len(mainFlags)) + for i, flag := range mainFlags { + result[i] = string(flag) + } + return result +} + +func productSubFlagOptionsString(flag utils.FlagType) []string { + subFlagsByFlag := utils.ProductSubFlagsByFlag() + subFlags := subFlagsByFlag[flag] + result := make([]string, len(subFlags)) + for i, subFlag := range subFlags { + result[i] = string(subFlag) + } + return result +} + +func normalizeStructuredSubFlagsInput(subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]utils.FlagType, error) { + values := make([]string, 0, len(subFlagsRaw)+1) + + if subFlagRaw != nil { + single := strings.TrimSpace(*subFlagRaw) + if single == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flag cannot be empty") + } + values = append(values, single) + } + + if hasSubFlagsField { + for _, raw := range subFlagsRaw { + item := strings.TrimSpace(raw) + if item == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "sub_flags cannot contain empty value") + } + values = append(values, item) + } + } + + if len(values) == 0 { + return nil, nil + } + + return utils.NormalizeFlagTypes(values), nil +} + +func resolveProductFlagsFromFlagInput(flagRaw *string, subFlagRaw *string, subFlagsRaw []string, hasSubFlagsField bool) ([]string, bool, error) { + if flagRaw == nil && subFlagRaw == nil && !hasSubFlagsField { + return nil, false, nil + } + + if flagRaw == nil && (subFlagRaw != nil || hasSubFlagsField) { + return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag is required when sub_flag/sub_flags is provided") + } + + flagText := strings.TrimSpace(*flagRaw) + if flagText == "" { + return nil, false, fiber.NewError(fiber.StatusBadRequest, "flag cannot be empty") + } + + flag := utils.CanonicalFlagType(flagText) + if !utils.IsProductMainFlag(flag) { + return nil, false, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Invalid product flag: %s. Allowed flags: %s", flagText, strings.Join(productMainFlagOptionsString(), ", ")), + ) + } + + out := []string{string(flag)} + + normalizedSubFlags, err := normalizeStructuredSubFlagsInput(subFlagRaw, subFlagsRaw, hasSubFlagsField) + if err != nil { + return nil, false, err + } + + if len(normalizedSubFlags) == 0 { + if !utils.ProductFlagAllowWithoutSubFlag(flag) { + return nil, false, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("sub_flag/sub_flags is required for flag %s", string(flag)), + ) + } + normalizedOut, normalizeErr := normalizeProductFlags(out) + if normalizeErr != nil { + return nil, false, normalizeErr + } + return normalizedOut, true, nil + } + + invalidSubFlags := make([]string, 0) + for _, subFlag := range normalizedSubFlags { + if !utils.IsValidProductSubFlag(flag, subFlag) { + invalidSubFlags = append(invalidSubFlags, string(subFlag)) + } + } + if len(invalidSubFlags) > 0 { + return nil, false, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Invalid sub_flags %s for flag %s. Allowed sub_flags: %s", strings.Join(invalidSubFlags, ", "), string(flag), strings.Join(productSubFlagOptionsString(flag), ", ")), + ) + } + + out = append(out, utils.FlagTypesToStrings(normalizedSubFlags)...) + normalizedOut, normalizeErr := normalizeProductFlags(out) + if normalizeErr != nil { + return nil, false, normalizeErr + } + return normalizedOut, true, nil +} + +func resolveCreateProductFlags(req *validation.Create) ([]string, error) { + hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil + if len(req.Flags) > 0 && hasStructuredInput { + return nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both") + } + + if len(req.Flags) > 0 { + return normalizeProductFlags(req.Flags) + } + + flags, _, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, req.SubFlags, req.SubFlags != nil) + return flags, err +} + +func resolveUpdateProductFlags(req *validation.Update) (bool, []string, error) { + hasStructuredInput := req.Flag != nil || req.SubFlag != nil || req.SubFlags != nil + + if req.Flags != nil { + if hasStructuredInput { + if len(*req.Flags) > 0 { + return false, nil, fiber.NewError(fiber.StatusBadRequest, "Use either flags or flag/sub_flag/sub_flags, not both") + } + } else { + flags, err := normalizeProductFlags(*req.Flags) + if err != nil { + return false, nil, err + } + return true, flags, nil + } + } + + subFlagsRaw := make([]string, 0) + if req.SubFlags != nil { + subFlagsRaw = *req.SubFlags + } + flags, provided, err := resolveProductFlagsFromFlagInput(req.Flag, req.SubFlag, subFlagsRaw, req.SubFlags != nil) + if err != nil { + return false, nil, err + } + return provided, flags, nil +} + func NewProductService(repo repository.ProductRepository, validate *validator.Validate) ProductService { return &productService{ Log: utils.Log, @@ -70,12 +229,32 @@ func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) - db = db.Where("is_visible = ?", true) + // Depletion master products are system products and often stored with is_visible = false. + // When requested explicitly via is_depletion=true, include hidden records. + if params.IsDepletion == nil || !*params.IsDepletion { + db = db.Where("is_visible = ?", true) + } if params.Search != "" { - return db.Where("name ILIKE ?", "%"+params.Search+"%") + db = db.Where("name ILIKE ?", "%"+params.Search+"%") } if params.ProductCategoryID != 0 { - return db.Where("product_category_id = ?", params.ProductCategoryID) + db = db.Where("product_category_id = ?", params.ProductCategoryID) + } + if params.IsDepletion != nil { + existsQuery := ` + EXISTS ( + SELECT 1 + FROM flags f + WHERE f.flagable_type = ? + AND f.flagable_id = products.id + AND UPPER(f.name) IN ? + ) + ` + if *params.IsDepletion { + db = db.Where(existsQuery, entity.FlagableTypeProduct, depletionProductFlags) + } else { + db = db.Where("NOT "+existsQuery, entity.FlagableTypeProduct, depletionProductFlags) + } } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -177,7 +356,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit } } - productFlags, flagErr := normalizeProductFlags(req.Flags) + productFlags, flagErr := resolveCreateProductFlags(req) if flagErr != nil { return nil, flagErr } @@ -337,13 +516,10 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) flagUpdate bool flagValues []string ) - if req.Flags != nil { - flagUpdate = true - var flagErr error - flagValues, flagErr = normalizeProductFlags(*req.Flags) - if flagErr != nil { - return nil, flagErr - } + var flagErr error + flagUpdate, flagValues, flagErr = resolveUpdateProductFlags(req) + if flagErr != nil { + return nil, flagErr } if len(updateBody) == 0 && !supplierUpdate && !flagUpdate { diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index 77e8e1bf..4b01e066 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -6,31 +6,37 @@ type SupplierPrice struct { } type Create struct { - Name string `json:"name" validate:"required_strict,min=3,max=50"` - Brand string `json:"brand" validate:"required_strict,min=2,max=50"` - Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` - UomID uint `json:"uom_id" validate:"required,gt=0"` - ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` - ProductPrice float64 `json:"product_price" validate:"required"` - SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` - Tax *float64 `json:"tax,omitempty" validate:"omitempty"` - ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` + Name string `json:"name" validate:"required_strict,min=3,max=50"` + Brand string `json:"brand" validate:"required_strict,min=2,max=50"` + Sku *string `json:"sku,omitempty" validate:"omitempty,max=100"` + UomID uint `json:"uom_id" validate:"required,gt=0"` + ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` + ProductPrice float64 `json:"product_price" validate:"required"` + SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` + Tax *float64 `json:"tax,omitempty" validate:"omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` + Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"` + SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"` + SubFlags []string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"` + Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=3"` - Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"` - Sku *string `json:"sku,omitempty" validate:"omitempty"` - UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` - ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"` - ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"` - SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` - Tax *float64 `json:"tax,omitempty" validate:"omitempty"` - ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"` + Sku *string `json:"sku,omitempty" validate:"omitempty"` + UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` + ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"` + ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"` + SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` + Tax *float64 `json:"tax,omitempty" validate:"omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` - Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` + Flag *string `json:"flag,omitempty" validate:"omitempty,max=50"` + SubFlag *string `json:"sub_flag,omitempty" validate:"omitempty,max=50"` + SubFlags *[]string `json:"sub_flags,omitempty" validate:"omitempty,dive,max=50"` + Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` } type Query struct { @@ -38,4 +44,5 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1"` Search string `query:"search" validate:"omitempty,max=50"` ProductCategoryID int `query:"product_category_id" validate:"omitempty,number,min=1"` + IsDepletion *bool `query:"is_depletion" validate:"omitempty"` } diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index 09514f0d..e87448b2 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -2,7 +2,6 @@ package chickins import ( "fmt" - "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -10,7 +9,6 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" @@ -20,6 +18,7 @@ import ( sChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -38,47 +37,12 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockkandangrepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectflockpopulationrepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productRepo := rProduct.NewProductRepository(db) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) userRepo := rUser.NewUserRepository(db) - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyProjectChickin, - Table: "project_chickins", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_usage_qty", - CreatedAt: "created_at", - }, - - ExcludedStockables: []fifo.StockableKey{fifo.StockableKeyProjectFlockPopulation}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register chickin usable workflow: %v", err)) - } - } - - if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyProjectFlockPopulation, - Table: "project_flock_populations", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used_qty", - CreatedAt: "created_at", - }, - OrderBy: []string{"created_at ASC", "id ASC"}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register project flock population stockable workflow: %v", err)) - } - } - approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { @@ -95,8 +59,9 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, + transferLayingRepo, validate, - fifoService) + fifoStockV2Service) userService := sUser.NewUserService(userRepo, validate) ChickinRoutes(router, userService, chickinService) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 7d2e7a7f..11daaf41 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "math" "strings" + "time" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" @@ -17,6 +19,7 @@ import ( repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/validations" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" @@ -25,10 +28,9 @@ import ( "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" ) -var chickinUsableKey = fifo.UsableKeyProjectChickin - type ChickinService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectChickin, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectChickin, error) @@ -51,11 +53,12 @@ type chickinService struct { ProjectflockKandangRepo rProjectFlock.ProjectFlockKandangRepository ProjectflockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ProjectChickinDetailRepo repository.ProjectChickinDetailRepository - FifoSvc commonSvc.FifoService + TransferLayingRepo rTransferLaying.TransferLayingRepository + FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository } -func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, validate *validator.Validate, fifoSvc commonSvc.FifoService) ChickinService { +func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo KandangRepo.KandangRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productRepo rProduct.ProductRepository, projectFlockRepo rProjectFlock.ProjectflockRepository, projectflockkandangRepo rProjectFlock.ProjectFlockKandangRepository, projectflockpopulationRepo rProjectFlock.ProjectFlockPopulationRepository, projectChickinDetailRepo repository.ProjectChickinDetailRepository, transferLayingRepo rTransferLaying.TransferLayingRepository, validate *validator.Validate, fifoStockV2Svc commonSvc.FifoStockV2Service) ChickinService { return &chickinService{ Log: utils.Log, Validate: validate, @@ -68,7 +71,8 @@ func NewChickinService(repo repository.ProjectChickinRepository, kandangRepo Kan ProjectflockKandangRepo: projectflockkandangRepo, ProjectflockPopulationRepo: projectflockpopulationRepo, ProjectChickinDetailRepo: projectChickinDetailRepo, - FifoSvc: fifoSvc, + TransferLayingRepo: transferLayingRepo, + FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), } } @@ -120,11 +124,36 @@ func (s chickinService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectChickin, e return chickin, nil } +func (s chickinService) ensureNotTransferred(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 || s.TransferLayingRepo == nil { + return nil + } + + transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, projectFlockKandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to resolve transfer laying by source kandang %d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + + if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sudah dipindahkan ke laying") + } + + return nil +} + func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]entity.ProjectChickin, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } + if err := s.ensureNotTransferred(c.Context(), req.ProjectFlockKandangId); err != nil { + return nil, err + } + projectFlockKandang, err := s.ProjectflockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found") @@ -160,30 +189,31 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product warehouse %d belongs to different flock. Only product warehouses with project_flock_kandang_id = NULL or = %d can be used", chickinReq.ProductWarehouseId, req.ProjectFlockKandangId)) } - if productWarehouse.Product.Id != 0 { + if productWarehouse.Product.Id != 0 { + category := strings.ToUpper(strings.TrimSpace(projectFlockKandang.ProjectFlock.Category)) + if category != string(utils.ProjectFlockCategoryGrowing) && category != string(utils.ProjectFlockCategoryLaying) { + return nil, fmt.Errorf("invalid flock category for chickin") + } - var requiredFlag utils.FlagType - if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryGrowing) { - requiredFlag = utils.FlagDOC - } else if projectFlockKandang.ProjectFlock.Category == string(utils.ProjectFlockCategoryLaying) { - requiredFlag = utils.FlagPullet - } else { - return nil, fmt.Errorf("invalid flock category for chickin") - } + hasAyamFlag := false + for _, flag := range productWarehouse.Product.Flags { + if utils.CanonicalFlagType(flag.Name) == utils.FlagAyam { + hasAyamFlag = true + break + } + } - hasRequiredFlag := false - for _, flag := range productWarehouse.Product.Flags { - if utils.FlagType(flag.Name) == requiredFlag { - hasRequiredFlag = true - break + if !hasAyamFlag { + return nil, fmt.Errorf( + "product warehouse %d cannot be used for %s chickin. Product must have AYAM flag (or legacy alias DOC/PULLET/LAYER) (product ID: %d, warehouse ID: %d)", + chickinReq.ProductWarehouseId, + projectFlockKandang.ProjectFlock.Category, + productWarehouse.Product.Id, + productWarehouse.Id, + ) } } - if !hasRequiredFlag { - return nil, fmt.Errorf("product warehouse %d cannot be used for %s chickin. Product must have %s flag (product ID: %d, warehouse ID: %d)", chickinReq.ProductWarehouseId, projectFlockKandang.ProjectFlock.Category, requiredFlag, productWarehouse.Product.Id, productWarehouse.Id) - } - } - chickinDate, err := utils.ParseDateString(chickinReq.ChickInDate) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid ChickInDate format for product warehouse %d", chickinReq.ProductWarehouseId)) @@ -260,7 +290,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti for idx, chickin := range newChikins { desiredQty := chickinQtyMap[uint(idx)] - if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { + if err := s.StageChickinStocks(c.Context(), dbTransaction, chickin, desiredQty, actorID); err != nil { return err } } @@ -334,6 +364,17 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } + chickin, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } + return nil, err + } + if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil { + return nil, err + } + updateBody := make(map[string]any) if req.ChickInDate != "" { @@ -353,7 +394,18 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return nil, err } - return s.GetOne(c, id) + updated, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + + if updated.UsageQty > 0 { + if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync chickin stock trace") + } + } + + return updated, nil } func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { @@ -366,29 +418,40 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return err } + if err := s.ensureNotTransferred(c.Context(), chickin.ProjectFlockKandangId); err != nil { + return err + } + actorID, err := m.ActorIDFromContext(c) if err != nil { return err } - if chickin.UsageQty > 0 { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + chickinRepoTx := repository.NewChickinRepository(tx) - currentUsageQty := chickin.UsageQty + if chickin.UsageQty > 0 || chickin.PendingUsageQty > 0 { + if err := s.ReleaseChickinStocks(c.Context(), tx, chickin, actorID); err != nil { + return err + } + } - if err := s.ReleaseChickinStocks(c.Context(), s.Repository.DB(), chickin, actorID); err != nil { + if err := chickinRepoTx.DeleteOne(c.Context(), id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + } return err } - warehouseDeltas := make(map[uint]float64) - warehouseDeltas[chickin.ProductWarehouseId] += currentUsageQty - if err := s.adjustProductWarehouseQuantities(c.Context(), s.Repository.DB(), warehouseDeltas); err != nil { + if err := s.syncChickinTraceForProductWarehouse(c.Context(), tx, chickin.ProductWarehouseId); err != nil { return err } - } - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Chickin not found") + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return fiberErr } return err } @@ -428,6 +491,9 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if err := commonSvc.EnsureRelations(c.Context(), commonSvc.RelationCheck{Name: "ProjectFlockKandang", ID: &id, Exists: s.ProjectflockKandangRepo.IdExists}); err != nil { return nil, err } + if err := s.ensureNotTransferred(c.Context(), id); err != nil { + return nil, err + } latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { @@ -451,8 +517,24 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) chickinRepoTx := repository.NewChickinRepository(dbTransaction) ProjectFlockPopulationRepotx := s.ProjectflockPopulationRepo.WithTx(dbTransaction) + touchedProductWarehouseIDs := make(map[uint]struct{}) for _, approvableID := range approvableIDs { + // Re-check latest approval inside transaction to prevent double-approve races. + var latest entity.Approval + if err := dbTransaction.WithContext(c.Context()). + Table("approvals"). + Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowChickin.String(), approvableID). + Order("id DESC"). + Limit(1). + Clauses(clause.Locking{Strength: "UPDATE"}). + Take(&latest).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to recheck approval status") + } + if latest.Id != 0 && latest.StepNumber != uint16(utils.ChickinStepPengajuan) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d sudah tidak berada di tahap PENGAJUAN", approvableID)) + } + if _, err := approvalSvc.CreateApproval( c.Context(), utils.ApprovalWorkflowChickin, @@ -491,6 +573,21 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, chickin := range chickins { + approvedQty := chickin.UsageQty + if approvedQty <= 0 { + approvedQty = chickin.PendingUsageQty + } + if approvedQty < 0 { + approvedQty = 0 + } + + if err := s.ConsumeChickinStocks(c.Context(), dbTransaction, &chickin, approvedQty, actorID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to finalize usage qty for chickin %d", chickin.Id)) + } + chickin.UsageQty = approvedQty + chickin.PendingUsageQty = 0 + touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} + populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) @@ -522,19 +619,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to create population for chickin %d", chickin.Id)) } - if err := chickinRepoTx.PatchOne(c.Context(), chickin.Id, map[string]any{ - "pending_usage_qty": 0, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to reset pending usage qty for chickin %d", chickin.Id)) - } - if err := s.ReplenishChickinStocks(c.Context(), dbTransaction, &chickin, sourcePW, population, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock for chickin %d", chickin.Id)) } } } if action == entity.ApprovalActionRejected { - chickins, err := chickinRepoTx.GetPendingByProjectFlockKandangID(c.Context(), approvableID) + chickins, err := chickinRepoTx.GetByProjectFlockKandangID(c.Context(), approvableID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID)) } @@ -544,16 +635,22 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } for _, chickin := range chickins { + populationExists, err := ProjectFlockPopulationRepotx.ExistsByProjectChickinID(c.Context(), chickin.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to check population for chickin %d", chickin.Id)) + } + if populationExists { + continue + } + + if chickin.UsageQty <= 0 && chickin.PendingUsageQty <= 0 { + continue + } if err := s.ReleaseChickinStocks(c.Context(), dbTransaction, &chickin, actorID); err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to release stock for rejected chickin %d: %v", chickin.Id, err)) } - - warehouseDeltas := make(map[uint]float64) - warehouseDeltas[chickin.ProductWarehouseId] += chickin.UsageQty - if err := s.adjustProductWarehouseQuantities(c.Context(), dbTransaction, warehouseDeltas); err != nil { - return err - } + touchedProductWarehouseIDs[chickin.ProductWarehouseId] = struct{}{} if err := chickinRepoTx.DeleteOne(c.Context(), chickin.Id); err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -563,6 +660,13 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit } } } + + for productWarehouseID := range touchedProductWarehouseIDs { + if err := s.syncChickinTraceForProductWarehouse(c.Context(), dbTransaction, productWarehouseID); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to sync chickin trace for product warehouse %d", productWarehouseID)) + } + } + return nil }) @@ -617,121 +721,245 @@ func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, } func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { - if chickin == nil || s.FifoSvc == nil { + if chickin == nil { return nil } - - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: chickinUsableKey, - UsableID: chickin.Id, - ProductWarehouseID: chickin.ProductWarehouseId, - Quantity: desiredQty, - AllowPending: true, - Tx: tx, - }) - if err != nil { - return err + if tx == nil { + return errors.New("transaction is required") + } + if desiredQty < 0 { + return errors.New("desired quantity must be zero or greater") } - if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, result.UsageQuantity, result.PendingQuantity); err != nil { - return err + return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, desiredQty, 0) +} + +func (s *chickinService) StageChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, desiredQty float64, actorID uint) error { + if chickin == nil { + return nil + } + if tx == nil { + return errors.New("transaction is required") + } + if desiredQty < 0 { + return errors.New("desired quantity must be zero or greater") } - if result.UsageQuantity > 0 { - decreaseLog := &entity.StockLog{ - Decrease: result.UsageQuantity, - LoggableType: string(utils.StockLogTypeChikin), - LoggableId: chickin.Id, - ProductWarehouseId: chickin.ProductWarehouseId, - CreatedBy: actorID, - Notes: fmt.Sprintf("Chickin #%d", 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] - decreaseLog.Stock = latestStockLog.Stock - decreaseLog.Stock -= decreaseLog.Decrease - } else { - decreaseLog.Stock -= decreaseLog.Decrease - } - - s.StockLogRepo.CreateOne(ctx, decreaseLog, nil) - } - - return nil + return s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, desiredQty) } func (s *chickinService) ReplenishChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, targetPW *entity.ProductWarehouse, population *entity.ProjectFlockPopulation, actorID uint) error { - if chickin == nil || targetPW == nil || population == nil || s.FifoSvc == nil { + if chickin == nil || targetPW == nil || population == nil { return nil } + if tx == nil { + return errors.New("transaction is required") + } + if s.FifoStockV2Svc == nil { + return errors.New("fifo v2 service is not available") + } - _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyProjectFlockPopulation, - StockableID: population.Id, - ProductWarehouseID: targetPW.Id, - Quantity: chickin.UsageQty, - Tx: tx, - }) - if err != nil { + if err := tx.WithContext(ctx). + Model(&entity.ProjectFlockPopulation{}). + Where("id = ?", population.Id). + Update("total_qty", chickin.UsageQty).Error; err != nil { return err } - return nil + asOf := chickin.ChickInDate + if asOf.IsZero() { + asOf = chickin.CreatedAt + } + return reflowChickinScope(ctx, s.FifoStockV2Svc, tx, targetPW.Id, &asOf) } func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB, chickin *entity.ProjectChickin, actorID uint) error { - if chickin == nil || s.FifoSvc == nil { + if chickin == nil { return nil } - - var currentUsage float64 - if err := tx.Model(&entity.ProjectChickin{}).Where("id = ?", chickin.Id).Select("usage_qty").Scan(¤tUsage).Error; err != nil { - - } - - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: chickinUsableKey, - UsableID: chickin.Id, - Tx: tx, - }); err != nil { - return err + if tx == nil { + return errors.New("transaction is required") } if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); 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 + return nil +} + +func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error { + if productWarehouseID == 0 { + return nil + } + if s.FifoStockV2Svc == nil { + return nil + } + + if tx == nil { + return s.Repository.DB().WithContext(ctx).Transaction(func(innerTx *gorm.DB) error { + return s.syncChickinTraceForProductWarehouse(ctx, innerTx, productWarehouseID) + }) + } + + flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return nil + } + + now := time.Now() + if err := tx.WithContext(ctx). + Table("stock_allocations"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()). + Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin). + Where("status = ?", entity.StockAllocationStatusActive). + Updates(map[string]any{ + "status": entity.StockAllocationStatusReleased, + "released_at": now, + "updated_at": now, + "note": "chickin_trace_reflow_reset", + }).Error; err != nil { + return err + } + + type chickinTraceRow struct { + ID uint `gorm:"column:id"` + UsageQty float64 `gorm:"column:usage_qty"` + ChickIn time.Time `gorm:"column:chick_in_date"` + } + chickins := make([]chickinTraceRow, 0) + if err := tx.WithContext(ctx). + Table("project_chickins"). + Select("id, usage_qty, chick_in_date"). + Where("product_warehouse_id = ?", productWarehouseID). + Where("deleted_at IS NULL"). + Where("usage_qty > 0"). + Order("chick_in_date ASC, id ASC"). + Scan(&chickins).Error; err != nil { + return err + } + if len(chickins) == 0 { + return nil + } + + gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{ + FlagGroupCode: flagGroupCode, + Lane: "STOCKABLE", + ProductWarehouseID: productWarehouseID, + Limit: 50000, + Tx: tx, + }) + if err != nil { + return err + } + if len(gatherRows) == 0 { + return nil + } + + type lotKey struct { + StockableType string + StockableID uint + } + remainingByLot := make(map[lotKey]float64, len(gatherRows)) + for _, row := range gatherRows { + key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID} + remainingByLot[key] = row.AvailableQuantity + } + + lotIndex := 0 + traceNow := time.Now() + for _, chickin := range chickins { + remaining := chickin.UsageQty + for remaining > 1e-6 && lotIndex < len(gatherRows) { + lot := gatherRows[lotIndex] + key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID} + available := remainingByLot[key] + if available <= 1e-6 { + lotIndex++ + continue + } + + portion := math.Min(remaining, available) + if portion <= 1e-6 { + lotIndex++ + continue + } + + insert := map[string]any{ + "product_warehouse_id": productWarehouseID, + "stockable_type": lot.Ref.LegacyTypeKey, + "stockable_id": lot.Ref.ID, + "usable_type": fifo.UsableKeyProjectChickin.String(), + "usable_id": chickin.ID, + "qty": portion, + "status": entity.StockAllocationStatusActive, + "allocation_purpose": entity.StockAllocationPurposeTraceChickin, + "engine_version": "v2", + "flag_group_code": flagGroupCode, + "function_code": "CHICKIN_TRACE", + "created_at": traceNow, + "updated_at": traceNow, + } + if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil { + return err + } + + remaining -= portion + remainingByLot[key] = available - portion } - s.StockLogRepo.CreateOne(ctx, increaseLog, nil) + if remaining > 1e-6 { + s.Log.Warnf( + "chickin trace partial allocation for product_warehouse_id=%d chickin_id=%d: remaining=%.3f", + productWarehouseID, + chickin.ID, + remaining, + ) + } } return nil } +func (s *chickinService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + selected := row{} + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = 'STOCKABLE'"). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("fg.priority ASC, rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + return selected.FlagGroupCode, nil +} + func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKandangID uint) error { if projectFlockKandangID == 0 { return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") @@ -755,10 +983,3 @@ func (s chickinService) EnsureChickInExists(ctx context.Context, projectFlockKan return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } - -func (s *chickinService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { - if len(deltas) == 0 { - return nil - } - return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) -} diff --git a/internal/modules/production/chickins/services/fifo_stock_v2_helper.go b/internal/modules/production/chickins/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..6d2e4477 --- /dev/null +++ b/internal/modules/production/chickins/services/fifo_stock_v2_helper.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +func reflowChickinScope( + ctx context.Context, + fifoStockV2Svc commonSvc.FifoStockV2Service, + tx *gorm.DB, + productWarehouseID uint, + asOf *time.Time, +) error { + if fifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if tx == nil { + return fmt.Errorf("transaction is required") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + _, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolveChickinFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("fg.priority ASC, rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 6dd74a1b..8a6ad158 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -2,7 +2,6 @@ package recordings import ( "fmt" - "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -24,9 +23,9 @@ import ( sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -48,7 +47,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate productRepo := rProduct.NewProductRepository(db) chickinRepo := rChickin.NewChickinRepository(db) chickinDetailRepo := rChickin.NewChickinDetailRepository(db) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + transferLayingRepo := rTransferLaying.NewTransferLayingRepository(db) stockLogRepo := rStockLogs.NewStockLogRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) @@ -61,76 +60,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate validate, ) - fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) - if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyRecordingEgg, - Table: "recording_eggs", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id)", - }, - OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_eggs.recording_id) ASC", "id ASC"}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err)) - } - } - if err := fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyRecordingDepletion, - Table: "recording_depletions", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "qty", - TotalUsedQuantity: "total_used_qty", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", - }, - OrderBy: []string{"(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id) ASC", "id ASC"}, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording depletion stockable workflow: %v", err)) - } - } - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyRecordingStock, - Table: "recording_stocks", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_stocks.recording_id)", - }, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) - } - } - if err := fifoService.RegisterUsable(fifo.UsableConfig{ - Key: fifo.UsableKeyRecordingDepletion, - Table: "recording_depletions", - Columns: fifo.UsableColumns{ - ID: "id", - ProductWarehouseID: "source_product_warehouse_id", - UsageQuantity: "usage_qty", - PendingQuantity: "pending_qty", - CreatedAt: "(SELECT r.record_datetime FROM recordings r WHERE r.id = recording_depletions.recording_id)", - }, - ExcludedStockables: []fifo.StockableKey{ - fifo.StockableKeyTransferToLayingIn, - fifo.StockableKeyStockTransferIn, - fifo.StockableKeyAdjustmentIn, - fifo.StockableKeyPurchaseItems, - fifo.StockableKeyRecordingEgg, - }, - }); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already registered") { - panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err)) - } - } + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) @@ -168,8 +98,9 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepo, projectFlockPopulationRepo, chickinDetailRepo, + transferLayingRepo, validate, - fifoService, + fifoStockV2Service, ) recordingService := sRecording.NewRecordingService( @@ -179,11 +110,12 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo, approvalRepo, approvalService, - fifoService, + fifoStockV2Service, stockLogRepo, productionStandardService, projectFlockService, chickinService, + transferLayingRepo, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 0f93d0a7..3010eca1 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, @@ -821,6 +894,7 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. FROM stock_allocations WHERE stockable_type = 'PROJECT_FLOCK_POPULATION' AND status = 'ACTIVE' + AND allocation_purpose = 'CONSUME' GROUP BY stockable_id ) a WHERE p.id = a.stockable_id @@ -831,14 +905,15 @@ func (r *RecordingRepositoryImpl) ResyncProjectFlockPopulationUsage(ctx context. UPDATE project_flock_populations p SET total_used_qty = 0 WHERE p.id IN (` + idsSubquery + `) - AND NOT EXISTS ( - SELECT 1 - FROM stock_allocations sa - WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' - AND sa.status = 'ACTIVE' - AND sa.stockable_id = p.id - ) - ` + AND NOT EXISTS ( + SELECT 1 + FROM stock_allocations sa + WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION' + AND sa.status = 'ACTIVE' + AND sa.allocation_purpose = 'CONSUME' + AND sa.stockable_id = p.id + ) + ` db := r.DB().WithContext(ctx) if tx != nil { diff --git a/internal/modules/production/recordings/services/fifo_stock_v2_helper.go b/internal/modules/production/recordings/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..68aea209 --- /dev/null +++ b/internal/modules/production/recordings/services/fifo_stock_v2_helper.go @@ -0,0 +1,137 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + recordingLaneUsable = "USABLE" + recordingLaneStockable = "STOCKABLE" + + recordingFunctionStockOut = "RECORDING_STOCK_OUT" + recordingFunctionDepletionOut = "RECORDING_DEPLETION_OUT" + recordingFunctionDepletionIn = "RECORDING_DEPLETION_IN" + recordingFunctionEggIn = "RECORDING_EGG_IN" + + recordingSourceStocks = "recording_stocks" + recordingSourceDepletions = "recording_depletions" + recordingSourceEggs = "recording_eggs" +) + +func (s *recordingService) reflowRecordingScope( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + recordingID uint, + lane string, + functionCode string, + sourceTable string, +) error { + if s == nil || s.FifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if tx == nil { + return fmt.Errorf("transaction is required") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolveRecordingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID, lane, functionCode, sourceTable) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + asOf, err := resolveRecordingAsOf(ctx, tx, recordingID) + if err != nil { + return err + } + + _, err = s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolveRecordingFlagGroupByProductWarehouse( + ctx context.Context, + tx *gorm.DB, + productWarehouseID uint, + lane string, + functionCode string, + sourceTable string, +) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + q := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", lane). + Where("rr.source_table = ?", sourceTable) + + if strings.TrimSpace(functionCode) != "" { + q = q.Where("rr.function_code = ?", functionCode) + } + + err := q. + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + +func resolveRecordingAsOf(ctx context.Context, tx *gorm.DB, recordingID uint) (*time.Time, error) { + if recordingID == 0 { + asOf := time.Now().UTC() + return &asOf, nil + } + + type row struct { + RecordDatetime time.Time `gorm:"column:record_datetime"` + } + var selected row + if err := tx.WithContext(ctx). + Table("recordings"). + Select("record_datetime"). + Where("id = ?", recordingID). + Limit(1). + Take(&selected).Error; err != nil { + return nil, err + } + + asOf := selected.RecordDatetime.UTC() + return &asOf, nil +} diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 5fd387bf..4ed3c0a5 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -10,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -19,9 +20,11 @@ import ( sProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" + rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + fifo "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "github.com/go-playground/validator/v10" @@ -52,7 +55,8 @@ type recordingService struct { ProductionStandardSvc sProductionStandard.ProductionStandardService ProjectFlockSvc sProjectFlock.ProjectflockService ChickinSvc sChickin.ChickinService - FifoSvc commonSvc.FifoService + TransferLayingRepo rTransferLaying.TransferLayingRepository + FifoStockV2Svc commonSvc.FifoStockV2Service StockLogRepo rStockLogs.StockLogRepository } @@ -63,11 +67,12 @@ func NewRecordingService( projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, - fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, stockLogRepo rStockLogs.StockLogRepository, productionStandardSvc sProductionStandard.ProductionStandardService, projectFlockSvc sProjectFlock.ProjectflockService, chickinSvc sChickin.ChickinService, + transferLayingRepo rTransferLaying.TransferLayingRepository, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -82,7 +87,8 @@ func NewRecordingService( ProductionStandardSvc: productionStandardSvc, ProjectFlockSvc: projectFlockSvc, ChickinSvc: chickinSvc, - FifoSvc: fifoSvc, + TransferLayingRepo: transferLayingRepo, + FifoStockV2Svc: fifoStockV2Svc, StockLogRepo: stockLogRepo, } } @@ -287,6 +293,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent category := strings.ToUpper(pfk.ProjectFlock.Category) isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + if err := s.enforceTransferRecordingRoute(ctx, pfk, recordTime); err != nil { + return nil, err + } + if err := s.ProjectFlockSvc.EnsureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { return nil, err } @@ -351,13 +361,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 +389,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 +412,14 @@ 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 + } + if err := s.Repository.ResyncProjectFlockPopulationUsage(ctx, tx, createdRecording.ProjectFlockKandangId); err != nil { + s.Log.Errorf("Failed to resync project flock population usage: %+v", err) return err } @@ -420,7 +428,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 +523,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 +552,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 +583,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 +630,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 +645,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 +808,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 +879,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 } @@ -897,6 +905,98 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { }) } +func (s *recordingService) enforceTransferRecordingRoute( + ctx context.Context, + pfk *entity.ProjectFlockKandang, + recordTime time.Time, +) error { + if pfk == nil || pfk.Id == 0 || s.TransferLayingRepo == nil { + return nil + } + + recordDate := normalizeDateOnlyUTC(recordTime) + category := strings.ToUpper(strings.TrimSpace(pfk.ProjectFlock.Category)) + + switch category { + case strings.ToUpper(string(utils.ProjectFlockCategoryLaying)): + transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, pfk.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to resolve approved transfer by target kandang %d: %+v", pfk.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + + effectiveDate := effectiveTransferDate(transfer) + if effectiveDate.IsZero() { + return nil + } + + if recordDate.Before(effectiveDate) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Recording kandang laying hanya bisa dimulai pada %s. Sebelumnya gunakan kandang growing", effectiveDate.Format("2006-01-02")), + ) + } + + if transfer.ExecutedAt == nil || transfer.ExecutedAt.IsZero() { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Transfer laying %s sudah efektif pada %s tetapi belum dieksekusi. Eksekusi transfer terlebih dahulu", transfer.TransferNumber, effectiveDate.Format("2006-01-02")), + ) + } + + case strings.ToUpper(string(utils.ProjectFlockCategoryGrowing)): + transfer, err := s.TransferLayingRepo.GetLatestApprovedBySourceKandang(ctx, pfk.Id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + s.Log.Errorf("Failed to resolve approved transfer by source kandang %d: %+v", pfk.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transfer laying") + } + + if transfer != nil && transfer.ExecutedAt != nil && !transfer.ExecutedAt.IsZero() { + return fiber.NewError( + fiber.StatusBadRequest, + "Project flock kandang sudah dipindahkan ke laying", + ) + } + + effectiveDate := effectiveTransferDate(transfer) + if effectiveDate.IsZero() { + return nil + } + + if !recordDate.Before(effectiveDate) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Recording kandang growing hanya diperbolehkan sampai %s. Gunakan kandang laying mulai %s", effectiveDate.AddDate(0, 0, -1).Format("2006-01-02"), effectiveDate.Format("2006-01-02")), + ) + } + } + + return nil +} + +func effectiveTransferDate(transfer *entity.LayingTransfer) time.Time { + if transfer == nil { + return time.Time{} + } + if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + return normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) + } + if !transfer.TransferDate.IsZero() { + return normalizeDateOnlyUTC(transfer.TransferDate) + } + return time.Time{} +} + +func normalizeDateOnlyUTC(value time.Time) time.Time { + return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) +} + func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) @@ -1232,3 +1332,1090 @@ 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)) + + consumeQty := refreshed.UsageQty + if refreshed.PendingQty > 0 { + consumeQty += refreshed.PendingQty + } + if err := s.allocatePopulationForDepletion(ctx, tx, *refreshed, consumeQty); err != nil { + return err + } + + 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() + stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) + + for _, depletion := range depletions { + if depletion.Id == 0 { + continue + } + if err := stockAllocationRepo.ReleaseByUsable(ctx, fifo.UsableKeyRecordingDepletion.String(), depletion.Id, nil, nil); err != nil { + return err + } + 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) allocatePopulationForDepletion( + ctx context.Context, + tx *gorm.DB, + depletion entity.RecordingDepletion, + consumeQty float64, +) error { + if consumeQty <= 0 { + return nil + } + if tx == nil { + return errors.New("transaction is required") + } + + sourceWarehouseID := uint(0) + if depletion.SourceProductWarehouseId != nil { + sourceWarehouseID = *depletion.SourceProductWarehouseId + } + if sourceWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") + } + + var projectFlockKandangID uint + if err := tx.WithContext(ctx). + Table("recordings"). + Select("project_flock_kandangs_id"). + Where("id = ?", depletion.RecordingId). + Scan(&projectFlockKandangID).Error; err != nil { + return err + } + if projectFlockKandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak ditemukan untuk depletion") + } + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID(ctx, projectFlockKandangID, sourceWarehouseID) + if err != nil { + return err + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk depletion") + } + + return fifoV2.AllocatePopulationConsumption( + ctx, + tx, + populations, + sourceWarehouseID, + fifo.UsableKeyRecordingDepletion.String(), + depletion.Id, + consumeQty, + ) +} + +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 eb9e5094..00000000 --- a/internal/modules/production/recordings/services/recording_fifo.service.go +++ /dev/null @@ -1,1133 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - "math" - "strings" - "time" - - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" - - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" -) - -var recordingStockUsableKey = fifo.UsableKeyRecordingStock -var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion - -const depletionUsageTolerance = 0.000001 - -func (s *recordingService) logStockTrace(action string, stock entity.RecordingStock, extra string) { - 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for consuming recording stocks") - return errors.New("fifo 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 - - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - ProductWarehouseID: stock.ProductWarehouseId, - Quantity: desiredTotal, - AllowPending: true, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording stock %d: %+v", stock.Id, err) - return err - } - - if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { - return err - } - s.logStockTrace("consume:done", stock, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desiredTotal, result.UsageQuantity, result.PendingQuantity)) - - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, - CreatedBy: actorID, - Decrease: logDecrease, - 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.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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for consuming recording depletions") - return errors.New("fifo 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 - result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - ProductWarehouseID: sourceWarehouseID, - Quantity: desired, - AllowPending: false, - Tx: tx, - }) - if err != nil { - s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err) - return err - } - - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { - return err - } - s.logDepletionTrace("consume:done", depletion, fmt.Sprintf("desired=%.3f used=%.3f pending=%.3f", desired, result.UsageQuantity, result.PendingQuantity)) - - logDecrease := result.UsageQuantity - if result.PendingQuantity > 0 { - logDecrease += result.PendingQuantity - } - if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - CreatedBy: actorID, - Decrease: logDecrease, - 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.Decrease - } else { - log.Stock -= log.Decrease - } - - if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { - return err - } - } - - destDelta := depletion.Qty + depletion.PendingQty - if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - if depletion.ProductWarehouseId == sourceWarehouseID { - continue - } - log := &entity.StockLog{ - ProductWarehouseId: depletion.ProductWarehouseId, - CreatedBy: actorID, - Increase: 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.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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for releasing recording stocks") - return errors.New("fifo 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 - } - if stock.UsageQty != nil && *stock.UsageQty > 0 { - activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id) - if err != nil { - return err - } - if activeCount == 0 { - s.Log.Warnf("recording-stock release: no active allocations, forcing usage/pending to 0 (stock_id=%d)", stock.Id) - if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { - return err - } - continue - } - if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { - return err - } - if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingStock, stock.Id); err != nil { - return err - } - } - s.logStockTrace("release:start", stock, "") - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingStockUsableKey, - UsableID: stock.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording stock %d: %+v", stock.Id, err) - return err - } - - if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { - return err - } - s.logStockTrace("release:done", stock, "") - - if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: stock.ProductWarehouseId, - CreatedBy: actorID, - Increase: *stock.UsageQty, - 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for releasing recording depletions") - return errors.New("fifo 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 - } - if depletion.UsageQty > 0 { - activeCount, err := s.countActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id) - if err != nil { - return err - } - if activeCount == 0 { - s.Log.Warnf("recording-depletion release: no active allocations, forcing usage/pending to 0 (depletion_id=%d)", depletion.Id) - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { - return err - } - if err := tx.WithContext(ctx). - Table("recording_depletions"). - Where("id = ?", depletion.Id). - Update("usage_qty", 0).Error; err != nil { - return err - } - continue - } - if err := s.resyncStockableUsageFromAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { - return err - } - if err := s.ensureActiveAllocations(ctx, tx, fifo.UsableKeyRecordingDepletion, depletion.Id); err != nil { - return err - } - } - s.logDepletionTrace("release:start", depletion, "") - if err := validateDepletionUsage(depletion); err != nil { - s.Log.Errorf("FIFO depletion mismatch for recording %d (depletion %d): qty=%.3f usage=%.3f pending=%.3f", depletion.RecordingId, depletion.Id, depletion.Qty, depletion.UsageQty, depletion.PendingQty) - return err - } - - sourceWarehouseID := uint(0) - if depletion.SourceProductWarehouseId != nil { - sourceWarehouseID = *depletion.SourceProductWarehouseId - } - if sourceWarehouseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion") - } - if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{ - UsableKey: recordingDepletionUsableKey, - UsableID: depletion.Id, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err) - return err - } - - if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { - return err - } - s.logDepletionTrace("release:done", depletion, "") - - logIncrease := depletion.Qty - if depletion.PendingQty > 0 { - logIncrease += depletion.PendingQty - } - if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 { - log := &entity.StockLog{ - ProductWarehouseId: sourceWarehouseID, - 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 - } - } - - destDelta := depletion.Qty + depletion.PendingQty - 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for replenishing recording eggs") - return errors.New("fifo 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 := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, - StockableID: egg.Id, - ProductWarehouseID: egg.ProductWarehouseId, - Quantity: float64(egg.Qty), - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) - 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for replenishing recording depletions") - return errors.New("fifo 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.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyRecordingDepletion, - StockableID: depletion.Id, - ProductWarehouseID: depletion.ProductWarehouseId, - Quantity: depletion.Qty, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to replenish FIFO stock for recording depletion %d: %+v", depletion.Id, err) - 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for reducing recording depletions") - return errors.New("fifo 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 := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyRecordingDepletion, - StockableID: depletion.Id, - ProductWarehouseID: depletion.ProductWarehouseId, - Quantity: -depletion.Qty, - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to reduce FIFO stock for recording depletion %d: %+v", depletion.Id, err) - 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for reducing recording eggs") - return errors.New("fifo 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 := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyRecordingEgg, - StockableID: egg.Id, - ProductWarehouseID: egg.ProductWarehouseId, - Quantity: -float64(egg.Qty), - Tx: tx, - }); err != nil { - s.Log.Errorf("Failed to reduce FIFO stock for recording egg %d: %+v", egg.Id, err) - 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for syncing recording stocks") - return errors.New("fifo 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.FifoSvc == nil { - s.Log.Errorf("FIFO service is not available for recording operations") - return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is required for recording operations") - } - return nil -} diff --git a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go index 581b9093..7b4b76ff 100644 --- a/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go +++ b/internal/modules/production/transfer_layings/controllers/transfer_laying.controller.go @@ -186,6 +186,28 @@ func (u *TransferLayingController) Approval(c *fiber.Ctx) error { }) } +func (u *TransferLayingController) Execute(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransferLayingService.Execute(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Execute transfer laying successfully", + Data: dto.ToTransferLayingDetailDTOWithSingleApproval(*result, result.LatestApproval), + }) +} + func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) if err != nil { diff --git a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go index 53e069b2..a23cc7df 100644 --- a/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go +++ b/internal/modules/production/transfer_layings/dto/transfer_laying.dto.go @@ -14,10 +14,12 @@ import ( // === DTO Structs === type TransferLayingRelationDTO struct { - Id uint `json:"id"` - TransferNumber string `json:"transfer_number"` - TransferDate time.Time `json:"transfer_date"` - Notes string `json:"notes"` + Id uint `json:"id"` + TransferNumber string `json:"transfer_number"` + TransferDate time.Time `json:"transfer_date"` + EffectiveMoveDate *time.Time `json:"effective_move_date,omitempty"` + ExecutedAt *time.Time `json:"executed_at,omitempty"` + Notes string `json:"notes"` } type ProjectFlockKandangWithKandangDTO struct { @@ -47,6 +49,8 @@ type TransferLayingListDTO struct { ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"` CreatedBy uint `json:"created_by"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + ExecutedBy *uint `json:"executed_by,omitempty"` + ExecutedUser *userDTO.UserRelationDTO `json:"executed_user,omitempty"` CreatedAt time.Time `json:"created_at"` Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` } @@ -88,10 +92,12 @@ type MaxTargetQtyForTransferDTO struct { func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO { return TransferLayingRelationDTO{ - Id: e.Id, - TransferNumber: e.TransferNumber, - TransferDate: e.TransferDate, - Notes: e.Notes, + Id: e.Id, + TransferNumber: e.TransferNumber, + TransferDate: e.TransferDate, + EffectiveMoveDate: e.EffectiveMoveDate, + ExecutedAt: e.ExecutedAt, + Notes: e.Notes, } } @@ -190,6 +196,12 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { createdUser = &mapped } + var executedUser *userDTO.UserRelationDTO + if e.ExecutedUser != nil && e.ExecutedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(*e.ExecutedUser) + executedUser = &mapped + } + var approval *approvalDTO.ApprovalRelationDTO if e.LatestApproval != nil { mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval) @@ -219,6 +231,8 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO { ToProjectFlock: toProjectFlock, CreatedBy: e.CreatedBy, CreatedUser: createdUser, + ExecutedBy: e.ExecutedBy, + ExecutedUser: executedUser, CreatedAt: e.CreatedAt, Approval: approval, } diff --git a/internal/modules/production/transfer_layings/module.go b/internal/modules/production/transfer_layings/module.go index dfe2ad44..a8044f79 100644 --- a/internal/modules/production/transfer_layings/module.go +++ b/internal/modules/production/transfer_layings/module.go @@ -10,11 +10,11 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/repositories" sTransferLaying "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" @@ -37,6 +37,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) // daftarin jadi stockable if err := fifoService.RegisterStockable(fifo.StockableConfig{ @@ -91,6 +92,7 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val warehouseRepo, approvalService, fifoService, + fifoStockV2Service, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go index 68867265..1d28d9c9 100644 --- a/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go +++ b/internal/modules/production/transfer_layings/repositories/laying_transfer.repository.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -14,6 +15,8 @@ type TransferLayingRepository interface { repository.BaseRepository[entity.LayingTransfer] GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error) IdExists(ctx context.Context, id uint) (bool, error) + GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) + GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) // Tambah method baru untuk query dengan filter lengkap GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) @@ -164,6 +167,7 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of Preload("FromProjectFlock"). Preload("ToProjectFlock"). Preload("CreatedUser"). + Preload("ExecutedUser"). Preload("Sources"). Preload("Sources.SourceProjectFlockKandang"). Preload("Sources.SourceProjectFlockKandang.Kandang"). @@ -180,3 +184,57 @@ func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, of return records, total, nil } + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedBySourceKandang(ctx context.Context, sourceProjectFlockKandangID uint) (*entity.LayingTransfer, error) { + if sourceProjectFlockKandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + + var transfer entity.LayingTransfer + err := r.db.WithContext(ctx). + Model(&entity.LayingTransfer{}). + Joins("JOIN laying_transfer_sources lts ON lts.laying_transfer_id = laying_transfers.id AND lts.deleted_at IS NULL"). + Where("lts.source_project_flock_kandang_id = ?", sourceProjectFlockKandangID). + Where("laying_transfers.deleted_at IS NULL"). + Where(`( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = laying_transfers.id + ORDER BY a.id DESC + LIMIT 1 + ) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved). + Order("laying_transfers.id DESC"). + First(&transfer).Error + if err != nil { + return nil, err + } + return &transfer, nil +} + +func (r *TransferLayingRepositoryImpl) GetLatestApprovedByTargetKandang(ctx context.Context, targetProjectFlockKandangID uint) (*entity.LayingTransfer, error) { + if targetProjectFlockKandangID == 0 { + return nil, gorm.ErrRecordNotFound + } + + var transfer entity.LayingTransfer + err := r.db.WithContext(ctx). + Model(&entity.LayingTransfer{}). + Joins("JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = laying_transfers.id AND ltt.deleted_at IS NULL"). + Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID). + Where("laying_transfers.deleted_at IS NULL"). + Where(`( + SELECT a.action + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = laying_transfers.id + ORDER BY a.id DESC + LIMIT 1 + ) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved). + Order("laying_transfers.id DESC"). + First(&transfer).Error + if err != nil { + return nil, err + } + return &transfer, nil +} diff --git a/internal/modules/production/transfer_layings/route.go b/internal/modules/production/transfer_layings/route.go index c16ba1a8..edd1877c 100644 --- a/internal/modules/production/transfer_layings/route.go +++ b/internal/modules/production/transfer_layings/route.go @@ -27,6 +27,7 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying. route.Patch("/:id", m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne) route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne) route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval) + route.Post("/:id/execute", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Execute) route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang) route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang) } diff --git a/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..3df37dbf --- /dev/null +++ b/internal/modules/production/transfer_layings/services/fifo_stock_v2_helper.go @@ -0,0 +1,87 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + transferLayingInFunctionCode = "TRANSFER_TO_LAYING_IN" + transferLayingStockableLane = "STOCKABLE" + transferLayingSourceTable = "laying_transfer_targets" +) + +func reflowTransferLayingScope( + ctx context.Context, + fifoStockV2Svc commonSvc.FifoStockV2Service, + tx *gorm.DB, + productWarehouseID uint, + asOf *time.Time, +) error { + if fifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if tx == nil { + return fmt.Errorf("transaction is required") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolveTransferLayingFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + _, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolveTransferLayingFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", transferLayingStockableLane). + Where("rr.function_code = ?", transferLayingInFunctionCode). + Where("rr.source_table = ?", transferLayingSourceTable). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} diff --git a/internal/modules/production/transfer_layings/services/transfer_laying.service.go b/internal/modules/production/transfer_layings/services/transfer_laying.service.go index 15351e56..9a1bf993 100644 --- a/internal/modules/production/transfer_layings/services/transfer_laying.service.go +++ b/internal/modules/production/transfer_layings/services/transfer_laying.service.go @@ -10,6 +10,8 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + fifoV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rInventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" @@ -34,6 +36,7 @@ type TransferLayingService interface { UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) DeleteOne(ctx *fiber.Ctx, id uint) error Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error) + Execute(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error) GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) } @@ -52,8 +55,13 @@ type transferLayingService struct { StockLogRepo rStockLogs.StockLogRepository ApprovalService commonSvc.ApprovalService FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service } +const ( + transferToLayingFlagGroupCode = "AYAM" +) + func NewTransferLayingService( repo repository.TransferLayingRepository, layingTransferSourceRepo repository.LayingTransferSourceRepository, @@ -65,6 +73,7 @@ func NewTransferLayingService( warehouseRepo rWarehouse.WarehouseRepository, approvalService commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, validate *validator.Validate, ) TransferLayingService { return &transferLayingService{ @@ -81,12 +90,14 @@ func NewTransferLayingService( StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()), ApprovalService: approvalService, FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, } } func (s transferLayingService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). + Preload("ExecutedUser"). Preload("FromProjectFlock"). Preload("ToProjectFlock"). Preload("Sources"). @@ -744,13 +755,10 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { repoTx := s.Repository.WithTx(dbTransaction) approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - stockAllocationRepo := commonRepo.NewStockAllocationRepository(dbTransaction) sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) - targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) - stockLogRepoTx := rStockLogs.NewStockLogRepository(dbTransaction) for _, approvableID := range approvableIDs { - transfer, err := repoTx.GetByID(c.Context(), approvableID, nil) + _, err := repoTx.GetByID(c.Context(), approvableID, nil) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("TransferLaying %d not found", approvableID)) @@ -771,148 +779,21 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( } if action == entity.ApprovalActionApproved { - sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), approvableID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sources transfer") } - - targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), approvableID) + effectiveMoveDate, err := s.calculateEffectiveMoveDate(c.Context(), sources) if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil targets transfer") + return err } - totalTargetQty := 0.0 - for _, target := range targets { - totalTargetQty += target.TotalQty - } - - totalSourceRequested := 0.0 - for _, source := range sources { - totalSourceRequested += source.RequestedQty - } - - for _, source := range sources { - if source.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", approvableID)) - } - - sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty - - consumeResult, err := s.FifoSvc.Consume(c.Context(), commonSvc.StockConsumeRequest{ - UsableKey: fifo.UsableKeyTransferToLayingOut, - UsableID: source.Id, - ProductWarehouseID: *source.ProductWarehouseId, - Quantity: sourceShare, - AllowPending: false, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal consume FIFO stock: %v", err)) - } - - if err := sourceRepoTx.PatchOne(c.Context(), source.Id, map[string]interface{}{ - "usage_qty": source.UsageQty + consumeResult.UsageQuantity, - "pending_usage_qty": consumeResult.PendingQuantity, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") - } - - targetShares := distributeProportionalWithRounding(targets, totalTargetQty, sourceShare) - - for i, target := range targets { - roundedQty := math.Round(targetShares[i]) - if roundedQty <= 0 { - continue - } - mappingAllocation := &entity.StockAllocation{ - StockableType: fifo.UsableKeyTransferToLayingOut.String(), - StockableId: source.Id, - UsableType: fifo.StockableKeyTransferToLayingIn.String(), - UsableId: target.Id, - ProductWarehouseId: *source.ProductWarehouseId, - Qty: roundedQty, - Status: entity.StockAllocationStatusActive, - } - if err := stockAllocationRepo.CreateOne(c.Context(), mappingAllocation, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target") - } - } - - stockLogDecrease := &entity.StockLog{ - ProductWarehouseId: *source.ProductWarehouseId, - CreatedBy: actorID, - Increase: 0, - Decrease: sourceShare, - LoggableType: string(utils.StockLogTypeTransferLaying), - LoggableId: approvableID, - Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), - } - stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *source.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") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease - } else { - stockLogDecrease.Stock -= stockLogDecrease.Decrease - } - - if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") - } - } - - for _, target := range targets { - if target.ProductWarehouseId == nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) - } - - note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) - _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyTransferToLayingIn, - StockableID: target.Id, - ProductWarehouseID: *target.ProductWarehouseId, - Quantity: target.TotalQty, - Note: ¬e, - Tx: dbTransaction, - }) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) - } - - if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]interface{}{ - "total_qty": target.TotalQty, - }, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") - } - - stockLogIncrease := &entity.StockLog{ - ProductWarehouseId: *target.ProductWarehouseId, - CreatedBy: actorID, - Increase: target.TotalQty, - Decrease: 0, - LoggableType: string(utils.StockLogTypeTransferLaying), - LoggableId: approvableID, - Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), - } - stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.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") - } - if len(stockLogs) > 0 { - latestStockLog := stockLogs[0] - stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase - } else { - stockLogIncrease.Stock += stockLogIncrease.Increase - } - - if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") - } + if err := repoTx.PatchOne(c.Context(), approvableID, map[string]any{ + "effective_move_date": effectiveMoveDate, + "executed_at": nil, + "executed_by": nil, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") } } } @@ -939,6 +820,393 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) ( return updated, nil } +func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { + if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + repoTx := s.Repository.WithTx(dbTransaction) + sourceRepoTx := repository.NewLayingTransferSourceRepository(dbTransaction) + targetRepoTx := repository.NewLayingTransferTargetRepository(dbTransaction) + approvalRepoTx := commonRepo.NewApprovalRepository(dbTransaction) + + transfer, err := repoTx.GetByID(c.Context(), id, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Transfer laying tidak ditemukan") + } + return err + } + + if transfer.ExecutedAt != nil { + return nil + } + + latestApproval, err := approvalRepoTx.LatestByTarget(c.Context(), string(utils.ApprovalWorkflowTransferToLaying), transfer.Id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if latestApproval == nil || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying harus disetujui sebelum dieksekusi") + } + + sources, err := sourceRepoTx.GetByLayingTransferId(c.Context(), transfer.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil sumber transfer laying") + } + + targets, err := targetRepoTx.GetByLayingTransferId(c.Context(), transfer.Id) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil target transfer laying") + } + + if transfer.EffectiveMoveDate == nil || transfer.EffectiveMoveDate.IsZero() { + effectiveMoveDate, calcErr := s.calculateEffectiveMoveDate(c.Context(), sources) + if calcErr != nil { + return calcErr + } + if patchErr := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ + "effective_move_date": effectiveMoveDate, + }, nil); patchErr != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan tanggal efektif transfer laying") + } + transfer.EffectiveMoveDate = &effectiveMoveDate + } + + effectiveMoveDate := normalizeDateOnlyUTC(*transfer.EffectiveMoveDate) + today := normalizeDateOnlyUTC(time.Now().UTC()) + if today.Before(effectiveMoveDate) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying baru bisa dieksekusi mulai tanggal %s", effectiveMoveDate.Format("2006-01-02"))) + } + + if err := s.executeApprovedTransferMovement(c.Context(), dbTransaction, transfer, actorID, sources, targets); err != nil { + return err + } + + executedAt := time.Now().UTC() + if err := repoTx.PatchOne(c.Context(), transfer.Id, map[string]any{ + "executed_at": executedAt, + "executed_by": actorID, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal menyimpan status eksekusi transfer laying") + } + + return nil + }) + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengeksekusi transfer laying") + } + + transfer, _, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + return transfer, nil +} + +func (s *transferLayingService) executeApprovedTransferMovement( + ctx context.Context, + tx *gorm.DB, + transfer *entity.LayingTransfer, + actorID uint, + sources []entity.LayingTransferSource, + targets []entity.LayingTransferTarget, +) error { + if transfer == nil || transfer.Id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying tidak valid") + } + if len(sources) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki sumber") + } + if len(targets) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Transfer laying belum memiliki target") + } + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") + } + if s.FifoSvc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available") + } + + stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx) + sourceRepoTx := repository.NewLayingTransferSourceRepository(tx) + targetRepoTx := repository.NewLayingTransferTargetRepository(tx) + stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) + + totalTargetQty := 0.0 + for _, target := range targets { + totalTargetQty += target.TotalQty + } + if totalTargetQty <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas target transfer laying harus lebih dari 0") + } + + totalSourceRequested := 0.0 + for _, source := range sources { + totalSourceRequested += source.RequestedQty + } + if totalSourceRequested <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Total kuantitas sumber transfer laying harus lebih dari 0") + } + + for _, source := range sources { + if source.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Source product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) + } + if source.RequestedQty <= 0 { + continue + } + + sourceShare := (source.RequestedQty / totalSourceRequested) * totalTargetQty + if sourceShare <= 0 { + continue + } + + if err := sourceRepoTx.PatchOne(ctx, source.Id, map[string]any{ + "usage_qty": source.UsageQty + sourceShare, + "pending_usage_qty": 0, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update source usage qty") + } + + asOf := transfer.TransferDate + if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() { + asOf = *transfer.EffectiveMoveDate + } + if _, err := s.FifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: transferToLayingFlagGroupCode, + ProductWarehouseID: *source.ProductWarehouseId, + AsOf: &asOf, + Tx: tx, + }); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal consume FIFO v2 stock: %v", err)) + } + + refreshedSource, err := sourceRepoTx.GetByID(ctx, source.Id, nil) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal refresh source transfer setelah reflow") + } + + usageDelta := refreshedSource.UsageQty - source.UsageQty + pendingQty := refreshedSource.PendingUsageQty + if pendingQty > 1e-6 || usageDelta < sourceShare-1e-6 { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Stok sumber tidak mencukupi untuk mengeksekusi transfer laying %s", transfer.TransferNumber), + ) + } + + movedQty := usageDelta + if err := s.allocatePopulationForTransfer(ctx, tx, source, movedQty); err != nil { + return err + } + targetShares := distributeProportionalWithRounding(targets, totalTargetQty, movedQty) + for i, target := range targets { + roundedQty := math.Round(targetShares[i]) + if roundedQty <= 0 { + continue + } + mappingAllocation := &entity.StockAllocation{ + StockableType: fifo.UsableKeyTransferToLayingOut.String(), + StockableId: source.Id, + UsableType: fifo.StockableKeyTransferToLayingIn.String(), + UsableId: target.Id, + ProductWarehouseId: *source.ProductWarehouseId, + Qty: roundedQty, + Status: entity.StockAllocationStatusActive, + } + if err := stockAllocationRepo.CreateOne(ctx, mappingAllocation, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal create mapping allocation source→target") + } + } + + stockLogDecrease := &entity.StockLog{ + ProductWarehouseId: *source.ProductWarehouseId, + CreatedBy: actorID, + Increase: 0, + Decrease: movedQty, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: transfer.Id, + Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), + } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *source.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") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease + } else { + stockLogDecrease.Stock -= stockLogDecrease.Decrease + } + + if err := stockLogRepoTx.CreateOne(ctx, stockLogDecrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") + } + } + + for _, target := range targets { + if target.ProductWarehouseId == nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id)) + } + + note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber) + _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ + StockableKey: fifo.StockableKeyTransferToLayingIn, + StockableID: target.Id, + ProductWarehouseID: *target.ProductWarehouseId, + Quantity: target.TotalQty, + Note: ¬e, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err)) + } + + if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{ + "total_qty": target.TotalQty, + }, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty") + } + + stockLogIncrease := &entity.StockLog{ + ProductWarehouseId: *target.ProductWarehouseId, + CreatedBy: actorID, + Increase: target.TotalQty, + Decrease: 0, + LoggableType: string(utils.StockLogTypeTransferLaying), + LoggableId: transfer.Id, + Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), + } + stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, *target.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") + } + if len(stockLogs) > 0 { + latestStockLog := stockLogs[0] + stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase + } else { + stockLogIncrease.Stock += stockLogIncrease.Increase + } + + if err := stockLogRepoTx.CreateOne(ctx, stockLogIncrease, nil); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") + } + } + + return nil +} + +func (s *transferLayingService) allocatePopulationForTransfer( + ctx context.Context, + tx *gorm.DB, + source entity.LayingTransferSource, + consumeQty float64, +) error { + if consumeQty <= 0 { + return nil + } + if tx == nil { + return errors.New("transaction is required") + } + if source.SourceProjectFlockKandangId == 0 || source.ProductWarehouseId == nil || *source.ProductWarehouseId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber atau product warehouse tidak valid") + } + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangIDAndProductWarehouseID( + ctx, + source.SourceProjectFlockKandangId, + *source.ProductWarehouseId, + ) + if err != nil { + return err + } + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan untuk transfer laying") + } + + return fifoV2.AllocatePopulationConsumption( + ctx, + tx, + populations, + *source.ProductWarehouseId, + fifo.UsableKeyTransferToLayingOut.String(), + source.Id, + consumeQty, + ) +} + +func (s *transferLayingService) calculateEffectiveMoveDate(ctx context.Context, sources []entity.LayingTransferSource) (time.Time, error) { + if len(sources) == 0 { + return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Sumber transfer laying tidak ditemukan") + } + + maxGrowingWeek := config.TransferToLayingGrowingMaxWeek + if maxGrowingWeek <= 0 { + maxGrowingWeek = 19 + } + + var baselineChickInDate time.Time + for _, source := range sources { + chickInDate, err := s.resolveSourceChickInDate(ctx, source.SourceProjectFlockKandangId) + if err != nil { + return time.Time{}, err + } + if baselineChickInDate.IsZero() || chickInDate.Before(baselineChickInDate) { + baselineChickInDate = chickInDate + } + } + + if baselineChickInDate.IsZero() { + return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in sumber transfer laying tidak ditemukan") + } + + effectiveMoveDate := baselineChickInDate.AddDate(0, 0, maxGrowingWeek*7) + return normalizeDateOnlyUTC(effectiveMoveDate), nil +} + +func (s *transferLayingService) resolveSourceChickInDate(ctx context.Context, sourceProjectFlockKandangID uint) (time.Time, error) { + if sourceProjectFlockKandangID == 0 { + return time.Time{}, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang sumber tidak valid") + } + + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, sourceProjectFlockKandangID) + if err != nil { + return time.Time{}, err + } + + var earliestChickInDate time.Time + for _, population := range populations { + if population.ProjectChickin == nil || population.ProjectChickin.ChickInDate.IsZero() { + continue + } + chickInDate := normalizeDateOnlyUTC(population.ProjectChickin.ChickInDate) + if earliestChickInDate.IsZero() || chickInDate.Before(earliestChickInDate) { + earliestChickInDate = chickInDate + } + } + + if earliestChickInDate.IsZero() { + return time.Time{}, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Tanggal chick in untuk kandang sumber %d tidak ditemukan", sourceProjectFlockKandangID), + ) + } + + return earliestChickInDate, nil +} + func createApprovalTransferLaying(ctx context.Context, tx *gorm.DB, transferLayingID uint, actorID uint) error { if transferLayingID == 0 || actorID == 0 { return nil @@ -1053,6 +1321,10 @@ func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFl return kandangMaxTargetQty, nil } +func normalizeDateOnlyUTC(value time.Time) time.Time { + return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC) +} + func distributeProportionalWithRounding(targets []entity.LayingTransferTarget, totalTargetQty, sourceShare float64) []float64 { if len(targets) == 0 { return []float64{} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index fae714fb..dbd7f772 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -23,7 +23,6 @@ import ( rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gorm.io/gorm" ) @@ -40,7 +39,6 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseRepository := expenseRepo.NewExpenseRepository(db) expenseRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) projectFlockKandangRepository := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) - stockAllocRepo := commonRepo.NewStockAllocationRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) @@ -73,19 +71,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate expenseServiceInstance, ) - fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) - _ = fifoService.RegisterStockable(fifo.StockableConfig{ - Key: fifo.StockableKeyPurchaseItems, - Table: "purchase_items", - Columns: fifo.StockableColumns{ - ID: "id", - ProductWarehouseID: "product_warehouse_id", - TotalQuantity: "total_qty", - TotalUsedQuantity: "total_used", - CreatedAt: "id", - }, - OrderBy: []string{"id ASC"}, - }) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) purchaseService := service.NewPurchaseService( validate, @@ -97,7 +83,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockKandangRepository, approvalService, expenseBridge, - fifoService, + fifoStockV2Service, documentSvc, ) diff --git a/internal/modules/purchases/services/fifo_stock_v2_helper.go b/internal/modules/purchases/services/fifo_stock_v2_helper.go new file mode 100644 index 00000000..e0b619a9 --- /dev/null +++ b/internal/modules/purchases/services/fifo_stock_v2_helper.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +const ( + purchaseInFunctionCode = "PURCHASE_IN" + purchaseStockableLane = "STOCKABLE" + purchaseSourceTable = "purchase_items" +) + +func reflowPurchaseScope( + ctx context.Context, + fifoStockV2Svc commonSvc.FifoStockV2Service, + tx *gorm.DB, + productWarehouseID uint, + asOf *time.Time, +) error { + if fifoStockV2Svc == nil { + return fmt.Errorf("FIFO v2 service is not available") + } + if productWarehouseID == 0 { + return fmt.Errorf("product warehouse id is required") + } + + flagGroupCode, err := resolvePurchaseFlagGroupByProductWarehouse(ctx, tx, productWarehouseID) + if err != nil { + return err + } + if strings.TrimSpace(flagGroupCode) == "" { + return fmt.Errorf("flag group code is not found for product warehouse %d", productWarehouseID) + } + + _, err = fifoStockV2Svc.Reflow(ctx, commonSvc.FifoStockV2ReflowRequest{ + FlagGroupCode: flagGroupCode, + ProductWarehouseID: productWarehouseID, + AsOf: asOf, + Tx: tx, + }) + return err +} + +func resolvePurchaseFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) { + type row struct { + FlagGroupCode string `gorm:"column:flag_group_code"` + } + + var selected row + err := tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Select("rr.flag_group_code"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", purchaseStockableLane). + Where("rr.function_code = ?", purchaseInFunctionCode). + Where("rr.source_table = ?", purchaseSourceTable). + Where(` + EXISTS ( + SELECT 1 + FROM product_warehouses pw + JOIN flags f ON f.flagable_id = pw.product_id + JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE + WHERE pw.id = ? + AND f.flagable_type = ? + AND fm.flag_group_code = rr.flag_group_code + ) + `, productWarehouseID, entity.FlagableTypeProduct). + Order("rr.id ASC"). + Limit(1). + Take(&selected).Error + if err != nil { + return "", err + } + + return strings.TrimSpace(selected.FlagGroupCode), nil +} + +func assignEarliestAsOf(m map[uint]time.Time, productWarehouseID uint, asOf time.Time) { + if productWarehouseID == 0 { + return + } + if current, ok := m[productWarehouseID]; !ok || asOf.Before(current) { + m[productWarehouseID] = asOf + } +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 50e891f5..9c038bdd 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -57,7 +57,7 @@ type purchaseService struct { ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge - FifoSvc commonSvc.FifoService + FifoStockV2Svc commonSvc.FifoStockV2Service DocumentSvc commonSvc.DocumentService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -77,7 +77,7 @@ func NewPurchaseService( projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, - fifoSvc commonSvc.FifoService, + fifoStockV2Svc commonSvc.FifoStockV2Service, documentSvc commonSvc.DocumentService, ) PurchaseService { return &purchaseService{ @@ -91,7 +91,7 @@ func NewPurchaseService( ProjectFlockKandangRepo: projectFlockKandangRepo, ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, - FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, DocumentSvc: documentSvc, approvalWorkflow: utils.ApprovalWorkflowPurchase, } @@ -256,35 +256,13 @@ func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) } if len(purchase.Items) > 0 { - itemIDs := make([]uint, 0, len(purchase.Items)) - for i := range purchase.Items { - if purchase.Items[i].Id == 0 { - continue - } - itemIDs = append(itemIDs, purchase.Items[i].Id) + lockedIDs, err := s.resolveChickinLockedItemIDs(c.Context(), s.PurchaseRepo.DB(), purchase.Items) + if err != nil { + return nil, err } - if len(itemIDs) > 0 { - var usedIDs []uint - if err := s.PurchaseRepo.DB().WithContext(c.Context()). - Model(&entity.StockAllocation{}). - Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Pluck("stockable_id", &usedIDs).Error; err != nil { - return nil, err - } - usedSet := make(map[uint]struct{}, len(usedIDs)) - for _, id := range usedIDs { - usedSet[id] = struct{}{} - } - for i := range purchase.Items { - if _, ok := usedSet[purchase.Items[i].Id]; ok { - purchase.Items[i].HasChickin = true - } + for i := range purchase.Items { + if _, ok := lockedIDs[purchase.Items[i].Id]; ok { + purchase.Items[i].HasChickin = true } } } @@ -532,48 +510,31 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid } if action == entity.ApprovalActionApproved { - itemIDs := make([]uint, 0, len(purchase.Items)) itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { if purchase.Items[i].Id == 0 { continue } - itemIDs = append(itemIDs, purchase.Items[i].Id) itemByID[purchase.Items[i].Id] = purchase.Items[i] } - if len(itemIDs) > 0 { - var usedIDs []uint - if err := s.PurchaseRepo.DB().WithContext(ctx). - Model(&entity.StockAllocation{}). - Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Pluck("stockable_id", &usedIDs).Error; err != nil { - return nil, err - } - if len(usedIDs) > 0 { - usedSet := make(map[uint]struct{}, len(usedIDs)) - for _, id := range usedIDs { - usedSet[id] = struct{}{} + lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items) + if err != nil { + return nil, err + } + if len(lockedIDs) > 0 { + for _, payload := range req.Items { + if payload.PurchaseItemID == 0 || payload.Qty == nil { + continue } - for _, payload := range req.Items { - if payload.PurchaseItemID == 0 || payload.Qty == nil { - continue - } - if _, used := usedSet[payload.PurchaseItemID]; !used { - continue - } - item, ok := itemByID[payload.PurchaseItemID] - if !ok { - continue - } - if *payload.Qty != item.SubQty { - return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah") - } + if _, locked := lockedIDs[payload.PurchaseItemID]; !locked { + continue + } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + if *payload.Qty != item.SubQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty tidak bisa diubah") } } } @@ -826,50 +787,37 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation req.Items[idx].TravelDocumentPath = &uploadedURL } } + lockedIDs := map[uint]struct{}{} if action == entity.ApprovalActionApproved { - itemIDs := make([]uint, 0, len(purchase.Items)) itemByID := make(map[uint]entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { if purchase.Items[i].Id == 0 { continue } - itemIDs = append(itemIDs, purchase.Items[i].Id) itemByID[purchase.Items[i].Id] = purchase.Items[i] } - if len(itemIDs) > 0 { - var usedIDs []uint - if err := s.PurchaseRepo.DB().WithContext(ctx). - Model(&entity.StockAllocation{}). - Distinct("stockable_id"). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Pluck("stockable_id", &usedIDs).Error; err != nil { - return nil, err + locked, err := s.resolveChickinLockedItemIDs(ctx, s.PurchaseRepo.DB(), purchase.Items) + if err != nil { + return nil, err + } + if len(locked) > 0 { + for id := range locked { + lockedIDs[id] = struct{}{} } - if len(usedIDs) > 0 { - usedSet := make(map[uint]struct{}, len(usedIDs)) - for _, id := range usedIDs { - usedSet[id] = struct{}{} + for _, payload := range req.Items { + if _, used := lockedIDs[payload.PurchaseItemID]; !used { + continue } - for _, payload := range req.Items { - if _, used := usedSet[payload.PurchaseItemID]; !used { - continue - } - item, ok := itemByID[payload.PurchaseItemID] - if !ok { - continue - } - receivedQty := item.SubQty - if payload.ReceivedQty != nil { - receivedQty = *payload.ReceivedQty - } - if receivedQty != item.TotalQty { - return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah") - } + item, ok := itemByID[payload.PurchaseItemID] + if !ok { + continue + } + receivedQty := item.SubQty + if payload.ReceivedQty != nil { + receivedQty = *payload.ReceivedQty + } + if receivedQty != item.TotalQty { + return nil, utils.BadRequest("Purchase sudah chickin, qty penerimaan tidak bisa diubah") } } } @@ -936,7 +884,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if receivedQty > item.SubQty { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } - if receivedQty < item.TotalUsed { + if receivedQty < item.TotalUsed && isReceivingBelowUsedBlocked(item, lockedIDs) { return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed)) } @@ -1026,22 +974,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) - deltas := make(map[uint]float64) affected := make(map[uint]struct{}) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) totalQtyDeltas := make(map[uint]float64) - fifoAdds := make([]struct { - itemID uint - pwID uint - qty float64 - }, 0, len(prepared)) - fifoSubs := make([]struct { - itemID uint - pwID uint - qty float64 - }, 0, len(prepared)) - resolvePendingIDs := make(map[uint]struct{}) + reflowAsOfByPW := make(map[uint]time.Time) logEntries := make([]struct { itemID uint pwID uint @@ -1083,35 +1020,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation delta float64 }{itemID: item.Id, pwID: *newPWID, delta: deltaQty}) } - switch { - case deltaQty > 0 && newPWID != nil: - if s.FifoSvc != nil { - fifoAdds = append(fifoAdds, struct { - itemID uint - pwID uint - qty float64 - }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) - resolvePendingIDs[*newPWID] = struct{}{} - } else { - deltas[*newPWID] += deltaQty - totalQtyDeltas[item.Id] += deltaQty - } - case deltaQty < 0 && newPWID != nil: - if s.FifoSvc != nil { - fifoSubs = append(fifoSubs, struct { - itemID uint - pwID uint - qty float64 - }{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) - affected[*newPWID] = struct{}{} - resolvePendingIDs[*newPWID] = struct{}{} - } else { - deltas[*newPWID] += deltaQty // negative - affected[*newPWID] = struct{}{} - totalQtyDeltas[item.Id] += deltaQty - } - case newPWID != nil: - resolvePendingIDs[*newPWID] = struct{}{} + if newPWID != nil { + assignEarliestAsOf(reflowAsOfByPW, *newPWID, prep.receivedDate.UTC()) + } + if deltaQty != 0 { + totalQtyDeltas[item.Id] += deltaQty + } + if deltaQty < 0 && newPWID != nil { + affected[*newPWID] = struct{}{} } dateCopy := prep.receivedDate @@ -1147,10 +1063,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } - if err := pwRepoTx.AdjustQuantities(c.Context(), deltas, nil); err != nil { - return err - } - if len(priceUpdates) > 0 { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { return err @@ -1180,48 +1092,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } } - if s.FifoSvc != nil { - for _, adj := range fifoAdds { - if adj.pwID == 0 || adj.qty <= 0 { - continue - } - if _, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ - StockableKey: fifo.StockableKeyPurchaseItems, - StockableID: adj.itemID, - ProductWarehouseID: adj.pwID, - Quantity: adj.qty, - Tx: tx, - }); err != nil { - return err - } + if len(reflowAsOfByPW) > 0 { + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - for _, adj := range fifoSubs { - if adj.pwID == 0 || adj.qty >= 0 { - continue - } - if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyPurchaseItems, - StockableID: adj.itemID, - ProductWarehouseID: adj.pwID, - Quantity: adj.qty, - Tx: tx, - }); err != nil { + for pwID, asOf := range reflowAsOfByPW { + asOfCopy := asOf + if err := reflowPurchaseScope(c.Context(), s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil { return err } } - for pwID := range resolvePendingIDs { - if pwID == 0 { - continue - } - resolved, err := s.FifoSvc.ResolvePending(c.Context(), commonSvc.PendingResolveRequest{ - ProductWarehouseID: pwID, - Tx: tx, - }) - if err != nil { - return err - } - s.Log.Infof("ResolvePending purchase=%d pw=%d resolved=%d", purchase.Id, pwID, len(resolved)) - } } if len(logEntries) > 0 { @@ -1505,28 +1385,12 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - itemIDs := make([]uint, 0, len(itemsToDelete)) - for _, item := range itemsToDelete { - if item.Id == 0 { - continue - } - itemIDs = append(itemIDs, item.Id) + lockedIDs, err := s.resolveChickinLockedItemIDs(ctx, tx, itemsToDelete) + if err != nil { + return err } - if len(itemIDs) > 0 { - var count int64 - if err := tx.Model(&entity.StockAllocation{}). - Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ?", - fifo.StockableKeyPurchaseItems.String(), - itemIDs, - fifo.UsableKeyProjectChickin.String(), - []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, - ). - Count(&count).Error; err != nil { - return err - } - if count > 0 { - return utils.BadRequest("Purchase already chickin, failed to delete purchase") - } + if len(lockedIDs) > 0 { + return utils.BadRequest("Purchase already chickin, failed to delete purchase") } if err := s.rollbackPurchaseStock(ctx, tx, itemsToDelete, note, actorID); err != nil { @@ -1577,10 +1441,9 @@ func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB return nil } - pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) stockLogRepoTx := rStockLogs.NewStockLogRepository(tx) - deltas := make(map[uint]float64) affected := make(map[uint]struct{}) + reflowAsOfByPW := make(map[uint]time.Time) logEntries := make([]struct { pwID uint qty float64 @@ -1596,42 +1459,43 @@ func (s *purchaseService) rollbackPurchaseStock(ctx context.Context, tx *gorm.DB pwID := *item.ProductWarehouseId qty := item.TotalQty - if s.FifoSvc != nil { - if err := s.FifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ - StockableKey: fifo.StockableKeyPurchaseItems, - StockableID: item.Id, - ProductWarehouseID: pwID, - Quantity: -qty, - Tx: tx, - }); err != nil { - return err - } - logEntries = append(logEntries, struct { - pwID uint - qty float64 - }{pwID: pwID, qty: qty}) - continue + if err := tx.WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("id = ?", item.Id). + Update("total_qty", 0).Error; err != nil { + return err } - deltas[pwID] -= qty affected[pwID] = struct{}{} + if item.ReceivedDate != nil { + assignEarliestAsOf(reflowAsOfByPW, pwID, item.ReceivedDate.UTC()) + } else { + assignEarliestAsOf(reflowAsOfByPW, pwID, time.Now().UTC()) + } logEntries = append(logEntries, struct { pwID uint qty float64 }{pwID: pwID, qty: qty}) } - if s.FifoSvc == nil && len(deltas) > 0 { - if err := pwRepoTx.AdjustQuantities(ctx, deltas, nil); err != nil { - return err + if len(reflowAsOfByPW) > 0 { + if s.FifoStockV2Svc == nil { + return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available") } - if len(affected) > 0 { - if err := pwRepoTx.CleanupEmpty(ctx, affected); err != nil { + for pwID, asOf := range reflowAsOfByPW { + asOfCopy := asOf + if err := reflowPurchaseScope(ctx, s.FifoStockV2Svc, tx, pwID, &asOfCopy); err != nil { return err } } } + if len(affected) > 0 { + if err := rProductWarehouse.NewProductWarehouseRepository(tx).CleanupEmpty(ctx, affected); err != nil { + return err + } + } + if strings.TrimSpace(note) != "" && actorID != 0 && len(logEntries) > 0 { logs := make([]*entity.StockLog, 0, len(logEntries)) for _, entry := range logEntries { @@ -1799,7 +1663,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( if *data.Qty <= 0 { return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) } - if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { + if item.TotalUsed > 0 && *data.Qty < item.TotalUsed && isReceivingBelowUsedBlocked(&item, nil) { return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) } if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { @@ -1918,6 +1782,51 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref return *provided, nil } +func purchaseItemHasFlag(item *entity.PurchaseItem, flag utils.FlagType) bool { + if item == nil || item.Product == nil { + return false + } + target := utils.NormalizeFlag(string(flag)) + for _, f := range item.Product.Flags { + if utils.NormalizeFlag(f.Name) == target { + return true + } + } + return false +} + +func isReceivingBelowUsedBlocked(item *entity.PurchaseItem, lockedIDs map[uint]struct{}) bool { + if item == nil { + return false + } + if !purchaseItemHasAnyFlag(item, []utils.FlagType{ + utils.FlagPullet, + utils.FlagLayer, + utils.FlagAyamAfkir, + utils.FlagAyamCulling, + utils.FlagAyamMati, + }) { + return false + } + if lockedIDs == nil { + return true + } + _, locked := lockedIDs[item.Id] + return locked +} + +func purchaseItemHasAnyFlag(item *entity.PurchaseItem, flags []utils.FlagType) bool { + if item == nil || item.Product == nil || len(flags) == 0 { + return false + } + for _, flag := range flags { + if purchaseItemHasFlag(item, flag) { + return true + } + } + return false +} + func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity.Purchase) error { if item == nil || item.Id == 0 || s.ApprovalSvc == nil { return nil @@ -2025,6 +1934,68 @@ func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase } } +func collectPurchaseItemIDs(items []entity.PurchaseItem) []uint { + itemIDs := make([]uint, 0, len(items)) + for i := range items { + if items[i].Id == 0 { + continue + } + itemIDs = append(itemIDs, items[i].Id) + } + return itemIDs +} + +func (s *purchaseService) resolveChickinLockedItemIDs(ctx context.Context, db *gorm.DB, items []entity.PurchaseItem) (map[uint]struct{}, error) { + itemIDs := collectPurchaseItemIDs(items) + return s.resolveChickinLockedItemIDsByItemID(ctx, db, itemIDs) +} + +func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Context, db *gorm.DB, itemIDs []uint) (map[uint]struct{}, error) { + locked := make(map[uint]struct{}) + if len(itemIDs) == 0 { + return locked, nil + } + if db == nil { + return nil, errors.New("database is required") + } + + var allocationLockedIDs []uint + if err := db.WithContext(ctx). + Model(&entity.StockAllocation{}). + Distinct("stockable_id"). + Where("stockable_type = ? AND stockable_id IN ? AND usable_type = ? AND status IN ? AND allocation_purpose = ?", + fifo.StockableKeyPurchaseItems.String(), + itemIDs, + fifo.UsableKeyProjectChickin.String(), + []string{entity.StockAllocationStatusActive, entity.StockAllocationStatusPending}, + entity.StockAllocationPurposeConsume, + ). + Pluck("stockable_id", &allocationLockedIDs).Error; err != nil { + return nil, err + } + for _, itemID := range allocationLockedIDs { + locked[itemID] = struct{}{} + } + + var conversionLockedIDs []uint + if err := db.WithContext(ctx). + Table("purchase_items pi"). + Distinct("pi.id"). + Joins("JOIN project_chickins pc ON pc.product_warehouse_id = pi.product_warehouse_id AND pc.deleted_at IS NULL"). + Joins("JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id AND pfp.deleted_at IS NULL"). + Where("pi.id IN ?", itemIDs). + Where("pi.project_flock_kandang_id IS NOT NULL"). + Where("pc.project_flock_kandang_id = pi.project_flock_kandang_id"). + Pluck("pi.id", &conversionLockedIDs).Error; err != nil { + return nil, err + } + for _, itemID := range conversionLockedIDs { + locked[itemID] = struct{}{} + } + + return locked, nil +} + func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { seen := make(map[uint]struct{}) ids := make([]uint, 0) diff --git a/internal/modules/repports/repositories/hpp_per_kandang.repository.go b/internal/modules/repports/repositories/hpp_per_kandang.repository.go index e13d3f17..7655fcdb 100644 --- a/internal/modules/repports/repositories/hpp_per_kandang.repository.go +++ b/internal/modules/repports/repositories/hpp_per_kandang.repository.go @@ -147,15 +147,16 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Table("project_chickins AS pc"). Select(` pfk.id AS project_flock_kandang_id, - COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS doc_cost, - COALESCE(SUM(pc.usage_qty), 0) AS doc_qty, + COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS doc_cost, + COALESCE(SUM(sa.qty), 0) AS doc_qty, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias`). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id"). - Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). + Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin). + Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). Where("pc.project_flock_kandang_id IN ?", projectFlockKandangIDs). @@ -221,13 +222,14 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, Table("recordings AS r"). Select(` r.project_flock_kandangs_id AS project_flock_kandang_id, + COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0) AS feed_cost, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias`). Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()). + Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume). Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id"). Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id"). Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id"). diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 2b91f579..c263180b 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -14,9 +14,17 @@ type FlagType string type FlagGroup string +type ProductFlagOption struct { + Flag FlagType `json:"flag"` + SubFlags []FlagType `json:"sub_flags"` + AllowWithoutSubFlag bool `json:"allow_without_sub_flag"` +} + const ( FlagIsActive FlagType = "IS_ACTIVE" + FlagAyam FlagType = "AYAM" + FlagDOC FlagType = "DOC" FlagPullet FlagType = "PULLET" FlagLayer FlagType = "LAYER" @@ -36,11 +44,13 @@ const ( FlagAyamMati FlagType = "AYAM-MATI" //flag telur - FlagTelur FlagType = "TELUR" - FlagTelurUtuh FlagType = "TELUR-UTUH" - FlagTelurPecah FlagType = "TELUR-PECAH" - FlagTelurPutih FlagType = "TELUR-PUTIH" - FlagTelurRetak FlagType = "TELUR-RETAK" + FlagTelur FlagType = "TELUR" + FlagTelurUtuh FlagType = "TELUR-UTUH" + FlagTelurPecah FlagType = "TELUR-PECAH" + FlagTelurPutih FlagType = "TELUR-PUTIH" + FlagTelurRetak FlagType = "TELUR-RETAK" + FlagTelurPapacal FlagType = "TELUR-PAPACAL" + FlagTelurJumbo FlagType = "TELUR-JUMBO" ) const ( @@ -50,9 +60,10 @@ const ( var flagGroupOptions = map[FlagGroup][]FlagType{ FlagGroupProduct: { - FlagDOC, - FlagPullet, - FlagLayer, + FlagAyam, + FlagAyamAfkir, + FlagAyamCulling, + FlagAyamMati, FlagPakan, FlagPreStarter, FlagStarter, @@ -61,12 +72,75 @@ var flagGroupOptions = map[FlagGroup][]FlagType{ FlagObat, FlagVitamin, FlagKimia, + FlagTelur, + FlagTelurUtuh, + FlagTelurPecah, + FlagTelurPutih, + FlagTelurRetak, + FlagTelurPapacal, + FlagTelurJumbo, }, FlagGroupNonstock: { FlagEkspedisi, }, } +var productMainFlags = []FlagType{ + FlagAyam, + FlagPakan, + FlagOVK, + FlagTelur, +} + +var productSubFlagsByFlag = map[FlagType][]FlagType{ + FlagAyam: { + FlagAyamAfkir, + FlagAyamCulling, + FlagAyamMati, + }, + FlagPakan: { + FlagPreStarter, + FlagStarter, + FlagFinisher, + }, + FlagOVK: { + FlagObat, + FlagVitamin, + FlagKimia, + }, + FlagTelur: { + FlagTelurUtuh, + FlagTelurPutih, + FlagTelurRetak, + FlagTelurPecah, + FlagTelurPapacal, + FlagTelurJumbo, + }, +} + +var productSubFlagToFlag = func() map[FlagType]FlagType { + out := make(map[FlagType]FlagType) + for flag, subFlags := range productSubFlagsByFlag { + for _, subFlag := range subFlags { + out[subFlag] = flag + } + } + return out +}() + +var productAllowWithoutSubFlagByFlag = map[FlagType]bool{ + FlagAyam: true, + FlagPakan: false, + FlagOVK: false, + FlagTelur: false, +} + +var legacyFlagTypeAliases = map[FlagType]FlagType{ + FlagDOC: FlagAyam, + FlagPullet: FlagAyam, + FlagLayer: FlagAyam, +} + var allFlagTypes = func() map[FlagType]struct{} { m := map[FlagType]struct{}{ FlagIsActive: {}, @@ -83,6 +157,102 @@ func AllFlagTypes() map[FlagType]struct{} { return allFlagTypes } +func canonicalizeFlagType(flag FlagType) FlagType { + if canonical, ok := legacyFlagTypeAliases[flag]; ok { + return canonical + } + return flag +} + +func CanonicalFlagType(v string) FlagType { + normalized := FlagType(strings.ToUpper(strings.TrimSpace(v))) + if normalized == "" { + return "" + } + return canonicalizeFlagType(normalized) +} + +func LegacyFlagTypeAliases() map[FlagType]FlagType { + out := make(map[FlagType]FlagType, len(legacyFlagTypeAliases)) + for legacy, canonical := range legacyFlagTypeAliases { + out[legacy] = canonical + } + return out +} + +func ProductMainFlags() []FlagType { + out := make([]FlagType, len(productMainFlags)) + copy(out, productMainFlags) + return out +} + +func ProductSubFlagsByFlag() map[FlagType][]FlagType { + out := make(map[FlagType][]FlagType, len(productSubFlagsByFlag)) + for flag, subFlags := range productSubFlagsByFlag { + dup := make([]FlagType, len(subFlags)) + copy(dup, subFlags) + out[flag] = dup + } + return out +} + +func ProductSubFlagToFlag() map[FlagType]FlagType { + out := make(map[FlagType]FlagType, len(productSubFlagToFlag)) + for subFlag, flag := range productSubFlagToFlag { + out[subFlag] = flag + } + return out +} + +func ProductFlagOptions() []ProductFlagOption { + result := make([]ProductFlagOption, 0, len(productMainFlags)) + for _, flag := range productMainFlags { + subFlags := productSubFlagsByFlag[flag] + dup := make([]FlagType, len(subFlags)) + copy(dup, subFlags) + result = append(result, ProductFlagOption{ + Flag: flag, + SubFlags: dup, + AllowWithoutSubFlag: productAllowWithoutSubFlagByFlag[flag], + }) + } + return result +} + +func ProductFlagAllowWithoutSubFlag(flag FlagType) bool { + canonical := canonicalizeFlagType(flag) + allow, ok := productAllowWithoutSubFlagByFlag[canonical] + if !ok { + return false + } + return allow +} + +func IsProductMainFlag(flag FlagType) bool { + canonical := canonicalizeFlagType(flag) + for _, f := range productMainFlags { + if f == canonical { + return true + } + } + return false +} + +func IsValidProductSubFlag(flag FlagType, subFlag FlagType) bool { + canonicalFlag := canonicalizeFlagType(flag) + canonicalSubFlag := canonicalizeFlagType(subFlag) + allowedSubFlags, ok := productSubFlagsByFlag[canonicalFlag] + if !ok { + return false + } + for _, allowed := range allowedSubFlags { + if allowed == canonicalSubFlag { + return true + } + } + return false +} + // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -198,6 +368,136 @@ const ( TransactionTypeSaldoAwal TransactionType = "SALDO_AWAL" ) +type AdjustmentTransactionType string + +const ( + AdjustmentTransactionTypePembelian AdjustmentTransactionType = "PEMBELIAN" + AdjustmentTransactionTypeRecording AdjustmentTransactionType = "RECORDING" + AdjustmentTransactionTypePenjualan AdjustmentTransactionType = "PENJUALAN" +) + +type AdjustmentTransactionSubtype string + +const ( + AdjustmentTransactionSubtypePurchaseIn AdjustmentTransactionSubtype = "PURCHASE_IN" + AdjustmentTransactionSubtypeRecordingDepletionOut AdjustmentTransactionSubtype = "RECORDING_DEPLETION_OUT" + AdjustmentTransactionSubtypeMarketingOut AdjustmentTransactionSubtype = "MARKETING_OUT" + AdjustmentTransactionSubtypeRecordingDepletionIn AdjustmentTransactionSubtype = "RECORDING_DEPLETION_IN" + AdjustmentTransactionSubtypeRecordingStockOut AdjustmentTransactionSubtype = "RECORDING_STOCK_OUT" + AdjustmentTransactionSubtypeRecordingEggIn AdjustmentTransactionSubtype = "RECORDING_EGG_IN" +) + +var adjustmentSubtypesByType = map[AdjustmentTransactionType][]string{ + AdjustmentTransactionTypePembelian: { + string(AdjustmentTransactionSubtypePurchaseIn), + }, + AdjustmentTransactionTypeRecording: { + string(AdjustmentTransactionSubtypeRecordingStockOut), + string(AdjustmentTransactionSubtypeRecordingDepletionOut), + string(AdjustmentTransactionSubtypeRecordingDepletionIn), + string(AdjustmentTransactionSubtypeRecordingEggIn), + }, + AdjustmentTransactionTypePenjualan: { + string(AdjustmentTransactionSubtypeMarketingOut), + }, +} + +var hiddenAdjustmentSubtypesForFrontend = map[string]struct{}{ + string(AdjustmentTransactionSubtypeRecordingDepletionOut): {}, +} + +var adjustmentSubtypeToType = func() map[string]AdjustmentTransactionType { + out := make(map[string]AdjustmentTransactionType) + for txType, subtypes := range adjustmentSubtypesByType { + for _, subtype := range subtypes { + code := strings.ToUpper(strings.TrimSpace(subtype)) + if code == "" { + continue + } + out[code] = txType + } + } + return out +}() + +func AdjustmentTransactionTypes() []string { + return []string{ + string(AdjustmentTransactionTypePembelian), + string(AdjustmentTransactionTypeRecording), + string(AdjustmentTransactionTypePenjualan), + } +} + +func AdjustmentTransactionSubtypesByType() map[string][]string { + out := make(map[string][]string, len(adjustmentSubtypesByType)) + for _, txType := range []AdjustmentTransactionType{ + AdjustmentTransactionTypePembelian, + AdjustmentTransactionTypeRecording, + AdjustmentTransactionTypePenjualan, + } { + src := adjustmentSubtypesByType[txType] + dup := make([]string, len(src)) + copy(dup, src) + out[string(txType)] = dup + } + return out +} + +func AdjustmentTransactionSubtypes() []string { + result := make([]string, 0) + for _, txType := range []AdjustmentTransactionType{ + AdjustmentTransactionTypePembelian, + AdjustmentTransactionTypeRecording, + AdjustmentTransactionTypePenjualan, + } { + result = append(result, adjustmentSubtypesByType[txType]...) + } + return result +} + +func AdjustmentTransactionSubtypesByTypeForFrontend() map[string][]string { + out := make(map[string][]string, len(adjustmentSubtypesByType)) + for txType, subtypes := range AdjustmentTransactionSubtypesByType() { + filtered := make([]string, 0, len(subtypes)) + for _, subtype := range subtypes { + if _, hidden := hiddenAdjustmentSubtypesForFrontend[subtype]; hidden { + continue + } + filtered = append(filtered, subtype) + } + out[txType] = filtered + } + return out +} + +func AdjustmentTransactionSubtypesForFrontend() []string { + result := make([]string, 0) + for _, subtype := range AdjustmentTransactionSubtypes() { + if _, hidden := hiddenAdjustmentSubtypesForFrontend[subtype]; hidden { + continue + } + result = append(result, subtype) + } + return result +} + +func ResolveAdjustmentTransactionType(functionCode string) string { + code := strings.ToUpper(strings.TrimSpace(functionCode)) + if txType, ok := adjustmentSubtypeToType[code]; ok { + return string(txType) + } + switch { + case strings.HasPrefix(code, "PURCHASE_"): + return string(AdjustmentTransactionTypePembelian) + case strings.HasPrefix(code, "MARKETING_"): + return string(AdjustmentTransactionTypePenjualan) + case strings.HasPrefix(code, "RECORDING_"): + return string(AdjustmentTransactionTypeRecording) + default: + return "" + } +} + // ------------------------------------------------------------------- // Payment Party // ------------------------------------------------------------------- @@ -491,7 +791,11 @@ const ( // ------------------------------------------------------------------- func IsValidFlagType(v string) bool { - _, ok := allFlagTypes[FlagType(strings.ToUpper(strings.TrimSpace(v)))] + flag := FlagType(strings.ToUpper(strings.TrimSpace(v))) + if _, ok := allFlagTypes[flag]; ok { + return true + } + _, ok := legacyFlagTypeAliases[flag] return ok } @@ -537,6 +841,7 @@ func NormalizeFlagTypes(flags []string) []FlagType { if normalized == "" { continue } + normalized = canonicalizeFlagType(normalized) if _, exists := seen[normalized]; exists { continue }