Merge branch 'development' into 'production'

Development

See merge request mbugroup/lti-api!473
This commit is contained in:
Adnan Zahir
2026-04-24 21:24:56 +07:00
3 changed files with 807 additions and 277 deletions
Binary file not shown.
+413 -111
View File
@@ -32,33 +32,63 @@ const (
transferReasonPrefix = "PRODUCT_FARM_TRANSFER" transferReasonPrefix = "PRODUCT_FARM_TRANSFER"
outputModeTable = "table" outputModeTable = "table"
outputModeJSON = "json" outputModeJSON = "json"
sourceTypeKandang = "kandang_to_farm"
sourceTypeFarmConsol = "farm_consolidation"
) )
// commandOptions holds all parsed CLI flags. // commandOptions holds all parsed CLI flags.
type commandOptions struct { type commandOptions struct {
Apply bool Apply bool
RollbackRunID string RollbackRunID string
LocationID uint LocationID uint
LocationName string LocationName string
TransferDate time.Time TransferDate time.Time
TransferDateRaw string TransferDateRaw string
AllLocations bool AllLocations bool
Output string FarmWarehouseOverrideID uint
ActorID uint SkipAmbiguous bool
RunID string // 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. // farmWarehouseEntry is a single LOKASI-type warehouse belonging to a location.
// If FarmCount > 1, the location is invalid (ambiguous target). 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 { type farmWarehouseInfo struct {
LocationID uint LocationID uint
LocationName string LocationName string
FarmCount int // AllFarm holds every LOKASI warehouse found for this location, sorted by id.
WarehouseID uint // only reliable when FarmCount == 1 AllFarm []farmWarehouseEntry
WarehouseName string // only reliable when FarmCount == 1 // 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 { type kandangStockRow struct {
LocationID uint LocationID uint
LocationName string LocationName string
@@ -68,13 +98,15 @@ type kandangStockRow struct {
ProductID uint ProductID uint
ProductName string ProductName string
OnHandQty float64 OnHandQty float64
AllocatedQty float64 // sum of ACTIVE CONSUME stock allocations AllocatedQty float64 // sum of ACTIVE CONSUME stock_allocations
LeftoverQty float64 // OnHandQty - AllocatedQty LeftoverQty float64 // OnHandQty - AllocatedQty
SourceType string // sourceTypeKandang or sourceTypeFarmConsol
} }
// transferReportRow is one row in the plan/apply report. // transferReportRow is one row in the plan/apply report.
type transferReportRow struct { type transferReportRow struct {
RunID string `json:"run_id"` RunID string `json:"run_id"`
SourceType string `json:"source_type"`
LocationID uint `json:"location_id"` LocationID uint `json:"location_id"`
LocationName string `json:"location_name"` LocationName string `json:"location_name"`
SourceWarehouseID uint `json:"source_warehouse_id"` SourceWarehouseID uint `json:"source_warehouse_id"`
@@ -93,8 +125,9 @@ type transferReportRow struct {
MovementNumber *string `json:"movement_number,omitempty"` 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 { type transferGroup struct {
SourceType string
LocationID uint LocationID uint
LocationName string LocationName string
SourceWarehouseID uint SourceWarehouseID uint
@@ -115,7 +148,7 @@ type applySummary struct {
GroupsApplied int `json:"groups_applied"` 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 { type rollbackDetailRow struct {
RunID string `json:"run_id"` RunID string `json:"run_id"`
TransferID uint64 `json:"transfer_id"` TransferID uint64 `json:"transfer_id"`
@@ -129,12 +162,14 @@ type rollbackDetailRow struct {
Reason string `json:"reason,omitempty"` 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 { type systemTransferExecutor interface {
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error) CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
} }
// ── Entry point ───────────────────────────────────────────────────────────────
func main() { func main() {
opts, err := parseFlags() opts, err := parseFlags()
if err != nil { if err != nil {
@@ -169,28 +204,48 @@ func main() {
} }
// ── Plan / Apply path ──────────────────────────────────────────────────── // ── Plan / Apply path ────────────────────────────────────────────────────
// Step 1: resolve which farm warehouse each location should use.
farmMap, err := loadFarmWarehouseMap(ctx, db, opts) farmMap, err := loadFarmWarehouseMap(ctx, db, opts)
if err != nil { if err != nil {
log.Fatalf("failed to load farm warehouse map: %v", err) 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 // In apply mode, warn about or hard-stop on unresolved locations.
// we are about to apply — the ambiguity is too risky to proceed.
if opts.Apply { if opts.Apply {
if msgs := validateFarmWarehouseMap(farmMap); len(msgs) > 0 { if msgs := listUnresolvedLocations(farmMap); len(msgs) > 0 {
for _, m := range msgs { 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 { if err != nil {
log.Fatalf("failed to load kandang leftover stocks: %v", err) 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 { if !opts.Apply {
renderTransferReport(opts.Output, reportRows, summarizeReport(reportRows, groups, 0)) 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.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name") 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.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.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.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
flag.Parse() 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.LocationName = strings.TrimSpace(opts.LocationName)
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID) opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output)) opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
@@ -233,6 +308,16 @@ func parseFlags() (*commandOptions, error) {
if opts.LocationID > 0 && opts.LocationName != "" { if opts.LocationID > 0 && opts.LocationName != "" {
return nil, errors.New("use either --location-id or --location-name, not both") 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.RollbackRunID != "" {
if opts.LocationID > 0 || opts.LocationName != "" { if opts.LocationID > 0 || opts.LocationName != "" {
return nil, errors.New("location filters are not supported with --rollback-run-id") return nil, errors.New("location filters are not supported with --rollback-run-id")
@@ -240,11 +325,14 @@ func parseFlags() (*commandOptions, error) {
if opts.TransferDateRaw != "" { if opts.TransferDateRaw != "" {
return nil, errors.New("--transfer-date is not used with --rollback-run-id") 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 { } else if opts.Apply {
if !opts.AllLocations && opts.LocationID == 0 && opts.LocationName == "" { if !opts.AllLocations && opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New( return nil, errors.New(
"apply mode requires --location-id, --location-name, or --all-locations for safety; " + "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 ──────────────────────────────────────────────────────────────── // ── DB loading ────────────────────────────────────────────────────────────────
// loadFarmWarehouseMap returns a map keyed by location_id. // loadFarmWarehouseMap returns one farmWarehouseInfo per location_id that has
// Each entry tells how many LOKASI-type warehouses the location has and which one // at least one KANDANG warehouse. It fetches every LOKASI warehouse for each
// to use (only safe when FarmCount == 1). // 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) { func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]farmWarehouseInfo, error) {
type row struct { type row struct {
LocationID uint `gorm:"column:location_id"` LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"` LocationName string `gorm:"column:location_name"`
FarmCount int `gorm:"column:farm_count"` FarmWHID *uint `gorm:"column:farm_wh_id"`
WarehouseID uint `gorm:"column:warehouse_id"` FarmWHName *string `gorm:"column:farm_wh_name"`
WarehouseName string `gorm:"column:warehouse_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). query := db.WithContext(ctx).
Table("warehouses kw"). Table("warehouses kw").
Select(` Select(`
DISTINCT
kw.location_id AS location_id, kw.location_id AS location_id,
l.name AS location_name, l.name AS location_name,
COUNT(fw.id) AS farm_count, fw.id AS farm_wh_id,
MIN(fw.id) AS warehouse_id, fw.name AS farm_wh_name
MIN(fw.name) AS warehouse_name
`). `).
Joins("JOIN locations l ON l.id = kw.location_id"). Joins("JOIN locations l ON l.id = kw.location_id").
Joins(`LEFT JOIN warehouses fw Joins(`LEFT JOIN warehouses fw
ON fw.location_id = kw.location_id ON fw.location_id = kw.location_id
AND UPPER(fw.type) = 'LOKASI' AND UPPER(fw.type) = 'LOKASI'
AND fw.deleted_at IS NULL`). AND fw.deleted_at IS NULL`).
Where("UPPER(kw.type) = 'KANDANG'"). Where("UPPER(kw.type) = 'KANDANG'").
Where("kw.deleted_at IS NULL"). 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") query = applyLocationFilter(query, opts, "kw")
@@ -336,28 +426,97 @@ func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions
return nil, err return nil, err
} }
result := make(map[uint]farmWarehouseInfo, len(rows)) result := make(map[uint]farmWarehouseInfo)
for _, r := range rows { for _, r := range rows {
result[r.LocationID] = farmWarehouseInfo{ info := result[r.LocationID]
LocationID: r.LocationID, info.LocationID = r.LocationID
LocationName: r.LocationName, info.LocationName = r.LocationName
FarmCount: r.FarmCount, if r.FarmWHID != nil && *r.FarmWHID > 0 {
WarehouseID: r.WarehouseID, // Guard against duplicates that DISTINCT might not eliminate across
WarehouseName: r.WarehouseName, // 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 return result, nil
} }
// validateFarmWarehouseMap returns one error message per location that has // applyFarmWarehouseOverride sets ChosenID/OtherFarm on every location in the
// more than one farm-level (LOKASI) warehouse. An empty slice means no issues. // map that still has multiple unresolved farm warehouses. overrideID must
func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string { // 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 var msgs []string
for _, info := range m { for _, info := range farmMap {
if info.FarmCount > 1 { 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( msgs = append(msgs, fmt.Sprintf(
"location %q (id=%d) has %d LOKASI warehouses; a unique farm warehouse is required — resolve the ambiguity before running", "location %q (id=%d) has %d LOKASI warehouses — rerun with --farm-warehouse-id=<id> to choose one: %s",
info.LocationName, info.LocationID, info.FarmCount, info.LocationName, info.LocationID, len(info.AllFarm), strings.Join(available, ", "),
)) ))
} }
} }
@@ -365,9 +524,9 @@ func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string {
return msgs return msgs
} }
// loadKandangLeftoverStocks loads every product_warehouse row for KANDANG-type // loadKandangLeftoverStocks returns all product_warehouse rows for KANDANG-type
// warehouses where on_hand_qty > 0, together with the sum of ACTIVE CONSUME // warehouses where on_hand_qty > 0, together with their active CONSUME
// stock_allocations so callers can compute the leftover qty. // allocations so the caller can derive leftover qty.
func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]kandangStockRow, error) { func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]kandangStockRow, error) {
type row struct { type row struct {
LocationID uint `gorm:"column:location_id"` 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") Order("l.name ASC, kw.name ASC, p.name ASC")
query = applyLocationFilter(query, opts, "kw") query = applyLocationFilter(query, opts, "kw")
query = applyFlagFilter(query, opts)
var rows []row var rows []row
if err := query.Scan(&rows).Error; err != nil { 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, OnHandQty: r.OnHandQty,
AllocatedQty: r.AllocatedQty, AllocatedQty: r.AllocatedQty,
LeftoverQty: r.OnHandQty - 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 return result, nil
@@ -448,6 +704,7 @@ func buildTransferPlan(
report := transferReportRow{ report := transferReportRow{
RunID: opts.RunID, RunID: opts.RunID,
SourceType: s.SourceType,
LocationID: s.LocationID, LocationID: s.LocationID,
LocationName: s.LocationName, LocationName: s.LocationName,
SourceWarehouseID: s.SourceWarehouseID, SourceWarehouseID: s.SourceWarehouseID,
@@ -461,26 +718,56 @@ func buildTransferPlan(
Status: "eligible", Status: "eligible",
} }
switch { switch s.SourceType {
case farm.FarmCount == 0: case sourceTypeFarmConsol:
report.Status = "skipped" // The destination is already resolved (OtherFarm is only populated
report.Reason = "missing_farm_warehouse" // when ChosenID is set). The only reason to skip is zero leftover.
case farm.FarmCount > 1: if s.LeftoverQty <= 0 {
// Treat as a hard error row so the operator knows to fix it. report.Status = "skipped"
report.Status = "error" if s.AllocatedQty > 0 {
report.Reason = fmt.Sprintf("multiple_farm_warehouses (found %d)", farm.FarmCount) report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty)
case s.LeftoverQty <= 0: } else {
report.Status = "skipped" report.Reason = "zero_on_hand_qty"
if s.AllocatedQty > 0 { }
report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty) }
} else { default: // sourceTypeKandang
report.Reason = "zero_on_hand_qty" 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=<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 { // Attach the chosen farm warehouse to the report row for visibility.
fwID := farm.WarehouseID if farm.isResolved() {
fwName := farm.WarehouseName fwID := farm.ChosenID
fwName := farm.ChosenName
report.FarmWarehouseID = &fwID report.FarmWarehouseID = &fwID
report.FarmWarehouseName = &fwName report.FarmWarehouseName = &fwName
} }
@@ -490,16 +777,17 @@ func buildTransferPlan(
continue continue
} }
groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.WarehouseID) groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.ChosenID)
grp := groupMap[groupKey] grp := groupMap[groupKey]
if grp == nil { if grp == nil {
grp = &transferGroup{ grp = &transferGroup{
SourceType: s.SourceType,
LocationID: s.LocationID, LocationID: s.LocationID,
LocationName: s.LocationName, LocationName: s.LocationName,
SourceWarehouseID: s.SourceWarehouseID, SourceWarehouseID: s.SourceWarehouseID,
SourceWarehouseName: s.SourceWarehouseName, SourceWarehouseName: s.SourceWarehouseName,
FarmWarehouseID: farm.WarehouseID, FarmWarehouseID: farm.ChosenID,
FarmWarehouseName: farm.WarehouseName, FarmWarehouseName: farm.ChosenName,
} }
groupMap[groupKey] = grp groupMap[groupKey] = grp
} }
@@ -544,7 +832,7 @@ func executeApply(
} }
reason := buildTransferReason(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate) 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{ transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
TransferReason: reason, TransferReason: reason,
@@ -598,7 +886,7 @@ func executeRollback(
for id := range byTransfer { for id := range byTransfer {
transferIDs = append(transferIDs, id) 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] }) sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
var firstErr error var firstErr error
@@ -620,8 +908,7 @@ func executeRollback(
return firstErr return firstErr
} }
// loadRollbackDetails finds all stock transfer rows that were created by the // loadRollbackDetails finds all transfer rows created by a given run_id.
// given run_id (matched via the transfer reason field).
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) { func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
type row struct { type row struct {
TransferID uint64 `gorm:"column:transfer_id"` 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). err := db.WithContext(ctx).
Table("stock_transfers st"). Table("stock_transfers st").
Select(` Select(`
st.id AS transfer_id, st.id AS transfer_id,
st.movement_number AS movement_number, st.movement_number AS movement_number,
COALESCE(loc.name, '') AS location_name, COALESCE(loc.name, '') AS location_name,
ws.name AS source_warehouse_name, ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name, wd.name AS farm_warehouse_name,
p.name AS product_name, p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS qty COALESCE(std.total_qty, std.usage_qty, 0) AS qty
`). `).
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id"). Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
Joins("JOIN warehouses wd ON wd.id = st.to_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 ─────────────────────────────────────────────────────────────────── // ── 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 { func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *gorm.DB {
if opts == nil { if opts == nil {
return q 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. // buildTransferReason produces the structured string stored in stock_transfers.reason.
// It is used as the rollback lookup key, so must remain stable and parseable. // This is the rollback lookup key so its format must remain stable.
func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string { func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string {
return fmt.Sprintf( return fmt.Sprintf(
"%s|run_id=%s|location=%s|src_warehouse=%s|farm_warehouse=%s|transfer_date=%s", "%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 // buildStockLogNotes produces a human-readable note for each stock_log entry.
// entry so operators can trace the origin of stock movements in the logs. func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time, sourceType string) string {
func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) 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( return fmt.Sprintf(
"[auto] leftover stock transfer from kandang to farm | run_id=%s | location=%s | from=%s | to=%s | date=%s", "[auto] %s | run_id=%s | location=%s | from=%s | to=%s | date=%s",
runID, kind, runID, locationName, srcWarehouse, farmWarehouse, date.Format("2006-01-02"),
locationName,
srcWarehouse,
farmWarehouse,
date.Format("2006-01-02"),
) )
} }
// buildRunReasonMatcher returns a LIKE pattern that matches all transfers from // buildRunReasonMatcher returns a SQL LIKE pattern matching all transfers from a run.
// a specific run_id regardless of the other fields in the reason string.
func buildRunReasonMatcher(runID string) string { func buildRunReasonMatcher(runID string) string {
return fmt.Sprintf("%s|run_id=%s|%%", transferReasonPrefix, strings.TrimSpace(runID)) return fmt.Sprintf("%s|run_id=%s|%%", transferReasonPrefix, strings.TrimSpace(runID))
} }
@@ -743,8 +1044,8 @@ func derefString(s *string) string {
return *s return *s
} }
// flattenGroups rebuilds reportRows from groups (which carry applied/failed // flattenGroups rebuilds the row list from groups (which carry applied/failed
// status) then appends skipped/error rows from the original slice. // status after apply) then appends skipped and error rows from the plan.
func flattenGroups(groups []transferGroup, fallback []transferReportRow) []transferReportRow { func flattenGroups(groups []transferGroup, fallback []transferReportRow) []transferReportRow {
if len(groups) == 0 { if len(groups) == 0 {
return fallback return fallback
@@ -807,7 +1108,7 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu
} }
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) 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 { for _, row := range rows {
transferID := "-" transferID := "-"
if row.TransferID != nil { if row.TransferID != nil {
@@ -818,8 +1119,9 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu
movementNumber = *row.MovementNumber movementNumber = *row.MovementNumber
} }
fmt.Fprintf(w, 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.RunID,
row.SourceType,
row.LocationName, row.LocationName,
row.SourceWarehouseName, row.SourceWarehouseName,
derefString(row.FarmWarehouseName), derefString(row.FarmWarehouseName),
+394 -166
View File
@@ -11,13 +11,8 @@ import (
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" 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 { type fakeSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer createResponses []*entity.StockTransfer
@@ -46,237 +41,467 @@ func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(_ context.Context, id
return nil return nil
} }
// ── validateFarmWarehouseMap ────────────────────────────────────────────────── // ── applyFarmWarehouseOverride ────────────────────────────────────────────────
func TestValidateFarmWarehouseMapReturnsMsgsForMultipleFarmWarehouses(t *testing.T) { func TestApplyOverrideResolvesMultiFarmLocation(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"}
farmMap := map[uint]farmWarehouseInfo{ 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{ 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: "K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
{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: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40, SourceType: sourceTypeKandang},
} }
reportRows, groups := buildTransferPlan(opts, farmMap, stocks) 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 { if groups[0].SourceType != sourceTypeKandang {
t.Fatalf("expected 1 transfer group, got %d", len(groups)) t.Errorf("expected group source type kandang_to_farm, got %s", groups[0].SourceType)
}
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])
} }
for _, row := range reportRows { for _, row := range reportRows {
if row.Status != "eligible" { if row.Status != "eligible" {
t.Errorf("expected eligible, got %s for %s", row.Status, row.ProductName) 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) { func TestBuildPlanSkipsMissingFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"} opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{ farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", FarmCount: 0}, 10: {LocationID: 10, LocationName: "Jamali", AllFarm: nil},
} }
stocks := []kandangStockRow{ 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) reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 { 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" { 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) { func TestBuildPlanErrorForUnresolvedMultiFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"} opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{ 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{ 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) reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 { 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" { 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") { 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) { func TestBuildPlanSkipsFullyAllocatedKandangStock(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"} opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{ 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{ stocks := []kandangStockRow{
// fully allocated {LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeKandang},
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0}, {LocationID: 10, SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50, SourceType: sourceTypeKandang},
// 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},
} }
reportRows, groups := buildTransferPlan(opts, farmMap, stocks) _, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 1 || len(groups[0].Rows) != 1 { if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected 1 group with 1 eligible row, got groups=%d", len(groups)) 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" { if reportRows[0].Status != "skipped" {
t.Errorf("expected fully-allocated row to be skipped, got %s", reportRows[0].Status) t.Errorf("expected 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)
} }
} }
// ── executeApply ────────────────────────────────────────────────────────────── // ── executeApply ──────────────────────────────────────────────────────────────
func TestExecuteApplyCreatesTransfersWithTaggedReasonAndNotes(t *testing.T) { func TestExecuteApplyTagsReasonAndNotesWithSourceType(t *testing.T) {
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC) date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
opts := &commandOptions{ opts := &commandOptions{RunID: "run-apply", TransferDate: date, ActorID: 99}
RunID: "product-farm-transfer-apply",
TransferDate: date,
ActorID: 99,
}
groups := []transferGroup{ groups := []transferGroup{
{ {
LocationID: 10, SourceType: sourceTypeKandang,
LocationName: "Jamali", LocationName: "Jamali",
SourceWarehouseID: 20, SourceWarehouseID: 20,
SourceWarehouseName: "Gudang K1", SourceWarehouseName: "K1",
FarmWarehouseID: 50, FarmWarehouseID: 50,
FarmWarehouseName: "Gudang Farm Jamali", FarmWarehouseName: "Farm A",
Rows: []*transferReportRow{ Rows: []*transferReportRow{{ProductID: 1, ProductName: "Pakan A", Qty: 100}},
{ProductID: 1, ProductName: "Pakan A", Qty: 100},
{ProductID: 2, ProductName: "OVK B", Qty: 40},
},
}, },
{ {
LocationID: 11, SourceType: sourceTypeFarmConsol,
LocationName: "Tamansari", LocationName: "Cijangkar",
SourceWarehouseID: 30, SourceWarehouseID: 60,
SourceWarehouseName: "Gudang K3", SourceWarehouseName: "Farm X",
FarmWarehouseID: 60, FarmWarehouseID: 61,
FarmWarehouseName: "Gudang Farm Tamansari", FarmWarehouseName: "Farm Y",
Rows: []*transferReportRow{ Rows: []*transferReportRow{{ProductID: 2, ProductName: "OVK B", Qty: 40}},
{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"),
}, },
} }
executor := &fakeSystemTransferExecutor{}
summary, err := executeApply(context.Background(), executor, opts, groups) summary, err := executeApply(context.Background(), executor, opts, groups)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 { if summary.GroupsApplied != 2 {
t.Fatalf("unexpected group summary: %+v", summary) t.Errorf("expected 2 groups applied, got %d", summary.GroupsApplied)
}
if summary.RowsApplied != 2 || summary.RowsFailed != 1 {
t.Fatalf("unexpected row summary: %+v", summary)
} }
if len(executor.createRequests) != 2 { if len(executor.createRequests) != 2 {
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests)) t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
} }
reason := executor.createRequests[0].TransferReason // Both requests must carry the run_id in the reason for rollback to work.
if !strings.HasPrefix(reason, transferReasonPrefix) { for i, req := range executor.createRequests {
t.Errorf("reason must start with prefix %q, got: %s", transferReasonPrefix, reason) if !strings.Contains(req.TransferReason, "run_id=run-apply") {
} t.Errorf("request %d reason missing run_id: %s", i, req.TransferReason)
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)
} }
notes := executor.createRequests[0].StockLogNotes // Notes for farm_consolidation should be distinct from kandang_to_farm.
if !strings.Contains(notes, "[auto] leftover stock transfer from kandang to farm") { if !strings.Contains(executor.createRequests[0].StockLogNotes, "kandang to farm") {
t.Errorf("stock log notes must be human-readable, got: %s", notes) t.Errorf("kandang group notes should say 'kandang to farm', got: %s", executor.createRequests[0].StockLogNotes)
} }
if !strings.Contains(notes, "Jamali") { if !strings.Contains(executor.createRequests[1].StockLogNotes, "consolidation") {
t.Errorf("stock log notes must contain location name, got: %s", notes) 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 != "" { executor := &fakeSystemTransferExecutor{
t.Errorf("movement number should be empty so the service generates one, got: %q", executor.createRequests[0].MovementNumber) 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" { if row1.Status != "applied" || row2.Status != "applied" {
t.Errorf("second group row must be failed: %+v", groups[1].Rows[0]) 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 { if row1.TransferID == nil || *row1.TransferID != 1001 {
t.Errorf("first group must carry transfer id 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 ─────────────────────────────────────────────────────────── // ── executeRollback ───────────────────────────────────────────────────────────
func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) { func TestExecuteRollbackDeletesDescendingAndMarksStatuses(t *testing.T) {
executor := &fakeSystemTransferExecutor{ executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{ deleteErrors: map[uint]error{200: errors.New("already consumed")},
200: errors.New("stock already consumed downstream"),
},
} }
rows := []rollbackDetailRow{ rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Pakan A"}, {TransferID: 100, ProductName: "Pakan A"},
@@ -285,24 +510,17 @@ func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) {
} }
err := executeRollback(context.Background(), executor, rows, 99) err := executeRollback(context.Background(), executor, rows, 99)
if err == nil { if err == nil || !strings.Contains(err.Error(), "already consumed") {
t.Fatal("expected rollback error for transfer 200") 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 { if executor.deletedTransferIDs[0] != 200 || executor.deletedTransferIDs[1] != 100 {
t.Fatalf("expected delete order [200 100], got %v", executor.deletedTransferIDs) t.Fatalf("expected delete order [200 100], got %v", executor.deletedTransferIDs)
} }
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" { 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" { 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 ─────────────────────────────── // ── buildTransferReason / buildRunReasonMatcher ───────────────────────────────
func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) { func TestBuildTransferReasonMatchesRunReasonMatcher(t *testing.T) {
runID := "product-farm-transfer-20260424T120000.000000000Z" runID := "product-farm-transfer-20260424T120000.000000000Z"
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC) date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
reason := buildTransferReason(runID, "Jamali", "Gudang K1", "Gudang Farm Jamali", date) reason := buildTransferReason(runID, "Jamali", "Gudang K1", "Gudang Farm Jamali", date)
matcher := buildRunReasonMatcher(runID) needle := strings.TrimSuffix(buildRunReasonMatcher(runID), "%")
// Simulate a LIKE match: matcher ends with % so check prefix.
needle := strings.TrimSuffix(matcher, "%")
if !strings.HasPrefix(reason, needle) { if !strings.HasPrefix(reason, needle) {
t.Errorf("reason %q does not match matcher prefix %q", 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) { 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)) 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, "|") 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 { 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 ─────────────────────────────────────────────────────────── // ── summarizeReport ───────────────────────────────────────────────────────────
func TestSummarizeReportCountsCorrectly(t *testing.T) { func TestSummarizeReportCountsAllStatuses(t *testing.T) {
rows := []transferReportRow{ rows := []transferReportRow{
{Status: "eligible"}, {Status: "eligible"},
{Status: "applied"}, {Status: "applied"},
@@ -368,6 +596,6 @@ func TestSummarizeReportCountsCorrectly(t *testing.T) {
t.Errorf("expected RowsFailed=1, got %d", s.RowsFailed) t.Errorf("expected RowsFailed=1, got %d", s.RowsFailed)
} }
if s.GroupsPlanned != 2 || s.GroupsApplied != 1 { 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)
} }
} }