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" ) // 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 } // farmWarehouseInfo holds resolved farm-level warehouse data for a location. // If FarmCount > 1, the location is invalid (ambiguous target). type farmWarehouseInfo struct { LocationID uint LocationName string FarmCount int WarehouseID uint // only reliable when FarmCount == 1 WarehouseName string // only reliable when FarmCount == 1 } // kandangStockRow is the raw row loaded from the DB for a single product in a kandang warehouse. 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 } // transferReportRow is one row in the plan/apply report. type transferReportRow struct { RunID string `json:"run_id"` 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 a single stock transfer: one kandang warehouse → one farm warehouse, with N products. type transferGroup struct { 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 that belongs to a transfer 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 so it can be faked in tests. type systemTransferExecutor interface { CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error } 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 ──────────────────────────────────────────────────── farmMap, err := loadFarmWarehouseMap(ctx, db, opts) if err != nil { log.Fatalf("failed to load farm warehouse map: %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. if opts.Apply { if msgs := validateFarmWarehouseMap(farmMap); len(msgs) > 0 { for _, m := range msgs { fmt.Fprintln(os.Stderr, "ERROR:", m) } log.Fatalf("aborting: resolve multiple-farm-warehouse conflicts before applying") } } stockRows, err := loadKandangLeftoverStocks(ctx, db, opts) if err != nil { log.Fatalf("failed to load kandang leftover stocks: %v", err) } reportRows, groups := buildTransferPlan(opts, farmMap, stockRows) 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)") 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() 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.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") } } 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", ) } } 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 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). 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"` } query := db.WithContext(ctx). Table("warehouses kw"). Select(` 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 `). 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"). Group("kw.location_id, l.name") query = applyLocationFilter(query, opts, "kw") var rows []row if err := query.Scan(&rows).Error; err != nil { return nil, err } result := make(map[uint]farmWarehouseInfo, len(rows)) for _, r := range rows { result[r.LocationID] = farmWarehouseInfo{ LocationID: r.LocationID, LocationName: r.LocationName, FarmCount: r.FarmCount, WarehouseID: r.WarehouseID, WarehouseName: r.WarehouseName, } } 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 { var msgs []string for _, info := range m { if info.FarmCount > 1 { 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, )) } } sort.Strings(msgs) 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. 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") 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, }) } 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, 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 { 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" } } if farm.FarmCount == 1 { fwID := farm.WarehouseID fwName := farm.WarehouseName report.FarmWarehouseID = &fwID report.FarmWarehouseName = &fwName } reportRows = append(reportRows, report) if report.Status != "eligible" { continue } groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.WarehouseID) grp := groupMap[groupKey] if grp == nil { grp = &transferGroup{ LocationID: s.LocationID, LocationName: s.LocationName, SourceWarehouseID: s.SourceWarehouseID, SourceWarehouseName: s.SourceWarehouseName, FarmWarehouseID: farm.WarehouseID, FarmWarehouseName: farm.WarehouseName, } 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) 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 minimize downstream conflicts. 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 stock transfer rows that were created by the // given run_id (matched via the transfer reason field). 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 ─────────────────────────────────────────────────────────────────── 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 a structured string stored in stock_transfers.reason. // It is used as the rollback lookup key, so must remain stable and parseable. 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 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 { 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"), ) } // buildRunReasonMatcher returns a LIKE pattern that matches all transfers from // a specific run_id regardless of the other fields in the reason string. 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 reportRows from groups (which carry applied/failed // status) then appends skipped/error rows from the original slice. 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\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%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\n", row.RunID, 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() }