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。
This commit is contained in:
@ -278,6 +278,155 @@ def get_outbound_list():
|
||||
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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user