Files
KCGL/inventory-backend/app/api/v1/audit.py
yueli 46aec0b954 feat(audit): 提升可读性 —— 操作对象补全物料名、详情降噪、字段字典补齐
业务方反馈两点:操作对象太干瘪、详情快照噪音太多。

1. 操作对象补全为「SKU - 物料名称 (规格型号)」

   库里 target_name 大多只存了 SKU('0000002270'),入库类甚至存的是内部
   标识('stock_buy ID:1667'),业务人员完全看不懂。

   补全走两条路径,**正确性优先**:
     · 快照里的 base_id(CREATE/DELETE 的库存行)—— 权威,零歧义
     · target_id 唯一命中一张股票表 —— 实测与上一条 1230/1230 完全一致

   ★ 撞号一律放弃:target_id 同时命中 2 张股票表时解出的 base_id
     24/27 是错的、命中 3 张时 44/44 全错。补一个**错的**物料名比不补更糟 ——
     那是"看起来完全可信的错误答案",业务方会照着它去找不相干的物料。
     实测 300 条撞号行 0 条被补,300 条唯一命中行全部补上。

   target_name 原值保留(target_keyword 搜索仍按它匹配),新增 target_display
   供展示。前端去掉灰显的 #target_id —— 业务人员不需要看数据库主键。

2. 详情快照降噪

   · 前端过滤空值:null / '' / [] / {} / '-'。判据**严格**:false 和 0
     不算空(`is_returned: false`、`quantity: 0` 是明确的业务事实,
     用真值判断会把它们一起吃掉,那是在篡改数据)。
   · 过滤纯技术字段:id / created_at / updated_at / target_id / module_name
     及 pgvector 的 `*embedding`(单条可达数 KB)。清单由后端下发
     (labels.hiddenFields),前端不硬编 —— 它会随新表增长,再存一份必然漂移。
     embedding 类按后缀拦截,新表加向量列不必改代码。
   · 变更对比表过滤「等于没改」的行(null ↔ 空串)。

3. 字段字典补齐:168 项

   实测快照里出现过但字典没有的字段全部补上(buyer_email / currency /
   in_date / exchange_rate / dosage / loss_rate / child / parent /
   production_* / return_* / *_threshold 等 60+ 个)。
   现在快照字段缺中文名的数量为 **0** —— 详情页不会再裸露英文。

4. 摘要优化

   · changes_of 丢掉无意义变更(null ↔ 空串)。库里 98 条记录带这种变更,
     写进摘要就是「备注:空→空」,纯噪音还挤占截断长度。
   · CREATE/DELETE 摘要改为核心属性**按槽位取值**:
         新增(入库):入库数量 10 件、库位 ZZTEST
     而不是「新增:SKU、base、状态… 等 29 个字段」。
   · 槽位只有数量与位置两个,**不含物料** —— 物料由「操作对象」列承担,
     同一屏里再来一遍是重复。数量字段也按槽位只取一个:
     in_quantity / stock_quantity / available_quantity 值往往相同,
     取三个会得到三个一样的数字。
   · 数字去掉无意义的 .0(10.0 → 10);单位取自物料主数据,
     纯数字的占位单位(库里有一批 unit='1')丢弃。

验证:33 + 16 项断言全过,含「补全的物料与快照 base_id 零冲突」「撞号行
一律不补」「快照字段缺中文名 0 个」「摘要无空→空」「导出口径与列表一致」;
vue-tsc --noEmit 与 vite build 均 exit=0。
日报回归:三天附件 490.5K / 193.4K / 10.3K,6 列结构未变。
2026-09-28 10:19:58 +08:00

