feat(daily-report): 正文保持摘要,新增 Excel 附件给全量明细

日报此前把明细压到极简(修改只列变更字段≥3 的记录、新增只给模块计数),
"199 条新增只看到 3 个模块名",查不到某条具体记录改了什么。

但也不能全塞进正文:实测某日 354 条修改展开近 3000 行,客户端渲染卡顿,
部分企业邮件网关会把超长自动邮件判为垃圾或截断 —— 发件方照样收到 250,
表面看一切正常,这比"信息少"更难排查。

故拆成两路:正文保持摘要(render),附件给全量明细(render_excel),
六个工作表不设 DETAIL_MIN_FIELDS 之类的阈值。

- email_service: send_email/send_email_async 支持 attachments。数据是内存
  字节、不落盘;中文文件名走 RFC 2231;.xlsx 用官方 MIME 类型,写错会让
  Outlook 当成未知二进制、附件无法直接打开。
- audit_export_service: 新增。审计日志 → 可读值/Excel 的共用层。
  「同一条「实际审批人ID: 7」一边翻了人名一边没翻」这类漂移,比不翻译更
  难发现,故取值/翻译/排版逻辑集中在此,由日报与审计页导出共用。
- daily_report_service: 私有副本改为消费上述共用层(纯重构)。
  附件大小与重构前逐字节一致(490.5K / 193.9K / 10.3K),行为未变。

附件生成失败时降级为纯文本正文继续发送,但记 error 级日志 —— 降级后邮件
看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。
This commit is contained in:
yueli
2026-09-28 09:46:25 +08:00
parent 0614597dbe
commit 1d6beaa376
3 changed files with 1051 additions and 275 deletions

View File

