fix(backend): 历史导入出库明细价格错列——售价误落成本列,新增修复工具
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:
wangjia
2026-07-10 16:51:51 +08:00
parent 39a2f14e0b
commit e19c498dba
3 changed files with 473 additions and 3 deletions
+198
View File
@@ -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)
// 出库单 1A(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)
}
// 出库单 2D(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)
}