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
This commit is contained in:
wangjia
2026-07-03 12:44:22 +08:00
parent 607b8aa763
commit 4fd0bb8b83
24 changed files with 601 additions and 135 deletions
@@ -0,0 +1,212 @@
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"])
}