diff --git a/cmd/verify-stock-consolidation/main.go b/cmd/verify-stock-consolidation/main.go new file mode 100644 index 00000000..3e267bcc --- /dev/null +++ b/cmd/verify-stock-consolidation/main.go @@ -0,0 +1,505 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strings" + "text/tabwriter" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + "gorm.io/gorm" +) + +const ( + outputTable = "table" + outputJSON = "json" + + caseA = "A" + caseB = "B" + caseAll = "all" +) + +type options struct { + Output string + AreaName string + KandangLocationName string + DBSSLMode string + VerifyCase string +} + +type sourceWarehouseCheck struct { + AreaName string `json:"area_name"` + KandangLocationName string `json:"kandang_location_name"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + SourceWarehouseID uint `json:"source_warehouse_id"` + SourceWarehouseName string `json:"source_warehouse_name"` + Case string `json:"case"` + DeletedAt *string `json:"deleted_at"` + StockInProductWH float64 `json:"stock_in_product_wh"` + ActivePurchaseItems int64 `json:"active_purchase_items"` + Status string `json:"status"` +} + +type destinationWarehouseCheck struct { + AreaName string `json:"area_name"` + KandangLocationName string `json:"kandang_location_name"` + FarmWarehouseID uint `json:"farm_warehouse_id"` + FarmWarehouseName string `json:"farm_warehouse_name"` + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` + CurrentQty float64 `json:"current_qty"` + StockLogsTotal float64 `json:"stock_logs_total"` + StockLogsCount int64 `json:"stock_logs_count"` + Status string `json:"status"` +} + +type orphanedReferenceCheck struct { + Table string `json:"table"` + Column string `json:"column"` + ReferenceCount int64 `json:"reference_count"` + DeletedWarehouseIDs string `json:"deleted_warehouse_ids"` +} + +type verificationSummary struct { + TotalSourceWarehouses int `json:"total_source_warehouses"` + CleanSourceWarehouses int `json:"clean_source_warehouses"` + DirtySourceWarehouses int `json:"dirty_source_warehouses"` + TotalDestinationWarehouses int `json:"total_destination_warehouses"` + MatchingDestinations int `json:"matching_destinations"` + DiscrepancyDestinations int `json:"discrepancy_destinations"` + TotalOrphanedReferences int64 `json:"total_orphaned_references"` + OverallStatus string `json:"overall_status"` +} + +func main() { + opts, err := parseFlags() + if err != nil { + log.Fatalf("invalid flags: %v", err) + } + + if opts.DBSSLMode != "" { + config.DBSSLMode = opts.DBSSLMode + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + + // Verify source warehouses + sourceChecks, err := verifySourceWarehouses(ctx, db, opts) + if err != nil { + log.Fatalf("failed to verify source warehouses: %v", err) + } + + // Verify destination warehouses + destChecks, err := verifyDestinationWarehouses(ctx, db, opts, sourceChecks) + if err != nil { + log.Fatalf("failed to verify destination warehouses: %v", err) + } + + // Verify no orphaned references + orphanedRefs, err := verifyOrphanedReferences(ctx, db, sourceChecks) + if err != nil { + log.Fatalf("failed to verify orphaned references: %v", err) + } + + // Render results + summary := buildSummary(sourceChecks, destChecks, orphanedRefs) + renderVerification(opts.Output, sourceChecks, destChecks, orphanedRefs, summary) +} + +func parseFlags() (*options, error) { + var opts options + flag.StringVar(&opts.Output, "output", outputTable, "Output format: table or json") + flag.StringVar(&opts.AreaName, "area-name", "", "Optional exact area name filter") + flag.StringVar(&opts.KandangLocationName, "kandang-location-name", "", "Optional exact canonical kandang location filter") + flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require") + flag.StringVar(&opts.VerifyCase, "verify-case", caseAll, "Verify specific case: A, B, or all") + flag.Parse() + + opts.Output = strings.ToLower(strings.TrimSpace(opts.Output)) + opts.AreaName = strings.TrimSpace(opts.AreaName) + opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName) + opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode) + opts.VerifyCase = strings.ToUpper(strings.TrimSpace(opts.VerifyCase)) + + if opts.Output == "" { + opts.Output = outputTable + } + if opts.Output != outputTable && opts.Output != outputJSON { + return nil, fmt.Errorf("unsupported --output=%s", opts.Output) + } + if opts.VerifyCase == "" { + opts.VerifyCase = caseAll + } + if opts.VerifyCase != caseA && opts.VerifyCase != caseB && opts.VerifyCase != caseAll { + return nil, fmt.Errorf("unsupported --verify-case=%s", opts.VerifyCase) + } + + return &opts, nil +} + +func verifySourceWarehouses(ctx context.Context, db *gorm.DB, opts *options) ([]sourceWarehouseCheck, error) { + filters := buildFilters(opts) + query := fmt.Sprintf(` +WITH case_a_warehouses AS ( + -- Case A: Kandang-level warehouses (type != 'LOKASI' or NULL) + SELECT + w.id, + a.name AS area_name, + kl.name AS kandang_location_name, + k.id, + k.name, + 'A'::text AS case_type + FROM warehouses w + JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL + JOIN locations kl ON kl.id = k.location_id + JOIN areas a ON a.id = kl.area_id + WHERE w.deleted_at IS NOT NULL + AND w.kandang_id IS NOT NULL + AND UPPER(COALESCE(w.type, '')) <> 'LOKASI' +), +case_b_warehouses AS ( + -- Case B: Wrong-location warehouses (location_id != kandang.location_id) + SELECT + w.id, + a.name AS area_name, + kl.name AS kandang_location_name, + k.id, + k.name, + 'B'::text AS case_type + FROM warehouses w + JOIN kandangs k ON k.id = w.kandang_id AND k.deleted_at IS NULL + JOIN locations kl ON kl.id = k.location_id + JOIN areas a ON a.id = kl.area_id + WHERE w.deleted_at IS NOT NULL + AND w.kandang_id IS NOT NULL + AND w.location_id IS DISTINCT FROM k.location_id +), +all_source_warehouses AS ( + SELECT id, area_name, kandang_location_name, id AS kandang_id, name, case_type FROM case_a_warehouses + UNION ALL + SELECT id, area_name, kandang_location_name, id AS kandang_id, name, case_type FROM case_b_warehouses +) +SELECT + asw.area_name, + asw.kandang_location_name, + asw.kandang_id, + asw.name AS kandang_name, + w.id AS source_warehouse_id, + w.name AS source_warehouse_name, + asw.case_type, + TO_CHAR(w.deleted_at, 'YYYY-MM-DD') AS deleted_at, + COALESCE(SUM(pw.qty), 0) AS stock_in_product_wh, + COUNT(DISTINCT pi.id) AS active_purchase_items +FROM all_source_warehouses asw +JOIN warehouses w ON w.id = asw.id +LEFT JOIN product_warehouses pw ON pw.warehouse_id = w.id +LEFT JOIN purchase_items pi ON pi.warehouse_id = w.id +WHERE true + %s +GROUP BY + asw.area_name, + asw.kandang_location_name, + asw.kandang_id, + asw.name, + w.id, + w.name, + asw.case_type, + w.deleted_at +ORDER BY asw.area_name ASC, asw.kandang_location_name ASC, w.name ASC +`, andClause(filters)) + + rows := make([]sourceWarehouseCheck, 0) + if err := db.WithContext(ctx).Raw(query).Scan(&rows).Error; err != nil { + return nil, err + } + + // Determine status for each row + for i := range rows { + if rows[i].StockInProductWH == 0 && rows[i].ActivePurchaseItems == 0 { + rows[i].Status = "CLEAN" + } else { + rows[i].Status = "DIRTY" + } + + // Filter by case if requested + if opts.VerifyCase != caseAll && rows[i].Case != opts.VerifyCase { + rows = append(rows[:i], rows[i+1:]...) + i-- + } + } + + return rows, nil +} + +func verifyDestinationWarehouses(ctx context.Context, db *gorm.DB, opts *options, sourceChecks []sourceWarehouseCheck) ([]destinationWarehouseCheck, error) { + filters := buildFilters(opts) + query := fmt.Sprintf(` +SELECT + a.name AS area_name, + kl.name AS kandang_location_name, + fw.id AS farm_warehouse_id, + fw.name AS farm_warehouse_name, + p.id AS product_id, + p.name AS product_name, + COALESCE(pw.qty, 0) AS current_qty, + COALESCE(SUM(sl.stock), 0) AS stock_logs_total, + COUNT(DISTINCT sl.id) AS stock_logs_count +FROM warehouses fw +JOIN locations loc ON loc.id = fw.location_id +JOIN areas a ON a.id = loc.area_id +JOIN kandangs k ON k.location_id = fw.location_id AND k.deleted_at IS NULL +JOIN locations kl ON kl.id = k.location_id +JOIN products p ON UPPER(COALESCE(p.type, '')) IN ('PAKAN', 'OVK') +LEFT JOIN product_warehouses pw ON pw.warehouse_id = fw.id AND pw.product_id = p.id +LEFT JOIN stock_logs sl ON sl.product_warehouse_id = pw.id +WHERE fw.deleted_at IS NULL + AND UPPER(COALESCE(fw.type, '')) = 'LOKASI' + %s +GROUP BY + a.name, + kl.name, + fw.id, + fw.name, + p.id, + p.name, + pw.qty +ORDER BY a.name ASC, kl.name ASC, fw.name ASC, p.name ASC +`, andClause(filters)) + + rows := make([]destinationWarehouseCheck, 0) + if err := db.WithContext(ctx).Raw(query).Scan(&rows).Error; err != nil { + return nil, err + } + + // Determine status: check if current_qty matches stock_logs + for i := range rows { + if rows[i].CurrentQty > 0 { + // Allow small floating point discrepancies + if abs(rows[i].CurrentQty-rows[i].StockLogsTotal) < 0.001 { + rows[i].Status = "MATCHED" + } else { + rows[i].Status = "DISCREPANCY" + } + } else { + rows[i].Status = "EMPTY" + } + } + + return rows, nil +} + +func verifyOrphanedReferences(ctx context.Context, db *gorm.DB, sourceChecks []sourceWarehouseCheck) ([]orphanedReferenceCheck, error) { + if len(sourceChecks) == 0 { + return []orphanedReferenceCheck{}, nil + } + + // Get unique warehouse IDs from source checks + warehouseIDs := make([]uint, 0) + for _, check := range sourceChecks { + warehouseIDs = append(warehouseIDs, check.SourceWarehouseID) + } + + // Check common references + var results []orphanedReferenceCheck + + refChecks := []struct { + table string + column string + }{ + {"purchase_items", "warehouse_id"}, + {"stock_transfers", "from_warehouse_id"}, + {"stock_transfers", "to_warehouse_id"}, + {"fifo_stock_v2_operation_log", "warehouse_id"}, + } + + for _, ref := range refChecks { + var count int64 + if err := db.Table(ref.table). + Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs). + Count(&count).Error; err != nil { + return nil, err + } + + if count > 0 { + // Get the specific warehouse IDs + var ids []uint + if err := db.Table(ref.table). + Where(fmt.Sprintf("%s IN ?", ref.column), warehouseIDs). + Pluck(ref.column, &ids).Error; err != nil { + return nil, err + } + + idStrs := make([]string, len(ids)) + for i, id := range ids { + idStrs[i] = fmt.Sprintf("%d", id) + } + + results = append(results, orphanedReferenceCheck{ + Table: ref.table, + Column: ref.column, + ReferenceCount: count, + DeletedWarehouseIDs: strings.Join(idStrs, ", "), + }) + } + } + + return results, nil +} + +func buildFilters(opts *options) []string { + filters := make([]string, 0, 2) + if opts.AreaName != "" { + filters = append(filters, fmt.Sprintf("a.name = '%s'", opts.AreaName)) + } + if opts.KandangLocationName != "" { + filters = append(filters, fmt.Sprintf("kl.name = '%s'", opts.KandangLocationName)) + } + return filters +} + +func andClause(filters []string) string { + if len(filters) == 0 { + return "" + } + return " AND " + strings.Join(filters, " AND ") +} + +func buildSummary(sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck) verificationSummary { + summary := verificationSummary{ + TotalSourceWarehouses: len(sourceChecks), + OverallStatus: "PASS", + } + + for _, check := range sourceChecks { + if check.Status == "CLEAN" { + summary.CleanSourceWarehouses++ + } else { + summary.DirtySourceWarehouses++ + summary.OverallStatus = "FAIL" + } + } + + summary.TotalDestinationWarehouses = len(destChecks) + for _, check := range destChecks { + if check.Status == "MATCHED" || check.Status == "EMPTY" { + summary.MatchingDestinations++ + } else if check.Status == "DISCREPANCY" { + summary.DiscrepancyDestinations++ + summary.OverallStatus = "FAIL" + } + } + + for _, ref := range orphanedRefs { + summary.TotalOrphanedReferences += ref.ReferenceCount + summary.OverallStatus = "FAIL" + } + + return summary +} + +func renderVerification(mode string, sourceChecks []sourceWarehouseCheck, destChecks []destinationWarehouseCheck, orphanedRefs []orphanedReferenceCheck, summary verificationSummary) { + if mode == outputJSON { + payload := map[string]any{ + "source_warehouses": sourceChecks, + "destination_warehouses": destChecks, + "orphaned_references": orphanedRefs, + "summary": summary, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(payload) + return + } + + // Table mode + fmt.Println("\n=== SOURCE WAREHOUSES VERIFICATION ===") + if len(sourceChecks) == 0 { + fmt.Println("No deleted warehouses found") + } else { + w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) + fmt.Fprintln(w, "AREA\tLOKASI\tKANDANG\tWAREHOUSE\tCASE\tDELETED_AT\tSTOCK_IN_PW\tPURCHASE_ITEMS\tSTATUS") + for _, check := range sourceChecks { + fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%d\t%s\n", + check.AreaName, + check.KandangLocationName, + check.KandangName, + check.SourceWarehouseName, + check.Case, + displayOptionalString(check.DeletedAt), + check.StockInProductWH, + check.ActivePurchaseItems, + check.Status, + ) + } + _ = w.Flush() + } + + fmt.Println("\n=== DESTINATION WAREHOUSES VERIFICATION ===") + if len(destChecks) == 0 { + fmt.Println("No destination warehouses found") + } else { + w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) + fmt.Fprintln(w, "AREA\tLOKASI\tFARM_WAREHOUSE\tPRODUCT\tCURRENT_QTY\tSTOCK_LOGS_TOTAL\tLOGS_COUNT\tSTATUS") + for _, check := range destChecks { + fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%.3f\t%.3f\t%d\t%s\n", + check.AreaName, + check.KandangLocationName, + check.FarmWarehouseName, + check.ProductName, + check.CurrentQty, + check.StockLogsTotal, + check.StockLogsCount, + check.Status, + ) + } + _ = w.Flush() + } + + if len(orphanedRefs) > 0 { + fmt.Println("\n=== ORPHANED REFERENCES (ERRORS) ===") + w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) + fmt.Fprintln(w, "TABLE\tCOLUMN\tCOUNT\tWAREHOUSE_IDS") + for _, ref := range orphanedRefs { + fmt.Fprintf( + w, + "%s\t%s\t%d\t%s\n", + ref.Table, + ref.Column, + ref.ReferenceCount, + ref.DeletedWarehouseIDs, + ) + } + _ = w.Flush() + } + + fmt.Printf("\n=== SUMMARY ===\n") + fmt.Printf("Source Warehouses: %d total, %d clean, %d dirty\n", summary.TotalSourceWarehouses, summary.CleanSourceWarehouses, summary.DirtySourceWarehouses) + fmt.Printf("Destination Warehouses: %d total, %d matching, %d discrepancies\n", summary.TotalDestinationWarehouses, summary.MatchingDestinations, summary.DiscrepancyDestinations) + fmt.Printf("Orphaned References: %d\n", summary.TotalOrphanedReferences) + fmt.Printf("Overall Status: %s\n", summary.OverallStatus) +} + +func displayOptionalString(value *string) string { + if value == nil || strings.TrimSpace(*value) == "" { + return "-" + } + return *value +} + +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} diff --git a/docs/STOCK_CONSOLIDATION_GUIDE.md b/docs/STOCK_CONSOLIDATION_GUIDE.md new file mode 100644 index 00000000..10a3147b --- /dev/null +++ b/docs/STOCK_CONSOLIDATION_GUIDE.md @@ -0,0 +1,460 @@ +# Stock Consolidation Operations Guide + +This guide explains how to use the warehouse consolidation commands to fix misplaced PAKAN/OVK stocks and migrate them to the correct farm-level warehouses. + +## Overview + +The stock consolidation system handles two main scenarios: + +| Case | Scenario | Root Cause | Solution | +|------|----------|-----------|----------| +| **Case B** | Invalid kandang references | Purchases pointed to warehouses with location mismatch | Move unused stocks to correct farm-level warehouse | +| **Case A** | General kandang cleanup | Any kandang-level warehouse with unused PAKAN/OVK stocks | Consolidate to farm-level warehouse | + +## Recommended Execution Order + +For a complete stock consolidation operation, follow this sequence: + +``` +1. find-wrong-warehouse-records ← Diagnose issues +2. repoint-wrong-warehouse-relations ← Fix Case B (invalid references) +3. consolidate-kandang-to-farm-stocks ← Fix Case A (general cleanup) +4. verify-stock-consolidation ← Audit and verify results +``` + +--- + +## Command Reference + +### 1. `find-wrong-warehouse-records` — Diagnostic Tool + +**Purpose:** Identify problematic warehouses and their associated stocks before making any changes. + +**Applies to:** Both Case A and Case B scenarios + +**What it does:** +- Lists warehouses with location mismatches (Case B) +- Shows stock allocations that reference wrong warehouses +- Helps identify scope of work needed + +#### Usage: + +```bash +# Report 1: Find warehouses with location mismatches (Case B issues) +./find-wrong-warehouse-records --report=warehouses + +# Report 2: Find stock allocations in wrong warehouses (Case B impact) +./find-wrong-warehouse-records --report=usage + +# Filter by area +./find-wrong-warehouse-records --report=warehouses --area-name "East Region" + +# Filter by kandang location +./find-wrong-warehouse-records --report=usage --kandang-location-name "Location 1" + +# Filter by product type +./find-wrong-warehouse-records --report=usage --usable-type=RECORDING_STOCK + +# JSON output for analysis +./find-wrong-warehouse-records --report=usage --output=json > analysis.json +``` + +#### Output Columns (Warehouses Report): +- **AREA**: Geographic area +- **KANDANG_LOCATION**: Kandang's intended location +- **KANDANG**: Kandang name +- **WRONG_LOCATION**: Where the warehouse actually is +- **WRONG_WAREHOUSE**: Problematic warehouse name +- **CORRECT_WAREHOUSE**: Where stocks should be + +#### Output Columns (Usage Report): +- **USABLE_TYPE**: RECORDING_STOCK or MARKETING_DELIVERY +- **PRODUCTS**: Which products are affected +- **QTY_FROM_WRONG_STOCK**: How much stock is misplaced +- **SOURCE_PURCHASES**: Which purchase orders are affected + +**When to use:** +- Before starting any consolidation +- To understand the scope of issues +- To get metrics on how much stock needs moving +- To identify which areas are most affected + +--- + +### 2. `repoint-wrong-warehouse-relations` — Fix Case B (Invalid References) + +**Purpose:** Fix purchases pointed to invalid kandang warehouses (location mismatch). + +**Applies to:** Case B only + +**Cases it handles:** +- ✅ Warehouses with `location_id ≠ kandang.location_id` (location mismatch) +- ✅ Only PAKAN/OVK products +- ✅ Only unused/leftover stocks (no active allocations) +- ✅ Moves to farm-level warehouse at correct location + +**What it does:** +1. Finds product_warehouses in wrong locations +2. Consolidates duplicates into survivor warehouses +3. Updates all references across the system +4. Recalculates FIFO stocks if needed +5. Optionally soft-deletes the wrong warehouse + +#### Usage: + +```bash +# Dry-run: See what would be moved (always run first!) +./repoint-wrong-warehouse-relations + +# Dry-run with specific filters +./repoint-wrong-warehouse-relations --area-name "East Region" +./repoint-wrong-warehouse-relations --kandang-location-name "Location 1" + +# Actually apply the migration +./repoint-wrong-warehouse-relations --apply + +# Apply but keep the wrong warehouses (for audit trail) +./repoint-wrong-warehouse-relations --apply --delete-wrong-warehouses=false + +# JSON output for automation/logging +./repoint-wrong-warehouse-relations --apply --output=json > migration.json +``` + +#### Flags: +- `--apply`: Apply changes (omit for dry-run) +- `--output`: `table` (default) or `json` +- `--area-name`: Filter by exact area name +- `--kandang-location-name`: Filter by exact location name +- `--delete-wrong-warehouses`: Soft-delete wrong warehouses (default: true) +- `--db-sslmode`: PostgreSQL SSL mode override (e.g., `require`) + +#### Output: + +**Table mode shows:** +- AREA, LOCATION, KANDANG: Where the issue is +- WRONG_WAREHOUSE: Source (will be deleted) +- TARGET_WAREHOUSE: Destination (farm-level) +- PRODUCT: What's being moved +- SURVIVOR_PW / ABSORBED_PW: Consolidation details +- NEEDS_REFLOW: Whether FIFO recalculation is needed + +**Summary shows:** +``` +Summary: plan_rows=15 wrong_warehouses=3 survivor_pws=12 absorbed_pws=5 + needs_reflow_pws=3 deleted_product_warehouses=5 soft_deleted_warehouses=3 + +Updated product_warehouse refs: + fifo_stock_v2_operation_log.product_warehouse_id=8 + fifo_stock_v2_reflow_checkpoints.product_warehouse_id=3 + purchase_items.warehouse_id=12 + +Updated warehouse refs: + purchase_items.warehouse_id=12 +``` + +#### Safety Features: +- **Dry-run first**: Always preview before applying +- **Prechecks**: Verifies no blocked references or FIFO conflicts +- **Atomic transactions**: All-or-nothing database updates +- **Reference verification**: Confirms all references were updated +- **Stock log recalculation**: Ensures FIFO accuracy after moves + +--- + +### 3. `consolidate-kandang-to-farm-stocks` — Fix Case A (General Cleanup) + +**Purpose:** Consolidate ALL kandang-level PAKAN/OVK stocks to farm-level warehouse. + +**Applies to:** Case A only + +**Cases it handles:** +- ✅ ALL kandang-level warehouses (type ≠ 'LOKASI') +- ✅ Only PAKAN/OVK products +- ✅ Only unused/leftover stocks (no active allocations) +- ✅ Moves to farm-level warehouse regardless of warehouse validity +- ✅ No location validation (processes all kandang warehouses) + +**What it does:** +1. Finds all kandang-level warehouses with unused stocks +2. Consolidates duplicates into survivor warehouses +3. Updates all references across the system +4. Recalculates FIFO stocks if needed +5. Optionally soft-deletes the kandang warehouse + +#### Usage: + +```bash +# Dry-run: See what would be consolidated +./consolidate-kandang-to-farm-stocks + +# Dry-run with filters +./consolidate-kandang-to-farm-stocks --area-name "East Region" +./consolidate-kandang-to-farm-stocks --kandang-location-name "Location 1" + +# Actually apply the consolidation +./consolidate-kandang-to-farm-stocks --apply + +# Apply but keep kandang warehouses +./consolidate-kandang-to-farm-stocks --apply --delete-kandang-warehouses=false + +# JSON output for logging +./consolidate-kandang-to-farm-stocks --apply --output=json > consolidation.json +``` + +#### Flags: +- `--apply`: Apply changes (omit for dry-run) +- `--output`: `table` (default) or `json` +- `--area-name`: Filter by exact area name +- `--kandang-location-name`: Filter by exact location name +- `--delete-kandang-warehouses`: Soft-delete kandang warehouses (default: true) +- `--db-sslmode`: PostgreSQL SSL mode override + +#### Output Format: +Similar to Case B, shows: +- Source kandang warehouse → Destination farm warehouse +- Product and quantity details +- Consolidation and FIFO reflow information + +#### Key Differences from Case B: +| Aspect | Case B | Case A | +|--------|--------|--------| +| Scope | Wrong-location warehouses only | ALL kandang-level warehouses | +| Validation | Checks location mismatch | No validation checks | +| When to use | After finding mismatches | General cleanup/consolidation | +| Risk level | Lower (targeted fix) | Higher (broader scope) | + +--- + +### 4. `verify-stock-consolidation` — Audit and Verify + +**Purpose:** Verify that stock consolidations were successful and no stocks were lost. + +**Applies to:** Both Case A and Case B (post-migration verification) + +**What it checks:** + +#### ✅ Source Warehouse Verification +Ensures deleted warehouses are clean: +- **CLEAN**: No remaining stock or purchase references +- **DIRTY**: Still has orphaned data (migration incomplete) + +#### ✅ Destination Warehouse Verification +Ensures farm-level warehouses received stocks correctly: +- **MATCHED**: Quantity in product_warehouse matches stock_logs +- **DISCREPANCY**: Quantity mismatch (data integrity issue!) +- **EMPTY**: No stocks (correct if nothing was supposed to move) + +#### ✅ Orphaned Reference Detection +Finds any remaining references to deleted warehouses in: +- `purchase_items.warehouse_id` +- `stock_transfers.from/to_warehouse_id` +- `fifo_stock_v2_operation_log.warehouse_id` + +#### Usage: + +```bash +# Verify all consolidations (Case A + B together) +./verify-stock-consolidation + +# Verify only Case B results +./verify-stock-consolidation --verify-case=B + +# Verify only Case A results +./verify-stock-consolidation --verify-case=A + +# Filter by area +./verify-stock-consolidation --area-name "East Region" + +# Filter by location +./verify-stock-consolidation --kandang-location-name "Location 1" + +# JSON output for reporting +./verify-stock-consolidation --output=json > verification_report.json +``` + +#### Flags: +- `--verify-case`: `A`, `B`, or `all` (default) +- `--output`: `table` (default) or `json` +- `--area-name`: Filter by exact area name +- `--kandang-location-name`: Filter by exact location name +- `--db-sslmode`: PostgreSQL SSL mode override + +#### Output Sections: + +**1. Source Warehouses** +``` +AREA LOKASI KANDANG WAREHOUSE CASE DELETED_AT STOCK PURCHASES STATUS +Area A Location 1 Kandang A KWH-A-01 A 2026-04-23 0.000 0 CLEAN +Area A Location 1 Kandang B WH-WRONG-001 B 2026-04-23 2.500 1 DIRTY ❌ +``` + +**2. Destination Warehouses** +``` +AREA LOKASI FARM_WAREHOUSE PRODUCT QTY LOGS_TOTAL LOGS STATUS +Area A Location 1 FWH-LOC-001 PAKAN A 2.500 2.500 3 MATCHED ✅ +Area A Location 1 FWH-LOC-001 OVK B 5.000 4.999 5 DISCREPANCY ❌ +``` + +**3. Orphaned References** (if any) +``` +TABLE COLUMN COUNT WAREHOUSE_IDS +purchase_items warehouse_id 3 1001, 1002, 1003 +stock_transfers from_warehouse_id 1 1001 +``` + +**4. Summary** +``` +Source Warehouses: 10 total, 8 clean, 2 dirty +Destination Warehouses: 15 total, 14 matching, 1 discrepancy +Orphaned References: 4 +Overall Status: FAIL ❌ +``` + +#### Interpreting Results: + +| Scenario | Meaning | Action | +|----------|---------|--------| +| ✅ Overall Status: PASS | All migrations successful | No action needed | +| ❌ Dirty Source Warehouses | Stocks not fully moved | Re-run repoint/consolidate | +| ❌ Discrepancy Destinations | Quantity mismatch | Investigate data integrity | +| ❌ Orphaned References | Broken references remain | Manual cleanup needed | + +--- + +## Complete Workflow Example + +### Scenario: Consolidate East Region stocks + +```bash +# Step 1: Understand the scope (Case B issues) +./find-wrong-warehouse-records --report=warehouses --area-name "East Region" +./find-wrong-warehouse-records --report=usage --area-name "East Region" + +# Review the output to understand: +# - How many wrong warehouses +# - How much stock needs moving +# - Which products are affected + +# Step 2: Fix Case B (invalid kandang references) +./repoint-wrong-warehouse-relations --area-name "East Region" +# Review dry-run output + +./repoint-wrong-warehouse-relations --apply --area-name "East Region" +# Watch for summary - should show successful updates + +# Step 3: Fix Case A (general kandang cleanup) +./consolidate-kandang-to-farm-stocks --area-name "East Region" +# Review dry-run output + +./consolidate-kandang-to-farm-stocks --apply --area-name "East Region" +# Watch for summary - should show consolidation complete + +# Step 4: Verify everything worked +./verify-stock-consolidation --area-name "East Region" +# Should show: +# - All source warehouses: CLEAN +# - All destination warehouses: MATCHED +# - Orphaned references: 0 +# - Overall Status: PASS ✅ +``` + +--- + +## Flags Reference + +### Common Flags (All Commands) + +| Flag | Description | Example | +|------|-------------|---------| +| `--output` | Output format | `--output=json` | +| `--area-name` | Filter by area | `--area-name "East Region"` | +| `--kandang-location-name` | Filter by location | `--kandang-location-name "Location 1"` | +| `--db-sslmode` | PostgreSQL SSL mode | `--db-sslmode=require` | + +### Migration-Specific Flags + +| Command | Flag | Description | +|---------|------|-------------| +| `repoint-wrong-warehouse-relations` | `--apply` | Apply changes | +| `repoint-wrong-warehouse-relations` | `--delete-wrong-warehouses` | Delete wrong warehouses (default: true) | +| `consolidate-kandang-to-farm-stocks` | `--apply` | Apply changes | +| `consolidate-kandang-to-farm-stocks` | `--delete-kandang-warehouses` | Delete kandang warehouses (default: true) | +| `verify-stock-consolidation` | `--verify-case` | Verify specific case (A, B, or all) | + +--- + +## Best Practices + +### Before Running Any Command + +1. **Back up the database** — These operations modify stock data +2. **Run in dry-run mode first** — Always preview changes before applying +3. **Check during low-traffic periods** — Avoid peak hours +4. **Have a rollback plan** — Know how to restore from backup if needed + +### When Running Migrations + +1. **Start small** — Use `--area-name` to test on one area first +2. **Check the summary** — Verify numbers make sense +3. **Watch for errors** — Stop if you see unexpected error messages +4. **Run verification immediately after** — Don't wait to verify + +### Red Flags (Stop and Investigate) + +- ❌ More rows affected than expected +- ❌ Negative quantities or zero counts where expecting data +- ❌ Errors about blocked references +- ❌ FIFO conflicts or in-flight artifacts +- ❌ Very large numbers in NEEDS_REFLOW + +### JSON Output for Automation + +All commands support `--output=json` for: +- Piping to other tools +- Parsing in scripts +- Generating reports +- Integration with monitoring systems + +```bash +# Example: Extract all affected warehouses to CSV +./find-wrong-warehouse-records --report=warehouses --output=json \ + | jq -r '.rows[] | [.area_name, .kandang_name, .wrong_warehouse_name] | @csv' \ + > affected_warehouses.csv +``` + +--- + +## Troubleshooting + +### Issue: "No wrong warehouse relations found" +- **Cause**: No matching Case B issues in the filter scope +- **Solution**: Remove filters or use different criteria + +### Issue: "found X rows still point to wrong warehouses" +- **Cause**: References not fully migrated +- **Solution**: Check for blocked references, re-run command + +### Issue: "discrepancy_destinations > 0" in verification +- **Cause**: Quantity mismatch in farm warehouse +- **Solution**: Investigate manually or rollback and retry + +### Issue: "DIRTY source warehouses" in verification +- **Cause**: Deleted warehouses still have stock/references +- **Solution**: May need manual cleanup or re-run migrations + +--- + +## Performance Notes + +- Commands use efficient SQL queries with proper filtering +- Large operations (100K+ rows) may take a few minutes +- Use area/location filters to reduce scope for testing +- Dry-runs don't modify database and complete quickly + +## Support + +For issues or questions: +1. Review the relevant section of this guide +2. Check the command output for specific error messages +3. Run verification to diagnose state issues +4. Contact the development team with JSON outputs from failed operations