Files
KCGL/inventory-backend/app/api/v1/bom.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

573 lines
22 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.

from flask import Blueprint, request, jsonify, current_app
from sqlalchemy import or_
from app.services.bom_service import BomService, _cache_delete
from app.services.bom_draft_service import BomDraftService
from app.models.base import MaterialBase
from app.models.bom import BomTable
from app.extensions import db
from flask_jwt_extended import jwt_required, get_jwt
from app.utils.decorators import permission_required
from app.services.auth_service import AuthService
bom_bp = Blueprint('bom', __name__)
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
def get_current_user_permissions():
"""
返回当前用户拥有的所有权限码列表(包括菜单和元素)
此函数根据角色查询数据库得到权限。
"""
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['bom_manage:*']
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
# 合并菜单和元素权限
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return perms
def filter_item_by_permissions(item_dict, user_permissions):
"""
根据用户权限过滤 item 字典,无权限的字段值置为 None
"""
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
field_to_perm = {
'bom_no': 'bom_manage:bom_no',
'parent_name': 'bom_manage:parent_name',
'parent_spec': 'bom_manage:parent_spec',
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'child_count': 'bom_manage:child_count',
}
# 如果用户是超级管理员且有 'bom_manage:*',则不过滤
if 'bom_manage:*' in user_permissions:
return item_dict
for field, perm_code in field_to_perm.items():
if field in item_dict and perm_code not in user_permissions:
item_dict[field] = None
return item_dict
# ==================== 新版 BOM 接口(基于 bom_no) ====================
@bom_bp.route('/list', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_list():
"""获取 BOM 列表,支持 keyword、active_only、category 过滤和分页"""
try:
keyword = request.args.get('keyword', '').strip()
active_only = request.args.get('active_only', 'false').lower() == 'true'
status = request.args.get('status', '').strip() or None
category = request.args.get('category', '').strip() or None
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
data = BomService.get_bom_list(
keyword=keyword, active_only=active_only,
category=category, page=page, limit=limit, status=status
)
# 字段级脱敏(data 现在是 {items, total, pages, current_page} 字典)
user_permissions = get_current_user_permissions()
if data.get('items'):
data['items'] = [filter_item_by_permissions(item, user_permissions) for item in data['items']]
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==============================================================================
# BOM 分组摘要接口 (GET /api/v1/bom/summary)
# 极轻量查询:仅 GROUP BY category + COUNT(DISTINCT bom_no+version)
# ==============================================================================
@bom_bp.route('/summary', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_summary():
try:
keyword = request.args.get('keyword', '').strip() or None
status = request.args.get('status', '').strip() or None
data = BomService.get_bom_summary(keyword=keyword, status=status)
return jsonify({'code': 200, 'msg': 'success', 'data': data})
except Exception as e:
current_app.logger.error(f'获取BOM摘要失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== BOM 独立启停(仅更新状态,不触发整表保存) ====================
@bom_bp.route('/status', methods=['POST'])
@jwt_required()
@permission_required('bom_manage:operation')
def update_bom_status():
"""仅切换某 BOM 版本(整组)的启用/停用状态。"""
try:
data = request.get_json() or {}
bom_no = data.get('bom_no')
version = data.get('version')
is_enabled = data.get('is_enabled')
if not bom_no or not version:
return jsonify({'code': 400, 'msg': 'bom_no 与 version 不能为空'}), 400
if not isinstance(is_enabled, bool):
return jsonify({'code': 400, 'msg': 'is_enabled 必须为布尔值'}), 400
BomService.update_enabled(bom_no, version, is_enabled)
return jsonify({'code': 200, 'msg': '更新成功'})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
current_app.logger.error(f'更新BOM状态失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== BOM 归档/取消归档(仍启用可编辑,但不作为其它 BOM 子件引用候选) ====================
@bom_bp.route('/archive', methods=['POST'])
@jwt_required()
@permission_required('bom_manage:operation')
def update_bom_archive():
"""切换某 BOM 版本(整组)的归档状态。"""
try:
data = request.get_json() or {}
bom_no = data.get('bom_no')
version = data.get('version')
is_archived = data.get('is_archived')
if not bom_no or not version:
return jsonify({'code': 400, 'msg': 'bom_no 与 version 不能为空'}), 400
if not isinstance(is_archived, bool):
return jsonify({'code': 400, 'msg': 'is_archived 必须为布尔值'}), 400
BomService.update_archived(bom_no, version, is_archived)
return jsonify({'code': 200, 'msg': '更新成功'})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
current_app.logger.error(f'更新BOM归档状态失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/detail/<path:bom_no>', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_detail(bom_no):
"""
根据 BOM 编号获取配方详情
Query参数: ?version=V1.0 (如果不传则取最新)
"""
try:
version = request.args.get('version')
data = BomService.get_bom_detail(bom_no, version=version)
if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM详情失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/save', methods=['POST'])
@jwt_required()
@permission_required('bom_manage:operation')
def save_bom():
"""保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)"""
try:
req_data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'bom_manage:*' not in user_permissions:
# 字段名到权限码的映射
field_to_perm = {
'parent_id': 'bom_manage:parent_id',
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'bom_no': 'bom_manage:bom_no',
}
# 清洗顶级字段
for field in list(req_data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
req_data.pop(field, None)
# 清洗 children 中的字段
if 'children' in req_data and isinstance(req_data['children'], list):
for child in req_data['children']:
# 子件字段映射
child_field_to_perm = {
'child_id': 'bom_manage:child_id',
'dosage': 'bom_manage:dosage',
'remark': 'bom_manage:remark',
}
for field in list(child.keys()):
perm_code = child_field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
child.pop(field, None)
# 必需字段校验
if 'parent_id' not in req_data or 'children' not in req_data:
return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400
# 校验 bom_no 不能为空
if 'bom_no' in req_data and not req_data['bom_no']:
return jsonify({'code': 400, 'msg': 'BOM编号不能为空'}), 400
bom_no = BomService.save_bom(req_data)
return jsonify({
'code': 200,
'msg': '保存成功',
'data': {'bom_no': bom_no}
})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
current_app.logger.error(f'保存BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/stock/<path:bom_no>', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_with_stock_by_no(bom_no):
"""根据 BOM 编号 (和可选 version) 获取配方详情及库存信息"""
try:
version = request.args.get('version')
data = BomService.get_bom_with_stock_by_bom_no(bom_no, version=version)
if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM库存信息失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== 删除BOM接口 ====================
@bom_bp.route('/<path:bom_no>', methods=['DELETE'])
@jwt_required()
@permission_required('bom_manage:operation')
def delete_bom(bom_no):
"""
根据 BOM 编号删除
Query参数: ?version=V1.0 (如果不传,删除该编号下所有版本)
"""
try:
version = request.args.get('version')
query = BomTable.query.filter_by(bom_no=bom_no)
if version:
query = query.filter_by(version=version)
# 【核心修复】:使用 .all() 查出该 BOM 版本下的所有子件记录
records = query.all()
if not records:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 循环删除所有关联记录(逐个 delete 可触发 SQLAlchemy 监听器记录审计日志)
for rec in records:
db.session.delete(rec)
db.session.commit()
# ===== 删除成功后立刻清除缓存(Cache Invalidation) =====
_cache_delete(bom_no, version)
current_app.logger.info(f"[BOM Cache] delete_bom → 缓存已失效 bom_no={bom_no} version={version}")
return jsonify({
'code': 200,
'msg': '删除成功',
'bom_no': bom_no
})
except Exception as e:
current_app.logger.error(f'删除BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== 兼容旧接口 ====================
@bom_bp.route('/<int:parent_id>', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom(parent_id):
try:
data = BomService.get_bom_with_stock(parent_id)
# 字段级脱敏
user_permissions = get_current_user_permissions()
data = filter_item_by_permissions(data, user_permissions)
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('', methods=['POST'])
@jwt_required()
@permission_required('bom_manage:operation')
def save_bom_legacy():
try:
req_data = request.get_json()
# 数据清洗:移除用户没有权限的字段
user_permissions = get_current_user_permissions()
# 超级管理员不过滤
if 'bom_manage:*' not in user_permissions:
# 字段名到权限码的映射
field_to_perm = {
'parent_id': 'bom_manage:parent_id',
'version': 'bom_manage:version',
'is_enabled': 'bom_manage:status',
'bom_no': 'bom_manage:bom_no',
}
# 清洗顶级字段
for field in list(req_data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
req_data.pop(field, None)
# 清洗 children 中的字段
if 'children' in req_data and isinstance(req_data['children'], list):
for child in req_data['children']:
# 子件字段映射
child_field_to_perm = {
'child_id': 'bom_manage:child_id',
'dosage': 'bom_manage:dosage',
'remark': 'bom_manage:remark',
}
for field in list(child.keys()):
perm_code = child_field_to_perm.get(field)
if perm_code and perm_code not in user_permissions:
child.pop(field, None)
parent_id = req_data.get('parent_id')
child_list = req_data.get('children', [])
if not parent_id or not isinstance(child_list, list):
return jsonify({'code': 400, 'msg': '参数错误'}), 400
BomService.create_or_update_bom(parent_id, child_list)
return jsonify({
'code': 200,
'msg': '保存成功'
})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
current_app.logger.error(f'保存BOM失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/base/list', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_material_base_list():
"""获取基础物料列表,支持分页和关键字搜索,用于前端下拉框"""
try:
# 获取分页和搜索参数
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 20))
keyword = request.args.get('keyword', '').strip()
# 构建查询条件
query = MaterialBase.query.filter_by(is_enabled=True)
# ★ 行级公司隔离:普通用户只能选本公司的物料作为 BOM 子件(超管/跨域不受限)
from app.utils.decorators import get_current_company_filter
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
# 添加关键字模糊搜索
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f"%{keyword}%"),
MaterialBase.spec_model.ilike(f"%{keyword}%")
)
)
# 执行分页查询
pagination = query.order_by(MaterialBase.id.desc()).paginate(
page=page, per_page=limit, error_out=False
)
# 构建返回数据 — ★ Fail-Closed: 剥离 referencePrice
items = []
for item in pagination.items:
d = item.to_dict()
d.pop('referencePrice', None)
items.append(d)
data = {
'list': items,
'total': pagination.total
}
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取基础物料列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/self-bom-versions', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_child_bom_versions():
"""
获取某物料作为父件时的"启用配方版本清单"(bom_no, version)。
前端在编辑/新建 BOM、选中自制件子件后调用,用于生成必选版本下拉。
Query参数: child_id (必填, 物料ID)
返回 data: [{bom_no, version}, ...];空数组 = 该物料非自制件(无现行 BOM),无需选版本。
"""
try:
child_id = request.args.get('child_id', type=int)
if not child_id:
return jsonify({'code': 400, 'msg': 'child_id 不能为空'}), 400
data = BomService.get_child_bom_versions(child_id)
return jsonify({'code': 200, 'msg': 'success', 'data': data})
except Exception as e:
current_app.logger.error(f'获取子件BOM版本失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/parents', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_bom_parents():
"""获取所有已定义BOM的父件物料列表(兼容旧版)"""
try:
subq = db.session.query(BomTable.parent_id).distinct().subquery()
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
data = [item.to_dict() for item in parents]
# 字段级脱敏 (如果需要)
user_permissions = get_current_user_permissions()
data = [filter_item_by_permissions(item, user_permissions) for item in data]
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
@bom_bp.route('/cascade-inventory', methods=['GET'])
@jwt_required()
@permission_required('bom_manage')
def get_cascade_inventory():
"""
根据 BOM 编号和订单数量,计算所有子件的级联库存缺口(供 AI 调用)
Query参数:
- bom_no: BOM编号(必填)
- order_qty: 订单需求量(必填,数值)
"""
try:
bom_no = request.args.get('bom_no', '').strip()
order_qty_str = request.args.get('order_qty', '').strip()
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
if not order_qty_str:
return jsonify({'code': 400, 'msg': 'order_qty 不能为空'}), 400
try:
order_qty = float(order_qty_str)
except ValueError:
return jsonify({'code': 400, 'msg': 'order_qty 必须为有效数字'}), 400
data = BomService.calculate_cascade_inventory(bom_no, order_qty)
if data is None:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
return jsonify({
'code': 200,
'msg': 'success',
'data': data
})
except Exception as e:
current_app.logger.error(f'级联库存计算失败: {str(e)}')
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
# ==================== BOM 草稿接口 ====================
@bom_bp.route('/draft/save', methods=['POST'])
@jwt_required()
def save_draft():
"""暂存草稿"""
data = request.get_json()
bom_no = data.get('bom_no')
version = data.get('version', 'V1.0')
parent_id = data.get('parent_id')
children = data.get('children', [])
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
if not parent_id:
return jsonify({'code': 400, 'msg': 'parent_id 不能为空'}), 400
bom_draft_no = BomDraftService.save_draft(bom_no, version, parent_id, children)
return jsonify({'code': 200, 'msg': '草稿暂存成功', 'data': {'bom_no': bom_draft_no}})
@bom_bp.route('/draft/detail', methods=['GET'])
@jwt_required()
def get_draft_detail():
"""读取草稿详情"""
bom_no = request.args.get('bom_no')
version = request.args.get('version', 'V1.0')
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
draft = BomDraftService.get_draft_detail(bom_no, version)
# 【核心修改】:查不到草稿是正常现象,返回 HTTP 200 即可
if draft is None:
return jsonify({'code': 200, 'msg': '无草稿', 'data': None}), 200
return jsonify({'code': 200, 'msg': '查询成功', 'data': draft})
@bom_bp.route('/draft/publish', methods=['POST'])
@jwt_required()
def publish_draft():
"""发布草稿为正式 BOM"""
data = request.get_json()
bom_no = data.get('bom_no')
version = data.get('version', 'V1.0')
if not bom_no:
return jsonify({'code': 400, 'msg': 'bom_no 不能为空'}), 400
try:
bom_draft_no = BomDraftService.publish_draft(bom_no, version)
return jsonify({'code': 200, 'msg': 'BOM 发布成功', 'data': {'bom_no': bom_draft_no}})
except ValueError as e:
return jsonify({'code': 400, 'msg': str(e)}), 400