Files
KCGL/inventory-backend/run.py
yueli ded5dfd7b3 feat(report): MOM 系统日报(纯模板,不接 AI)
每天 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)
2026-09-23 17:16:26 +08:00

98 lines
4.2 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.

# 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)