feat(backend): 出入库定价字段消歧 + 总利润落库 + 成本仅管理员可见

- 字段重命名(旧列弃用保留,启动幂等回填 BackfillPricingColumns):
  入库 unit_price/total_price/total_amount → cost_price/cost_amount/cost_total;
  出库同前缀 + 新增 sale_amount(售价小计)/ sale_total(应收)/ profit_total(总利润)
- 建单落三值;确认售价联动重算 sale_amount/sale_total/profit_total;
  确认进价回填成本后联动重算受影响出库单利润(recalcStockOutProfit)
- 出库 List/Get 对 operator/readonly 抹零成本与利润(stripStockOutCost 服务端兜底)
- 兼容一版:Create/Update 接受旧 key unit_price 回落(v1.0.87 及之前客户端)
- SUM 聚合/价格趋势/导入/种子工具同步切新列;测试全量改名 + 6 个新回归

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 12:44:22 +08:00
parent 607b8aa763
commit 4fd0bb8b83
24 changed files with 601 additions and 135 deletions
+89
View File
@@ -0,0 +1,89 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// BackfillPricingColumns:旧列(unit_price/total_price/total_amount)拷入新列,幂等。
// 测试库 DDL 已是新列,这里手动补建旧列模拟升级前的生产库形态。
func TestBackfillPricingColumns_Idempotent(t *testing.T) {
db := testutil.SetupTestDB()
for _, ddl := range []string{
`ALTER TABLE stock_in_items ADD COLUMN unit_price REAL DEFAULT 0`,
`ALTER TABLE stock_in_items ADD COLUMN total_price REAL DEFAULT 0`,
`ALTER TABLE stock_in_orders ADD COLUMN total_amount REAL DEFAULT 0`,
`ALTER TABLE stock_out_items ADD COLUMN unit_price REAL DEFAULT 0`,
`ALTER TABLE stock_out_items ADD COLUMN total_price REAL DEFAULT 0`,
`ALTER TABLE stock_out_orders ADD COLUMN total_amount REAL DEFAULT 0`,
} {
require.NoError(t, db.Exec(ddl).Error)
}
shop := testutil.CreateTestShop(db, "MIG1")
// 升级前形态:只有旧列有值(新列 0)
inOrder := model.StockInOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "R1", WarehouseID: 1, OperatorID: 1, Status: "approved"}
require.NoError(t, db.Create(&inOrder).Error)
db.Exec(`UPDATE stock_in_orders SET total_amount = 500 WHERE id = ?`, inOrder.ID)
inItem := model.StockInItem{OrderID: inOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 10}
require.NoError(t, db.Create(&inItem).Error)
db.Exec(`UPDATE stock_in_items SET unit_price = 50, total_price = 500 WHERE id = ?`, inItem.ID)
outOrder := model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "C1", WarehouseID: 1, OperatorID: 1, Status: "approved"}
require.NoError(t, db.Create(&outOrder).Error)
db.Exec(`UPDATE stock_out_orders SET total_amount = 800 WHERE id = ?`, outOrder.ID)
outItem := model.StockOutItem{OrderID: outOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 2, SalePrice: 400}
require.NoError(t, db.Create(&outItem).Error)
db.Exec(`UPDATE stock_out_items SET unit_price = 355, total_price = 710, sale_amount = 0 WHERE id = ?`, outItem.ID)
// 待定价出库单:sale_price=0 → sale_amount/profit 均应保持 0
pendOrder := model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "C2", WarehouseID: 1, OperatorID: 1, Status: "approved"}
require.NoError(t, db.Create(&pendOrder).Error)
pendItem := model.StockOutItem{OrderID: pendOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 3}
require.NoError(t, db.Create(&pendItem).Error)
db.Exec(`UPDATE stock_out_items SET unit_price = 100, total_price = 300 WHERE id = ?`, pendItem.ID)
BackfillPricingColumns(db)
var gotIn model.StockInItem
db.First(&gotIn, inItem.ID)
assert.Equal(t, float64(50), gotIn.CostPrice)
assert.Equal(t, float64(500), gotIn.CostAmount)
var gotInOrder model.StockInOrder
db.First(&gotInOrder, inOrder.ID)
assert.Equal(t, float64(500), gotInOrder.CostTotal)
var gotOut model.StockOutItem
db.First(&gotOut, outItem.ID)
assert.Equal(t, float64(355), gotOut.CostPrice)
assert.Equal(t, float64(710), gotOut.CostAmount)
assert.Equal(t, float64(800), gotOut.SaleAmount) // 400×2
var gotOutOrder model.StockOutOrder
db.First(&gotOutOrder, outOrder.ID)
assert.Equal(t, float64(800), gotOutOrder.SaleTotal)
assert.Equal(t, float64(90), gotOutOrder.ProfitTotal) // (400-355)×2
var gotPend model.StockOutItem
db.First(&gotPend, pendItem.ID)
assert.Equal(t, float64(100), gotPend.CostPrice)
assert.Equal(t, float64(0), gotPend.SaleAmount)
var gotPendOrder model.StockOutOrder
db.First(&gotPendOrder, pendOrder.ID)
assert.Equal(t, float64(0), gotPendOrder.ProfitTotal)
// 幂等:第二次执行不改变结果(人为改一个新列值验证不会被旧值覆盖——新列非 0 即跳过)
db.Exec(`UPDATE stock_out_items SET sale_price = 450, sale_amount = 900 WHERE id = ?`, outItem.ID)
BackfillPricingColumns(db)
var again model.StockOutItem
db.First(&again, outItem.ID)
assert.Equal(t, float64(900), again.SaleAmount) // 未被回填覆盖
var againOrder model.StockOutOrder
db.First(&againOrder, outOrder.ID)
assert.Equal(t, float64(800), againOrder.SaleTotal) // 已有值不重拷
}