Files
lti-api/cmd/reflow-quantity-product-warehouse-from-stock-allocation/main.go
T

283 lines
7.1 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/config"
"gitlab.com/mbugroup/lti-api.git/internal/database"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
const qtyEpsilon = 1e-6
const (
levelAll = 1
levelByProductName = 2
levelByProductWarehouse = 3
)
type reflowRow struct {
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
CurrentQty float64 `gorm:"column:current_qty"`
SumTotalQty float64 `gorm:"column:sum_total_qty"`
SumAllocatedQty float64 `gorm:"column:sum_allocated_qty"`
ComputedQty float64 `gorm:"column:computed_qty"`
}
func main() {
var (
apply bool
level int
productName string
productWarehouseID uint
)
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
flag.IntVar(&level, "level", levelAll, "CLI level: 1=all product_warehouse scope, 2=product name scope, 3=product_warehouse_id scope")
flag.StringVar(&productName, "product-name", "", "Product name (required for level 2)")
flag.UintVar(&productWarehouseID, "product-warehouse-id", 0, "Product warehouse id (required for level 3)")
flag.Parse()
productName = strings.TrimSpace(productName)
if err := validateFlags(level, productName, productWarehouseID); err != nil {
log.Fatalf("invalid flags: %v", err)
}
ctx := context.Background()
db := database.Connect(config.DBHost, config.DBName)
rows, err := loadReflowRows(ctx, db, level, productName, productWarehouseID)
if err != nil {
log.Fatalf("failed to calculate reflow qty: %v", err)
}
fmt.Printf("Mode: %s\n", modeLabel(apply))
fmt.Printf("Level: %d (%s)\n", level, levelLabel(level))
if productName != "" {
fmt.Printf("Filter product_name: %s\n", productName)
}
if productWarehouseID > 0 {
fmt.Printf("Filter product_warehouse_id: %d\n", productWarehouseID)
}
fmt.Printf("Targets found: %d\n\n", len(rows))
if len(rows) == 0 {
fmt.Println("No product warehouse found from purchase_items scope")
return
}
negativePlan := 0
for _, row := range rows {
if row.ComputedQty < 0 {
negativePlan++
}
fmt.Printf(
"PLAN pw=%d product_id=%d product=%q current_qty=%.3f total_qty=%.3f allocated_qty=%.3f computed_qty=%.3f delta=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.SumTotalQty,
row.SumAllocatedQty,
row.ComputedQty,
row.ComputedQty-row.CurrentQty,
)
}
if !apply {
fmt.Println()
fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0 negative_plan=%d\n", len(rows), negativePlan)
return
}
updated := 0
skipped := 0
negativeUpdated := 0
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, row := range rows {
if nearlyEqual(row.CurrentQty, row.ComputedQty) {
fmt.Printf(
"SKIP pw=%d reason=no_change current_qty=%.3f computed_qty=%.3f\n",
row.ProductWarehouseID,
row.CurrentQty,
row.ComputedQty,
)
skipped++
continue
}
if err := tx.Table("product_warehouses").
Where("id = ?", row.ProductWarehouseID).
Update("qty", row.ComputedQty).Error; err != nil {
return fmt.Errorf("update qty for product_warehouse_id=%d: %w", row.ProductWarehouseID, err)
}
if row.ComputedQty < 0 {
negativeUpdated++
}
fmt.Printf(
"DONE pw=%d product_id=%d product=%q old_qty=%.3f new_qty=%.3f\n",
row.ProductWarehouseID,
row.ProductID,
row.ProductName,
row.CurrentQty,
row.ComputedQty,
)
updated++
}
return nil
})
if err != nil {
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=1 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
log.Printf("error: %v", err)
os.Exit(1)
}
fmt.Println()
fmt.Printf(
"Summary: planned=%d updated=%d skipped=%d failed=0 negative_plan=%d negative_updated=%d\n",
len(rows),
updated,
skipped,
negativePlan,
negativeUpdated,
)
}
func validateFlags(level int, productName string, productWarehouseID uint) error {
switch level {
case levelAll:
if productName != "" {
return errors.New("--product-name cannot be used on level 1")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 1")
}
case levelByProductName:
if productName == "" {
return errors.New("--product-name is required on level 2")
}
if productWarehouseID > 0 {
return errors.New("--product-warehouse-id cannot be used on level 2")
}
case levelByProductWarehouse:
if productWarehouseID == 0 {
return errors.New("--product-warehouse-id is required on level 3")
}
if productName != "" {
return errors.New("--product-name cannot be used on level 3")
}
default:
return fmt.Errorf("unsupported --level=%d (allowed: 1, 2, 3)", level)
}
return nil
}
func loadReflowRows(
ctx context.Context,
db *gorm.DB,
level int,
productName string,
productWarehouseID uint,
) ([]reflowRow, error) {
allocSub := db.WithContext(ctx).
Table("stock_allocations sa").
Select(`
sa.stockable_id,
COALESCE(SUM(sa.qty), 0) AS used_qty
`).
Where("sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.deleted_at IS NULL").
Group("sa.stockable_id")
calcSub := db.WithContext(ctx).
Table("purchase_items pi").
Select(`
pi.product_warehouse_id,
COALESCE(SUM(pi.total_qty), 0) AS sum_total_qty,
COALESCE(SUM(COALESCE(alloc.used_qty, 0)), 0) AS sum_allocated_qty,
COALESCE(SUM(COALESCE(pi.total_qty, 0) - COALESCE(alloc.used_qty, 0)), 0) AS computed_qty
`).
Joins("LEFT JOIN (?) alloc ON alloc.stockable_id = pi.id", allocSub).
Where("pi.product_warehouse_id IS NOT NULL").
Group("pi.product_warehouse_id")
query := db.WithContext(ctx).
Table("product_warehouses pw").
Select(`
pw.id AS product_warehouse_id,
pw.product_id AS product_id,
p.name AS product_name,
COALESCE(pw.qty, 0) AS current_qty,
calc.sum_total_qty,
calc.sum_allocated_qty,
calc.computed_qty
`).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN (?) calc ON calc.product_warehouse_id = pw.id", calcSub).
Order("pw.id ASC")
switch level {
case levelByProductName:
query = query.Where("LOWER(p.name) = LOWER(?)", productName)
case levelByProductWarehouse:
query = query.Where("pw.id = ?", productWarehouseID)
}
rows := make([]reflowRow, 0)
if err := query.Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func modeLabel(apply bool) string {
if apply {
return "APPLY"
}
return "DRY-RUN"
}
func levelLabel(level int) string {
switch level {
case levelAll:
return "all product_warehouse from purchase_items"
case levelByProductName:
return "specific product name"
case levelByProductWarehouse:
return "specific product_warehouse_id"
default:
return "unknown"
}
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= qtyEpsilon
}