432 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# inventory-backend/app/api/v1/audit.py
import io
from datetime import datetime, timedelta
from flask import Blueprint, current_app, jsonify, request, send_file
from flask_jwt_extended import jwt_required
from sqlalchemy import func, or_
from app.extensions import db
from app.models.audit import AuditLog
from app.models.system import SysUser
from app.services.audit_export_service import (
SYSTEM_USERNAME,
build_audit_workbook,
changes_summary,
format_target_display,
load_material_context,
load_ref_maps,
)
from app.utils.decorators import get_current_company_filter, permission_required
audit_bp = Blueprint('audit', __name__)
# =============================================================================
# 操作类型归一化 / 中文化标签
#
# ★ 映射表已统一收敛到 app/utils/audit_labels.py(**唯一来源**)。
# 本模块与日报服务共用同一份,前端经 GET /audit/labels 拉取同一份 ——
# 改一处即可,不会再出现"两边手工同步、改一边漏一边"的漂移。
#
# 历史问题(迁移前):前端 AuditLog.vue 自带一份 fieldMap,后端这里自带一份
# ACTION_ALIASES,两份副本各自演化。
# =============================================================================
from app.utils.audit_labels import ( # noqa: E402
ACTION_ALIASES,
canon_action,
expand_modules,
labels_payload,
module_options,
)
# 列表页「变更摘要」列的截断长度。太长会把表格撑变形且扫读性差 ——
# 完整逐字段对比在详情抽屉里,摘要只负责"一眼看出改了啥"。
SUMMARY_LIMIT = 120
# 单次导出的记录数上限。
#
# ★ 这是**熔断**,不是"为了让报表好看"的截断:
# · Excel 单表硬上限 1,048,576 行,长表展开后会成倍放大;
# · 同步请求跑太久会超时,用户只看到"失败",不知道是数据量的问题。
# 实测当前全库共 5.8 万条、全量导出 3.3 秒 / 2MB,正常情况下永远碰不到这个值。
# 触顶时会**显式告知**(写进文件名和汇总工作表),不静默丢弃 ——
# 报表的读法就是"看到的就是全部",偷偷砍掉比报错更危险。
EXPORT_LIMIT = 100000
# =============================================================================
# 查询构造 —— /logs 与 /logs/export 的**唯一**入口
# =============================================================================
def _build_audit_query(args):
"""
按请求参数构造审计日志查询,返回 (query, company_limit)。
★ 列表与导出共用本函数。两处各写一份筛选逻辑,迟早会出现
"页面上看到 300 条、导出来 280 条"这种对不上的情况 ——
而报表对不上,比没有报表更糟(人会照着错的数字做决定)。
这正是 audit_labels.py 开头警告过的"两份手工同步的副本必然漂移"。
★ 可多选的参数用逗号分隔(与仓库既有惯例一致:semi.vue 的 statuses
用 join(',') 传参、inbound/product.py 用 split(',') 接收)。
"""
query = AuditLog.query
# ---------- 操作来源:all(默认) / user(仅真实用户) / system(仅系统) ----------
# 背景:改造前的全局监听器没有请求上下文守卫,系统初始化与后台定时任务
# 产生了大量 username='system' 的日志(历史存量约 1.8 万条),会把列表刷屏。
operator_type = (args.get('operator_type') or 'all').strip().lower()
if operator_type not in ('all', 'user', 'system'):
operator_type = 'all'
if operator_type == 'user':
query = query.filter(AuditLog.username != SYSTEM_USERNAME)
elif operator_type == 'system':
query = query.filter(AuditLog.username == SYSTEM_USERNAME)
username = (args.get('username') or '').strip()
if username:
query = query.filter(AuditLog.username.like(f'%{username}%'))
# ---------- 模块(支持多选 + 聚合别名)----------
# ★ 经 expand_modules 展开:「入库(全部)」会变成它名下的全部成员值。
# 这一步是必需的 —— 审计的 module 在 2026-09-10 换过一次口径
# (旧的「入库管理」→ 新的「库存管理」),不展开就会在换口径那天
# 断掉,用户看到"入库记录突然没了"。
# 详见 audit_labels.MODULE_GROUPS 的说明。
modules = [m for m in (args.get('module') or '').split(',') if m.strip()]
if modules:
query = query.filter(AuditLog.module.in_(expand_modules(modules)))
# ---------- 操作类型(支持多选)----------
actions = [a for a in (args.get('action') or '').split(',') if a.strip()]
if actions:
# ★ 逐个归一化再合并别名集合,不能只处理第一个:
# 历史数据里 CREATE 与「新增」混用,多选时若只对首个值展开别名,
# 其余选项就搜不到早期数据(4 月前的中文 action)。
aliases = set()
for a in actions:
canon = canon_action(a)
aliases.update(ACTION_ALIASES.get(canon, (a,)))
query = query.filter(AuditLog.action.in_(aliases))
target_id = (args.get('target_id') or '').strip()
if target_id:
query = query.filter(AuditLog.target_id == target_id)
# ---------- 操作对象模糊搜索 ----------
# 与 target_id 的分工:target_id 是**精确**匹配(程序化调用、追单条记录用),
# target_keyword 是给人在搜索框里用的模糊匹配,同时命中单号/编码/名称。
#
# ★ 用 ILIKE '%kw%' 走不了 btree 索引,会顺序扫描。当前全库 5.8 万条实测
# 毫秒级,可接受;若将来量级上去了,再加 pg_trgm 的 GIN 索引即可
# (不需要改这里的写法)。
target_keyword = (args.get('target_keyword') or '').strip()
if target_keyword:
query = query.filter(or_(
AuditLog.target_id.ilike(f'%{target_keyword}%'),
AuditLog.target_name.ilike(f'%{target_keyword}%'),
))
# ---------- 日期区间 ----------
# 语义是**闭区间**:end_date 当天 23:59:59 的数据也要包含进来,
# 故上界取次日 00:00 的严格小于。写成 <= end_date 会静默丢掉当天。
start_date = (args.get('start_date') or '').strip()
if start_date:
try:
query = query.filter(
AuditLog.created_at >= datetime.strptime(start_date, '%Y-%m-%d'))
except ValueError:
pass
end_date = (args.get('end_date') or '').strip()
if end_date:
try:
query = query.filter(
AuditLog.created_at < datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1))
except ValueError:
pass
# ---------- 【行级数据隔离】----------
# AuditLog 表**没有** company_name 字段,公司信息只能经 SysUser.department 反查。
# 审计日志的 username 存的是账号('gaoxue'),而 sys_user.username 存的是
# '高雪/gaoxue',故取 '/' 之后的账号部分去 join。
#
# ★ 导出接口必须带同样的隔离,否则"导出"就成了绕过数据隔离的后门 ——
# 页面按公司过滤、导出却给全量,这个洞比页面越权更隐蔽。
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.join(
SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username
).filter(SysUser.department == company_limit)
return query, company_limit
def _filter_summary_rows(args):
"""
把本次生效的筛选条件写成汇总表的几行。
★ 收到 Excel 的人看不到页面上的筛选框,没有这段说明就无从判断
"这份表是全量还是某个子集"。
"""
rows = []
operator_type = (args.get('operator_type') or '').strip().lower()
if operator_type:
rows.append(('筛选条件', '操作来源',
{'user': '仅真实用户', 'system': '仅系统操作'}.get(operator_type, '全部')))
labels = [
('username', '操作人(模糊匹配)'),
('module', '模块'),
('action', '操作类型'),
('target_id', '目标ID'),
('target_keyword', '操作对象(模糊匹配)'),
]
for key, label in labels:
val = (args.get(key) or '').strip()
if val:
rows.append(('筛选条件', label, val))
start_date = (args.get('start_date') or '').strip()
end_date = (args.get('end_date') or '').strip()
if start_date or end_date:
rows.append(('筛选条件', '日期区间',
f"{start_date or '不限'} ~ {end_date or '不限'}"))
return rows
def _serialize_logs(rows):
"""
审计记录 → 响应 dict(列表与详情共用)。
在 to_dict() 之上补两件事:
★ **action 归一化**。历史数据里 CREATE 与「新增」混用(早期装饰器写中文,
现行监听器写英文)。归一后前端不必再为每种历史写法兜底 —— 否则每个
新消费者都要记得再兜一次,漏一个就静默显示英文/中文原值。
归一用 audit_labels.canon_action(**唯一来源**,覆盖全部历史别名),
不在模型上另建一份映射:那份只认识 3 个中文词,`批量删除`/`分配`/`归还`
这类别名会漏掉。
★ **变更摘要**。这里算而不是做成模型 @property:摘要要把 user_id / base_id
翻成人名 / 物料名,需要查库;模型属性里发查询就是 N+1。
改为整页一次性批量解析(load_ref_maps 按 ref 类型各查一次),
50 条的页面对应 2~3 次查询,与逐行查库是两回事。
"""
if not rows:
return []
ref_maps = load_ref_maps(rows)
# ★ 操作对象补全:库里 target_name 大多只存了 SKU('0000002270')甚至内部
# 标识('stock_buy ID:1667'),业务人员看不懂。补成
# 「SKU - 物料名称 (规格型号)」;解不出物料的行不补,前端回落原始值。
# 见 load_material_context —— 撞号的 target_id 一律放弃,宁可留空不补错。
materials = load_material_context(rows)
out = []
for r in rows:
d = r.to_dict()
d['action'] = canon_action(d.get('action'))
mat = materials.get(r.id)
# 保留原始 target_name(搜索仍按它匹配),展示用 target_display
d['target_display'] = format_target_display(mat)
d['summary'] = changes_summary(
r, ref_maps, limit=SUMMARY_LIMIT, material=mat)
out.append(d)
return out
def _distinct_with_company(column, company_limit):
"""
取某列的去重值(带公司隔离)。
★ 下拉选项也要隔离:不带的话,A 公司的用户在筛选框里能看到 B 公司
才有的模块名,属于低危但确实存在的信息泄露。
"""
q = db.session.query(column).distinct()
if company_limit is not None:
q = q.join(SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username) \
.filter(SysUser.department == company_limit)
return [v[0] for v in q.all() if v[0]]
# =============================================================================
# 接口
# =============================================================================
@audit_bp.route('/logs', methods=['GET'])
@jwt_required()
@permission_required('system_audit')
def get_audit_logs():
"""获取审计日志列表(分页)"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 50, type=int)
query, company_limit = _build_audit_query(request.args)
# 排序
query = query.order_by(AuditLog.created_at.desc())
# 分页
pagination = query.paginate(page=page, per_page=page_size, error_out=False)
data = _serialize_logs(pagination.items)
# 获取可用的模块和操作类型(同公司范围内)
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
actions_raw = _distinct_with_company(AuditLog.action, company_limit)
# ★ action 下拉项归一化:把 CREATE/create/新增 等别名合并为一个规范值,
# 避免下拉框出现「CREATE」与「新增」两个语义重复的选项。
actions = sorted({canon_action(a) for a in actions_raw})
return jsonify({
'code': 200,
'msg': '获取成功',
'data': {
'list': data,
'total': pagination.total,
'page': page,
'pageSize': page_size,
# ★ [{value,label}],历史命名已折叠进聚合项且不再单独列出 ——
# 规则由后端 module_options() 统一决定,前端零硬编。
'modules': module_options(raw_modules),
'actions': actions
}
}), 200
except Exception as e:
current_app.logger.error(f"获取审计日志失败: {str(e)}")
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
@audit_bp.route('/logs/export', methods=['GET'])
@jwt_required()
@permission_required('system_audit')
def export_audit_logs():
"""
按筛选条件导出审计日志为 Excel(同步返回文件流)。
★ 筛选条件与 GET /logs **完全一致**(共用 _build_audit_query),
且**忽略分页参数** —— 导的是符合条件的全量,不是当前页。
「只导当前页 50 条」是导出功能最常见的误解,故这里明确不支持。
★ 为什么同步返回而不是走 export_service 那套异步任务:
实测全库 5.8 万条导出耗时 3.3 秒、2MB,同步完全够用;
而异步那套要落盘(uploads/exports/)且**没有清理机制**,
日积月累会把磁盘吃掉 —— 见 export_service/excel_task.py。
"""
try:
query, _ = _build_audit_query(request.args)
# 台账按时间倒序:导出的人多半想看"最近的"(与列表页一致)
query = query.order_by(AuditLog.created_at.desc())
total = query.count()
truncated = total > EXPORT_LIMIT
rows = query.limit(EXPORT_LIMIT).all() if truncated else query.all()
note = None
if truncated:
note = (f"匹配记录共 {total} 条,超过单次导出上限 {EXPORT_LIMIT} 条,"
f"本次仅导出前 {EXPORT_LIMIT} 条。请缩小日期范围后分批导出。")
data = build_audit_workbook(
rows,
summary_rows=_filter_summary_rows(request.args),
note=note,
)
stamp = datetime.now().strftime('%Y%m%d_%H%M%S')
# 文件名里带上「部分」—— 用户不解压也能看出这不是全量
name = (f"审计日志_部分{EXPORT_LIMIT}条_{stamp}.xlsx" if truncated
else f"审计日志_{stamp}.xlsx")
return send_file(
io.BytesIO(data),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=name,
)
except Exception as e:
current_app.logger.error(f"导出审计日志失败: {str(e)}")
return jsonify({'code': 500, 'msg': f'导出失败: {str(e)}'}), 500
@audit_bp.route('/logs/<int:log_id>', methods=['GET'])
@jwt_required()
@permission_required('system_audit')
def get_audit_log_detail(log_id):
"""
获取单条审计日志详情。
★ 补权限码与公司隔离。此前这里**只有 @jwt_required()**,任何登录用户
改一下 URL 里的 id 就能读到全部审计明细(含 details 里的完整快照,
那是被操作记录的原始字段值)。列表接口有 system_audit 把关,
详情接口提供的信息是列表的超集,没道理比列表更宽松。
★ 越权与不存在**统一返回 404**,不区分二者 —— 区分开来就等于告诉
探测者"这个 id 是存在的,只是你没权限",反而泄露了数据规模。
"""
try:
query = AuditLog.query.filter(AuditLog.id == log_id)
# 与列表用同一个公司判据(SysUser.department 反查),保证两处一致:
# 列表里看不到的记录,详情也不该看得到。
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.join(
SysUser, func.split_part(SysUser.username, '/', 2) == AuditLog.username
).filter(SysUser.department == company_limit)
log = query.first()
if not log:
return jsonify({'code': 404, 'msg': '日志不存在'}), 404
return jsonify({
'code': 200,
'msg': '获取成功',
# 与列表同一套序列化:action 归一 + 摘要(详情页也用得上同一条摘要)
'data': _serialize_logs([log])[0]
}), 200
except Exception as e:
current_app.logger.error(f"获取审计日志详情失败: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500
@audit_bp.route('/modules', methods=['GET'])
@jwt_required()
def get_modules():
"""
获取模块下拉选项(用于筛选)。
返回 [{value, label}] 而不是裸的 module 字符串 —— 历史命名已被折叠进
「入库」聚合项,规则由 audit_labels.module_options() 统一决定。
★ 只挂 @jwt_required()、不加权限码:这里返回的是模块**名称**(不含任何
业务记录),且已做公司隔离;审计页本身由 /logs 的权限码把关,
与 /labels 的处理一致。
"""
try:
company_limit = get_current_company_filter()
raw_modules = _distinct_with_company(AuditLog.module, company_limit)
return jsonify({'code': 200, 'data': module_options(raw_modules)}), 200
except Exception as e:
current_app.logger.error(f"获取模块列表失败: {str(e)}")
return jsonify({'code': 500, 'msg': str(e)}), 500
@audit_bp.route('/labels', methods=['GET'])
@jwt_required()
def get_labels():
"""
下发操作类型与字段名的中文映射。
★ 后端是**唯一来源**,前端不再自带副本 —— 否则两份手工同步的映射必然漂移。
前端拉到之前(或拉取失败时)对未命中字段原样显示字段名,
是可控的降级,不会让页面崩掉。
免权限码:纯静态标签,不含任何业务数据;审计页本身已由
@permission_required('system_audit') 把关(见 /logs)。
"""
return jsonify({'code': 200, 'data': labels_payload()}), 200