Files
KCGL/inventory-backend/app/services/inventory_task.py
yueli b4445f07d9 feat(material): 物料列表新增「采购在途」自动标识,预警邮件改用活跃单静音
base_service.get_list:
  新增 isPurchasing 字段,与待采购池共用同一份活跃单判定。放在预警权限
  判断之外 —— 能看到物料列表的人都该知道这个物料已经在采购了。

inventory_task:
  静音条件从人工标记 is_ordered 改为「该物料无活跃采购单」。

★ 为什么必须同时改邮件:预警邮件由出库执行流程触发(outbound_service 出库
  完成后的调用),功能是活的;而它的静音入口(前端人工「标记已采购」)随本次
  改造一并移除。若不改这里,用户会彻底失去静音能力,每个缺货物料在每次出库后
  都会发信。自动判定在建单时静默、单据驳回或强制结案后自动恢复,比人工标记准。

两张表的判定口径都由 utils/purchase_activity.py 提供,不各写一遍。
2026-09-18 14:31:00 +08:00

292 lines
12 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.

"""
库存预警扫描与邮件通知服务
定时(或手动触发)扫描所有 is_enabled=True 且 is_ordered=False 的预警配置,
按物料配置的邮箱独立发送,不依赖 SysUser 角色。
- 库存 <= red_threshold → 红色预警邮件(发 setting.red_emails
- red_threshold < 库存 <= yellow_threshold → 黄色预警邮件(发 setting.yellow_emails
- 同一收件人在多条记录中出现 → 聚合为一封邮件
- 发送成功后更新 last_notified_at
"""
from datetime import datetime, timezone, timedelta
from collections import defaultdict
from sqlalchemy import func
from app.extensions import db
from app.models.base import MaterialBase, MaterialWarningSetting
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
class InventoryWarningService:
@staticmethod
def _prefetch_inventory_map(settings):
"""
批量预取所有预警物料的库存总计(单条 SQL跨三表聚合
性能优化v2
- 消除 N+1用 UNION ALL + GROUP BY 替代循环内逐条 scalar 查询
- 消除额外的 MaterialBase.get():批量返回 name / spec_model
Returns:
dict[int, dict]: {base_id: {'inv': float, 'avail': float, 'name': str, 'spec': str}, ...}
"""
from sqlalchemy import text
base_ids = list({s.base_id for s in settings})
if not base_ids:
return {}
# ── 单条 SQL三表 UNION ALL → 外层 GROUP BY ──
sql = text("""
SELECT base_id,
SUM(stock_qty) AS total_stock,
SUM(avail_qty) AS total_avail
FROM (
SELECT base_id, stock_quantity AS stock_qty, available_quantity AS avail_qty
FROM stock_buy
WHERE base_id = ANY(:ids)
UNION ALL
SELECT base_id, stock_quantity, available_quantity
FROM stock_semi
WHERE base_id = ANY(:ids)
UNION ALL
SELECT base_id, stock_quantity, available_quantity
FROM stock_product
WHERE base_id = ANY(:ids)
) AS combined
GROUP BY base_id
""")
rows = db.session.execute(sql, {'ids': base_ids}).fetchall()
# 批量查询 MaterialBase
materials = MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
mat_map = {m.id: {'name': m.name, 'spec': m.spec_model or ''} for m in materials}
result = {}
for row in rows:
bid = row.base_id
mat = mat_map.get(bid, {'name': '', 'spec': ''})
result[bid] = {
'inv': float(row.total_stock or 0),
'avail': float(row.total_avail or 0),
'name': mat['name'],
'spec': mat['spec']
}
return result
@staticmethod
def _parse_emails(email_str: str) -> list:
"""从逗号分隔字符串中提取并清洗有效邮箱列表"""
if not email_str or not email_str.strip():
return []
return [e.strip() for e in email_str.split(',') if e.strip() and '@' in e.strip()]
@staticmethod
def _build_text_table(rows: list, level: str) -> str:
"""
构建纯文本物料清单表格
Args:
rows: [{"name": ..., "spec": ..., "qty": ..., "threshold": ..., "shortfall": ...}, ...]
level: "red""yellow",决定阈值列标题
"""
lines = [
"名称 | 规格 | 当前库存 | 缺少数量",
"-" * 55,
]
for r in rows:
name = r.get('name', '-') or '-'
spec = r.get('spec', '-') or '-'
qty = r.get('qty', '-')
shortfall = r.get('shortfall', '-')
lines.append(f"{name} | {spec} | {qty} | 差{shortfall}")
return '\n'.join(lines)
@staticmethod
def check_and_send_warning_emails() -> dict:
"""
执行库存预警扫描与邮件发送
1. 查询所有 is_enabled=True 且**没有活跃采购单**的预警配置
2. 按 level 归类物料,按邮箱聚合(同一邮箱 → 一封邮件)
3. 调用 send_email 发送,更新 last_notified_at
★ 静音条件从「人工标记 is_ordered」改成了「是否有活跃采购单」
人工标记的前端入口已随「采购在途」自动化改造一并移除,若不改这里,
用户将彻底失去静音能力(本函数由出库执行流程触发,见
outbound_service.py 出库完成后的调用)。自动化判定与待采购池、
物料列表 isPurchasing 共用同一份定义,口径一致。
Returns:
{
"red_count": N, # 触发红色预警的物料数
"yellow_count": N, # 触发黄色预警的物料数
"red_sent": True/False,
"yellow_sent": True/False,
"timestamp": "..."
}
"""
from app.utils.email_service import send_email_async
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
# 查询启用了预警、且当前没有活跃采购单的配置。
# 有在途单 → 不必再催采购,静音;单据被驳回或强制结案后自动恢复告警。
from app.utils.purchase_activity import active_purchase_exists
settings = MaterialWarningSetting.query.filter(
MaterialWarningSetting.is_enabled == True,
~active_purchase_exists(MaterialWarningSetting.base_id),
).all()
red_rows_by_email = defaultdict(list) # email -> [物料row, ...]
yellow_rows_by_email = defaultdict(list)
total_red = 0
total_yellow = 0
total_red_cascaded = 0 # 红色顺延到黄色
total_yellow_cascaded = 0 # 黄色顺延到红色
sent_red = False
sent_yellow = False
processed_settings = []
# ★ 性能优化:批量预取所有 setting 对应的库存 + 物料信息1 条 SQL 替代 N*4 条)
inv_map = InventoryWarningService._prefetch_inventory_map(settings)
for setting in settings:
base_id = setting.base_id
mat_data = inv_map.get(base_id)
if not mat_data:
continue
name = mat_data['name']
spec = mat_data['spec']
inv = mat_data['inv']
red_th = float(setting.red_threshold) if setting.red_threshold is not None else None
yellow_th = float(setting.yellow_threshold) if setting.yellow_threshold is not None else None
# ★ 红色预警:库存 <= red_threshold走 setting.red_emails ★
if red_th is not None and inv <= red_th:
total_red += 1
red_emails = InventoryWarningService._parse_emails(setting.red_emails)
emails_to_use = red_emails
use_yellow_channel = False # 是否走黄色通道(顺延时为 True
if not emails_to_use:
# ★ 红色预警但无 red_emails顺延使用 yellow_emails ★
emails_to_use = InventoryWarningService._parse_emails(setting.yellow_emails)
if emails_to_use:
total_yellow += 1
total_red_cascaded += 1
use_yellow_channel = True
print(f"[InventoryWarning] 物料「{name}」红色预警触发,但 red_emails 为空,顺延使用 yellow_emails 发黄色预警")
if emails_to_use:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(red_th, 2),
'shortfall': round(red_th - inv, 2),
}
if use_yellow_channel:
for email in emails_to_use:
yellow_rows_by_email[email].append(row)
else:
for email in emails_to_use:
red_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」红单跳过:无 red_emails 且 yellow_emails 也为空")
# ★ 黄色预警red_threshold < 库存 <= yellow_threshold走 setting.yellow_emails ★
elif (
(red_th is not None and yellow_th is not None and red_th < inv <= yellow_th)
or (red_th is None and yellow_th is not None and inv <= yellow_th)
):
total_yellow += 1
yellow_emails = InventoryWarningService._parse_emails(setting.yellow_emails)
emails_to_use = yellow_emails
use_red_channel = False # 是否走红色通道(顺延时为 True
if not emails_to_use:
# ★ 黄色预警但无 yellow_emails顺延使用 red_emails ★
emails_to_use = InventoryWarningService._parse_emails(setting.red_emails)
if emails_to_use:
total_red += 1
total_yellow_cascaded += 1
use_red_channel = True
print(f"[InventoryWarning] 物料「{name}」黄色预警触发,但 yellow_emails 为空,顺延使用 red_emails 发红色预警")
if emails_to_use:
processed_settings.append(setting)
row = {
'name': name,
'spec': spec,
'qty': round(inv, 2),
'threshold': round(yellow_th, 2),
'shortfall': round(yellow_th - inv, 2),
}
if use_red_channel:
for email in emails_to_use:
red_rows_by_email[email].append(row)
else:
for email in emails_to_use:
yellow_rows_by_email[email].append(row)
else:
print(f"[InventoryWarning] 物料「{name}」黄单跳过:无 yellow_emails 且 red_emails 也为空")
else:
continue
# ★ 按邮箱聚合,批量发送红色预警邮件 ★
for email, rows in red_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'red')
subject = f"【红色预警】库存告急(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到红色预警阈值,请立即处理采购:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email_async(email, subject, content)
sent_red = True
# ★ 按邮箱聚合,批量发送黄色预警邮件 ★
for email, rows in yellow_rows_by_email.items():
table = InventoryWarningService._build_text_table(rows, 'yellow')
subject = f"【黄色预警】库存偏低(共 {len(rows)} 条)"
content = (
f"您好,\n\n"
f"以下物料当前库存已达到黄色预警阈值,请关注采购进度:\n\n"
f"{table}\n\n"
"详情请登录仓库管理系统查看。\n\n"
"此邮件由系统自动发送,请勿回复。"
)
send_email_async(email, subject, content)
sent_yellow = True
# ★ 批量更新 last_notified_at ★
if processed_settings:
for s in processed_settings:
s.last_notified_at = now
db.session.commit()
return {
'red_count': total_red,
'yellow_count': total_yellow,
'red_cascaded_count': total_red_cascaded,
'yellow_cascaded_count': total_yellow_cascaded,
'red_sent': sent_red,
'yellow_sent': sent_yellow,
'timestamp': now.strftime('%Y-%m-%d %H:%M:%S')
}