cmd: two steps auto transfer

This commit is contained in:
Adnan Zahir
2026-04-24 21:17:41 +07:00
parent 6033535894
commit 42c088e772
3 changed files with 68 additions and 10 deletions
Binary file not shown.
+41 -10
View File
@@ -48,9 +48,13 @@ type commandOptions struct {
AllLocations bool
FarmWarehouseOverrideID uint
SkipAmbiguous bool
Output string
ActorID uint
RunID string
// FlagFilter is an optional set of product flag names (upper-cased).
// When non-empty only products that carry at least one of these flags are
// included. Populated from --flags="PAKAN,OVK".
FlagFilter []string
Output string
ActorID uint
RunID string
}
// farmWarehouseEntry is a single LOKASI-type warehouse belonging to a location.
@@ -234,7 +238,7 @@ func main() {
// Step 3: load leftover stocks from extra farm warehouses that need
// consolidation into the chosen farm warehouse.
extraFarmStocks, err := loadExtraFarmLeftoverStocks(ctx, db, farmMap)
extraFarmStocks, err := loadExtraFarmLeftoverStocks(ctx, db, farmMap, opts)
if err != nil {
log.Fatalf("failed to load extra farm leftover stocks: %v", err)
}
@@ -276,10 +280,21 @@ func parseFlags() (*commandOptions, error) {
"When a location has multiple LOKASI warehouses, use this warehouse id as the chosen target. "+
"Stocks from the other LOKASI warehouses are also transferred to the chosen one. "+
"Requires --location-id or --location-name.")
var flagsRaw string
flag.StringVar(&flagsRaw, "flags", "",
"Comma-separated list of product flag names to include (e.g. PAKAN,OVK). "+
"Only products that carry at least one of these flags are transferred. "+
"Leave empty to transfer all products regardless of flags.")
flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json")
flag.UintVar(&opts.ActorID, "actor-id", 1, "Actor id used for created/deleted transfers")
flag.Parse()
for _, f := range strings.Split(flagsRaw, ",") {
if name := strings.ToUpper(strings.TrimSpace(f)); name != "" {
opts.FlagFilter = append(opts.FlagFilter, name)
}
}
opts.LocationName = strings.TrimSpace(opts.LocationName)
opts.RollbackRunID = strings.TrimSpace(opts.RollbackRunID)
opts.Output = strings.ToLower(strings.TrimSpace(opts.Output))
@@ -553,6 +568,7 @@ func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOp
Order("l.name ASC, kw.name ASC, p.name ASC")
query = applyLocationFilter(query, opts, "kw")
query = applyFlagFilter(query, opts)
var rows []row
if err := query.Scan(&rows).Error; err != nil {
@@ -581,7 +597,7 @@ func loadKandangLeftoverStocks(ctx context.Context, db *gorm.DB, opts *commandOp
// 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) {
func loadExtraFarmLeftoverStocks(ctx context.Context, db *gorm.DB, farmMap map[uint]farmWarehouseInfo, opts *commandOptions) ([]kandangStockRow, error) {
// Collect extra farm warehouse IDs together with their location context.
type extraSource struct {
LocationID uint
@@ -620,7 +636,7 @@ func loadExtraFarmLeftoverStocks(ctx context.Context, db *gorm.DB, farmMap map[u
}
var rows []row
err := db.WithContext(ctx).
q := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
fw.id AS source_warehouse_id,
@@ -641,10 +657,9 @@ func loadExtraFarmLeftoverStocks(ctx context.Context, db *gorm.DB, farmMap map[u
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 {
Where("COALESCE(pw.qty, 0) > 0")
q = applyFlagFilter(q, opts)
if err := q.Order("fw.name ASC, p.name ASC").Scan(&rows).Error; err != nil {
return nil, err
}
@@ -949,6 +964,22 @@ func loadRollbackDetails(ctx context.Context, db *gorm.DB, runID string) ([]roll
// ── Helpers ───────────────────────────────────────────────────────────────────
// applyFlagFilter adds an EXISTS subquery that restricts results to products
// carrying at least one flag from opts.FlagFilter. When the filter is empty
// the query is returned unchanged so all products are included.
func applyFlagFilter(q *gorm.DB, opts *commandOptions) *gorm.DB {
if len(opts.FlagFilter) == 0 {
return q
}
return q.Where(`EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = p.id
AND f.flagable_type = 'products'
AND UPPER(f.name) IN ?
)`, opts.FlagFilter)
}
func applyLocationFilter(q *gorm.DB, opts *commandOptions, tableAlias string) *gorm.DB {
if opts == nil {
return q
@@ -301,6 +301,33 @@ func TestBuildPlanSkipAmbiguousDowngradesErrorToSkipped(t *testing.T) {
}
}
// ── applyFlagFilter (unit-level, via buildTransferPlan) ───────────────────────
// applyFlagFilter is a DB-level filter so we test its effect indirectly: the
// flag filter is applied before rows reach buildTransferPlan, so we simulate
// by only passing stock rows that the query would have returned.
// The real guard is that loadKandangLeftoverStocks receives the filtered set.
// Here we verify that buildTransferPlan itself is agnostic to the filter and
// simply processes whatever rows it is given.
func TestBuildPlanOnlyTransfersRowsPassedToIt(t *testing.T) {
opts := &commandOptions{RunID: "test-run", FlagFilter: []string{"PAKAN"}}
farmMap := map[uint]farmWarehouseInfo{
10: {LocationID: 10, AllFarm: []farmWarehouseEntry{{ID: 50, Name: "Farm A"}}, ChosenID: 50, ChosenName: "Farm A"},
}
// Simulate: only PAKAN products survived the DB filter; OVK was excluded.
stocks := []kandangStockRow{
{LocationID: 10, SourceWarehouseID: 20, ProductID: 1, ProductName: "Pakan Broiler", OnHandQty: 100, LeftoverQty: 100, SourceType: sourceTypeKandang},
}
_, groups := buildTransferPlan(opts, farmMap, stocks)
if len(groups) != 1 || len(groups[0].Rows) != 1 {
t.Fatalf("expected 1 group with 1 row, got %d groups", len(groups))
}
if groups[0].Rows[0].ProductName != "Pakan Broiler" {
t.Errorf("unexpected product: %s", groups[0].Rows[0].ProductName)
}
}
// ── buildTransferPlan — farm_consolidation source ────────────────────────────
func TestBuildPlanFarmConsolidationCreatesOwnGroup(t *testing.T) {