feat(backend): 入库「确认进价」前向补偿(暂估价→真实价)

调货 0 价入库并已审核/出库后,价格确定时补填真实进价:一事务内
回填入库明细/库存批次/已出库成本快照,重算入库单总额,按差额补
应付流水,同步参考进价。按 product_id 精确命中(序列号 1:1),
不动售价/应收。仅 approved 单、管理员/超管,差额幂等可重复确认。

新增 POST /stock-in/orders/:id/confirm-cost 及 4 个测试用例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-23 20:07:49 +08:00
parent 7969bb41e3
commit 3625240839
5 changed files with 337 additions and 0 deletions
@@ -0,0 +1,191 @@
package handler
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// 调货核心场景:0 价入库 → 审核 → 部分出库 → 确认进价(前向补偿)。
func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "CC_MAIN")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
prod := testutil.CreateTestProduct(db, shop.ID, "茅台")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 0 价入库 10 → 审核
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 10.0, "unit_price": 0.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
inID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inID), adminToken, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inID), adminToken, nil)
var inItem model.StockInItem
require.NoError(t, db.Where("order_id = ?", inID).First(&inItem).Error)
pid := inItem.ProductID // 序列号模型:入库为该行新建独立 product
// 库存批次成本待定(UnitPrice 为 NULL
var inv model.Inventory
require.NoError(t, db.Where("shop_id = ? AND stock_in_item_id = ?", shop.ID, inItem.ID).First(&inv).Error)
assert.Nil(t, inv.UnitPrice, "0 价入库时库存成本应为待定(NULL)")
// 出库 4(售价 80,成本暂为 0)→ 审核
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": pid, "quantity": 4.0, "unit_price": 0.0, "sale_price": 80.0}},
})
outID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", outID), adminToken, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", outID), adminToken, nil)
// 确认进价 50
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken,
map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 50.0}}})
require.Equal(t, http.StatusOK, w.Code)
// 1. 入库明细成本回填
db.First(&inItem, inItem.ID)
assert.Equal(t, 50.0, inItem.UnitPrice)
assert.Equal(t, 500.0, inItem.TotalPrice)
// 2. 剩余库存批次成本回填(6 件)
db.First(&inv, inv.ID)
require.NotNil(t, inv.UnitPrice)
assert.Equal(t, 50.0, *inv.UnitPrice)
// 3. 已出库行成本快照回填,售价/应收不变
var outItem model.StockOutItem
require.NoError(t, db.Where("order_id = ?", outID).First(&outItem).Error)
assert.Equal(t, 50.0, outItem.UnitPrice, "已出库成本应回填")
assert.Equal(t, 200.0, outItem.TotalPrice, "成本小计 = 50×4")
assert.Equal(t, 80.0, outItem.SalePrice, "售价不应被改动")
var outOrder model.StockOutOrder
db.First(&outOrder, outID)
assert.Equal(t, 320.0, outOrder.TotalAmount, "应收按售价 4×80,确认进价不影响")
// 4. 入库单总额按差额重算:0 → 500
var inOrder model.StockInOrder
db.First(&inOrder, inID)
assert.Equal(t, 500.0, inOrder.TotalAmount)
// 5. 应付差额调整流水:+500(balance 是应收应付混合滚动总账:0 入 + 320 应收 + 500 = 820
var adj model.FinanceRecord
require.NoError(t, db.Where("shop_id = ? AND type = 'payable' AND ref_type = 'stock_in_cost_adjust'", shop.ID).
First(&adj).Error)
assert.Equal(t, 500.0, adj.Amount)
assert.Equal(t, 820.0, adj.Balance)
// 6. product 参考进价同步(pid 是入库新建的独立 product
var p2 model.Product
require.NoError(t, db.First(&p2, pid).Error)
assert.Equal(t, 50.0, p2.PurchasePrice)
}
// 非管理员无权确认进价。
func TestConfirmCost_Forbidden(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "CC_FORBID")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
op := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
prod := testutil.CreateTestProduct(db, shop.ID, "五粮液")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
opToken := getAuthToken(op.ID, shop.ID, "operator")
r := setupProtectedRouter(db)
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 5.0, "unit_price": 0.0}},
})
inID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inID), adminToken, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inID), adminToken, nil)
var inItem model.StockInItem
db.Where("order_id = ?", inID).First(&inItem)
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), opToken,
map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 30.0}}})
assert.Equal(t, http.StatusForbidden, w.Code)
}
// 仅 approved 单可确认;草稿/待审核拒绝。
func TestConfirmCost_RejectsNonApproved(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "CC_DRAFT")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
prod := testutil.CreateTestProduct(db, shop.ID, "汾酒")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 5.0, "unit_price": 0.0}},
})
inID := extractID(w)
var inItem model.StockInItem
db.Where("order_id = ?", inID).First(&inItem)
// 仍是 draft → 400
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken,
map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 30.0}}})
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// 重复确认:按新旧差额增量补偿,不重复全量。
func TestConfirmCost_RepeatableByDiff(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "CC_REPEAT")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
prod := testutil.CreateTestProduct(db, shop.ID, "剑南春")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", adminToken, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": prod.ID, "quantity": 10.0, "unit_price": 0.0}},
})
inID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inID), adminToken, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inID), adminToken, nil)
var inItem model.StockInItem
db.Where("order_id = ?", inID).First(&inItem)
// 第一次确认 50diff 500
makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken,
map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 50.0}}})
// 第二次修正为 60diff 100
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inID), adminToken,
map[string]interface{}{"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 60.0}}})
require.Equal(t, http.StatusOK, w.Code)
db.First(&inItem, inItem.ID)
assert.Equal(t, 60.0, inItem.UnitPrice)
var inOrder model.StockInOrder
db.First(&inOrder, inID)
assert.Equal(t, 600.0, inOrder.TotalAmount, "总额 = 60×10")
// 两条调整流水:+500 与 +100,余额滚动到 600
var adjs []model.FinanceRecord
db.Where("shop_id = ? AND ref_type = 'stock_in_cost_adjust'", shop.ID).Order("id ASC").Find(&adjs)
require.Len(t, adjs, 2)
assert.Equal(t, 500.0, adjs[0].Amount)
assert.Equal(t, 100.0, adjs[1].Amount)
assert.Equal(t, 600.0, adjs[1].Balance)
}
+38
View File
@@ -315,3 +315,41 @@ func (h *StockInHandler) Return(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "returned"})
}
// ConfirmCost POST /api/v1/stock-in/orders/:id/confirm-cost
// 确认进价:调货等以 0 价(暂估)入库并已审核后,价格确定时补填真实进价。
// 前向补偿(更新库存成本、回填已出库成本快照、补应付差额),不反审核。
// 仅 approved 单、管理员/超管(service 内判权)。
func (h *StockInHandler) ConfirmCost(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
role := middleware.GetRole(c)
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var req struct {
Items []service.CostConfirmItem `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Items) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供要确认的明细"})
return
}
for _, it := range req.Items {
if it.UnitPrice <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "进价必须大于 0"})
return
}
}
if err := h.stockSvc.ConfirmStockInCost(shopID, id, userID, role, req.Items); err != nil {
if errors.Is(err, service.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权确认进价"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "cost confirmed"})
}
@@ -66,6 +66,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw)
stockIn.POST("/orders/:id/return", stockInH.Return)
stockIn.POST("/orders/:id/confirm-cost", stockInH.ConfirmCost)
// 出库路由
stockOut := api.Group("/stock-out")
+2
View File
@@ -157,6 +157,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
stockIn.PUT("/orders/:id/withdraw", stockInH.Withdraw)
// 退单(已审核单退货,库存删除+冲应付):仅管理员/超管(service 内判权)
stockIn.POST("/orders/:id/return", stockInH.Return)
// 确认进价(暂估0价→真实价,前向补偿):仅管理员/超管(service 内判权)
stockIn.POST("/orders/:id/confirm-cost", stockInH.ConfirmCost)
}
// 出库
+105
View File
@@ -157,6 +157,111 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error
})
}
// CostConfirmItem 是「确认进价」的单行入参:把某入库明细的进价补成真实值。
type CostConfirmItem struct {
ItemID uint64 `json:"item_id"`
UnitPrice float64 `json:"unit_price"`
}
// ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后,
// 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内
// 前向写补偿:
// 1. 入库明细 UnitPrice / TotalPrice 改为真实值
// 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值
// 3. 该 product 已出库行 StockOutItem.UnitPrice/TotalPrice(成本快照)回填真实值
// product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收)
// 4. 入库单 TotalAmount 按差额重算
// 5. 对供应商应付按差额补一条调整流水(滚动余额)
//
// 仅 approved 单可确认(draft 直接编辑即可);权限:管理员/超管。
// 以「旧价→新价」差额计算补偿,支持多次修正(再次确认按新旧差额补)。
func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role string, items []CostConfirmItem) error {
if role != "admin" && role != "superadmin" {
return ErrForbidden
}
return s.db.Transaction(func(tx *gorm.DB) error {
var order model.StockInOrder
if err := tx.Preload("Items").Where("id = ? AND shop_id = ?", orderID, shopID).
First(&order).Error; err != nil {
return err
}
if order.Status != "approved" {
return errors.New("只有已审核单据可确认进价")
}
want := make(map[uint64]float64, len(items))
for _, it := range items {
want[it.ItemID] = it.UnitPrice
}
now := time.Now()
var totalDiff float64
for i := range order.Items {
it := &order.Items[i]
newPrice, ok := want[it.ID]
if !ok || newPrice == it.UnitPrice {
continue
}
oldPrice := it.UnitPrice
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,
}).Error; err != nil {
return err
}
// 2. 该明细的剩余库存批次(已全部出库则无行,跳过)
if err := tx.Model(&model.Inventory{}).
Where("shop_id = ? AND stock_in_item_id = ? AND deleted_at IS NULL", shopID, it.ID).
Update("unit_price", newPrice).Error; err != nil {
return err
}
// 3. 已出库行成本快照回填(按 product_id 精确命中;product_id=0 的历史导入单不传播)
if it.ProductID != 0 {
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),
}).Error; err != nil {
return err
}
}
// 同步 product 参考进价
if it.ProductID != 0 {
tx.Model(&model.Product{}).Where("id = ? AND shop_id = ?", it.ProductID, shopID).
Update("purchase_price", newPrice)
}
}
if totalDiff == 0 {
return nil
}
// 4. 入库单总额按差额重算
if err := tx.Model(&order).
Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil {
return err
}
// 5. 应付差额调整流水(滚动余额)
bal := partnerLastBalance(tx, shopID, order.PartnerID) + totalDiff
oid := order.ID
return tx.Create(&model.FinanceRecord{
ShopID: shopID, PartnerID: order.PartnerID, Type: "payable",
Amount: totalDiff, Balance: bal, Status: "open",
RefType: "stock_in_cost_adjust", RefID: &oid, OperatorID: userID, RecordDate: now,
}).Error
})
}
// ApproveStockOut 审核出库单,FIFO 扣减批次库存
func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error {
return s.db.Transaction(func(tx *gorm.DB) error {