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:
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
// BackfillPricingColumns 2026-07 定价字段消歧的一次性数据迁移(幂等,可反复执行):
|
||||
// - stock_in_items / stock_out_items:unit_price→cost_price、total_price→cost_amount
|
||||
// - stock_in_orders:total_amount→cost_total;stock_out_orders:total_amount→sale_total
|
||||
// - stock_out_items.sale_amount = sale_price×quantity(待定价行保持 0)
|
||||
// - stock_out_orders.profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×qty : 0)
|
||||
//
|
||||
// 只在新列为 0 且旧列非 0 时拷贝,不修改旧列(旧列观察一版后手动 DROP)。
|
||||
// profit_total 以「为 0 且存在已定价明细」为待回填判据——利润恰为 0 的单重算一次
|
||||
// 结果不变,幂等。全新安装(schema.sql 已无旧列)时整体跳过。
|
||||
func BackfillPricingColumns(db *gorm.DB) {
|
||||
// 旧列存在性守卫:四张表的旧列是同批产生的,抽查一张即可
|
||||
// (model 已无 UnitPrice 字段,HasColumn 会按原始列名探测)
|
||||
if !db.Migrator().HasColumn(&model.StockOutItem{}, "unit_price") {
|
||||
return
|
||||
}
|
||||
stmts := []string{
|
||||
`UPDATE stock_in_items SET cost_price = unit_price WHERE cost_price = 0 AND unit_price <> 0`,
|
||||
`UPDATE stock_in_items SET cost_amount = total_price WHERE cost_amount = 0 AND total_price <> 0`,
|
||||
`UPDATE stock_in_orders SET cost_total = total_amount WHERE cost_total = 0 AND total_amount <> 0`,
|
||||
`UPDATE stock_out_items SET cost_price = unit_price WHERE cost_price = 0 AND unit_price <> 0`,
|
||||
`UPDATE stock_out_items SET cost_amount = total_price WHERE cost_amount = 0 AND total_price <> 0`,
|
||||
`UPDATE stock_out_items SET sale_amount = sale_price * quantity WHERE sale_amount = 0 AND sale_price > 0`,
|
||||
`UPDATE stock_out_orders SET sale_total = total_amount WHERE sale_total = 0 AND total_amount <> 0`,
|
||||
`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 profit_total = 0 AND EXISTS (
|
||||
SELECT 1 FROM stock_out_items i2
|
||||
WHERE i2.order_id = stock_out_orders.id AND i2.sale_price > 0
|
||||
)`,
|
||||
}
|
||||
for _, q := range stmts {
|
||||
res := db.Exec(q)
|
||||
if res.Error != nil {
|
||||
log.Printf("backfill pricing columns failed (%.60s...): %v", q, res.Error)
|
||||
} else if res.RowsAffected > 0 {
|
||||
log.Printf("backfill pricing: %d rows (%.60s...)", res.RowsAffected, q)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) // 已有值不重拷
|
||||
}
|
||||
@@ -63,8 +63,8 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
}
|
||||
|
||||
var unitPricePtr *float64
|
||||
if itemCopy.UnitPrice != 0 {
|
||||
unitPricePtr = &itemCopy.UnitPrice
|
||||
if itemCopy.CostPrice != 0 {
|
||||
unitPricePtr = &itemCopy.CostPrice
|
||||
}
|
||||
|
||||
// 优先使用明细自带快照列(历史导入单的真实商品信息在此),
|
||||
@@ -130,13 +130,13 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
|
||||
|
||||
// 自动创建应付账款财务记录
|
||||
{
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.CostTotal
|
||||
orderID := order.ID
|
||||
rec := model.FinanceRecord{
|
||||
ShopID: shopID,
|
||||
PartnerID: order.PartnerID,
|
||||
Type: "payable",
|
||||
Amount: order.TotalAmount,
|
||||
Amount: order.CostTotal,
|
||||
Balance: bal,
|
||||
Status: "open",
|
||||
RefType: "stock_in",
|
||||
@@ -172,11 +172,11 @@ type SaleConfirmItem struct {
|
||||
// ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后,
|
||||
// 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内
|
||||
// 前向写补偿:
|
||||
// 1. 入库明细 UnitPrice / TotalPrice 改为真实值
|
||||
// 1. 入库明细 CostPrice / CostAmount 改为真实值
|
||||
// 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值
|
||||
// 3. 该 product 已出库行 StockOutItem.UnitPrice/TotalPrice(成本快照)回填真实值
|
||||
// 3. 该 product 已出库行 StockOutItem.CostPrice/CostAmount(成本快照)回填真实值
|
||||
// (product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收)
|
||||
// 4. 入库单 TotalAmount 按差额重算
|
||||
// 4. 入库单 CostTotal 按差额重算
|
||||
// 5. 对供应商应付按差额补一条调整流水(滚动余额)
|
||||
//
|
||||
// 仅 approved 单可确认(draft 直接编辑即可);权限:管理员/超管。
|
||||
@@ -202,21 +202,22 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
|
||||
now := time.Now()
|
||||
var totalDiff float64
|
||||
var changedProducts []uint64 // 成本被改的商品:其已出库单的利润需联动重算
|
||||
|
||||
for i := range order.Items {
|
||||
it := &order.Items[i]
|
||||
newPrice, ok := want[it.ID]
|
||||
if !ok || newPrice == it.UnitPrice {
|
||||
if !ok || newPrice == it.CostPrice {
|
||||
continue
|
||||
}
|
||||
oldPrice := it.UnitPrice
|
||||
oldPrice := it.CostPrice
|
||||
diff := (newPrice - oldPrice) * it.Quantity
|
||||
totalDiff += diff
|
||||
|
||||
// 1. 入库明细
|
||||
if err := tx.Model(it).Updates(map[string]interface{}{
|
||||
"unit_price": newPrice,
|
||||
"total_price": newPrice * it.Quantity,
|
||||
"cost_price": newPrice,
|
||||
"cost_amount": newPrice * it.Quantity,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -233,8 +234,8 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
if err := tx.Model(&model.StockOutItem{}).
|
||||
Where("shop_id = ? AND product_id = ?", shopID, it.ProductID).
|
||||
Updates(map[string]interface{}{
|
||||
"unit_price": newPrice,
|
||||
"total_price": gorm.Expr("? * quantity", newPrice),
|
||||
"cost_price": newPrice,
|
||||
"cost_amount": gorm.Expr("? * quantity", newPrice),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -245,15 +246,31 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
tx.Model(&model.Product{}).Where("id = ? AND shop_id = ?", it.ProductID, shopID).
|
||||
Update("purchase_price", newPrice)
|
||||
}
|
||||
if it.ProductID != 0 {
|
||||
changedProducts = append(changedProducts, it.ProductID)
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 成本快照变了 → 受影响出库单的总利润联动重算
|
||||
if len(changedProducts) > 0 {
|
||||
var outOrderIDs []uint64
|
||||
if err := tx.Model(&model.StockOutItem{}).
|
||||
Where("shop_id = ? AND product_id IN ?", shopID, changedProducts).
|
||||
Distinct().Pluck("order_id", &outOrderIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recalcStockOutProfit(tx, shopID, outOrderIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if totalDiff == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 入库单总额按差额重算
|
||||
// 4. 入库单应付合计按差额重算
|
||||
if err := tx.Model(&order).
|
||||
Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil {
|
||||
Update("cost_total", gorm.Expr("cost_total + ?", totalDiff)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -268,8 +285,22 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s
|
||||
})
|
||||
}
|
||||
|
||||
// recalcStockOutProfit 按行利润口径整单重算总利润:
|
||||
// profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×quantity : 0)。
|
||||
// 确认售价 / 确认进价(成本回填)后调用,保证利润与两侧价格始终一致。
|
||||
func recalcStockOutProfit(tx *gorm.DB, shopID uint64, orderIDs []uint64) error {
|
||||
if len(orderIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return 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, orderIDs).Error
|
||||
}
|
||||
|
||||
// ConfirmStockOutSale 出库「先出后定价」:对 sale_price<=0 的明细写真实售价,
|
||||
// 重算应收(total_amount=Σ售价×数量)并补应收差额流水。仅 admin/superadmin、已审核单可确认。
|
||||
// 重算应收(sale_total=Σ售价小计)与总利润,并补应收差额流水。仅 admin/superadmin、已审核单可确认。
|
||||
func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role string, items []SaleConfirmItem) error {
|
||||
if role != "admin" && role != "superadmin" {
|
||||
return ErrForbidden
|
||||
@@ -299,7 +330,11 @@ func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role
|
||||
}
|
||||
diff := (newPrice - it.SalePrice) * it.Quantity
|
||||
totalDiff += diff
|
||||
if err := tx.Model(it).Update("sale_price", newPrice).Error; err != nil {
|
||||
// 售价与售价小计同步改,避免「单价×数量 ≠ 金额」的口径漂移
|
||||
if err := tx.Model(it).Updates(map[string]interface{}{
|
||||
"sale_price": newPrice,
|
||||
"sale_amount": newPrice * it.Quantity,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -308,9 +343,12 @@ func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role
|
||||
return nil
|
||||
}
|
||||
|
||||
// 应收总额按差额重算
|
||||
// 应收合计按差额重算;总利润整单重算
|
||||
if err := tx.Model(&order).
|
||||
Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil {
|
||||
Update("sale_total", gorm.Expr("sale_total + ?", totalDiff)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recalcStockOutProfit(tx, shopID, []uint64{order.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -400,13 +438,13 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
|
||||
// 自动创建应收账款财务记录
|
||||
{
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount
|
||||
bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.SaleTotal
|
||||
oid := order.ID
|
||||
rec := model.FinanceRecord{
|
||||
ShopID: shopID,
|
||||
PartnerID: order.PartnerID,
|
||||
Type: "receivable",
|
||||
Amount: order.TotalAmount,
|
||||
Amount: order.SaleTotal,
|
||||
Balance: bal,
|
||||
Status: "open",
|
||||
RefType: "stock_out",
|
||||
@@ -497,7 +535,7 @@ func (s *StockService) ReturnStockIn(shopID, orderID, userID uint64, role string
|
||||
return err
|
||||
}
|
||||
it.ReturnedQuantity = it.Quantity
|
||||
returnedAmount += it.TotalPrice
|
||||
returnedAmount += it.CostAmount
|
||||
}
|
||||
|
||||
// 冲减应付(负向调整记录,滚动余额)
|
||||
@@ -549,8 +587,8 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin
|
||||
Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore)
|
||||
|
||||
var unitPricePtr *float64
|
||||
if it.UnitPrice != 0 {
|
||||
up := it.UnitPrice
|
||||
if it.CostPrice != 0 {
|
||||
up := it.CostPrice
|
||||
unitPricePtr = &up
|
||||
}
|
||||
unit := ""
|
||||
@@ -584,7 +622,7 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin
|
||||
return err
|
||||
}
|
||||
it.ReturnedQuantity = it.Quantity
|
||||
// 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致)
|
||||
// 冲应收按售价口径(与审核建应收 SaleTotal=Σ售价×数量 一致)
|
||||
returnedAmount += it.Quantity * it.SalePrice
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) {
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 10,
|
||||
UnitPrice: 5.0,
|
||||
TotalPrice: 50.0,
|
||||
CostPrice: 5.0,
|
||||
CostAmount: 50.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -157,8 +157,8 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) {
|
||||
ShopID: shop.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 5,
|
||||
UnitPrice: 10.0,
|
||||
TotalPrice: 50.0,
|
||||
CostPrice: 10.0,
|
||||
CostAmount: 50.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user