From 6faadf8670bfc57a274f8314d26199209ada8ba3 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 21 Jun 2026 21:24:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=E5=BA=93=E5=AD=98=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E6=8C=89=E5=95=86=E5=93=81=E7=BC=96=E5=8F=B7=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E4=B8=8D=E5=86=8D=E6=8C=89=E5=90=8D=E7=A7=B0=E5=90=88?= =?UTF-8?q?=E5=B9=B6=20+=20=E6=96=B0=E5=A2=9E=20fix-inventory-products=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx --- backend/cmd/fix-inventory-products/main.go | 197 ++++++++++++++++++ .../cmd/fix-inventory-products/main_test.go | 101 +++++++++ backend/internal/handler/import.go | 25 ++- 3 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 backend/cmd/fix-inventory-products/main.go create mode 100644 backend/cmd/fix-inventory-products/main_test.go diff --git a/backend/cmd/fix-inventory-products/main.go b/backend/cmd/fix-inventory-products/main.go new file mode 100644 index 0000000..2380862 --- /dev/null +++ b/backend/cmd/fix-inventory-products/main.go @@ -0,0 +1,197 @@ +// 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) + } +} diff --git a/backend/cmd/fix-inventory-products/main_test.go b/backend/cmd/fix-inventory-products/main_test.go new file mode 100644 index 0000000..caad5a5 --- /dev/null +++ b/backend/cmd/fix-inventory-products/main_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "testing" + + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// 模拟 bug:两条不同编号(ZXZ001/ZXZ002)、同名同系列同规格的库存行, +// 因旧导入按「名称|系列|规格」合并,都错指向同一个 product(ZXZ001)。 +// 修复后:每行应指向「编号与自己一致」的独立 product。 +func TestRun_RebuildsPerCodeProduct(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "FIX001") + + // 已有一个 product,编号 ZXZ001(合并目标) + wrong := model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Code: "ZXZ001", Name: "茅台", Series: "飞天", Spec: "500ml", Unit: "瓶", + } + if err := db.Create(&wrong).Error; err != nil { + t.Fatalf("建 product 失败: %v", err) + } + wrongID := wrong.ID + + // 两条库存行:各自带真实编号,但都错指 wrong + price := 1314.0 + mk := func(code string) { + if err := db.Create(&model.Inventory{ + ShopID: shop.ID, ProductID: &wrongID, Quantity: 1, + ProductCode: code, ProductName: "茅台", Series: "飞天", Spec: "500ml", + Unit: "瓶", BatchNo: "B" + code, UnitPrice: &price, + }).Error; err != nil { + t.Fatalf("建库存失败: %v", err) + } + } + mk("ZXZ001") + mk("ZXZ002") + + if err := run(db, shop.ID, false); err != nil { + t.Fatalf("run 失败: %v", err) + } + + // 每行的 product.code 应等于该行快照编号 + var invs []model.Inventory + db.Where("shop_id = ?", shop.ID).Order("id ASC").Find(&invs) + if len(invs) != 2 { + t.Fatalf("库存行数=%d 应为 2", len(invs)) + } + seen := map[uint64]bool{} + for _, inv := range invs { + if inv.ProductID == nil { + t.Fatalf("行 %d product_id 为空", inv.ID) + } + var p model.Product + if err := db.First(&p, *inv.ProductID).Error; err != nil { + t.Fatalf("查 product 失败: %v", err) + } + if p.Code != inv.ProductCode { + t.Errorf("行 %d: product.code=%q 应=%q", inv.ID, p.Code, inv.ProductCode) + } + // 新建的 product 应带上快照信息 + 拼音 + if p.Code == "ZXZ002" { + if p.BatchNo != "BZXZ002" || p.PurchasePrice != price || p.NamePinyin == "" { + t.Errorf("新建 product 信息不全: %+v", p) + } + } + seen[*inv.ProductID] = true + } + if len(seen) != 2 { + t.Errorf("两行应指向 2 个不同 product,实际 %d", len(seen)) + } +} + +// dry-run 不写库 +func TestRun_DryRunNoWrite(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "FIX002") + wrong := model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Code: "ZXZ001", Name: "茅台", Series: "飞天", Spec: "500ml", + } + db.Create(&wrong) + wrongID := wrong.ID + db.Create(&model.Inventory{ + ShopID: shop.ID, ProductID: &wrongID, Quantity: 1, + ProductCode: "ZXZ999", ProductName: "茅台", Series: "飞天", Spec: "500ml", + }) + + var before int64 + db.Model(&model.Product{}).Where("shop_id = ?", shop.ID).Count(&before) + if err := run(db, shop.ID, true); err != nil { + t.Fatalf("dry-run 失败: %v", err) + } + var after int64 + db.Model(&model.Product{}).Where("shop_id = ?", shop.ID).Count(&after) + if before != after { + t.Errorf("dry-run 不应建 product:before=%d after=%d", before, after) + } +} diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index 012ea1f..1ae39d0 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -633,12 +633,13 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) { res.total++ // 有商品名称的行才计入总数 - // 从缓存查商品,找不到才创建 + // 从缓存查商品,找不到才创建。 + // 有商品编号时:只按编号匹配,绝不回退「名称|系列|规格」合并——同名不同编号 + // 是不同的特有产品/序列号,回退名称会把它们错并到同一 product(历史 bug 根因)。 var prod *model.Product if productCode != "" { prod = productByCode[productCode] - } - if prod == nil { + } else { prod = productByNSS[productName+"|"+series+"|"+spec] } if prod == nil { @@ -912,14 +913,18 @@ func parseUploadedExcel(c *gin.Context) ([][]string, error) { func findOrCreateProductFn(db *gorm.DB, shopID uint64, code, name, series, spec string) (model.Product, error) { var p model.Product - if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", - shopID, name, series, spec).First(&p).Error == nil { - // 若现有商品没有编码,补填 - if p.Code == "" && code != "" { - db.Model(&p).Update("code", code) - p.Code = code + if code != "" { + // 有编号:按「编号」唯一匹配/创建(编号即特有产品序列号), + // 绝不按「名称|系列|规格」合并——同名不同编号是不同商品。 + if db.Where("shop_id = ? AND code = ? AND deleted_at IS NULL", shopID, code).First(&p).Error == nil { + return p, nil + } + } else { + // 无编号:退回「名称|系列|规格」匹配(供 ImportStockIn/Out 等无编号场景)。 + if db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL", + shopID, name, series, spec).First(&p).Error == nil { + return p, nil } - return p, nil } p = model.Product{ TenantBase: model.TenantBase{ShopID: shopID},