mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 21:41:55 +00:00
525 lines
16 KiB
Go
525 lines
16 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" 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
|
|
}
|