feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离)

背景
----
此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 ——
任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为
**只有该物品的当前持有人本人可以发起**。

改动(前后端同改,缺一不可)
----
· service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细
  current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。
· get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端
  localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。
· 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有
  的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来
  (后端会拒,列出来只会误导)。

★ 连带调整:移除 route 上的 permission_required('borrow_transfer')
  责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限,
  再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人
  反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。
  真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。

  ⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与
    4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口,
    可在此基础上加豁免;若确定不需要,该权限码可择期下线。

验证(13 项断言全通过)
----
· 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒
· 持有人本人发起成功,from_user_id 正确记为持有人
· can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True
· 接收环节不受影响;库存零副作用、数据零残留
This commit is contained in:
yueli
2026-09-17 10:20:34 +08:00
parent 5f0cdc3adb
commit 4bd6765ab4
3 changed files with 57 additions and 15 deletions

View File

@ -527,7 +527,8 @@ class TransService:
# 故同单的其他明细可以同时各自挂着待接收,互不阻塞。)
# ==========================================================================
@staticmethod
def transfer_borrow(borrow_id, to_user_id, transfer_qty=None, operator_name='System', remark=None):
def transfer_borrow(borrow_id, to_user_id, transfer_qty=None, operator_name='System', remark=None,
caller_user_id=None):
"""
发起转交(双向握手第一步):只落一条 PENDING 流水,**不动物权**。
@ -538,9 +539,11 @@ class TransService:
----
borrow_id : 该单**任一明细行**的 ID仅用于解析单据身份borrow_no
to_user_id : 接收人ID
transfer_qty : 可选。传入时须等于整单待还量,仅作一致性校验
operator_name: 发起操作的库管
transfer_qty : 可选。传入时须等于该明细待还量,仅作一致性校验
operator_name: 操作人展示名(写入流水备查)
remark : 转交备注
caller_user_id: **调用者本人ID**,强校验其必须是该明细的当前持有人。
传 None 一律拒绝,不做「系统内部调用」的隐式放行。
返回已 commit 的 TransBorrowTransfer
异常 ValueError
@ -594,6 +597,15 @@ class TransService:
from_id = int(record.current_holder_id)
from_name = record.current_holder_name or user_display_name(SysUser.query.get(from_id))
# --- 3.5 责任链隔离:只有当前持有人本人可以发起转交 ---
# 物品在谁手上,就只能由谁把它交出去 —— 否则任何人都能把别人保管的
# 资产「转」给第三方,责任链形同虚设。
# ★ 前端隐藏按钮只是降噪,**这里才是真正的边界**:接口可被直接调用。
if caller_user_id is None or int(caller_user_id) != from_id:
raise ValueError(
f"只有该物品的当前持有人【{from_name}】本人可以发起转交"
)
# --- 4. 接收人校验 ---
to_user = SysUser.query.get(to_user_id)
if not to_user:
@ -1443,6 +1455,15 @@ class TransService:
and int(_pt['to_user_id']) == int(current_user_id)
)
d['pending_transfer'] = _pt
# ★ 谁能发起转交:**只有该明细当前的持有人本人**。
# 前端据此显示【转交】,后端 transfer_borrow 做同样的强校验 ——
# 界面遮挡不是安全边界,两处必须同口径。
# 同样由后端判定:前端 localStorage 里没有 user_id。
d['can_transfer'] = (
current_user_id is not None
and d.get('current_holder_id') is not None
and int(d['current_holder_id']) == int(current_user_id)
)
else:
_pending_map = {}