2026-04-29 15:40:43 +08:00
|
|
|
|
"""
|
|
|
|
|
|
库存预警扫描与邮件通知服务
|
|
|
|
|
|
|
|
|
|
|
|
定时(或手动触发)扫描所有 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
|
2026-07-15 17:37:57 +08:00
|
|
|
|
def _prefetch_inventory_map(settings):
|
2026-04-29 15:40:43 +08:00
|
|
|
|
"""
|
2026-07-15 17:37:57 +08:00
|
|
|
|
批量预取所有预警物料的库存总计(单条 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}, ...}
|
2026-04-29 15:40:43 +08:00
|
|
|
|
"""
|
2026-07-15 17:37:57 +08:00
|
|
|
|
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
|
2026-04-29 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
@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:
|
2026-05-12 15:15:46 +08:00
|
|
|
|
rows: [{"name": ..., "spec": ..., "qty": ..., "threshold": ..., "shortfall": ...}, ...]
|
2026-04-29 15:40:43 +08:00
|
|
|
|
level: "red" 或 "yellow",决定阈值列标题
|
|
|
|
|
|
"""
|
|
|
|
|
|
lines = [
|
2026-05-12 15:15:46 +08:00
|
|
|
|
"名称 | 规格 | 当前库存 | 缺少数量",
|
|
|
|
|
|
"-" * 55,
|
2026-04-29 15:40:43 +08:00
|
|
|
|
]
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
name = r.get('name', '-') or '-'
|
|
|
|
|
|
spec = r.get('spec', '-') or '-'
|
|
|
|
|
|
qty = r.get('qty', '-')
|
2026-05-12 15:15:46 +08:00
|
|
|
|
shortfall = r.get('shortfall', '-')
|
|
|
|
|
|
lines.append(f"{name} | {spec} | {qty} | 差{shortfall}个")
|
2026-04-29 15:40:43 +08:00
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def check_and_send_warning_emails() -> dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
执行库存预警扫描与邮件发送
|
|
|
|
|
|
|
|
|
|
|
|
1. 查询所有 is_enabled=True 且 is_ordered=False 的预警配置
|
|
|
|
|
|
2. 按 level 归类物料,按邮箱聚合(同一邮箱 → 一封邮件)
|
|
|
|
|
|
3. 调用 send_email 发送,更新 last_notified_at
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
{
|
|
|
|
|
|
"red_count": N, # 触发红色预警的物料数
|
|
|
|
|
|
"yellow_count": N, # 触发黄色预警的物料数
|
|
|
|
|
|
"red_sent": True/False,
|
|
|
|
|
|
"yellow_sent": True/False,
|
|
|
|
|
|
"timestamp": "..."
|
|
|
|
|
|
}
|
|
|
|
|
|
"""
|
2026-07-16 11:26:12 +08:00
|
|
|
|
from app.utils.email_service import send_email_async
|
2026-04-29 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
beijing_tz = timezone(timedelta(hours=8))
|
|
|
|
|
|
now = datetime.now(beijing_tz)
|
|
|
|
|
|
|
|
|
|
|
|
# 查询启用了预警且未标记采购的配置
|
|
|
|
|
|
settings = MaterialWarningSetting.query.filter(
|
|
|
|
|
|
MaterialWarningSetting.is_enabled == True,
|
|
|
|
|
|
MaterialWarningSetting.is_ordered == False
|
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
|
|
red_rows_by_email = defaultdict(list) # email -> [物料row, ...]
|
|
|
|
|
|
yellow_rows_by_email = defaultdict(list)
|
|
|
|
|
|
|
|
|
|
|
|
total_red = 0
|
|
|
|
|
|
total_yellow = 0
|
2026-05-12 14:44:23 +08:00
|
|
|
|
total_red_cascaded = 0 # 红色顺延到黄色
|
|
|
|
|
|
total_yellow_cascaded = 0 # 黄色顺延到红色
|
2026-04-29 15:40:43 +08:00
|
|
|
|
sent_red = False
|
|
|
|
|
|
sent_yellow = False
|
|
|
|
|
|
processed_settings = []
|
|
|
|
|
|
|
2026-07-15 17:37:57 +08:00
|
|
|
|
# ★ 性能优化:批量预取所有 setting 对应的库存 + 物料信息(1 条 SQL 替代 N*4 条)
|
|
|
|
|
|
inv_map = InventoryWarningService._prefetch_inventory_map(settings)
|
|
|
|
|
|
|
2026-04-29 15:40:43 +08:00
|
|
|
|
for setting in settings:
|
|
|
|
|
|
base_id = setting.base_id
|
2026-07-15 17:37:57 +08:00
|
|
|
|
mat_data = inv_map.get(base_id)
|
|
|
|
|
|
if not mat_data:
|
2026-04-29 15:40:43 +08:00
|
|
|
|
continue
|
|
|
|
|
|
|
2026-07-15 17:37:57 +08:00
|
|
|
|
name = mat_data['name']
|
|
|
|
|
|
spec = mat_data['spec']
|
|
|
|
|
|
inv = mat_data['inv']
|
2026-04-29 15:40:43 +08:00
|
|
|
|
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)
|
2026-05-12 14:44:23 +08:00
|
|
|
|
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:
|
2026-04-29 15:40:43 +08:00
|
|
|
|
processed_settings.append(setting)
|
|
|
|
|
|
row = {
|
|
|
|
|
|
'name': name,
|
|
|
|
|
|
'spec': spec,
|
|
|
|
|
|
'qty': round(inv, 2),
|
|
|
|
|
|
'threshold': round(red_th, 2),
|
2026-05-12 15:15:46 +08:00
|
|
|
|
'shortfall': round(red_th - inv, 2),
|
2026-04-29 15:40:43 +08:00
|
|
|
|
}
|
2026-05-12 14:44:23 +08:00
|
|
|
|
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)
|
2026-04-29 15:40:43 +08:00
|
|
|
|
else:
|
2026-05-12 14:44:23 +08:00
|
|
|
|
print(f"[InventoryWarning] 物料「{name}」红单跳过:无 red_emails 且 yellow_emails 也为空")
|
2026-04-29 15:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
# ★ 黄色预警: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)
|
2026-05-12 14:44:23 +08:00
|
|
|
|
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:
|
2026-04-29 15:40:43 +08:00
|
|
|
|
processed_settings.append(setting)
|
|
|
|
|
|
row = {
|
|
|
|
|
|
'name': name,
|
|
|
|
|
|
'spec': spec,
|
|
|
|
|
|
'qty': round(inv, 2),
|
|
|
|
|
|
'threshold': round(yellow_th, 2),
|
2026-05-12 15:15:46 +08:00
|
|
|
|
'shortfall': round(yellow_th - inv, 2),
|
2026-04-29 15:40:43 +08:00
|
|
|
|
}
|
2026-05-12 14:44:23 +08:00
|
|
|
|
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)
|
2026-04-29 15:40:43 +08:00
|
|
|
|
else:
|
2026-05-12 14:44:23 +08:00
|
|
|
|
print(f"[InventoryWarning] 物料「{name}」黄单跳过:无 yellow_emails 且 red_emails 也为空")
|
2026-04-29 15:40:43 +08:00
|
|
|
|
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"
|
|
|
|
|
|
"此邮件由系统自动发送,请勿回复。"
|
|
|
|
|
|
)
|
2026-07-16 11:26:12 +08:00
|
|
|
|
send_email_async(email, subject, content)
|
2026-04-29 15:40:43 +08:00
|
|
|
|
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"
|
|
|
|
|
|
"此邮件由系统自动发送,请勿回复。"
|
|
|
|
|
|
)
|
2026-07-16 11:26:12 +08:00
|
|
|
|
send_email_async(email, subject, content)
|
2026-04-29 15:40:43 +08:00
|
|
|
|
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,
|
2026-05-12 14:44:23 +08:00
|
|
|
|
'red_cascaded_count': total_red_cascaded,
|
|
|
|
|
|
'yellow_cascaded_count': total_yellow_cascaded,
|
2026-04-29 15:40:43 +08:00
|
|
|
|
'red_sent': sent_red,
|
|
|
|
|
|
'yellow_sent': sent_yellow,
|
|
|
|
|
|
'timestamp': now.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
}
|