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

@ -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}