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" gorm:"column:case_type"` 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, args := 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 w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM ( SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as 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_a_warehouses UNION ALL SELECT w_id, area_name, kandang_location_name, k_id AS kandang_id, name, case_type FROM ( SELECT w.id as w_id, a.name AS area_name, kl.name AS kandang_location_name, k.id as 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 ) 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.w_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, args...).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, args := 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 kl ON kl.id = fw.location_id JOIN areas a ON a.id = kl.area_id JOIN product_warehouses pw ON pw.warehouse_id = fw.id JOIN products p ON p.id = pw.product_id JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = 'products' AND UPPER(f.name) IN ('PAKAN', 'OVK') 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, args...).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"}, } 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 using raw SQL var ids []uint query := fmt.Sprintf("SELECT DISTINCT %s FROM %s WHERE %s IN ?", ref.column, ref.table, ref.column) if err := db.Raw(query, warehouseIDs).Scan(&ids).Error; err != nil { return nil, err } idStrs := make([]string, 0, len(ids)) for _, id := range ids { idStrs = append(idStrs, 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, []any) { filters := make([]string, 0, 2) args := make([]any, 0, 2) if opts.AreaName != "" { filters = append(filters, "a.name = ?") args = append(args, opts.AreaName) } if opts.KandangLocationName != "" { filters = append(filters, "kl.name = ?") args = append(args, opts.KandangLocationName) } return filters, args } 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 }