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:
yueli
2026-09-16 16:45:52 +08:00
parent a252013573
commit 9eb4792d4a
3 changed files with 187 additions and 5 deletions

View File

@ -2479,7 +2479,11 @@ def update_stocktake_quantity():
# 不可丢给后台任务,否则会静默失去审计痕迹。
@bp.route('/<int:stock_id>/change-status', methods=['POST'])
@permission_required('inventory_stocktake:operation')
# ★ 专用权限码。原先搭 inventory_stocktake:operation(盲盘作业)的便车,
# 但「冻结/标不良」是库存状态治理动作,与盘点作业职责不同,审计上不合规。
# 无冒号形式,不触发 _expand_operation_perms 的前缀桥接。
# 权限注册见 db_migrations/add_defective_operation_perms.sql
@permission_required('stock_change_status')
def change_stock_status(stock_id):
"""
变更单条库存行的状态(在库 / 冻结 / 不良品)。
@ -2646,7 +2650,10 @@ def _assert_company_owns(row):
@bp.route('/defective', methods=['GET'])
@permission_required('inventory_stocktake')
# ★ 专用查看权限。原先复用 inventory_stocktake盲盘作业—— 实测 SALES销售
# 角色持有该权限,意味着销售人员能读整份不良品台账,与业务对台账可见性的
# 要求不符。无冒号形式不触发前缀桥接。注册见 add_return_view_support.sql
@permission_required('defective_list')
def list_defective_goods():
"""
不良品在管台账分页查询(供「不良品在管台账」看板页使用)。
@ -2762,7 +2769,13 @@ def _defective_unit_cost(goods):
@bp.route('/return-from-outbound', methods=['POST'])
@permission_required('inventory_stocktake:operation')
# ★ 库管 SOP 专用权限码:退回是实物交接动作,只应由具备库管职责的人员执行。
# 刻意使用**无冒号**的 'outbound_return' 而非 'outbound_list:return'
# 后者会命中 _expand_operation_perms() 的前缀桥接outbound_list 下存在
# outbound_list:operation导致持有该权限的角色被一并放行授权面失控。
# 无冒号码不触发桥接,判定与前端 hasPermission 的精确匹配完全一致。
# DDL/授权见 db_migrations/add_outbound_return_perm.sql
@permission_required('outbound_return')
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods
@prevent_double_submit(lock_timeout=5)
def return_from_outbound():
@ -2826,6 +2839,11 @@ def return_from_outbound():
stock_row = _lock_source_stock_row(outbound.source_table, outbound.stock_id)
_assert_company_owns(stock_row)
# 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块
# 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。
_base = getattr(stock_row, 'base', None)
snapshot_company = ((_base.company_name if _base else '') or '').strip() or None
goods = None
if is_defective:
# ================= 不良品分支 =================
@ -2876,6 +2894,7 @@ def return_from_outbound():
return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD,
reason=reason,
operator=operator_name,
company_name=snapshot_company,
)
db.session.add(ledger)
db.session.flush() # 先拿到 ledger.id供在管台账回填
@ -2913,7 +2932,9 @@ def return_from_outbound():
@bp.route('/defective/<int:goods_id>/restock', methods=['POST'])
@permission_required('inventory_stocktake:operation')
# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。
# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql
@permission_required('defective_restock')
# ★ 幂等锁必须置于 permission_required **内层**prevent_double_submit 依赖
# get_jwt_identity(),若放在外层则 JWT 尚未验证 → 抛错 → 被其 except 捕获
# 后 fail-open 降级放行,锁形同虚设。
@ -3027,7 +3048,9 @@ def restock_defective_goods(goods_id):
@bp.route('/defective/<int:goods_id>/scrap', methods=['POST'])
@permission_required('inventory_stocktake:operation')
# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。
# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql
@permission_required('defective_scrap')
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods
@prevent_double_submit(lock_timeout=5)
def scrap_defective_goods(goods_id):

View File

@ -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):
"""

View File

@ -268,6 +268,15 @@ class TransReturn(db.Model):
operator = db.Column(db.String(100))
return_time = db.Column(db.DateTime, default=beijing_time)
# ★ 公司快照:退回发生时的所属公司。
# 不靠 join 推 —— 隔离判定的链路是
# trans_return → trans_outbound → (source_table, stock_id) → 库存表 → 物料主表
# 而库存行会被入库模块**物理删除**(实测 1077 条出库记录中已有 7 条悬空),
# 链路一断,记录就会对普通用户静默消失。审计视图静默丢数据不可接受。
# 与 trans_defective_goods.company_name 同一处理方式。
# DDL 见 db_migrations/add_return_view_support.sql
company_name = db.Column(db.String(255), index=True)
def to_dict(self):
return {
'id': self.id,
@ -280,6 +289,7 @@ class TransReturn(db.Model):
'reason': self.reason,
'operator': self.operator,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
'company_name': self.company_name,
}