mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-26 08:15:43 +00:00
cmd: skip ambiguous farm then resolve double farm warehouses
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user