fix(backend): 历史导入出库明细价格错列——售价误落成本列,新增修复工具
Design Source Checks / design-source (push) Successful in 39s
Design Source Checks / design-source (push) Successful in 39s
根因:旧系统出库明细「单价/金额」是售价口径(应收侧),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>
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user