Files
KCGL/inventory-backend/app/api/v1/inbound/base.py
yueli 4808a48594 refactor(audit): 审计架构清理——复活白名单监听器、停用噪声监听器、清除僵尸装饰器
一、统一为单一监听器实现
  原先两套 SQLAlchemy 事件监听器并存:
    · app/utils/audit_events.py   —— 全局监听 db.Model、无白名单、无请求上下文守卫(实际在跑)
    · app/core/audit_listener.py  —— 白名单制、有守卫、有模型级开关(从未生效)
  后者失效的根因:注册代码写在 extensions.py 的 init_extensions() 内,
  而该函数全仓库只有定义、没有任何调用(create_app 直接内联调用 db.init_app 等)。

  现统一由 app/core/audit_listener.py 承担,并在 create_app() 中显式注册。
  extensions.py 的死函数 init_extensions 整体删除,避免后人误以为它是有效入口。

二、修复监听器三处致命缺陷(此前注册了也写不进数据)
  1. 事件回调第二个参数是 Connection,原代码却调用 Connection.add()(不存在),
     每次写日志都抛 AttributeError 并被 except 吞掉 → 改为 connection.execute()
  2. register_audit_listeners 从 app.models 批量 import 多个未导出的模型,
     ImportError 被上层 try/except 吞掉 → 改为按表名从 db.metadata 取模型
  3. 本项目有 31 处函数体内延迟导入模型(如 scrap.py 内部才 import ScrapApproval),
     一次性注册会静默漏表 → 增加 ensure_audit_listeners() 惰性补绑,
     并在模型预加载段补全审批单/BOM/采购等模型

三、强约束
  · WHITELIST_TABLES:仅 18 张核心业务表,系统表/草稿表/向量表不再自审
  · has_request_context() 守卫:系统初始化与后台定时任务不再产生 username=system 噪声
  · IGNORE_FIELDS 增加 password/password_hash/salt/token/secret/api_key(安全红线)
  · created_at 显式写 beijing_time(),与全系统时间口径一致

四、清除僵尸装饰器
  @audit_log 早已退化为直接透传的空壳(module/action 参数全被忽略,
  数据库中零星的中文 action 即其历史遗留产物),却仍挂在 38 处路由上。
  连同 13 个文件的 import 一并移除;audit_events.register_audit_events 改为空操作。

验证:应用上下文中的写操作不产生日志;HTTP 请求产生 5 条日志,
对象为业务单号(APR-SCRAP-... / SKU),模块中文,操作人真实,时间为北京时间。
2026-09-10 14:16:27 +08:00

580 lines
24 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.

