fix(backend): 库存导入按商品编号匹配不再按名称合并 + 新增 fix-inventory-products 修复工具
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user