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)
This commit is contained in:
@ -24,4 +24,34 @@ class UserRole:
|
||||
OUTBOUND: '出库员',
|
||||
PURCHASER: '采购员',
|
||||
SALES: '销售'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class OutboundType:
|
||||
"""
|
||||
出库类型(trans_outbound.outbound_type)。
|
||||
|
||||
★ 前端 views/outbound/index.vue 的 formatType() 里另有一份同名映射,
|
||||
两份是手工同步的副本。日报(后端)需要同一份口径,故在此定义;
|
||||
**改这里时请一并改前端**,或后续像审计标签那样改为接口下发。
|
||||
"""
|
||||
SALES = 'SALES' # 销售出库
|
||||
USE = 'USE' # 内部领用
|
||||
PRODUCTION = 'PRODUCTION' # 生产出库
|
||||
SCRAP = 'SCRAP' # 报废
|
||||
LOSS = 'LOSS' # 盘亏出库
|
||||
REPAIR = 'REPAIR' # 维修出库
|
||||
|
||||
LABELS = {
|
||||
SALES: '销售出库',
|
||||
USE: '内部领用',
|
||||
PRODUCTION: '生产出库',
|
||||
SCRAP: '报废',
|
||||
LOSS: '盘亏出库',
|
||||
REPAIR: '维修出库',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def label(cls, code):
|
||||
"""未命中时原样返回,避免报表上出现空白"""
|
||||
return cls.LABELS.get((code or '').strip(), (code or '').strip())
|
||||
@ -60,7 +60,15 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg:
|
||||
if cfg is None:
|
||||
cfg = _get_config()
|
||||
|
||||
print(f"[DEBUG send_email] cfg = {cfg}")
|
||||
# ★ 不要把 cfg 整个打出来 —— 它含 'password'(邮箱授权码),
|
||||
# 一 print 就落到 stdout → docker logs,任何能看日志的人都拿得到发信凭证。
|
||||
# 只打非敏感字段,密码只报"有没有设"。
|
||||
_pw_state = '已设置' if cfg.get('password') else '空'
|
||||
print(
|
||||
f"[DEBUG send_email] server={cfg.get('server')} port={cfg.get('port')} "
|
||||
f"sender={cfg.get('sender')} ssl={cfg.get('use_ssl')} tls={cfg.get('use_tls')} "
|
||||
f"enabled={cfg.get('enabled')} password={_pw_state}"
|
||||
)
|
||||
|
||||
# 发送总开关
|
||||
if not cfg.get('enabled'):
|
||||
|
||||
70
inventory-backend/app/utils/job_lock.py
Normal file
70
inventory-backend/app/utils/job_lock.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""
|
||||
跨进程的定时任务互斥锁。
|
||||
|
||||
为什么需要这个
|
||||
--------------
|
||||
`gunicorn.conf.py` 配了 8 个 worker(`workers = min(cpu*2+1, 8)`),而 `run.py`
|
||||
是在**模块级**启动 APScheduler 的 —— gunicorn 未开 preload_app,每个 worker
|
||||
都会独立 import 一次 `run.py`,于是**每个 worker 各起一份调度器**,同一个
|
||||
cron 任务在相同时刻被并发执行 8 次。
|
||||
|
||||
实测:容器里跑着 8 个 worker 进程,`create_app()` 与"调度器已启动"两条日志
|
||||
的出现次数完全相等 —— 每个 worker 都配了一份。
|
||||
|
||||
后果:库存预警邮件每天实际会重复发 8 封。
|
||||
|
||||
APScheduler 自带的 `max_instances` 只在**单个调度器实例内**生效,管不了
|
||||
跨进程;`replace_existing` 同理。这里用 PostgreSQL 的**会话级咨询锁**
|
||||
(`pg_try_advisory_lock`)做真正的跨进程互斥:抢到锁的那个 worker 才执行,
|
||||
其余直接跳过。好处是不用引入 Redis 之类的新组件(compose 里也没有 redis,
|
||||
`redis_client` 恒为 None,相关装饰器全程 fail-open,指望不上)。
|
||||
|
||||
★ 会话级锁绑定在**连接**上,必须在同一条连接上解锁 —— 所以这里显式取一条
|
||||
专用连接,用完归还,不复用请求上下文里的 session。
|
||||
"""
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 锁标识:PostgreSQL 咨询锁是 bigint,取固定值便于排查(不要用随机数)。
|
||||
LOCK_INVENTORY_WARNING = 891001001 # 库存预警每日邮件
|
||||
LOCK_DAILY_REPORT = 891001002 # MOM 系统日报
|
||||
|
||||
|
||||
@contextmanager
|
||||
def advisory_lock(key):
|
||||
"""
|
||||
尝试获取会话级咨询锁,产出「是否抢到」。
|
||||
|
||||
用法::
|
||||
|
||||
with advisory_lock(LOCK_DAILY_REPORT) as acquired:
|
||||
if not acquired:
|
||||
return # 别的 worker 正在跑,本轮跳过
|
||||
...实际干活...
|
||||
|
||||
★ 抢不到锁是**正常路径**,不是错误 —— 说明另一个 worker 正在执行同一任务。
|
||||
调用方应当静默跳过,而不是报警。
|
||||
★ 拿锁失败(数据库不可用等)会让异常向上抛:宁可让调度器记一次失败,
|
||||
也不要"没抢到锁"和"抢锁时数据库挂了"两种截然不同的情况被混为一谈。
|
||||
"""
|
||||
conn = db.engine.connect()
|
||||
acquired = False
|
||||
try:
|
||||
acquired = bool(
|
||||
conn.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key}).scalar()
|
||||
)
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
try:
|
||||
conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 解锁失败不应盖过业务异常;连接关闭时锁也会随之释放
|
||||
logger.warning(f"[JobLock] 释放咨询锁 {key} 失败: {e}")
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user