每天 17:30(北京时间)自动汇总当日 新增/修改/删除、出库、借库,
渲染为纯文本邮件发送。
为什么不用 AI
--------------
此前那份日报由 Dify(外部 AI 服务,见 api/v1/ai_proxy.py)生成,
好处是能写人话,代价是:
· 外部依赖一断,整份日报就没了 —— 这正是"AI 坏了"之后发生的事;
· 按次计费、走网络、要管密钥;
· **会在报表里编数字**,而日报是给人做决策用的,数字必须可信。
日报是固定格式的结构化统计(计数 + 清单),本来也不需要创造力。
数据全在库里:audit_logs / trans_outbound / trans_borrow。
口径与审计页保持一致
--------------------
· action 经 canon_action() 归一化(历史数据里 CREATE 与「新增」混用,
不归一会漏掉早期数据);
· 默认排除 system 占位账号(与审计页默认的"真实用户"视图一致)。
· 凡有上限处一律显式写明「另有 N 条」,不静默截断。
· 变更值全部翻译成人类可读:状态码按模块查表、审批人/物料 ID → 名称、
布尔 → 是/否、人名串规整。翻不动就原样显示 —— 报表里的错译比裸数字
更难被发现,也更危险。
★ 顺带修掉两个既有缺陷(日报每天都会走这条路径,不修会一直中招):
1) 定时任务被 8 个 worker 重复执行
gunicorn.conf.py 配了 8 worker,而 run.py 在**模块级**启动 APScheduler
—— gunicorn 未开 preload_app,每个 worker 独立 import 一次 run.py,
于是每个 worker 各起一份调度器,同一 cron 任务被并发执行 8 次。
现有库存预警邮件因此每天重复发 8 封。
新增 app/utils/job_lock.py:用 PostgreSQL 会话级咨询锁做跨进程互斥,
抢到锁的 worker 才执行,其余静默跳过。不引入新组件(compose 里没有
redis,redis_client 恒为 None,相关装饰器全程 fail-open,指望不上)。
实测 8 线程并发抢锁恰好 1 个成功。
2) 邮箱授权码被打印进日志
email_service.py 的 print(f"[DEBUG send_email] cfg = {cfg}") 会把含
password 的整个配置打到 stdout → docker logs。改为只打非敏感字段。
新增配置:MAIL_DAILY_REPORT_RECIPIENTS(逗号分隔,默认 duxingchen@iris-rs.cn)
98 lines
4.2 KiB
Python
98 lines
4.2 KiB
Python
# inventory-backend/run.py
|
||
from app import create_app
|
||
|
||
app = create_app()
|
||
|
||
# =========================================================
|
||
# 定时任务注册
|
||
#
|
||
# ★★ 每个任务都必须套 advisory_lock,否则会被执行 **8 次**。
|
||
#
|
||
# gunicorn.conf.py 配了 8 个 worker,且未开 preload_app —— 每个 worker
|
||
# 都会独立 import 一次本文件,于是在模块级启动的这份调度器会**每个
|
||
# worker 各起一份**,同一个 cron 任务在相同时刻被并发执行 8 次。
|
||
# (库存预警此前正是如此:5 条预警配置,每天实际发 8 封重复邮件。
|
||
# APScheduler 的 max_instances 只管单个调度器实例内,管不了跨进程。)
|
||
#
|
||
# advisory_lock 用 PostgreSQL 会话级咨询锁做真正的跨进程互斥,
|
||
# 抢到锁的 worker 才执行,其余静默跳过。详见 app/utils/job_lock.py。
|
||
# =========================================================
|
||
from apscheduler.schedulers.background import BackgroundScheduler
|
||
from apscheduler.triggers.cron import CronTrigger
|
||
import pytz
|
||
|
||
beijing_tz = pytz.timezone('Asia/Shanghai')
|
||
|
||
|
||
def _run_warning_job():
|
||
"""库存预警扫描与邮件发送(每天 9:30 北京时间)"""
|
||
with app.app_context():
|
||
from app.utils.job_lock import advisory_lock, LOCK_INVENTORY_WARNING
|
||
with advisory_lock(LOCK_INVENTORY_WARNING) as acquired:
|
||
if not acquired:
|
||
# 正常路径:另一个 worker 正在执行同一任务
|
||
print("[Scheduler] 库存预警:另一 worker 正在执行,本轮跳过")
|
||
return
|
||
try:
|
||
from app.services.inventory_task import InventoryWarningService
|
||
result = InventoryWarningService.check_and_send_warning_emails()
|
||
print(f"[Scheduler] 库存预警扫描完成: red={result['red_count']}, yellow={result['yellow_count']}")
|
||
except Exception as e:
|
||
print(f"[Scheduler] 库存预警任务失败: {e}")
|
||
|
||
|
||
def _run_daily_report_job():
|
||
"""MOM 系统日报(每天 17:30 北京时间)"""
|
||
with app.app_context():
|
||
from app.utils.job_lock import advisory_lock, LOCK_DAILY_REPORT
|
||
with advisory_lock(LOCK_DAILY_REPORT) as acquired:
|
||
if not acquired:
|
||
print("[Scheduler] 系统日报:另一 worker 正在执行,本轮跳过")
|
||
return
|
||
try:
|
||
from app.services.daily_report_service import DailyReportService
|
||
r = DailyReportService.send_daily_report()
|
||
print(f"[Scheduler] 系统日报已发送: {r['subject']} -> {r['recipients']}")
|
||
except Exception as e:
|
||
print(f"[Scheduler] 系统日报任务失败: {e}")
|
||
|
||
|
||
scheduler = BackgroundScheduler(timezone=beijing_tz)
|
||
scheduler.add_job(
|
||
func=_run_warning_job,
|
||
trigger=CronTrigger(hour=9, minute=30, timezone=beijing_tz),
|
||
id='inventory_warning_daily',
|
||
name='库存预警每日邮件发送',
|
||
replace_existing=True
|
||
)
|
||
scheduler.add_job(
|
||
func=_run_daily_report_job,
|
||
trigger=CronTrigger(hour=17, minute=30, timezone=beijing_tz),
|
||
id='daily_report',
|
||
name='MOM系统日报',
|
||
replace_existing=True
|
||
)
|
||
scheduler.start()
|
||
print("✅ 定时任务已启动:库存预警 9:30 / MOM系统日报 17:30(北京时间)")
|
||
|
||
if __name__ == '__main__':
|
||
# =================================================
|
||
# 路由打印调试 (启动时会在控制台列出所有 URL)
|
||
# 这一步能帮你确认 /api/inbound/base/list 是否存在
|
||
# =================================================
|
||
print("\n====== 当前生效的路由映射 ======")
|
||
try:
|
||
# 按 URL 排序打印,方便查找
|
||
sorted_rules = sorted(app.url_map.iter_rules(), key=lambda x: str(x))
|
||
for rule in sorted_rules:
|
||
# 过滤掉一些系统自带的 static 路由,只显示 API
|
||
if 'api' in str(rule):
|
||
methods = ','.join(rule.methods - {'OPTIONS', 'HEAD'})
|
||
print(f"{str(rule):<50} | {methods:<10} | {rule.endpoint}")
|
||
except Exception:
|
||
pass
|
||
print("==============================\n")
|
||
|
||
# 启动开发服务器
|
||
# 端口设置为 5000 (Flask 默认) 或 8000,请确保与前端 Vite 代理一致
|
||
app.run(host='0.0.0.0', port=8000, debug=True) |