Merge branch 'cmd/auto-transfer-products-to-farm' into 'development'

cmd: skip ambiguous farm then resolve double farm warehouses

See merge request mbugroup/lti-api!471
This commit is contained in:
Adnan Zahir
2026-04-24 20:18:59 +07:00
3 changed files with 749 additions and 277 deletions
Binary file not shown.
+382 -111
View File
@@ -32,33 +32,59 @@ const (
transferReasonPrefix = "PRODUCT_FARM_TRANSFER"
outputModeTable = "table"
outputModeJSON = "json"
sourceTypeKandang = "kandang_to_farm"
sourceTypeFarmConsol = "farm_consolidation"
)
// commandOptions holds all parsed CLI flags.
type commandOptions struct {
Apply bool
RollbackRunID string
LocationID uint
LocationName string
TransferDate time.Time
TransferDateRaw string
AllLocations bool
Output string
ActorID uint
RunID string
Apply bool
RollbackRunID string
LocationID uint
LocationName string
TransferDate time.Time
TransferDateRaw string
AllLocations bool
FarmWarehouseOverrideID uint
SkipAmbiguous bool
Output string
ActorID uint
RunID string
}
// farmWarehouseInfo holds resolved farm-level warehouse data for a location.
// If FarmCount > 1, the location is invalid (ambiguous target).
// farmWarehouseEntry is a single LOKASI-type warehouse belonging to a location.
type farmWarehouseEntry struct {
ID uint
Name string
}
// farmWarehouseInfo holds all LOKASI warehouses for a location plus the
// resolved target warehouse (ChosenID). When a location has exactly one
// LOKASI warehouse, ChosenID is set automatically. When multiple exist,
// ChosenID is only set after applying --farm-warehouse-id; until then it
// stays 0 and the location is flagged as an error.
type farmWarehouseInfo struct {
LocationID uint
LocationName string
FarmCount int
WarehouseID uint // only reliable when FarmCount == 1
WarehouseName string // only reliable when FarmCount == 1
LocationID uint
LocationName string
// AllFarm holds every LOKASI warehouse found for this location, sorted by id.
AllFarm []farmWarehouseEntry
// ChosenID is the resolved transfer destination (0 = unresolved ambiguity).
ChosenID uint
ChosenName string
// OtherFarm holds non-chosen LOKASI warehouses that must be consolidated
// into ChosenID. Populated only when --farm-warehouse-id resolves a
// multi-warehouse location.
OtherFarm []farmWarehouseEntry
}
// kandangStockRow is the raw row loaded from the DB for a single product in a kandang warehouse.
func (f farmWarehouseInfo) farmCount() int { return len(f.AllFarm) }
func (f farmWarehouseInfo) isResolved() bool { return f.ChosenID > 0 }
func (f farmWarehouseInfo) hasFarm() bool { return len(f.AllFarm) > 0 }
// kandangStockRow is a single product-warehouse row loaded from the DB.
// SourceType distinguishes ordinary kandang stocks from extra farm-warehouse
// stocks that need inter-farm consolidation.
type kandangStockRow struct {
LocationID uint
LocationName string
@@ -68,13 +94,15 @@ type kandangStockRow struct {
ProductID uint
ProductName string
OnHandQty float64
AllocatedQty float64 // sum of ACTIVE CONSUME stock allocations
AllocatedQty float64 // sum of ACTIVE CONSUME stock_allocations
LeftoverQty float64 // OnHandQty - AllocatedQty
SourceType string // sourceTypeKandang or sourceTypeFarmConsol
}
// transferReportRow is one row in the plan/apply report.
type transferReportRow struct {
RunID string `json:"run_id"`
SourceType string `json:"source_type"`
LocationID uint `json:"location_id"`
LocationName string `json:"location_name"`
SourceWarehouseID uint `json:"source_warehouse_id"`
@@ -93,8 +121,9 @@ type transferReportRow struct {
MovementNumber *string `json:"movement_number,omitempty"`
}
// transferGroup is a single stock transfer: one kandang warehouse → one farm warehouse, with N products.
// transferGroup is one stock transfer document: source → farm, N products.
type transferGroup struct {
SourceType string
LocationID uint
LocationName string
SourceWarehouseID uint
@@ -115,7 +144,7 @@ type applySummary struct {
GroupsApplied int `json:"groups_applied"`
}
// rollbackDetailRow is one product line that belongs to a transfer created by a previous run.
// rollbackDetailRow is one product line created by a previous run.
type rollbackDetailRow struct {
RunID string `json:"run_id"`
TransferID uint64 `json:"transfer_id"`
@@ -129,12 +158,14 @@ type rollbackDetailRow struct {
Reason string `json:"reason,omitempty"`
}
// systemTransferExecutor abstracts the transfer service so it can be faked in tests.
// systemTransferExecutor abstracts the transfer service for testability.
type systemTransferExecutor interface {
CreateSystemTransfer(ctx context.Context, req *transferSvc.SystemTransferRequest) (*entity.StockTransfer, error)
DeleteSystemTransfer(ctx context.Context, id uint, actorID uint) error
}
// ── Entry point ───────────────────────────────────────────────────────────────
func main() {
opts, err := parseFlags()
if err != nil {
@@ -169,28 +200,48 @@ func main() {
}
// ── Plan / Apply path ────────────────────────────────────────────────────
// Step 1: resolve which farm warehouse each location should use.
farmMap, err := loadFarmWarehouseMap(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load farm warehouse map: %v", err)
}
if err := applyFarmWarehouseOverride(farmMap, opts.FarmWarehouseOverrideID); err != nil {
log.Fatalf("invalid --farm-warehouse-id: %v", err)
}
// Abort early if any in-scope location has multiple farm warehouses and
// we are about to apply — the ambiguity is too risky to proceed.
// In apply mode, warn about or hard-stop on unresolved locations.
if opts.Apply {
if msgs := validateFarmWarehouseMap(farmMap); len(msgs) > 0 {
if msgs := listUnresolvedLocations(farmMap); len(msgs) > 0 {
for _, m := range msgs {
fmt.Fprintln(os.Stderr, "ERROR:", m)
if opts.SkipAmbiguous {
fmt.Fprintln(os.Stderr, "WARN (skipping):", m)
} else {
fmt.Fprintln(os.Stderr, "ERROR:", m)
}
}
if !opts.SkipAmbiguous {
log.Fatalf("aborting: use --farm-warehouse-id to choose the target warehouse for each location listed above, or pass --skip-ambiguous to skip them and process the rest")
}
log.Fatalf("aborting: resolve multiple-farm-warehouse conflicts before applying")
}
}
stockRows, err := loadKandangLeftoverStocks(ctx, db, opts)
// Step 2: load leftover stocks from kandang warehouses.
kandangStocks, err := loadKandangLeftoverStocks(ctx, db, opts)
if err != nil {
log.Fatalf("failed to load kandang leftover stocks: %v", err)
}
reportRows, groups := buildTransferPlan(opts, farmMap, stockRows)
// Step 3: load leftover stocks from extra farm warehouses that need
// consolidation into the chosen farm warehouse.
extraFarmStocks, err := loadExtraFarmLeftoverStocks(ctx, db, farmMap)
if err != nil {
log.Fatalf("failed to load extra farm leftover stocks: %v", err)
}
// Step 4: merge and plan.
allStocks := append(kandangStocks, extraFarmStocks...)
reportRows, groups := buildTransferPlan(opts, farmMap, allStocks)
if !opts.Apply {
renderTransferReport(opts.Output, reportRows, summarizeReport(reportRows, groups, 0))
@@ -215,7 +266,16 @@ func parseFlags() (*commandOptions, error) {
flag.UintVar(&opts.LocationID, "location-id", 0, "Filter by location id")
flag.StringVar(&opts.LocationName, "location-name", "", "Filter by exact location name")
flag.StringVar(&opts.TransferDateRaw, "transfer-date", "", "Transfer date in YYYY-MM-DD format (default: today in Asia/Jakarta)")
flag.BoolVar(&opts.AllLocations, "all-locations", false, "Allow apply without a location filter (transfers all locations)")
flag.BoolVar(&opts.AllLocations, "all-locations", false, "Allow apply without a location filter (transfers all locations at once)")
flag.BoolVar(&opts.SkipAmbiguous, "skip-ambiguous", false,
"When a location has multiple LOKASI warehouses and no --farm-warehouse-id is set, "+
"skip that location (status=skipped) instead of treating it as an error. "+
"Useful for an initial global run: unambiguous locations transfer immediately while "+
"ambiguous ones are left for a follow-up run with --farm-warehouse-id.")
flag.UintVar(&opts.FarmWarehouseOverrideID, "farm-warehouse-id", 0,
"When a location has multiple LOKASI warehouses, use this warehouse id as the chosen target. "+
"Stocks from the other LOKASI warehouses are also transferred to the chosen one. "+
"Requires --location-id or --location-name.")
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()
@@ -233,6 +293,16 @@ func parseFlags() (*commandOptions, error) {
if opts.LocationID > 0 && opts.LocationName != "" {
return nil, errors.New("use either --location-id or --location-name, not both")
}
if opts.FarmWarehouseOverrideID > 0 {
if opts.AllLocations {
return nil, errors.New("--farm-warehouse-id cannot be combined with --all-locations; specify --location-id or --location-name so the override targets the right location")
}
if opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New("--farm-warehouse-id requires --location-id or --location-name")
}
}
if opts.RollbackRunID != "" {
if opts.LocationID > 0 || opts.LocationName != "" {
return nil, errors.New("location filters are not supported with --rollback-run-id")
@@ -240,11 +310,14 @@ func parseFlags() (*commandOptions, error) {
if opts.TransferDateRaw != "" {
return nil, errors.New("--transfer-date is not used with --rollback-run-id")
}
if opts.FarmWarehouseOverrideID > 0 {
return nil, errors.New("--farm-warehouse-id is not used with --rollback-run-id")
}
} else if opts.Apply {
if !opts.AllLocations && opts.LocationID == 0 && opts.LocationName == "" {
return nil, errors.New(
"apply mode requires --location-id, --location-name, or --all-locations for safety; " +
"use --all-locations only when you have reviewed the dry-run output for all locations",
"use --all-locations only after reviewing the dry-run output for all locations",
)
}
}
@@ -299,35 +372,37 @@ func newSystemTransferService(db *gorm.DB) systemTransferExecutor {
// ── DB loading ────────────────────────────────────────────────────────────────
// loadFarmWarehouseMap returns a map keyed by location_id.
// Each entry tells how many LOKASI-type warehouses the location has and which one
// to use (only safe when FarmCount == 1).
// loadFarmWarehouseMap returns one farmWarehouseInfo per location_id that has
// at least one KANDANG warehouse. It fetches every LOKASI warehouse for each
// location as distinct rows and builds the list in Go, so there is no
// aggregation that could miscount farm warehouses.
func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions) (map[uint]farmWarehouseInfo, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
FarmCount int `gorm:"column:farm_count"`
WarehouseID uint `gorm:"column:warehouse_id"`
WarehouseName string `gorm:"column:warehouse_name"`
LocationID uint `gorm:"column:location_id"`
LocationName string `gorm:"column:location_name"`
FarmWHID *uint `gorm:"column:farm_wh_id"`
FarmWHName *string `gorm:"column:farm_wh_name"`
}
// DISTINCT on (location_id, farm_wh_id) so multiple KANDANG warehouses in
// the same location don't produce duplicate farm-warehouse rows.
query := db.WithContext(ctx).
Table("warehouses kw").
Select(`
DISTINCT
kw.location_id AS location_id,
l.name AS location_name,
COUNT(DISTINCT fw.id) AS farm_count,
MIN(fw.id) AS warehouse_id,
MIN(fw.name) AS warehouse_name
fw.id AS farm_wh_id,
fw.name AS farm_wh_name
`).
Joins("JOIN locations l ON l.id = kw.location_id").
Joins(`LEFT JOIN warehouses fw
ON fw.location_id = kw.location_id
AND UPPER(fw.type) = 'LOKASI'
AND fw.deleted_at IS NULL`).
ON fw.location_id = kw.location_id
AND UPPER(fw.type) = 'LOKASI'
AND fw.deleted_at IS NULL`).
Where("UPPER(kw.type) = 'KANDANG'").
Where("kw.deleted_at IS NULL").
Group("kw.location_id, l.name")
Order("kw.location_id ASC, fw.id ASC")
query = applyLocationFilter(query, opts, "kw")
@@ -336,28 +411,97 @@ func loadFarmWarehouseMap(ctx context.Context, db *gorm.DB, opts *commandOptions
return nil, err
}
result := make(map[uint]farmWarehouseInfo, len(rows))
result := make(map[uint]farmWarehouseInfo)
for _, r := range rows {
result[r.LocationID] = farmWarehouseInfo{
LocationID: r.LocationID,
LocationName: r.LocationName,
FarmCount: r.FarmCount,
WarehouseID: r.WarehouseID,
WarehouseName: r.WarehouseName,
info := result[r.LocationID]
info.LocationID = r.LocationID
info.LocationName = r.LocationName
if r.FarmWHID != nil && *r.FarmWHID > 0 {
// Guard against duplicates that DISTINCT might not eliminate across
// different location_id groupings due to Go map updates.
alreadySeen := false
for _, e := range info.AllFarm {
if e.ID == *r.FarmWHID {
alreadySeen = true
break
}
}
if !alreadySeen {
info.AllFarm = append(info.AllFarm, farmWarehouseEntry{
ID: *r.FarmWHID,
Name: derefString(r.FarmWHName),
})
}
}
result[r.LocationID] = info
}
// Automatically resolve locations that have exactly one farm warehouse.
for locID, info := range result {
if len(info.AllFarm) == 1 {
info.ChosenID = info.AllFarm[0].ID
info.ChosenName = info.AllFarm[0].Name
result[locID] = info
}
}
return result, nil
}
// validateFarmWarehouseMap returns one error message per location that has
// more than one farm-level (LOKASI) warehouse. An empty slice means no issues.
func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string {
// applyFarmWarehouseOverride sets ChosenID/OtherFarm on every location in the
// map that still has multiple unresolved farm warehouses. overrideID must
// appear in the location's AllFarm list; if it does not, an error is returned
// so the operator knows the ID is wrong before any transfer is attempted.
// Locations with 0 or 1 farm warehouses are left untouched.
func applyFarmWarehouseOverride(farmMap map[uint]farmWarehouseInfo, overrideID uint) error {
if overrideID == 0 {
return nil
}
for locID, info := range farmMap {
if len(info.AllFarm) <= 1 {
continue // no ambiguity; override is irrelevant for this location
}
found := false
others := make([]farmWarehouseEntry, 0, len(info.AllFarm)-1)
for _, fw := range info.AllFarm {
if fw.ID == overrideID {
info.ChosenID = fw.ID
info.ChosenName = fw.Name
found = true
} else {
others = append(others, fw)
}
}
if !found {
available := make([]string, 0, len(info.AllFarm))
for _, fw := range info.AllFarm {
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
}
return fmt.Errorf(
"warehouse id %d is not a LOKASI warehouse for location %q (id=%d)\n available farm warehouses: %s",
overrideID, info.LocationName, info.LocationID, strings.Join(available, ", "),
)
}
info.OtherFarm = others
farmMap[locID] = info
}
return nil
}
// listUnresolvedLocations returns one human-readable error message per location
// that still has multiple farm warehouses with no override chosen.
func listUnresolvedLocations(farmMap map[uint]farmWarehouseInfo) []string {
var msgs []string
for _, info := range m {
if info.FarmCount > 1 {
for _, info := range farmMap {
if len(info.AllFarm) > 1 && !info.isResolved() {
available := make([]string, 0, len(info.AllFarm))
for _, fw := range info.AllFarm {
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
}
msgs = append(msgs, fmt.Sprintf(
"location %q (id=%d) has %d LOKASI warehouses; a unique farm warehouse is required — resolve the ambiguity before running",
info.LocationName, info.LocationID, info.FarmCount,
"location %q (id=%d) has %d LOKASI warehouses — rerun with --farm-warehouse-id=<id> to choose one: %s",
info.LocationName, info.LocationID, len(info.AllFarm), strings.Join(available, ", "),
))
}
}
@@ -365,9 +509,9 @@ func validateFarmWarehouseMap(m map[uint]farmWarehouseInfo) []string {
return msgs
}
// loadKandangLeftoverStocks loads every product_warehouse row for KANDANG-type
// warehouses where on_hand_qty > 0, together with the sum of ACTIVE CONSUME
// stock_allocations so callers can compute the leftover qty.
// loadKandangLeftoverStocks returns all product_warehouse rows for KANDANG-type
// warehouses where on_hand_qty > 0, together with their active CONSUME
// allocations so the caller can derive leftover qty.
func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOptions) ([]kandangStockRow, error) {
type row struct {
LocationID uint `gorm:"column:location_id"`
@@ -428,6 +572,103 @@ func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOp
OnHandQty: r.OnHandQty,
AllocatedQty: r.AllocatedQty,
LeftoverQty: r.OnHandQty - r.AllocatedQty,
SourceType: sourceTypeKandang,
})
}
return result, nil
}
// loadExtraFarmLeftoverStocks loads leftover stocks from every OtherFarm
// warehouse in the map. These are LOKASI-type warehouses that will be
// consolidated into the chosen farm warehouse when --farm-warehouse-id is used.
func loadExtraFarmLeftoverStocks(ctx context.Context, db *gorm.DB, farmMap map[uint]farmWarehouseInfo) ([]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
err := 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").
Order("fw.name ASC, p.name ASC").
Scan(&rows).Error
if err != nil {
return nil, err
}
// Build a lookup: warehouseID → extraSource for location context.
srcByWH := make(map[uint]extraSource, len(sources))
for _, s := range sources {
srcByWH[s.WHID] = s
}
result := make([]kandangStockRow, 0, len(rows))
for _, r := range rows {
src := srcByWH[r.WarehouseID]
result = append(result, kandangStockRow{
LocationID: src.LocationID,
LocationName: src.LocationName,
SourceWarehouseID: r.WarehouseID,
SourceWarehouseName: r.WarehouseName,
ProductWarehouseID: r.ProductWarehouseID,
ProductID: r.ProductID,
ProductName: r.ProductName,
OnHandQty: r.OnHandQty,
AllocatedQty: r.AllocatedQty,
LeftoverQty: r.OnHandQty - r.AllocatedQty,
SourceType: sourceTypeFarmConsol,
})
}
return result, nil
@@ -448,6 +689,7 @@ func buildTransferPlan(
report := transferReportRow{
RunID: opts.RunID,
SourceType: s.SourceType,
LocationID: s.LocationID,
LocationName: s.LocationName,
SourceWarehouseID: s.SourceWarehouseID,
@@ -461,26 +703,56 @@ func buildTransferPlan(
Status: "eligible",
}
switch {
case farm.FarmCount == 0:
report.Status = "skipped"
report.Reason = "missing_farm_warehouse"
case farm.FarmCount > 1:
// Treat as a hard error row so the operator knows to fix it.
report.Status = "error"
report.Reason = fmt.Sprintf("multiple_farm_warehouses (found %d)", farm.FarmCount)
case s.LeftoverQty <= 0:
report.Status = "skipped"
if s.AllocatedQty > 0 {
report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty)
} else {
report.Reason = "zero_on_hand_qty"
switch s.SourceType {
case sourceTypeFarmConsol:
// The destination is already resolved (OtherFarm is only populated
// when ChosenID is set). The only reason to skip is zero leftover.
if s.LeftoverQty <= 0 {
report.Status = "skipped"
if s.AllocatedQty > 0 {
report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty)
} else {
report.Reason = "zero_on_hand_qty"
}
}
default: // sourceTypeKandang
switch {
case !farm.hasFarm():
report.Status = "skipped"
report.Reason = "missing_farm_warehouse"
case farm.farmCount() > 1 && !farm.isResolved():
// Multiple LOKASI warehouses and no override was given. List the
// available warehouse IDs so the operator knows what to pass to
// --farm-warehouse-id.
available := make([]string, 0, len(farm.AllFarm))
for _, fw := range farm.AllFarm {
available = append(available, fmt.Sprintf("%s (id=%d)", fw.Name, fw.ID))
}
hint := fmt.Sprintf(
"multiple_farm_warehouses — rerun with --farm-warehouse-id=<id> to choose one: %s",
strings.Join(available, " | "),
)
if opts.SkipAmbiguous {
report.Status = "skipped"
report.Reason = hint
} else {
report.Status = "error"
report.Reason = hint
}
case s.LeftoverQty <= 0:
report.Status = "skipped"
if s.AllocatedQty > 0 {
report.Reason = fmt.Sprintf("fully_allocated (on_hand=%.3f allocated=%.3f)", s.OnHandQty, s.AllocatedQty)
} else {
report.Reason = "zero_on_hand_qty"
}
}
}
if farm.FarmCount == 1 {
fwID := farm.WarehouseID
fwName := farm.WarehouseName
// Attach the chosen farm warehouse to the report row for visibility.
if farm.isResolved() {
fwID := farm.ChosenID
fwName := farm.ChosenName
report.FarmWarehouseID = &fwID
report.FarmWarehouseName = &fwName
}
@@ -490,16 +762,17 @@ func buildTransferPlan(
continue
}
groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.WarehouseID)
groupKey := fmt.Sprintf("%d:%d", s.SourceWarehouseID, farm.ChosenID)
grp := groupMap[groupKey]
if grp == nil {
grp = &transferGroup{
SourceType: s.SourceType,
LocationID: s.LocationID,
LocationName: s.LocationName,
SourceWarehouseID: s.SourceWarehouseID,
SourceWarehouseName: s.SourceWarehouseName,
FarmWarehouseID: farm.WarehouseID,
FarmWarehouseName: farm.WarehouseName,
FarmWarehouseID: farm.ChosenID,
FarmWarehouseName: farm.ChosenName,
}
groupMap[groupKey] = grp
}
@@ -544,7 +817,7 @@ func executeApply(
}
reason := buildTransferReason(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate)
notes := buildStockLogNotes(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate)
notes := buildStockLogNotes(opts.RunID, grp.LocationName, grp.SourceWarehouseName, grp.FarmWarehouseName, opts.TransferDate, grp.SourceType)
transfer, err := svc.CreateSystemTransfer(ctx, &transferSvc.SystemTransferRequest{
TransferReason: reason,
@@ -598,7 +871,7 @@ func executeRollback(
for id := range byTransfer {
transferIDs = append(transferIDs, id)
}
// Delete in descending order to minimize downstream conflicts.
// Delete in descending order to unwind downstream dependencies first.
sort.Slice(transferIDs, func(i, j int) bool { return transferIDs[i] > transferIDs[j] })
var firstErr error
@@ -620,8 +893,7 @@ func executeRollback(
return firstErr
}
// loadRollbackDetails finds all stock transfer rows that were created by the
// given run_id (matched via the transfer reason field).
// loadRollbackDetails finds all transfer rows created by a given run_id.
func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]rollbackDetailRow, error) {
type row struct {
TransferID uint64 `gorm:"column:transfer_id"`
@@ -638,13 +910,13 @@ func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]roll
err := db.WithContext(ctx).
Table("stock_transfers st").
Select(`
st.id AS transfer_id,
st.movement_number AS movement_number,
COALESCE(loc.name, '') AS location_name,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
st.id AS transfer_id,
st.movement_number AS movement_number,
COALESCE(loc.name, '') AS location_name,
ws.name AS source_warehouse_name,
wd.name AS farm_warehouse_name,
p.name AS product_name,
COALESCE(std.total_qty, std.usage_qty, 0) AS qty
`).
Joins("JOIN warehouses ws ON ws.id = st.from_warehouse_id").
Joins("JOIN warehouses wd ON wd.id = st.to_warehouse_id").
@@ -691,8 +963,8 @@ func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *g
}
}
// buildTransferReason produces a structured string stored in stock_transfers.reason.
// It is used as the rollback lookup key, so must remain stable and parseable.
// buildTransferReason produces the structured string stored in stock_transfers.reason.
// This is the rollback lookup key so its format must remain stable.
func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string {
return fmt.Sprintf(
"%s|run_id=%s|location=%s|src_warehouse=%s|farm_warehouse=%s|transfer_date=%s",
@@ -705,21 +977,19 @@ func buildTransferReason(runID, locationName, srcWarehouse, farmWarehouse string
)
}
// buildStockLogNotes produces a human-readable note attached to each stock log
// entry so operators can trace the origin of stock movements in the logs.
func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time) string {
// buildStockLogNotes produces a human-readable note for each stock_log entry.
func buildStockLogNotes(runID, locationName, srcWarehouse, farmWarehouse string, date time.Time, sourceType string) string {
kind := "leftover stock transfer from kandang to farm"
if sourceType == sourceTypeFarmConsol {
kind = "farm warehouse consolidation (non-primary farm to chosen farm)"
}
return fmt.Sprintf(
"[auto] leftover stock transfer from kandang to farm | run_id=%s | location=%s | from=%s | to=%s | date=%s",
runID,
locationName,
srcWarehouse,
farmWarehouse,
date.Format("2006-01-02"),
"[auto] %s | run_id=%s | location=%s | from=%s | to=%s | date=%s",
kind, runID, locationName, srcWarehouse, farmWarehouse, date.Format("2006-01-02"),
)
}
// buildRunReasonMatcher returns a LIKE pattern that matches all transfers from
// a specific run_id regardless of the other fields in the reason string.
// buildRunReasonMatcher returns a SQL LIKE pattern matching all transfers from a run.
func buildRunReasonMatcher(runID string) string {
return fmt.Sprintf("%s|run_id=%s|%%", transferReasonPrefix, strings.TrimSpace(runID))
}
@@ -743,8 +1013,8 @@ func derefString(s *string) string {
return *s
}
// flattenGroups rebuilds reportRows from groups (which carry applied/failed
// status) then appends skipped/error rows from the original slice.
// flattenGroups rebuilds the row list from groups (which carry applied/failed
// status after apply) then appends skipped and error rows from the plan.
func flattenGroups(groups []transferGroup, fallback []transferReportRow) []transferReportRow {
if len(groups) == 0 {
return fallback
@@ -807,7 +1077,7 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "RUN_ID\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tON_HAND\tALLOCATED\tLEFTOVER\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
fmt.Fprintln(w, "RUN_ID\tSOURCE_TYPE\tLOCATION\tSOURCE_WAREHOUSE\tFARM_WAREHOUSE\tPRODUCT\tON_HAND\tALLOCATED\tLEFTOVER\tSTATUS\tREASON\tTRANSFER_ID\tMOVEMENT_NUMBER")
for _, row := range rows {
transferID := "-"
if row.TransferID != nil {
@@ -818,8 +1088,9 @@ func renderTransferReport(mode string, rows []transferReportRow, summary applySu
movementNumber = *row.MovementNumber
}
fmt.Fprintf(w,
"%s\t%s\t%s\t%s\t%s\t%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\n",
"%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%.3f\t%.3f\t%s\t%s\t%s\t%s\n",
row.RunID,
row.SourceType,
row.LocationName,
row.SourceWarehouseName,
derefString(row.FarmWarehouseName),
+367 -166
View File
@@ -11,13 +11,8 @@ import (
transferSvc "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services"
)
// ── Helpers ───────────────────────────────────────────────────────────────────
// ── Fake executor ─────────────────────────────────────────────────────────────
func ptrUint(v uint) *uint { return &v }
func ptrStr(s string) *string { return &s }
func ptrUint64(v uint64) *uint64 { return &v }
// fakeSystemTransferExecutor records calls and returns pre-configured responses.
type fakeSystemTransferExecutor struct {
createRequests []*transferSvc.SystemTransferRequest
createResponses []*entity.StockTransfer
@@ -46,237 +41,440 @@ func (f *fakeSystemTransferExecutor) DeleteSystemTransfer(_ context.Context, id
return nil
}
// ── validateFarmWarehouseMap ──────────────────────────────────────────────────
// ── applyFarmWarehouseOverride ────────────────────────────────────────────────
func TestValidateFarmWarehouseMapReturnsMsgsForMultipleFarmWarehouses(t *testing.T) {
m := map[uint]farmWarehouseInfo{
1: {LocationID: 1, LocationName: "Jamali", FarmCount: 1},
2: {LocationID: 2, LocationName: "Cijangkar", FarmCount: 3},
3: {LocationID: 3, LocationName: "Tamansari", FarmCount: 2},
}
msgs := validateFarmWarehouseMap(m)
if len(msgs) != 2 {
t.Fatalf("expected 2 error messages, got %d: %v", len(msgs), msgs)
}
for _, msg := range msgs {
if !strings.Contains(msg, "LOKASI warehouses") {
t.Errorf("expected message to mention LOKASI warehouses, got: %s", msg)
}
}
}
func TestValidateFarmWarehouseMapNoErrorsWhenAllUnique(t *testing.T) {
m := map[uint]farmWarehouseInfo{
1: {LocationID: 1, LocationName: "Jamali", FarmCount: 1},
2: {LocationID: 2, LocationName: "Cijangkar", FarmCount: 0},
}
if msgs := validateFarmWarehouseMap(m); len(msgs) != 0 {
t.Fatalf("expected no messages, got: %v", msgs)
}
}
// ── buildTransferPlan ─────────────────────────────────────────────────────────
func TestBuildTransferPlanEligibleRowsGroupedByWarehousePair(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"}
func TestApplyOverrideResolvesMultiFarmLocation(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"},
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
if err := applyFarmWarehouseOverride(farmMap, 51); err != nil {
t.Fatalf("unexpected error: %v", err)
}
info := farmMap[10]
if info.ChosenID != 51 {
t.Errorf("expected ChosenID=51, got %d", info.ChosenID)
}
if info.ChosenName != "Farm B" {
t.Errorf("expected ChosenName=Farm B, got %s", info.ChosenName)
}
if len(info.OtherFarm) != 1 || info.OtherFarm[0].ID != 50 {
t.Errorf("expected OtherFarm=[Farm A], got %+v", info.OtherFarm)
}
}
func TestApplyOverrideErrorsWhenIDNotInAllFarm(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
err := applyFarmWarehouseOverride(farmMap, 99)
if err == nil {
t.Fatal("expected error for unknown warehouse id, got nil")
}
if !strings.Contains(err.Error(), "99") {
t.Errorf("error should mention the invalid id, got: %v", err)
}
if !strings.Contains(err.Error(), "Farm A") || !strings.Contains(err.Error(), "Farm B") {
t.Errorf("error should list available warehouses, got: %v", err)
}
}
func TestApplyOverrideIgnoresSingleFarmLocations(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Jamali",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
ChosenID: 50,
ChosenName: "Farm A",
},
}
// Override ID 50 is present, but there is only 1 farm; the function should
// not touch this location (no OtherFarm to populate).
if err := applyFarmWarehouseOverride(farmMap, 50); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(farmMap[10].OtherFarm) != 0 {
t.Errorf("expected no OtherFarm for single-farm location, got %+v", farmMap[10].OtherFarm)
}
}
func TestApplyOverrideNoopWhenZero(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{
{ID: 50, Name: "Farm A"},
{ID: 51, Name: "Farm B"},
},
},
}
if err := applyFarmWarehouseOverride(farmMap, 0); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if farmMap[10].ChosenID != 0 {
t.Errorf("expected ChosenID unchanged (0), got %d", farmMap[10].ChosenID)
}
}
// ── listUnresolvedLocations ───────────────────────────────────────────────────
func TestListUnresolvedLocationsReturnsOnlyAmbiguous(t *testing.T) {
farmMap := map[uint]farmWarehouseInfo{
1: {LocationID: 1, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50},
2: {
LocationID: 2,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
3: {LocationID: 3, LocationName: "Tamansari", AllFarm: nil}, // no farm at all, not an error here
}
msgs := listUnresolvedLocations(farmMap)
if len(msgs) != 1 {
t.Fatalf("expected 1 unresolved message, got %d: %v", len(msgs), msgs)
}
if !strings.Contains(msgs[0], "Cijangkar") {
t.Errorf("message should name the ambiguous location, got: %s", msgs[0])
}
if !strings.Contains(msgs[0], "Farm X") || !strings.Contains(msgs[0], "Farm Y") {
t.Errorf("message should list available warehouses, got: %s", msgs[0])
}
}
// ── buildTransferPlan — kandang source ───────────────────────────────────────
func TestBuildPlanKandangEligibleGroupedByWarehousePair(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductWarehouseID: 101, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 0, LeftoverQty: 100},
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductWarehouseID: 102, ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40},
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "K1", ProductID: 2, ProductName: "OVK B", OnHandQty: 50, AllocatedQty: 10, LeftoverQty: 40, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(reportRows) != 2 {
t.Fatalf("expected 2 report rows, got %d", len(reportRows))
if len(groups) != 1 || len(groups[0].Rows) != 2 {
t.Fatalf("expected 1 group with 2 rows, got %d groups", len(groups))
}
if len(groups) != 1 {
t.Fatalf("expected 1 transfer group, got %d", len(groups))
}
if len(groups[0].Rows) != 2 {
t.Fatalf("expected 2 products in group, got %d", len(groups[0].Rows))
}
if reportRows[1].AllocatedQty != 10 || reportRows[1].Qty != 40 {
t.Errorf("unexpected allocated/leftover qty for OVK B: %+v", reportRows[1])
if groups[0].SourceType != sourceTypeKandang {
t.Errorf("expected group source type kandang_to_farm, got %s", groups[0].SourceType)
}
for _, row := range reportRows {
if row.Status != "eligible" {
t.Errorf("expected eligible, got %s for %s", row.Status, row.ProductName)
}
}
if reportRows[1].Qty != 40 {
t.Errorf("expected leftover qty 40 for OVK B, got %.3f", reportRows[1].Qty)
}
}
func TestBuildTransferPlanSkipsMissingFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"}
func TestBuildPlanSkipsMissingFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", FarmCount: 0},
10: {LocationID: 10, LocationName: "Jamali", AllFarm: nil},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, SourceWarehouseName: "Gudang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100},
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no transfer groups, got %d", len(groups))
t.Fatalf("expected no groups, got %d", len(groups))
}
if reportRows[0].Status != "skipped" || reportRows[0].Reason != "missing_farm_warehouse" {
t.Errorf("unexpected status/reason: %s / %s", reportRows[0].Status, reportRows[0].Reason)
t.Errorf("unexpected: %s / %s", reportRows[0].Status, reportRows[0].Reason)
}
}
func TestBuildTransferPlanMarksErrorForMultipleFarmWarehouses(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"}
func TestBuildPlanErrorForUnresolvedMultiFarmWarehouse(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Cijangkar", FarmCount: 2},
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, SourceWarehouseName: "Gudang K2", ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200},
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no transfer groups, got %d", len(groups))
t.Fatalf("expected no groups, got %d", len(groups))
}
if reportRows[0].Status != "error" {
t.Errorf("expected error status for multiple farm warehouses, got %s", reportRows[0].Status)
t.Errorf("expected error status, got %s", reportRows[0].Status)
}
if !strings.Contains(reportRows[0].Reason, "multiple_farm_warehouses") {
t.Errorf("unexpected reason: %s", reportRows[0].Reason)
t.Errorf("reason should mention multiple_farm_warehouses, got: %s", reportRows[0].Reason)
}
// The error message must list the available warehouses so the operator knows
// which --farm-warehouse-id to use.
if !strings.Contains(reportRows[0].Reason, "Farm X") || !strings.Contains(reportRows[0].Reason, "Farm Y") {
t.Errorf("reason should list available warehouses, got: %s", reportRows[0].Reason)
}
}
func TestBuildTransferPlanSkipsFullyAllocatedStock(t *testing.T) {
opts := &commandOptions{RunID: "product-farm-transfer-test"}
func TestBuildPlanSkipsFullyAllocatedKandangStock(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, LocationName: "Jamali", FarmCount: 1, WarehouseID: 50, WarehouseName: "Gudang Farm Jamali"},
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
stocks := []kandangStockRow{
// fully allocated
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0},
// partially allocated, should be eligible with leftover qty
{LocationID: 10, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50},
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeKandang},
{LocationID: 10, SourceWarehouseID: 20, ProductID: 2, ProductName: "OVK B", OnHandQty: 80, AllocatedQty: 30, LeftoverQty: 50, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
_, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected 1 group with 1 eligible row, got groups=%d", len(groups))
}
if groups[0].Rows[0].ProductName != "OVK B" {
t.Errorf("expected only OVK B to be eligible, got %s", groups[0].Rows[0].ProductName)
}
}
func TestBuildPlanSkipAmbiguousDowngradesErrorToSkipped(t *testing.T) {
opts := &commandOptions{RunID: "test-run", SkipAmbiguous: true}
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 60, Name: "Farm X"}, {ID: 61, Name: "Farm Y"}},
// ChosenID = 0: unresolved
},
11: {
LocationID: 11,
LocationName: "Jamali",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
ChosenID: 50,
ChosenName: "Farm A",
},
}
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 21, ProductID: 3, ProductName: "Pakan C", OnHandQty: 200, LeftoverQty: 200, SourceType: sourceTypeKandang},
{LocationID: 11, LocationName: "Jamali", SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
// Ambiguous location must be skipped, not error.
ambiguous := reportRows[0]
if ambiguous.LocationName != "Cijangkar" {
t.Fatalf("expected first row to be Cijangkar, got %s", ambiguous.LocationName)
}
if ambiguous.Status != "skipped" {
t.Errorf("expected skipped with --skip-ambiguous, got %s", ambiguous.Status)
}
if !strings.Contains(ambiguous.Reason, "multiple_farm_warehouses") {
t.Errorf("reason should still explain the cause, got: %s", ambiguous.Reason)
}
// Unambiguous location must still be eligible and grouped.
if len(groups) != 1 || groups[0].LocationName != "Jamali" {
t.Errorf("expected 1 group for Jamali, got %d groups", len(groups))
}
}
// ── buildTransferPlan — farm_consolidation source ────────────────────────────
func TestBuildPlanFarmConsolidationCreatesOwnGroup(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
// Location 10 has 2 farm warehouses; Farm B (id=51) was chosen, Farm A
// (id=50) is OtherFarm whose stocks need consolidating.
farmMap := map[uint]farmWarehouseInfo{
10: {
LocationID: 10,
LocationName: "Cijangkar",
AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}, {ID: 51, Name: "Farm B"}},
ChosenID: 51,
ChosenName: "Farm B",
OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}},
},
}
// Extra farm stock from Farm A + normal kandang stock.
stocks := []kandangStockRow{
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 20, SourceWarehouseName: "Kandang K1", ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
{LocationID: 10, LocationName: "Cijangkar", SourceWarehouseID: 50, SourceWarehouseName: "Farm A", ProductID: 2, ProductName: "OVK B", OnHandQty: 60, LeftoverQty: 60, SourceType: sourceTypeFarmConsol},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 2 {
t.Fatalf("expected 2 groups (one per source warehouse), got %d", len(groups))
}
if len(reportRows) != 2 {
t.Fatalf("expected 2 report rows, got %d", len(reportRows))
}
groupsBySource := make(map[uint]*transferGroup, 2)
for i := range groups {
groupsBySource[groups[i].SourceWarehouseID] = &groups[i]
}
kandangGroup := groupsBySource[20]
if kandangGroup == nil {
t.Fatal("expected a group with SourceWarehouseID=20")
}
if kandangGroup.SourceType != sourceTypeKandang {
t.Errorf("expected kandang_to_farm group, got %s", kandangGroup.SourceType)
}
if kandangGroup.FarmWarehouseID != 51 {
t.Errorf("expected kandang group to target Farm B (51), got %d", kandangGroup.FarmWarehouseID)
}
consolGroup := groupsBySource[50]
if consolGroup == nil {
t.Fatal("expected a consolidation group with SourceWarehouseID=50")
}
if consolGroup.SourceType != sourceTypeFarmConsol {
t.Errorf("expected farm_consolidation group, got %s", consolGroup.SourceType)
}
if consolGroup.FarmWarehouseID != 51 {
t.Errorf("expected consolidation group to target Farm B (51), got %d", consolGroup.FarmWarehouseID)
}
}
func TestBuildPlanFarmConsolidationSkipsZeroLeftover(t *testing.T) {
opts := &commandOptions{RunID: "test-run"}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50}, {ID: 51}}, ChosenID: 51, ChosenName: "Farm B", OtherFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}},
}
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 50, ProductID: 1, ProductName: "Pakan A", OnHandQty: 100, AllocatedQty: 100, LeftoverQty: 0, SourceType: sourceTypeFarmConsol},
}
reportRows, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 0 {
t.Fatalf("expected no groups, got %d", len(groups))
}
if reportRows[0].Status != "skipped" {
t.Errorf("expected fully-allocated row to be skipped, got %s", reportRows[0].Status)
}
if !strings.Contains(reportRows[0].Reason, "fully_allocated") {
t.Errorf("unexpected reason: %s", reportRows[0].Reason)
}
if groups[0].Rows[0].Qty != 50 {
t.Errorf("expected leftover qty 50, got %.3f", groups[0].Rows[0].Qty)
t.Errorf("expected skipped, got %s", reportRows[0].Status)
}
}
// ── executeApply ──────────────────────────────────────────────────────────────
func TestExecuteApplyCreatesTransfersWithTaggedReasonAndNotes(t *testing.T) {
func TestExecuteApplyTagsReasonAndNotesWithSourceType(t *testing.T) {
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
opts := &commandOptions{
RunID: "product-farm-transfer-apply",
TransferDate: date,
ActorID: 99,
}
opts := &commandOptions{RunID: "run-apply", TransferDate: date, ActorID: 99}
groups := []transferGroup{
{
LocationID: 10,
SourceType: sourceTypeKandang,
LocationName: "Jamali",
SourceWarehouseID: 20,
SourceWarehouseName: "Gudang K1",
SourceWarehouseName: "K1",
FarmWarehouseID: 50,
FarmWarehouseName: "Gudang Farm Jamali",
Rows: []*transferReportRow{
{ProductID: 1, ProductName: "Pakan A", Qty: 100},
{ProductID: 2, ProductName: "OVK B", Qty: 40},
},
FarmWarehouseName: "Farm A",
Rows: []*transferReportRow{{ProductID: 1, ProductName: "Pakan A", Qty: 100}},
},
{
LocationID: 11,
LocationName: "Tamansari",
SourceWarehouseID: 30,
SourceWarehouseName: "Gudang K3",
FarmWarehouseID: 60,
FarmWarehouseName: "Gudang Farm Tamansari",
Rows: []*transferReportRow{
{ProductID: 3, ProductName: "Pakan C", Qty: 200},
},
},
}
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{
{Id: 1001, MovementNumber: "PND-LTI-1001"},
},
createErrors: []error{
nil,
errors.New("destination warehouse locked"),
SourceType: sourceTypeFarmConsol,
LocationName: "Cijangkar",
SourceWarehouseID: 60,
SourceWarehouseName: "Farm X",
FarmWarehouseID: 61,
FarmWarehouseName: "Farm Y",
Rows: []*transferReportRow{{ProductID: 2, ProductName: "OVK B", Qty: 40}},
},
}
executor := &fakeSystemTransferExecutor{}
summary, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if summary.GroupsPlanned != 2 || summary.GroupsApplied != 1 {
t.Fatalf("unexpected group summary: %+v", summary)
}
if summary.RowsApplied != 2 || summary.RowsFailed != 1 {
t.Fatalf("unexpected row summary: %+v", summary)
if summary.GroupsApplied != 2 {
t.Errorf("expected 2 groups applied, got %d", summary.GroupsApplied)
}
if len(executor.createRequests) != 2 {
t.Fatalf("expected 2 create requests, got %d", len(executor.createRequests))
}
reason := executor.createRequests[0].TransferReason
if !strings.HasPrefix(reason, transferReasonPrefix) {
t.Errorf("reason must start with prefix %q, got: %s", transferReasonPrefix, reason)
}
if !strings.Contains(reason, "run_id=product-farm-transfer-apply") {
t.Errorf("reason must contain run_id, got: %s", reason)
}
if !strings.Contains(reason, "location=Jamali") {
t.Errorf("reason must contain location, got: %s", reason)
}
if !strings.Contains(reason, "transfer_date=2026-04-24") {
t.Errorf("reason must contain transfer_date, got: %s", reason)
// Both requests must carry the run_id in the reason for rollback to work.
for i, req := range executor.createRequests {
if !strings.Contains(req.TransferReason, "run_id=run-apply") {
t.Errorf("request %d reason missing run_id: %s", i, req.TransferReason)
}
}
notes := executor.createRequests[0].StockLogNotes
if !strings.Contains(notes, "[auto] leftover stock transfer from kandang to farm") {
t.Errorf("stock log notes must be human-readable, got: %s", notes)
// Notes for farm_consolidation should be distinct from kandang_to_farm.
if !strings.Contains(executor.createRequests[0].StockLogNotes, "kandang to farm") {
t.Errorf("kandang group notes should say 'kandang to farm', got: %s", executor.createRequests[0].StockLogNotes)
}
if !strings.Contains(notes, "Jamali") {
t.Errorf("stock log notes must contain location name, got: %s", notes)
if !strings.Contains(executor.createRequests[1].StockLogNotes, "consolidation") {
t.Errorf("consolidation group notes should say 'consolidation', got: %s", executor.createRequests[1].StockLogNotes)
}
}
func TestExecuteApplyCreatesTransferWithCorrectProductsAndRecordsTransferID(t *testing.T) {
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
opts := &commandOptions{RunID: "run-1", TransferDate: date, ActorID: 1}
row1 := &transferReportRow{ProductID: 1, ProductName: "Pakan A", Qty: 100}
row2 := &transferReportRow{ProductID: 2, ProductName: "OVK B", Qty: 40}
groups := []transferGroup{
{
SourceType: sourceTypeKandang, SourceWarehouseID: 20, FarmWarehouseID: 50,
Rows: []*transferReportRow{row1, row2},
},
}
if executor.createRequests[0].MovementNumber != "" {
t.Errorf("movement number should be empty so the service generates one, got: %q", executor.createRequests[0].MovementNumber)
executor := &fakeSystemTransferExecutor{
createResponses: []*entity.StockTransfer{{Id: 1001, MovementNumber: "PND-LTI-1001"}},
}
if groups[0].Rows[0].Status != "applied" || groups[0].Rows[1].Status != "applied" {
t.Errorf("first group rows must be applied: %+v", groups[0].Rows)
_, err := executeApply(context.Background(), executor, opts, groups)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if groups[1].Rows[0].Status != "failed" {
t.Errorf("second group row must be failed: %+v", groups[1].Rows[0])
if row1.Status != "applied" || row2.Status != "applied" {
t.Errorf("both rows should be applied: %s, %s", row1.Status, row2.Status)
}
if groups[0].Rows[0].TransferID == nil || *groups[0].Rows[0].TransferID != 1001 {
t.Errorf("first group must carry transfer id 1001")
if row1.TransferID == nil || *row1.TransferID != 1001 {
t.Errorf("expected transfer id 1001, got %+v", row1.TransferID)
}
if row1.MovementNumber == nil || *row1.MovementNumber != "PND-LTI-1001" {
t.Errorf("expected movement number PND-LTI-1001, got %+v", row1.MovementNumber)
}
// Verify both products were included in the create request.
if len(executor.createRequests[0].Products) != 2 {
t.Errorf("expected 2 products in request, got %d", len(executor.createRequests[0].Products))
}
}
// ── executeRollback ───────────────────────────────────────────────────────────
func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) {
func TestExecuteRollbackDeletesDescendingAndMarksStatuses(t *testing.T) {
executor := &fakeSystemTransferExecutor{
deleteErrors: map[uint]error{
200: errors.New("stock already consumed downstream"),
},
deleteErrors: map[uint]error{200: errors.New("already consumed")},
}
rows := []rollbackDetailRow{
{TransferID: 100, ProductName: "Pakan A"},
@@ -285,24 +483,17 @@ func TestExecuteRollbackDeletesInDescendingOrderAndMarksStatuses(t *testing.T) {
}
err := executeRollback(context.Background(), executor, rows, 99)
if err == nil {
t.Fatal("expected rollback error for transfer 200")
if err == nil || !strings.Contains(err.Error(), "already consumed") {
t.Fatalf("expected error for transfer 200, got: %v", err)
}
if !strings.Contains(err.Error(), "stock already consumed downstream") {
t.Fatalf("unexpected error: %v", err)
}
if len(executor.deletedTransferIDs) != 2 {
t.Fatalf("expected 2 delete calls, got %d", len(executor.deletedTransferIDs))
}
// descending: 200 before 100
if executor.deletedTransferIDs[0] != 200 || executor.deletedTransferIDs[1] != 100 {
t.Fatalf("expected delete order [200 100], got %v", executor.deletedTransferIDs)
}
if rows[0].Status != "rolled_back" || rows[2].Status != "rolled_back" {
t.Fatalf("transfer 100 rows must be rolled_back: %+v", rows)
t.Errorf("transfer 100 rows must be rolled_back: %+v", rows)
}
if rows[1].Status != "failed" {
t.Fatalf("transfer 200 row must be failed: %+v", rows[1])
t.Errorf("transfer 200 row must be failed: %+v", rows[1])
}
}
@@ -315,14 +506,12 @@ func TestExecuteRollbackRequiresActorID(t *testing.T) {
// ── buildTransferReason / buildRunReasonMatcher ───────────────────────────────
func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) {
func TestBuildTransferReasonMatchesRunReasonMatcher(t *testing.T) {
runID := "product-farm-transfer-20260424T120000.000000000Z"
date := time.Date(2026, 4, 24, 0, 0, 0, 0, time.UTC)
reason := buildTransferReason(runID, "Jamali", "Gudang K1", "Gudang Farm Jamali", date)
matcher := buildRunReasonMatcher(runID)
// Simulate a LIKE match: matcher ends with % so check prefix.
needle := strings.TrimSuffix(matcher, "%")
needle := strings.TrimSuffix(buildRunReasonMatcher(runID), "%")
if !strings.HasPrefix(reason, needle) {
t.Errorf("reason %q does not match matcher prefix %q", reason, needle)
}
@@ -330,17 +519,29 @@ func TestBuildTransferReasonIsMatchedByRunReasonMatcher(t *testing.T) {
func TestBuildTransferReasonSanitizesPipes(t *testing.T) {
reason := buildTransferReason("run-1", "Lok|asi", "Gudang|K1", "Farm|WH", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
// Pipes inside field values must be replaced so the structured format stays parseable.
parts := strings.Split(reason, "|")
// Expect exactly 6 pipe-separated segments (prefix + 5 key=value pairs).
// prefix + 5 key=value segments = 6 parts
if len(parts) != 6 {
t.Errorf("expected 6 pipe segments, got %d: %v", len(parts), parts)
t.Errorf("expected 6 pipe-separated segments, got %d: %v", len(parts), parts)
}
}
func TestBuildStockLogNotesContainsSourceTypeHint(t *testing.T) {
date := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
kandangNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeKandang)
consolNote := buildStockLogNotes("r", "Loc", "Src", "Dst", date, sourceTypeFarmConsol)
if !strings.Contains(kandangNote, "kandang to farm") {
t.Errorf("kandang note should mention 'kandang to farm': %s", kandangNote)
}
if !strings.Contains(consolNote, "consolidation") {
t.Errorf("consolidation note should mention 'consolidation': %s", consolNote)
}
}
// ── summarizeReport ───────────────────────────────────────────────────────────
func TestSummarizeReportCountsCorrectly(t *testing.T) {
func TestSummarizeReportCountsAllStatuses(t *testing.T) {
rows := []transferReportRow{
{Status: "eligible"},
{Status: "applied"},
@@ -368,6 +569,6 @@ func TestSummarizeReportCountsCorrectly(t *testing.T) {
t.Errorf("expected RowsFailed=1, got %d", s.RowsFailed)
}
if s.GroupsPlanned != 2 || s.GroupsApplied != 1 {
t.Errorf("unexpected group counts: planned=%d applied=%d", s.GroupsPlanned, s.GroupsApplied)
t.Errorf("unexpected group counts: %+v", s)
}
}