mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 05:21:57 +00:00
283 lines
7.1 KiB
Go
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
|
|
}
|