Files
KCGL/inventory-backend/app/services/daily_report_service.py
yueli 956d4acb5f feat(audit): 快照嵌套分层渲染 + 凭据字段从接口剥离(安全修复)
起因:借库管理等单据的详情页仍显示原始 JSON 代码块。
根因:details 有**第三种**存法 {'payload': {...}},且内部嵌着对象数组
(items 明细行),而旧实现只认平铺的 created/deleted_snapshot,
遇到非平铺结构就退化成原始 JSON。

1. 快照键收敛(后端)
   created / deleted_snapshot / payload 三种键与展示标题收进
   audit_labels.SNAPSHOT_VIEWS,经 /audit/labels 下发 snapshotViews,
   前端不再硬编键名 —— 将来加第四种只改一处。
   audit_export_service 与 daily_report_service 改为引用同一份。
   changes_summary 也改用 snapshot_of_any:payload 型记录的新增摘要
   此前整列为空,现在有内容了(如「新增(借库管理):物料明细 1 项、
   备注、借用人、签名、预计归还时间」)。

2. 分层渲染(前端)
   · 标量字段 → el-descriptions(保留字典翻译与空值/技术字段过滤)
   · 数组字段 → el-table:列取**所有元素键的并集**(同一数组内元素键
     并不完全一致,只取首个元素会漏字段),跳过技术字段,列头走 fieldLabel
   · 标量数组(arrival_photo 等图片 URL 列表)→ 单列表格
   · details / payload 本身是数组或标量的情况也一并处理
   · 只有确实无可识别结构时才回落到原始 JSON

   实测全库 58728 条:**0 条**会退化到原始 JSON 兜底
   (payload 1495 条全部拆出结构)。

3. ★ 安全修复:审计快照里存有**明文密码**,本次从所有出口剥离
   实测 `用户管理/新增` 的 payload 快照记着哈希前的原始密码(27 条,
   形如 '123456'、'shili0823'),因为写入路径把请求体整包记进了审计。

   · sanitize_details():递归剥掉凭据类字段,应用于列表与详情接口
   · changes_of / snapshot_of 在**源头**滤掉凭据:逐个出口去补必然漏掉
     某一个,而漏掉的那个就是泄漏点(日报附件、导出、摘要都走这两个函数)
   · 凭据判定用**关键词**(password/passwd/secret/token/private_key)
     而非逐个登记 —— 今天漏的是 payload 里的 password,明天可能是
     reset_token。新表加凭据字段也自动被挡住。

   ★ 第一版我只做了前端隐藏(hiddenFields),被测试抓出来:原始响应里
     密码照样在,打开 devtools 就读得到。**要挡的数据必须在接口出口剥掉,
     前端隐藏不是防线。**

验证:14 + 15 项断言全过,含「列表/详情响应无密码原文与 password 键」
「全量导出的表头与单元格均无凭据」「日报三天附件无凭据」
「sanitize_details 递归进嵌套数组且不原地改原对象」。
6 列结构与附件大小未变(490.5K / 193.4K / 10.3K);vue-tsc 与 vite build exit=0。

⚠️ 数据库中仍存有明文密码(16 条非空),本次只挡展示与导出,**未改数据** ——
   改数据不可逆,需单独立项决定。
2026-09-28 10:40:00 +08:00

670 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,
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 (
SNAPSHOT_CREATED,
SNAPSHOT_DELETED,
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}