mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
cmd: add commands to fix misplaced stocks and move leftover kandang stocks to farm-level warehouse
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user