Files
jiu/backend/cmd/fix-history-prices/main_test.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

199 lines
7.6 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.
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)
}