From 75d0accb144c0c43d6b4dcbae6ea9e9ee71efc9b Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 6 Jul 2026 08:11:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=BA=93=E5=AD=98=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E4=BB=85=E7=AE=A1=E7=90=86=E5=91=98=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E2=80=94=E2=80=94List=20=E6=8A=B9=E7=A9=BA=20unit=5Fprice?= =?UTF-8?q?=E3=80=81Summary=20=E6=8A=B9=E9=9B=B6=E8=B4=A7=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与出库 stripStockOutCost 同口径:operator/readonly 服务端兜底抹除, 防抓包看到成本;sale_price 与数量等字段不受影响。CLAUDE.md 口径同步。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o --- CLAUDE.md | 2 +- backend/internal/handler/inventory.go | 17 ++++++++ backend/internal/handler/inventory_test.go | 49 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 36da61c..5fec0e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,7 @@ cd client && flutter test ### 定价字段口径(2026-07 消歧后,唯一口径) - 明细两侧价格分列:`cost_price`(成本/进价单价)+ `cost_amount`(成本小计);出库另有 `sale_price`(售价)+ `sale_amount`(售价小计,待定价=0)。**旧列 unit_price/total_price/total_amount 已弃用**(值已回填新列,观察一版后 DROP),新代码禁止读写旧列。 - 单据合计:入库 `cost_total`(应付=Σ总进价);出库 `sale_total`(应收=Σ售价小计)+ `profit_total`(总利润=Σ(售价>0?(售价-成本)×数量:0),建单落库,确认售价/确认进价联动重算,重算函数 `recalcStockOutProfit`)。 -- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。 +- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。**库存同口径**(2026-07-06):库存 List 抹空 unit_price、Summary 抹零 stock_value/last_month_value(`stripInventoryCost`),前端隐藏成本价/总价列、卡片单价、货值 KPI(`isAdminProvider`)、商品抽屉成本行。 - 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。 ### 库存变更 diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 03511d6..4dba89d 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -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) } diff --git a/backend/internal/handler/inventory_test.go b/backend/internal/handler/inventory_test.go index b09c725..1d905b1 100644 --- a/backend/internal/handler/inventory_test.go +++ b/backend/internal/handler/inventory_test.go @@ -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) + + // admin:unit_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 / readonly:unit_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)) + } +}