@ -0,0 +1,696 @@
"""
审计日志 → 可读值 / Excel 的共用层。
为什么单独抽这一层
------------------
「把 audit_logs 的一行翻成人话」有两个消费者:
· 每日日报的 Excel 附件 —— `daily_report_service.render_excel()`
· 审计页的按条件导出 —— `api/v1/audit.py` 的 `GET /audit/logs/export`
两边各写一份的代价不是"多写点代码",而是**结果会不一致**:同一条
「实际审批人ID: 7」,一边翻了人名一边没翻;同一个 status=3,出库模块是
「已完成(已出库)」、报废模块是「已执行(已报废)」,某一边漏了按模块查表就会译错。
**译错的报表比不翻译的更难发现** —— 裸数字至少一看就知道没处理过。
所以这里的边界是:凡是「审计日志 → 人看得懂的东西」都放这里;
上层的两个消费者只决定「取哪些行、列怎么排」。
历史坑(本模块已处理,勿在调用方重新发明)
--------------------------------------------
· audit_logs.created_at 是 naive 北京时间(由 beijing_time() 写入);
· target_id 在不同模块之间会撞号,定位一条记录必须同时看 module + url;
· details 有三种互不相同的结构,键名还不统一:
CREATE → {'created': {...}}
UPDATE → {'changes': {字段: {old, new}}}
DELETE → {'deleted_snapshot': {...}}
记错键名不会报错,只会得到一张空表。
"""
import json
from collections import Counter, defaultdict
from app.models.base import MaterialBase
from app.utils.audit_labels import (
BOOLEAN_FIELDS,
PERSON_NAME_FIELDS,
USER_ID_FIELDS,
action_label,
bool_label,
canon_action,
enum_label,
field_label,
id_ref_of,
person_name_label,
)
# 系统占位账号:菜单/权限初始化一类的自动写入挂在这个名下(口径同审计页)
SYSTEM_USERNAME = 'system'
# 未解析出实体时的占位
UNKNOWN = '-'
# details 里的三种结构各自的键名 —— 集中在此,避免调用方记错
SNAPSHOT_CREATED = 'created'
SNAPSHOT_DELETED = 'deleted_snapshot'
CHANGES_KEY = 'changes'
# 变更字段的噪声过滤。
#
# 图片/链接/更新时间的变化与业务动作无关,却会把「变更字段数」顶上去 ——
# 看起来"有 3 个字段变了",实际业务上什么都没发生。故在**计数之前**就滤掉。
# 快照(新增/删除)里同样滤掉:那几个字段是超长字符串,进 Excel 会把列宽
# 撑爆且无业务价值。
IGNORED_CHANGE_FIELDS = frozenset({
'updated_at',
'product_image',
'product_image_remark',
'manual_link',
'manual_link_remark',
'purchase_link',
})
# 单元格字符上限。Excel 硬上限是 32767,取 2000 是因为更长的值
# (items_json 整包明细、超长报错文本)在单元格里已无法阅读。
# **截断处补省略号**,不做静默截断。
EXCEL_CELL_LIMIT = 2000
# 快照展开成列的上限。快照是整表全字段,不同表并集理论上可达数百列;
# 超过此值按「出现频次」从低到高丢弃,并由调用方显式写明丢了几列。
EXCEL_SNAPSHOT_COL_LIMIT = 80
# 明细表的固定前导列。
# ★ URL 单独成列而不是拼进"描述"里:审计日志的 target_id 在不同模块会撞号,
# 定位一条记录必须同时看 module + url。
AUDIT_HEAD = ['时间', '操作人', '模块', '操作类型', '操作对象', '目标ID', 'URL']
# 日报附件的表头。
#
# ★ 与 AUDIT_HEAD 有两处**故意的**不同,都是为了不改变既有附件的形态:
# 1. 不含「操作类型」—— 日报按操作类型分了表(新增/修改/删除明细各一个
# sheet),每行再重复一遍同一个值是纯冗余:多一列宽度、零信息量。
# 审计页导出是一张混合台账,则必须有这一列。
# 2. 这里叫「目标名称」且排在「目标ID」之前 —— 日报附件一直以来的列序。
# 审计页的 UI 管它叫「操作对象」。**不改它**:既有使用者可能按列位置
# 取数,换序会让数据整体错位一列(Excel 不报错,只是全错)。
DAILY_REPORT_HEAD = ['时间', '操作人', '模块', '目标ID', '目标名称', 'URL']
# 屏幕/表格里的变更值截断长度(纯文本正文用;Excel 用 EXCEL_CELL_LIMIT)
TEXT_VALUE_LIMIT = 40
# =============================================================================
# 基础类型转换
# =============================================================================
def as_int(value):
"""尽力转 int;转不了返回 None(bool 不算数 —— 它是 int 的子类)"""
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, str):
s = value.strip()
if s.lstrip('-').isdigit():
return int(s)
return None
def text_of(val):
"""
任意值 → 文本。
· 空值 → 空串(调用方决定显示成「空」还是留空单元格);
· dict / list → 紧凑 JSON —— 直接 str() 会得到 Python repr
(单引号 + True/None),那是给开发者看的,不是给人看的。
"""
if val is None or val == '':
return ''
if isinstance(val, (dict, list)):
try:
return json.dumps(val, ensure_ascii=False, separators=(',', ':'))
except (TypeError, ValueError):
return str(val)
return str(val)
def truncate(text, limit):
"""截断并补省略号。limit 为 None 或 <=0 时不截断。"""
if not limit or limit <= 0 or len(text) <= limit:
return text
return text[:limit] + '…'
def fmt_value(val, limit=TEXT_VALUE_LIMIT):
"""纯文本正文里的变更值显示:空值统一显示「空」(与审计页详情弹窗一致)。"""
s = text_of(val)
if not s:
return '空'
return truncate(s, limit)
def cell(val, module=None, key=None, ref_maps=None):
"""
Excel 单元格取值。
★ 空值给**空串**而不是「空」:附表是拿来筛选/排序/透视的,混入「空」
这个字面量会让"按旧值排序"把空值排到中间,也让 COUNTIF 失真。
正文那边要「空」是因为纯文本里空着根本看不出有这一项,两处需求相反。
★ 传 module/key 时会先做 ID→实体的翻译(翻不动则原样显示);
值已由 resolved_changes() 翻过时不要传,避免重复解析。
★ dict/list 转紧凑 JSON,超长按 EXCEL_CELL_LIMIT 截断并补省略号。
"""
if module is not None:
resolved = resolve_value(module, key, val, ref_maps or {})
if resolved is not None:
val = resolved
return truncate(text_of(val), EXCEL_CELL_LIMIT)
# =============================================================================
# 操作人
# =============================================================================
def operator_of(row):
"""
操作人显示名:显示名(账号),与旧日报的「平板(pingban)」同形。
★ display_name 库里存法**不统一**,实测有四种:
'高雪(gaoxue)' / '高闯/gaochuang' / '杜邢宸(duxingchen)' / ''(空)
直接 f"{display_name}({username})" 会拼出
'杜邢宸(duxingchen)(duxingchen)' 这种账号重复的怪名字。
故先把 display_name 里已内嵌的账号部分剥掉,再统一格式化。
"""
dn = (getattr(row, 'display_name', '') or '').strip()
un = (getattr(row, 'username', '') or '').strip()
# 剥掉内嵌账号:'高雪(gaoxue)'→'高雪','高闯/gaochuang'→'高闯'
for sep in ('(', '/'):
if sep in dn:
dn = dn.split(sep)[0].strip()
if dn and un and dn != un:
return f"{dn}({un})"
return dn or un or UNKNOWN
def fmt_name(raw):
"""
统一库里五花八门的操作人写法。
audit_logs 侧有 display_name/username 两个字段可以拼,但
trans_outbound.operator_name / trans_borrow.dispatch_operator 这类
**流水表**只存了一个字符串,实测有 '高闯/gaochuang' 与
'杜邢宸(duxingchen)' 两种风格。同一封邮件里两种风格并存会显得很随意,
故统一成「名(账号)」。
"""
s = (raw or '').strip()
if not s:
return UNKNOWN
if '/' in s:
name, _, acct = s.partition('/')
name, acct = name.strip(), acct.strip()
if name and acct:
return f"{name}({acct})"
return name or acct or s
return s
# =============================================================================
# 引用 ID → 实体的批量解析
#
# 逐条查库是 N+1,故先把涉及的 ID 按类型收集起来,每类一次查完。
# =============================================================================
def load_user_map(user_ids):
"""{user_id: '名(账号)'}"""
if not user_ids:
return {}
from app.models.system import SysUser
rows = SysUser.query.filter(SysUser.id.in_(user_ids)).all()
return {u.id: fmt_name(u.username) for u in rows}
def load_material_name_map(base_ids):
"""{base_id: 物料名}"""
if not base_ids:
return {}
rows = MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
return {m.id: (m.name or '').strip() for m in rows}
def load_menu_map(menu_ids):
"""
{menu_id: 菜单名}。
★ 0 是 sys_menu.parent_id 的默认值,含义是「没有上级」= 顶级菜单 ——
不能当成"查不到的 ID"而显示成 0。
"""
mapping = {0: '顶级菜单'}
ids = {i for i in menu_ids if i}
if not ids:
return mapping
from app.models.system import SysMenu
for m in SysMenu.query.filter(SysMenu.id.in_(ids)).all():
mapping[m.id] = (m.name or '').strip() or f'菜单#{m.id}'
return mapping
def load_return_ledger_map(return_ids):
"""
{trans_return.id: 'SKU 的退回流水(不良品 1.0)'}。
退回流水表本身没有可读单号,能标识它这段记录的就是「哪个物料、什么类型、
多少数量」—— 所以直接把这三样拼出来,而不是显示一个光秃秃的行号。
"""
if not return_ids:
return {}
from app.models.transaction import TransReturn
out = {}
for r in TransReturn.query.filter(TransReturn.id.in_(return_ids)).all():
qty = float(r.return_qty or 0)
rtype = (r.return_type or '').strip()
out[r.id] = f"{r.sku or '-'} 的退回流水({rtype} {qty})".replace('( ', '(')
return out
REF_LOADERS = {
'user': load_user_map,
'material': load_material_name_map,
'menu': load_menu_map,
'return_ledger': load_return_ledger_map,
}
def _collect_ref(ref_ids, user_ids, module, key, value):
"""把一个候选值按类型塞进对应的收集集合(只收集,不查库)"""
iv = as_int(value)
if iv is None:
return
if key in USER_ID_FIELDS:
user_ids.add(iv)
return
ref = id_ref_of(module, key)
if ref:
ref_ids[ref].add(iv)
def load_ref_maps(rows):
"""
一批 audit_logs 行 → {ref 类型: {id: 显示名}}。
★ 覆盖三种结构的**全部**引用列,不只是 UPDATE 的变更值:
新增/删除的整字段快照里同样有 base_id / parent_id 这类引用列
(BOM 的整表快照尤其多)。只收集 UPDATE 的话,附表里就会冒出
「父级ID: 1234」这种裸数字。
"""
user_ids = set()
ref_ids = defaultdict(set)
for r in rows:
module = getattr(r, 'module', None)
for key, old, new in changes_of(r):
_collect_ref(ref_ids, user_ids, module, key, old)
_collect_ref(ref_ids, user_ids, module, key, new)
for which in (SNAPSHOT_CREATED, SNAPSHOT_DELETED):
for key, value in snapshot_of(r, which).items():
_collect_ref(ref_ids, user_ids, module, key, value)
ref_maps = {'user': load_user_map(user_ids)}
for ref, ids in ref_ids.items():
loader = REF_LOADERS.get(ref)
if loader:
ref_maps[ref] = loader(ids)
return ref_maps
# =============================================================================
# details 解析
# =============================================================================
def changes_of(row):
"""
UPDATE 记录的字段变更 → [(字段, 旧值, 新值)];结构异常时返回空列表。
噪声字段在这里就滤掉,理由见 IGNORED_CHANGE_FIELDS 的说明。
"""
details = row.details or {}
if not isinstance(details, dict):
return []
changes = details.get(CHANGES_KEY)
if not isinstance(changes, dict):
return []
out = []
for key, val in changes.items():
if key in IGNORED_CHANGE_FIELDS:
continue
if isinstance(val, dict):
out.append((key, val.get('old'), val.get('new')))
else:
# 兼容「非 {old,new} 结构」的历史写法:整值视为新值
out.append((key, None, val))
return out
def snapshot_of(row, which):
"""
CREATE / DELETE 记录的整字段快照 → {字段: 值};结构异常时返回 {}。
which 取 SNAPSHOT_CREATED / SNAPSHOT_DELETED —— 两张表用了不同的键名,
写错不会报错,只会得到一张空表,故集中在此处而非散落到调用点。
快照里同样过一遍 IGNORED_CHANGE_FIELDS(理由同 changes_of)。
"""
details = row.details or {}
if not isinstance(details, dict):
return {}
snap = details.get(which)
if not isinstance(snap, dict):
return {}
return {k: v for k, v in snap.items() if k not in IGNORED_CHANGE_FIELDS}
def has_detail_content(details):
"""details 里是否有可展示的结构(与前端 hasDetailContent 同判据)"""
if not isinstance(details, dict) or not details:
return False
return any(
details.get(k) for k in (CHANGES_KEY, SNAPSHOT_DELETED, SNAPSHOT_CREATED, 'payload')
)
# =============================================================================
# 取值翻译
# =============================================================================
def resolve_value(module, field, value, ref_maps):
"""
把单个变更值翻成人类可读形式;翻不动返回 None(调用方回落原值)。
四类映射,优先级即从上到下:
· 布尔列 → 是 / 否
· 人名串 → '杜邢宸/duxingchen' → '杜邢宸(duxingchen)'
· 引用 ID 的列 → 查实体(用户 / 物料 / 菜单 / 退回流水…,
指向哪张表由 audit_labels.id_ref_of 按模块判定)
· 枚举列 → 按模块查状态表(见 audit_labels.enum_label)
★ 翻不动就**原样显示**,绝不硬编一个可能错的中文 —— 报表里的错译
比裸数字更难被发现,也更危险。
"""
if field in BOOLEAN_FIELDS:
text = bool_label(value)
if text is not None:
return text
# 人名串('杜邢宸/duxingchen')→ '杜邢宸(duxingchen)'。
# 按字段名限定,理由见 audit_labels.PERSON_NAME_FIELDS 的说明。
if field in PERSON_NAME_FIELDS:
text = person_name_label(value)
if text is not None:
return text
iv = as_int(value)
if iv is not None:
if field in USER_ID_FIELDS:
return (ref_maps.get('user') or {}).get(iv)
ref = id_ref_of(module, field)
if ref:
got = (ref_maps.get(ref) or {}).get(iv)
if got is not None:
return got
return enum_label(module, field, value)
def resolved_changes(module, changes, ref_maps):
"""
一组字段变更 → [(中文标签, 旧值, 新值)]。
★ 只做「翻译」(ID→名称),**不做「格式化」**(截断、空值占位)——
正文与 Excel 对格式化的要求正好相反:正文要短(截断 40 字、空值显示
「空」),附表要全(不截断、空值留空便于筛选排序)。
翻译逻辑才是必须共用的部分,各写一份迟早漂移成
"邮件里是人名、附表里是裸 ID"。
★ 「实际审批人ID: 空 → 7」解析成人名后,标签里的「ID」后缀就不贴切了,
故解析成功时去掉后缀 → 「实际审批人: 空 → 杜邢宸(duxingchen)」。
"""
out = []
for key, old, new in changes:
old_t = resolve_value(module, key, old, ref_maps)
new_t = resolve_value(module, key, new, ref_maps)
label = field_label(key)
is_ref = key in USER_ID_FIELDS or id_ref_of(module, key) is not None
if is_ref and (old_t is not None or new_t is not None) and label.endswith('ID'):
label = label[:-2]
out.append((
label,
old if old_t is None else old_t,
new if new_t is None else new_t,
))
return out
def changes_summary(row, ref_maps, limit=200, resolved=None):
"""
一行日志的变更摘要(一句话),供台账 sheet 用。
台账里只给一条能扫读的摘要,完整逐字段对比在「变更明细」sheet ——
把人话摘要和可筛选的数据分开,两者各司其职。
resolved: 已算好的 resolved_changes 结果。导出时同一行的翻译要同时喂给
台账摘要和明细长表,调用方预计算一次传进来即可 —— 否则每行
要跑两遍 resolve_value(4 万行的导出上是可感知的浪费)。
"""
if canon_action(row.action) == 'UPDATE':
if resolved is None:
resolved = resolved_changes(row.module, changes_of(row), ref_maps)
if not resolved:
return '(仅变更了图片/链接/更新时间等噪声字段)'
parts = [f"{label}: {fmt_value(old)}→{fmt_value(new)}"
for label, old, new in resolved]
return truncate(';'.join(parts), limit)
for which, verb in ((SNAPSHOT_CREATED, '新增'), (SNAPSHOT_DELETED, '删除前快照')):
snap = snapshot_of(row, which)
if snap:
names = [field_label(k) for k in list(snap)[:8]]
more = '' if len(snap) <= 8 else f" 等 {len(snap)} 个字段"
return truncate(f"{verb}:{'、'.join(names)}{more}", limit)
return ''
# =============================================================================
# Excel 排版
# =============================================================================
def display_width(text):
"""
估算字符串的显示宽度:CJK 字符按 2 列、其余按 1 列。
★ 直接拿 len() 当列宽,中文列会明显偏窄(一个汉字占两个字符位),
「物料名称」这类列会被截成「物料名…」。
"""
return sum(2 if ord(ch) > 0x2E80 else 1 for ch in str(text))
def snapshot_headers(cols, reserved=()):
"""
快照字段 → 表头。**重名的补原字段名**。
★ 重名有两个来源,必须一起处理:
1. FIELD_LABELS 里多个字段映射到同一中文名 ——
is_active / is_enabled → 「是否启用」
warehouse_location / warehouse_loc → 「库位」
operator_name / operator → 「操作人」
2. 快照字段与**固定前导列**撞名 —— BOM 快照里就有 operator 字段,
中文名同样是「操作人」,与 AUDIT_HEAD 的第 2 列撞上。
(实测 2026-07-16 的 59 列新增明细正是被这一条卡住。)
Excel 不会因为表头重名报错,但人分不清哪列是哪列,
且按名字取列(VLOOKUP / 脚本)会静默取到第一个 —— 故重名时
退化成「操作人(operator)」。
"""
labels = [field_label(c) for c in cols]
counted = Counter(labels) + Counter(reserved)
return [
f"{lab}({col})" if counted[lab] > 1 else lab
for lab, col in zip(labels, cols)
]
def fill_sheet(ws, headers, rows, wrap_cols=()):
"""
写入表头 + 数据行,套用统一样式。
样式与 export_service/excel_task.py 的库存导出保持一致(深蓝表头、
隔行浅蓝、细边框),这样公司内部几个导出的观感统一。
wrap_cols: 需自动换行的列序号(从 1 起)。长文本列(旧值/新值/URL)
不换行会被右侧有值的单元格**盖住**,看起来像"没有这一列"。
"""
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
header_fill = PatternFill("solid", fgColor="1F4E79")
header_font = Font(bold=True, color="FFFFFF", size=11)
header_align = Alignment(horizontal='center', vertical='center', wrap_text=True)
thin = Side(style='thin', color='BFBFBF')
border = Border(left=thin, right=thin, top=thin, bottom=thin)
data_font = Font(size=10)
even_fill = PatternFill("solid", fgColor="DEEAF1")
ws.append(list(headers))
for idx in range(1, len(headers) + 1):
c = ws.cell(row=1, column=idx)
c.fill = header_fill
c.font = header_font
c.alignment = header_align
c.border = border
# 表头行加高,否则带换行的中文表头会被压成一条缝
ws.row_dimensions[1].height = 28
for i, row in enumerate(rows):
ws.append(list(row))
r = i + 2
for ci in range(1, len(headers) + 1):
c = ws.cell(row=r, column=ci)
c.font = data_font
c.border = border
c.alignment = Alignment(horizontal='left', vertical='top',
wrap_text=(ci in wrap_cols))
if i % 2 == 1:
c.fill = even_fill
# 冻结首行:几千行明细里往下滚,没有表头就不知道在看哪一列
ws.freeze_panes = 'A2'
# 列宽按内容估算,封顶 60 —— 不封顶的话一个超长 URL 会把该列拉到屏幕外,
# 其他列全被挤没。只采样前 200 行,避免大数据量下空转。
for ci, head in enumerate(headers, start=1):
width = display_width(head)
for row in rows[:200]:
width = max(width, display_width(row[ci - 1]))
ws.column_dimensions[get_column_letter(ci)].width = min(width + 2, 60)
def _timestamp(row):
return row.created_at.strftime('%Y-%m-%d %H:%M:%S') if row.created_at else ''
def audit_head_values(row):
"""审计页导出的前导列取值(与 AUDIT_HEAD 一一对应)"""
return [
_timestamp(row),
operator_of(row),
row.module or '',
action_label(canon_action(row.action)),
row.target_name or '',
row.target_id or '',
row.url or '',
]
def daily_report_head_values(row):
"""日报附件的前导列取值(与 DAILY_REPORT_HEAD 一一对应,列序不同,见其说明)"""
return [
_timestamp(row),
operator_of(row),
row.module or '',
row.target_id or '',
row.target_name or '',
row.url or '',
]
# 台账 sheet 的列 = 固定前导列 + 变更摘要 + IP
LEDGER_EXTRA = ['变更摘要', 'IP地址']
def build_audit_workbook(rows, ref_maps=None, summary_rows=None, note=None):
"""
一批 audit_logs 行 → .xlsx 字节流(内存中构建,不落盘)。
工作表:
汇总 —— 调用方给出的筛选条件 + 本次导出的口径说明
审计日志 —— 一行一条记录(台账)
变更明细 —— 一行一个变更字段(仅 UPDATE),便于按字段筛选透视
★ 为什么用长表放变更:宽表要取所有记录的变更字段并集,而不同模块改的
字段完全不同,并集轻松上百列且绝大多数格子是空的。长表列固定 8 个,
想筛"今天所有改过状态的记录"只需对「字段」列做一次筛选。
★ 全程 BytesIO,不写临时文件 —— 导出是随时可能发生的按需操作,
落盘就意味着要么写清理逻辑,要么让磁盘被日复一日地吃掉。
note: 截断等需要显式告知的说明(None 表示无)。**不静默截断**是本项目
报表的一贯要求。
"""
import io
from openpyxl import Workbook
if ref_maps is None:
ref_maps = load_ref_maps(rows)
# ---------- 审计日志(台账)----------
ledger = []
detail = []
for r in rows:
# UPDATE 行的翻译只算一次,同时喂给台账摘要与明细长表(见 changes_summary)
changes = (resolved_changes(r.module, changes_of(r), ref_maps)
if canon_action(r.action) == 'UPDATE' else None)
ledger.append(
audit_head_values(r)
+ [changes_summary(r, ref_maps, resolved=changes), r.ip_address or '']
)
if changes is None:
continue
if not changes:
# ★ 改的全是噪声字段的记录不能凭空消失 —— 它确实发生过。
detail.append(
audit_head_values(r)[:6]
+ ['(仅变更了图片/链接/更新时间等噪声字段)', '', '']
)
continue
for label, old, new in changes:
detail.append(
audit_head_values(r)[:6]
+ [label, cell(old), cell(new)]
)
# ---------- 汇总 ----------
summary = list(summary_rows or [])
summary.append(('本次导出', '记录数', len(rows)))
summary.append(('本次导出', '变更明细行数', len(detail)))
if note:
summary.append(('本次导出', '注意', note))
wb = Workbook()
ws = wb.active
ws.title = '汇总'
fill_sheet(ws, ['分类', '项目', '数值/说明'], summary, wrap_cols=(3,))
fill_sheet(
wb.create_sheet('审计日志'),
AUDIT_HEAD + LEDGER_EXTRA,
ledger,
wrap_cols=(7, 8), # URL 与变更摘要
)
fill_sheet(
wb.create_sheet('变更明细'),
AUDIT_HEAD[:6] + ['字段', '旧值', '新值'],
detail,
wrap_cols=(7, 8, 9),
)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()

