起因:借库管理等单据的详情页仍显示原始 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 条非空),本次只挡展示与导出,**未改数据** ——
改数据不可逆,需单独立项决定。
670 lines
29 KiB
Python
670 lines
29 KiB
Python
"""
|
||
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}
|