cmd: skip ambiguous farm then resolve double farm warehouses

This commit is contained in:
Adnan Zahir
2026-04-24 17:37:04 +07:00
parent f1d7966e2f
commit 6033535894
3 changed files with 749 additions and 277 deletions
+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),