feat(purchase): 新增待采购池接口(防重复采购)

GET /api/v1/purchase/pending-pool:找出「有效供给仍低于预警线」的物料。

  有效供给 = 物理总库存 + 在途量
  过滤规则 = 有效供给 <= 红/黄阈值 才进池
  建议采购量 = max(1, 目标阈值 - 有效供给)

★ 为什么不用「有活跃单就排除」:红线 10、库存 0、某采购员只建了一张
  数量 5 的单时,该物料会立刻从池中消失,剩下 5 个缺口永远无人认领。
  改为按量计算后它继续留池,suggested_qty 自动降到 5。

★ 用 <= 而非 < 是与业务方确认后刻意维持的口径(与物料列表预警、预警邮件
  同源),勿擅自改成严格小于 —— 只改本接口会造成「列表亮黄灯、池子却排除」
  的撕裂,真要改必须三处一起动。代价是恰好等于阈值时建议量落到保底的 1,
  前端 tooltip 已单独说明,不展示算不成立的错误算式。

权限:新增 inbound_purchase:pending_pool(注册为 sys_element 而非新建
SysMenu)。因 ensure_default_permissions 在角色已有权限时整段跳过,另写了
存量角色自动迁移,并按源行镜像 company_name 避免跨公司作用域泄漏。
This commit is contained in:
yueli
2026-09-18 14:30:53 +08:00
parent fc5e62c848
commit dec33b685c
3 changed files with 327 additions and 1 deletions

View File

