# 文件路径: 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: # 获取筛选条件 # ★ 此处刻意不放 company:export_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/) # ============================================================================== @inbound_base_bp.route('/', 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/) # ============================================================================== @inbound_base_bp.route('/', 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