From 1d6beaa3765b498ab1bae523d3d43f8ead0166ef Mon Sep 17 00:00:00 2001 From: yueli Date: Mon, 28 Sep 2026 09:46:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(daily-report):=20=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E6=91=98=E8=A6=81=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20Excel=20=E9=99=84=E4=BB=B6=E7=BB=99=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=98=8E=E7=BB=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 日报此前把明细压到极简(修改只列变更字段≥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 级日志 —— 降级后邮件 看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。 --- .../app/services/audit_export_service.py | 696 ++++++++++++++++++ .../app/services/daily_report_service.py | 552 +++++++------- inventory-backend/app/utils/email_service.py | 78 +- 3 files changed, 1051 insertions(+), 275 deletions(-) create mode 100644 inventory-backend/app/services/audit_export_service.py diff --git a/inventory-backend/app/services/audit_export_service.py b/inventory-backend/app/services/audit_export_service.py new file mode 100644 index 0000000..24fea99 --- /dev/null +++ b/inventory-backend/app/services/audit_export_service.py @@ -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() diff --git a/inventory-backend/app/services/daily_report_service.py b/inventory-backend/app/services/daily_report_service.py index 008a625..ca6e4d1 100644 --- a/inventory-backend/app/services/daily_report_service.py +++ b/inventory-backend/app/services/daily_report_service.py @@ -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} diff --git a/inventory-backend/app/utils/email_service.py b/inventory-backend/app/utils/email_service.py index b7a3d41..6b6644d 100644 --- a/inventory-backend/app/utils/email_service.py +++ b/inventory-backend/app/utils/email_service.py @@ -7,6 +7,7 @@ import os import smtplib import ssl import logging +from email.mime.application import MIMEApplication from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.header import Header @@ -15,6 +16,39 @@ from typing import List, Union logger = logging.getLogger(__name__) +# 附件扩展名 → MIME 子类型。 +# +# ★ .xlsx 必须用官方类型,不能图省事写 'xlsx':那样 Content-Type 会变成 +# application/xlsx,Outlook 等客户端会当成未知二进制,附件无法双击直接 +# 打开(得先存盘再手动改后缀),用户只会反馈"附件打不开"。 +_XLSX_SUBTYPE = 'vnd.openxmlformats-officedocument.spreadsheetml.sheet' +_ATTACHMENT_SUBTYPES = { + '.xlsx': _XLSX_SUBTYPE, + '.xls': 'vnd.ms-excel', + '.csv': 'vnd.ms-excel', + '.pdf': 'pdf', + '.zip': 'zip', +} + + +def _attachment_subtype(filename: str) -> str: + """按扩展名给出 MIME 子类型;未登记的回落到 octet-stream(即默认值)""" + ext = os.path.splitext(filename or '')[1].lower() + return _ATTACHMENT_SUBTYPES.get(ext, 'octet-stream') + + +def _normalize_attachment(att): + """ + 附件入参 → (文件名, 字节)。接受两种写法,避免调用方纠结: + + {'filename': '日报.xlsx', 'data': b'...'} + ('日报.xlsx', b'...') + """ + if isinstance(att, dict): + return att.get('filename') or 'attachment', att.get('data') or b'' + filename, data = att + return filename or 'attachment', data or b'' + def _get_config(): """ @@ -46,15 +80,19 @@ def _get_config(): } -def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg: dict = None): +def send_email(to_email: Union[str, List[str]], subject: str, content: str, + cfg: dict = None, attachments: list = None): """ 通用邮件发送函数 Args: - to_email: 收件人,单个邮箱字符串或列表 - subject: 邮件主题 - content: 邮件正文(纯文本) - cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context) + to_email: 收件人,单个邮箱字符串或列表 + subject: 邮件主题 + content: 邮件正文(纯文本) + cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context) + attachments: 可选,附件列表。每项为 + {'filename': '日报.xlsx', 'data': b'...'} 或 ('日报.xlsx', b'...')。 + 数据是**字节**而非路径 —— 调用方在内存里生成,不落盘。 发送失败时打印日志,不抛出异常 """ @@ -91,7 +129,9 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg: return try: - msg = MIMEMultipart() + # ★ 显式 'mixed':虽然 MIMEMultipart() 默认就是 mixed,但正文+附件 + # 的语义要求就在这里,写出来才不会被后人"顺手"改成 related/alternative。 + msg = MIMEMultipart('mixed') msg['From'] = cfg['sender'] msg['To'] = ', '.join(recipients) msg['Subject'] = Header(subject, 'utf-8') @@ -104,7 +144,26 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg: msg['Message-ID'] = make_msgid(domain=(cfg['sender'].split('@')[-1] or None)) msg.attach(MIMEText(content, 'plain', 'utf-8')) - print(f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} 发件人: {cfg['username']}") + # 附件:内容已在内存里,直接 base64 编码挂上,不经过文件系统 + attached = 0 + for att in (attachments or []): + filename, data = _normalize_attachment(att) + if not data: + logger.warning(f"[Email] 附件 {filename} 内容为空,已跳过") + continue + part = MIMEApplication(data, _subtype=_attachment_subtype(filename)) + # ★ 中文文件名必须走 RFC 2231(('utf-8','',name) 三元组), + # 直接塞原始中文会生成非法的 Content-Disposition 头, + # 客户端显示成乱码或一串 =?utf-8?b?...?= 原文。 + part.add_header('Content-Disposition', 'attachment', + filename=('utf-8', '', filename)) + msg.attach(part) + attached += 1 + + print( + f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} " + f"发件人: {cfg['username']} 附件: {attached} 个" + ) if cfg.get('use_ssl'): context = ssl.create_default_context() @@ -136,7 +195,8 @@ def send_email(to_email: Union[str, List[str]], subject: str, content: str, cfg: logger.error(f"[Email] 发送邮件时发生未知异常: {e}") -def send_email_async(to_email: Union[str, List[str]], subject: str, content: str): +def send_email_async(to_email: Union[str, List[str]], subject: str, content: str, + attachments: list = None): """ 异步发送邮件(守护线程,不阻塞主请求线程)。 @@ -149,7 +209,7 @@ def send_email_async(to_email: Union[str, List[str]], subject: str, content: str t = threading.Thread( target=send_email, args=(to_email, subject, content), - kwargs={'cfg': cfg}, + kwargs={'cfg': cfg, 'attachments': attachments}, daemon=True ) t.start()