Compare commits
2 Commits
client-v1.1.8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 658df690f6 | |||
| e19c498dba |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
func f64(v float64) *float64 { return &v }
|
||||
|
||||
// fixture 复刻线上错列形态:
|
||||
// - 占位商品 HIST-PLACEHOLDER,历史出库明细 product_id 均指向它
|
||||
// - 明细 cost_price/cost_amount 实为售价口径(源「单价/金额」),sale_* = 0
|
||||
// - 单头 sale_total = 源合计(售价口径,与明细"成本"合计相等)
|
||||
//
|
||||
// 四个成本回填场景:A=入库明细价 B=库存快照价 D=商品进价 C=查无。
|
||||
type fixture struct {
|
||||
db *gorm.DB
|
||||
shopID uint64
|
||||
placeholder *model.Product
|
||||
order1 *model.StockOutOrder // A/B/C 三行,sale_total 与明细一致
|
||||
order2 *model.StockOutOrder // D 一行,sale_total 故意不一致
|
||||
normalItem *model.StockOutItem // 非占位商品的正常明细,不得被动到
|
||||
}
|
||||
|
||||
func setupFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "FIX01")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "主仓")
|
||||
|
||||
// 复刻线上形态:占位商品已被界面软删,明细仍引用其 id,工具必须照常定位
|
||||
ph := &model.Product{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: placeholderProductCode, Name: "历史导入占位"}
|
||||
require.NoError(t, db.Create(ph).Error)
|
||||
require.NoError(t, db.Exec(
|
||||
"UPDATE products SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?", ph.ID).Error)
|
||||
|
||||
// A:入库明细里有真实进价 13600(同时给商品主数据一个更低优先级的进价,验证①优先)
|
||||
prodA := &model.Product{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: "ZXZ027628", Name: "茅台铁盖1995五星", PublicID: "pub-a", PurchasePrice: 1}
|
||||
require.NoError(t, db.Create(prodA).Error)
|
||||
sin := &model.StockInOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "RKTEST1", WarehouseID: wh.ID, OperatorID: 1, Status: "approved"}
|
||||
require.NoError(t, db.Create(sin).Error)
|
||||
require.NoError(t, db.Create(&model.StockInItem{
|
||||
OrderID: sin.ID, ShopID: shop.ID, ProductID: ph.ID,
|
||||
ProductCode: "ZXZ027628", Quantity: 1, CostPrice: 13600, CostAmount: 13600,
|
||||
}).Error)
|
||||
|
||||
// B:只有库存快照价 500
|
||||
require.NoError(t, db.Create(&model.Inventory{
|
||||
ShopID: shop.ID, ProductCode: "B001", Quantity: 2, UnitPrice: f64(500),
|
||||
}).Error)
|
||||
|
||||
// D:只有商品主数据进价 1500
|
||||
prodD := &model.Product{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
Code: "D001", Name: "商品D", PublicID: "pub-d", PurchasePrice: 1500}
|
||||
require.NoError(t, db.Create(prodD).Error)
|
||||
|
||||
// 出库单 1:A(14300×1) + B(400×2) + C(999×1),sale_total 与明细合计一致
|
||||
o1 := &model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "CKTEST1", WarehouseID: wh.ID, OperatorID: 1, Status: "approved",
|
||||
SaleTotal: 14300 + 800 + 999}
|
||||
require.NoError(t, db.Create(o1).Error)
|
||||
for _, it := range []*model.StockOutItem{
|
||||
{OrderID: o1.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "ZXZ027628",
|
||||
Quantity: 1, CostPrice: 14300, CostAmount: 14300},
|
||||
{OrderID: o1.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "B001",
|
||||
Quantity: 2, CostPrice: 400, CostAmount: 800},
|
||||
{OrderID: o1.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "C001",
|
||||
Quantity: 1, CostPrice: 999, CostAmount: 999},
|
||||
} {
|
||||
require.NoError(t, db.Create(it).Error)
|
||||
}
|
||||
|
||||
// 出库单 2:D(2000×1),sale_total 故意与明细不一致(源数据脏),应只报不改
|
||||
o2 := &model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "CKTEST2", WarehouseID: wh.ID, OperatorID: 1, Status: "pending",
|
||||
SaleTotal: 123}
|
||||
require.NoError(t, db.Create(o2).Error)
|
||||
require.NoError(t, db.Create(&model.StockOutItem{
|
||||
OrderID: o2.ID, ShopID: shop.ID, ProductID: ph.ID, ProductCode: "D001",
|
||||
Quantity: 1, CostPrice: 2000, CostAmount: 2000,
|
||||
}).Error)
|
||||
|
||||
// 正常单(非占位商品):sale_price=0 且 cost>0,但 product_id 非占位 → 不得被动
|
||||
prodN := testutil.CreateTestProduct(db, shop.ID, "正常商品")
|
||||
o3 := &model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID},
|
||||
OrderNo: "CKTEST3", WarehouseID: wh.ID, OperatorID: 1, Status: "approved",
|
||||
SaleTotal: 0}
|
||||
require.NoError(t, db.Create(o3).Error)
|
||||
normal := &model.StockOutItem{OrderID: o3.ID, ShopID: shop.ID, ProductID: prodN.ID,
|
||||
ProductCode: "P-正常商品", Quantity: 1, CostPrice: 100, CostAmount: 100}
|
||||
require.NoError(t, db.Create(normal).Error)
|
||||
|
||||
return &fixture{db: db, shopID: shop.ID, placeholder: ph,
|
||||
order1: o1, order2: o2, normalItem: normal}
|
||||
}
|
||||
|
||||
func TestFixShop_DryRunDoesNotWrite(t *testing.T) {
|
||||
fx := setupFixture(t)
|
||||
|
||||
st, err := fixShop(fx.db, fx.shopID, false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, st.Items)
|
||||
assert.Equal(t, 1, st.CostFromStockIn)
|
||||
assert.Equal(t, 1, st.CostFromInventory)
|
||||
assert.Equal(t, 1, st.CostFromProduct)
|
||||
assert.Equal(t, 1, st.CostUnmatched)
|
||||
assert.Equal(t, 2, st.Orders)
|
||||
assert.Equal(t, []string{"CKTEST2"}, st.TotalMismatches)
|
||||
|
||||
// 回滚后数据原样
|
||||
var it model.StockOutItem
|
||||
require.NoError(t, fx.db.Where("product_code = ?", "ZXZ027628").
|
||||
Where("order_id = ?", fx.order1.ID).First(&it).Error)
|
||||
assert.Equal(t, 14300.0, it.CostPrice)
|
||||
assert.Equal(t, 0.0, it.SalePrice)
|
||||
var o model.StockOutOrder
|
||||
require.NoError(t, fx.db.First(&o, fx.order1.ID).Error)
|
||||
assert.Equal(t, 0.0, o.ProfitTotal)
|
||||
}
|
||||
|
||||
func TestFixShop_Apply(t *testing.T) {
|
||||
fx := setupFixture(t)
|
||||
|
||||
st, err := fixShop(fx.db, fx.shopID, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, st.Items)
|
||||
|
||||
get := func(orderID uint64, code string) model.StockOutItem {
|
||||
var it model.StockOutItem
|
||||
require.NoError(t, fx.db.Where("order_id = ? AND product_code = ?", orderID, code).
|
||||
First(&it).Error)
|
||||
return it
|
||||
}
|
||||
|
||||
// A:售价归位 14300,成本回填入库价 13600
|
||||
a := get(fx.order1.ID, "ZXZ027628")
|
||||
assert.Equal(t, 14300.0, a.SalePrice)
|
||||
assert.Equal(t, 14300.0, a.SaleAmount)
|
||||
assert.Equal(t, 13600.0, a.CostPrice)
|
||||
assert.Equal(t, 13600.0, a.CostAmount)
|
||||
|
||||
// B:成本回填库存快照价 500,小计 = 2×500
|
||||
b := get(fx.order1.ID, "B001")
|
||||
assert.Equal(t, 400.0, b.SalePrice)
|
||||
assert.Equal(t, 800.0, b.SaleAmount)
|
||||
assert.Equal(t, 500.0, b.CostPrice)
|
||||
assert.Equal(t, 1000.0, b.CostAmount)
|
||||
|
||||
// C:查无进价 → 成本待定 0
|
||||
c := get(fx.order1.ID, "C001")
|
||||
assert.Equal(t, 999.0, c.SalePrice)
|
||||
assert.Equal(t, 0.0, c.CostPrice)
|
||||
assert.Equal(t, 0.0, c.CostAmount)
|
||||
|
||||
// D:成本回填商品进价 1500
|
||||
d := get(fx.order2.ID, "D001")
|
||||
assert.Equal(t, 2000.0, d.SalePrice)
|
||||
assert.Equal(t, 1500.0, d.CostPrice)
|
||||
|
||||
// 单头:利润按 recalcStockOutProfit 口径;sale_total 不动
|
||||
var o1, o2 model.StockOutOrder
|
||||
require.NoError(t, fx.db.First(&o1, fx.order1.ID).Error)
|
||||
// (14300-13600)×1 + (400-500)×2 + (999-0)×1 = 700 - 200 + 999
|
||||
assert.InDelta(t, 1499.0, o1.ProfitTotal, 0.001)
|
||||
assert.Equal(t, 16099.0, o1.SaleTotal)
|
||||
require.NoError(t, fx.db.First(&o2, fx.order2.ID).Error)
|
||||
assert.InDelta(t, 500.0, o2.ProfitTotal, 0.001)
|
||||
assert.Equal(t, 123.0, o2.SaleTotal) // 不一致只报不改
|
||||
assert.Equal(t, []string{"CKTEST2"}, st.TotalMismatches)
|
||||
|
||||
// 正常明细未被波及
|
||||
var n model.StockOutItem
|
||||
require.NoError(t, fx.db.First(&n, fx.normalItem.ID).Error)
|
||||
assert.Equal(t, 100.0, n.CostPrice)
|
||||
assert.Equal(t, 0.0, n.SalePrice)
|
||||
|
||||
// 幂等:重跑无待修行
|
||||
st2, err := fixShop(fx.db, fx.shopID, true)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, st2.Items)
|
||||
assert.Equal(t, 0, st2.Orders)
|
||||
}
|
||||
|
||||
func TestFixShop_NoPlaceholder(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "FIX02")
|
||||
_, err := fixShop(db, shop.ID, false)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -529,13 +529,17 @@ func (ic *importCtx) writeOrder(
|
||||
TenantBase: model.TenantBase{ShopID: ic.shopID},
|
||||
OrderNo: orderNo, Type: orderType, WarehouseID: ic.whID,
|
||||
PartnerID: partnerID, OperatorID: operatorID, CreatorID: creatorID,
|
||||
// 忠实导入:源「金额」合计沿旧行为落应收合计(sale_price=0 待定价)
|
||||
// 忠实导入:源「金额」合计是售价口径,落应收合计(与明细 sale_* 同口径)
|
||||
ReviewerID: reviewerID, Status: status, OrderDate: orderDate, SaleTotal: totalAmount,
|
||||
}
|
||||
if err := tx.Create(&o).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dr := range details {
|
||||
// 源出库明细「单价/金额」是售价口径(卖给客户的价,应收侧),落 sale 列;
|
||||
// 真实成本导入时不可知,留 0(成本待定),事后用 cmd/fix-history-prices
|
||||
// 按商品编号回查入库价/库存价/商品进价回填。
|
||||
// (2026-07-10 修正:此前误落 cost_price/cost_amount,线上数据已用同工具修复)
|
||||
it := model.StockOutItem{
|
||||
OrderID: o.ID, ShopID: ic.shopID, ProductID: ic.placeholderProductID,
|
||||
ProductCode: get(dr, dCols, "商品编号"),
|
||||
@@ -543,8 +547,8 @@ func (ic *importCtx) writeOrder(
|
||||
Series: get(dr, dCols, "系列"),
|
||||
Spec: get(dr, dCols, "规格"),
|
||||
Quantity: parseFloatLoose(get(dr, dCols, "数量")),
|
||||
CostPrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
CostAmount: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
SalePrice: parseFloatLoose(get(dr, dCols, "单价")),
|
||||
SaleAmount: parseFloatLoose(get(dr, dCols, "金额")),
|
||||
BatchNo: get(dr, dCols, "批次号"),
|
||||
}
|
||||
it.ProductionDate = parseDatePtr(get(dr, dCols, "生产日期"))
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<ul>
|
||||
<li><a href="runbooks/ec2-to-ali-migration.html">EC2 → 阿里云 迁移计划</a><span class="tag html">HTML</span> <span class="hint">— jiu 服务/数据/DB/文件/域名/CI 整体迁移(2026-07-02 已割接)</span></li>
|
||||
<li><a href="runbooks/ddos-response.html">DDoS / 爬取洪峰应急</a><span class="tag html">HTML</span> <span class="hint">— 判定/CC 处置/高防·CDN 切换/黑洞预案(2026-07-05,配套安全三件套加固)</span></li>
|
||||
<li><a href="runbooks/fix-history-prices-20260710.html">历史导入出库价格错列修复复盘</a><span class="tag html">HTML</span> <span class="hint">— 售价误落成本列根因/修复设计/26335 行执行结果/遗留脏数据清单/回滚(2026-07-10,shop8 生产)</span></li>
|
||||
</ul>
|
||||
|
||||
<h2>🧪 测试 · 审计报告</h2>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>历史导入出库价格错列修复复盘(2026-07-10) — 酒库管理系统</title>
|
||||
<style>
|
||||
:root{
|
||||
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
|
||||
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
|
||||
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
|
||||
}
|
||||
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
|
||||
h1{font-size:22px;margin:0 0 4px;}
|
||||
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
|
||||
h3{font-size:14px;margin:18px 0 8px;color:var(--accent);}
|
||||
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
|
||||
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;}
|
||||
.lead{font-size:13.5px;color:var(--ink);}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0;}
|
||||
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border-bottom:1px solid var(--border);}
|
||||
td{padding:8px 10px;border-bottom:1px solid #EEF1F5;vertical-align:top;}
|
||||
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;}
|
||||
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
|
||||
pre{background:#1E2430;color:#D8E0EC;border-radius:8px;padding:12px 14px;overflow:auto;font-size:12px;line-height:1.55;}
|
||||
pre .c{color:#7A8699;}
|
||||
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;}
|
||||
.tag.risk{background:var(--danger-bg);color:var(--danger);}
|
||||
.tag.ok{background:var(--success-bg);color:var(--success);}
|
||||
.tag.warn{background:var(--warn-bg);color:var(--warn);}
|
||||
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:10px 14px;border-radius:4px;margin:12px 0;font-size:13px;}
|
||||
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
|
||||
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
|
||||
.callout.ok{border-color:var(--success);background:var(--success-bg);}
|
||||
.callout b{color:var(--ink);}
|
||||
ul,ol{margin:6px 0;padding-left:22px;}
|
||||
li{margin:3px 0;font-size:13px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>历史导入出库价格错列修复复盘</h1>
|
||||
<div class="sub">门店:鼎晟酒行(shop_id=8, S000008)· 生产库 ali/jiu_mysql · 2026-07-10 执行 · 修复工具 <code>backend/cmd/fix-history-prices</code> · commit <code>e19c498</code></div>
|
||||
|
||||
<div class="callout ok"><b>一句话结论:</b>2026-06-20 历史进销存导入(<code>cmd/import-history</code>)把旧系统<b>出库明细的「单价/金额」(售价口径)误写入成本列</b> <code>cost_price/cost_amount</code>,售价列留 0,导致全部历史出库单显示「待定价」、成本列实为售价。本次用一次性工具修复 <b>26,335 行明细 / 4,927 张出库单</b>:售价归位、成本按序列号回查真实进价回填、利润重算;应收合计不动,财务零影响。</div>
|
||||
|
||||
<h2>一、症状与发现路径</h2>
|
||||
<div class="card lead">
|
||||
<ul>
|
||||
<li>用户报障:出库单详情里售价/总售价/利润整列「待定价」(首例即历史导入单,成本价 2,130 实为售价)。</li>
|
||||
<li>关键实证(用户比对截图):出库单 <b>CK202606150001</b> 明细「成本价」14,300,而同一序列号 <b>ZXZ027628 茅台铁盖1995五星</b> 在库存/入库侧的真实进价是 <b>13,600</b>——出库"成本"14,300 就是卖给客户的售价。</li>
|
||||
<li>旁证:该单 9 瓶 × 14,300 = 128,700 恰好等于单头「合计金额」(应收 <code>sale_total</code>)——单头是售价口径、明细却落在成本列,自相矛盾。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>二、根因</h2>
|
||||
<div class="card lead">
|
||||
<p><code>backend/cmd/import-history/main.go</code> 的出库明细映射(修复前):</p>
|
||||
<pre>CostPrice: parseFloatLoose(get(dr, dCols, "单价")), <span class="c">// ✗ 源出库「单价」是售价口径</span>
|
||||
CostAmount: parseFloatLoose(get(dr, dCols, "金额")), <span class="c">// ✗ 同上;sale_price / sale_amount 留 0</span></pre>
|
||||
<ul>
|
||||
<li>旧系统出库单的「单价/金额」= 卖给客户的价(应收侧);入库单的「单价/金额」= 进价(应付侧)。<b>入库映射本来就对,只有出库错列。</b></li>
|
||||
<li>单头处理却是对的:源「单据金额」→ <code>sale_total</code>(应收合计),因此财务 KPI(直接 SUM 单头列)一直正确,问题只在明细两列 + 利润。</li>
|
||||
<li>显示层连锁:明细 <code>sale_price=0</code> → 前端按「待定价」渲染(<code>order_detail_drawer.dart</code> 口径 <code>sale_price<=0</code>);成本列展示的是售价,管理员看到的"成本/利润"失真。</li>
|
||||
</ul>
|
||||
<p>映射已随修复同步改对(出库明细「单价/金额」→ <code>sale_price/sale_amount</code>,成本留 0 待回查),以后再导不会复发。</p>
|
||||
</div>
|
||||
|
||||
<h2>三、修复设计(cmd/fix-history-prices)</h2>
|
||||
<div class="card lead">
|
||||
<table>
|
||||
<tr><th>要点</th><th>说明</th></tr>
|
||||
<tr><td>修复范围</td><td>仅历史导入明细:<code>stock_out_items.product_id = 占位商品 HIST-PLACEHOLDER(shop8 id=4809)</code> 且 <code>sale_price=0 AND cost_price>0</code>。<b>注意占位商品已被界面软删</b>(2026-06-20),工具定位时不过滤 <code>deleted_at</code>。</td></tr>
|
||||
<tr><td>售价归位</td><td><code>sale_price ← 原 cost_price</code>、<code>sale_amount ← 原 cost_amount</code>(忠实保留源售价,不做任何归一)。</td></tr>
|
||||
<tr><td>成本回填</td><td>按商品编号(序列号,一物一码)逐级回查:① <code>stock_in_items</code> 同店同编号入库价 → ② <code>inventories.unit_price</code> 库存快照价(快照列或经 product 关联)→ ③ <code>products.purchase_price</code>;均查不到 → 0(成本待定)。与库存列表展示链 <code>COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)</code> 同序。</td></tr>
|
||||
<tr><td>利润重算</td><td>涉及单据 <code>profit_total</code> 按 <code>service.recalcStockOutProfit</code> 同口径:<code>Σ(sale_price>0 ? (sale-cost)×qty : 0)</code>。成本待定行暂按全售价计(与系统既有"成本待定"语义一致)。</td></tr>
|
||||
<tr><td>应收不动</td><td><code>sale_total</code> 保持源单头合计——财务应收/KPI 直接 SUM 单头列,<b>修复对财务零影响、无需冲账</b>。修复后校验 Σ明细售价小计 vs 单头合计,不一致只报不改。</td></tr>
|
||||
<tr><td>安全机制</td><td>默认 dry-run(事务内试算后回滚);<code>--apply</code> 才落库;幂等(已修行 <code>sale_price>0</code> 自动跳过,可重跑)。SQLite 内存库单测覆盖四种成本来源 / dry-run 回滚 / 幂等 / 非占位明细不波及 / 占位商品软删仍可定位。</td></tr>
|
||||
</table>
|
||||
<pre><span class="c"># 用法(本机交叉编译 → scp 到 ali 宿主执行;jiu_mysql 容器映射 127.0.0.1:3306)</span>
|
||||
cd backend && GOOS=linux GOARCH=amd64 go build -o /tmp/fix-history-prices ./cmd/fix-history-prices
|
||||
scp /tmp/fix-history-prices ali:/tmp/
|
||||
ssh ali '/tmp/fix-history-prices --shop-id 8 --env-file /opt/jiu/config/production.env' <span class="c"># dry-run</span>
|
||||
ssh ali '/tmp/fix-history-prices --shop-id 8 --env-file /opt/jiu/config/production.env --apply' <span class="c"># 落库</span></pre>
|
||||
</div>
|
||||
|
||||
<h2>四、执行记录与结果(2026-07-10)</h2>
|
||||
<div class="card lead">
|
||||
<table>
|
||||
<tr><th>步骤</th><th>结果</th></tr>
|
||||
<tr><td>1. 全库备份(改库前)</td><td><span class="tag ok">完成</span> 本机 <code>~/jiu-db-backups/jiu_db_prefix_fix_20260710.sql.gz</code>(2.8MB,gzip -t 校验通过)</td></tr>
|
||||
<tr><td>2. dry-run</td><td>待修 26,335 行 / 4,927 单;成本回填:入库价 25,139 / 库存价 32 / 商品进价 0 / 查无 1,164;合计不一致单 2 张</td></tr>
|
||||
<tr><td>3. --apply 落库</td><td><span class="tag ok">完成</span> 数字与 dry-run 完全一致</td></tr>
|
||||
<tr><td>4. 核验</td><td><span class="tag ok">全绿</span> 待修行剩 0;抽查 ZXZ027628:成本 13,600 / 售价 14,300 ✓;CK202606150001:应收 128,700 / 利润 22,900 ✓</td></tr>
|
||||
</table>
|
||||
<p>成本回填命中率 95.5%(25,139/26,335 走入库明细价,最精确来源);1,164 行查无进价 = 该序列号在导出的历史窗口内无入库记录(更早的老库存),成本置 0 待定。</p>
|
||||
</div>
|
||||
|
||||
<h2>五、遗留脏数据清单(源数据问题,忠实保留,待业务决策)</h2>
|
||||
<div class="card lead">
|
||||
<table>
|
||||
<tr><th>#</th><th>问题</th><th>影响</th><th>建议处置</th></tr>
|
||||
<tr><td>1</td><td><span class="tag risk">数量脏</span> <b>CK202403050009</b> 行 ZXZ010370 茅台白皮2003 <b>数量=50,902</b>(该序列号入库仅 1 瓶),明细金额 2.62 亿</td><td>该单 <code>profit_total</code> 虚高至 3,312 万;单头应收 542,950 正常</td><td>业务核实真实数量(大概率 1)后单行修正并重算该单利润。同族问题见「装箱数当数量」脏数据(库存侧 784 行,另案待决策)</td></tr>
|
||||
<tr><td>2</td><td><span class="tag warn">合计差</span> CK202401170005 单头 95,400 vs 明细合计 95,500(差 100)</td><td>轻微,应收按单头</td><td>可不处理;或对账时人工核</td></tr>
|
||||
<tr><td>3</td><td><span class="tag warn">零额单</span> 出库 47 张 / 入库 13 张 合计 ≤ 0</td><td>可能是赠送/内部调拨/源缺失</td><td>保留原貌</td></tr>
|
||||
<tr><td>4</td><td><span class="tag warn">成本待定</span> 出库明细 1,164 行查无进价(cost=0);入库明细 59 行进价=0</td><td>所在单利润暂按全售价计,偏高</td><td>后续补录进价可自动纠正利润(确认进价/再跑回填)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>六、回滚方式</h2>
|
||||
<div class="card lead">
|
||||
<div class="callout danger"><b>回滚会连带覆盖备份时刻之后的所有新业务数据</b>,仅在修复本身被证实有误时使用;局部纠错优先用幂等工具或单行 SQL。</div>
|
||||
<pre><span class="c"># 全库恢复(备份 = 修复前全库快照)</span>
|
||||
gunzip -c ~/jiu-db-backups/jiu_db_prefix_fix_20260710.sql.gz | ssh ali 'PW=...; docker exec -i -e MYSQL_PWD="$PW" jiu_mysql mysql -uroot jiu_db'</pre>
|
||||
<p>密码取法同 <code>scripts/ci/backup-db.sh</code>:从 <code>/opt/jiu/config/production.env</code> 的 <code>DATABASE_DSN</code> 解析。</p>
|
||||
</div>
|
||||
|
||||
<h2>七、关联索引</h2>
|
||||
<div class="card lead">
|
||||
<ul>
|
||||
<li>修复工具与测试:<code>backend/cmd/fix-history-prices/</code>(main.go + main_test.go)</li>
|
||||
<li>导入映射修正:<code>backend/cmd/import-history/main.go</code>(出库明细改落 sale 列)</li>
|
||||
<li>提交:<code>e19c498</code> fix(backend): 历史导入出库明细价格错列——售价误落成本列,新增修复工具</li>
|
||||
<li>历次同族修复:库存商品编号重复(2026-06-21,<code>cmd/fix-inventory-products</code>,按名合并 → 按编号重指);导入实跑记录见项目记忆 <code>project_history_import</code></li>
|
||||
<li>定价字段口径(cost/sale 分列、待定价、利润公式):见项目 <code>CLAUDE.md</code>「定价字段口径」节与开发手册</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user