feat(backend): 库存成本仅管理员可见——List 抹空 unit_price、Summary 抹零货值

与出库 stripStockOutCost 同口径:operator/readonly 服务端兜底抹除,
防抓包看到成本;sale_price 与数量等字段不受影响。CLAUDE.md 口径同步。

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-06 08:11:05 +08:00
parent 3fc0ca5f0c
commit 75d0accb14
3 changed files with 67 additions and 1 deletions
+17
View File
@@ -47,6 +47,17 @@ type inventoryRow struct {
CreatedAt string `json:"created_at"`
}
// stripInventoryCost 服务端兜底:库存成本仅管理员可见(与出库 stripStockOutCost 同口径)。
// operator/readonly 的 List 响应把 unit_price(成本进价)置空,sale_price 不受影响。
func stripInventoryCost(role string, rows []inventoryRow) {
if role == "admin" || role == "superadmin" {
return
}
for i := range rows {
rows[i].UnitPrice = nil
}
}
// List GET /api/v1/inventory
func (h *InventoryHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
@@ -162,6 +173,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
return
}
stripInventoryCost(middleware.GetRole(c), rows)
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
}
@@ -210,6 +222,11 @@ func (h *InventoryHandler) Summary(c *gin.Context) {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
// 库存货值 = Σ(qty×进价),成本口径,仅管理员可见(同 stripInventoryCost
if role := middleware.GetRole(c); role != "admin" && role != "superadmin" {
s.StockValue = 0
s.LastMonthValue = 0
}
c.JSON(http.StatusOK, s)
}
@@ -393,3 +393,52 @@ func TestInventoryHandler_AfterStockInApprove(t *testing.T) {
invItem := data[0].(map[string]interface{})
assert.Equal(t, float64(20), invItem["quantity"])
}
// 库存成本仅管理员可见(stripInventoryCost):operator/readonly 的 List unit_price 抹空、
// Summary 货值抹零;admin 原样返回。与出库 stripStockOutCost 同口径。
func TestInventoryHandler_CostVisibility(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_COST")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
readonly := testutil.CreateTestUser(db, shop.ID, "ro", "pass", "readonly")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
r := setupProtectedRouter(db)
price := 100.0
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID,
Quantity: 10, UnitPrice: &price,
}).Error)
// adminunit_price / stock_value 原样可见
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
w := makeRequest(r, "GET", "/api/v1/inventory", adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
row := parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, float64(100), row["unit_price"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", adminToken, nil)
assert.Equal(t, float64(1000), parseResponse(w)["stock_value"].(float64))
// operator / readonlyunit_price 抹空、货值抹零,其余字段不受影响
for _, u := range []struct {
id uint64
role string
}{{operator.ID, "operator"}, {readonly.ID, "readonly"}} {
token := getAuthToken(u.id, shop.ID, u.role)
w = makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
row = parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Nil(t, row["unit_price"], "role=%s unit_price 应被抹空", u.role)
assert.Equal(t, float64(10), row["quantity"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(0), resp["stock_value"].(float64), "role=%s 货值应抹零", u.role)
assert.Equal(t, float64(0), resp["last_month_value"].(float64))
assert.Equal(t, float64(1), resp["sku_count"].(float64))
assert.Equal(t, float64(10), resp["in_stock_qty"].(float64))
}
}