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 ( outputModeTable = "table" outputModeJSON = "json" reportUsage = "usage" reportWarehouses = "warehouses" ) type options struct { Output string Report string AreaName string KandangLocationName string WrongWarehouseName string CorrectWarehouseName string UsableType string DBSSLMode string } type usageRow struct { UsableType string `gorm:"column:usable_type" json:"usable_type"` UsableID uint `gorm:"column:usable_id" json:"usable_id"` AreaName string `gorm:"column:area_name" json:"area_name"` LokasiName string `gorm:"column:lokasi_name" json:"lokasi_name"` KandangName string `gorm:"column:kandang_name" json:"kandang_name"` WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"` WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"` CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"` CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"` ProductNames string `gorm:"column:product_names" json:"product_names"` SourcePurchaseNumbers string `gorm:"column:source_purchase_numbers" json:"source_purchase_numbers"` SourcePurchaseItemIDs string `gorm:"column:source_purchase_item_ids" json:"source_purchase_item_ids"` QtyFromWrongStock float64 `gorm:"column:qty_from_wrong_stock" json:"qty_from_wrong_stock"` RecordingID *uint `gorm:"column:recording_id" json:"recording_id,omitempty"` RecordingDate *string `gorm:"column:recording_date" json:"recording_date,omitempty"` SoNumber *string `gorm:"column:so_number" json:"so_number,omitempty"` SoDate *string `gorm:"column:so_date" json:"so_date,omitempty"` } type warehouseMismatchRow struct { AreaName string `gorm:"column:area_name" json:"area_name"` WrongLocationName string `gorm:"column:wrong_location_name" json:"wrong_location_name"` KandangLocationName string `gorm:"column:kandang_location_name" json:"kandang_location_name"` KandangID uint `gorm:"column:kandang_id" json:"kandang_id"` KandangName string `gorm:"column:kandang_name" json:"kandang_name"` WrongWarehouseID uint `gorm:"column:wrong_warehouse_id" json:"wrong_warehouse_id"` WrongWarehouseName string `gorm:"column:wrong_warehouse_name" json:"wrong_warehouse_name"` WrongWarehouseType string `gorm:"column:wrong_warehouse_type" json:"wrong_warehouse_type"` CorrectWarehouseID uint `gorm:"column:correct_warehouse_id" json:"correct_warehouse_id"` CorrectWarehouseName string `gorm:"column:correct_warehouse_name" json:"correct_warehouse_name"` } type summary struct { Rows int `json:"rows"` TotalQty float64 `json:"total_qty,omitempty"` } func main() { opts, err := parseFlags() if err != nil { log.Fatalf("invalid flags: %v", err) } ctx := context.Background() if opts.DBSSLMode != "" { config.DBSSLMode = opts.DBSSLMode } db := database.Connect(config.DBHost, config.DBName) switch opts.Report { case reportUsage: rows, err := loadUsageRows(ctx, db, opts) if err != nil { log.Fatalf("failed loading usage rows: %v", err) } renderUsageReport(opts.Output, rows) case reportWarehouses: rows, err := loadWarehouseMismatchRows(ctx, db, opts) if err != nil { log.Fatalf("failed loading warehouse mismatch rows: %v", err) } renderWarehouseReport(opts.Output, rows) default: log.Fatalf("unsupported --report=%s", opts.Report) } } func parseFlags() (*options, error) { var opts options flag.StringVar(&opts.Output, "output", outputModeTable, "Output format: table or json") flag.StringVar(&opts.Report, "report", reportUsage, "Report type: usage or warehouses") 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.WrongWarehouseName, "wrong-warehouse-name", "", "Optional exact wrong warehouse name filter") flag.StringVar(&opts.CorrectWarehouseName, "correct-warehouse-name", "", "Optional exact correct warehouse name filter") flag.StringVar(&opts.UsableType, "usable-type", "", "Optional usage type filter: RECORDING_STOCK or MARKETING_DELIVERY") flag.StringVar(&opts.DBSSLMode, "db-sslmode", "", "Optional database sslmode override, for example: require") flag.Parse() opts.Output = strings.ToLower(strings.TrimSpace(opts.Output)) opts.Report = strings.ToLower(strings.TrimSpace(opts.Report)) opts.AreaName = strings.TrimSpace(opts.AreaName) opts.KandangLocationName = strings.TrimSpace(opts.KandangLocationName) opts.WrongWarehouseName = strings.TrimSpace(opts.WrongWarehouseName) opts.CorrectWarehouseName = strings.TrimSpace(opts.CorrectWarehouseName) opts.UsableType = strings.ToUpper(strings.TrimSpace(opts.UsableType)) opts.DBSSLMode = strings.TrimSpace(opts.DBSSLMode) if opts.Output == "" { opts.Output = outputModeTable } if opts.Output != outputModeTable && opts.Output != outputModeJSON { return nil, fmt.Errorf("unsupported --output=%s", opts.Output) } if opts.Report == "" { opts.Report = reportUsage } if opts.Report != reportUsage && opts.Report != reportWarehouses { return nil, fmt.Errorf("unsupported --report=%s", opts.Report) } if opts.UsableType != "" && opts.UsableType != "RECORDING_STOCK" && opts.UsableType != "MARKETING_DELIVERY" { return nil, fmt.Errorf("unsupported --usable-type=%s", opts.UsableType) } return &opts, nil } func loadUsageRows(ctx context.Context, db *gorm.DB, opts *options) ([]usageRow, error) { warehouseFilters, warehouseArgs := buildWarehouseFilters(opts) usageFilters := make([]string, 0, 1) usageArgs := make([]any, 0, 1) if opts.UsableType != "" { usageFilters = append(usageFilters, "sa.usable_type = ?") usageArgs = append(usageArgs, opts.UsableType) } args := append([]any{}, warehouseArgs...) args = append(args, usageArgs...) query := fmt.Sprintf(` WITH wrong_warehouses AS ( SELECT w.id AS wrong_warehouse_id, w.name AS wrong_warehouse_name, k.id AS kandang_id, k.name AS kandang_name, a.name AS area_name, kl.name AS kandang_location_name, correct_w.id AS correct_warehouse_id, correct_w.name AS correct_warehouse_name 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 JOIN LATERAL ( SELECT w2.id, w2.name FROM warehouses w2 WHERE w2.kandang_id = w.kandang_id AND w2.location_id = k.location_id AND w2.deleted_at IS NULL AND w2.id <> w.id ORDER BY w2.id ASC LIMIT 1 ) AS correct_w ON TRUE WHERE w.deleted_at IS NULL AND w.kandang_id IS NOT NULL AND w.location_id IS DISTINCT FROM k.location_id %s ), wrong_allocs AS ( SELECT sa.usable_type, sa.usable_id, sa.qty, pi.id AS purchase_item_id, COALESCE(p.po_number, p.pr_number) AS purchase_number, pr.name AS product_name, ww.area_name, ww.kandang_location_name, ww.kandang_name, ww.wrong_warehouse_id, ww.wrong_warehouse_name, ww.correct_warehouse_id, ww.correct_warehouse_name FROM stock_allocations sa JOIN purchase_items pi ON pi.id = sa.stockable_id JOIN purchases p ON p.id = pi.purchase_id AND p.deleted_at IS NULL JOIN products pr ON pr.id = pi.product_id JOIN wrong_warehouses ww ON ww.wrong_warehouse_id = pi.warehouse_id WHERE sa.stockable_type = 'PURCHASE_ITEMS' AND sa.status = 'ACTIVE' AND sa.allocation_purpose = 'CONSUME' AND sa.deleted_at IS NULL %s ) SELECT wa.usable_type, wa.usable_id, wa.area_name, wa.kandang_location_name AS lokasi_name, wa.kandang_name, wa.wrong_warehouse_id, wa.wrong_warehouse_name, wa.correct_warehouse_id, wa.correct_warehouse_name, STRING_AGG(DISTINCT wa.product_name, ' | ') AS product_names, STRING_AGG(DISTINCT wa.purchase_number, ', ') AS source_purchase_numbers, STRING_AGG(DISTINCT wa.purchase_item_id::text, ', ') AS source_purchase_item_ids, SUM(wa.qty) AS qty_from_wrong_stock, rs.recording_id, TO_CHAR(r.record_datetime::date, 'YYYY-MM-DD') AS recording_date, m.so_number, TO_CHAR(m.so_date::date, 'YYYY-MM-DD') AS so_date FROM wrong_allocs wa LEFT JOIN recording_stocks rs ON wa.usable_type = 'RECORDING_STOCK' AND rs.id = wa.usable_id LEFT JOIN recordings r ON r.id = rs.recording_id LEFT JOIN marketing_delivery_products mdp ON wa.usable_type = 'MARKETING_DELIVERY' AND mdp.id = wa.usable_id LEFT JOIN marketing_products mp ON mp.id = mdp.marketing_product_id LEFT JOIN marketings m ON m.id = mp.marketing_id GROUP BY wa.usable_type, wa.usable_id, wa.area_name, wa.kandang_location_name, wa.kandang_name, wa.wrong_warehouse_id, wa.wrong_warehouse_name, wa.correct_warehouse_id, wa.correct_warehouse_name, rs.recording_id, r.record_datetime, m.so_number, m.so_date ORDER BY wa.area_name ASC, wa.kandang_location_name ASC, wa.wrong_warehouse_name ASC, wa.usable_type ASC, wa.usable_id ASC `, andClause(warehouseFilters), andClause(usageFilters)) rows := make([]usageRow, 0) if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { return nil, err } return rows, nil } func loadWarehouseMismatchRows(ctx context.Context, db *gorm.DB, opts *options) ([]warehouseMismatchRow, error) { warehouseFilters, args := buildWarehouseFilters(opts) query := fmt.Sprintf(` SELECT a.name AS area_name, wl.name AS wrong_location_name, kl.name AS kandang_location_name, k.id AS kandang_id, k.name AS kandang_name, w.id AS wrong_warehouse_id, w.name AS wrong_warehouse_name, w.type AS wrong_warehouse_type, correct_w.id AS correct_warehouse_id, correct_w.name AS correct_warehouse_name 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 LEFT JOIN locations wl ON wl.id = w.location_id JOIN LATERAL ( SELECT w2.id, w2.name FROM warehouses w2 WHERE w2.kandang_id = w.kandang_id AND w2.location_id = k.location_id AND w2.deleted_at IS NULL AND w2.id <> w.id ORDER BY w2.id ASC LIMIT 1 ) AS correct_w ON TRUE WHERE w.deleted_at IS NULL AND w.kandang_id IS NOT NULL AND w.location_id IS DISTINCT FROM k.location_id %s ORDER BY a.name ASC, kl.name ASC, k.name ASC, w.id ASC `, andClause(warehouseFilters)) rows := make([]warehouseMismatchRow, 0) if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { return nil, err } return rows, nil } func buildWarehouseFilters(opts *options) ([]string, []any) { filters := make([]string, 0, 4) args := make([]any, 0, 4) if opts == nil { return filters, args } 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) } if opts.WrongWarehouseName != "" { filters = append(filters, "w.name = ?") args = append(args, opts.WrongWarehouseName) } if opts.CorrectWarehouseName != "" { filters = append(filters, "correct_w.name = ?") args = append(args, opts.CorrectWarehouseName) } return filters, args } func andClause(filters []string) string { if len(filters) == 0 { return "" } return " AND " + strings.Join(filters, " AND ") } func renderUsageReport(mode string, rows []usageRow) { if mode == outputModeJSON { payload := map[string]any{ "report": reportUsage, "rows": rows, "summary": summarizeUsage(rows), } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(payload) return } w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) fmt.Fprintln(w, "USABLE_TYPE\tUSABLE_ID\tAREA\tLOKASI\tKANDANG\tWRONG_WAREHOUSE\tCORRECT_WAREHOUSE\tPRODUCTS\tQTY_FROM_WRONG_STOCK\tRECORDING_ID\tRECORDING_DATE\tSO_NUMBER\tSO_DATE\tSOURCE_PURCHASES\tSOURCE_PURCHASE_ITEM_IDS") for _, row := range rows { fmt.Fprintf( w, "%s\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%.3f\t%s\t%s\t%s\t%s\t%s\t%s\n", row.UsableType, row.UsableID, row.AreaName, row.LokasiName, row.KandangName, row.WrongWarehouseName, row.CorrectWarehouseName, row.ProductNames, row.QtyFromWrongStock, displayOptionalUint(row.RecordingID), displayOptionalString(row.RecordingDate), displayOptionalString(row.SoNumber), displayOptionalString(row.SoDate), row.SourcePurchaseNumbers, row.SourcePurchaseItemIDs, ) } _ = w.Flush() s := summarizeUsage(rows) fmt.Printf("\nSummary: rows=%d total_qty=%.3f\n", s.Rows, s.TotalQty) } func renderWarehouseReport(mode string, rows []warehouseMismatchRow) { if mode == outputModeJSON { payload := map[string]any{ "report": reportWarehouses, "rows": rows, "summary": summary{Rows: len(rows)}, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") _ = enc.Encode(payload) return } w := tabwriter.NewWriter(os.Stdout, 2, 8, 2, ' ', 0) fmt.Fprintln(w, "AREA\tKANDANG_LOCATION\tKANDANG_ID\tKANDANG\tWRONG_LOCATION\tWRONG_WAREHOUSE_ID\tWRONG_WAREHOUSE\tWRONG_WAREHOUSE_TYPE\tCORRECT_WAREHOUSE_ID\tCORRECT_WAREHOUSE") for _, row := range rows { fmt.Fprintf( w, "%s\t%s\t%d\t%s\t%s\t%d\t%s\t%s\t%d\t%s\n", row.AreaName, row.KandangLocationName, row.KandangID, row.KandangName, row.WrongLocationName, row.WrongWarehouseID, row.WrongWarehouseName, row.WrongWarehouseType, row.CorrectWarehouseID, row.CorrectWarehouseName, ) } _ = w.Flush() fmt.Printf("\nSummary: rows=%d\n", len(rows)) } func summarizeUsage(rows []usageRow) summary { out := summary{Rows: len(rows)} for _, row := range rows { out.TotalQty += row.QtyFromWrongStock } return out } func displayOptionalUint(value *uint) string { if value == nil || *value == 0 { return "-" } return fmt.Sprintf("%d", *value) } func displayOptionalString(value *string) string { if value == nil || strings.TrimSpace(*value) == "" { return "-" } return *value }