View File

@ -22,6 +22,23 @@ MOM 系统日报 —— 采集当日数据 → 渲染纯文本 → 发送邮件
★ 凡有上限的地方一律显式写明「另有 N 条」,不静默截断。报表的读法就是
"看到的就是全部",偷偷砍掉会让人对数据产生错误信心。
★ **正文摘要 + Excel 附件全量**(2026-09 起)。
此前正文把明细压到极简(只列变更字段≥3 的记录、新增只给模块计数),
好处是一屏看完,代价是"199 条新增只看到 3 个模块名"—— 想查某条具体
记录改了什么,正文根本给不出来。
但也不能把这些全塞进正文:实测某日 354 条修改展开近 3000 行,邮件客户端
渲染卡顿,部分企业邮件网关直接把超长自动邮件判为垃圾或截断 —— 这是比
"信息少"更难排查的故障(发件方照样收到 250,看起来一切正常)。
故拆成两路:**正文保持摘要**(`render()`,一眼看完发生了什么),
**附件给全量明细**(`render_excel()`,每类操作一个工作表,不设阈值)。
★ 取值/翻译/Excel 排版逻辑**不在这里**,在 `audit_export_service`。
审计页的按条件导出要用同一套(否则会出现"日报里是人名、导出里是裸 ID"),
故那一层由两个消费者共用,本模块只负责「取哪些行、怎么组织成日报」。
时间口径
--------
audit_logs.created_at 由 beijing_time() 写入,是 naive 北京时间。
@ -31,32 +48,34 @@ import logging
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from app.extensions import db
from app.models.audit import AuditLog
from app.models.base import MaterialBase
from app.models.outbound import TransOutbound
from app.models.transaction import TransBorrow
from app.utils.audit_labels import (
BOOLEAN_FIELDS,
PERSON_NAME_FIELDS,
USER_ID_FIELDS,
bool_label,
canon_action,
enum_label,
field_label,
id_ref_of,
person_name_label,
from app.services.audit_export_service import (
DAILY_REPORT_HEAD,
EXCEL_SNAPSHOT_COL_LIMIT,
SNAPSHOT_CREATED,
SNAPSHOT_DELETED,
SYSTEM_USERNAME,
UNKNOWN,
cell,
changes_of,
daily_report_head_values,
fill_sheet,
fmt_name,
fmt_value,
load_ref_maps,
operator_of,
resolved_changes,
snapshot_headers,
snapshot_of,
)
from app.utils.audit_labels import canon_action
from app.utils.constants import OutboundType
logger = logging.getLogger(__name__)
# 系统占位账号:菜单/权限初始化一类的自动写入挂在这个名下(口径同审计页)
SYSTEM_USERNAME = 'system'
# 未解析出物料信息时的占位
UNKNOWN = '-'
class DailyReportService:
"""日报采集与渲染。所有方法均为无状态,便于手动触发与测试。"""
@ -69,15 +88,6 @@ class DailyReportService:
MODULE_LIMIT = 20
# 【出库】【借库】单据条数上限
DOC_LIMIT = 50
# 变更字段的噪声过滤 —— 见 _changes_of() 的说明
IGNORED_CHANGE_FIELDS = frozenset({
'updated_at',
'product_image',
'product_image_remark',
'manual_link',
'manual_link_remark',
'purchase_link',
})
# ------------------------------------------------------------------
# 时间边界
@ -151,186 +161,6 @@ class DailyReportService:
# ------------------------------------------------------------------
# 采集
# ------------------------------------------------------------------
@staticmethod
def _operator_of(row):
"""
操作人显示名:显示名(账号),与旧日报的「平板(pingban)」同形。
★ display_name 库里存法**不统一**,实测有四种:
'高雪(gaoxue)' / '高闯/gaochuang' / '杜邢宸(duxingchen)' / ''(空)
直接 f"{display_name}({username})" 会拼出
'杜邢宸(duxingchen)(duxingchen)' 这种账号重复的怪名字。
故先把 display_name 里已内嵌的账号部分剥掉,再统一格式化。
"""
dn = (getattr(row, 'display_name', '') or '').strip()
un = (getattr(row, 'username', '') or '').strip()
# 剥掉内嵌账号:'高雪(gaoxue)'→'高雪','高闯/gaochuang'→'高闯'
for sep in ('(', '/'):
if sep in dn:
dn = dn.split(sep)[0].strip()
if dn and un and dn != un:
return f"{dn}({un})"
return dn or un or UNKNOWN
@staticmethod
def _fmt_name(raw):
"""
统一库里五花八门的操作人写法。
audit_logs 侧有 display_name/username 两个字段可以拼,但
trans_outbound.operator_name / trans_borrow.dispatch_operator 这类
**流水表**只存了一个字符串,实测有 '高闯/gaochuang' 与
'杜邢宸(duxingchen)' 两种风格。同一封邮件里两种风格并存会显得很随意,
故统一成「名(账号)」。
"""
s = (raw or '').strip()
if not s:
return UNKNOWN
if '/' in s:
name, _, acct = s.partition('/')
name, acct = name.strip(), acct.strip()
if name and acct:
return f"{name}({acct})"
return name or acct or s
return s
@staticmethod
def _as_int(value):
"""尽力转 int;转不了返回 None(bool 不算数 —— 它是 int 的子类)"""
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float) and value.is_integer():
return int(value)
if isinstance(value, str):
s = value.strip()
if s.lstrip('-').isdigit():
return int(s)
return None
@staticmethod
def _load_user_map(user_ids):
"""{user_id: '名(账号)'}"""
if not user_ids:
return {}
from app.models.system import SysUser
rows = SysUser.query.filter(SysUser.id.in_(user_ids)).all()
return {u.id: DailyReportService._fmt_name(u.username) for u in rows}
@staticmethod
def _load_material_name_map(base_ids):
"""{base_id: 物料名}"""
if not base_ids:
return {}
rows = MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
return {m.id: (m.name or '').strip() for m in rows}
@staticmethod
def _load_menu_map(menu_ids):
"""
{menu_id: 菜单名}。
★ 0 是 sys_menu.parent_id 的默认值,含义是「没有上级」= 顶级菜单 ——
不能当成"查不到的 ID"而显示成 0。
"""
mapping = {0: '顶级菜单'}
ids = {i for i in menu_ids if i}
if not ids:
return mapping
from app.models.system import SysMenu
for m in SysMenu.query.filter(SysMenu.id.in_(ids)).all():
mapping[m.id] = (m.name or '').strip() or f'菜单#{m.id}'
return mapping
@staticmethod
def _load_return_ledger_map(return_ids):
"""
{trans_return.id: 'SKU 的退回流水(不良品 1.0)'}。
退回流水表本身没有可读单号,能标识它这段记录的就是「哪个物料、什么类型、
多少数量」——所以直接把这三样拼出来,而不是显示一个光秃秃的行号。
"""
if not return_ids:
return {}
from app.models.transaction import TransReturn
out = {}
for r in TransReturn.query.filter(TransReturn.id.in_(return_ids)).all():
qty = float(r.return_qty or 0)
rtype = (r.return_type or '').strip()
out[r.id] = f"{r.sku or '-'} 的退回流水({rtype} {qty})".replace('( ', '(')
return out
@staticmethod
def _resolve_value(module, field, value, ref_maps):
"""
把单个变更值翻成人类可读形式;翻不动返回 None(调用方回落原值)。
四类映射,优先级即从上到下:
· 布尔列 → 是 / 否
· 人名串 → '杜邢宸/duxingchen' → '杜邢宸(duxingchen)'
· 引用 ID 的列 → 查实体(用户 / 物料 / 菜单 / 退回流水…,
指向哪张表由 audit_labels.id_ref_of 按模块判定)
· 枚举列 → 按模块查状态表(见 audit_labels.enum_label)
★ 翻不动就**原样显示**,绝不硬编一个可能错的中文 —— 报表里的错译
比裸数字更难被发现,也更危险。
"""
if field in BOOLEAN_FIELDS:
text = bool_label(value)
if text is not None:
return text
# 人名串('杜邢宸/duxingchen')→ '杜邢宸(duxingchen)'。
# 按字段名限定,理由见 audit_labels.PERSON_NAME_FIELDS 的说明。
if field in PERSON_NAME_FIELDS:
text = person_name_label(value)
if text is not None:
return text
iv = DailyReportService._as_int(value)
if iv is not None:
if field in USER_ID_FIELDS:
return (ref_maps.get('user') or {}).get(iv)
ref = id_ref_of(module, field)
if ref:
got = (ref_maps.get(ref) or {}).get(iv)
if got is not None:
return got
return enum_label(module, field, value)
@staticmethod
def _changes_of(row):
"""
UPDATE 记录的字段变更 → [(字段, 旧值, 新值)];结构异常时返回空列表。
★ 噪声字段在**计数之前**就滤掉:图片/链接/更新时间的变化与业务动作无关,
算进「变更字段数」会把噪声顶过阈值,让日报里塞满 URL 对比 ——
看起来"有 3 个字段变了",实际业务上什么都没发生。
"""
details = row.details or {}
if not isinstance(details, dict):
return []
changes = details.get('changes')
if not isinstance(changes, dict):
return []
out = []
for key, val in changes.items():
if key in DailyReportService.IGNORED_CHANGE_FIELDS:
continue
if isinstance(val, dict):
out.append((key, val.get('old'), val.get('new')))
else:
# 兼容「非 {old,new} 结构」的历史写法:整值视为新值
out.append((key, None, val))
return out
@staticmethod
def collect(report_date=None):
"""
@ -356,37 +186,12 @@ class DailyReportService:
# 【修改】按操作人分组,只保留「变更字段数 >= 阈值」的记录供详列
update_by_operator = defaultdict(list)
for r in by_action['UPDATE']:
update_by_operator[DailyReportService._operator_of(r)].append(r)
update_by_operator[operator_of(r)].append(r)
# ---------- 变更值里的 ID 批量解析 ----------
# ---------- 变更值 / 快照里的 ID 批量解析 ----------
# 「实际审批人ID: 空 → 7」这种显示没有意义,要变成「杜邢宸」。
# 逐条查库是 N+1,故先把涉及的 ID 按类型收集起来,每类一次查完。
user_ids = set()
ref_ids = defaultdict(set) # ref 类型 -> {id}
for rows in update_by_operator.values():
for r in rows:
for key, old, new in DailyReportService._changes_of(r):
for v in (old, new):
iv = DailyReportService._as_int(v)
if iv is None:
continue
if key in USER_ID_FIELDS:
user_ids.add(iv)
continue
ref = id_ref_of(r.module, key)
if ref:
ref_ids[ref].add(iv)
loaders = {
'user': DailyReportService._load_user_map,
'material': DailyReportService._load_material_name_map,
'menu': DailyReportService._load_menu_map,
'return_ledger': DailyReportService._load_return_ledger_map,
}
ref_maps = {'user': loaders['user'](user_ids)}
for ref, ids in ref_ids.items():
if ref in loaders:
ref_maps[ref] = loaders[ref](ids)
# 逐条查库是 N+1,load_ref_maps 会按 ref 类型批量查一次。
ref_maps = load_ref_maps(audit_rows)
# ---------- 出库 ----------
outbound_rows = TransOutbound.query.filter(
@ -426,7 +231,7 @@ class DailyReportService:
head = rows[0]
outbound_list.append({
'no': no,
'operator': DailyReportService._fmt_name(head.operator_name),
'operator': fmt_name(head.operator_name),
'consumer': head.consumer_name or '',
'type': OutboundType.label(head.outbound_type),
'time': head.outbound_time.strftime('%H:%M') if head.outbound_time else '',
@ -443,20 +248,31 @@ class DailyReportService:
head = rows[0]
borrow_list.append({
'no': no,
'borrower': DailyReportService._fmt_name(head.borrower_name),
'operator': DailyReportService._fmt_name(head.dispatch_operator),
'borrower': fmt_name(head.borrower_name),
'operator': fmt_name(head.dispatch_operator),
'returned': bool(head.is_returned),
'time': head.borrow_time.strftime('%H:%M') if head.borrow_time else '',
'items': [_item_of(r) for r in rows],
})
borrow_list.sort(key=lambda d: d['time'])
# 附件要按时间顺序展开全量明细,故快照式地保留原始行
# (正文只用到下面的计数字段,用不到这些行)。
audit_rows_sorted = sorted(audit_rows, key=lambda r: r.created_at or start)
by_action_sorted = defaultdict(list)
for r in audit_rows_sorted:
by_action_sorted[canon_action(r.action)].append(r)
return {
'report_date': start.strftime('%Y-%m-%d'),
'audit': {
'create_total': len(by_action['CREATE']),
'update_total': len(by_action['UPDATE']),
'delete_total': len(by_action['DELETE']),
# 附件明细用的原始行(按时间升序);正文只用上面的计数
'create_rows': by_action_sorted['CREATE'],
'update_rows': by_action_sorted['UPDATE'],
'delete_rows': by_action_sorted['DELETE'],
'create_by_module': create_by_module.most_common(),
'update_by_operator': {
k: v for k, v in sorted(
@ -473,35 +289,17 @@ class DailyReportService:
# ------------------------------------------------------------------
# 渲染
# ------------------------------------------------------------------
@staticmethod
def _fmt_value(val):
"""变更值显示:空值统一显示「空」(与审计页详情弹窗一致)。"""
if val is None or val == '':
return '空'
s = str(val)
return s if len(s) <= 40 else s[:40] + '…'
@staticmethod
def _render_changes(lines, module, changes, ref_maps):
"""
输出一组字段变更:值先经 _resolve_value 翻译,翻不动才显示原值。
输出一组字段变更:值先翻译,翻不动才显示原值(见 resolved_changes)。
★ 「实际审批人ID: 空 → 7」解析成人名后,标签里的「ID」后缀就不贴切了,
故解析成功时去掉后缀 → 「实际审批人: 空 → 杜邢宸(duxingchen)」。
正文用 fmt_value(空值显示「空」、超长截断 40 字);附表走 audit_export
的 cell(空值留空、截断 2000 字)—— 两处对格式化的要求相反。
"""
for key, old, new in changes:
old_t = DailyReportService._resolve_value(module, key, old, ref_maps)
new_t = DailyReportService._resolve_value(module, key, new, ref_maps)
label = field_label(key)
is_ref = key in USER_ID_FIELDS or id_ref_of(module, key) is not None
if is_ref and (old_t is not None or new_t is not None) and label.endswith('ID'):
label = label[:-2]
for label, old, new in resolved_changes(module, changes, ref_maps):
lines.append(
f" · {label}: "
f"{DailyReportService._fmt_value(old if old_t is None else old_t)} → "
f"{DailyReportService._fmt_value(new if new_t is None else new_t)}"
f" · {label}: {fmt_value(old)} → {fmt_value(new)}"
)
@staticmethod
@ -535,8 +333,7 @@ class DailyReportService:
quiet_records = 0
for operator, rows in audit['update_by_operator'].items():
detailed = [r for r in rows
if len(DailyReportService._changes_of(r))
>= DailyReportService.DETAIL_MIN_FIELDS]
if len(changes_of(r)) >= DailyReportService.DETAIL_MIN_FIELDS]
if not detailed:
quiet_ops += 1
quiet_records += len(rows)
@ -546,7 +343,7 @@ class DailyReportService:
for r in detailed[:DailyReportService.DETAIL_LIMIT]:
lines.append(f" · [{r.module or '未知模块'}] {r.url or ''}")
DailyReportService._render_changes(
lines, r.module, DailyReportService._changes_of(r),
lines, r.module, changes_of(r),
audit.get('ref_maps') or {},
)
if len(detailed) > DailyReportService.DETAIL_LIMIT:
@ -605,12 +402,217 @@ class DailyReportService:
empty_text="今日无借库记录",
)
# ★ 必须显式告知附件的存在与文件名。正文里的数字是**被截断后**的
# (每个操作人最多 DETAIL_LIMIT 条、新增只有模块计数),不看附件
# 会把摘要误当成全部 —— 这正是「正文摘要 + 附件全量」最容易踩的坑。
lines.append("=" * 60)
lines.append(
f"📎 以上为摘要。全量明细(每条新增/修改/删除记录、全部变更字段、"
f"所有出库借库单)见附件:{DailyReportService.excel_filename(date_str)}"
)
lines.append("此邮件由 MOM 系统自动发送,请勿回复。")
subject = f"MOM系统日报 {date_str}"
return subject, '\n'.join(lines)
# ------------------------------------------------------------------
# Excel 附件
# ------------------------------------------------------------------
@staticmethod
def excel_filename(date_str):
"""附件文件名。集中在此,避免正文里的提示与实际附件名对不上。"""
return f"MOM系统日报_{date_str}.xlsx"
@staticmethod
def _snapshot_columns(rows, which):
"""
一批记录的快照字段并集 → 列名列表(按出现频次降序)。
频次降序而不是首次出现顺序:同一个模块的记录字段一致,高频字段排在
左边,跨模块时才排到右侧,人一眼就能从左往右找到想要的列。
返回 (列名列表, 被丢弃的列数) —— 丢弃数交给调用方显式写进汇总表。
"""
counts = Counter()
for r in rows:
for k in snapshot_of(r, which):
counts[k] += 1
cols = [k for k, _ in counts.most_common()]
if len(cols) > EXCEL_SNAPSHOT_COL_LIMIT:
return cols[:EXCEL_SNAPSHOT_COL_LIMIT], len(cols) - EXCEL_SNAPSHOT_COL_LIMIT
return cols, 0
@staticmethod
def render_excel(stats):
"""
把 collect() 的结果渲染成 .xlsx 字节流。
工作表:
汇总 —— 各操作总量、新增按模块、修改按操作人
新增明细 —— 每条新增记录 + 它的完整字段快照(列=字段并集)
修改明细 —— 每条修改记录的每个变更字段占一行(长表,便于筛选透视)
删除明细 —— 每条删除记录 + 删除前快照
出库明细 —— 每个物料行占一行,单据级字段重复填充
借库明细 —— 同上
★ 全程在内存(BytesIO)里构建,**不落盘**。日报是每天一封的定时任务,
一旦落盘就得额外写清理逻辑,否则磁盘会被日复一日地慢慢吃掉;
而这份附件对系统本身没有任何留存价值(收件人邮箱里已有一份)。
★ 「修改明细」用长表而不是宽表:宽表要取所有记录的变更字段并集,
而不同模块改的字段完全不同,并集轻松上百列,且绝大多数格子是空的。
长表(一条变更一行)的列固定 10 个,想按字段筛"今天所有改过状态的
记录"只需对「字段」列做一次筛选。
"""
import io
from openpyxl import Workbook
audit = stats['audit']
ref_maps = audit.get('ref_maps') or {}
date_str = stats['report_date']
create_rows = audit.get('create_rows') or []
update_rows = audit.get('update_rows') or []
delete_rows = audit.get('delete_rows') or []
notes = [] # 显式记录附件里做过的取舍,写进汇总表
sheets = [] # [(标题, 表头, 数据行, 换行列号)]
# ---------- 新增明细 ----------
create_cols, create_dropped = DailyReportService._snapshot_columns(
create_rows, SNAPSHOT_CREATED)
if create_dropped:
notes.append(
f"「新增明细」快照字段过多,按出现频次省略了 {create_dropped} 个低频列"
)
create_data = []
for r in create_rows:
snap = snapshot_of(r, SNAPSHOT_CREATED)
create_data.append(
daily_report_head_values(r)
+ [cell(snap.get(c), r.module, c, ref_maps) for c in create_cols]
)
sheets.append((
'新增明细',
DAILY_REPORT_HEAD + snapshot_headers(create_cols, DAILY_REPORT_HEAD),
create_data,
(6,), # URL 列自动换行
))
# ---------- 修改明细(长表:一条变更一行)----------
update_data = []
for r in update_rows:
changes = resolved_changes(r.module, changes_of(r), ref_maps)
if not changes:
# ★ 一条记录不能因为"改的全是图片/链接等噪声字段"就在附件里
# 凭空消失 —— 它确实发生过。占位一行,并说明原因。
update_data.append(
daily_report_head_values(r)
+ [0, '(仅变更了图片/链接/更新时间等噪声字段)', '', '']
)
continue
head = daily_report_head_values(r)
for label, old, new in changes:
# old / new 已由 resolved_changes 翻译过,故这里只做格式化,
# 不再传 module —— 重复解析一次不但浪费,还会让读者以为
# 这列的值是原始值。
update_data.append(head + [len(changes), label, cell(old), cell(new)])
sheets.append((
'修改明细',
DAILY_REPORT_HEAD + ['变更字段数', '字段', '旧值', '新值'],
update_data,
(6, 9, 10),
))
# ---------- 删除明细 ----------
delete_cols, delete_dropped = DailyReportService._snapshot_columns(
delete_rows, SNAPSHOT_DELETED)
if delete_dropped:
notes.append(
f"「删除明细」快照字段过多,按出现频次省略了 {delete_dropped} 个低频列"
)
delete_data = []
for r in delete_rows:
snap = snapshot_of(r, SNAPSHOT_DELETED)
delete_data.append(
daily_report_head_values(r)
+ [cell(snap.get(c), r.module, c, ref_maps) for c in delete_cols]
)
sheets.append((
'删除明细',
DAILY_REPORT_HEAD + snapshot_headers(delete_cols, DAILY_REPORT_HEAD),
delete_data,
(6,),
))
# ---------- 出库明细 ----------
outbound_data = []
for d in stats['outbound']:
for it in d['items']:
outbound_data.append([
d['no'], d['time'], d['operator'], d['consumer'], d['type'],
it['name'], it['spec'], it['quantity'], it['category'],
])
sheets.append((
'出库明细',
['出库单号', '时间', '操作员', '客户', '出库类型',
'物料名称', '规格型号', '数量', '分类'],
outbound_data, (),
))
# ---------- 借库明细 ----------
borrow_data = []
for d in stats['borrow']:
for it in d['items']:
borrow_data.append([
d['no'], d['time'], d['borrower'], d['operator'],
'是' if d['returned'] else '否',
it['name'], it['spec'], it['quantity'], it['category'],
])
sheets.append((
'借库明细',
['借出单号', '时间', '借用人', '库管', '是否已归还',
'物料名称', '规格型号', '数量', '分类'],
borrow_data, (),
))
# ---------- 汇总(含上面收集到的 notes)----------
summary_rows = [
('总量', '新增记录', audit['create_total']),
('总量', '修改记录', audit['update_total']),
('总量', '删除记录', audit['delete_total']),
('出库', '单据数', len(stats['outbound'])),
('出库', '物料行数', len(outbound_data)),
('借库', '单据数', len(stats['borrow'])),
('借库', '物料行数', len(borrow_data)),
]
for name, cnt in audit['create_by_module']:
summary_rows.append(('新增·按模块', name, cnt))
for operator, rows in audit['update_by_operator'].items():
summary_rows.append(('修改·按操作人', operator, len(rows)))
# ★ 附件的取舍写在这里,而不是只打日志:收件人看到的是附件,
# 日志他看不到。多一列"说明"专放这类提示。
summary_rows.append(('数据口径', '统计日期', date_str))
summary_rows.append((
'数据口径', '统计范围',
'北京时间当日 00:00–24:00;已排除 system 占位账号',
))
for note in notes:
summary_rows.append(('数据口径', note, ''))
wb = Workbook()
ws = wb.active
ws.title = '汇总'
fill_sheet(ws, ['分类', '项目', '数值/说明'], summary_rows, wrap_cols=(3,))
for title, headers, rows, wrap_cols in sheets:
fill_sheet(wb.create_sheet(title=title), headers, rows, wrap_cols)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# ------------------------------------------------------------------
# 发送
# ------------------------------------------------------------------
@ -642,6 +644,24 @@ class DailyReportService:
return {'subject': None, 'recipients': [], 'stats': None}
subject, body, stats = DailyReportService.build_report(report_date)
send_email(recipients, subject, body)
logger.info(f"[日报] 已发送 {stats['report_date']} → {recipients}")
# ★ 附件失败不连累正文:正文摘要本身是有效信息,发出去远好过整封不发。
# 但**必须记 error**(不是 warning)—— 降级后邮件看起来完全正常,
# 不留下显眼日志的话,附件静默丢失可以持续几个月没人发现。
attachments = None
try:
data = DailyReportService.render_excel(stats)
attachments = [{
'filename': DailyReportService.excel_filename(stats['report_date']),
'data': data,
}]
except Exception as e:
logger.error(f"[日报] Excel 附件生成失败,本次降级为纯文本正文: {e}",
exc_info=True)
send_email(recipients, subject, body, attachments=attachments)
logger.info(
f"[日报] 已发送 {stats['report_date']} → {recipients}"
f"(附件 {len(attachments[0]['data']) if attachments else 0} 字节)"
)
return {'subject': subject, 'recipients': recipients, 'stats': stats}