fix(purchase): 待采购清单的参考价格改按 material_list:referencePrice 管控

修复一个权限旁路:待采购清单的「参考单价」原本后端无条件返回、前端列也没有
任何门控,而 material_list:referencePrice 只授予 SUPER_ADMIN 与 SUPERVISOR。

结果是 WAREHOUSE_MGR(库管) / INBOUND(入库员) / SALES(销售) 虽然都持有
inbound_purchase:pending_pool、能打开待采购清单,就会看到参考价格 —— 而这个
数字他们在物料列表里是被挡住的。等于本页面成了绕过该权限的后门。

参考价格来自 material_base.reference_price,与物料列表同源,因此复用同一个
权限码管控,不另造新码(同源数据用同码,口径才不会漂)。

改动:
- 后端 get_pending_purchase_pool 新增 include_reference_price 参数,
  fail-closed 默认 False;无权限时**整个字段不返回**而不是给 None
- 新增 _has_material_reference_price_perm(),判定方式与既有的
  _filter_purchase_prices 保持一致(超管/主管放行 + 逐个权限码比对)
- 前端「参考单价」列加 hasPermission 门控,TS 类型改为可选

★ 只挡前端等于没挡(接口仍然裸奔),前后端必须同时改。
This commit is contained in:
yueli
2026-09-18 14:36:02 +08:00
parent 970c03fb44
commit 26a1857acb
4 changed files with 45 additions and 4 deletions

View File

@ -53,6 +53,25 @@ def _filter_purchase_prices(item_dict):
item_dict.pop('tax_rate', None)
def _has_material_reference_price_perm():
"""
能否看物料的参考价格。
★ 参考价格material_base.reference_price是受 material_list:referencePrice
管控的价格数据,与物料列表同源。任何暴露它的新接口都必须用**同一个码**
否则就成了绕过该权限的后门 —— 物料列表里挡住的数字,换个页面就看到了。
判定方式与 _filter_purchase_prices 保持一致(超管/主管放行 + 逐个权限码比对)。
"""
from app.services.auth_service import AuthService
claims = get_jwt()
role = claims.get('role', '')
if role.upper() in ('SUPER_ADMIN', 'SUPERVISOR'):
return True
perm_dict = AuthService.get_user_permissions(role, company_name=claims.get('company_name', ''))
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return 'material_list:referencePrice' in all_perms
# --------------------------------------------------------
# 1. 采购申请列表
# GET /api/v1/purchase
@ -452,6 +471,8 @@ def get_pending_purchase_pool():
page=page, per_page=limit, keyword=keyword,
category=category, material_type=material_type,
warning_status=warning_status,
# 参考价格按 material_list:referencePrice 管控,无权限时不返回该字段
include_reference_price=_has_material_reference_price_perm(),
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
except Exception as e:

View File

@ -536,7 +536,8 @@ class PurchaseService:
@staticmethod
def get_pending_purchase_pool(page=1, per_page=20, keyword=None,
category=None, material_type=None,
warning_status=None):
warning_status=None,
include_reference_price=False):
"""
待采购池:找出「**有效供给**仍不足、需要再买」的物料。
@ -561,6 +562,12 @@ class PurchaseService:
- 阈值可能为 None红黄两个阈值**各自独立**判断,不能 coalesce 成一个
- 用 `<=` 而非 `<` —— 恰好等于阈值即算不足
★ 参考价格reference_price默认**不返回**fail-closed
它是 material_base.reference_price属于受 material_list:referencePrice
管控的价格数据 —— 与物料列表同源,就必须同码管控,否则这里会变成
绕过该权限的后门(物料列表看不到、待采购清单却看得到)。
调用方API 层)按权限显式传 include_reference_price=True。
★ 关于 `<=`2026-09-18 与业务方确认,**刻意维持,勿擅自改成 `<`**
阈值是「警戒下限」,到线即需关注;且物料列表预警、预警邮件用的是同一套
`<=` 口径(那是系统原有约定,不是本接口另立的)。若只把本接口改成 `<`
@ -738,7 +745,11 @@ class PurchaseService:
'company_name': material.company_name or '',
'image': PurchaseService._first_image(material.product_image),
'purchase_link': material.purchase_link or '',
'reference_price': float(material.reference_price) if material.reference_price is not None else None,
# fail-closed无权限时整个字段不出现而不是给 None ——
# 前端据字段是否存在决定列是否渲染,语义更干净
**({'reference_price': float(material.reference_price)
if material.reference_price is not None else None}
if include_reference_price else {}),
'inventory_count': inv,
'available_count': avail,
# 在途量与有效供给一并返回,前端才能向采购员解释「为什么只建议买这么多」

View File

@ -175,7 +175,8 @@ export interface PendingPoolItem {
company_name: string
image: string
purchase_link: string
reference_price: number | null
// 仅供参考展示,受 material_list:referencePrice 管控 —— 无权限时后端**不返回该字段**
reference_price?: number | null
inventory_count: number
available_count: number
// 在途量 = 该物料所有活跃采购单的剩余待入库量之和

View File

@ -184,7 +184,15 @@
</template>
</el-table-column>
<el-table-column label="参考单价" width="110" align="right">
<!-- 参考价格是受 material_list:referencePrice 管控的价格数据与物料列表同源
同码这里与后端 include_reference_price 一一对应后端无权限时整字段
不返回前端也不渲染该列 只挡前端等于没挡接口仍然裸奔 -->
<el-table-column
v-if="userStore.hasPermission('material_list:referencePrice')"
label="参考单价"
width="110"
align="right"
>
<template #default="{ row }">
<span v-if="row.reference_price != null">{{ row.reference_price.toFixed(2) }}</span>
<span v-else style="color: #c0c4cc;">-</span>