feat(daily-report): 正文保持摘要,新增 Excel 附件给全量明细
日报此前把明细压到极简(修改只列变更字段≥3 的记录、新增只给模块计数), "199 条新增只看到 3 个模块名",查不到某条具体记录改了什么。 但也不能全塞进正文:实测某日 354 条修改展开近 3000 行,客户端渲染卡顿, 部分企业邮件网关会把超长自动邮件判为垃圾或截断 —— 发件方照样收到 250, 表面看一切正常,这比"信息少"更难排查。 故拆成两路:正文保持摘要(render),附件给全量明细(render_excel), 六个工作表不设 DETAIL_MIN_FIELDS 之类的阈值。 - email_service: send_email/send_email_async 支持 attachments。数据是内存 字节、不落盘;中文文件名走 RFC 2231;.xlsx 用官方 MIME 类型,写错会让 Outlook 当成未知二进制、附件无法直接打开。 - audit_export_service: 新增。审计日志 → 可读值/Excel 的共用层。 「同一条「实际审批人ID: 7」一边翻了人名一边没翻」这类漂移,比不翻译更 难发现,故取值/翻译/排版逻辑集中在此,由日报与审计页导出共用。 - daily_report_service: 私有副本改为消费上述共用层(纯重构)。 附件大小与重构前逐字节一致(490.5K / 193.9K / 10.3K),行为未变。 附件生成失败时降级为纯文本正文继续发送,但记 error 级日志 —— 降级后邮件 看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。
This commit is contained in:
696
inventory-backend/app/services/audit_export_service.py
Normal file
696
inventory-backend/app/services/audit_export_service.py
Normal file
@ -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()
|
||||||
@ -22,6 +22,23 @@ MOM 系统日报 —— 采集当日数据 → 渲染纯文本 → 发送邮件
|
|||||||
★ 凡有上限的地方一律显式写明「另有 N 条」,不静默截断。报表的读法就是
|
★ 凡有上限的地方一律显式写明「另有 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 北京时间。
|
audit_logs.created_at 由 beijing_time() 写入,是 naive 北京时间。
|
||||||
@ -31,32 +48,34 @@ import logging
|
|||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.extensions import db
|
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
from app.models.outbound import TransOutbound
|
from app.models.outbound import TransOutbound
|
||||||
from app.models.transaction import TransBorrow
|
from app.models.transaction import TransBorrow
|
||||||
from app.utils.audit_labels import (
|
from app.services.audit_export_service import (
|
||||||
BOOLEAN_FIELDS,
|
DAILY_REPORT_HEAD,
|
||||||
PERSON_NAME_FIELDS,
|
EXCEL_SNAPSHOT_COL_LIMIT,
|
||||||
USER_ID_FIELDS,
|
SNAPSHOT_CREATED,
|
||||||
bool_label,
|
SNAPSHOT_DELETED,
|
||||||
canon_action,
|
SYSTEM_USERNAME,
|
||||||
enum_label,
|
UNKNOWN,
|
||||||
field_label,
|
cell,
|
||||||
id_ref_of,
|
changes_of,
|
||||||
person_name_label,
|
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
|
from app.utils.constants import OutboundType
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 系统占位账号:菜单/权限初始化一类的自动写入挂在这个名下(口径同审计页)
|
|
||||||
SYSTEM_USERNAME = 'system'
|
|
||||||
|
|
||||||
# 未解析出物料信息时的占位
|
|
||||||
UNKNOWN = '-'
|
|
||||||
|
|
||||||
|
|
||||||
class DailyReportService:
|
class DailyReportService:
|
||||||
"""日报采集与渲染。所有方法均为无状态,便于手动触发与测试。"""
|
"""日报采集与渲染。所有方法均为无状态,便于手动触发与测试。"""
|
||||||
@ -69,15 +88,6 @@ class DailyReportService:
|
|||||||
MODULE_LIMIT = 20
|
MODULE_LIMIT = 20
|
||||||
# 【出库】【借库】单据条数上限
|
# 【出库】【借库】单据条数上限
|
||||||
DOC_LIMIT = 50
|
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
|
@staticmethod
|
||||||
def collect(report_date=None):
|
def collect(report_date=None):
|
||||||
"""
|
"""
|
||||||
@ -356,37 +186,12 @@ class DailyReportService:
|
|||||||
# 【修改】按操作人分组,只保留「变更字段数 >= 阈值」的记录供详列
|
# 【修改】按操作人分组,只保留「变更字段数 >= 阈值」的记录供详列
|
||||||
update_by_operator = defaultdict(list)
|
update_by_operator = defaultdict(list)
|
||||||
for r in by_action['UPDATE']:
|
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」这种显示没有意义,要变成「杜邢宸」。
|
# 「实际审批人ID: 空 → 7」这种显示没有意义,要变成「杜邢宸」。
|
||||||
# 逐条查库是 N+1,故先把涉及的 ID 按类型收集起来,每类一次查完。
|
# 逐条查库是 N+1,load_ref_maps 会按 ref 类型批量查一次。
|
||||||
user_ids = set()
|
ref_maps = load_ref_maps(audit_rows)
|
||||||
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)
|
|
||||||
|
|
||||||
# ---------- 出库 ----------
|
# ---------- 出库 ----------
|
||||||
outbound_rows = TransOutbound.query.filter(
|
outbound_rows = TransOutbound.query.filter(
|
||||||
@ -426,7 +231,7 @@ class DailyReportService:
|
|||||||
head = rows[0]
|
head = rows[0]
|
||||||
outbound_list.append({
|
outbound_list.append({
|
||||||
'no': no,
|
'no': no,
|
||||||
'operator': DailyReportService._fmt_name(head.operator_name),
|
'operator': fmt_name(head.operator_name),
|
||||||
'consumer': head.consumer_name or '',
|
'consumer': head.consumer_name or '',
|
||||||
'type': OutboundType.label(head.outbound_type),
|
'type': OutboundType.label(head.outbound_type),
|
||||||
'time': head.outbound_time.strftime('%H:%M') if head.outbound_time else '',
|
'time': head.outbound_time.strftime('%H:%M') if head.outbound_time else '',
|
||||||
@ -443,20 +248,31 @@ class DailyReportService:
|
|||||||
head = rows[0]
|
head = rows[0]
|
||||||
borrow_list.append({
|
borrow_list.append({
|
||||||
'no': no,
|
'no': no,
|
||||||
'borrower': DailyReportService._fmt_name(head.borrower_name),
|
'borrower': fmt_name(head.borrower_name),
|
||||||
'operator': DailyReportService._fmt_name(head.dispatch_operator),
|
'operator': fmt_name(head.dispatch_operator),
|
||||||
'returned': bool(head.is_returned),
|
'returned': bool(head.is_returned),
|
||||||
'time': head.borrow_time.strftime('%H:%M') if head.borrow_time else '',
|
'time': head.borrow_time.strftime('%H:%M') if head.borrow_time else '',
|
||||||
'items': [_item_of(r) for r in rows],
|
'items': [_item_of(r) for r in rows],
|
||||||
})
|
})
|
||||||
borrow_list.sort(key=lambda d: d['time'])
|
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 {
|
return {
|
||||||
'report_date': start.strftime('%Y-%m-%d'),
|
'report_date': start.strftime('%Y-%m-%d'),
|
||||||
'audit': {
|
'audit': {
|
||||||
'create_total': len(by_action['CREATE']),
|
'create_total': len(by_action['CREATE']),
|
||||||
'update_total': len(by_action['UPDATE']),
|
'update_total': len(by_action['UPDATE']),
|
||||||
'delete_total': len(by_action['DELETE']),
|
'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(),
|
'create_by_module': create_by_module.most_common(),
|
||||||
'update_by_operator': {
|
'update_by_operator': {
|
||||||
k: v for k, v in sorted(
|
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
|
@staticmethod
|
||||||
def _render_changes(lines, module, changes, ref_maps):
|
def _render_changes(lines, module, changes, ref_maps):
|
||||||
"""
|
"""
|
||||||
输出一组字段变更:值先经 _resolve_value 翻译,翻不动才显示原值。
|
输出一组字段变更:值先翻译,翻不动才显示原值(见 resolved_changes)。
|
||||||
|
|
||||||
★ 「实际审批人ID: 空 → 7」解析成人名后,标签里的「ID」后缀就不贴切了,
|
正文用 fmt_value(空值显示「空」、超长截断 40 字);附表走 audit_export
|
||||||
故解析成功时去掉后缀 → 「实际审批人: 空 → 杜邢宸(duxingchen)」。
|
的 cell(空值留空、截断 2000 字)—— 两处对格式化的要求相反。
|
||||||
"""
|
"""
|
||||||
for key, old, new in changes:
|
for label, old, new in resolved_changes(module, changes, ref_maps):
|
||||||
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]
|
|
||||||
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f" · {label}: "
|
f" · {label}: {fmt_value(old)} → {fmt_value(new)}"
|
||||||
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)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -535,8 +333,7 @@ class DailyReportService:
|
|||||||
quiet_records = 0
|
quiet_records = 0
|
||||||
for operator, rows in audit['update_by_operator'].items():
|
for operator, rows in audit['update_by_operator'].items():
|
||||||
detailed = [r for r in rows
|
detailed = [r for r in rows
|
||||||
if len(DailyReportService._changes_of(r))
|
if len(changes_of(r)) >= DailyReportService.DETAIL_MIN_FIELDS]
|
||||||
>= DailyReportService.DETAIL_MIN_FIELDS]
|
|
||||||
if not detailed:
|
if not detailed:
|
||||||
quiet_ops += 1
|
quiet_ops += 1
|
||||||
quiet_records += len(rows)
|
quiet_records += len(rows)
|
||||||
@ -546,7 +343,7 @@ class DailyReportService:
|
|||||||
for r in detailed[:DailyReportService.DETAIL_LIMIT]:
|
for r in detailed[:DailyReportService.DETAIL_LIMIT]:
|
||||||
lines.append(f" · [{r.module or '未知模块'}] {r.url or ''}")
|
lines.append(f" · [{r.module or '未知模块'}] {r.url or ''}")
|
||||||
DailyReportService._render_changes(
|
DailyReportService._render_changes(
|
||||||
lines, r.module, DailyReportService._changes_of(r),
|
lines, r.module, changes_of(r),
|
||||||
audit.get('ref_maps') or {},
|
audit.get('ref_maps') or {},
|
||||||
)
|
)
|
||||||
if len(detailed) > DailyReportService.DETAIL_LIMIT:
|
if len(detailed) > DailyReportService.DETAIL_LIMIT:
|
||||||
@ -605,12 +402,217 @@ class DailyReportService:
|
|||||||
empty_text="今日无借库记录",
|
empty_text="今日无借库记录",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ★ 必须显式告知附件的存在与文件名。正文里的数字是**被截断后**的
|
||||||
|
# (每个操作人最多 DETAIL_LIMIT 条、新增只有模块计数),不看附件
|
||||||
|
# 会把摘要误当成全部 —— 这正是「正文摘要 + 附件全量」最容易踩的坑。
|
||||||
lines.append("=" * 60)
|
lines.append("=" * 60)
|
||||||
|
lines.append(
|
||||||
|
f"📎 以上为摘要。全量明细(每条新增/修改/删除记录、全部变更字段、"
|
||||||
|
f"所有出库借库单)见附件:{DailyReportService.excel_filename(date_str)}"
|
||||||
|
)
|
||||||
lines.append("此邮件由 MOM 系统自动发送,请勿回复。")
|
lines.append("此邮件由 MOM 系统自动发送,请勿回复。")
|
||||||
|
|
||||||
subject = f"MOM系统日报 {date_str}"
|
subject = f"MOM系统日报 {date_str}"
|
||||||
return subject, '\n'.join(lines)
|
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}
|
return {'subject': None, 'recipients': [], 'stats': None}
|
||||||
|
|
||||||
subject, body, stats = DailyReportService.build_report(report_date)
|
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}
|
return {'subject': subject, 'recipients': recipients, 'stats': stats}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import os
|
|||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
import logging
|
import logging
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.header import Header
|
from email.header import Header
|
||||||
@ -15,6 +16,39 @@ from typing import List, Union
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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():
|
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:
|
Args:
|
||||||
to_email: 收件人,单个邮箱字符串或列表
|
to_email: 收件人,单个邮箱字符串或列表
|
||||||
subject: 邮件主题
|
subject: 邮件主题
|
||||||
content: 邮件正文(纯文本)
|
content: 邮件正文(纯文本)
|
||||||
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = MIMEMultipart()
|
# ★ 显式 'mixed':虽然 MIMEMultipart() 默认就是 mixed,但正文+附件
|
||||||
|
# 的语义要求就在这里,写出来才不会被后人"顺手"改成 related/alternative。
|
||||||
|
msg = MIMEMultipart('mixed')
|
||||||
msg['From'] = cfg['sender']
|
msg['From'] = cfg['sender']
|
||||||
msg['To'] = ', '.join(recipients)
|
msg['To'] = ', '.join(recipients)
|
||||||
msg['Subject'] = Header(subject, 'utf-8')
|
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['Message-ID'] = make_msgid(domain=(cfg['sender'].split('@')[-1] or None))
|
||||||
msg.attach(MIMEText(content, 'plain', 'utf-8'))
|
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'):
|
if cfg.get('use_ssl'):
|
||||||
context = ssl.create_default_context()
|
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}")
|
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(
|
t = threading.Thread(
|
||||||
target=send_email,
|
target=send_email,
|
||||||
args=(to_email, subject, content),
|
args=(to_email, subject, content),
|
||||||
kwargs={'cfg': cfg},
|
kwargs={'cfg': cfg, 'attachments': attachments},
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
t.start()
|
t.start()
|
||||||
|
|||||||
Reference in New Issue
Block a user