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")
|
||||
|
||||
Reference in New Issue
Block a user