// fix-inventory-products —— 一次性修复「历史库存导入把不同编号合并到同一 product」的数据。 // // 背景:旧的 ImportInventory 用「名称|系列|规格」匹配 product,忽略商品编号, // 导致很多本应各自独立(不同 ZXZ 序列号)的库存行共用了同一个 product,屏幕上 // 显示成重复编号。每行真实的商品编号 + 名称/系列/规格/单位/生产日期/批次/进价 // 都完整保存在该库存行自身的快照列里(inventories.product_code 等),只是外键 // product_id 指错了。 // // 修复(就地、不删数据、幂等):遍历目标门店活跃库存行,对每行: // 1. 按 (shop_id, code=inv.product_code) 找 product; // 2. 找不到就用该行快照新建一个独立 product(保留原始编号); // 3. 把 inv.product_id 重指到它。 // // 用法(在 backend/ 目录下): // // go run ./cmd/fix-inventory-products --shop-id 8 --dry-run // go run ./cmd/fix-inventory-products --shop-id 8 // # 远端:交叉编译后 scp,--env-file 读 production.env 的 DATABASE_DSN package main import ( "flag" "log" "os" "strings" "github.com/google/uuid" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/util" ) func main() { var ( shopID = flag.Uint64("shop-id", 0, "目标门店 id") dryRun = flag.Bool("dry-run", false, "只统计不写库") envFile = flag.String("env-file", "", "可选:systemd 风格 KEY=VALUE 环境文件(远端读 DATABASE_DSN)") ) flag.Parse() if *envFile != "" { loadEnvFile(*envFile) } config.Load() db, err := gorm.Open(mysql.Open(config.C.Database.DSN), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatalf("连接数据库失败: %v", err) } if *shopID == 0 { log.Fatal("必须提供 --shop-id") } var shop model.Shop if err := db.First(&shop, *shopID).Error; err != nil { log.Fatalf("门店 id=%d 不存在: %v", *shopID, err) } log.Printf("目标门店: %s (code=%s, id=%d) dry-run=%v", shop.Name, shop.Code, shop.ID, *dryRun) if err := run(db, shop.ID, *dryRun); err != nil { log.Fatalf("修复失败: %v", err) } } // run 执行修复(导出供测试)。 func run(db *gorm.DB, shopID uint64, dryRun bool) error { // 预加载该店所有未删 product,建 code→product 索引(避免逐行查询)。 var products []model.Product if err := db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&products).Error; err != nil { return err } byCode := make(map[string]*model.Product, len(products)) for i := range products { p := &products[i] if p.Code != "" { byCode[p.Code] = p } } // 遍历活跃库存行(按 id 升序,稳定可复现)。 var invs []model.Inventory if err := db.Where("shop_id = ? AND deleted_at IS NULL", shopID). Order("id ASC").Find(&invs).Error; err != nil { return err } var ( created int // 新建的 product 数 repointed int // 重指 product_id 的库存行数 skipped int // 已正确(product_id 已指向同编号 product) noCode int // 快照无编号,跳过(理论上为 0) ) for i := range invs { inv := &invs[i] code := strings.TrimSpace(inv.ProductCode) if code == "" { noCode++ continue } // 1. 找/建该编号的 product prod := byCode[code] if prod == nil { np := buildProductFromInv(shopID, inv) if dryRun { // dry-run 不写库,但要在缓存里占位,避免同编号重复计入 created np.Code = code byCode[code] = &np created++ } else { if err := db.Create(&np).Error; err != nil { return err } byCode[code] = &np created++ } prod = byCode[code] } // 2. 重指 product_id(已正确则跳过) if inv.ProductID != nil && *inv.ProductID == prod.ID { skipped++ continue } repointed++ if !dryRun { if err := db.Model(&model.Inventory{}). Where("id = ?", inv.ID). Update("product_id", prod.ID).Error; err != nil { return err } } } log.Printf("库存行总数=%d | 新建 product=%d | 重指 product_id=%d | 已正确跳过=%d | 无编号跳过=%d", len(invs), created, repointed, skipped, noCode) if dryRun { log.Printf("[dry-run] 未写库") } return nil } // buildProductFromInv 用库存行快照构造一个独立 product(保留原始编号)。 func buildProductFromInv(shopID uint64, inv *model.Inventory) model.Product { full, initials := util.ToPinyin(inv.ProductName) p := model.Product{ TenantBase: model.TenantBase{ShopID: shopID}, PublicID: uuid.New().String(), Code: inv.ProductCode, Name: inv.ProductName, Series: inv.Series, Spec: inv.Spec, Unit: inv.Unit, BatchNo: inv.BatchNo, NamePinyin: full, NameInitials: initials, } if inv.ProductionDate != nil { d := *inv.ProductionDate p.ProductionDate = &d } if inv.UnitPrice != nil { p.PurchasePrice = *inv.UnitPrice } return p } // loadEnvFile 解析 systemd EnvironmentFile 风格 KEY=VALUE,值原样保留(不做 shell 解引号), // 安全处理 DSN 中的 & ( ) ? 等特殊字符。config.Load() 随后经 AutomaticEnv 读 DATABASE_DSN。 func loadEnvFile(path string) { data, err := os.ReadFile(path) if err != nil { log.Fatalf("读取 env 文件 %s 失败: %v", path, err) } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } eq := strings.IndexByte(line, '=') if eq <= 0 { continue } key := strings.TrimSpace(line[:eq]) val := strings.TrimSpace(line[eq+1:]) if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] { val = val[1 : len(val)-1] } _ = os.Setenv(key, val) } }