_allocate_bom_requirements 在整轮 for req 循环中复用同一份可用量快照
(rows_by_base 建好后不再更新),而 for 循环本身不按 base_id 去重。同一
物料以多行进入时,每行都从同一份快照重新分配一遍,把同一个 stock_id
重复分配 N 次,累计分配量可超过该行真实可用量。
超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验
(take > avail)才抛错,报「可用库存不足(需 X,实剩 Y)」,而实际库存
充足。实剩为 0.0 是当最大候选批次的可用量恰好等于单行需求时的收尾形态。
重复行来自正常业务,非脏数据:
- 购物车里同一物料的多批次就是多行(Selection.vue 提交时只带
base_id + quantity,stock_id/source_table 被丢弃);
- BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重);
- 调拨 / 补发等拆行场景。
修复:在函数入口按 base_id 合并 reqs、required_qty 求和。分配语义不变
—— 分配器本就按 base_id 拉全量批次行、降序分配,输入里的 stock_id 从来
就被忽略。
选在此处收口而非合并调用方的 items:_allocate_bom_requirements 是全系统
库存分配的唯一权威入口(出库与借库都经 reserve_for_items 走到这里),
一处修改同时覆盖两条链路。
实测(base_id=711,四个批次可用量合计 124):
三行各 30 修复前 ERROR(需30/实剩10) → 修复后 OK,2117 出 70 + 1182 出 20
两行各 30 修复前静默超配(两行都绑 2117) → 修复后 OK,合并为 2117 出 60
十行各 30 修复后报真实缺口「需 300,可用 124」(走正常缺料分支)
1302 lines
54 KiB
Python
1302 lines
54 KiB
Python
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
|
||
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
|
||
|
||
|
||
# ==============================================================================
|
||
# 辅助函数:本单预占索引(扫码阶段回加「本单自己锁掉的货」)
|
||
# ==============================================================================
|
||
# 扫码页面上工人应当能以「实时可用量 + 本单预占量」为上限 —— 本单自己锁定
|
||
# 的货当然扫得进去。若直接用实时 available_quantity,被本单占满的行会显示 0,
|
||
# 工人根本扫不进去(见 create.vue / borrow.vue 的扫码校验)。
|
||
#
|
||
# ★ 两道必须守住的门禁
|
||
# 1) biz_type 区分单据表。outbound_approval 与 borrow_approval 是两张独立
|
||
# 表、ID 空间独立,而 /scan 与 /alternatives 被出库页与借库页**共用**。
|
||
# 只传 request_id 会让借库单 ID 命中另一张出库单,把别人的预占回加到
|
||
# 本行 —— 后端最终仍会拦住(不会真超卖),但表现为「扫完提交被拒」,
|
||
# 比现状更难排查。
|
||
# 2) status ∈ {0, 1}。set_items() 只在创建时调用,执行(3)/驳回(2)/完结(4)
|
||
# 后 items_json 里的 reserved=True / allocated_qty 原样保留,而
|
||
# release_reserved() 早已把库存归还 —— 此时再回加就是凭空多出一份
|
||
# 可用量,且后端也会放行(该 available 真实存在)→ 真超卖。
|
||
# 反之也不能写死 ==1:预占在**提交申请时**就发生(status=0),而两个
|
||
# 审批页默认筛选的就是待审批单。
|
||
#
|
||
# 任何异常一律静默降级为「不回加」:这是读侧辅助接口,报错会直接阻断现场
|
||
# 作业;降级方向是 fail-closed(有效量偏小),最坏提示「库存不足」,绝不超卖。
|
||
def _own_reserved_index(biz_type, request_id):
|
||
"""
|
||
本单预占索引 {(source_table, stock_id): 预占量}。
|
||
|
||
不满足状态门禁 / 单据不存在 / 参数非法时返回 {}(降级为不回加)。
|
||
注意返回的是**实时重算**的结果,不做任何累加,因此草稿反复刷新幂等。
|
||
"""
|
||
if not request_id:
|
||
return {}
|
||
|
||
if (biz_type or '').strip().lower() == 'borrow':
|
||
from app.models.borrow import BorrowApproval as _Approval
|
||
else:
|
||
# 缺省按出库兜底,兼容未传 biz_type 的旧调用方
|
||
from app.models.outbound import OutboundApproval as _Approval
|
||
|
||
try:
|
||
approval = _Approval.query.get(int(request_id))
|
||
except (TypeError, ValueError):
|
||
return {}
|
||
|
||
if not approval:
|
||
current_app.logger.warning(
|
||
f"[reservation] 预占回加降级:单据不存在 biz_type={biz_type} id={request_id}"
|
||
)
|
||
return {}
|
||
|
||
# ★ 二次回加门禁:仅待审批(0)/已通过(1)的单据,其预占才真实存在
|
||
if approval.status not in (0, 1):
|
||
return {}
|
||
|
||
from app.services.inventory_reservation import reserved_index
|
||
return reserved_index(approval.get_items())
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 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
|
||
|
||
# ★ 本单预占回加:biz_type 区分出库/借库两张审批单,缺一不可
|
||
biz_type = (request.args.get('biz_type') or 'outbound').strip()
|
||
request_id = request.args.get('request_id', type=int)
|
||
reserved_map = _own_reserved_index(biz_type, request_id)
|
||
|
||
try:
|
||
# 调用 Service 层去三个表中查找 (Service已更新,会返回价格)
|
||
result = OutboundService.get_stock_by_barcode(barcode, reserved_map)
|
||
|
||
if result:
|
||
# ★ Fail-Closed: 扫码响应剥离价格字段
|
||
result.pop('price', None)
|
||
# 预占是否生效:false 表示已降级为实时可用量(前端可据此提示)
|
||
result['reservation_applied'] = bool(reserved_map)
|
||
return jsonify({
|
||
'code': 200,
|
||
'msg': '扫描成功',
|
||
'data': result
|
||
})
|
||
else:
|
||
return jsonify({
|
||
'code': 404,
|
||
'msg': '未找到对应的库存记录,请确认条码是否正确'
|
||
}), 404
|
||
|
||
except ValueError as e:
|
||
# ★ 业务性拒绝(物料状态异常等):属于「扫到了但按规定不能出」,
|
||
# 不是系统故障 —— 返回 400 + 明确文案,便于前端红字提示工人。
|
||
# 若落到下方 500 分支,前端只会显示「服务器错误」,工人无从判断。
|
||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||
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():
|
||
# 权限检查:有 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', [])
|
||
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', '')
|
||
|
||
# ★ 高级筛选: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,
|
||
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
|
||
|
||
|
||
def _resolve_return_materials(rows):
|
||
"""
|
||
批量解析退回流水对应的物料名称/规格。
|
||
|
||
trans_return 只存 (source_table, stock_id) 多态指针,需回查三张库存表。
|
||
★ 源库存行可能已被物理删除(实测出库记录中已有悬空行),取不到时返回
|
||
空字符串由前端显示占位 —— 刻意**不**因此丢弃该行:退回台账的完整性
|
||
优先于展示美观,缺名字总比少一条记录好。
|
||
"""
|
||
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
|
||
|
||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi,
|
||
'stock_product': StockProduct}
|
||
resolved = {}
|
||
for table, model in model_map.items():
|
||
ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id}
|
||
if not ids:
|
||
continue
|
||
for obj in model.query.options(joinedload(model.base)).filter(
|
||
model.id.in_(ids)).all():
|
||
base = getattr(obj, 'base', None)
|
||
resolved[(table, obj.id)] = {
|
||
'material_name': (base.name if base else '') or '',
|
||
'spec_model': (base.spec_model if base else '') or '',
|
||
}
|
||
return resolved
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 退回流水(只读台账)
|
||
# GET /api/v1/outbound/returns
|
||
# --------------------------------------------------------
|
||
@outbound_bp.route('/returns', methods=['GET'])
|
||
# ★ 专用查看权限,仅授予超管/主管/库管三个核心角色。
|
||
# 无冒号形式不触发 _expand_operation_perms 的前缀桥接,
|
||
# 注册见 db_migrations/add_return_view_support.sql
|
||
@permission_required('outbound_return_list')
|
||
def list_returns():
|
||
"""
|
||
原单退回流水台账(只读,无任何写操作)。
|
||
|
||
Query:
|
||
page / page_size 分页,默认 1 / 20
|
||
keyword 模糊匹配 出库单号 / SKU / 操作人
|
||
return_type '良品' / '不良品';'全部' 或留空 = 不过滤
|
||
start_date / end_date 按退回时间过滤(10 位日期自动补时分秒)
|
||
|
||
★ 行级隔离直接按 trans_return.company_name **快照**过滤,不走 join 链:
|
||
源库存行会被入库模块物理删除,链路一断该记录就会对普通用户静默消失。
|
||
"""
|
||
from app.extensions import db
|
||
from app.utils.decorators import get_current_company_filter
|
||
from app.models.transaction import TransReturn, VALID_RETURN_TYPES
|
||
from app.models.outbound import TransOutbound
|
||
|
||
page = request.args.get('page', 1, type=int) or 1
|
||
page_size = request.args.get('page_size', 20, type=int) or 20
|
||
page_size = min(max(page_size, 1), 200) # 防超大分页拖垮库
|
||
|
||
keyword = (request.args.get('keyword') or '').strip()
|
||
return_type = (request.args.get('return_type') or '').strip()
|
||
start_date = (request.args.get('start_date') or '').strip()
|
||
end_date = (request.args.get('end_date') or '').strip()
|
||
|
||
try:
|
||
query = TransReturn.query
|
||
|
||
# 行级隔离(超管/跨域 company_limit 为 None,不受限)
|
||
company_limit = get_current_company_filter()
|
||
if company_limit is not None:
|
||
query = query.filter(TransReturn.company_name == company_limit)
|
||
|
||
if return_type and return_type not in ('全部', 'all'):
|
||
if return_type not in VALID_RETURN_TYPES:
|
||
return jsonify({
|
||
'code': 400,
|
||
'msg': f'不支持的退回类型:{return_type},'
|
||
f'仅支持 {"、".join(VALID_RETURN_TYPES)}',
|
||
}), 400
|
||
query = query.filter(TransReturn.return_type == return_type)
|
||
|
||
# 日期边界补全时分秒,避免 10 位日期被当成零点截断(与全系统口径一致)
|
||
if start_date and len(start_date) == 10:
|
||
start_date = f'{start_date} 00:00:00'
|
||
if end_date and len(end_date) == 10:
|
||
end_date = f'{end_date} 23:59:59'
|
||
if start_date:
|
||
query = query.filter(TransReturn.return_time >= start_date)
|
||
if end_date:
|
||
query = query.filter(TransReturn.return_time <= end_date)
|
||
|
||
if keyword:
|
||
like = f'%{keyword}%'
|
||
# 出库单号不在本表,先经 trans_outbound 求出命中的 outbound_id 集合
|
||
matched = db.session.query(TransOutbound.id).filter(
|
||
TransOutbound.outbound_no.ilike(like)
|
||
).subquery()
|
||
query = query.filter(db.or_(
|
||
TransReturn.sku.ilike(like),
|
||
TransReturn.operator.ilike(like),
|
||
TransReturn.outbound_id.in_(db.session.query(matched.c.id)),
|
||
))
|
||
|
||
# 默认按退回时间倒序:最新退回的最需要核对
|
||
query = query.order_by(TransReturn.return_time.desc(),
|
||
TransReturn.id.desc())
|
||
pg = query.paginate(page=page, per_page=page_size, error_out=False)
|
||
|
||
rows = pg.items
|
||
|
||
# ---- 批量补出库单号(避免 N+1)----
|
||
outbound_ids = {r.outbound_id for r in rows if r.outbound_id}
|
||
outbound_map = {}
|
||
if outbound_ids:
|
||
for o in TransOutbound.query.filter(
|
||
TransOutbound.id.in_(outbound_ids)).all():
|
||
outbound_map[o.id] = o.outbound_no
|
||
|
||
# ---- 批量补物料名 ----
|
||
mat_map = _resolve_return_materials(rows)
|
||
|
||
items = []
|
||
for r in rows:
|
||
d = r.to_dict()
|
||
d['outbound_no'] = outbound_map.get(r.outbound_id, '')
|
||
info = mat_map.get((r.source_table, r.stock_id)) or {}
|
||
d['material_name'] = info.get('material_name', '')
|
||
d['spec_model'] = info.get('spec_model', '')
|
||
items.append(d)
|
||
|
||
return jsonify({
|
||
'code': 200,
|
||
'msg': 'success',
|
||
'data': {
|
||
'list': items,
|
||
'total': pg.total,
|
||
'page': page,
|
||
'page_size': page_size,
|
||
},
|
||
}), 200
|
||
|
||
except Exception as e:
|
||
traceback.print_exc()
|
||
return jsonify({'code': 500, 'msg': f'查询失败: {str(e)}'}), 500
|
||
|
||
|
||
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 # ★ 必须在此导入:本函数模块级作用域不可见
|
||
|
||
# 归一化需求,容忍字符串数字
|
||
#
|
||
# ★ 必须按 base_id 合并。同一物料会以多行进入本函数,来源都是正常业务:
|
||
# · 购物车里同一物料的多批次就是多行(Selection.vue 提交时只带
|
||
# base_id + quantity,stock_id 被丢弃);
|
||
# · BOM 明细里同一子件被多处引用(前端 requirements 按 child_id 不去重);
|
||
# · 调拨 / 补发等拆行场景。
|
||
# 而下方分配是「按 base_id 拉全量批次行、降序分配」,且候选快照在整轮
|
||
# for req 循环里**不更新** —— 同一个 base_id 出现 N 行,每行都会从同一份
|
||
# 快照重新分配一遍,把同一个 stock_id 重复分配 N 次。
|
||
# 超配在分配阶段不会暴露,直到 reserve_for_items 的二次校验
|
||
# (take > avail)才炸,报「可用库存不足」而实际库存充足。
|
||
# 合并后 required_qty 求和,分配语义不变(输入里的 stock_id 本就被忽略)。
|
||
# ★ 不采用「合并入参 items」的方案:那是症状侧,_allocate_bom_requirements
|
||
# 才是全系统库存分配的唯一权威入口,在此收口可同时覆盖出库与借库。
|
||
reqs = []
|
||
merged = {}
|
||
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
|
||
hit = merged.get(bid)
|
||
if hit is not None:
|
||
hit['required_qty'] += need
|
||
else:
|
||
merged[bid] = {'base_id': bid, 'required_qty': need,
|
||
'name': r.get('name') or '', 'spec_model': r.get('spec_model') or ''}
|
||
reqs = list(merged.values())
|
||
|
||
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 = {}
|
||
|
||
# ★ 状态门槛:本函数是**全系统库存分配的唯一权威入口** ——
|
||
# 出库申请与借库申请都经 reserve_for_items() 走到这里拿候选行,
|
||
# 故在此处加一条即对两者同时生效(报废不走分配器,见 scrap_approval_service)。
|
||
# 规则见 inventory_reservation.allocatable_filter:仅「在库」可被分配,
|
||
# 「冻结」「不良品」以及 status 为 NULL 的行一律不进入候选集。
|
||
#
|
||
# Fail-Closed 说明:若该条件导致查询异常,下方 except 会 continue 掉整张表,
|
||
# 表现为「该物料无库存」而非「放行坏件」,方向上是安全的。
|
||
from app.services.inventory_reservation import allocatable_filter
|
||
|
||
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, # ★ 只取真正可用的行
|
||
allocatable_filter(model), # ★ 硬隔离:非「在库」一律不出货
|
||
)
|
||
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
|
||
|
||
|
||
# ==============================================================================
|
||
# 备选库位查询 (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():
|
||
"""
|
||
查询某物料的全部可替代库位。
|
||
|
||
Query: base_id(必填)、source_table / stock_id(可选,用于标注推荐行)
|
||
biz_type / request_id(可选,出库/借库单据;用于回加本单预占)
|
||
|
||
★ available_quantity 返回的是「有效可用量」= 实时可用量 + 本单在该行的预占量,
|
||
因此过滤条件也相应放宽为「实时可用量 > 0 或 本单预占了该行」。
|
||
否则被本单占满的行会从列表里凭空消失(实时可用量为 0),工人看不到
|
||
自己明明锁定的批次。实时原值另以 raw_available_quantity 返回备查。
|
||
别人的预占不回加,防超卖能力不丢。
|
||
|
||
Returns: { items: [{stock_id, source_table, warehouse_location,
|
||
available_quantity, raw_available_quantity,
|
||
reserved_quantity, is_own_reserved,
|
||
is_locked, typeLabel, sku, batch_number}],
|
||
total_available }
|
||
"""
|
||
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
|
||
|
||
# ★ 本单预占回加(biz_type 区分出库/借库两张审批单)
|
||
biz_type = (request.args.get('biz_type') or 'outbound').strip()
|
||
request_id = request.args.get('request_id', type=int)
|
||
reserved_map = _own_reserved_index(biz_type, request_id)
|
||
|
||
# 按库存表分组本单预占的 stock_id,供 OR 过滤使用
|
||
own_ids = {}
|
||
for (st, sid) in reserved_map:
|
||
own_ids.setdefault(st, set()).add(sid)
|
||
|
||
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 import or_
|
||
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', '成品'),
|
||
):
|
||
# ★ 只给真正能拿的;但本单自己预占的行即使实时可用量为 0 也要给
|
||
# (否则工人看不到自己锁定的批次)。
|
||
# 注意 id 集合为空时不能拼 in_([]):SQLAlchemy 会渲染成恒假
|
||
# 表达式并告警,这里退化为原条件。
|
||
condition = model.available_quantity > 0
|
||
_ids = own_ids.get(source_table)
|
||
if _ids:
|
||
condition = or_(condition, model.id.in_(_ids))
|
||
|
||
q = model.query.filter(model.base_id == base_id, condition)
|
||
# 公司隔离作用于整个 query,保持在 OR 之外
|
||
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:
|
||
raw_avail = float(s.available_quantity or 0)
|
||
reserved = float(reserved_map.get((source_table, s.id), 0) or 0)
|
||
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': raw_avail + reserved,
|
||
'raw_available_quantity': raw_avail,
|
||
'reserved_quantity': reserved,
|
||
# ★ 本单已锁定该行(分配器可能跨批次拆分,故可能是多行)
|
||
'is_own_reserved': reserved > 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),
|
||
'reservation_applied': bool(reserved_map),
|
||
}
|
||
}), 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)
|
||
#
|
||
# ★ 两种用法:
|
||
# 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():
|
||
"""
|
||
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 {}
|
||
requirements = data.get('requirements')
|
||
child_ids = data.get('child_ids', [])
|
||
|
||
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
|
||
|
||
# ------------------------------------------------------------------
|
||
# ★ 分配模式:后端完成跨批次分配
|
||
# ------------------------------------------------------------------
|
||
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()
|
||
@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
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 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
|