Files
jiu/backend/cmd/fix-inventory-products/main.go
T

198 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}