package main import ( "context" "encoding/json" "errors" "flag" "fmt" "log" "os" "sort" "strings" "text/tabwriter" "time" "github.com/go-playground/validator/v10" "github.com/sirupsen/logrus" "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" pwRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" transferRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" pfkRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gorm.io/gorm" ) const ( transferReasonPrefix = "PRODUCT_FARM_TRANSFER" outputModeTable = "table" outputModeJSON = "json" sourceTypeKandang = "kandang_to_farm" sourceTypeFarmConsol = "farm_consolidation" ) // commandOptions holds all parsed CLI flags. type commandOptions struct { Apply bool RollbackRunID string LocationID uint LocationName string TransferDate time.Time TransferDateRaw string AllLocations bool FarmWarehouseOverrideID uint SkipAmbiguous bool // FlagFilter is an optional set of product flag names (upper-cased). // When non-empty only products that carry at least one of these flags are // included. Populated from --flags="PAKAN,OVK". FlagFilter []string Output string ActorID uint RunID string } // farmWarehouseEntry is a single LOKASI-type warehouse belonging to a location. type farmWarehouseEntry struct { ID uint Name string } // farmWarehouseInfo holds all LOKASI warehouses for a location plus the // resolved target warehouse (ChosenID). When a location has exactly one // LOKASI warehouse, ChosenID is set automatically. When multiple exist, // ChosenID is only set after applying --farm-warehouse-id; until then it // stays 0 and the location is flagged as an error. type farmWarehouseInfo struct { LocationID uint LocationName string // AllFarm holds every LOKASI warehouse found for this location, sorted by id. AllFarm []farmWarehouseEntry // ChosenID is the resolved transfer destination (0 = unresolved ambiguity). ChosenID uint ChosenName string // OtherFarm holds non-chosen LOKASI warehouses that must be consolidated // into ChosenID. Populated only when --farm-warehouse-id resolves a // multi-warehouse location. OtherFarm []farmWarehouseEntry } func (f farmWarehouseInfo) farmCount() int { return len(f.AllFarm) } func (f farmWarehouseInfo) isResolved() bool { return f.ChosenID > 0 } func (f farmWarehouseInfo) hasFarm() bool { return len(f.AllFarm) > 0 } // kandangStockRow is a single product-warehouse row loaded from the DB. // SourceType distinguishes ordinary kandang stocks from extra farm-warehouse // stocks that need inter-farm consolidation. type kandangStockRow struct { LocationID uint LocationName string SourceWarehouseID uint SourceWarehouseName string ProductWarehouseID uint ProductID uint ProductName string OnHandQty float64 AllocatedQty float64 // sum of ACTIVE CONSUME stock_allocations LeftoverQty float64 // OnHandQty - AllocatedQty SourceType string // sourceTypeKandang or sourceTypeFarmConsol } // transferReportRow is one row in the plan/apply report. type transferReportRow struct { RunID string `json:"run_id"` SourceType string `json:"source_type"` LocationID uint `json:"location_id"` LocationName string `json:"location_name"` SourceWarehouseID uint `json:"source_warehouse_id"` SourceWarehouseName string `json:"source_warehouse_name"` FarmWarehouseID *uint `json:"farm_warehouse_id,omitempty"` FarmWarehouseName *string `json:"farm_warehouse_name,omitempty"` ProductWarehouseID uint `json:"product_warehouse_id"` ProductID uint `json:"product_id"` ProductName string `json:"product_name"` OnHandQty float64 `json:"on_hand_qty"` AllocatedQty float64 `json:"allocated_qty"` Qty float64 `json:"qty"` // leftover qty to transfer Status string `json:"status"` Reason string `json:"reason,omitempty"` TransferID *uint64 `json:"transfer_id,omitempty"` MovementNumber *string `json:"movement_number,omitempty"` } // transferGroup is one stock transfer document: source → farm, N products. type transferGroup struct { SourceType string LocationID uint LocationName string SourceWarehouseID uint SourceWarehouseName string FarmWarehouseID uint FarmWarehouseName string Rows []*transferReportRow } // applySummary is printed at the end of a plan or apply run. type applySummary struct { RowsPlanned int `json:"rows_planned"` RowsApplied int `json:"rows_applied"` RowsSkipped int `json:"rows_skipped"` RowsError int `json:"rows_error"` RowsFailed int `json:"rows_failed"` GroupsPlanned int `json:"groups_planned"` GroupsApplied int `json:"groups_applied"` } // rollbackDetailRow is one product line created by a previous run. type rollbackDetailRow struct { RunID string `json:"run_id"` TransferID uint64 `json:"transfer_id"` MovementNumber string `json:"movement_number"` LocationName string `json:"location_name"` SourceWarehouseName string `json:"source_warehouse_name"` FarmWarehouseName string `json:"farm_warehouse_name"` ProductName string `json:"product_name"` Qty float64 `json:"qty"` Status string `json:"status"` Reason string `json:"reason,omitempty"` } // systemTransferExecutor abstracts the transfer service for testability. type systemTransferExecutor interface { CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error } // ── Entry point ─────────────────────────────────────────────────────────────── func main() { opts, err := parseFlags() if err != nil { log.Fatalf("invalid flags: %v", err) } db := database.Connect(config.DBHost, config.DBName) ctx := context.Background() // ── Rollback path ──────────────────────────────────────────────────────── if strings.TrimSpace(opts.RollbackRunID) != "" { rows, err := loadRollbackDetails(ctx, db, opts.RollbackRunID) if err != nil { log.Fatalf("failed to load rollback details: %v", err) } if len(rows) == 0 { fmt.Fprintf(os.Stderr, "no transfers found for run_id=%s\n", opts.RollbackRunID) os.Exit(1) } if !opts.Apply { for i := range rows { rows[i].Status = "eligible" } renderRollbackReport(opts.Output, rows) return } if err := executeRollback(ctx, newSystemTransferService(db), rows, opts.ActorID); err != nil { log.Fatalf("rollback failed: %v", err) } renderRollbackReport(opts.Output, rows) return } // ── Plan / Apply path ──────────────────────────────────────────────────── // Step 1: resolve which farm warehouse each location should use. farmMap, err := loadFarmWarehouseMap(ctx, db, opts) if err != nil { log.Fatalf("failed to load farm warehouse map: %v", err) } if err := applyFarmWarehouseOverride(farmMap, opts.FarmWarehouseOverrideID); err != nil { log.Fatalf("invalid --farm-warehouse-id: %v", err) } // In apply mode, warn about or hard-stop on unresolved locations. if opts.Apply { if msgs := listUnresolvedLocations(farmMap); len(msgs) > 0 { for _, m := range msgs { if opts.SkipAmbiguous { fmt.Fprintln(os.Stderr, "WARN (skipping):", m) } else { fmt.Fprintln(os.Stderr, "ERROR:", m) } } if !opts.SkipAmbiguous { log.Fatalf("aborting: use --farm-warehouse-id to choose the target warehouse for each location listed above, or pass --skip-ambiguous to skip them and process the rest") } } } // Step 2: load leftover stocks from kandang warehouses. kandangStocks, err := loadKandangLeftoverStocks(ctx, db, opts) if err != nil { log.Fatalf("failed to load kandang leftover stocks: %v", err) } // Step 3: load leftover stocks from extra farm warehouses that need // consolidation into the chosen farm warehouse. extraFarmStocks, err := loadExtraFarmLeftoverStocks(ctx, db, farmMap, opts) if err != nil { log.Fatalf("failed to load extra farm leftover stocks: %v", err) } // Step 4: merge and plan. allStocks := append(kandangStocks, extraFarmStocks...) reportRows, groups := buildTransferPlan(opts, farmMap, allStocks) if !opts.Apply { renderTransferReport(opts.Output, reportRows, summarizeReport(reportRows, groups, 0)) return } summary, err := executeApply(ctx, newSystemTransferService(db), opts, groups) if err != nil { log.Fatalf("apply failed: %v", err) } finalRows := flattenGroups(groups, reportRows) summary = summarizeReport(finalRows, groups, summary.GroupsApplied) renderTransferReport(opts.Output, finalRows, summary) } // ── Flag parsing ────────────────────────────────────────────────────────────── func parseFlags() (*commandOptions, error) { var opts commandOptions flag.BoolVar(&opts.Apply, "apply", false, "Apply transfers. If false, run as dry-run and print the plan") flag.StringVar(&opts.RollbackRunID, "rollback-run-id", "", "Rollback all transfers created by the provided run_id") flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id") flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name") flag.StringVar(&opts.TransferDateRaw, "transfer-date", "", "Transfer date in YYYY-MM-DD format (default: today in Asia/Jakarta)") flag.BoolVar(&opts.AllLocations, "all-locations", false, "Allow apply without a location filter (transfers all locations at once)") flag.BoolVar(&opts.SkipAmbiguous, "skip-ambiguous", false, "When a location has multiple LOKASI warehouses and no --farm-warehouse-id is set, "+ "skip that location (status=skipped) instead of treating it as an error. "+ "Useful for an initial global run: unambiguous locations transfer immediately while "+ "ambiguous ones are left for a follow-up run with --farm-warehouse-id.") flag.UintVar(&opts.FarmWarehouseOverrideID, "farm-warehouse-id", 0, "When a location has multiple LOKASI warehouses, use this warehouse id as the chosen target. "+ "Stocks from the other LOKASI warehouses are also transferred to the chosen one. "+ "Requires --location-id or --location-name.") var flagsRaw string flag.StringVar(&flagsRaw, "flags", "", "Comma-separated list of product flag names to include (e.g. PAKAN,OVK). "+ "Only products that carry at least one of these flags are transferred. "+ "Leave empty to transfer all products regardless of flags.") flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json") flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers") flag.Parse() for _, f := range strings.Split(flagsRaw, ",") { if name := strings.ToUpper(strings.TrimSpace(f)); name != "" { opts.FlagFilter = append(opts.FlagFilter, name) } } opts.LocationName = strings.TrimSpace(opts.LocationName) opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID) opts.Output = strings.ToLower(strings.TrimSpace(opts.Output)) if opts.Output == "" { opts.Output = outputModeTable } if opts.Output != outputModeTable && opts.Output != outputModeJSON { return nil, fmt.Errorf("unsupported --output=%s; must be 'table' or 'json'", opts.Output) } if opts.LocationID > 0 && opts.LocationName != "" { return nil, errors.New("use either --location-id or --location-name, not both") } if opts.FarmWarehouseOverrideID > 0 { if opts.AllLocations { return nil, errors.New("--farm-warehouse-id cannot be combined with --all-locations; specify --location-id or --location-name so the override targets the right location") } if opts.LocationID == 0 && opts.LocationName == "" { return nil, errors.New("--farm-warehouse-id requires --location-id or --location-name") } } if opts.RollbackRunID != "" { if opts.LocationID > 0 || opts.LocationName != "" { return nil, errors.New("location filters are not supported with --rollback-run-id") } if opts.TransferDateRaw != "" { return nil, errors.New("--transfer-date is not used with --rollback-run-id") } if opts.FarmWarehouseOverrideID > 0 { return nil, errors.New("--farm-warehouse-id is not used with --rollback-run-id") } } else if opts.Apply { if !opts.AllLocations && opts.LocationID == 0 && opts.LocationName == "" { return nil, errors.New( "apply mode requires --location-id, --location-name, or --all-locations for safety; " + "use --all-locations only after reviewing the dry-run output for all locations", ) } } jakarta := time.FixedZone("Asia/Jakarta", 7*3600) if strings.TrimSpace(opts.TransferDateRaw) == "" { opts.TransferDate = normalizeDateOnly(time.Now().In(jakarta)) } else { t, err := time.Parse("2006-01-02", opts.TransferDateRaw) if err != nil { return nil, fmt.Errorf("invalid --transfer-date: %w", err) } opts.TransferDate = normalizeDateOnly(t) } opts.RunID = buildRunID() return &opts, nil } // ── Service wiring ──────────────────────────────────────────────────────────── func newSystemTransferService(db *gorm.DB) systemTransferExecutor { validate := validator.New() stockTransferRepo := transferRepo.NewStockTransferRepository(db) stockTransferDetailRepo := transferRepo.NewStockTransferDetailRepository(db) stockTransferDeliveryRepo := transferRepo.NewStockTransferDeliveryRepository(db) stockTransferDeliveryItemRepo := transferRepo.NewStockTransferDeliveryItemRepository(db) stockLogsRepo := stockLogRepo.NewStockLogRepository(db) productWarehouseRepo := pwRepo.NewProductWarehouseRepository(db) warehouseRepository := warehouseRepo.NewWarehouseRepository(db) projectFlockKandangRepo := pfkRepo.NewProjectFlockKandangRepository(db) projectFlockPopulationRepo := pfkRepo.NewProjectFlockPopulationRepository(db) fifoSvc := service.NewFifoStockV2Service(db, logrus.StandardLogger()) return transferSvc.NewTransferService( validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, stockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, nil, warehouseRepository, projectFlockKandangRepo, projectFlockPopulationRepo, nil, fifoSvc, nil, ) } // ── DB loading ──────────────────────────────────────────────────────────────── // loadFarmWarehouseMap returns one farmWarehouseInfo per location_id that has // at least one KANDANG warehouse. It fetches every LOKASI warehouse for each // location as distinct rows and builds the list in Go, so there is no // aggregation that could miscount farm warehouses. func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]farmWarehouseInfo, error) { type row struct { LocationID uint `gorm:"column:location_id"` LocationName string `gorm:"column:location_name"` FarmWHID *uint `gorm:"column:farm_wh_id"` FarmWHName *string `gorm:"column:farm_wh_name"` } // DISTINCT on (location_id, farm_wh_id) so multiple KANDANG warehouses in // the same location don't produce duplicate farm-warehouse rows. query := db.WithContext(ctx). Table("warehouses kw"). Select(` DISTINCT kw.location_id AS location_id, l.name AS location_name, fw.id AS farm_wh_id, fw.name AS farm_wh_name `). Joins("JOIN locations l ON l.id = kw.location_id"). Joins(`LEFT JOIN warehouses fw ON fw.location_id = kw.location_id AND UPPER(fw.type) = 'LOKASI' AND fw.deleted_at IS NULL`). Where("UPPER(kw.type) = 'KANDANG'"). Where("kw.deleted_at IS NULL"). Order("kw.location_id ASC, fw.id ASC") query = applyLocationFilter(query, opts, "kw") var rows []row if err := query.Scan(&rows).Error; err != nil { return nil, err } result := make(map[uint]farmWarehouseInfo) for _, r := range rows { info := result[r.LocationID] info.LocationID = r.LocationID info.LocationName = r.LocationName if r.FarmWHID != nil && *r.FarmWHID > 0 { // Guard against duplicates that DISTINCT might not eliminate across // different location_id groupings due to Go map updates. alreadySeen := false for _, e := range info.AllFarm { if e.ID == *r.FarmWHID { alreadySeen = true break } } if !alreadySeen { info.AllFarm = append(info.AllFarm, farmWarehouseEntry{ ID: *r.FarmWHID, Name: derefString(r.FarmWHName), }) } } result[r.LocationID] = info } // Automatically resolve locations that have exactly one farm warehouse. for locID, info := range result { if len(info.AllFarm) == 1 { info.ChosenID = info.AllFarm[0].ID info.ChosenName = info.AllFarm[0].Name result[locID] = info } } return result, nil } // applyFarmWarehouseOverride sets ChosenID/OtherFarm on every location in the // map that still has multiple unresolved farm warehouses. overrideID must // appear in the location's AllFarm list; if it does not, an error is returned // so the operator knows the ID is wrong before any transfer is attempted. // Locations with 0 or 1 farm warehouses are left untouched. func applyFarmWarehouseOverride(farmMap map[uint]farmWarehouseInfo, overrideID uint) error { if overrideID == 0 { return nil } for locID, info := range farmMap { if len(info.AllFarm) <= 1 { continue // no ambiguity; override is irrelevant for this location } found := false others := make([]farmWarehouseEntry, 0, len(info.AllFarm)-1) for _, fw := range info.AllFarm { if fw.ID == overrideID { info.ChosenID = fw.ID info.ChosenName = fw.Name found = true } else { others = append(others, fw) } } if !found { available := make([]string, 0, len(info.AllFarm)) for _, fw := range info.AllFarm { available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID)) } return fmt.Errorf( "warehouse id %d is not a LOKASI warehouse for location %q (id=%d)\n available farm warehouses: %s", overrideID, info.LocationName, info.LocationID, strings.Join(available, ", "), ) } info.OtherFarm = others farmMap[locID] = info } return nil } // listUnresolvedLocations returns one human-readable error message per location // that still has multiple farm warehouses with no override chosen. func listUnresolvedLocations(farmMap map[uint]farmWarehouseInfo) []string { var msgs []string for _, info := range farmMap { if len(info.AllFarm) > 1 && !info.isResolved() { available := make([]string, 0, len(info.AllFarm)) for _, fw := range info.AllFarm { available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID)) } msgs = append(msgs, fmt.Sprintf( "location %q (id=%d) has %d LOKASI warehouses — rerun with --farm-warehouse-id= to choose one: %s", info.LocationName, info.LocationID, len(info.AllFarm), strings.Join(available, ", "), )) } } sort.Strings(msgs) return msgs } // loadKandangLeftoverStocks returns all product_warehouse rows for KANDANG-type // warehouses where on_hand_qty > 0, together with their active CONSUME // allocations so the caller can derive leftover qty. func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]kandangStockRow, error) { type row struct { LocationID uint `gorm:"column:location_id"` LocationName string `gorm:"column:location_name"` SourceWarehouseID uint `gorm:"column:source_warehouse_id"` SourceWarehouseName string `gorm:"column:source_warehouse_name"` ProductWarehouseID uint `gorm:"column:product_warehouse_id"` ProductID uint `gorm:"column:product_id"` ProductName string `gorm:"column:product_name"` OnHandQty float64 `gorm:"column:on_hand_qty"` AllocatedQty float64 `gorm:"column:allocated_qty"` } query := db.WithContext(ctx). Table("product_warehouses pw"). Select(` kw.location_id AS location_id, l.name AS location_name, kw.id AS source_warehouse_id, kw.name AS source_warehouse_name, pw.id AS product_warehouse_id, pw.product_id AS product_id, p.name AS product_name, COALESCE(pw.qty, 0) AS on_hand_qty, COALESCE(( SELECT SUM(sa.qty) FROM stock_allocations sa WHERE sa.product_warehouse_id = pw.id AND sa.status = 'ACTIVE' AND sa.allocation_purpose = 'CONSUME' AND sa.deleted_at IS NULL ), 0) AS allocated_qty `). Joins("JOIN warehouses kw ON kw.id = pw.warehouse_id AND kw.deleted_at IS NULL"). Joins("JOIN locations l ON l.id = kw.location_id"). Joins("JOIN products p ON p.id = pw.product_id AND p.deleted_at IS NULL"). Where("UPPER(kw.type) = 'KANDANG'"). Where("COALESCE(pw.qty, 0) > 0"). Order("l.name ASC, kw.name ASC, p.name ASC") query = applyLocationFilter(query, opts, "kw") query = applyFlagFilter(query, opts) var rows []row if err := query.Scan(&rows).Error; err != nil { return nil, err } result := make([]kandangStockRow, 0, len(rows)) for _, r := range rows { result = append(result, kandangStockRow{ LocationID: r.LocationID, LocationName: r.LocationName, SourceWarehouseID: r.SourceWarehouseID, SourceWarehouseName: r.SourceWarehouseName, ProductWarehouseID: r.ProductWarehouseID, ProductID: r.ProductID, ProductName: r.ProductName, OnHandQty: r.OnHandQty, AllocatedQty: r.AllocatedQty, LeftoverQty: r.OnHandQty - r.AllocatedQty, SourceType: sourceTypeKandang, }) } return result, nil } // loadExtraFarmLeftoverStocks loads leftover stocks from every OtherFarm // warehouse in the map. These are LOKASI-type warehouses that will be // consolidated into the chosen farm warehouse when --farm-warehouse-id is used. func loadExtraFarmLeftoverStocks(ctx context.Context, db *gorm.DB, farmMap map[uint]farmWarehouseInfo, opts *commandOptions) ([]kandangStockRow, error) { // Collect extra farm warehouse IDs together with their location context. type extraSource struct { LocationID uint LocationName string WHID uint WHName string } sources := make([]extraSource, 0) for _, info := range farmMap { for _, fw := range info.OtherFarm { sources = append(sources, extraSource{ LocationID: info.LocationID, LocationName: info.LocationName, WHID: fw.ID, WHName: fw.Name, }) } } if len(sources) == 0 { return nil, nil } warehouseIDs := make([]uint, 0, len(sources)) for _, s := range sources { warehouseIDs = append(warehouseIDs, s.WHID) } type row struct { WarehouseID uint `gorm:"column:source_warehouse_id"` WarehouseName string `gorm:"column:source_warehouse_name"` ProductWarehouseID uint `gorm:"column:product_warehouse_id"` ProductID uint `gorm:"column:product_id"` ProductName string `gorm:"column:product_name"` OnHandQty float64 `gorm:"column:on_hand_qty"` AllocatedQty float64 `gorm:"column:allocated_qty"` } var rows []row q := db.WithContext(ctx). Table("product_warehouses pw"). Select(` fw.id AS source_warehouse_id, fw.name AS source_warehouse_name, pw.id AS product_warehouse_id, pw.product_id AS product_id, p.name AS product_name, COALESCE(pw.qty, 0) AS on_hand_qty, COALESCE(( SELECT SUM(sa.qty) FROM stock_allocations sa WHERE sa.product_warehouse_id = pw.id AND sa.status = 'ACTIVE' AND sa.allocation_purpose = 'CONSUME' AND sa.deleted_at IS NULL ), 0) AS allocated_qty `). Joins("JOIN warehouses fw ON fw.id = pw.warehouse_id AND fw.deleted_at IS NULL"). Joins("JOIN products p ON p.id = pw.product_id AND p.deleted_at IS NULL"). Where("fw.id IN ?", warehouseIDs). Where("COALESCE(pw.qty, 0) > 0") q = applyFlagFilter(q, opts) if err := q.Order("fw.name ASC, p.name ASC").Scan(&rows).Error; err != nil { return nil, err } // Build a lookup: warehouseID → extraSource for location context. srcByWH := make(map[uint]extraSource, len(sources)) for _, s := range sources { srcByWH[s.WHID] = s } result := make([]kandangStockRow, 0, len(rows)) for _, r := range rows { src := srcByWH[r.WarehouseID] result = append(result, kandangStockRow{ LocationID: src.LocationID, LocationName: src.LocationName, SourceWarehouseID: r.WarehouseID, SourceWarehouseName: r.WarehouseName, ProductWarehouseID: r.ProductWarehouseID, ProductID: r.ProductID, ProductName: r.ProductName, OnHandQty: r.OnHandQty, AllocatedQty: r.AllocatedQty, LeftoverQty: r.OnHandQty - r.AllocatedQty, SourceType: sourceTypeFarmConsol, }) } return result, nil } // ── Plan building ───────────────────────────────────────────────────────────── func buildTransferPlan( opts *commandOptions, farmMap map[uint]farmWarehouseInfo, stocks []kandangStockRow, ) ([]transferReportRow, []transferGroup) { reportRows := make([]transferReportRow, 0, len(stocks)) groupMap := make(map[string]*transferGroup) // key: "srcWarehouseID:farmWarehouseID" for _, s := range stocks { farm := farmMap[s.LocationID] report := transferReportRow{ RunID: opts.RunID, SourceType: s.SourceType, LocationID: s.LocationID, LocationName: s.LocationName, SourceWarehouseID: s.SourceWarehouseID, SourceWarehouseName: s.SourceWarehouseName, ProductWarehouseID: s.ProductWarehouseID, ProductID: s.ProductID, ProductName: s.ProductName, OnHandQty: s.OnHandQty, AllocatedQty: s.AllocatedQty, Qty: s.LeftoverQty, Status: "eligible", } switch s.SourceType { case sourceTypeFarmConsol: // The destination is already resolved (OtherFarm is only populated // when ChosenID is set). The only reason to skip is zero leftover. if s.LeftoverQty <= 0 { report.Status = "skipped" if s.AllocatedQty > 0 { report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty) } else { report.Reason = "zero_on_hand_qty" } } default: // sourceTypeKandang switch { case !farm.hasFarm(): report.Status = "skipped" report.Reason = "missing_farm_warehouse" case farm.farmCount() > 1 && !farm.isResolved(): // Multiple LOKASI warehouses and no override was given. List the // available warehouse IDs so the operator knows what to pass to // --farm-warehouse-id. available := make([]string, 0, len(farm.AllFarm)) for _, fw := range farm.AllFarm { available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID)) } hint := fmt.Sprintf( "multiple_farm_warehouses — rerun with --farm-warehouse-id= to choose one: %s", strings.Join(available, " | "), ) if opts.SkipAmbiguous { report.Status = "skipped" report.Reason = hint } else { report.Status = "error" report.Reason = hint } case s.LeftoverQty <= 0: report.Status = "skipped" if s.AllocatedQty > 0 { report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty) } else { report.Reason = "zero_on_hand_qty" } } } // Attach the chosen farm warehouse to the report row for visibility. if farm.isResolved() { fwID := farm.ChosenID fwName := farm.ChosenName report.FarmWarehouseID = &fwID report.FarmWarehouseName = &fwName } reportRows = append(reportRows, report) if report.Status != "eligible" { continue } groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.ChosenID) grp := groupMap[groupKey] if grp == nil { grp = &transferGroup{ SourceType: s.SourceType, LocationID: s.LocationID, LocationName: s.LocationName, SourceWarehouseID: s.SourceWarehouseID, SourceWarehouseName: s.SourceWarehouseName, FarmWarehouseID: farm.ChosenID, FarmWarehouseName: farm.ChosenName, } groupMap[groupKey] = grp } grp.Rows = append(grp.Rows, &reportRows[len(reportRows)-1]) } groups := make([]transferGroup, 0, len(groupMap)) for _, grp := range groupMap { sort.Slice(grp.Rows, func(i, j int) bool { return grp.Rows[i].ProductName < grp.Rows[j].ProductName }) groups = append(groups, *grp) } sort.Slice(groups, func(i, j int) bool { if groups[i].LocationName == groups[j].LocationName { return groups[i].SourceWarehouseName < groups[j].SourceWarehouseName } return groups[i].LocationName < groups[j].LocationName }) return reportRows, groups } // ── Apply / Rollback execution ──────────────────────────────────────────────── func executeApply( ctx context.Context, svc systemTransferExecutor, opts *commandOptions, groups []transferGroup, ) (applySummary, error) { summary := applySummary{GroupsPlanned: len(groups)} for i := range groups { grp := &groups[i] products := make([]transferSvc.SystemTransferProduct, 0, len(grp.Rows)) for _, row := range grp.Rows { products = append(products, transferSvc.SystemTransferProduct{ ProductID: row.ProductID, ProductQty: row.Qty, }) } reason := buildTransferReason(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate) notes := buildStockLogNotes(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate, grp.SourceType) transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{ TransferReason: reason, TransferDate: opts.TransferDate, SourceWarehouseID: grp.SourceWarehouseID, DestinationWarehouseID: grp.FarmWarehouseID, Products: products, ActorID: opts.ActorID, StockLogNotes: notes, }) if err != nil { for _, row := range grp.Rows { row.Status = "failed" row.Reason = err.Error() summary.RowsFailed++ } continue } summary.GroupsApplied++ for _, row := range grp.Rows { row.Status = "applied" row.TransferID = &transfer.Id row.MovementNumber = &transfer.MovementNumber summary.RowsApplied++ } } for _, grp := range groups { summary.RowsPlanned += len(grp.Rows) } return summary, nil } func executeRollback( ctx context.Context, svc systemTransferExecutor, rows []rollbackDetailRow, actorID uint, ) error { if actorID == 0 { return fmt.Errorf("--actor-id is required for rollback") } byTransfer := make(map[uint64][]int) for idx, row := range rows { byTransfer[row.TransferID] = append(byTransfer[row.TransferID], idx) } transferIDs := make([]uint64, 0, len(byTransfer)) for id := range byTransfer { transferIDs = append(transferIDs, id) } // Delete in descending order to unwind downstream dependencies first. sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] }) var firstErr error for _, id := range transferIDs { err := svc.DeleteSystemTransfer(ctx, uint(id), actorID) for _, idx := range byTransfer[id] { if err != nil { rows[idx].Status = "failed" rows[idx].Reason = err.Error() } else { rows[idx].Status = "rolled_back" } } if err != nil && firstErr == nil { firstErr = err } } return firstErr } // loadRollbackDetails finds all transfer rows created by a given run_id. func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) { type row struct { TransferID uint64 `gorm:"column:transfer_id"` MovementNumber string `gorm:"column:movement_number"` LocationName string `gorm:"column:location_name"` SourceWarehouseName string `gorm:"column:source_warehouse_name"` FarmWarehouseName string `gorm:"column:farm_warehouse_name"` ProductName string `gorm:"column:product_name"` Qty float64 `gorm:"column:qty"` } needle := buildRunReasonMatcher(runID) var dbRows []row err := db.WithContext(ctx). Table("stock_transfers st"). Select(` st.id AS transfer_id, st.movement_number AS movement_number, COALESCE(loc.name, '') AS location_name, ws.name AS source_warehouse_name, wd.name AS farm_warehouse_name, p.name AS product_name, COALESCE(std.total_qty, std.usage_qty, 0) AS qty `). Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id"). Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id"). Joins("LEFT JOIN locations loc ON loc.id = COALESCE(ws.location_id, wd.location_id)"). Joins("JOIN stock_transfer_details std ON std.stock_transfer_id = st.id AND std.deleted_at IS NULL"). Joins("JOIN products p ON p.id = std.product_id"). Where("st.deleted_at IS NULL"). Where("st.reason LIKE ?", needle). Order("st.id DESC, std.id ASC"). Scan(&dbRows).Error if err != nil { return nil, err } rows := make([]rollbackDetailRow, 0, len(dbRows)) for _, r := range dbRows { rows = append(rows, rollbackDetailRow{ RunID: runID, TransferID: r.TransferID, MovementNumber: r.MovementNumber, LocationName: r.LocationName, SourceWarehouseName: r.SourceWarehouseName, FarmWarehouseName: r.FarmWarehouseName, ProductName: r.ProductName, Qty: r.Qty, }) } return rows, nil } // ── Helpers ─────────────────────────────────────────────────────────────────── // applyFlagFilter adds an EXISTS subquery that restricts results to products // carrying at least one flag from opts.FlagFilter. When the filter is empty // the query is returned unchanged so all products are included. func applyFlagFilter(q *gorm.DB, opts *commandOptions) *gorm.DB { if len(opts.FlagFilter) == 0 { return q } return q.Where(`EXISTS ( SELECT 1 FROM flags f WHERE f.flagable_id = p.id AND f.flagable_type = 'products' AND UPPER(f.name) IN ? )`, opts.FlagFilter) } func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *gorm.DB { if opts == nil { return q } switch { case opts.LocationID > 0: return q.Where(tableAlias+".location_id = ?", opts.LocationID) case opts.LocationName != "": return q.Where("LOWER(l.name) = LOWER(?)", opts.LocationName) default: return q } } // buildTransferReason produces the structured string stored in stock_transfers.reason. // This is the rollback lookup key so its format must remain stable. func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string { return fmt.Sprintf( "%s|run_id=%s|location=%s|src_warehouse=%s|farm_warehouse=%s|transfer_date=%s", transferReasonPrefix, runID, sanitizePipeField(locationName), sanitizePipeField(srcWarehouse), sanitizePipeField(farmWarehouse), date.Format("2006-01-02"), ) } // buildStockLogNotes produces a human-readable note for each stock_log entry. func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time, sourceType string) string { kind := "leftover stock transfer from kandang to farm" if sourceType == sourceTypeFarmConsol { kind = "farm warehouse consolidation (non-primary farm to chosen farm)" } return fmt.Sprintf( "[auto] %s | run_id=%s | location=%s | from=%s | to=%s | date=%s", kind, runID, locationName, srcWarehouse, farmWarehouse, date.Format("2006-01-02"), ) } // buildRunReasonMatcher returns a SQL LIKE pattern matching all transfers from a run. func buildRunReasonMatcher(runID string) string { return fmt.Sprintf("%s|run_id=%s|%%", transferReasonPrefix, strings.TrimSpace(runID)) } func buildRunID() string { return fmt.Sprintf("product-farm-transfer-%s", time.Now().UTC().Format("20060102T150405.000000000Z")) } func sanitizePipeField(s string) string { return strings.ReplaceAll(strings.TrimSpace(s), "|", "/") } func normalizeDateOnly(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) } func derefString(s *string) string { if s == nil { return "" } return *s } // flattenGroups rebuilds the row list from groups (which carry applied/failed // status after apply) then appends skipped and error rows from the plan. func flattenGroups(groups []transferGroup, fallback []transferReportRow) []transferReportRow { if len(groups) == 0 { return fallback } rows := make([]transferReportRow, 0, len(fallback)) for _, grp := range groups { for _, row := range grp.Rows { rows = append(rows, *row) } } for _, row := range fallback { if row.Status == "skipped" || row.Status == "error" { rows = append(rows, row) } } sort.Slice(rows, func(i, j int) bool { if rows[i].LocationName == rows[j].LocationName { if rows[i].SourceWarehouseName == rows[j].SourceWarehouseName { return rows[i].ProductName < rows[j].ProductName } return rows[i].SourceWarehouseName < rows[j].SourceWarehouseName } return rows[i].LocationName < rows[j].LocationName }) return rows } func summarizeReport(rows []transferReportRow, groups []transferGroup, appliedGroups int) applySummary { summary := applySummary{ GroupsPlanned: len(groups), GroupsApplied: appliedGroups, } for _, row := range rows { switch row.Status { case "eligible": summary.RowsPlanned++ case "applied": summary.RowsPlanned++ summary.RowsApplied++ case "failed": summary.RowsPlanned++ summary.RowsFailed++ case "skipped": summary.RowsSkipped++ case "error": summary.RowsError++ } } return summary } // ── Rendering ───────────────────────────────────────────────────────────────── func renderTransferReport(mode string, rows []transferReportRow, summary applySummary) { if mode == outputModeJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(map[string]any{"rows": rows, "summary": summary}) return } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "RUN_ID\tSOURCE_TYPE\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tON_HAND\tALLOCATED\tLEFTOVER\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER") for _, row := range rows { transferID := "-" if row.TransferID != nil { transferID = fmt.Sprintf("%d", *row.TransferID) } movementNumber := "-" if row.MovementNumber != nil { movementNumber = *row.MovementNumber } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\n", row.RunID, row.SourceType, row.LocationName, row.SourceWarehouseName, derefString(row.FarmWarehouseName), row.ProductName, row.OnHandQty, row.AllocatedQty, row.Qty, row.Status, row.Reason, transferID, movementNumber, ) } _ = w.Flush() fmt.Printf( "\nSummary: rows_planned=%d rows_applied=%d rows_skipped=%d rows_error=%d rows_failed=%d groups_planned=%d groups_applied=%d\n", summary.RowsPlanned, summary.RowsApplied, summary.RowsSkipped, summary.RowsError, summary.RowsFailed, summary.GroupsPlanned, summary.GroupsApplied, ) } func renderRollbackReport(mode string, rows []rollbackDetailRow) { if mode == outputModeJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(map[string]any{"rows": rows}) return } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "RUN_ID\tTRANSFER_ID\tMOVEMENT_NUMBER\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tQTY\tSTATUS\tREASON") for _, row := range rows { fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\n", row.RunID, row.TransferID, row.MovementNumber, row.LocationName, row.SourceWarehouseName, row.FarmWarehouseName, row.ProductName, row.Qty, row.Status, row.Reason, ) } _ = w.Flush() }