diff --git a/auto-transfer-products-to-farm b/auto-transfer-products-to-farm index 9256ccd8..a8935ce9 100755 Binary files a/auto-transfer-products-to-farm and b/auto-transfer-products-to-farm differ diff --git a/cmd/auto-transfer-products-to-farm/main.go b/cmd/auto-transfer-products-to-farm/main.go index 221b5ad3..964b337a 100644 --- a/cmd/auto-transfer-products-to-farm/main.go +++ b/cmd/auto-transfer-products-to-farm/main.go @@ -32,33 +32,63 @@ 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 - Output string - ActorID uint - RunID string + 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 } -// farmWarehouseInfo holds resolved farm-level warehouse data for a location. -// If FarmCount > 1, the location is invalid (ambiguous target). +// 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 - FarmCount int - WarehouseID uint // only reliable when FarmCount == 1 - WarehouseName string // only reliable when FarmCount == 1 + 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 } -// kandangStockRow is the raw row loaded from the DB for a single product in a kandang warehouse. +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 @@ -68,13 +98,15 @@ type kandangStockRow struct { ProductID uint ProductName string OnHandQty float64 - AllocatedQty float64 // sum of ACTIVE CONSUME stock allocations + 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"` @@ -93,8 +125,9 @@ type transferReportRow struct { MovementNumber *string `json:"movement_number,omitempty"` } -// transferGroup is a single stock transfer: one kandang warehouse → one farm warehouse, with N products. +// transferGroup is one stock transfer document: source → farm, N products. type transferGroup struct { + SourceType string LocationID uint LocationName string SourceWarehouseID uint @@ -115,7 +148,7 @@ type applySummary struct { GroupsApplied int `json:"groups_applied"` } -// rollbackDetailRow is one product line that belongs to a transfer created by a previous run. +// rollbackDetailRow is one product line created by a previous run. type rollbackDetailRow struct { RunID string `json:"run_id"` TransferID uint64 `json:"transfer_id"` @@ -129,12 +162,14 @@ type rollbackDetailRow struct { Reason string `json:"reason,omitempty"` } -// systemTransferExecutor abstracts the transfer service so it can be faked in tests. +// 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 { @@ -169,28 +204,48 @@ func main() { } // ── 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) + } - // Abort early if any in-scope location has multiple farm warehouses and - // we are about to apply — the ambiguity is too risky to proceed. + // In apply mode, warn about or hard-stop on unresolved locations. if opts.Apply { - if msgs := validateFarmWarehouseMap(farmMap); len(msgs) > 0 { + if msgs := listUnresolvedLocations(farmMap); len(msgs) > 0 { for _, m := range msgs { - fmt.Fprintln(os.Stderr, "ERROR:", m) + 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") } - log.Fatalf("aborting: resolve multiple-farm-warehouse conflicts before applying") } } - stockRows, err := loadKandangLeftoverStocks(ctx, db, opts) + // 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) } - reportRows, groups := buildTransferPlan(opts, farmMap, stockRows) + // 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)) @@ -215,11 +270,31 @@ func parseFlags() (*commandOptions, error) { 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)") + 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)) @@ -233,6 +308,16 @@ func parseFlags() (*commandOptions, error) { 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") @@ -240,11 +325,14 @@ func parseFlags() (*commandOptions, error) { 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 when you have reviewed the dry-run output for all locations", + "use --all-locations only after reviewing the dry-run output for all locations", ) } } @@ -299,35 +387,37 @@ func newSystemTransferService(db *gorm.DB) systemTransferExecutor { // ── DB loading ──────────────────────────────────────────────────────────────── -// loadFarmWarehouseMap returns a map keyed by location_id. -// Each entry tells how many LOKASI-type warehouses the location has and which one -// to use (only safe when FarmCount == 1). +// 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"` - FarmCount int `gorm:"column:farm_count"` - WarehouseID uint `gorm:"column:warehouse_id"` - WarehouseName string `gorm:"column:warehouse_name"` + 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, - COUNT(fw.id) AS farm_count, - MIN(fw.id) AS warehouse_id, - MIN(fw.name) AS warehouse_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`). + 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"). - Group("kw.location_id, l.name") + Order("kw.location_id ASC, fw.id ASC") query = applyLocationFilter(query, opts, "kw") @@ -336,28 +426,97 @@ func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions return nil, err } - result := make(map[uint]farmWarehouseInfo, len(rows)) + result := make(map[uint]farmWarehouseInfo) for _, r := range rows { - result[r.LocationID] = farmWarehouseInfo{ - LocationID: r.LocationID, - LocationName: r.LocationName, - FarmCount: r.FarmCount, - WarehouseID: r.WarehouseID, - WarehouseName: r.WarehouseName, + 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 } -// validateFarmWarehouseMap returns one error message per location that has -// more than one farm-level (LOKASI) warehouse. An empty slice means no issues. -func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string { +// 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 m { - if info.FarmCount > 1 { + 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; a unique farm warehouse is required — resolve the ambiguity before running", - info.LocationName, info.LocationID, info.FarmCount, + "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, ", "), )) } } @@ -365,9 +524,9 @@ func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string { return msgs } -// loadKandangLeftoverStocks loads every product_warehouse row for KANDANG-type -// warehouses where on_hand_qty > 0, together with the sum of ACTIVE CONSUME -// stock_allocations so callers can compute the leftover qty. +// 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"` @@ -409,6 +568,7 @@ func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOp 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 { @@ -428,6 +588,102 @@ func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOp 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 @@ -448,6 +704,7 @@ func buildTransferPlan( report := transferReportRow{ RunID: opts.RunID, + SourceType: s.SourceType, LocationID: s.LocationID, LocationName: s.LocationName, SourceWarehouseID: s.SourceWarehouseID, @@ -461,26 +718,56 @@ func buildTransferPlan( Status: "eligible", } - switch { - case farm.FarmCount == 0: - report.Status = "skipped" - report.Reason = "missing_farm_warehouse" - case farm.FarmCount > 1: - // Treat as a hard error row so the operator knows to fix it. - report.Status = "error" - report.Reason = fmt.Sprintf("multiple_farm_warehouses (found %d)", farm.FarmCount) - 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" + 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" + } } } - if farm.FarmCount == 1 { - fwID := farm.WarehouseID - fwName := farm.WarehouseName + // 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 } @@ -490,16 +777,17 @@ func buildTransferPlan( continue } - groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.WarehouseID) + 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.WarehouseID, - FarmWarehouseName: farm.WarehouseName, + FarmWarehouseID: farm.ChosenID, + FarmWarehouseName: farm.ChosenName, } groupMap[groupKey] = grp } @@ -544,7 +832,7 @@ func executeApply( } reason := buildTransferReason(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate) - notes := buildStockLogNotes(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, @@ -598,7 +886,7 @@ func executeRollback( for id := range byTransfer { transferIDs = append(transferIDs, id) } - // Delete in descending order to minimize downstream conflicts. + // 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 @@ -620,8 +908,7 @@ func executeRollback( return firstErr } -// loadRollbackDetails finds all stock transfer rows that were created by the -// given run_id (matched via the transfer reason field). +// 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"` @@ -638,13 +925,13 @@ func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]roll 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 + 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"). @@ -677,6 +964,22 @@ func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]roll // ── 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 @@ -691,8 +994,8 @@ func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *g } } -// buildTransferReason produces a structured string stored in stock_transfers.reason. -// It is used as the rollback lookup key, so must remain stable and parseable. +// 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", @@ -705,21 +1008,19 @@ func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string ) } -// buildStockLogNotes produces a human-readable note attached to each stock log -// entry so operators can trace the origin of stock movements in the logs. -func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string { +// 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] leftover stock transfer from kandang to farm | run_id=%s | location=%s | from=%s | to=%s | date=%s", - runID, - locationName, - srcWarehouse, - farmWarehouse, - date.Format("2006-01-02"), + "[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 LIKE pattern that matches all transfers from -// a specific run_id regardless of the other fields in the reason string. +// 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)) } @@ -743,8 +1044,8 @@ func derefString(s *string) string { return *s } -// flattenGroups rebuilds reportRows from groups (which carry applied/failed -// status) then appends skipped/error rows from the original slice. +// 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 @@ -807,7 +1108,7 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tON_HAND\tALLOCATED\tLEFTOVER\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER") + 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 { @@ -818,8 +1119,9 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu movementNumber = *row.MovementNumber } fmt.Fprintf(w, - "%s\t%s\t%s\t%s\t%s\t%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\n", + "%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), diff --git a/cmd/auto-transfer-products-to-farm/main_test.go b/cmd/auto-transfer-products-to-farm/main_test.go index 4b42efbb..37c881af 100644 --- a/cmd/auto-transfer-products-to-farm/main_test.go +++ b/cmd/auto-transfer-products-to-farm/main_test.go @@ -11,13 +11,8 @@ import ( transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" ) -// ── Helpers ─────────────────────────────────────────────────────────────────── +// ── Fake executor ───────────────────────────────────────────────────────────── -func ptrUint(v uint) *uint { return &v } -func ptrStr(s string) *string { return &s } -func ptrUint64(v uint64) *uint64 { return &v } - -// fakeSystemTransferExecutor records calls and returns pre-configured responses. type fakeSystemTransferExecutor struct { createRequests []*transferSvc.SystemTransferRequest createResponses []*entity.StockTransfer @@ -46,237 +41,467 @@ func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(_ context.Context, id return nil } -// ── validateFarmWarehouseMap ────────────────────────────────────────────────── +// ── applyFarmWarehouseOverride ──────────────────────────────────────────────── -func TestValidateFarmWarehouseMapReturnsMsgsForMultipleFarmWarehouses(t *testing.T) { - m := map[uint]farmWarehouseInfo{ - 1: {LocationID: 1, LocationName: "Jamali", FarmCount: 1}, - 2: {LocationID: 2, LocationName: "Cijangkar", FarmCount: 3}, - 3: {LocationID: 3, LocationName: "Tamansari", FarmCount: 2}, - } - - msgs := validateFarmWarehouseMap(m) - if len(msgs) != 2 { - t.Fatalf("expected 2 error messages, got %d: %v", len(msgs), msgs) - } - for _, msg := range msgs { - if !strings.Contains(msg, "LOKASI warehouses") { - t.Errorf("expected message to mention LOKASI warehouses, got: %s", msg) - } - } -} - -func TestValidateFarmWarehouseMapNoErrorsWhenAllUnique(t *testing.T) { - m := map[uint]farmWarehouseInfo{ - 1: {LocationID: 1, LocationName: "Jamali", FarmCount: 1}, - 2: {LocationID: 2, LocationName: "Cijangkar", FarmCount: 0}, - } - if msgs := validateFarmWarehouseMap(m); len(msgs) != 0 { - t.Fatalf("expected no messages, got: %v", msgs) - } -} - -// ── buildTransferPlan ───────────────────────────────────────────────────────── - -func TestBuildTransferPlanEligibleRowsGroupedByWarehousePair(t *testing.T) { - opts := &commandOptions{RunID: "product-farm-transfer-test"} +func TestApplyOverrideResolvesMultiFarmLocation(t *testing.T) { farmMap := map[uint]farmWarehouseInfo{ - 10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"}, + 10: { + LocationID: 10, + LocationName: "Cijangkar", + AllFarm: []farmWarehouseEntry{ + {ID: 50, Name: "Farm A"}, + {ID: 51, Name: "Farm B"}, + }, + }, + } + + if err := applyFarmWarehouseOverride(farmMap, 51); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + info := farmMap[10] + if info.ChosenID != 51 { + t.Errorf("expected ChosenID=51, got %d", info.ChosenID) + } + if info.ChosenName != "Farm B" { + t.Errorf("expected ChosenName=Farm B, got %s", info.ChosenName) + } + if len(info.OtherFarm) != 1 || info.OtherFarm[0].ID != 50 { + t.Errorf("expected OtherFarm=[Farm A], got %+v", info.OtherFarm) + } +} + +func TestApplyOverrideErrorsWhenIDNotInAllFarm(t *testing.T) { + farmMap := map[uint]farmWarehouseInfo{ + 10: { + LocationID: 10, + LocationName: "Cijangkar", + AllFarm: []farmWarehouseEntry{ + {ID: 50, Name: "Farm A"}, + {ID: 51, Name: "Farm B"}, + }, + }, + } + + err := applyFarmWarehouseOverride(farmMap, 99) + if err == nil { + t.Fatal("expected error for unknown warehouse id, got nil") + } + if !strings.Contains(err.Error(), "99") { + t.Errorf("error should mention the invalid id, got: %v", err) + } + if !strings.Contains(err.Error(), "Farm A") || !strings.Contains(err.Error(), "Farm B") { + t.Errorf("error should list available warehouses, got: %v", err) + } +} + +func TestApplyOverrideIgnoresSingleFarmLocations(t *testing.T) { + farmMap := map[uint]farmWarehouseInfo{ + 10: { + LocationID: 10, + LocationName: "Jamali", + AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, + ChosenID: 50, + ChosenName: "Farm A", + }, + } + + // Override ID 50 is present, but there is only 1 farm; the function should + // not touch this location (no OtherFarm to populate). + if err := applyFarmWarehouseOverride(farmMap, 50); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(farmMap[10].OtherFarm) != 0 { + t.Errorf("expected no OtherFarm for single-farm location, got %+v", farmMap[10].OtherFarm) + } +} + +func TestApplyOverrideNoopWhenZero(t *testing.T) { + farmMap := map[uint]farmWarehouseInfo{ + 10: { + LocationID: 10, + LocationName: "Cijangkar", + AllFarm: []farmWarehouseEntry{ + {ID: 50, Name: "Farm A"}, + {ID: 51, Name: "Farm B"}, + }, + }, + } + if err := applyFarmWarehouseOverride(farmMap, 0); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if farmMap[10].ChosenID != 0 { + t.Errorf("expected ChosenID unchanged (0), got %d", farmMap[10].ChosenID) + } +} + +// ── listUnresolvedLocations ─────────────────────────────────────────────────── + +func TestListUnresolvedLocationsReturnsOnlyAmbiguous(t *testing.T) { + farmMap := map[uint]farmWarehouseInfo{ + 1: {LocationID: 1, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50}, + 2: { + LocationID: 2, + LocationName: "Cijangkar", + AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}}, + // ChosenID = 0: unresolved + }, + 3: {LocationID: 3, LocationName: "Tamansari", AllFarm: nil}, // no farm at all, not an error here + } + + msgs := listUnresolvedLocations(farmMap) + if len(msgs) != 1 { + t.Fatalf("expected 1 unresolved message, got %d: %v", len(msgs), msgs) + } + if !strings.Contains(msgs[0], "Cijangkar") { + t.Errorf("message should name the ambiguous location, got: %s", msgs[0]) + } + if !strings.Contains(msgs[0], "Farm X") || !strings.Contains(msgs[0], "Farm Y") { + t.Errorf("message should list available warehouses, got: %s", msgs[0]) + } +} + +// ── buildTransferPlan — kandang source ─────────────────────────────────────── + +func TestBuildPlanKandangEligibleGroupedByWarehousePair(t *testing.T) { + opts := &commandOptions{RunID: "test-run"} + farmMap := map[uint]farmWarehouseInfo{ + 10: {LocationID: 10, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"}, } stocks := []kandangStockRow{ - {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductWarehouseID: 101, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 0, LeftoverQty: 100}, - {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductWarehouseID: 102, ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40}, + {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang}, + {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40, SourceType: sourceTypeKandang}, } reportRows, groups := buildTransferPlan(opts, farmMap, stocks) - if len(reportRows) != 2 { - t.Fatalf("expected 2 report rows, got %d", len(reportRows)) + + if len(groups) != 1 || len(groups[0].Rows) != 2 { + t.Fatalf("expected 1 group with 2 rows, got %d groups", len(groups)) } - if len(groups) != 1 { - t.Fatalf("expected 1 transfer group, got %d", len(groups)) - } - if len(groups[0].Rows) != 2 { - t.Fatalf("expected 2 products in group, got %d", len(groups[0].Rows)) - } - if reportRows[1].AllocatedQty != 10 || reportRows[1].Qty != 40 { - t.Errorf("unexpected allocated/leftover qty for OVK B: %+v", reportRows[1]) + if groups[0].SourceType != sourceTypeKandang { + t.Errorf("expected group source type kandang_to_farm, got %s", groups[0].SourceType) } for _, row := range reportRows { if row.Status != "eligible" { t.Errorf("expected eligible, got %s for %s", row.Status, row.ProductName) } } + if reportRows[1].Qty != 40 { + t.Errorf("expected leftover qty 40 for OVK B, got %.3f", reportRows[1].Qty) + } } -func TestBuildTransferPlanSkipsMissingFarmWarehouse(t *testing.T) { - opts := &commandOptions{RunID: "product-farm-transfer-test"} +func TestBuildPlanSkipsMissingFarmWarehouse(t *testing.T) { + opts := &commandOptions{RunID: "test-run"} farmMap := map[uint]farmWarehouseInfo{ - 10: {LocationID: 10, LocationName: "Jamali", FarmCount: 0}, + 10: {LocationID: 10, LocationName: "Jamali", AllFarm: nil}, } stocks := []kandangStockRow{ - {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100}, + {LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang}, } reportRows, groups := buildTransferPlan(opts, farmMap, stocks) if len(groups) != 0 { - t.Fatalf("expected no transfer groups, got %d", len(groups)) + t.Fatalf("expected no groups, got %d", len(groups)) } if reportRows[0].Status != "skipped" || reportRows[0].Reason != "missing_farm_warehouse" { - t.Errorf("unexpected status/reason: %s / %s", reportRows[0].Status, reportRows[0].Reason) + t.Errorf("unexpected: %s / %s", reportRows[0].Status, reportRows[0].Reason) } } -func TestBuildTransferPlanMarksErrorForMultipleFarmWarehouses(t *testing.T) { - opts := &commandOptions{RunID: "product-farm-transfer-test"} +func TestBuildPlanErrorForUnresolvedMultiFarmWarehouse(t *testing.T) { + opts := &commandOptions{RunID: "test-run"} farmMap := map[uint]farmWarehouseInfo{ - 10: {LocationID: 10, LocationName: "Cijangkar", FarmCount: 2}, + 10: { + LocationID: 10, + LocationName: "Cijangkar", + AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}}, + // ChosenID = 0: unresolved + }, } stocks := []kandangStockRow{ - {LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, SourceWarehouseName: "Gudang K2", ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200}, + {LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang}, } reportRows, groups := buildTransferPlan(opts, farmMap, stocks) if len(groups) != 0 { - t.Fatalf("expected no transfer groups, got %d", len(groups)) + t.Fatalf("expected no groups, got %d", len(groups)) } if reportRows[0].Status != "error" { - t.Errorf("expected error status for multiple farm warehouses, got %s", reportRows[0].Status) + t.Errorf("expected error status, got %s", reportRows[0].Status) } if !strings.Contains(reportRows[0].Reason, "multiple_farm_warehouses") { - t.Errorf("unexpected reason: %s", reportRows[0].Reason) + t.Errorf("reason should mention multiple_farm_warehouses, got: %s", reportRows[0].Reason) + } + // The error message must list the available warehouses so the operator knows + // which --farm-warehouse-id to use. + if !strings.Contains(reportRows[0].Reason, "Farm X") || !strings.Contains(reportRows[0].Reason, "Farm Y") { + t.Errorf("reason should list available warehouses, got: %s", reportRows[0].Reason) } } -func TestBuildTransferPlanSkipsFullyAllocatedStock(t *testing.T) { - opts := &commandOptions{RunID: "product-farm-transfer-test"} +func TestBuildPlanSkipsFullyAllocatedKandangStock(t *testing.T) { + opts := &commandOptions{RunID: "test-run"} farmMap := map[uint]farmWarehouseInfo{ - 10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"}, + 10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"}, } stocks := []kandangStockRow{ - // fully allocated - {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0}, - // partially allocated, should be eligible with leftover qty - {LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50}, + {LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeKandang}, + {LocationID: 10, SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50, SourceType: sourceTypeKandang}, } - reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + _, groups := buildTransferPlan(opts, farmMap, stocks) if len(groups) != 1 || len(groups[0].Rows) != 1 { t.Fatalf("expected 1 group with 1 eligible row, got groups=%d", len(groups)) } + if groups[0].Rows[0].ProductName != "OVK B" { + t.Errorf("expected only OVK B to be eligible, got %s", groups[0].Rows[0].ProductName) + } +} + +func TestBuildPlanSkipAmbiguousDowngradesErrorToSkipped(t *testing.T) { + opts := &commandOptions{RunID: "test-run", SkipAmbiguous: true} + farmMap := map[uint]farmWarehouseInfo{ + 10: { + LocationID: 10, + LocationName: "Cijangkar", + AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}}, + // ChosenID = 0: unresolved + }, + 11: { + LocationID: 11, + LocationName: "Jamali", + AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, + ChosenID: 50, + ChosenName: "Farm A", + }, + } + stocks := []kandangStockRow{ + {LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang}, + {LocationID: 11, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang}, + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + + // Ambiguous location must be skipped, not error. + ambiguous := reportRows[0] + if ambiguous.LocationName != "Cijangkar" { + t.Fatalf("expected first row to be Cijangkar, got %s", ambiguous.LocationName) + } + if ambiguous.Status != "skipped" { + t.Errorf("expected skipped with --skip-ambiguous, got %s", ambiguous.Status) + } + if !strings.Contains(ambiguous.Reason, "multiple_farm_warehouses") { + t.Errorf("reason should still explain the cause, got: %s", ambiguous.Reason) + } + + // Unambiguous location must still be eligible and grouped. + if len(groups) != 1 || groups[0].LocationName != "Jamali" { + t.Errorf("expected 1 group for Jamali, got %d groups", len(groups)) + } +} + +// ── applyFlagFilter (unit-level, via buildTransferPlan) ─────────────────────── + +// applyFlagFilter is a DB-level filter so we test its effect indirectly: the +// flag filter is applied before rows reach buildTransferPlan, so we simulate +// by only passing stock rows that the query would have returned. +// The real guard is that loadKandangLeftoverStocks receives the filtered set. +// Here we verify that buildTransferPlan itself is agnostic to the filter and +// simply processes whatever rows it is given. +func TestBuildPlanOnlyTransfersRowsPassedToIt(t *testing.T) { + opts := &commandOptions{RunID: "test-run", FlagFilter: []string{"PAKAN"}} + farmMap := map[uint]farmWarehouseInfo{ + 10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"}, + } + // Simulate: only PAKAN products survived the DB filter; OVK was excluded. + stocks := []kandangStockRow{ + {LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan Broiler", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang}, + } + + _, groups := buildTransferPlan(opts, farmMap, stocks) + if len(groups) != 1 || len(groups[0].Rows) != 1 { + t.Fatalf("expected 1 group with 1 row, got %d groups", len(groups)) + } + if groups[0].Rows[0].ProductName != "Pakan Broiler" { + t.Errorf("unexpected product: %s", groups[0].Rows[0].ProductName) + } +} + +// ── buildTransferPlan — farm_consolidation source ──────────────────────────── + +func TestBuildPlanFarmConsolidationCreatesOwnGroup(t *testing.T) { + opts := &commandOptions{RunID: "test-run"} + // Location 10 has 2 farm warehouses; Farm B (id=51) was chosen, Farm A + // (id=50) is OtherFarm whose stocks need consolidating. + farmMap := map[uint]farmWarehouseInfo{ + 10: { + LocationID: 10, + LocationName: "Cijangkar", + AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}, {ID: 51, Name: "Farm B"}}, + ChosenID: 51, + ChosenName: "Farm B", + OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, + }, + } + // Extra farm stock from Farm A + normal kandang stock. + stocks := []kandangStockRow{ + {LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 20, SourceWarehouseName: "Kandang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang}, + {LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 50, SourceWarehouseName: "Farm A", ProductID: 2, ProductName: "OVK B", OnHandQty: 60, LeftoverQty: 60, SourceType: sourceTypeFarmConsol}, + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + + if len(groups) != 2 { + t.Fatalf("expected 2 groups (one per source warehouse), got %d", len(groups)) + } + if len(reportRows) != 2 { + t.Fatalf("expected 2 report rows, got %d", len(reportRows)) + } + + groupsBySource := make(map[uint]*transferGroup, 2) + for i := range groups { + groupsBySource[groups[i].SourceWarehouseID] = &groups[i] + } + + kandangGroup := groupsBySource[20] + if kandangGroup == nil { + t.Fatal("expected a group with SourceWarehouseID=20") + } + if kandangGroup.SourceType != sourceTypeKandang { + t.Errorf("expected kandang_to_farm group, got %s", kandangGroup.SourceType) + } + if kandangGroup.FarmWarehouseID != 51 { + t.Errorf("expected kandang group to target Farm B (51), got %d", kandangGroup.FarmWarehouseID) + } + + consolGroup := groupsBySource[50] + if consolGroup == nil { + t.Fatal("expected a consolidation group with SourceWarehouseID=50") + } + if consolGroup.SourceType != sourceTypeFarmConsol { + t.Errorf("expected farm_consolidation group, got %s", consolGroup.SourceType) + } + if consolGroup.FarmWarehouseID != 51 { + t.Errorf("expected consolidation group to target Farm B (51), got %d", consolGroup.FarmWarehouseID) + } +} + +func TestBuildPlanFarmConsolidationSkipsZeroLeftover(t *testing.T) { + opts := &commandOptions{RunID: "test-run"} + farmMap := map[uint]farmWarehouseInfo{ + 10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50}, {ID: 51}}, ChosenID: 51, ChosenName: "Farm B", OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}}, + } + stocks := []kandangStockRow{ + {LocationID: 10, SourceWarehouseID: 50, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeFarmConsol}, + } + + reportRows, groups := buildTransferPlan(opts, farmMap, stocks) + if len(groups) != 0 { + t.Fatalf("expected no groups, got %d", len(groups)) + } if reportRows[0].Status != "skipped" { - t.Errorf("expected fully-allocated row to be skipped, got %s", reportRows[0].Status) - } - if !strings.Contains(reportRows[0].Reason, "fully_allocated") { - t.Errorf("unexpected reason: %s", reportRows[0].Reason) - } - if groups[0].Rows[0].Qty != 50 { - t.Errorf("expected leftover qty 50, got %.3f", groups[0].Rows[0].Qty) + t.Errorf("expected skipped, got %s", reportRows[0].Status) } } // ── executeApply ────────────────────────────────────────────────────────────── -func TestExecuteApplyCreatesTransfersWithTaggedReasonAndNotes(t *testing.T) { +func TestExecuteApplyTagsReasonAndNotesWithSourceType(t *testing.T) { date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC) - opts := &commandOptions{ - RunID: "product-farm-transfer-apply", - TransferDate: date, - ActorID: 99, - } + opts := &commandOptions{RunID: "run-apply", TransferDate: date, ActorID: 99} groups := []transferGroup{ { - LocationID: 10, + SourceType: sourceTypeKandang, LocationName: "Jamali", SourceWarehouseID: 20, - SourceWarehouseName: "Gudang K1", + SourceWarehouseName: "K1", FarmWarehouseID: 50, - FarmWarehouseName: "Gudang Farm Jamali", - Rows: []*transferReportRow{ - {ProductID: 1, ProductName: "Pakan A", Qty: 100}, - {ProductID: 2, ProductName: "OVK B", Qty: 40}, - }, + FarmWarehouseName: "Farm A", + Rows: []*transferReportRow{{ProductID: 1, ProductName: "Pakan A", Qty: 100}}, }, { - LocationID: 11, - LocationName: "Tamansari", - SourceWarehouseID: 30, - SourceWarehouseName: "Gudang K3", - FarmWarehouseID: 60, - FarmWarehouseName: "Gudang Farm Tamansari", - Rows: []*transferReportRow{ - {ProductID: 3, ProductName: "Pakan C", Qty: 200}, - }, - }, - } - - executor := &fakeSystemTransferExecutor{ - createResponses: []*entity.StockTransfer{ - {Id: 1001, MovementNumber: "PND-LTI-1001"}, - }, - createErrors: []error{ - nil, - errors.New("destination warehouse locked"), + SourceType: sourceTypeFarmConsol, + LocationName: "Cijangkar", + SourceWarehouseID: 60, + SourceWarehouseName: "Farm X", + FarmWarehouseID: 61, + FarmWarehouseName: "Farm Y", + Rows: []*transferReportRow{{ProductID: 2, ProductName: "OVK B", Qty: 40}}, }, } + executor := &fakeSystemTransferExecutor{} summary, err := executeApply(context.Background(), executor, opts, groups) if err != nil { t.Fatalf("unexpected error: %v", err) } - if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 { - t.Fatalf("unexpected group summary: %+v", summary) - } - if summary.RowsApplied != 2 || summary.RowsFailed != 1 { - t.Fatalf("unexpected row summary: %+v", summary) + if summary.GroupsApplied != 2 { + t.Errorf("expected 2 groups applied, got %d", summary.GroupsApplied) } if len(executor.createRequests) != 2 { t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests)) } - reason := executor.createRequests[0].TransferReason - if !strings.HasPrefix(reason, transferReasonPrefix) { - t.Errorf("reason must start with prefix %q, got: %s", transferReasonPrefix, reason) - } - if !strings.Contains(reason, "run_id=product-farm-transfer-apply") { - t.Errorf("reason must contain run_id, got: %s", reason) - } - if !strings.Contains(reason, "location=Jamali") { - t.Errorf("reason must contain location, got: %s", reason) - } - if !strings.Contains(reason, "transfer_date=2026-04-24") { - t.Errorf("reason must contain transfer_date, got: %s", reason) + // Both requests must carry the run_id in the reason for rollback to work. + for i, req := range executor.createRequests { + if !strings.Contains(req.TransferReason, "run_id=run-apply") { + t.Errorf("request %d reason missing run_id: %s", i, req.TransferReason) + } } - notes := executor.createRequests[0].StockLogNotes - if !strings.Contains(notes, "[auto] leftover stock transfer from kandang to farm") { - t.Errorf("stock log notes must be human-readable, got: %s", notes) + // Notes for farm_consolidation should be distinct from kandang_to_farm. + if !strings.Contains(executor.createRequests[0].StockLogNotes, "kandang to farm") { + t.Errorf("kandang group notes should say 'kandang to farm', got: %s", executor.createRequests[0].StockLogNotes) } - if !strings.Contains(notes, "Jamali") { - t.Errorf("stock log notes must contain location name, got: %s", notes) + if !strings.Contains(executor.createRequests[1].StockLogNotes, "consolidation") { + t.Errorf("consolidation group notes should say 'consolidation', got: %s", executor.createRequests[1].StockLogNotes) + } +} + +func TestExecuteApplyCreatesTransferWithCorrectProductsAndRecordsTransferID(t *testing.T) { + date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC) + opts := &commandOptions{RunID: "run-1", TransferDate: date, ActorID: 1} + + row1 := &transferReportRow{ProductID: 1, ProductName: "Pakan A", Qty: 100} + row2 := &transferReportRow{ProductID: 2, ProductName: "OVK B", Qty: 40} + groups := []transferGroup{ + { + SourceType: sourceTypeKandang, SourceWarehouseID: 20, FarmWarehouseID: 50, + Rows: []*transferReportRow{row1, row2}, + }, } - if executor.createRequests[0].MovementNumber != "" { - t.Errorf("movement number should be empty so the service generates one, got: %q", executor.createRequests[0].MovementNumber) + executor := &fakeSystemTransferExecutor{ + createResponses: []*entity.StockTransfer{{Id: 1001, MovementNumber: "PND-LTI-1001"}}, } - if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" { - t.Errorf("first group rows must be applied: %+v", groups[0].Rows) + + _, err := executeApply(context.Background(), executor, opts, groups) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if groups[1].Rows[0].Status != "failed" { - t.Errorf("second group row must be failed: %+v", groups[1].Rows[0]) + if row1.Status != "applied" || row2.Status != "applied" { + t.Errorf("both rows should be applied: %s, %s", row1.Status, row2.Status) } - if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 { - t.Errorf("first group must carry transfer id 1001") + if row1.TransferID == nil || *row1.TransferID != 1001 { + t.Errorf("expected transfer id 1001, got %+v", row1.TransferID) + } + if row1.MovementNumber == nil || *row1.MovementNumber != "PND-LTI-1001" { + t.Errorf("expected movement number PND-LTI-1001, got %+v", row1.MovementNumber) + } + + // Verify both products were included in the create request. + if len(executor.createRequests[0].Products) != 2 { + t.Errorf("expected 2 products in request, got %d", len(executor.createRequests[0].Products)) } } // ── executeRollback ─────────────────────────────────────────────────────────── -func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) { +func TestExecuteRollbackDeletesDescendingAndMarksStatuses(t *testing.T) { executor := &fakeSystemTransferExecutor{ - deleteErrors: map[uint]error{ - 200: errors.New("stock already consumed downstream"), - }, + deleteErrors: map[uint]error{200: errors.New("already consumed")}, } rows := []rollbackDetailRow{ {TransferID: 100, ProductName: "Pakan A"}, @@ -285,24 +510,17 @@ func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) { } err := executeRollback(context.Background(), executor, rows, 99) - if err == nil { - t.Fatal("expected rollback error for transfer 200") + if err == nil || !strings.Contains(err.Error(), "already consumed") { + t.Fatalf("expected error for transfer 200, got: %v", err) } - if !strings.Contains(err.Error(), "stock already consumed downstream") { - t.Fatalf("unexpected error: %v", err) - } - if len(executor.deletedTransferIDs) != 2 { - t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs)) - } - // descending: 200 before 100 if executor.deletedTransferIDs[0] != 200 || executor.deletedTransferIDs[1] != 100 { t.Fatalf("expected delete order [200 100], got %v", executor.deletedTransferIDs) } if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" { - t.Fatalf("transfer 100 rows must be rolled_back: %+v", rows) + t.Errorf("transfer 100 rows must be rolled_back: %+v", rows) } if rows[1].Status != "failed" { - t.Fatalf("transfer 200 row must be failed: %+v", rows[1]) + t.Errorf("transfer 200 row must be failed: %+v", rows[1]) } } @@ -315,14 +533,12 @@ func TestExecuteRollbackRequiresActorID(t *testing.T) { // ── buildTransferReason / buildRunReasonMatcher ─────────────────────────────── -func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) { +func TestBuildTransferReasonMatchesRunReasonMatcher(t *testing.T) { runID := "product-farm-transfer-20260424T120000.000000000Z" date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC) reason := buildTransferReason(runID, "Jamali", "Gudang K1", "Gudang Farm Jamali", date) - matcher := buildRunReasonMatcher(runID) - // Simulate a LIKE match: matcher ends with % so check prefix. - needle := strings.TrimSuffix(matcher, "%") + needle := strings.TrimSuffix(buildRunReasonMatcher(runID), "%") if !strings.HasPrefix(reason, needle) { t.Errorf("reason %q does not match matcher prefix %q", reason, needle) } @@ -330,17 +546,29 @@ func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) { func TestBuildTransferReasonSanitizesPipes(t *testing.T) { reason := buildTransferReason("run-1", "Lok|asi", "Gudang|K1", "Farm|WH", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) - // Pipes inside field values must be replaced so the structured format stays parseable. parts := strings.Split(reason, "|") - // Expect exactly 6 pipe-separated segments (prefix + 5 key=value pairs). + // prefix + 5 key=value segments = 6 parts if len(parts) != 6 { - t.Errorf("expected 6 pipe segments, got %d: %v", len(parts), parts) + t.Errorf("expected 6 pipe-separated segments, got %d: %v", len(parts), parts) + } +} + +func TestBuildStockLogNotesContainsSourceTypeHint(t *testing.T) { + date := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + kandangNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeKandang) + consolNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeFarmConsol) + + if !strings.Contains(kandangNote, "kandang to farm") { + t.Errorf("kandang note should mention 'kandang to farm': %s", kandangNote) + } + if !strings.Contains(consolNote, "consolidation") { + t.Errorf("consolidation note should mention 'consolidation': %s", consolNote) } } // ── summarizeReport ─────────────────────────────────────────────────────────── -func TestSummarizeReportCountsCorrectly(t *testing.T) { +func TestSummarizeReportCountsAllStatuses(t *testing.T) { rows := []transferReportRow{ {Status: "eligible"}, {Status: "applied"}, @@ -368,6 +596,6 @@ func TestSummarizeReportCountsCorrectly(t *testing.T) { t.Errorf("expected RowsFailed=1, got %d", s.RowsFailed) } if s.GroupsPlanned != 2 || s.GroupsApplied != 1 { - t.Errorf("unexpected group counts: planned=%d applied=%d", s.GroupsPlanned, s.GroupsApplied) + t.Errorf("unexpected group counts: %+v", s) } }