@ -673,6 +673,12 @@ class PermissionService:
('inbound_purchase:unit_price', '采购单价', 'column'),
('inbound_purchase:total_price', '采购总价', 'column'),
('inbound_purchase:tax_rate', '税率', 'column'),
# ★ 待采购清单(防重复采购)菜单权限。
# 注册成 element 而非新建 SysMenu:侧边栏与路由守卫只读前端
# meta.permissions + sys_role_permission,从不读 sys_menu;
# 且 init_all_menus 的「冗余子菜单清理」会按 target_code 删权限行,
# 新增 SysMenu 等于多一个被该清理逻辑波及的面。
('inbound_purchase:pending_pool', '待采购清单', 'operation'),
]
for code, name, etype in purchase_elements:
existing = SysElement.query.filter_by(
@ -833,6 +839,48 @@ class PermissionService:
print(f"[权限迁移] {role_code}: inbound_buy → inbound_purchase 已自动迁移")
total_inserted += 1
# ★ 自动迁移:已有「采购申请」菜单权限的角色 → 自动获得「待采购清单」权限
#
# 为什么必须独立成段:上面的默认分配逻辑在角色**已有任何权限**时
# 就整段跳过(见上方 existing_count > 0 的分支),存量角色根本走不到
# 那里。不写这一段,除了超管之外没有任何角色能拿到新权限。
#
# 本段依赖 autoflush:上一个循环刚 add 的 inbound_purchase 行尚未
# commit,这里的查询必须能看见它们(扩展默认 autoflush=True 保证了这点)。
for role_code in known_roles:
if role_code.upper() == 'SUPER_ADMIN':
continue
# 以「采购申请菜单权限」为门控 —— 能进采购页的角色就该看到待采购清单。
# ★ 同时镜像它的 company_name:若某角色的采购菜单是公司定制的
# (company_name='A'),补出的权限必须落在同一作用域;硬写 NULL
# 会让其他公司的同角色用户凭空多出这个菜单。
src_perm = SysRolePermission.query.filter_by(
role_code=role_code, target_code='inbound_purchase', type='menu'
).first()
if not src_perm:
continue
# 幂等检查也必须带 company_name,否则 A 公司已有行会跳过 B 公司那条
already = SysRolePermission.query.filter_by(
role_code=role_code,
target_code='inbound_purchase:pending_pool',
type='element',
company_name=src_perm.company_name,
).first()
if already:
continue
db.session.add(SysRolePermission(
role_code=role_code,
target_code='inbound_purchase:pending_pool',
type='element',
company_name=src_perm.company_name,
))
total_inserted += 1
print(f"[权限迁移] {role_code}: 已补充「待采购清单」权限"
f"(作用域={src_perm.company_name or '全局'})")
# ★ SUPERVISOR 主管默认获得采购申请子菜单权限
for role_code in known_roles:
if role_code.upper() == 'SUPERVISOR':

View File

@ -528,4 +528,248 @@ class PurchaseService:
"""
send_email_async(requester.email, subject, content)
except Exception as e:
print(f"[Email] 采购申请驳回通知失败: {e}")
print(f"[Email] 采购申请驳回通知失败: {e}")
# ============================================================
# 待采购池(防重复采购)
# ============================================================
@staticmethod
def get_pending_purchase_pool(page=1, per_page=20, keyword=None,
category=None, material_type=None,
warning_status=None):
"""
待采购池:找出「**有效供给**仍不足、需要再买」的物料。
业务目的:杜绝多名采购员对同一短缺物料重复购买,同时**不能漏掉缺口**。
核心是有效供给:
有效供给 = 当前物理总库存 + 在途量
在途量 = SUM(该物料所有活跃采购单的剩余待入库量)
= SUM(GREATEST(采购量 - 累计入库量, 0))
过滤规则:有效供给 <= 红/黄阈值 才留在池中。
★ 为什么不是「有采购单就排除」(本接口的第一版):
红线 10、库存 0、某采购员只建了一张数量 5 的单 —— 若按 EXISTS 一刀切,
该物料会**立刻从池中消失**,剩下 5 个缺口永远无人认领。这是掩耳盗铃。
按量计算后它继续留在池中,suggested_qty 自动降到 5,提醒下一位只补 5 个。
★ 判定口径必须与物料列表预警(inbound/base_service.get_list)严格一致,
否则会出现「列表亮红灯、池子却说不用买」的撕裂:
- 用**物理库存总量**(三个 stock_* 表的 stock_quantity 之和),不是可用量
- 阈值可能为 None,红黄两个阈值**各自独立**判断,不能 coalesce 成一个
- 用 `<=` 而非 `<` —— 恰好等于阈值即算不足
★ 关于 `<=`(2026-09-18 与业务方确认,**刻意维持,勿擅自改成 `<`**):
阈值是「警戒下限」,到线即需关注;且物料列表预警、预警邮件用的是同一套
`<=` 口径(那是系统原有约定,不是本接口另立的)。若只把本接口改成 `<`,
会出现「列表亮黄灯、池子却把它排除」的撕裂。
代价是有效供给恰好等于阈值时 suggested_qty 会落到最小值 1(看似
「不用买却建议买 1 个」)—— 这是有意的缓冲,前端 tooltip 已单独说明。
真要改成 `<`,必须**同时**改 base_service 的判级与 inventory_task 的
触发条件,三处一起动。
"""
import math
from sqlalchemy import and_, case, cast, desc, or_
from sqlalchemy.types import Numeric as SqlNumeric
from app.models.base import MaterialWarningSetting
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.inbound.semi import StockSemi
from app.utils.decorators import get_current_company_filter
from app.utils.purchase_activity import in_transit_subquery
# ---- 三表库存聚合(口径同 base_service.get_list:146-170)----
buy_sub = db.session.query(
StockBuy.base_id,
func.sum(StockBuy.stock_quantity).label('buy_inv'),
func.sum(StockBuy.available_quantity).label('buy_avail')
).group_by(StockBuy.base_id).subquery()
semi_sub = db.session.query(
StockSemi.base_id,
func.sum(StockSemi.stock_quantity).label('semi_inv'),
func.sum(StockSemi.available_quantity).label('semi_avail')
).group_by(StockSemi.base_id).subquery()
prod_sub = db.session.query(
StockProduct.base_id,
func.sum(StockProduct.stock_quantity).label('prod_inv'),
func.sum(StockProduct.available_quantity).label('prod_avail')
).group_by(StockProduct.base_id).subquery()
total_inv = (func.coalesce(buy_sub.c.buy_inv, 0)
+ func.coalesce(semi_sub.c.semi_inv, 0)
+ func.coalesce(prod_sub.c.prod_inv, 0))
total_avail = (func.coalesce(buy_sub.c.buy_avail, 0)
+ func.coalesce(semi_sub.c.semi_avail, 0)
+ func.coalesce(prod_sub.c.prod_avail, 0))
# ★ 必须先物化再过滤:total_inv 是聚合表达式,直接在带 GROUP BY 的查询里
# 参与比较会落到 HAVING 语义;物化成子查询后,它在外层就是普通列,
# 可以安全地进 WHERE(与 base_service.get_list:175-191 同一手法)。
inner_sub = (
db.session.query(
MaterialBase.id.label('base_id'),
total_inv.label('total_inv'),
total_avail.label('total_avail'),
)
.outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id)
.outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id)
.outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id)
.subquery()
)
# ---- 在途量聚合(每个物料一张单一张单地累加剩余待入库量)----
intransit_sub = in_transit_subquery()
inv_col = inner_sub.c.total_inv
avail_col = inner_sub.c.total_avail
# 没有活跃采购单的物料不在 intransit_sub 里,outerjoin 后为 NULL → 兜成 0
intransit_col = func.coalesce(intransit_sub.c.intransit, 0)
# ★ 有效供给:判缺货、算建议量,全部基于它,而不是光看库存
effective_col = inv_col + intransit_col
red_col = cast(MaterialWarningSetting.red_threshold, SqlNumeric)
yellow_col = cast(MaterialWarningSetting.yellow_threshold, SqlNumeric)
# inner join 预警配置:缺货的前提就是预警已启用,outer join 无意义。
# (material_warning_settings 按约定与物料 1:1 且已核实无重复行,
# 故不会因 join 放大行数。若将来出现重复行,此处需要改为每物料取一条。)
query = (
db.session.query(MaterialBase, MaterialWarningSetting,
inv_col, avail_col, intransit_col)
.join(inner_sub, MaterialBase.id == inner_sub.c.base_id)
.outerjoin(intransit_sub, MaterialBase.id == intransit_sub.c.base_id)
.join(MaterialWarningSetting, MaterialBase.id == MaterialWarningSetting.base_id)
.filter(MaterialWarningSetting.is_enabled.is_(True))
)
# ---- ① 有效供给不足 ----
# 对应 Python 侧的 `if 供给<=红 ... elif 供给<=黄`:命中任一即不足。
# ★ 绝不写成 `supply <= coalesce(red, yellow)` —— 那会把「红阈值未配置」
# 偷换成「用黄阈值」,且在两者都为 NULL 时静默退化成 NULL 比较。
red_hit = and_(MaterialWarningSetting.red_threshold.isnot(None), effective_col <= red_col)
yellow_hit = and_(MaterialWarningSetting.yellow_threshold.isnot(None), effective_col <= yellow_col)
query = query.filter(or_(red_hit, yellow_hit))
# ---- 行级数据隔离(多租户)----
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# ---- 可选筛选 ----
if keyword:
kw = f'%{keyword.strip()}%'
query = query.filter(or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
MaterialBase.company_name.ilike(kw),
))
if category:
query = query.filter(MaterialBase.category.ilike(f"{category.strip()}%"))
if material_type:
query = query.filter(MaterialBase.material_type.ilike(material_type.strip()))
if warning_status == 2:
query = query.filter(red_hit)
elif warning_status == 1:
# 黄色要在红之外,否则 1/2 语义重叠
query = query.filter(yellow_hit, ~red_hit)
# ---- 排序:最缺的排最上面(复刻 base_service.get_list:372-391 的口径)----
warning_level = case(
(red_hit, 2),
(yellow_hit, 1),
else_=0,
)
# 缺口按**有效供给**算,在途已经补上的部分不再计入缺口
gap = case(
(red_hit, red_col - effective_col),
(yellow_hit, yellow_col - effective_col),
else_=0,
)
query = query.order_by(desc(warning_level), desc(gap), MaterialBase.id.asc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
items = []
for row in pagination.items:
material = row[0]
setting = row[1]
inv = float(row[2]) if row[2] is not None else 0.0
avail = float(row[3]) if row[3] is not None else 0.0
intransit = float(row[4]) if row[4] is not None else 0.0
effective = inv + intransit
red = float(setting.red_threshold) if setting.red_threshold is not None else None
yellow = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
# 判级用有效供给 —— 与上面的 SQL 过滤条件保持同一口径
status = 0
if red is not None and effective <= red:
status = 2
elif yellow is not None and effective <= yellow:
status = 1
# 建议采购量 = 阈值 - 有效供给,一次性把供给抬出**整个**预警带,
# 否则刚补完货下一条预警马上又来。
# 取 max(红,黄) 而非「被触发的那个」——正常配置下红<黄,补到黄线即可
# 完全退出;红黄配反的脏数据下也安全。
# 双阈值都为 None 时降级为 None,不会出现 None - effective 的 TypeError。
#
# ★ 注意在途已计入 effective,所以「已买 5」时这里只会建议剩下的 5,
# 而不是按库存从零算。这正是本次改造要修的那个漏洞。
targets = [t for t in (red, yellow) if t is not None]
target = max(targets) if targets else None
suggested = max(1, math.ceil(target - effective)) if target is not None else None
items.append({
'material_id': material.id,
'name': material.name,
'spec_model': material.spec_model or '',
'unit': material.unit or '',
'category': material.category or '',
'material_type': material.material_type or '',
'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,
'inventory_count': inv,
'available_count': avail,
# 在途量与有效供给一并返回,前端才能向采购员解释「为什么只建议买这么多」
'in_transit_qty': intransit,
'effective_supply': effective,
'warning_status': status,
'warning_red': red,
'warning_yellow': yellow,
'target_threshold': target,
'suggested_qty': suggested,
'is_ordered': bool(setting.is_ordered),
})
return {
'items': items,
'total': pagination.total,
'pages': pagination.pages,
'current_page': page,
}
@staticmethod
def _first_image(product_image):
"""从 material_base.product_image 的 JSON 字符串里取第一张图,取不到返回空串。"""
if not product_image:
return ''
try:
if isinstance(product_image, str) and not product_image.startswith('['):
return product_image # 兼容旧数据:单条 URL 直接存字符串
parsed = json.loads(product_image)
if isinstance(parsed, list) and parsed:
return parsed[0] or ''
except Exception:
return ''
return ''