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