diff --git a/cmd/reflow-quantity-product-warehouse-from-stock-allocation/main.go b/cmd/reflow-quantity-product-warehouse-from-stock-allocation/main.go new file mode 100644 index 00000000..a4259bf1 --- /dev/null +++ b/cmd/reflow-quantity-product-warehouse-from-stock-allocation/main.go @@ -0,0 +1,282 @@ +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 +}