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 }