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

329 lines
14 KiB
Python

# inventory-backend/app/api/v1/inbound/semi.py
from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService
from app.utils.decorators import permission_required
from app.models.base import MaterialBase
from app.services.track_query_service import lookup_product
import traceback
# === 这一行非常关键,绝对不能丢!===
inbound_semi_bp = Blueprint('stock_semi', __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 ['inbound_semi:*']
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
return perm_dict.get('menus', []) + perm_dict.get('elements', [])
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, 'StockSemi', user_permissions)
@inbound_semi_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_semi')
def search_base():
try:
keyword = request.args.get('keyword', '')
page = request.args.get('page', 1, type=int)
result = SemiInboundService.search_base_material(keyword, page)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/search-bom', methods=['GET'])
@permission_required('inbound_semi')
def search_bom():
try:
keyword = request.args.get('keyword', '')
parent_spec = request.args.get('parent_spec', None)
data = SemiInboundService.search_bom_options(keyword, parent_spec=parent_spec)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/list', methods=['GET'])
@permission_required('inbound_semi')
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
keyword = request.args.get('keyword', '')
sku = request.args.get('sku', '')
search_field = request.args.get('searchField', 'all')
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
company = request.args.get('company', '')
category = request.args.get('category', '')
material_type = request.args.get('material_type', '')
order_by_column = request.args.get('orderByColumn', '')
is_asc = request.args.get('isAsc', '')
advanced_filters_str = request.args.get('advancedFilters', '')
# 准备额外筛选字典
extra_filters = {}
if company:
extra_filters['company'] = company
if category:
extra_filters['category'] = category
if material_type:
extra_filters['material_type'] = material_type
if order_by_column:
extra_filters['order_by_column'] = order_by_column
if is_asc:
extra_filters['is_asc'] = is_asc
if advanced_filters_str:
try:
import json
advanced_filters = json.loads(advanced_filters_str)
extra_filters['advanced_filters'] = advanced_filters
except Exception:
extra_filters['advanced_filters'] = []
# 调用服务,传入所有参数
result = SemiInboundService.get_list(
page, limit, keyword, sku, search_field, statuses,
**extra_filters
)
user_permissions = get_current_user_permissions()
if result.get('items'):
# ★ 字段权限过滤会剥离 base_id,但前端扫码/选物料联动需要它匹配历史批号
# 在过滤前保留 base_id,过滤后补回(非敏感字段,仅物料关联 ID)
preserved_items = []
for item in result['items']:
base_id = item.get('base_id')
filtered = filter_item_by_permissions(item, user_permissions)
if base_id is not None:
filtered['base_id'] = base_id
preserved_items.append(filtered)
result['items'] = preserved_items
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/submit', methods=['POST'])
@permission_required('inbound_semi:operation')
def submit():
try:
data = request.get_json()
if not data: return jsonify({"code": 400, "msg": "No data"}), 400
# 库位必填校验(安全兜底)
location = data.get('warehouse_location', '').strip()
if not location:
return jsonify({"code": 400, "msg": "入库失败:库位为必填项,不能为空!"}), 400
user_permissions = get_current_user_permissions()
if 'inbound_semi:*' not in user_permissions:
for field in ('raw_material_cost', 'manual_cost', 'unit_total_cost', 'total_price'):
perm_code = f'inbound_semi:{field}'
if field in data and perm_code not in user_permissions:
data.pop(field, None)
new_stock = SemiInboundService.handle_inbound(data)
from app.utils.field_permissions import apply_strict_rbac
resp = apply_strict_rbac(new_stock.to_dict(), 'StockSemi', user_permissions)
return jsonify({"code": 200, "msg": "入库成功", "data": resp})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/track-lookup', methods=['GET'])
@permission_required('inbound_semi')
def track_lookup():
"""扫码入库:根据 Track 身份证查询产品信息,并匹配 MOM 物料库"""
try:
code = (request.args.get('code') or '').strip()
if not code:
return jsonify({"code": 400, "msg": "code 参数不能为空"}), 400
track_info = lookup_product(code)
if not track_info:
return jsonify({"code": 404, "msg": "未在 Track 系统中找到该身份证对应的产品"}), 404
material_id = track_info.get('material_id')
material = None
if material_id:
try:
material_id = int(material_id)
except (TypeError, ValueError):
material_id = None
if material_id:
material = MaterialBase.query.filter(
MaterialBase.id == material_id,
MaterialBase.is_enabled == True
).first()
if material is None:
return jsonify({
"code": 404,
"msg": "扫码产品的物料在 MOM 物料库中不存在,请先在【基础信息】中完善该物料后再入库"
}), 404
return jsonify({
"code": 200,
"msg": "success",
"data": {
"track": track_info,
"material": {
"id": material.id,
"company_name": material.company_name,
"name": material.name,
"spec": material.spec_model,
"category": material.category,
"unit": material.unit,
"type": material.material_type,
}
}
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_semi:operation')
def update_semi(id):
try:
data = request.get_json()
user_permissions = get_current_user_permissions()
if 'inbound_semi:*' not in user_permissions:
field_to_perm = {
'id': 'inbound_semi:id',
'base_id': 'inbound_semi:base_id',
'company_name': 'material_list:companyName',
'material_name': 'material_list:name',
'spec_model': 'material_list:spec',
'category': 'material_list:category',
'material_type': 'material_list:type',
'unit': 'material_list:unit',
'sku': 'inbound_semi:sku',
'in_date': 'inbound_semi:inbound_date',
'barcode': 'inbound_semi:barcode',
'serial_number': 'inbound_semi:sn_bn',
'batch_number': 'inbound_semi:sn_bn',
'warehouse_location': 'inbound_semi:warehouse_loc',
'status': 'inbound_semi:status',
'quality_status': 'inbound_semi:quality_status',
'in_quantity': 'inbound_semi:in_quantity',
'stock_quantity': 'inbound_semi:stock_quantity',
'available_quantity': 'inbound_semi:available_quantity',
'bom_code': 'inbound_semi:bom_code',
'bom_version': 'inbound_semi:bom_version',
'work_order_code': 'inbound_semi:work_order_code',
'raw_material_cost': 'inbound_semi:raw_material_cost',
'manual_cost': 'inbound_semi:manual_cost',
'unit_total_cost': 'inbound_semi:unit_total_cost',
'total_price': 'inbound_semi:total_price',
'production_manager': 'inbound_semi:production_manager',
'production_start_time': 'inbound_semi:production_start_time',
'production_end_time': 'inbound_semi:production_end_time',
'arrival_photo': 'inbound_semi:arrival_photo',
'quality_report_link': 'inbound_semi:quality_report_link',
'detail_link': 'inbound_semi:detail_link',
'remark': 'inbound_semi:remark',
'print_copies': 'inbound_semi:print_copies',
}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code is None:
data.pop(field, None) # Default Deny
elif perm_code not in user_permissions:
data.pop(field, None)
SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_semi:operation')
def delete_semi(id):
try:
material_name = SemiInboundService.delete_inbound(id)
return jsonify({"code": 200, "msg": "删除成功", "material_name": material_name})
except ValueError as ve:
return jsonify({"code": 400, "msg": str(ve)})
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"code": 500, "msg": f"服务器内部错误详情: {str(e)}"}), 500
@inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_semi')
def get_history(id):
try:
data = SemiInboundService.get_outbound_history(id)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_semi')
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = SemiInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
@inbound_semi_bp.route('/options', methods=['GET'])
@permission_required('inbound_semi')
def get_options():
try:
data = SemiInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_semi_bp.route('/suggestions/managers', methods=['GET'])
@permission_required('inbound_semi')
def get_manager_history():
keyword = request.args.get('keyword', '')
try:
data = SemiInboundService.get_history_managers(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 9. BOM 原材料成本自动核算 (新增)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/calculate-bom-cost', methods=['GET'])
@permission_required('inbound_semi')
def calculate_bom_cost():
try:
bom_code = request.args.get('bom_code')
bom_version = request.args.get('bom_version')
if not bom_code or not bom_version:
return jsonify({"code": 400, "msg": "bom_code和bom_version不能为空"}), 400
cost = SemiInboundService.calculate_bom_cost(bom_code, bom_version)
return jsonify({"code": 200, "msg": "success", "data": cost})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 获取最近一次入库的库位(跨表查询)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/last-location', methods=['GET'])
@permission_required('inbound_semi')
def get_last_location():
"""
获取指定物料最近一次入库的库位
查询顺序:半成品入库 -> 采购入库 -> 成品入库,返回最新入库的库位
"""
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({"code": 400, "msg": "base_id required"}), 400
location = SemiInboundService.get_last_location_by_base_id(base_id)
return jsonify({"code": 200, "msg": "success", "data": {"location": location}})