Files
KCGL/inventory-backend/app/api/v1/outbound.py
yueli 9eb4792d4a feat(return): 退回流水看板接口与权限收口
新增只读台账接口:
- GET /api/v1/outbound/returns  退回流水(分页 + 关键词 + 类型 + 时间过滤)
  返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 /
  操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按
  多态来源批量解析,均为批量查询无 N+1。

权限收口(配合 db_migrations 里的三个权限码):
- return-from-outbound   inventory_stocktake:operation -> outbound_return
- GET /stock/defective   inventory_stocktake           -> defective_list
- restock                inventory_stocktake:operation -> defective_restock
- scrap                  inventory_stocktake:operation -> defective_scrap
- change-status          inventory_stocktake:operation -> stock_change_status

  原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。
  实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份
  不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后,
  实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。

trans_return 补 company_name 快照:
  退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除
  (实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户
  静默消失。改由退回时落快照,隔离不再依赖任何 join。
2026-09-16 16:45:52 +08:00

1282 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 # ★ 必须在此导入:本函数模块级作用域不可见
# 归一化需求,容忍字符串数字
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 = {}
# ★ 状态门槛:本函数是**全系统库存分配的唯一权威入口** ——
# 出库申请与借库申请都经 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