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

1016 lines
40 KiB
Python
Raw Normal View History

perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
from flask import Blueprint, request, jsonify, current_app
from app.services.outbound_service import OutboundService
from flask_jwt_extended import jwt_required, get_jwt_identity, 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, prevent_double_submit, is_privileged_viewer
from app.services.auth_service import AuthService
import traceback
outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound')
# ==============================================================================
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
# ==============================================================================
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 []
# 超级管理员返回所有字段权限 (忽略大小写)
if user_role.upper() == 'SUPER_ADMIN':
return ['outbound_list:*']
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 对齐:仅 total_amount/unit_price/subtotal 需权限
"""
field_to_perm = {
'total_amount': 'outbound_list:total_amount',
'unit_price': 'outbound_list:unit_price',
'subtotal': 'outbound_list:subtotal',
}
if 'outbound_list:*' 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
# 递归处理明细子项
if 'items' in item_dict and isinstance(item_dict['items'], list):
for sub_item in item_dict['items']:
filter_item_by_permissions(sub_item, user_permissions)
return item_dict
# --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/outbound/scan?barcode=...
# --------------------------------------------------------
@outbound_bp.route('/scan', methods=['GET'])
@jwt_required()
@permission_required('outbound_selection')
def scan_barcode():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
try:
# 调用 Service 层去三个表中查找 (Service已更新,会返回价格)
result = OutboundService.get_stock_by_barcode(barcode)
if result:
# ★ Fail-Closed: 扫码响应剥离价格字段
result.pop('price', None)
return jsonify({
'code': 200,
'msg': '扫描成功',
'data': result
})
else:
return jsonify({
'code': 404,
'msg': '未找到对应的库存记录,请确认条码是否正确'
}), 404
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'扫描查询出错: {str(e)}'}), 500
# --------------------------------------------------------
# 2. 提交出库单接口 (批量)
# POST /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['POST'])
@jwt_required()
@prevent_double_submit(lock_timeout=5)
def create_outbound():
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
# 权限检查:有 outbound_selection 菜单或操作权限即可提交
claims = get_jwt()
user_role = claims.get('role')
user_company = claims.get('company_name', '')
if not user_role:
return jsonify({'code': 403, 'msg': '未授权'}), 403
if user_role.upper() != 'SUPER_ADMIN':
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
outbound_perms = [p for p in perms if 'outbound' in p.lower() or 'selection' in p.lower() or 'create' in p.lower()]
current_app.logger.warning(
f"[出库权限调试] role={user_role}, company={user_company}, "
f"出库相关权限={outbound_perms}, 全部权限数={len(perms)}"
)
if 'outbound_selection' not in perms and not any(
p.startswith('outbound_selection:') or p.startswith('outbound_create:') for p in perms
):
return jsonify({'code': 403, 'msg': '权限不足'}), 403
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
# 获取当前登录用户名 (JWT identity)
current_user_name = get_jwt_identity()
if not current_user_name:
current_user_name = 'Unknown'
# 获取最终的操作员名称
final_operator = data.get('operator_name')
if not final_operator:
final_operator = current_user_name
# 必填校验 (针对整个单据)
# items 必须是列表且不为空,consumer_name 和 signature_path 必填
if 'items' not in data or not data['items']:
return jsonify({'code': 400, 'msg': '出库商品列表不能为空'}), 400
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
try:
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
return jsonify({
'code': 200,
'msg': '出库成功',
'data': {'outbound_no': outbound_no}
})
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
# --------------------------------------------------------
# 3. 获取出库记录列表 (分组展示)
# GET /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['GET'])
@jwt_required()
@permission_required('outbound_list')
def get_outbound_list():
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
keyword = request.args.get('keyword', '')
search_type = request.args.get('search_type', 'all')
company = request.args.get('company', '')
feat(records): 高级筛选引擎 + 出库记录接入 一、新增共享工具 app/utils/advanced_filter.py 系统内已有该模式(material/list.vue、stock/inbound/buy.vue), 沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、 操作符 eq/ne/contains/not_contains/ge/le。 · parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询 · build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入 · build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base) 二、★ 父子关系处理(本次核心) 记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。 若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围, 展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」 再让主查询按单号 IN 过滤。 实测对照(单 OUT-20260811-1519-0003,21 条明细): 按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。 三、★ 否定操作符语义(NOT IN) 子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是 「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。 用户意图是**整单排除**,故 apply_child_condition() 统一: 肯定 → order_no IN (含匹配明细的单号) 否定 → order_no NOT IN (含匹配明细的单号) 两者子查询完全一致(都用肯定形式谓词),仅外层取反。 父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。 四、出库记录接入(前端弹窗 + 后端接线) 验证: eq 0000000002 → 1 单;material_name contains 白板 → 16 单 sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除 material_name not_contains 白板 → 379 = 395-16
2026-09-10 13:05:59 +08:00
# ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询)
from app.utils.advanced_filter import parse_advanced_filters
advanced_filters = parse_advanced_filters(
request.args.get('advancedFilters', '')
)
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;管理者看全部
consumer_name = None
if not is_privileged_viewer():
_identity = get_jwt_identity()
if _identity:
from app.models.system import SysUser
_u = SysUser.query.get(int(_identity))
# username 形如 “中文名/xiaolongxia” → 取“/”前的领用人姓名
_uname = _u.username if _u else ''
consumer_name = _uname.split('/')[0].strip() if _uname else None
# ★ [修改] 调用分组查询服务,支持搜索类型
result = OutboundService.get_grouped_list(
page, limit, keyword, search_type=search_type,
feat(records): 高级筛选引擎 + 出库记录接入 一、新增共享工具 app/utils/advanced_filter.py 系统内已有该模式(material/list.vue、stock/inbound/buy.vue), 沿用其既有约定:参数名 advancedFilters、值为 JSON 字符串、 操作符 eq/ne/contains/not_contains/ge/le。 · parse_advanced_filters() 解析并规整,坏输入退化为空列表不影响主查询 · build_predicate() 单条件 → SQLAlchemy 谓词,未登记字段返回 None 杜绝列注入 · build_material_name_select() 物料名三表联查(buy/semi/product JOIN material_base) 二、★ 父子关系处理(本次核心) 记录接口返回的是**按单号分组的订单**,而用户筛选字段多落在**明细行**上。 若直接 .filter(TransOutbound.sku.ilike(...)),会在 GROUP BY 前收窄明细范围, 展开行里的兄弟明细会凭空消失。正确做法是先求「含匹配明细的单号集合」 再让主查询按单号 IN 过滤。 实测对照(单 OUT-20260811-1519-0003,21 条明细): 按其中一条 SKU 筛选 → 子查询法保住全部 21 条;直接 filter 只剩 1 条。 三、★ 否定操作符语义(NOT IN) 子级字段的 ne / not_contains 不能直接用 SQL != / NOT LIKE —— 那表达的是 「本单存在某条不等于 X 的明细」,多明细单几乎必然成立,等于筛选失效。 用户意图是**整单排除**,故 apply_child_condition() 统一: 肯定 → order_no IN (含匹配明细的单号) 否定 → order_no NOT IN (含匹配明细的单号) 两者子查询完全一致(都用肯定形式谓词),仅外层取反。 父级字段(单号/操作人)仍走标准 SQL 谓词,语义无歧义。 四、出库记录接入(前端弹窗 + 后端接线) 验证: eq 0000000002 → 1 单;material_name contains 白板 → 16 单 sku ne 0000000002 → 394 = 395-1,含该 SKU 的单被整体排除 material_name not_contains 白板 → 379 = 395-16
2026-09-10 13:05:59 +08:00
company=company, consumer_name=consumer_name,
advanced_filters=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': '获取成功',
'data': result
})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷 问题现象 -------- BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件, 并提示库存不足。 根因 ---- 两个接口口径不一致: · GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839 · POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1 前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行, 若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。 为何必须放在后端 ---------------- 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖; 且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。 更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图, 而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减) 使用同一份数据源。 改动 ---- bom-match-stock 新增分配模式: 请求 { requirements: [{base_id, required_qty, name, spec_model}] } 响应 { items: [...已分配行], shortages: [...缺料明细] } _allocate_bom_requirements() 在 DB 层完成: 1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行 (带公司隔离,join base 取名称规格); 2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数; 3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty; 4. 分配不足记录 shortage 但不阻断其它物料。 每行仍携带 uniqueKey,前端可直接入购物车。 返回前剥离价格成本字段(Fail-Closed)。 旧查询模式(child_ids)保留,兼容未改造的调用方。 顺带修复一处静默失败 -------------------- 查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。 改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。 实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺; base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5; 需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
2026-09-10 14:16:47 +08:00
def _allocate_bom_requirements(requirements, company_limit,
StockBuy, StockSemi, StockProduct, MaterialBase):
"""
★ BOM 需求分配核心
对每个 base_id:
1. 直接查库取该物料的**全部可用库存行**(available_quantity > 0);
2. 按库位优先、库存量降序排序(大行优先,减少拆分行数);
3. 依次扣减 required_qty,为每一行产出 (stock_id, source_table, allocated_qty);
4. 分配不足时记录缺口,供前端提示,但不阻断其它物料的分配。
返回的每一行都携带真实 stock_id 与 source_table,可直接入购物车;
因为这些数字直接来自 DB,前端无需(也不应)再做任何分配运算。
并发说明:此处只读取快照用于装配购物车,真正扣减在提交出库时由
create_outbound_batch 以 with_for_update 加锁并二次校验可用量。
"""
from flask import jsonify
from sqlalchemy.orm import joinedload # ★ 必须在此导入:本函数模块级作用域不可见
# 归一化需求,容忍字符串数字
reqs = []
for r in requirements:
try:
bid = int(r.get('base_id'))
except (TypeError, ValueError):
continue
try:
need = float(r.get('required_qty') or 0)
except (TypeError, ValueError):
need = 0.0
if bid <= 0 or need <= 0:
continue
reqs.append({'base_id': bid, 'required_qty': need,
'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''})
if not reqs:
return jsonify({'code': 400, 'msg': 'requirements 中无有效的 base_id/required_qty'}), 400
base_ids = list({r['base_id'] for r in reqs})
# ---- 一次性拉取全部候选库存行(三表)----
# 按 (base_id, source_table) 归集
rows_by_base = {}
for model, source_table, type_label, type_key in (
(StockBuy, 'stock_buy', '采购件', 'material'),
(StockSemi, 'stock_semi', '半成品', 'semi'),
(StockProduct, 'stock_product', '成品', 'product'),
):
try:
q = model.query.filter(
model.base_id.in_(base_ids),
model.available_quantity > 0, # ★ 只取真正可用的行
)
if company_limit is not None:
q = q.filter(model.base.has(MaterialBase.company_name == company_limit))
rows = q.options(joinedload(model.base)).all()
except Exception as e:
# 不静默:某张表查询失败会直接表现为"该物料无库存",极难排查
current_app.logger.error(
f"[bom-allocate] {source_table} 查询失败: {type(e).__name__}: {e}"
)
continue
for s in rows:
bid = int(s.base_id)
rows_by_base.setdefault(bid, []).append(
(float(s.available_quantity or 0), source_table, type_key, type_label, s)
)
# ---- 逐物料分配 ----
allocated_items = []
shortages = []
for req in reqs:
bid = req['base_id']
remaining = req['required_qty']
# 可用量降序:优先进大行,减少购物车拆分行数
candidates = sorted(rows_by_base.get(bid, []), key=lambda x: -x[0])
if not candidates:
shortages.append({
'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'],
'required_qty': remaining, 'allocated_qty': 0, 'missing': remaining,
})
continue
for avail, source_table, type_key, type_label, s in candidates:
if remaining <= 0:
break
take = min(remaining, avail)
if take <= 0:
continue
d = s.to_dict()
d['stock_id'] = s.id
d['source_table'] = source_table
d['type'] = type_key
d['stock_type'] = type_key
d['typeLabel'] = type_label
d['uniqueKey'] = f"{type_key}_{s.id}"
d['name'] = d.get('material_name') or (s.base.name if s.base else '') or ''
d['standard'] = d.get('spec_model') or (s.base.spec_model if s.base else '') or ''
d['warehouse_location'] = getattr(s, 'warehouse_location', '') or ''
d['available_quantity'] = float(s.available_quantity or 0)
d['allocated_qty'] = take # ★ 本次分配给该行的数量
d['export_quantity'] = take # 兼容购物车字段名
# Fail-Closed: 剥离价格成本字段
for k in ('unit_price', 'post_tax_unit_price', 'pre_tax_unit_price', 'total_price',
'tax_rate', 'currency', 'exchange_rate', 'sale_price',
'raw_material_cost', 'manual_cost', 'unit_total_cost'):
d.pop(k, None)
allocated_items.append(d)
remaining -= take
if remaining > 0:
shortages.append({
'base_id': bid, 'name': req['name'], 'spec_model': req['spec_model'],
'required_qty': req['required_qty'],
'allocated_qty': req['required_qty'] - remaining,
'missing': remaining,
})
return jsonify({
'code': 200,
'msg': 'success',
'data': {
'items': allocated_items,
'shortages': shortages,
'summary': {
'requested': len(reqs),
'allocated_kinds': len({i['base_id'] for i in allocated_items}),
'shortage_kinds': len(shortages),
},
}
}), 200
feat(outbound): 备选库位可见性,让「物理覆盖」不再盲扫 问题 ---- 预占会把货锁定在某个库位,但工人到现场可能进不去/找不到该库位, 需要改扫同物料的其它批次。后端执行端已支持按 base_id 校验、允许换批次, 但系统从不告诉他「还有哪些库位有货」—— 工人只能凭记忆或挨个翻。 后端:新增 GET /api/v1/outbound/alternatives -------------------------------------------- 入参 base_id(必填)、source_table/stock_id(可选,用于标注推荐行) 返回该物料全部可用库存行 + 合计可用量,推荐行置顶、其余按可用量降序。 为什么不复用 stock/list 或 bom-match-stock 的查询模式: 那两处按 stock_quantity > 0 过滤,会把「有货但已被别单全部预占」的库位 也列出来,工人跑过去才发现拿不到。实测库中有 14 行处于该状态。 本接口按 available_quantity > 0 过滤,只给真正能拿的库位。 前端:计划清单库位列加图标 + popover ------------------------------------ [推荐] Y1/2/1 可用 5 ← 本单锁定行(来自 items_json 的 stock_id) [备选] Y2/3/4 可用 10 [备选] Z1/1/1 可用 2 三处取舍: · trigger="click" 而非 hover —— 车间用扫码枪/触摸屏,hover 在触屏不可用 · @show 时才发请求 —— 计划清单可能几十行,渲染即请求会打出一片并发 · 附提示文案「现场取不到推荐库位时可直接扫备选库位条码出库」 注:历史单据的 items_json 无 stock_id,此时所有库位显示为「备选」 (不影响可用性,仅少了推荐标记);预占改造后新提交的单可正确标注。 实测:造 3 批次 Y1/2/1(5) Y2/3/4(10) Z1/1/1(2),预占首个后其 available=0, 接口正确排除该库位,返回两个备选、合计可用 12。
2026-09-10 14:59:17 +08:00
# ==============================================================================
# 备选库位查询 (GET /api/v1/outbound/alternatives)
#
# 场景:申请单已把货预占在某个库位,但工人到现场发现该库位进不去/找不到,
# 需要改扫同物料的其它批次。改造前系统不告诉他「还有哪些库位有货」,
# 工人只能凭记忆或挨个翻 —— 这个接口就是为「物理覆盖」提供可见性。
#
# 与 bom-match-stock 查询模式的区别:
# · 该模式按 stock_quantity > 0 过滤,会把「有货但已被别单全部预占」的
# 库位也列出来,工人跑过去才发现拿不到货;
# · 本接口按 available_quantity > 0 过滤,只给**真正能拿**的库位,
# 并额外标注哪一条是本单锁定的推荐行。
# ==============================================================================
@outbound_bp.route('/alternatives', methods=['GET'])
@jwt_required()
def get_stock_alternatives():
"""
查询某物料的全部可替代库位(available_quantity > 0)。
Query: base_id(必填)、source_table / stock_id(可选,用于标注推荐行)
Returns: { items: [{stock_id, source_table, warehouse_location,
available_quantity, is_locked, typeLabel, sku, batch_number}] }
"""
try:
base_id = request.args.get('base_id', type=int)
if not base_id:
return jsonify({'code': 400, 'msg': 'base_id 不能为空'}), 400
prefer_table = (request.args.get('source_table') or '').strip()
try:
prefer_stock_id = int(request.args.get('stock_id') or 0)
except (TypeError, ValueError):
prefer_stock_id = 0
from app.utils.decorators import get_current_company_filter
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy.orm import joinedload
company_limit = get_current_company_filter()
items = []
for model, source_table, label in (
(StockBuy, 'stock_buy', '采购件'),
(StockSemi, 'stock_semi', '半成品'),
(StockProduct, 'stock_product', '成品'),
):
q = model.query.filter(
model.base_id == base_id,
model.available_quantity > 0, # ★ 只给真正能拿的
)
if company_limit is not None:
q = q.filter(model.base.has(MaterialBase.company_name == company_limit))
try:
rows = q.options(joinedload(model.base)).all()
except Exception as e:
current_app.logger.error(
f"[alternatives] {source_table} 查询失败: {type(e).__name__}: {e}"
)
continue
for s in rows:
items.append({
'stock_id': s.id,
'source_table': source_table,
'typeLabel': label,
'sku': s.sku or '',
'batch_number': getattr(s, 'batch_number', '') or getattr(s, 'serial_number', '') or '',
'warehouse_location': getattr(s, 'warehouse_location', '') or '',
'available_quantity': float(s.available_quantity or 0),
# ★ 该行是否就是本单锁定的推荐批次
'is_locked': (prefer_stock_id and s.id == prefer_stock_id
and source_table == prefer_table),
})
# 排序:推荐行置顶,其余按可用量降序(工人优先看到货最多的库位)
items.sort(key=lambda x: (not x['is_locked'], -x['available_quantity']))
return jsonify({
'code': 200, 'msg': 'success',
'data': {
'items': items,
'total_available': round(sum(i['available_quantity'] for i in items), 4),
}
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'查询备选库位失败: {str(e)}'}), 500
# ==============================================================================
# BOM 匹配库存接口 (POST /api/v1/outbound/bom-match-stock)
feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷 问题现象 -------- BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件, 并提示库存不足。 根因 ---- 两个接口口径不一致: · GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839 · POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1 前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行, 若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。 为何必须放在后端 ---------------- 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖; 且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。 更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图, 而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减) 使用同一份数据源。 改动 ---- bom-match-stock 新增分配模式: 请求 { requirements: [{base_id, required_qty, name, spec_model}] } 响应 { items: [...已分配行], shortages: [...缺料明细] } _allocate_bom_requirements() 在 DB 层完成: 1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行 (带公司隔离,join base 取名称规格); 2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数; 3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty; 4. 分配不足记录 shortage 但不阻断其它物料。 每行仍携带 uniqueKey,前端可直接入购物车。 返回前剥离价格成本字段(Fail-Closed)。 旧查询模式(child_ids)保留,兼容未改造的调用方。 顺带修复一处静默失败 -------------------- 查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。 改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。 实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺; base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5; 需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
2026-09-10 14:16:47 +08:00
#
# ★ 两种用法:
# 1) 分配模式(推荐):传 requirements=[{base_id, required_qty, ...}]
# 后端在 DB 层完成「跨批次分配」,返回精确的 (stock_id, source_table, allocated_qty)
# 2) 查询模式(兼容旧调用):传 child_ids=[...],返回该批 base_id 的全部库存行
#
# 为什么分配必须在后端做
# ----------------------
# 分配需要「该 base_id 的全部可用库存行」这一完整视图,且必须与出库扣减
# (outbound_service.create_outbound_batch 按 stock_id 逐行 with_for_update 扣减)
# 使用同一套数据。放在前端会引入两类必然故障:
# · 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖;
# · base_id 与 stock_id 的类型/精度差异会导致匹配落空,静默算成"缺料"。
# 后端直接查库分配,从根本上消除上述不确定性。
# ==============================================================================
@outbound_bp.route('/bom-match-stock', methods=['POST'])
@jwt_required()
def bom_match_stock():
"""
feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷 问题现象 -------- BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件, 并提示库存不足。 根因 ---- 两个接口口径不一致: · GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839 · POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1 前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行, 若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。 为何必须放在后端 ---------------- 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖; 且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。 更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图, 而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减) 使用同一份数据源。 改动 ---- bom-match-stock 新增分配模式: 请求 { requirements: [{base_id, required_qty, name, spec_model}] } 响应 { items: [...已分配行], shortages: [...缺料明细] } _allocate_bom_requirements() 在 DB 层完成: 1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行 (带公司隔离,join base 取名称规格); 2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数; 3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty; 4. 分配不足记录 shortage 但不阻断其它物料。 每行仍携带 uniqueKey,前端可直接入购物车。 返回前剥离价格成本字段(Fail-Closed)。 旧查询模式(child_ids)保留,兼容未改造的调用方。 顺带修复一处静默失败 -------------------- 查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。 改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。 实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺; base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5; 需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
2026-09-10 14:16:47 +08:00
BOM 库存匹配 / 分配。
分配模式 Body:
{
"requirements": [
{"base_id": 123, "required_qty": 10, "name": "...", "spec_model": "..."},
...
]
}
Returns:
{
"code": 200,
"data": {
"items": [ # 已分配好的库存行,前端可直接入购物车
{"base_id", "stock_id", "source_table", "allocated_qty",
"available_quantity", "sku", "name", ..., "shortage": 0}
],
"shortages": [{"base_id", "name", "required_qty", "allocated_qty", "missing"}]
}
}
查询模式 Body: { "child_ids": [1, 2, 3] } → 返回全部匹配库存行(旧行为)
"""
try:
data = request.get_json() or {}
feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷 问题现象 -------- BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件, 并提示库存不足。 根因 ---- 两个接口口径不一致: · GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839 · POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1 前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行, 若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。 为何必须放在后端 ---------------- 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖; 且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。 更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图, 而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减) 使用同一份数据源。 改动 ---- bom-match-stock 新增分配模式: 请求 { requirements: [{base_id, required_qty, name, spec_model}] } 响应 { items: [...已分配行], shortages: [...缺料明细] } _allocate_bom_requirements() 在 DB 层完成: 1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行 (带公司隔离,join base 取名称规格); 2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数; 3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty; 4. 分配不足记录 shortage 但不阻断其它物料。 每行仍携带 uniqueKey,前端可直接入购物车。 返回前剥离价格成本字段(Fail-Closed)。 旧查询模式(child_ids)保留,兼容未改造的调用方。 顺带修复一处静默失败 -------------------- 查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。 改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。 实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺; base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5; 需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
2026-09-10 14:16:47 +08:00
requirements = data.get('requirements')
child_ids = data.get('child_ids', [])
feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷 问题现象 -------- BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件, 并提示库存不足。 根因 ---- 两个接口口径不一致: · GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839 · POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1 前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行, 若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。 为何必须放在后端 ---------------- 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖; 且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。 更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图, 而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减) 使用同一份数据源。 改动 ---- bom-match-stock 新增分配模式: 请求 { requirements: [{base_id, required_qty, name, spec_model}] } 响应 { items: [...已分配行], shortages: [...缺料明细] } _allocate_bom_requirements() 在 DB 层完成: 1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行 (带公司隔离,join base 取名称规格); 2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数; 3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty; 4. 分配不足记录 shortage 但不阻断其它物料。 每行仍携带 uniqueKey,前端可直接入购物车。 返回前剥离价格成本字段(Fail-Closed)。 旧查询模式(child_ids)保留,兼容未改造的调用方。 顺带修复一处静默失败 -------------------- 查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。 改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。 实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺; base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5; 需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
2026-09-10 14:16:47 +08:00
if not requirements and not child_ids:
return jsonify({'code': 400, 'msg': 'requirements 或 child_ids 不能为空'}), 400
# ★ 行级公司隔离:普通用户只能匹配本公司的库存(超管/跨域不受限)
from app.utils.decorators import get_current_company_filter
from app.models.base import MaterialBase
company_limit = get_current_company_filter()
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
from sqlalchemy.orm import joinedload
feat(bom): 后端承接 BOM 库存分配,消除多批次物料只能加 1 件的缺陷 问题现象 -------- BOM 选单中物料显示需求 10、聚合可用 839,加入购物车却只剩 1 件, 并提示库存不足。 根因 ---- 两个接口口径不一致: · GET /bom/stock/<bom_no> 按 base_id 聚合 → current_stock=839 · POST /outbound/bom-match-stock 不聚合,每批次一行 → 某行只有 1 前端用 stockList.find(s => s.base_id == child_id) 只取第一条库存行, 若首行恰好只剩 1 件,需求量又被 Math.min 压到 1,现象即如此。 为何必须放在后端 ---------------- 前端 stockList 由多个入口写入(手动选单/搜索/BOM),随时可能被覆盖; 且 base_id 与 stock_id 的类型差异会让匹配静默落空,表现同样是「库存不足」。 更关键的是:分配需要「该 base_id 全部可用库存行」的完整视图, 而这必须与出库扣减(create_outbound_batch 按 stock_id 逐行加锁扣减) 使用同一份数据源。 改动 ---- bom-match-stock 新增分配模式: 请求 { requirements: [{base_id, required_qty, name, spec_model}] } 响应 { items: [...已分配行], shortages: [...缺料明细] } _allocate_bom_requirements() 在 DB 层完成: 1. 三张库存表按 base_id 一次性取全部 available_quantity > 0 的行 (带公司隔离,join base 取名称规格); 2. 可用量降序排序 —— 优先进大行,减少购物车拆分行数; 3. 逐物料扣减 required_qty,产出真实 stock_id + source_table + allocated_qty; 4. 分配不足记录 shortage 但不阻断其它物料。 每行仍携带 uniqueKey,前端可直接入购物车。 返回前剥离价格成本字段(Fail-Closed)。 旧查询模式(child_ids)保留,兼容未改造的调用方。 顺带修复一处静默失败 -------------------- 查询块的 except 原为直接 continue,会把 NameError 等错误吞成「该物料无库存」。 改为 logger.error 输出,避免同类问题再次以业务结论的形式出现。 实测(base_id=2405,聚合 841,需 10):分配 1 行 stock_id=1961 分配 10,无短缺; base_id=2963(9 行各 1),需 5 → 5 行各 1 合计 5; 需 20(聚合仅 9)→ 9 行合计 9,短缺 11。
2026-09-10 14:16:47 +08:00
# ------------------------------------------------------------------
# ★ 分配模式:后端完成跨批次分配
# ------------------------------------------------------------------
if requirements:
return _allocate_bom_requirements(
requirements, company_limit,
StockBuy, StockSemi, StockProduct, MaterialBase,
)
# ------------------------------------------------------------------
# 查询模式(兼容旧调用):返回全部匹配库存行
# ------------------------------------------------------------------
# 去重
child_ids = list(set(int(x) for x in child_ids))
all_items = []
# 采购件
buy_items = StockBuy.query.filter(
StockBuy.base_id.in_(child_ids),
StockBuy.stock_quantity > 0
)
if company_limit is not None:
buy_items = buy_items.filter(StockBuy.base.has(MaterialBase.company_name == company_limit))
buy_items = buy_items.options(joinedload(StockBuy.base)).all()
for s in buy_items:
d = s.to_dict()
d['type'] = 'material'
d['stock_type'] = 'material'
d['typeLabel'] = '采购件'
d['uniqueKey'] = f"material_{s.id}"
d['name'] = d.get('material_name', '')
d['standard'] = d.get('spec_model', '')
all_items.append(d)
# 半成品
try:
semi_items = StockSemi.query.filter(
StockSemi.base_id.in_(child_ids),
StockSemi.stock_quantity > 0
)
if company_limit is not None:
semi_items = semi_items.filter(StockSemi.base.has(MaterialBase.company_name == company_limit))
semi_items = semi_items.options(joinedload(StockSemi.base)).all()
for s in semi_items:
d = s.to_dict()
d['type'] = 'semi'
d['stock_type'] = 'semi'
d['typeLabel'] = '半成品'
d['uniqueKey'] = f"semi_{s.id}"
d['name'] = d.get('material_name', '')
d['standard'] = d.get('spec_model', '')
all_items.append(d)
except Exception:
pass
# 成品
try:
prod_items = StockProduct.query.filter(
StockProduct.base_id.in_(child_ids),
StockProduct.stock_quantity > 0
)
if company_limit is not None:
prod_items = prod_items.filter(StockProduct.base.has(MaterialBase.company_name == company_limit))
prod_items = prod_items.options(joinedload(StockProduct.base)).all()
for s in prod_items:
d = s.to_dict()
d['type'] = 'product'
d['stock_type'] = 'product'
d['typeLabel'] = '成品'
d['uniqueKey'] = f"product_{s.id}"
d['name'] = d.get('material_name', '')
d['standard'] = d.get('spec_model', '')
all_items.append(d)
except Exception:
pass
# ★ Fail-Closed: 剥离所有价格成本字段(BOM 匹配用于出库选单,无需价格)
for d in all_items:
stype = d.get('stock_type', '')
if stype == 'material':
for k in ('unit_price', 'post_tax_unit_price', 'total_price',
'tax_rate', 'currency', 'exchange_rate'):
d.pop(k, None)
elif stype == 'semi':
for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost',
'total_price', 'unit_price'):
d.pop(k, None)
elif stype == 'product':
for k in ('raw_material_cost', 'manual_cost', 'unit_total_cost',
'sale_price', 'unit_price'):
d.pop(k, None)
return jsonify({'code': 200, 'msg': 'success', 'data': {'items': all_items}})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# ==============================================================================
# 出库审批相关接口
# ==============================================================================
from app.services.outbound_service import OutboundApprovalService
def get_current_user_id():
"""获取当前用户ID"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None
def get_current_user_info():
"""获取当前用户信息和角色"""
from app.models.system import SysUser
identity = get_jwt_identity()
if not identity:
return None, None
# JWT identity 是数据库主键整数,直接用 .get() 查询
user = SysUser.query.get(identity)
return user.id if user else None, user.role if user else None
# --------------------------------------------------------
# 4. 创建出库审批单
# POST /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['POST'])
@jwt_required()
perf: 综合安全加固 — RBAC严格映射+异步邮件+字段权限白名单+前端对齐+导入模板 本次提交包含本会话所有修改的最终统一提交 ## 权限系统重构 - permission_service.py: 添加入库/采购操作元素 + ensure_default_permissions - field_permissions.py: 严格1-to-1 Default Deny 字段映射(StockBuy/Semi/Product/MaterialBase) - decorators.py: _expand_operation_perms 双向粒度桥接 + prevent_double_submit - deploy_production.sql: 修复 sys_element 别名码(qty_inbound→in_quantity) ## 采购模块 - purchase.py: 权限驱动可见性 + inbound_purchase独立权限 + 价格字段过滤 - purchase_service.py: 异步邮件 + 三阶段批量模糊匹配防N+1 - purchase/index.vue: canApprove严格操作权限 + upload重复修复 ## 导出/入 - base_service.py: export_excel 流式写入防OOM + get_latest_specs 优化 - import_service.py + import_api.py: Excel批量导入(模板+预览+执行) - ImportDialog.vue: 三步骤导入弹窗 ## 异步邮件 - email_service.py: send_email_async (守护线程) - inventory_task.py: send_email→send_email_async ## 前端对齐 - product/semi/buy.vue: 列对齐in_quantity/stock_quantity/available_quantity + localStorage缓存V2 - buyOdoo.vue: 排序修复 + 导入按钮 + 移除点击展开加载 - BomManage.vue: 懒加载分组 + 导入按钮 - list.vue: 导入按钮 - Selection.vue + borrow/apply: BOM匹配修复 + 导入按钮 - outbound/create.vue: 出库类型必选 - AppMain.vue: 移除transition白屏修复 - material_base.ts, outbound.ts, bom.ts, stock.ts: 新增API函数
2026-07-17 13:07:12 +08:00
@permission_required('outbound_selection')
def create_outbound_request():
"""
创建出库审批单(申请阶段,用户只需提交宏观物料信息,无需关联具体库存记录)
请求体示例:
{
"items": [
{
"name": "物料A", // 物料名称 (必填)
"spec_model": "规格1", // 规格型号 (必填)
"quantity": 10, // 计划出库数量 (必填)
"warehouse_location": "A区-01-01", // 库位 (可选)
"remark": "备注信息" // 物品备注 (可选)
}
],
"allowed_approvers": [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
],
"remark": "紧急出库申请"
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
items = data.get('items', [])
if not items:
return jsonify({'code': 400, 'msg': '出库物品列表不能为空'}), 400
# ★ 申请阶段仅校验宏观字段:名称、规格、数量
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing = [f for f in required_fields if f not in item or item.get(f) is None or str(item.get(f)).strip() == '']
if missing:
return jsonify({
'code': 400,
'msg': f'第{idx + 1}条物品缺少必填字段: {", ".join(missing)}。'
f'必须包含: name(名称), spec_model(规格), quantity(数量)'
}), 400
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
return jsonify({'code': 400, 'msg': f'第{idx + 1}条物品的出库数量必须大于0'}), 400
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': f'第{idx + 1}条物品的 quantity 格式无效'}), 400
# ★ 指定审批人:前端传 approver_id 则精准通知,否则用默认角色规则
approver_id = data.get('approver_id')
_default_approvers = [
{"type": "role", "value": "SUPERVISOR"},
{"type": "role", "value": "SUPER_ADMIN"}
]
allowed_approvers = data.get('allowed_approvers') or _default_approvers
# 创建审批单(直接存储前端传来的宏观信息快照,不查询库存)
approval = OutboundApprovalService.create_request(
applicant_id=user_id,
items=items,
allowed_approvers=allowed_approvers,
remark=data.get('remark'),
approver_id=approver_id,
outbound_type=data.get('outbound_type'), # 出库类型(申请时确定)
force_approval=((user_role or '').upper() == 'WAREHOUSE_MGR') # 库管代建 → 强制审批
)
return jsonify({
'code': 200,
'msg': '审批单创建成功',
'data': approval.to_dict()
}), 200
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.1 出库申请预检(判断所选物料是否需审批,驱动前端是否显示审批人)
# POST /api/v1/outbound/request/check-approval
# --------------------------------------------------------
@outbound_bp.route('/request/check-approval', methods=['POST'])
@jwt_required()
@permission_required('outbound_selection')
def check_outbound_approval():
try:
data = request.get_json() or {}
items = data.get('items', []) or []
from app.services.approval_control import resolve_approval_control
need_approval, flagged = resolve_approval_control(items)
return jsonify({
"code": 200, "msg": "success",
"data": {"need_approval": need_approval, "materials": flagged}
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": f"预检失败: {str(e)}"}), 500
# --------------------------------------------------------
# 5. 审批出库申请
# PATCH /api/v1/outbound/request/<id>/approve
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
@jwt_required()
@permission_required('outbound_approval')
def approve_outbound_request(request_id):
"""
审批出库申请
请求体示例:
{
"action": "approve", // "approve" 通过, "reject" 驳回
"reject_reason": "库存不足" // 仅在驳回时需要
}
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
data = request.get_json() or {}
action = data.get('action', 'approve')
reject_reason = data.get('reject_reason')
if action not in ('approve', 'reject'):
return jsonify({'code': 400, 'msg': '无效的审批操作,仅支持 approve 或 reject'}), 400
if action == 'reject' and not reject_reason:
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
success, message, approval = OutboundApprovalService.approve(
request_id=request_id,
user_id=user_id,
user_role=user_role,
action=action,
reject_reason=reject_reason
)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({
'code': 200,
'msg': message,
'data': approval.to_dict() if approval else None
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 5.5 手动完结/作废审批单
# POST /api/v1/outbound/request/<id>/close
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>/close', methods=['POST'])
@jwt_required()
@permission_required('outbound_approval')
def close_outbound_request(request_id):
"""
手动完结/作废已通过的审批单(状态 1-已通过 → 4-已完结)
适用场景:已通过但无法出库/作废的单据,库管手动清理,
使其从"已审批通过"列表中消失。
"""
try:
user_id, user_role = get_current_user_info()
if not user_id:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
success, message, approval = OutboundApprovalService.close_request(
request_id=request_id,
user_id=user_id,
user_role=user_role
)
if not success:
return jsonify({'code': 400, 'msg': message}), 400
return jsonify({
'code': 200,
'msg': message,
'data': approval.to_dict() if approval else None
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
feat(outbound): 申请人撤回自己的申请单 + 我的申请单端点 背景 ---- 出库审批页是管理视角(需 outbound_approval 权限),普通申请人提交后 **没有任何入口看回自己的单据**,更谈不上撤回。 服务层:抽出共用释放逻辑 ------------------------ 新增 OutboundApprovalService.withdraw_request(),与既有的 close_request() (管理路径)形成两条独立入口: close_request —— 管理路径,需 outbound_approval 等权限,仅 status==1 withdraw_request —— 申请人路径,仅校验「单据归属」,status 0 或 1 均可 两者各自完成权限与状态校验后,调用**同一个** _release_and_close()。 释放逻辑只有一份实现,不会因修改其中一处而漏掉另一处。 为什么单独开一条路径,而不是在 close_request 里加 if 分支: 权限模型不同(管理角色 vs 单据归属)。混在一个函数里,后续修改容易 互相影响 —— 这正是需要避免的访问控制风险。 API --- POST /outbound/request/<id>/withdraw 申请人撤回(仅 @jwt_required) · 归属断言:非本人且非特权 → 403「无权撤回他人的申请单」 · 状态守卫:仅 0/1 可撤回;执行成功后 status 会被置 3,故该判断 本身即执行守卫,已执行或已撤回的单都进不来 GET /outbound/my-requests 我的申请(仅 @jwt_required) · applicant_id 硬编码为当前登录用户,不接受任何入参覆盖 两者都**不做模块权限校验** —— 普通申请人无需持有 outbound_approval (那是管理权限)。与「给审批端点加 if 降级放行」是两条路:后者把管理 逻辑与用户逻辑混在一个端点里,一旦 is_privileged_viewer() 判定出错 即越权;本端点从设计上就没有「看别人」的分支。 安全实测 -------- 普通员工查我的申请(此前 403) → 200 B 查列表看不到 A 的单 → 39 单中无 A 的单 B 撤回 A 的单 → 403,且库存未被释放 A 撤回自己的单 → 200,库存 17→20 完全释放 主管代撤他人工单 → 200(特权路径) 待审批(status=0) 撤回 → 200,库存释放 重复撤回 → 400「当前状态不可撤回」
2026-09-10 15:36:59 +08:00
# --------------------------------------------------------
# 5.6 申请人撤回自己的申请单
# POST /api/v1/outbound/request/<id>/withdraw
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>/withdraw', methods=['POST'])
@jwt_required()
def withdraw_outbound_request(request_id):
"""
撤回自己的出库申请单(待审批 或 已通过但未执行)。
★ 严格职责分离:本端点**不做模块权限校验**(@jwt_required 即可),
权限判定完全落在「单据归属」上 —— 服务层会断言
applicant_id == 当前用户,否则 403。库管/主管可代撤。
与 /close 的区别:/close 是管理路径(需 outbound_approval 权限),
本端点是申请人路径,两者共用底层释放逻辑。
"""
try:
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
claims = get_jwt()
success, message, approval = OutboundApprovalService.withdraw_request(
request_id=request_id,
user_id=int(identity),
user_role=claims.get('role'),
)
if not success:
# 归属不符按 403 返回,其余为业务校验失败(400)
code = 403 if '无权' in message else 400
return jsonify({'code': code, 'msg': message}), code
return jsonify({
'code': 200,
'msg': message,
'data': approval.to_dict() if approval else None
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'撤回失败: {str(e)}'}), 500
# --------------------------------------------------------
# 5.7 我的申请单(申请人视角)
# GET /api/v1/outbound/my-requests
#
# ★ 严格职责分离:本端点仅需 @jwt_required,**不做模块权限校验**。
# applicant_id 在服务端硬编码为当前登录用户,不接受任何入参覆盖 ——
# 因此普通申请人无需持有 outbound_approval(那是管理权限),
# 也不可能借此看到他人的单据。
#
# 这与「给审批端点加 if 降级放行」是两条路:后者把管理与用户逻辑
# 混在一个端点里,一旦 is_privileged_viewer() 判定出错就会越权;
# 本端点从设计上就没有"看别人"的分支。
# --------------------------------------------------------
@outbound_bp.route('/my-requests', methods=['GET'])
@jwt_required()
def get_my_outbound_requests():
"""
查询当前登录用户提交的出库申请单。
Query: page / limit / status(可选,0待审 1已通过 2已驳回 3已完成 4已撤回)
"""
try:
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
status = request.args.get('status')
status = int(status) if status not in (None, '', 'all') else None
result = OutboundApprovalService.get_request_list(
page=page,
per_page=limit,
applicant_id=int(identity), # ★ 硬编码,不接受入参覆盖
status=status,
)
return jsonify({'code': 200, 'msg': '获取成功', 'data': result}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取我的申请单失败: {str(e)}'}), 500
# --------------------------------------------------------
# 6. 获取审批单列表
# GET /api/v1/outbound/request
# --------------------------------------------------------
@outbound_bp.route('/request', methods=['GET'])
@jwt_required()
@permission_required('outbound_approval')
def get_outbound_request_list():
"""
获取出库审批单列表
Query参数:
- page: 页码 (默认1)
- limit: 每页数量 (默认10)
- applicant_id: 按申请人筛选 (可选)
- status: 按状态筛选 (0待审/1通过/2驳回/3完成, 可选)
"""
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
applicant_id = request.args.get('applicant_id')
if applicant_id:
applicant_id = int(applicant_id)
status = request.args.get('status')
if status is not None:
status = int(status)
# ★ 数据权限:普通申请人只能看“自己的”出库记录;库管/主管/超管(或跨域)才可看他人
if not is_privileged_viewer():
identity = get_jwt_identity()
applicant_id = int(identity) if identity else None
result = OutboundApprovalService.get_request_list(
page=page,
per_page=limit,
applicant_id=applicant_id,
status=status
)
return jsonify({
'code': 200,
'msg': '获取成功',
'data': result
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500
# --------------------------------------------------------
# 7. 获取单个审批单详情
# GET /api/v1/outbound/request/<id>
# --------------------------------------------------------
@outbound_bp.route('/request/<int:request_id>', methods=['GET'])
@jwt_required()
@permission_required('outbound_approval')
def get_outbound_request_detail(request_id):
"""获取出库审批单详情"""
try:
approval = OutboundApprovalService.get_request_by_id(request_id)
if not approval:
return jsonify({'code': 404, 'msg': '审批单不存在'}), 404
# ★ 数据权限:普通申请人只能看自己的单;库管/主管/超管(或跨域)可看任意
if not is_privileged_viewer():
identity = get_jwt_identity()
if int(approval.applicant_id or 0) != int(identity or 0):
return jsonify({'code': 403, 'msg': '无权查看他人的出库记录'}), 403
return jsonify({
'code': 200,
'msg': '获取成功',
'data': approval.to_dict()
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500