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:
@@ -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)
|
||||
|
||||
// 第一次确认 50(diff 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}}})
|
||||
// 第二次修正为 60(diff 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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 出库
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user