cmd: add commands to fix misplaced stocks and move leftover kandang stocks to farm-level warehouse

This commit is contained in:
Adnan Zahir
2026-04-23 21:09:04 +07:00
parent e45ebca5a4
commit 891da70efb
4 changed files with 2540 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
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
}
File diff suppressed because it is too large Load Diff