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