Files
KCGL/inventory-backend/app/api/v1/inbound/base.py
yueli 97ff63523c fix(export): 公司隔离移出 if filters 判定,确保无条件执行
export_excel 的行级公司隔离原本嵌套在 `if filters:` 内部:
只要调用方不传或传空筛选条件,整段隔离会被跳过且不抛错 —— 静默失效。
(实测: 修复前 export_excel({}, None) 会导出跨公司全部 1816 行。)

现把 get_current_company_filter() 及其 filter_conditions.append 提到
if filters: 之前,无论有无筛选条件都绝对执行。

同时清理路由里 filters 字典的 'company' 死键 —— export_excel 从不消费它,
真正生效的是 get_current_company_filter() 直接读 request.args 上的公司标识。

注意:前端 handleExport 的 company 参数必须保留(已在代码中加注说明)。
实测 WAREHOUSE_MGR 带 ?company=IRIS 导出 1126 行仅 IRIS,不带则 1816 行
涵盖 IRIS+LICA —— 跨域角色按公司收窄范围完全依赖这个 query 参数。

实测(修复后):
  SALES/IRIS          filters={}          → 1126 行, 仅 IRIS
  SALES/IRIS          filters={keyword:''}→ 1126 行, 仅 IRIS
  WAREHOUSE_MGR       ?company=IRIS       → 1126 行, 仅 IRIS
  WAREHOUSE_MGR       (无参数)            → 1816 行, IRIS+LICA
2026-09-11 12:41:27 +08:00

583 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:
# 获取筛选条件
# ★ 此处刻意不放 companyexport_excel 从不消费该键,放进来是死参数、易误导。
# 真正生效的公司隔离由 export_excel 内的 get_current_company_filter() 直接读
# request.args 里的 company / company_name —— 所以前端仍需把它带在 query
# string 上,跨域角色(超管 / WAREHOUSE_MGR按公司出报表就靠这个参数。
filters = {
'keyword': request.args.get('keyword', ''),
'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