Files
lti-api/cmd/verify-stock-consolidation/main.go
T
2026-04-23 22:41:32 +07:00

507 lines
15 KiB
Go

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 true
JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = 'products' AND UPPER(f.name) 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
}