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

318 lines
16 KiB
Python

# inventory-backend/app/api/v1/inbound/product.py
from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService
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_product_bp = Blueprint('stock_product', __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_product:*']
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, 'StockProduct', user_permissions)
@inbound_product_bp.route('/search-base', methods=['GET'])
@permission_required('inbound_product')
def search_base():
try:
keyword = request.args.get('keyword', '')
page = request.args.get('page', 1, type=int)
result = ProductInboundService.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_product_bp.route('/search-bom', methods=['GET'])
@permission_required('inbound_product')
def search_bom():
try:
keyword = request.args.get('keyword', '')
parent_spec = request.args.get('parent_spec', None)
data = ProductInboundService.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_product_bp.route('/list', methods=['GET'])
@permission_required('inbound_product')
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 []
category = request.args.get('category', '')
material_type = request.args.get('material_type', '')
company = request.args.get('company', '')
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 = ProductInboundService.get_list(
page, limit, keyword, sku, search_field, statuses,
category=extra_filters.get('category'),
material_type=extra_filters.get('material_type'),
company=extra_filters.get('company'),
order_by_column=extra_filters.get('order_by_column'),
is_asc=extra_filters.get('is_asc'),
advanced_filters=extra_filters.get('advanced_filters')
)
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
@inbound_product_bp.route('/submit', methods=['POST'])
@permission_required('inbound_product: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_product:*' not in user_permissions:
# ★ 基本操作字段(库位/数量等)不参与字段权限过滤
field_to_perm = {'id': 'inbound_product:id', 'company_name': 'inbound_product:company_name', 'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category', 'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model', 'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date', 'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number', 'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status', 'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version', 'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id', 'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time', 'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost', 'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price', 'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link', 'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link'}
for field in list(data.keys()):
perm_code = field_to_perm.get(field)
if perm_code and perm_code not in user_permissions: data.pop(field, None)
new_stock = ProductInboundService.handle_inbound(data)
from app.utils.field_permissions import apply_strict_rbac
resp = apply_strict_rbac(new_stock.to_dict(), 'StockProduct', 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_product_bp.route('/track-lookup', methods=['GET'])
@permission_required('inbound_product')
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_product_bp.route('/<int:id>', methods=['PUT'])
@permission_required('inbound_product:operation')
def update(id):
try:
data = request.get_json()
user_permissions = get_current_user_permissions()
if 'inbound_product:*' not in user_permissions:
field_to_perm = {
'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id',
'company_name': 'material_list:companyName', 'material_name': 'material_list:name',
'category': 'material_list:category', 'material_type': 'material_list:type',
'spec_model': 'material_list:spec', 'unit': 'material_list:unit',
'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date',
'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number',
'warehouse_location': 'inbound_product:warehouse_loc',
'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status',
'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity',
'available_quantity': 'inbound_product:available_quantity',
'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version',
'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id',
'production_manager': 'inbound_product:production_manager',
'production_start_time': 'inbound_product:production_start_time',
'production_end_time': 'inbound_product:production_end_time',
'raw_material_cost': 'inbound_product:raw_material_cost',
'manual_cost': 'inbound_product:manual_cost',
'unit_total_cost': 'inbound_product:unit_total_cost',
'total_price': 'inbound_product:total_price',
'sale_price': 'inbound_product:sale_price',
'product_photo': 'inbound_product:product_photo',
'quality_report_link': 'inbound_product:quality_report_link',
'inspection_report_link': 'inbound_product:inspection_report_link',
'detail_link': 'inbound_product:detail_link',
'remark': 'inbound_product:remark',
'print_copies': 'inbound_product: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)
ProductInboundService.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_product_bp.route('/<int:id>', methods=['DELETE'])
@permission_required('inbound_product:operation')
def delete(id):
try:
material_name = ProductInboundService.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_product_bp.route('/<int:id>/history', methods=['GET'])
@permission_required('inbound_product')
def get_history(id):
try:
data = ProductInboundService.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_product_bp.route('/suggestions/users', methods=['GET'])
@permission_required('inbound_product')
def get_user_suggestions():
keyword = request.args.get('keyword', '')
data = ProductInboundService.search_system_users(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
@inbound_product_bp.route('/options', methods=['GET'])
@permission_required('inbound_product')
def get_options():
try:
data = ProductInboundService.get_filter_options()
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_product_bp.route('/suggestions/managers', methods=['GET'])
@permission_required('inbound_product')
def get_manager_history():
keyword = request.args.get('keyword', '')
try:
data = ProductInboundService.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_product_bp.route('/calculate-bom-cost', methods=['GET'])
@permission_required('inbound_product')
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 = ProductInboundService.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_product_bp.route('/last-location', methods=['GET'])
@permission_required('inbound_product')
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 = ProductInboundService.get_last_location_by_base_id(base_id)
return jsonify({"code": 200, "msg": "success", "data": {"location": location}})