Files
jiu/backend/internal/handler/stock_cost_confirm_test.go
T
wangjia 4fd0bb8b83 feat(backend): 出入库定价字段消歧 + 总利润落库 + 成本仅管理员可见
- 字段重命名(旧列弃用保留,启动幂等回填 BackfillPricingColumns):
  入库 unit_price/total_price/total_amount → cost_price/cost_amount/cost_total;
  出库同前缀 + 新增 sale_amount(售价小计)/ sale_total(应收)/ profit_total(总利润)
- 建单落三值;确认售价联动重算 sale_amount/sale_total/profit_total;
  确认进价回填成本后联动重算受影响出库单利润(recalcStockOutProfit)
- 出库 List/Get 对 operator/readonly 抹零成本与利润(stripStockOutCost 服务端兜底)
- 兼容一版:Create/Update 接受旧 key unit_price 回落(v1.0.87 及之前客户端)
- SUM 聚合/价格趋势/导入/种子工具同步切新列;测试全量改名 + 6 个新回归

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 12:44:22 +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
// 库存批次成本待定(CostPrice 为 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.CostPrice)
assert.Equal(t, 500.0, inItem.CostAmount)
// 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.CostPrice, "已出库成本应回填")
assert.Equal(t, 200.0, outItem.CostAmount, "成本小计 = 50×4")
assert.Equal(t, 80.0, outItem.SalePrice, "售价不应被改动")
var outOrder model.StockOutOrder
db.First(&outOrder, outID)
assert.Equal(t, 320.0, outOrder.SaleTotal, "应收按售价 4×80,确认进价不影响")
// 4. 入库单总额按差额重算:0 → 500
var inOrder model.StockInOrder
db.First(&inOrder, inID)
assert.Equal(t, 500.0, inOrder.CostTotal)
// 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.CostPrice)
var inOrder model.StockInOrder
db.First(&inOrder, inID)
assert.Equal(t, 600.0, inOrder.CostTotal, "总额 = 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)
}