diff --git a/auto-transfer-products-to-farm b/auto-transfer-products-to-farm index c0a021b6..a8935ce9 100755 Binary files a/auto-transfer-products-to-farm and b/auto-transfer-products-to-farm differ diff --git a/cmd/auto-transfer-products-to-farm/main.go b/cmd/auto-transfer-products-to-farm/main.go index ecea87f9..964b337a 100644 --- a/cmd/auto-transfer-products-to-farm/main.go +++ b/cmd/auto-transfer-products-to-farm/main.go @@ -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 diff --git a/cmd/auto-transfer-products-to-farm/main_test.go b/cmd/auto-transfer-products-to-farm/main_test.go index e19f76a7..37c881af 100644 --- a/cmd/auto-transfer-products-to-farm/main_test.go +++ b/cmd/auto-transfer-products-to-farm/main_test.go @@ -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) {