Files
KCGL/inventory-backend/app/services/purchase_service.py
yueli 26a1857acb 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 类型改为可选

★ 只挡前端等于没挡(接口仍然裸奔),前后端必须同时改。
2026-09-18 14:36:02 +08:00

786 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
from datetime import datetime, timezone, timedelta, date
from sqlalchemy import func
from app.extensions import db, beijing_time
from app.models.purchase import PurchaseRequest
from app.models.base import MaterialBase
class PurchaseService:
@staticmethod
def generate_request_no(batch_seq: int = None):
"""
生成采购单号: PUR-yyyyMMdd-HHmm-批次-批内序号
Args:
batch_seq: 今日第几次采购批次(前端提交一批时传入,同一批共享)
None 时回退为旧格式 PUR-日期-时间-流水
返回:
- 有 batch_seq: PUR-20260831-1021-0001-0001批次号-批内序号)
- 无 batch_seq: PUR-20260831-1021-0001旧格式兼容
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
if batch_seq is not None:
# 批次前缀: PUR-日期-时间-批次
batch_prefix = f"PUR-{date_str}-{time_str}-{batch_seq:04d}"
# 批内序号: 该批次前缀下的记录数 + 1
item_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
.filter(PurchaseRequest.request_no.like(f"{batch_prefix}-%")).scalar()
return f"{batch_prefix}-{(item_count + 1):04d}"
# 旧格式兼容: PUR-日期-时间-流水
prefix = f"PUR-{date_str}-{time_str}-"
existing_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
return f"{prefix}{(existing_count + 1):04d}"
@staticmethod
def get_next_batch_seq():
"""
返回今日下一个采购批次号(今日第几次)
统计今日已有的不同批次单号第4段数量+1
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"PUR-{date_str}-{time_str}-"
# 查询今日所有单号提取第4段批次号去重
rows = db.session.query(PurchaseRequest.request_no) \
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).all()
batch_seqs = set()
for (rn,) in rows:
parts = rn.split('-')
if len(parts) >= 4:
batch_seqs.add(parts[3])
return len(batch_seqs) + 1
@staticmethod
def auto_fill_from_material(keyword: str):
"""
根据 name 或 spec_model 自动补全另一个字段
keyword: 用户输入的名称或规格
返回: {'name': ..., 'spec_model': ...} 或 None
"""
if not keyword:
return None
material = MaterialBase.query.filter(
(MaterialBase.name.ilike(f'%{keyword}%')) |
(MaterialBase.spec_model.ilike(f'%{keyword}%'))
).first()
if material:
return {
'name': material.name,
'spec_model': material.spec_model or ''
}
return None
@staticmethod
def create_purchase_request(data: dict, requester_id: int):
"""
创建采购申请
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
unit_price, total_price, approver_id, base_id (可选)
"""
batch_seq = data.get('batch_seq')
request_no = PurchaseService.generate_request_no(batch_seq=batch_seq)
purchase_date = data.get('purchase_date')
if isinstance(purchase_date, str):
purchase_date = datetime.strptime(purchase_date, '%Y-%m-%d').date()
elif isinstance(purchase_date, datetime):
purchase_date = purchase_date.date()
# [新增] 自动匹配/关联基础物料
base_id = data.get('base_id')
if not base_id and data.get('name'):
# 尝试通过 name + spec_model 精确匹配 MaterialBase
material = MaterialBase.query.filter(
MaterialBase.name == data['name'],
MaterialBase.spec_model == data.get('spec_model', ''),
MaterialBase.is_enabled == True
).first()
if material:
base_id = material.id
purchase = PurchaseRequest(
request_no=request_no,
base_id=base_id, # [新增]
name=data['name'],
spec_model=data.get('spec_model', ''),
quantity=float(data['quantity']),
purchase_date=purchase_date,
supplier_link=data.get('supplier_link', ''),
remark=data.get('remark', ''),
images=json.dumps(data.get('images', []), ensure_ascii=False) if data.get('images') else '[]',
unit_price=float(data.get('unit_price', 0) or 0),
total_price=float(data.get('total_price', 0) or 0),
tax_rate=float(data.get('tax_rate', 0) or 0),
requester_id=requester_id,
approver_id=data.get('approver_id'),
status=0
)
db.session.add(purchase)
db.session.commit()
# 发送邮件给审批人
PurchaseService._notify_new_request(purchase)
return purchase
@staticmethod
def approve_purchase_request(purchase_id: int, user_id: int, action: str, reject_reason: str = None):
"""
审批采购申请
action: 'approve''reject'
"""
purchase = db.session.get(PurchaseRequest, purchase_id)
if not purchase:
raise ValueError("采购申请不存在")
# ★ approved_at 与 created_at(beijing_time) 同为 naive 本地时间,避免存库时被转成 UTC 早 8 小时
now = beijing_time()
# ★ 完结:库管将「已通过(1)」的申请单置为「已完结(4)」(仿出库审批)
if action == 'close':
if purchase.status != 1:
raise ValueError("仅「已通过」的采购申请可完结")
purchase.status = 4
purchase.approver_id = user_id
purchase.approved_at = now
db.session.commit()
return purchase
if purchase.status != 0:
raise ValueError("当前状态不允许审批")
if action == 'approve':
purchase.status = 1
purchase.approver_id = user_id
purchase.approved_at = now
db.session.commit()
PurchaseService._notify_approved(purchase)
elif action == 'reject':
purchase.status = 2
purchase.approver_id = user_id
purchase.approved_at = now
purchase.reject_reason = reject_reason or ''
db.session.commit()
PurchaseService._notify_rejected(purchase)
else:
raise ValueError("无效的审批操作")
return purchase
@staticmethod
def get_purchase_list(page=1, per_page=20, requester_id=None, status=None,
keyword=None, search_type='all',
start_date=None, end_date=None):
"""
获取采购申请列表,普通用户只看自己的,主管/超管看同公司全部。
搜索参数(与出库/报废记录保持同一套语义,便于用户迁移习惯):
keyword 关键词
search_type all / no(单号) / name(物料名称) / spec_model / requester(申请人)
start_date / end_date 按采购日期过滤(含边界)
"""
from app.utils.decorators import get_current_company_filter
from app.models.system import SysUser
from sqlalchemy import or_, cast, String
query = PurchaseRequest.query
if requester_id is not None:
query = query.filter(PurchaseRequest.requester_id == requester_id)
if status is not None:
query = query.filter(PurchaseRequest.status == status)
# ---- 关键词搜索 ----
#
# 申请人姓名存在 SysUser.username格式为「姓名/账号」(如 韩善龙/hanshanlong
# 因此按「姓名」搜索时用 ilike 直接匹配整串即可命中。
# 该类字段只存在于 SysUser故需要 join单号/名称/规格则不需要 ——
# 只在必要时 join避免无谓的联表。
needs_user = bool(keyword) and search_type in ('requester', 'all')
if needs_user:
query = query.outerjoin(SysUser, PurchaseRequest.requester_id == SysUser.id)
if keyword:
kw = f'%{keyword}%'
if search_type == 'no':
query = query.filter(PurchaseRequest.request_no.ilike(kw))
elif search_type == 'name':
query = query.filter(PurchaseRequest.name.ilike(kw))
elif search_type == 'spec_model':
query = query.filter(PurchaseRequest.spec_model.ilike(kw))
elif search_type == 'requester':
query = query.filter(SysUser.username.ilike(kw))
else: # all —— 覆盖单号 / 名称 / 规格 / 备注 / 申请人
query = query.filter(or_(
PurchaseRequest.request_no.ilike(kw),
PurchaseRequest.name.ilike(kw),
PurchaseRequest.spec_model.ilike(kw),
PurchaseRequest.remark.ilike(kw),
SysUser.username.ilike(kw),
))
# ---- 日期范围(按采购日期)----
if start_date:
query = query.filter(PurchaseRequest.purchase_date >= start_date)
if end_date:
query = query.filter(PurchaseRequest.purchase_date <= end_date)
# 【行级数据隔离】同公司可见:匹配 MaterialBase.company_name 或 SysUser.department
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.outerjoin(MaterialBase, PurchaseRequest.base_id == MaterialBase.id)
# SysUser 可能已被上面的关键词分支 join 过 —— 重复 join 会产生
# 笛卡尔积导致结果翻倍,此处按需补 join。
if not needs_user:
query = query.outerjoin(SysUser, PurchaseRequest.requester_id == SysUser.id)
query = query.filter(or_(
MaterialBase.company_name == company_limit,
SysUser.department == company_limit
))
# ★ 兜底去重:上述 join 在特定组合下(如 base_id 为空 + requester 匹配)
# 仍可能产生重复行,用 distinct 保证每个单据只出现一次。
query = query.distinct()
query = query.order_by(PurchaseRequest.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [p.to_dict() for p in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_purchase_by_id(purchase_id: int):
purchase = db.session.get(PurchaseRequest, purchase_id)
return purchase.to_dict() if purchase else None
@staticmethod
def get_approved_requests(page=1, per_page=20, keyword=None):
"""
获取已审批通过且未入库的采购申请列表(专供库管按单入库使用)
筛选条件:
- status == 1已审批通过
- 尚未被任何 StockBuy 关联request_id 未被引用)
返回字段包含: 采购申请信息 + MaterialBase 基础物料信息
"""
from app.models.inbound.buy import StockBuy
# 子查询:所有已被入库引用的 request_id去重
stocked_ids = db.session.query(StockBuy.request_id).filter(
StockBuy.request_id.isnot(None)
).distinct().subquery()
# 主查询:已通过 且 不在已入库集合中
query = db.session.query(PurchaseRequest).filter(
PurchaseRequest.status == 1
).filter(
~PurchaseRequest.id.in_(stocked_ids)
)
# 可选关键词搜索:采购单号 / 名称 / 规格
if keyword:
k = f'%{keyword.strip()}%'
query = query.filter(
PurchaseRequest.request_no.ilike(k) |
PurchaseRequest.name.ilike(k) |
PurchaseRequest.spec_model.ilike(k)
)
query = query.order_by(PurchaseRequest.approved_at.desc().nullslast(),
PurchaseRequest.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
# ★ 批量预取 MaterialBase三段式查询base_id 精确 → name 精确 → name 模糊)
purchase_items = list(pagination.items)
base_ids = {p.base_id for p in purchase_items if p.base_id}
names_no_base = [p.name for p in purchase_items if p.name and not p.base_id]
material_map = {} # base_id → MaterialBase
name_map = {} # (name, spec) or (name, '__fallback__') → MaterialBase
# 阶段1: base_id 精确预取
if base_ids:
materials = MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
material_map = {m.id: m for m in materials}
# 阶段2: name 精确预取name.in_
if names_no_base:
exact_matches = MaterialBase.query.filter(
MaterialBase.name.in_(names_no_base),
MaterialBase.is_enabled == True
).all()
for m in exact_matches:
name_map[(m.name, m.spec_model or '')] = m
# 阶段3: name 模糊预取(仅>=2字符 + LIMIT 500 防爆炸)
unmatched_names = []
for p in purchase_items:
if p.base_id and p.base_id in material_map:
continue
if p.name and (p.name, p.spec_model or '') in name_map:
continue
n = (p.name or '').strip()
if len(n) >= 2: # ★ 安全阈值至少2字符才做模糊匹配
unmatched_names.append(n)
if unmatched_names:
fuzzy_conditions = [
MaterialBase.name.ilike(f'%{n}%') for n in unmatched_names
]
fuzzy_matches = MaterialBase.query.filter(
db.or_(*fuzzy_conditions),
MaterialBase.is_enabled == True
).order_by(MaterialBase.id.desc()).limit(500).all() # ★ 硬上限最多500条
for m in fuzzy_matches:
key = (m.name, '__fuzzy__')
if key not in name_map:
name_map[key] = m
# 内存匹配O(1) 字典查找)
items = []
for p in purchase_items:
item = p.to_dict()
material = None
# 1. base_id 精确
if p.base_id:
material = material_map.get(p.base_id)
# 2. name + spec 精确
if not material and p.name:
material = name_map.get((p.name, p.spec_model or ''))
# 3. name 模糊回退
if not material and p.name:
material = name_map.get((p.name, '__fuzzy__'))
if material:
item['material'] = {
'id': material.id,
'company_name': material.company_name or '',
'name': material.name,
'spec_model': material.spec_model or '',
'category': material.category or '',
'unit': material.unit or '',
'type': material.material_type or '',
'is_inspection_required': bool(material.is_inspection_required),
}
else:
item['material'] = None
items.append(item)
return {
'items': items,
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def search_base_material(keyword: str, page: int = 1, limit: int = 20):
"""
物料基础信息搜索,支持 name/spec_model/company_name 模糊匹配,返回分页结果
用于采购申请弹窗的物料远程搜索
"""
from sqlalchemy import and_, or_
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
k = keyword.strip()
k_str = f'%{k}%'
query = query.filter(or_(
MaterialBase.name.ilike(k_str),
MaterialBase.spec_model.ilike(k_str),
MaterialBase.company_name.ilike(k_str)
))
query = query.order_by(MaterialBase.id.desc())
pagination = query.paginate(page=page, per_page=limit, error_out=False)
items = []
for item in pagination.items:
items.append({
'id': item.id,
'company_name': item.company_name,
'name': item.name,
'spec_model': item.spec_model,
'category': item.category,
'unit': item.unit,
'type': item.material_type,
'pinyin': getattr(item, 'pinyin', ''),
'status': '启用'
})
return {
'items': items,
'total': pagination.total,
'page': page,
'has_next': pagination.has_next
}
@staticmethod
def _notify_new_request(purchase):
"""发送新申请邮件给审批人"""
try:
from app.utils.email_service import send_email_async
from app.models.system import SysUser
if not purchase.approver_id:
return
approver = db.session.get(SysUser, purchase.approver_id)
if not approver or not approver.email:
return
subject = f"【待审批】采购申请单 {purchase.request_no}"
content = f"""您好,
您有一笔新的采购申请待审批:
申请单号:{purchase.request_no}
采购物品:{purchase.name}
规格型号:{purchase.spec_model or '-'}
采购数量:{float(purchase.quantity)}
申请时间:{purchase.created_at.strftime('%Y-%m-%d %H:%M') if purchase.created_at else '-'}
备注说明:{purchase.remark or ''}
请登录仓库管理系统进行审批。
此邮件由系统自动发送,请勿回复。
"""
send_email_async(approver.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通知审批人失败: {e}")
except Exception:
print(f"[Email] 采购申请通知审批人失败: {e}")
@staticmethod
def _notify_approved(purchase):
"""审批通过后通知申请人"""
try:
from app.utils.email_service import send_email_async
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已通过】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已审批通过,现已交给库管。
待库管完成入库后,您可在系统中查询采购记录。
此邮件由系统自动发送,请勿回复。
"""
send_email_async(requester.email, subject, content)
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 采购申请通过通知申请人失败: {e}")
except Exception:
print(f"[Email] 采购申请通过通知申请人失败: {e}")
@staticmethod
def _notify_rejected(purchase):
"""审批驳回后通知申请人"""
try:
from app.utils.email_service import send_email_async
from app.models.system import SysUser
requester = db.session.get(SysUser, purchase.requester_id)
if not requester or not requester.email:
return
subject = f"【已驳回】采购申请单 {purchase.request_no}"
content = f"""{"尊敬的 " + requester.username + ",您好" if requester.username else "您好"}
您的采购申请单 {purchase.request_no}{purchase.name})已被驳回。
驳回原因:{purchase.reject_reason or '未说明'}
请登录仓库管理系统查看详情。
此邮件由系统自动发送,请勿回复。
"""
send_email_async(requester.email, subject, content)
except Exception as 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,
include_reference_price=False):
"""
待采购池:找出「**有效供给**仍不足、需要再买」的物料。
业务目的:杜绝多名采购员对同一短缺物料重复购买,同时**不能漏掉缺口**。
核心是有效供给:
有效供给 = 当前物理总库存 + 在途量
在途量 = SUM(该物料所有活跃采购单的剩余待入库量)
= SUM(GREATEST(采购量 - 累计入库量, 0))
过滤规则:有效供给 <= 红/黄阈值 才留在池中。
★ 为什么不是「有采购单就排除」(本接口的第一版):
红线 10、库存 0、某采购员只建了一张数量 5 的单 —— 若按 EXISTS 一刀切,
该物料会**立刻从池中消失**,剩下 5 个缺口永远无人认领。这是掩耳盗铃。
按量计算后它继续留在池中suggested_qty 自动降到 5提醒下一位只补 5 个。
★ 判定口径必须与物料列表预警inbound/base_service.get_list严格一致
否则会出现「列表亮红灯、池子却说不用买」的撕裂:
- 用**物理库存总量**(三个 stock_* 表的 stock_quantity 之和),不是可用量
- 阈值可能为 None红黄两个阈值**各自独立**判断,不能 coalesce 成一个
- 用 `<=` 而非 `<` —— 恰好等于阈值即算不足
★ 参考价格reference_price默认**不返回**fail-closed
它是 material_base.reference_price属于受 material_list:referencePrice
管控的价格数据 —— 与物料列表同源,就必须同码管控,否则这里会变成
绕过该权限的后门(物料列表看不到、待采购清单却看得到)。
调用方API 层)按权限显式传 include_reference_price=True。
★ 关于 `<=`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 '',
# 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,
# 在途量与有效供给一并返回,前端才能向采购员解释「为什么只建议买这么多」
'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 ''