# 文件路径: app/api/v1/inbound/base.py
from flask import Blueprint, request, jsonify, send_file, g, current_app
from app.extensions import db, beijing_time
from app.services.inbound.base_service import MaterialBaseService
from app.utils.decorators import login_required, permission_required
from app.models.base import MaterialBase, MaterialWarningSetting
import traceback
import datetime
import json
inbound_base_bp = Blueprint('stock_base', __name__)
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色+公司查询数据库得到权限。
"""
from flask_jwt_extended import get_jwt
from app.services.auth_service import AuthService
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return []
# 超级管理员返回所有字段权限
from app.utils.constants import UserRole
if str(user_role).strip().upper() == UserRole.SUPER_ADMIN:
return [
'material_list:*',
'material_list:id',
'material_list:companyName',
'material_list:name',
'material_list:commonName',
'material_list:category',
'material_list:type',
'material_list:spec',
'material_list:unit',
'material_list:inventoryCount',
'material_list:availableCount',
'material_list:files',
'material_list:isEnabled',
'material_list:referencePrice',
'material_list:operation'
]
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def _invalidate_specs_cache():
"""规格连号缓存失效(新增/修改/删除基础信息时调用)"""
try:
from app.extensions import redis_client
if redis_client:
redis_client.delete('inventory:specs:grouped')
except Exception:
pass
def filter_item_by_permissions(item_dict, user_permissions):
"""严格 Default Deny 字段过滤 (see app/utils/field_permissions.py)"""
from app.utils.field_permissions import apply_strict_rbac
return apply_strict_rbac(item_dict, 'MaterialBase', user_permissions)
# ==============================================================================
# 1. 搜索接口 (GET /api/v1/inbound/base/search)
# ==============================================================================
@inbound_base_bp.route('/search', methods=['GET'])
@permission_required('material_list')
def search_base():
try:
keyword = request.args.get('keyword', '')
data = MaterialBaseService.search_material(keyword)
# 字段级脱敏
user_permissions = get_current_user_permissions()
filtered_data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({"code": 200, "msg": "success", "data": filtered_data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 1.1 计量单位字典接口 (GET /api/v1/inbound/base/units)
# ==============================================================================
@inbound_base_bp.route('/units', methods=['GET'])
@permission_required('material_list')
def get_unit_dict():
"""
获取所有已存在的非空计量单位(去重 + 排序),用于前端
新增/编辑弹窗中"计量单位"下拉框的历史记录。
"""
try:
units = MaterialBaseService.get_distinct_units()
return jsonify({"code": 200, "msg": "success", "data": units})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 2. 列表接口 (GET /api/v1/inbound/base/list)
# ==============================================================================
@inbound_base_bp.route('/list', methods=['GET'])
@permission_required('material_list')
def get_list():
try:
page = request.args.get('pageNum', 1, type=int)
limit = request.args.get('pageSize', 10, type=int)
# 解析高级筛选条件
advanced_filters_raw = request.args.get('advancedFilters', '[]')
try:
advanced_filters_list = json.loads(advanced_filters_raw)
except:
advanced_filters_list = []
# 构造筛选条件
filters = {
'keyword': request.args.get('keyword', ''),
'company': request.args.get('company', ''),
'category': request.args.get('category', ''),
'type': request.args.get('type', ''),
'isEnabled': request.args.get('isEnabled', None),
'orderByColumn': request.args.get('orderByColumn', ''),
'isAsc': request.args.get('isAsc', None),
'advancedFilters': advanced_filters_list,
'enableWarningSort': request.args.get('enableWarningSort', 'false').lower() == 'true',
'has_stock': request.args.get('has_stock', ''),
'searchField': request.args.get('searchField', 'all')
}
user_permissions = get_current_user_permissions()
# 自动拦截:如果用户有预警查看权限,且当前没有按特定列手动排序,则强制开启预警智能排序
has_warning_perm = 'material_list:view_warning' in user_permissions
if has_warning_perm and not filters.get('orderByColumn'):
filters['enableWarningSort'] = True
result = MaterialBaseService.get_list(page, limit, filters, user_permissions)
# 字段级脱敏
user_permissions = get_current_user_permissions()
if result.get('items'):
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 1.4 Odoo 分组摘要接口 (GET /api/v1/inbound/base/odoo-summary)
# 极轻量查询:仅 GROUP BY category + COUNT,不 JOIN 任何库存表
# ==============================================================================
@inbound_base_bp.route('/odoo-summary', methods=['GET'])
@permission_required('material_list')
def get_odoo_summary():
try:
keyword = request.args.get('keyword', '').strip() or None
is_enabled_raw = request.args.get('isEnabled', None)
is_enabled = None
if is_enabled_raw is not None:
val = str(is_enabled_raw).lower()
if val in ('1', 'true', 'yes', 't'):
is_enabled = True
elif val in ('0', 'false', 'no', 'f'):
is_enabled = False
data = MaterialBaseService.get_odoo_summary(keyword=keyword, is_enabled=is_enabled)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 2.1 选项接口 (GET /api/v1/inbound/base/options)
# ==============================================================================
@inbound_base_bp.route('/options', methods=['GET'])
@permission_required('material_list')
def get_options():
try:
data = MaterialBaseService.get_distinct_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 2.2 导出接口 (GET /api/v1/inbound/base/export)
# ==============================================================================
@inbound_base_bp.route('/export', methods=['GET'])
@permission_required('material_list')
def export_data():
try:
# 获取筛选条件
filters = {
'keyword': request.args.get('keyword', ''),
'company': request.args.get('company', ''),
'category': request.args.get('category', ''),
'type': request.args.get('type', ''),
'isEnabled': request.args.get('isEnabled', None)
}
# 获取当前用户权限
user_permissions = get_current_user_permissions()
# 生成 Excel 文件流(传入用户权限进行脱敏)
file_stream = MaterialBaseService.export_excel(filters, user_permissions)
# 生成文件名:库存统计+年月日+时分秒 (北京时间 UTC+8)
bj_time = beijing_time()
filename = f"库存统计_{bj_time.strftime('%Y%m%d_%H%M%S')}.xlsx"
# 发送文件
# 注意:download_name 仅在较新 Flask 版本有效,旧版本可能需要手动 header,
# 但通常浏览器下载名由前端 Blob 处理或 Content-Disposition 决定。
return send_file(
file_stream,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": f"导出失败: {str(e)}"}), 500
# ==============================================================================
# 3. 新增接口 (POST /api/v1/inbound/base/)
# ==============================================================================
@inbound_base_bp.route('/', methods=['POST'])
@permission_required('material_list:operation')
def create():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
# 获取当前用户权限
user_permissions = get_current_user_permissions()
# 字段到权限码的映射(与 filter_item_by_permissions 一致)
field_to_perm = {
'id': 'material_list:id',
'companyName': 'material_list:companyName',
'name': 'material_list:name',
'commonName': 'material_list:commonName',
'category': 'material_list:category',
'type': 'material_list:type',
'spec': 'material_list:spec',
'unit': 'material_list:unit',
'inventoryCount': 'material_list:inventoryCount',
'availableCount': 'material_list:availableCount',
'generalManual': 'material_list:files',
'generalImage': 'material_list:files',
'referencePrice': 'material_list:referencePrice',
'isEnabled': 'material_list:isEnabled'
}
# 过滤用户没有权限的字段
filtered_data = {}
# 如果拥有通配符权限,则不过滤
if 'material_list:*' in user_permissions:
filtered_data = data
else:
for key, value in data.items():
if key in field_to_perm:
perm_code = field_to_perm[key]
if perm_code in user_permissions:
filtered_data[key] = value
# 没有权限则跳过,不包含在 filtered_data 中
else:
# 不在映射中的字段,默认允许(例如 visibilityLevel)
filtered_data[key] = value
MaterialBaseService.create_material(filtered_data)
_invalidate_specs_cache()
return jsonify({"code": 200, "msg": "新增成功"})
except ValueError as e:
# 捕获业务逻辑验证错误 (如名称为空)
return jsonify({"code": 400, "msg": str(e)}), 400
except Exception as e:
# 捕获系统错误
traceback.print_exc()
return jsonify({"code": 500, "msg": f"系统错误: {str(e)}"}), 500
# ==============================================================================
# 4. 修改接口 (PUT /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['PUT'])
@permission_required('material_list:operation')
def update(id):
try:
data = request.get_json()
# 获取当前用户权限
user_permissions = get_current_user_permissions()
# 字段到权限码的映射(与 filter_item_by_permissions 一致)
field_to_perm = {
'id': 'material_list:id',
'companyName': 'material_list:companyName',
'name': 'material_list:name',
'commonName': 'material_list:commonName',
'category': 'material_list:category',
'type': 'material_list:type',
'spec': 'material_list:spec',
'unit': 'material_list:unit',
'inventoryCount': 'material_list:inventoryCount',
'availableCount': 'material_list:availableCount',
'generalManual': 'material_list:files',
'generalImage': 'material_list:files',
'referencePrice': 'material_list:referencePrice',
'isEnabled': 'material_list:isEnabled'
}
# 过滤用户没有权限的字段
filtered_data = {}
# 如果拥有通配符权限,则不过滤
if 'material_list:*' in user_permissions:
filtered_data = data
else:
for key, value in data.items():
if key in field_to_perm:
perm_code = field_to_perm[key]
if perm_code in user_permissions:
filtered_data[key] = value
# 没有权限则跳过,不包含在 filtered_data 中
else:
# 不在映射中的字段,默认允许(例如 visibilityLevel)
filtered_data[key] = value
# 使用过滤后的数据调用服务
MaterialBaseService.update_material(id, filtered_data)
_invalidate_specs_cache()
return jsonify({"code": 200, "msg": "修改成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 5. 删除接口 (DELETE /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('material_list:operation')
def delete(id):
try:
material_name = MaterialBaseService.delete_material(id)
_invalidate_specs_cache()
return jsonify({"code": 200, "msg": "删除成功", "material_name": material_name})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 2.5 批量设置预警 API (POST /api/v1/inbound/base/warning/batch-set)
# ==============================================================================
@inbound_base_bp.route('/warning/batch-set', methods=['POST'])
@permission_required('material_list:edit_warning')
def batch_set_warning():
"""
批量设置物料预警配置
请求体格式: [
{"baseId": 1, "isEnabled": true, "yellowThreshold": 10, "redThreshold": 5},
{"baseId": 2, "isEnabled": false}
]
"""
try:
data = request.get_json()
if not isinstance(data, list):
return jsonify({"code": 400, "msg": "请求体必须为数组"})
updated_count = 0
created_count = 0
for item in data:
base_id = item.get('baseId')
if not base_id:
continue
# 查找物料是否存在
material = MaterialBase.query.get(base_id)
if not material:
current_app.logger.warning(f"物料ID {base_id} 不存在,跳过")
continue
# 查找现有预警设置
warning = MaterialWarningSetting.query.filter_by(base_id=base_id).first()
if warning:
# 更新现有记录
if 'isEnabled' in item:
warning.is_enabled = bool(item['isEnabled'])
# 安全转换阈值,None 默认转为 0
yellow_val = item.get('yellowThreshold')
red_val = item.get('redThreshold')
warning.yellow_threshold = float(yellow_val) if yellow_val is not None else 0
warning.red_threshold = float(red_val) if red_val is not None else 0
warning.yellow_emails = item.get('yellowEmails', warning.yellow_emails)
warning.red_emails = item.get('redEmails', warning.red_emails)
updated_count += 1
else:
# 创建新记录
yellow_val = item.get('yellowThreshold')
red_val = item.get('redThreshold')
warning = MaterialWarningSetting(
base_id=base_id,
is_enabled=item.get('isEnabled', False),
yellow_threshold=float(yellow_val) if yellow_val is not None else 0,
red_threshold=float(red_val) if red_val is not None else 0,
yellow_emails=item.get('yellowEmails', ''),
red_emails=item.get('redEmails', '')
)
db.session.add(warning)
created_count += 1
db.session.commit()
return jsonify({
"code": 200,
"msg": "批量设置成功",
"data": {
"created": created_count,
"updated": updated_count
}
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量设置预警失败: {str(e)}")
return jsonify({"code": 500, "msg": f"批量设置预警失败: {str(e)}"}), 500
# ==============================================================================
# 2.6 标记已采购 API (POST /api/v1/inbound/base/warning/mark-ordered)
# ==============================================================================
@inbound_base_bp.route('/warning/mark-ordered', methods=['POST'])
@permission_required('material_list:edit_warning')
def mark_warning_ordered():
"""
前端标记预警物料已处理采购(标记 is_ordered)
请求体格式: {"baseId": 123, "isOrdered": true}
"""
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
base_id = data.get('baseId')
if not base_id:
return jsonify({"code": 400, "msg": "baseId 不能为空"}), 400
is_ordered = bool(data.get('isOrdered', False))
warning = MaterialWarningSetting.query.filter_by(base_id=base_id).first()
if not warning:
return jsonify({"code": 404, "msg": f"物料ID {base_id} 的预警配置不存在"}), 404
warning.is_ordered = is_ordered
db.session.commit()
status_text = "已标记为已采购" if is_ordered else "已重置为未采购"
return jsonify({
"code": 200,
"msg": status_text,
"data": warning.to_dict()
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"标记已采购失败: {str(e)}")
return jsonify({"code": 500, "msg": f"标记已采购失败: {str(e)}"}), 500
# ==============================================================================
# 2.7 批量设置强制质检 API (POST /api/v1/inbound/base/batch-inspection)
# ==============================================================================
@inbound_base_bp.route('/batch-inspection', methods=['POST'])
@permission_required('material_list:operation')
def batch_set_inspection():
"""
批量设置物料强制质检标记
请求体格式: {
"ids": [1, 2, 3],
"isInspectionRequired": true
}
"""
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
ids = data.get('ids', [])
is_inspection_required = bool(data.get('isInspectionRequired', False))
if not ids:
return jsonify({"code": 400, "msg": "请选择要设置的物料"}), 400
updated_count = 0
for base_id in ids:
material = MaterialBase.query.get(base_id)
if material:
material.is_inspection_required = is_inspection_required
updated_count += 1
db.session.commit()
return jsonify({
"code": 200,
"msg": f"批量设置成功,已更新 {updated_count} 条记录",
"data": {
"updated": updated_count
}
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量设置强制质检失败: {str(e)}")
return jsonify({"code": 500, "msg": f"批量设置强制质检失败: {str(e)}"}), 500
# ==============================================================================
# 2.8 批量设置“出库/借库需审批” (POST /api/v1/inbound/base/batch-approval)
# ==============================================================================
@inbound_base_bp.route('/batch-approval', methods=['POST'])
@permission_required('material_list:isApprovalRequired')
def batch_set_approval_required():
"""
批量设置物料“出库/借库需审批”标记(仿强制质检批量接口)。
请求体: { "ids": [1,2,3], "isApprovalRequired": true }
"""
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
ids = data.get('ids', [])
is_approval_required = bool(data.get('isApprovalRequired', False))
if not ids:
return jsonify({"code": 400, "msg": "请选择要设置的物料"}), 400
updated_count = 0
for base_id in ids:
material = MaterialBase.query.get(base_id)
if material:
material.is_approval_required = is_approval_required
updated_count += 1
db.session.commit()
return jsonify({
"code": 200,
"msg": f"批量设置成功,已更新 {updated_count} 条记录",
"data": {"updated": updated_count}
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量设置需审批失败: {str(e)}")
return jsonify({"code": 500, "msg": f"批量设置需审批失败: {str(e)}"}), 500
# ==============================================================================
# 2.7 智能分组求最大连号 API (GET /api/v1/inbound/base/spec-latest)
# ==============================================================================
@inbound_base_bp.route('/spec-latest', methods=['GET'])
@permission_required('material_list')
def get_spec_latest():
"""
获取所有规格型号的最大连号,按智能分组返回
返回格式: [{"group": "S", "latest": "S0115/S0115"}, {"group": "Opt4xxx", "latest": "Opt4018/Opt4018"}, ...]
"""
try:
data = MaterialBaseService.get_latest_specs()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500