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:
@ -423,3 +423,37 @@ def search_material_for_purchase():
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 9. 待采购池(防重复采购)
|
||||
# GET /api/v1/purchase/pending-pool?page=1&limit=20&keyword=xxx
|
||||
# --------------------------------------------------------
|
||||
@purchase_bp.route('/pending-pool', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('inbound_purchase:pending_pool')
|
||||
def get_pending_purchase_pool():
|
||||
"""
|
||||
待采购清单:库存已触及预警线、且没有活跃采购单的物料。
|
||||
|
||||
防重逻辑的核心 —— 物料一旦有在途采购单(待审批/已通过/部分到货未齐),
|
||||
立即从本列表中消失;单据被驳回或强制结案后才会重新回流。
|
||||
"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
keyword = request.args.get('keyword', '').strip() or None
|
||||
category = request.args.get('category', '').strip() or None
|
||||
material_type = request.args.get('type', '').strip() or None
|
||||
|
||||
warning_status = request.args.get('warning_status', type=int)
|
||||
|
||||
result = PurchaseService.get_pending_purchase_pool(
|
||||
page=page, per_page=limit, keyword=keyword,
|
||||
category=category, material_type=material_type,
|
||||
warning_status=warning_status,
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'获取失败: {str(e)}'}), 500
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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 ''
|
||||
Reference in New Issue
Block a user