Files
jiu/backend/internal/handler/stock_cost_confirm_test.go
T
wangjia 3625240839 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>
2026-06-23 20:07:49 +08:00

192 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}