diff --git a/cmd/delete-adjustments/main.go b/cmd/delete-adjustments/main.go new file mode 100644 index 00000000..072db41c --- /dev/null +++ b/cmd/delete-adjustments/main.go @@ -0,0 +1,442 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + "time" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" + "gorm.io/gorm" +) + +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) + + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + stockAllocRepo := commonRepo.NewStockAllocationRepository(db) + fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, nil) + fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil) + if err := registerAdjustmentFIFO(fifoSvc); err != nil { + log.Fatalf("failed to register adjustment fifo config: %v", err) + } + + adjustments, err := loadAdjustments(ctx, db, ids) + if err != nil { + 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, + Usable: commonSvc.FifoStockV2Ref{ + ID: adj.ID, + LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(), + FunctionCode: route.FunctionCode, + }, + DesiredQty: 0, + IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()), + Tx: tx, + } + 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=reverse_stock+delete\n", + adj.ID, + route.FunctionCode, + adj.TotalQty, + removeQty, + ) + + if !apply { + skipped++ + continue + } + + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if removeQty > 0 { + if err := fifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{ + StockableKey: fifo.StockableKeyAdjustmentIn, + StockableID: adj.ID, + ProductWarehouseID: adj.ProductWarehouseID, + Quantity: -removeQty, + Tx: tx, + }); err != nil { + return fmt.Errorf("reverse stockable quantity: %w", err) + } + } + + if err := 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 registerAdjustmentFIFO(fifoSvc commonSvc.FifoService) error { + if err := fifoSvc.RegisterStockable(fifo.StockableConfig{ + Key: fifo.StockableKeyAdjustmentIn, + Table: "adjustment_stocks", + Columns: fifo.StockableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + TotalQuantity: "total_qty", + TotalUsedQuantity: "total_used", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + return err + } + + if err := fifoSvc.RegisterUsable(fifo.UsableConfig{ + Key: fifo.UsableKeyAdjustmentOut, + Table: "adjustment_stocks", + Columns: fifo.UsableColumns{ + ID: "id", + ProductWarehouseID: "product_warehouse_id", + UsageQuantity: "usage_qty", + PendingQuantity: "pending_qty", + CreatedAt: "created_at", + }, + OrderBy: []string{"created_at ASC", "id ASC"}, + }); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") { + return err + } + return nil +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + 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). + 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). + Count(&count).Error + return count, err +} + +func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error { + return tx.WithContext(ctx). + Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ?", usableType, usableID). + Error +} + +func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error { + return tx.WithContext(ctx). + Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ?", stockableType, stockableID). + 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..0246e542 --- /dev/null +++ b/cmd/reflow-adjustments/main.go @@ -0,0 +1,343 @@ +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 + } + + usableType := fifo.UsableKeyAdjustmentOut.String() + if route.SourceTable == "adjustment_stocks" && strings.TrimSpace(route.LegacyTypeKey) != "" { + usableType = strings.TrimSpace(route.LegacyTypeKey) + } + + activeAllocationCount, err := countActiveAllocations(ctx, db, usableType, adj.ID) + 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, + Usable: commonSvc.FifoStockV2Ref{ + ID: adj.ID, + LegacyTypeKey: usableType, + FunctionCode: route.FunctionCode, + }, + DesiredQty: desiredQty, + IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()), + } + 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). + Count(&count).Error + if err != nil { + return 0, err + } + return count, nil +} 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/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/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go index b9c9cc48..3c4beb06 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,7 +25,7 @@ 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)) @@ -75,6 +75,8 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { }) } + adjustmentSubtypesByType := utils.AdjustmentTransactionSubtypesByTypeForFrontend() + return map[string]interface{}{ "flags": flagList, "warehouse_types": []string{ @@ -94,6 +96,9 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { "BISNIS", "INDIVIDUAL", }, + "adjustment": map[string]interface{}{ + "transaction_subtypes": adjustmentSubtypesByType, + }, "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..b1522c0f 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -34,6 +34,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat stockAllocRepo := commonRepo.NewStockAllocationRepository(db) fifoService := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, utils.Log) + fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log) err := fifoService.RegisterStockable(fifo.StockableConfig{ Key: fifo.StockableKeyAdjustmentIn, @@ -74,6 +75,7 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat 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..2020dbec 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" @@ -40,8 +41,14 @@ type adjustmentService struct { ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository AdjustmentStockRepository adjustmentStockRepo.AdjustmentStockRepository FifoSvc common.FifoService + FifoStockV2Svc common.FifoStockV2Service } +const ( + adjustmentLaneStockable = "STOCKABLE" + adjustmentLaneUsable = "USABLE" +) + func NewAdjustmentService( productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, @@ -49,6 +56,7 @@ func NewAdjustmentService( productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, adjustmentStockRepo adjustmentStockRepo.AdjustmentStockRepository, fifoSvc common.FifoService, + fifoStockV2Svc common.FifoStockV2Service, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, ) AdjustmentService { @@ -62,6 +70,7 @@ func NewAdjustmentService( ProjectFlockKandangRepo: projectFlockKandangRepo, AdjustmentStockRepository: adjustmentStockRepo, FifoSvc: fifoSvc, + FifoStockV2Svc: fifoStockV2Svc, } } @@ -70,6 +79,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 +106,93 @@ 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.AdjustmentTransactionSubtypeRecordingDepletionIn) { + functionCode = string(utils.AdjustmentTransactionSubtypeRecordingDepletionOut) + } + + 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) + + allowPending := false + if routeMeta.Lane == adjustmentLaneUsable { + allowPending, err = s.resolveOverconsumePolicy(ctx, routeMeta) + if err != nil { + s.Log.Errorf("Failed to resolve overconsume rule: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve FIFO policy") + } } var createdAdjustmentStockId uint var projectFlockKandangID *uint - 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 +211,115 @@ 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)) - } - newLog.Decrease = req.Quantity - newLog.Stock -= newLog.Decrease - } - - if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { - - return err - } - adjustmentStock := &entity.AdjustmentStock{ ProductWarehouseId: productWarehouse.Id, + TransactionType: transactionType, + FunctionCode: routeMeta.FunctionCode, + Price: req.Price, + GrandTotal: grandTotal, } - code, err := s.AdjustmentStockRepository.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix) + 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 transactionType == string(utils.StockLogTransactionTypeIncrease) { - - note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) - _, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ + switch routeMeta.Lane { + case adjustmentLaneStockable: + fifoNote := fmt.Sprintf("Stock Adjustment %s #%s", routeMeta.FunctionCode, adjustmentStock.AdjNumber) + result, err := s.FifoSvc.Replenish(ctx, common.StockReplenishRequest{ StockableKey: fifo.StockableKeyAdjustmentIn, StockableID: adjustmentStock.Id, - ProductWarehouseID: uint(productWarehouse.Id), - Quantity: req.Quantity, - Note: ¬e, + ProductWarehouseID: productWarehouse.Id, + Quantity: qty, + Note: &fifoNote, Tx: tx, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to replenish stock via FIFO: %v", err)) } + increaseQty = result.AddedQuantity + case adjustmentLaneUsable: + if s.FifoStockV2Svc != nil { + usableLegacyTypeKey := fifo.UsableKeyAdjustmentOut.String() + if routeMeta.SourceTable == "adjustment_stocks" && strings.TrimSpace(routeMeta.LegacyTypeKey) != "" { + usableLegacyTypeKey = strings.TrimSpace(routeMeta.LegacyTypeKey) + } - } 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)) + reflowResult, err := s.FifoStockV2Svc.Reflow(ctx, common.FifoStockV2ReflowRequest{ + FlagGroupCode: routeMeta.FlagGroupCode, + ProductWarehouseID: productWarehouse.Id, + Usable: common.FifoStockV2Ref{ + ID: adjustmentStock.Id, + LegacyTypeKey: usableLegacyTypeKey, + FunctionCode: routeMeta.FunctionCode, + }, + DesiredQty: qty, + AllowOverConsume: &allowPending, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO v2: %v", err)) + } + decreaseQty = reflowResult.Allocate.AllocatedQty + } else { + result, err := s.FifoSvc.Consume(ctx, common.StockConsumeRequest{ + UsableKey: fifo.UsableKeyAdjustmentOut, + UsableID: adjustmentStock.Id, + ProductWarehouseID: productWarehouse.Id, + Quantity: qty, + AllowPending: allowPending, + Tx: tx, + }) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Failed to consume stock via FIFO: %v", err)) + } + decreaseQty = result.UsageQuantity } + default: + return fiber.NewError(fiber.StatusBadRequest, "Unsupported transaction subtype lane") + } + + stockLogs, err := stockLogRepoTX.GetByProductWarehouse(ctx, productWarehouse.Id, 1) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs") + } + + currentStock := 0.0 + if len(stockLogs) > 0 { + currentStock = stockLogs[0].Stock + } + + newLog := &entity.StockLog{ + LoggableType: string(utils.StockLogTypeAdjustment), + LoggableId: adjustmentStock.Id, + Notes: note, + ProductWarehouseId: productWarehouse.Id, + CreatedBy: actorID, + Increase: increaseQty, + Decrease: decreaseQty, + Stock: currentStock + increaseQty - decreaseQty, + } + + if err := stockLogRepoTX.CreateOne(ctx, newLog, nil); err != nil { + return err } createdAdjustmentStockId = adjustmentStock.Id @@ -261,6 +338,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 { @@ -291,6 +453,12 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu 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 +481,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 +489,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/utils/constant.go b/internal/utils/constant.go index 2b91f579..1829b941 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -198,6 +198,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(AdjustmentTransactionSubtypeRecordingDepletionIn): {}, +} + +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 // -------------------------------------------------------------------