Files
jiu/backend/cmd/fix-history-prices/main.go
T
wangjia e19c498dba
Design Source Checks / design-source (push) Successful in 39s
fix(backend): 历史导入出库明细价格错列——售价误落成本列,新增修复工具
根因:旧系统出库明细「单价/金额」是售价口径(应收侧),import-history 误落
cost_price/cost_amount,sale_price 留 0(整单显示待定价);单头 sale_total
却按售价口径正确落库,明细与单头矛盾。实证:ZXZ027628 出库"成本"14300=售价,
真实进价 13600 在库存/入库侧。

- import-history:出库明细「单价/金额」改落 sale_price/sale_amount(成本留 0 待回查)
- 新增 cmd/fix-history-prices 一次性修复线上数据:售价归位 + 按商品编号
  回查真实进价(①入库明细 ②库存快照 ③商品进价)+ profit_total 同口径重算;
  sale_total 不动(应收不变);默认 dry-run,--apply 落库,幂等可重跑
- 测试:四种成本来源/dry-run 回滚/幂等/非占位明细不波及/利润应收口径

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 16:55:03 +08:00

269 lines
9.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-history-prices —— 一次性修复 import-history 出库明细的价格错列。
//
// 根因:旧系统出库单明细的「单价/金额」是售价口径(卖给客户的价,应收侧),
// import-history 却把它落到了 cost_price/cost_amount(成本列),sale_price/
// sale_amount 留 0(整单显示「待定价」);而单头合计已按售价口径落 sale_total,
// 明细与单头自相矛盾。实证:ZXZ027628 出库明细"成本"14300 = 售价,
// 真实进价 13600 在库存/入库侧。
//
// 修复(仅动历史导入明细,即 product_id 指向 HIST-PLACEHOLDER 占位商品的行):
// - sale_price ← 原 cost_price、sale_amount ← 原 cost_amount(源售价归位)
// - cost_price/cost_amount ← 按商品编号(序列号,一物一码)回查真实进价:
// ① stock_in_items 同店同编号入库明细价 ② inventories.unit_price 库存快照价
// ③ products.purchase_price 商品主数据进价;均查不到 → 0(成本待定)
// - 涉及单据 profit_total 按 service.recalcStockOutProfit 同口径重算
// - sale_total 不动(= 源单头合计,应收不变,财务无需冲账)
//
// 幂等:只处理 sale_price=0 且 cost_price>0 的占位明细,重跑自动跳过已修行。
// 默认 dry-run(事务内试算后回滚),--apply 才真正落库。
//
// 用法(在 backend/ 目录下执行):
//
// go run ./cmd/fix-history-prices --shop-code S001 # dry-run
// go run ./cmd/fix-history-prices --shop-code S001 --apply # 落库
package main
import (
"errors"
"flag"
"fmt"
"log"
"math"
"os"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
const placeholderProductCode = "HIST-PLACEHOLDER"
// errDryRun 哨兵错误:dry-run 模式下用它触发事务回滚(试算走完真实 SQL 但不落库)。
var errDryRun = errors.New("dry-run rollback")
type fixStats struct {
Items int // 修复的出库明细行数
CostFromStockIn int // 成本回填来源:入库明细
CostFromInventory int // 成本回填来源:库存快照
CostFromProduct int // 成本回填来源:商品主数据进价
CostUnmatched int // 查无进价(cost 置 0,成本待定)
Orders int // 涉及并重算利润的出库单数
TotalMismatches []string // Σ明细售价小计 ≠ 单头 sale_total 的单号(只报不改)
}
type outItemRow struct {
ID uint64
OrderID uint64
ProductCode string
Quantity float64
CostPrice float64
CostAmount float64
}
type costHit struct {
price float64
src string // stock_in / inventory / product / ""(unmatched)
}
func round2(x float64) float64 { return math.Round(x*100) / 100 }
// lookupCost 按商品编号回查真实进价(商品编号=序列号,一物一码,匹配精确)。
func lookupCost(tx *gorm.DB, shopID uint64, code string) costHit {
if strings.TrimSpace(code) == "" {
return costHit{}
}
var p float64
// ① 同店同编号的入库明细成本(历史导入入库价与正常入库价同源)
row := tx.Raw(`SELECT cost_price FROM stock_in_items
WHERE shop_id = ? AND product_code = ? AND cost_price > 0
ORDER BY id DESC LIMIT 1`, shopID, code).Row()
if row.Scan(&p) == nil && p > 0 {
return costHit{p, "stock_in"}
}
// ② 库存快照价(快照列或经 product 关联均可命中)
row = tx.Raw(`SELECT inv.unit_price FROM inventories inv
LEFT JOIN products pr ON pr.id = inv.product_id AND pr.deleted_at IS NULL
WHERE inv.shop_id = ? AND inv.deleted_at IS NULL AND inv.unit_price > 0
AND (inv.product_code = ? OR pr.code = ?)
ORDER BY inv.id DESC LIMIT 1`, shopID, code, code).Row()
if row.Scan(&p) == nil && p > 0 {
return costHit{p, "inventory"}
}
// ③ 商品主数据进价
row = tx.Raw(`SELECT purchase_price FROM products
WHERE shop_id = ? AND code = ? AND deleted_at IS NULL AND purchase_price > 0
ORDER BY id DESC LIMIT 1`, shopID, code).Row()
if row.Scan(&p) == nil && p > 0 {
return costHit{p, "product"}
}
return costHit{}
}
// fixShop 修复一家店的历史导入出库明细价格。apply=false 时事务回滚(dry-run)。
func fixShop(db *gorm.DB, shopID uint64, apply bool) (*fixStats, error) {
// 占位商品可能已被用户在界面软删(线上即如此)——历史明细仍引用其 id,
// 展示走快照列不受影响,这里按 id 定位范围即可,故不过滤 deleted_at。
var ph model.Product
if err := db.Where("shop_id = ? AND code = ?",
shopID, placeholderProductCode).First(&ph).Error; err != nil {
return nil, fmt.Errorf("未找到占位商品 %s(该店没有历史导入数据?): %w", placeholderProductCode, err)
}
st := &fixStats{}
err := db.Transaction(func(tx *gorm.DB) error {
var items []outItemRow
if err := tx.Raw(`SELECT id, order_id, product_code, quantity, cost_price, cost_amount
FROM stock_out_items
WHERE shop_id = ? AND product_id = ? AND sale_price = 0 AND cost_price > 0
ORDER BY id`, shopID, ph.ID).Scan(&items).Error; err != nil {
return err
}
st.Items = len(items)
cache := map[string]costHit{}
orderIDs := map[uint64]bool{}
for _, it := range items {
hit, ok := cache[it.ProductCode]
if !ok {
hit = lookupCost(tx, shopID, it.ProductCode)
cache[it.ProductCode] = hit
}
switch hit.src {
case "stock_in":
st.CostFromStockIn++
case "inventory":
st.CostFromInventory++
case "product":
st.CostFromProduct++
default:
st.CostUnmatched++
}
if err := tx.Exec(`UPDATE stock_out_items
SET sale_price = ?, sale_amount = ?, cost_price = ?, cost_amount = ?
WHERE id = ?`,
it.CostPrice, it.CostAmount, hit.price, round2(it.Quantity*hit.price), it.ID).Error; err != nil {
return err
}
orderIDs[it.OrderID] = true
}
st.Orders = len(orderIDs)
if len(orderIDs) > 0 {
ids := make([]uint64, 0, len(orderIDs))
for id := range orderIDs {
ids = append(ids, id)
}
// 与 service.recalcStockOutProfit 同口径重算总利润
if err := tx.Exec(`UPDATE stock_out_orders SET profit_total = (
SELECT COALESCE(SUM(CASE WHEN i.sale_price > 0
THEN (i.sale_price - i.cost_price) * i.quantity ELSE 0 END), 0)
FROM stock_out_items i WHERE i.order_id = stock_out_orders.id
) WHERE shop_id = ? AND id IN ?`, shopID, ids).Error; err != nil {
return err
}
// 校验:修复后 Σ明细售价小计 应与源单头合计 sale_total 一致(只报不改)
if err := tx.Raw(`SELECT o.order_no FROM stock_out_orders o
WHERE o.shop_id = ? AND o.id IN ?
AND ABS(o.sale_total - (SELECT COALESCE(SUM(i.sale_amount),0)
FROM stock_out_items i WHERE i.order_id = o.id)) > 0.01`,
shopID, ids).Scan(&st.TotalMismatches).Error; err != nil {
return err
}
}
if !apply {
return errDryRun
}
return nil
})
if err != nil && !errors.Is(err, errDryRun) {
return nil, err
}
return st, nil
}
func main() {
var (
shopCode = flag.String("shop-code", "", "目标门店 code(与 shop-id 二选一)")
shopID = flag.Uint64("shop-id", 0, "目标门店 id(与 shop-code 二选一)")
apply = flag.Bool("apply", false, "真正落库(缺省 dry-run:试算后回滚)")
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)
}
var shop model.Shop
switch {
case *shopID > 0:
if err := db.First(&shop, *shopID).Error; err != nil {
log.Fatalf("门店 id=%d 不存在: %v", *shopID, err)
}
case *shopCode != "":
if err := db.Where("code = ?", *shopCode).First(&shop).Error; err != nil {
log.Fatalf("门店 code=%s 不存在: %v", *shopCode, err)
}
default:
log.Fatal("必须指定 --shop-code 或 --shop-id")
}
st, err := fixShop(db, shop.ID, *apply)
if err != nil {
log.Fatalf("修复失败(已回滚,未改动任何数据): %v", err)
}
mode := "*** DRY-RUN(已回滚,未写库)***"
if *apply {
mode = "已落库"
}
fmt.Println("═══════════════════════════════════════════")
fmt.Println(" 历史出库价格错列修复汇总 —— " + mode)
fmt.Println("═══════════════════════════════════════════")
fmt.Printf(" 门店:%s (id=%d)\n", shop.Name, shop.ID)
fmt.Printf(" 修复明细行(售价←原成本列):%d\n", st.Items)
fmt.Printf(" 成本回填:入库价 %d / 库存价 %d / 商品进价 %d / 查无(成本待定) %d\n",
st.CostFromStockIn, st.CostFromInventory, st.CostFromProduct, st.CostUnmatched)
fmt.Printf(" 涉及出库单(已重算利润):%d\n", st.Orders)
if len(st.TotalMismatches) > 0 {
fmt.Printf(" ⚠ 应收合计与明细不一致的单(未改动,需人工核):%v\n", st.TotalMismatches)
} else {
fmt.Println(" 应收合计校验:全部一致 ✓")
}
}
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)
}
}