diff --git a/cmd/adjust-quantity-product-warehouse-from-purchase/main.go b/cmd/adjust-quantity-product-warehouse-from-purchase/main.go new file mode 100644 index 00000000..2d7ab9fb --- /dev/null +++ b/cmd/adjust-quantity-product-warehouse-from-purchase/main.go @@ -0,0 +1,291 @@ +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" + "gorm.io/gorm" +) + +const ( + levelAllNoFlagProducts = 1 + levelProductName = 2 + levelProductWarehouse = 3 + qtyEpsilon = 1e-6 +) + +type targetRow 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"` + ComputedQty float64 `gorm:"column:computed_qty"` +} + +func main() { + var ( + level int + productName string + productWarehouseID uint + apply bool + ) + + flag.IntVar( + &level, + "level", + levelAllNoFlagProducts, + "CLI level: 1=all products without flags, 2=specific product name (without flags), 3=specific product warehouse id", + ) + 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.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run") + 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) + + targets, err := loadTargets(ctx, db, level, productName, productWarehouseID) + if err != nil { + log.Fatalf("failed to load target product warehouses: %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(targets)) + + if len(targets) == 0 { + fmt.Println("No matching product warehouse rows to process") + return + } + + for _, row := range targets { + fmt.Printf( + "PLAN pw=%d product_id=%d product=%q current_qty=%.3f computed_qty=%.3f delta=%.3f\n", + row.ProductWarehouseID, + row.ProductID, + row.ProductName, + row.CurrentQty, + row.ComputedQty, + row.ComputedQty-row.CurrentQty, + ) + } + + if !apply { + fmt.Println() + fmt.Printf("Summary: planned=%d updated=0 skipped=0 failed=0\n", len(targets)) + return + } + + updated := 0 + skipped := 0 + err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, row := range targets { + 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) + } + + 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\n", len(targets), updated, skipped) + log.Printf("error: %v", err) + os.Exit(1) + } + + fmt.Println() + fmt.Printf("Summary: planned=%d updated=%d skipped=%d failed=0\n", len(targets), updated, skipped) +} + +func validateFlags(level int, productName string, productWarehouseID uint) error { + switch level { + case levelAllNoFlagProducts: + 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 levelProductName: + 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 levelProductWarehouse: + 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 loadTargets( + ctx context.Context, + db *gorm.DB, + level int, + productName string, + productWarehouseID uint, +) ([]targetRow, error) { + switch level { + case levelAllNoFlagProducts: + return loadTargetsLevel1ByProductWithoutFlags(ctx, db) + case levelProductName: + return loadTargetsLevel2ByProductWarehouseWithoutFlags(ctx, db, productName) + case levelProductWarehouse: + return loadTargetByProductWarehouseID(ctx, db, productWarehouseID) + default: + return nil, fmt.Errorf("unsupported level %d", level) + } +} + +func loadTargetsLevel1ByProductWithoutFlags(ctx context.Context, db *gorm.DB) ([]targetRow, error) { + rows := make([]targetRow, 0) + if err := 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, + COALESCE(SUM(pi.total_qty), 0) AS computed_qty + `). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("p.deleted_at IS NULL"). + Where("f.id IS NULL"). + Group("pw.id, pw.product_id, p.name, pw.qty"). + Order("pw.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func loadTargetsLevel2ByProductWarehouseWithoutFlags( + ctx context.Context, + db *gorm.DB, + productName string, +) ([]targetRow, error) { + rows := make([]targetRow, 0) + if err := 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, + COALESCE(SUM(pi.total_qty), 0) AS computed_qty + `). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("p.deleted_at IS NULL"). + Where("f.id IS NULL"). + Where("LOWER(p.name) = LOWER(?)", productName). + Group("pw.id, pw.product_id, p.name, pw.qty"). + Order("pw.id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + + return rows, nil +} + +func loadTargetByProductWarehouseID(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]targetRow, error) { + rows := make([]targetRow, 0) + if err := 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, + COALESCE(SUM(pi.total_qty), 0) AS computed_qty + `). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id"). + Where("pw.id = ?", productWarehouseID). + Group("pw.id, pw.product_id, p.name, pw.qty"). + 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 levelAllNoFlagProducts: + return "all products without flags (source: purchase_items by product_warehouse_id)" + case levelProductName: + return "specific product name without flags (source: purchase_items by product_warehouse_id)" + case levelProductWarehouse: + return "specific product_warehouse_id (source: purchase_items by product_warehouse_id)" + default: + return "unknown" + } +} + +func nearlyEqual(a, b float64) bool { + return math.Abs(a-b) <= qtyEpsilon +}