每天 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)
57 lines
1.9 KiB
Python
57 lines
1.9 KiB
Python
# app/utils/constants.py
|
||
|
||
class UserRole:
|
||
"""
|
||
用户角色定义
|
||
"""
|
||
SUPER_ADMIN = 'SUPER_ADMIN' # 超级管理员 (IRIS)
|
||
SUPERVISOR = 'SUPERVISOR' # 主管
|
||
FINANCE = 'FINANCE' # 财务
|
||
WAREHOUSE_MGR = 'WAREHOUSE_MGR' # 库管
|
||
INBOUND = 'INBOUND' # 入库员
|
||
OUTBOUND = 'OUTBOUND' # 出库员
|
||
PURCHASER = 'PURCHASER' # 采购员
|
||
SALES = 'SALES' # 销售
|
||
|
||
# 角色中文映射(用于前端展示或日志)
|
||
# 注意:这个字典在 auth_service 遍历时需要被过滤掉
|
||
ROLE_MAP = {
|
||
SUPER_ADMIN: '超级管理员',
|
||
SUPERVISOR: '主管',
|
||
FINANCE: '财务',
|
||
WAREHOUSE_MGR: '库管',
|
||
INBOUND: '入库员',
|
||
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()) |