Files
jiu/backend/internal/handler/pricing_fields_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

213 lines
9.3 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
// 2026-07 定价字段消歧(cost_price/cost_amount/cost_total/sale_amount/sale_total/profit_total
// 的专项回归:建单三值落库、确认售价联动、确认进价→利润联动、operator 响应抹零。
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"
)
// 建单即落三值:sale_amount(行)、sale_total、profit_total(单据)
func TestStockOutHandler_Create_PricingFields(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PRC1")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
prod := testutil.CreateTestProduct(db, shop.ID, "Gin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100})
// 成本 355 × 2,售价 400 × 2 —— 即线上暴露口径问题的那组数
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": prod.ID, "quantity": 2.0, "cost_price": 355.0, "sale_price": 400.0},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
var it model.StockOutItem
db.Where("order_id = ?", orderID).First(&it)
assert.Equal(t, float64(355), it.CostPrice)
assert.Equal(t, float64(710), it.CostAmount) // 2×355
assert.Equal(t, float64(800), it.SaleAmount) // 2×400
var o model.StockOutOrder
db.First(&o, orderID)
assert.Equal(t, float64(800), o.SaleTotal)
assert.Equal(t, float64(90), o.ProfitTotal) // (400-355)×2
}
// 兼容回归:旧客户端发 unit_price(无 cost_price)→ 回落生效
func TestStockOutHandler_Create_LegacyUnitPriceKey(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PRC2")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
prod := testutil.CreateTestProduct(db, shop.ID, "Rum")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 10})
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": prod.ID, "quantity": 3.0, "unit_price": 20.0, "sale_price": 25.0},
},
})
require.Equal(t, http.StatusCreated, w.Code)
var it model.StockOutItem
db.Where("order_id = ?", extractID(w)).First(&it)
assert.Equal(t, float64(20), it.CostPrice)
assert.Equal(t, float64(60), it.CostAmount)
}
// 确认售价:sale_price/sale_amount/sale_total/profit_total 四值联动重算
func TestStockOutHandler_ConfirmSale_RecalcsAmounts(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PRC3")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
prod := testutil.CreateTestProduct(db, shop.ID, "Sake")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100})
// 待定价出库:成本 5 × 10
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, 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, "cost_price": 5.0},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
var it model.StockOutItem
db.Where("order_id = ?", orderID).First(&it)
assert.Equal(t, float64(0), it.SaleAmount) // 待定价
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/confirm-sale", orderID), token, map[string]interface{}{
"items": []map[string]interface{}{{"item_id": it.ID, "sale_price": 8.0}},
})
require.Equal(t, http.StatusOK, w.Code)
var got model.StockOutItem
db.First(&got, it.ID)
assert.Equal(t, float64(8), got.SalePrice)
assert.Equal(t, float64(80), got.SaleAmount)
var o model.StockOutOrder
db.First(&o, orderID)
assert.Equal(t, float64(80), o.SaleTotal)
assert.Equal(t, float64(30), o.ProfitTotal) // (8-5)×10
}
// 确认进价(成本回填)后,受影响出库单的 profit_total 联动重算
func TestConfirmStockInCost_RecalcsStockOutProfit(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PRC4")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 0 价暂估入库(新建独立产品)→ 审核
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_name": "调货茅台", "series": "普通", "spec": "500ml", "quantity": 10.0, "cost_price": 0.0},
},
})
require.Equal(t, http.StatusCreated, w.Code)
inOrderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inOrderID), token, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inOrderID), token, nil)
var inItem model.StockInItem
require.NoError(t, db.Where("order_id = ?", inOrderID).First(&inItem).Error)
// 以 0 成本出库 2 瓶、售价 10 → 审核;利润此刻 = (10-0)×2 = 20
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{
{"product_id": inItem.ProductID, "quantity": 2.0, "cost_price": 0.0, "sale_price": 10.0},
},
})
require.Equal(t, http.StatusCreated, w.Code)
outOrderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", outOrderID), token, nil)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", outOrderID), token, nil)
var beforeO model.StockOutOrder
db.First(&beforeO, outOrderID)
assert.Equal(t, float64(20), beforeO.ProfitTotal)
// 确认进价 4 → 出库成本快照回填 → 利润重算 (10-4)×2 = 12
w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inOrderID), token, map[string]interface{}{
"items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 4.0}},
})
require.Equal(t, http.StatusOK, w.Code)
var outItem model.StockOutItem
db.Where("order_id = ?", outOrderID).First(&outItem)
assert.Equal(t, float64(4), outItem.CostPrice)
assert.Equal(t, float64(8), outItem.CostAmount)
var afterO model.StockOutOrder
db.First(&afterO, outOrderID)
assert.Equal(t, float64(12), afterO.ProfitTotal)
}
// 成本/利润仅管理员可见:operator 的 List/Get 响应中 cost_price/cost_amount/profit_total 被抹零
func TestStockOutHandler_CostHiddenFromOperator(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PRC5")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
op := testutil.CreateTestUser(db, shop.ID, "op1", "pass", "operator")
wh := testutil.CreateTestWarehouse(db, shop.ID, "W")
prod := testutil.CreateTestProduct(db, shop.ID, "Vermouth")
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
opToken := getAuthToken(op.ID, shop.ID, "operator")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 10})
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": prod.ID, "quantity": 2.0, "cost_price": 30.0, "sale_price": 50.0},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
// 管理员可见成本与利润
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), adminToken, nil)
require.Equal(t, http.StatusOK, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, float64(40), data["profit_total"])
items := data["items"].([]interface{})
assert.Equal(t, float64(30), items[0].(map[string]interface{})["cost_price"])
// operator:成本/利润抹零,售价照常
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), opToken, nil)
require.Equal(t, http.StatusOK, w.Code)
data = parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, float64(0), data["profit_total"])
items = data["items"].([]interface{})
line := items[0].(map[string]interface{})
assert.Equal(t, float64(0), line["cost_price"])
assert.Equal(t, float64(0), line["cost_amount"])
assert.Equal(t, float64(50), line["sale_price"])
assert.Equal(t, float64(100), line["sale_amount"])
}