Files
KCGL/inventory-backend/app/services/daily_report_service.py
yueli 1d6beaa376 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 级日志 —— 降级后邮件
看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。
2026-09-28 09:46:25 +08:00

668 lines
29 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.

"""
MOM 系统日报 —— 采集当日数据 → 渲染纯文本 → 发送邮件。
设计取舍
--------
★ **纯代码统计,不接 AI**。
日报是固定格式的结构化统计(计数 + 清单),不是创造性任务。此前那份日报由
Dify(外部 AI 服务,见 api/v1/ai_proxy.py)生成,好处是能写人话,代价是:
· 外部依赖一断,整份日报就没了(这正是"AI 坏了"之后发生的事情);
· 按次计费、要走网络、要管密钥;
· **会在报表里编数字** —— 日报是给人做决策用的,数字必须可信。
换成确定性模板后,这些代价全部消失,而日报本来也不需要创造力。
★ 口径与前端「操作审计日志」页保持一致,避免两处对同一天给出不同数字:
· action 一律经 canon_action() 归一化(历史数据里 CREATE 与「新增」混用,
不归一会漏掉早期数据);
· 默认排除 system 占位账号(与审计页默认的"真实用户"视图一致)。
注意这是**占位账号**,不是"系统自动产生的日志" —— 判据是 username。
★ 凡有上限的地方一律显式写明「另有 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 北京时间。
本模块按**北京时间的自然日**切分 [00:00, 次日 00:00)。
"""
import logging
from collections import Counter, defaultdict
from datetime import datetime, timedelta
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.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__)
class DailyReportService:
"""日报采集与渲染。所有方法均为无状态,便于手动触发与测试。"""
# 【修改】只详列「变更字段数 >= 该值」的记录 —— 全列会把日报冲成一屏流水
DETAIL_MIN_FIELDS = 3
# 【修改】详列条数上限(超出部分显式报数,不静默丢弃)
DETAIL_LIMIT = 30
# 【新增】模块清单条数上限
MODULE_LIMIT = 20
# 【出库】【借库】单据条数上限
DOC_LIMIT = 50
# ------------------------------------------------------------------
# 时间边界
# ------------------------------------------------------------------
@staticmethod
def _day_bounds(report_date=None):
"""返回 [当天 00:00, 次日 00:00)。report_date 为 date 对象,缺省取今天。"""
if report_date is None:
report_date = datetime.now().date()
start = datetime(report_date.year, report_date.month, report_date.day)
return start, start + timedelta(days=1)
# ------------------------------------------------------------------
# 物料信息解析(出库/借库明细要显示名称/规格/分类)
# ------------------------------------------------------------------
@staticmethod
def _load_material_index(pairs):
"""
批量解析 {(source_table, stock_id)} → {'name','spec','category'}。
★ 批量而非逐条:一张日报可能涉及上百行明细,逐行查库就是 N+1。
★ 取不到就跳过(源库存行可能已被物理删除),由渲染层显示占位符 ——
不因为缺一个物料名就让整份日报发不出去。
"""
from app.models.inbound.buy import StockBuy
from app.models.inbound.product import StockProduct
from app.models.inbound.semi import StockSemi
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct,
}
by_table = defaultdict(set)
for st, sid in pairs:
st = (st or '').strip()
if st in model_map and sid:
try:
by_table[st].add(int(sid))
except (TypeError, ValueError):
continue
index = {}
for table, ids in by_table.items():
model = model_map[table]
rows = model.query.filter(model.id.in_(ids)).all()
base_ids = {r.base_id for r in rows if getattr(r, 'base_id', None)}
bases = {}
if base_ids:
bases = {
b.id: b
for b in MaterialBase.query.filter(MaterialBase.id.in_(base_ids)).all()
}
for r in rows:
b = bases.get(getattr(r, 'base_id', None))
if b is None:
continue
# 分类:material_base.category 本身**已经是完整路径**
#(实测 'IRIS/原材料/结构/非标OS'、'LICA/生产配件'),
# company_name 是它的首段、material_type 是它的末段 ——
# 再拼一遍会得到 'LICA/LICA/生产配件/生产配件' 这种重复。
# 故直接取用,不拼接。
index[(table, r.id)] = {
'name': (b.name or '').strip(),
'spec': (b.spec_model or '').strip(),
'category': (b.category or '').strip(),
}
return index
# ------------------------------------------------------------------
# 采集
# ------------------------------------------------------------------
@staticmethod
def collect(report_date=None):
"""
采集指定日期的日报数据。
Returns: dict(结构见下方各处注释),供 render() 使用,也可单独用于调试。
"""
start, end = DailyReportService._day_bounds(report_date)
# ---------- 审计日志 ----------
audit_rows = AuditLog.query.filter(
AuditLog.created_at >= start,
AuditLog.created_at < end,
AuditLog.username != SYSTEM_USERNAME,
).all()
by_action = defaultdict(list)
for r in audit_rows:
by_action[canon_action(r.action)].append(r)
create_by_module = Counter((r.module or '未知模块') for r in by_action['CREATE'])
# 【修改】按操作人分组,只保留「变更字段数 >= 阈值」的记录供详列
update_by_operator = defaultdict(list)
for r in by_action['UPDATE']:
update_by_operator[operator_of(r)].append(r)
# ---------- 变更值 / 快照里的 ID 批量解析 ----------
# 「实际审批人ID: 空 → 7」这种显示没有意义,要变成「杜邢宸」。
# 逐条查库是 N+1,load_ref_maps 会按 ref 类型批量查一次。
ref_maps = load_ref_maps(audit_rows)
# ---------- 出库 ----------
outbound_rows = TransOutbound.query.filter(
TransOutbound.outbound_time >= start,
TransOutbound.outbound_time < end,
).order_by(TransOutbound.outbound_time).all()
# ---------- 借库 ----------
borrow_rows = TransBorrow.query.filter(
TransBorrow.borrow_time >= start,
TransBorrow.borrow_time < end,
).order_by(TransBorrow.borrow_time).all()
# ---------- 物料信息批量解析 ----------
pairs = [(r.source_table, r.stock_id) for r in outbound_rows]
pairs += [(r.source_table, r.stock_id) for r in borrow_rows]
material_index = DailyReportService._load_material_index(pairs)
def _item_of(row):
info = material_index.get(
((row.source_table or '').strip(), row.stock_id), {}
)
return {
'sku': row.sku or '',
'name': info.get('name') or '',
'spec': info.get('spec') or '',
'category': info.get('category') or '',
'quantity': float(row.quantity or 0),
}
# 出库按单号聚合
outbound_docs = defaultdict(list)
for r in outbound_rows:
outbound_docs[r.outbound_no or f"#{r.id}"].append(r)
outbound_list = []
for no, rows in outbound_docs.items():
head = rows[0]
outbound_list.append({
'no': no,
'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 '',
'items': [_item_of(r) for r in rows],
})
outbound_list.sort(key=lambda d: d['time'])
# 借库按单号聚合
borrow_docs = defaultdict(list)
for r in borrow_rows:
borrow_docs[r.borrow_no or f"#{r.id}"].append(r)
borrow_list = []
for no, rows in borrow_docs.items():
head = rows[0]
borrow_list.append({
'no': no,
'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(
update_by_operator.items(), key=lambda kv: -len(kv[1])
)
},
# 变更值里 ID → 实体的解析结果(key 为 ref 类型),渲染时用
'ref_maps': ref_maps,
},
'outbound': outbound_list,
'borrow': borrow_list,
}
# ------------------------------------------------------------------
# 渲染
# ------------------------------------------------------------------
@staticmethod
def _render_changes(lines, module, changes, ref_maps):
"""
输出一组字段变更:值先翻译,翻不动才显示原值(见 resolved_changes)。
正文用 fmt_value(空值显示「空」、超长截断 40 字);附表走 audit_export
的 cell(空值留空、截断 2000 字)—— 两处对格式化的要求相反。
"""
for label, old, new in resolved_changes(module, changes, ref_maps):
lines.append(
f" · {label}: {fmt_value(old)} → {fmt_value(new)}"
)
@staticmethod
def _render_audit(lines, audit):
# ---------- 新增 ----------
lines.append("【新增】")
total = audit['create_total']
lines.append(f" 总量: {total} 条")
if total == 0:
lines.append(" 今日无新增记录")
else:
mods = audit['create_by_module']
for name, cnt in mods[:DailyReportService.MODULE_LIMIT]:
lines.append(f" · {name}: {cnt} 条")
if len(mods) > DailyReportService.MODULE_LIMIT:
rest = len(mods) - DailyReportService.MODULE_LIMIT
lines.append(f" (另有 {rest} 个模块未列出)")
lines.append("")
# ---------- 修改 ----------
lines.append("【修改】")
total = audit['update_total']
lines.append(f" 总量: {total} 条")
if total == 0:
lines.append(" 今日无修改记录")
lines.append("")
return
# ★ 只详列「有实质变更」的操作人;其余合并成一行报数。
# 否则每天几十位操作人各占两行「无变更字段≥3的记录」,日报一半是废话。
quiet_ops = 0
quiet_records = 0
for operator, rows in audit['update_by_operator'].items():
detailed = [r for r in rows
if len(changes_of(r)) >= DailyReportService.DETAIL_MIN_FIELDS]
if not detailed:
quiet_ops += 1
quiet_records += len(rows)
continue
lines.append(f" 【{operator}】共 {len(rows)} 条")
lines.append(f" 以下记录变更字段≥{DailyReportService.DETAIL_MIN_FIELDS}:")
for r in detailed[:DailyReportService.DETAIL_LIMIT]:
lines.append(f" · [{r.module or '未知模块'}] {r.url or ''}")
DailyReportService._render_changes(
lines, r.module, changes_of(r),
audit.get('ref_maps') or {},
)
if len(detailed) > DailyReportService.DETAIL_LIMIT:
rest = len(detailed) - DailyReportService.DETAIL_LIMIT
lines.append(f" (另有 {rest} 条未列出)")
lines.append("")
if quiet_ops:
lines.append(
f" 另有 {quiet_ops} 位操作人(共 {quiet_records} 条)"
f"无变更字段≥{DailyReportService.DETAIL_MIN_FIELDS}的记录,未逐条列出"
)
lines.append("")
@staticmethod
def _render_docs(lines, title, docs, head_fields, empty_text):
lines.append(f"【{title}】")
lines.append(f" 总量: {len(docs)} 条")
if not docs:
lines.append(f" {empty_text}")
lines.append("")
return
for d in docs[:DailyReportService.DOC_LIMIT]:
lines.append(f" · {d['no']}")
lines.append(" " + " | ".join(f"{k}: {d[v]}" for k, v in head_fields))
for it in d['items']:
lines.append(
f" 物料: {it['name']} | 规格: {it['spec'] or UNKNOWN} | "
f"数量: {it['quantity']} | 分类: {it['category'] or UNKNOWN}"
)
if len(docs) > DailyReportService.DOC_LIMIT:
rest = len(docs) - DailyReportService.DOC_LIMIT
lines.append(f" (另有 {rest} 张单据未列出)")
lines.append("")
@staticmethod
def render(stats):
"""把 collect() 的结果渲染成纯文本正文。返回 (subject, body)。"""
date_str = stats['report_date']
lines = [f"📋 MOM系统日报 — {date_str}", "=" * 60, ""]
DailyReportService._render_audit(lines, stats['audit'])
lines.append("【删除】")
lines.append(f" 总量: {stats['audit']['delete_total']} 条")
lines.append("")
DailyReportService._render_docs(
lines, "出库", stats['outbound'],
head_fields=[('操作员', 'operator'), ('客户', 'consumer'), ('类型', 'type')],
empty_text="今日无出库记录",
)
DailyReportService._render_docs(
lines, "借库", stats['borrow'],
head_fields=[('借用人', 'borrower'), ('库管', 'operator')],
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()
# ------------------------------------------------------------------
# 发送
# ------------------------------------------------------------------
@staticmethod
def build_report(report_date=None):
"""采集 + 渲染,但不发送 —— 用于预览与调试。返回 (subject, body, stats)。"""
stats = DailyReportService.collect(report_date)
subject, body = DailyReportService.render(stats)
return subject, body, stats
@staticmethod
def send_daily_report(report_date=None, recipients=None):
"""
采集 → 渲染 → 发送。供定时任务与手动触发共用。
recipients 缺省时取 config.MAIL_DAILY_REPORT_RECIPIENTS。
返回 {'subject','recipients','stats'},便于调用方打日志。
"""
from flask import current_app
from app.utils.email_service import send_email
if recipients is None:
raw = current_app.config.get('MAIL_DAILY_REPORT_RECIPIENTS', '') or ''
recipients = [e.strip() for e in raw.split(',') if e.strip()]
if isinstance(recipients, str):
recipients = [e.strip() for e in recipients.split(',') if e.strip()]
if not recipients:
logger.warning("[日报] 未配置收件人(MAIL_DAILY_REPORT_RECIPIENTS),跳过发送")
return {'subject': None, 'recipients': [], 'stats': None}
subject, body, stats = DailyReportService.build_report(report_date)
# ★ 附件失败不连累正文:正文摘要本身是有效信息,发出去远好过整封不发。
# 但**必须记 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}