Files
KCGL/inventory-backend/app/api/v1/bom.py

573 lines
22 KiB
Python
Raw Normal View History

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
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
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():
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
"""获取 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
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
category = request.args.get('category', '').strip() or None
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
data = BomService.get_bom_list(
keyword=keyword, active_only=active_only,
category=category, page=page, limit=limit, status=status
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
)
# 字段级脱敏(data 现在是 {items, total, pages, current_page} 字典)
user_permissions = get_current_user_permissions()
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
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
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
# ==============================================================================
# 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)
perf: 系统级性能优化与并发安全修复 ## 并发安全修复 (4处) - scrap.py: 报废执行添加 SELECT FOR UPDATE 悲观锁,消除 TOCTOU 竞态 - stock.py (adjust_stock): 盘点调整添加 for_update=True 行锁 - outbound_service.py: 低库存预警 SMTP 调用移到 commit 之后,避免长事务 - trans_service.py: execute_dispatch 按 (source_table, id) 排序 items,消除死锁风险 ## N+1 查询优化 (2处) - inventory_task.py: _prefetch_inventory_map 单条 UNION ALL+GROUP BY 替代循环内逐条查询(N*4次→2次) - stock.py (export_stocktake): get_borrowed_qty 批量 GROUP BY 替代逐条 TransBorrow 查询(~18000次→1次) ## BOM 列表性能重构 - bom_service.py: get_bom_list 单条 GROUP BY+string_agg+分页,消除 N+1 循环查询 - bom_service.py: 新增 get_bom_summary (轻量 GROUP BY category+COUNT) - bom.py: 新增 /api/v1/bom/summary 路由,/list 支持 category 过滤 ## Odoo 基础信息懒加载 - base_service.py: 新增 get_odoo_summary (GROUP BY category+COUNT) - base.py: 新增 /api/v1/inbound/base/odoo-summary 路由 - buyOdoo.vue: 懒加载分组架构 (fetchOdooSummary + loadGroupItems) - material_base.ts: 新增 getOdooSummary API ## 前端 Bug 修复 - BomManage.vue: 懒加载分组 (fetchBomSummary + loadGroupItems + collapse) - BomManage.vue: 适配新 API 格式 (res.data.items 替代 res.data) - buyOdoo.vue: 移除 "点击展开加载" 文字 - Selection.vue + borrow/apply/index.vue: openBomSelect 适配新 API 格式
2026-07-15 17:37:57 +08:00
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