mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
467 lines
14 KiB
Go
467 lines
14 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 (
|
|
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
|
|
}
|