3625240839
调货 0 价入库并已审核/出库后,价格确定时补填真实进价:一事务内 回填入库明细/库存批次/已出库成本快照,重算入库单总额,按差额补 应付流水,同步参考进价。按 product_id 精确命中(序列号 1:1), 不动售价/应收。仅 approved 单、管理员/超管,差额幂等可重复确认。 新增 POST /stock-in/orders/:id/confirm-cost 及 4 个测试用例。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
8.5 KiB
Go
192 lines
8.5 KiB
Go
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)
|
||
}
|