feat(borrow): 转交粒度下沉到明细行,支持部分转交

背景(业务方推翻上一轮约束)
----
上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个
持有人」当作 bug 去修。业务方验收后明确纠正:

    物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人),
    单内多持有人才是符合现实的正常状态。

故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。

改动
----
· transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。
  转出方 = 该行当前持有人;数量 = 该行待还量。
· accept_transfer:只转移 transfer.borrow_id 指向的那一行 ——
  整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。
· 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」:
  同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。
· get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no),
  否则同单多项待接收会互相覆盖。
· 删除已无用的 _load_slip_for_update。

★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id,
  「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中
  「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用;
  接口对传入的非整行数量会明确提示「应另立一条明细行」。

数据层
----
无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与
分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。
存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动**
—— 它现在不再是 bug,而是部分转交的正常形态。

验证(合成 2 明细单,21 项断言全通过)
----
· 只转工具A:工具B 完全不受影响
· 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒
· accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人)
· 两个持有人、以及待接收人,三方各自都能在列表中看到该单
· pending_transfer 挂在正确的明细行上,is_mine 判定正确
· reject 后主表持有人不变;非整行数量被拒并提示拆行
· 全程 available_quantity 无变化,库存精确还原、零残留数据
This commit is contained in:
yueli
2026-09-17 10:12:31 +08:00
parent 263f7ba3a1
commit 7d9cdeb297
3 changed files with 147 additions and 77 deletions

View File

@ -512,30 +512,20 @@ class TransService:
raise e
# ==========================================================================
# 借库转交(一期 + 双向握手)
# 借库转交(双向握手 + 明细行粒度
#
# 状态机:
# PENDING ──accept──> ACCEPTED (主表 current_holder 正式转移)
# └───reject──> REJECTED (主表不动,责任仍在原持有人)
#
# ★ 覆盖范围是**整张单**borrow_no),不是单行明细 —— 原实现收明细行 ID
# 只改一行,一张 2 明细的单转交后一半归新接收人、一半仍是原借用人
# 前端按单号聚合便同时显示两个名字(实测 BOR-20260917-0001
# ★ 转交粒度 = **明细行**trans_borrow.id),不是整张单。
# 物理现场经常只转交部分工具(借了 2 件、只把 1 件给别人)
# 一张单下的不同明细归属不同持有人是**正常业务形态**,不是需要修复的
# 「单内撕裂」。前端按单号聚合时需自行处理「多人持有」的展示。
#
# (唯一性约束也随之从「单号至多一条 PENDING」下沉为「明细行至多一条」
# 故同单的其他明细可以同时各自挂着待接收,互不阻塞。)
# ==========================================================================
@staticmethod
def _load_slip_for_update(borrow_no):
"""
按单号锁定整单并返回全部明细行(含已归还的)。
★ 按 id 升序加锁:并发下所有事务以相同顺序取行锁,避免与归还/转交
交叉加锁造成死锁(与 execute_dispatch 的 items.sort 同一考虑)。
"""
return (TransBorrow.query
.filter(TransBorrow.borrow_no == borrow_no)
.order_by(TransBorrow.id.asc())
.with_for_update()
.all())
@staticmethod
def transfer_borrow(borrow_id, to_user_id, transfer_qty=None, operator_name='System', remark=None):
"""
@ -564,47 +554,45 @@ class TransService:
except (TypeError, ValueError):
raise ValueError("接收人 to_user_id 格式无效应为数字ID")
anchor = TransBorrow.query.get(borrow_id)
if not anchor:
# ★ 转交粒度 = **明细行**trans_borrow.id不是整张单。
# 物理现场经常只转交部分工具(借了 2 件、只把 1 件给别人),
# 一张单下的不同明细本就允许归属不同持有人 —— 那是正常业务形态,
# 不是需要修复的「撕裂」。
record = TransBorrow.query.with_for_update().get(borrow_id)
if not record:
raise ValueError("借出记录不存在")
borrow_no = anchor.borrow_no
if not borrow_no:
raise ValueError("该借出记录缺少单号,无法转交")
borrow_no = record.borrow_no
rows = TransService._load_slip_for_update(borrow_no)
open_rows = [r for r in rows if not r.is_returned]
# --- 1. 状态准入:这一行必须还在外 ---
if record.is_returned:
raise ValueError("该明细已归还,无可转交的实物")
if record.status == 'scrapped':
raise ValueError("该明细已转入报废流程,不可转交")
# --- 1. 状态准入 ---
if not open_rows:
raise ValueError("该借用单已全部归还,无可转交的实物")
if any(r.status == 'scrapped' for r in open_rows):
raise ValueError("该借用单已转入报废流程,不可转交")
# --- 2. 数量:整单全量,不接受部分转交 ---
total_pending = sum(
float(r.quantity or 0) - float(r.returned_quantity or 0) for r in open_rows
)
if total_pending <= 0:
raise ValueError("该借用单待还数量为 0无可转交的实物")
# --- 2. 数量:整行转交 ---
# 一行只能有一个 current_holder_id故不支持「同一行只转一部分」——
# 那需要把这行拆成两行。经业务确认,现场场景中「借 2 件转 1 件」
# 的两件本就是两条明细行,故此限制不影响实际使用。
pending_qty = float(record.quantity or 0) - float(record.returned_quantity or 0)
if pending_qty <= 0:
raise ValueError("该明细待还数量为 0无可转交的实物")
if transfer_qty is not None:
try:
transfer_qty = float(transfer_qty)
except (TypeError, ValueError):
raise ValueError("转交数量格式无效,应为数字")
if abs(transfer_qty - total_pending) > 1e-6:
if abs(transfer_qty - pending_qty) > 1e-6:
raise ValueError(
f"目前仅支持整单全部转交:本单待还 {total_pending}"
f"本次仅转交 {transfer_qty}。部分转交会让同一张单出现两个持有人"
f"请整单转交,或先办理部分归还后再转交"
f"转交粒度是整条明细:该明细待还 {pending_qty}"
f"本次填写 {transfer_qty}若需转交其中一部分,"
f"该部分应为另一条明细行"
)
# --- 3. 转出方 = 所选明细当前的持有人 ---
# 正常单据内各明细持有人一致;历史遗留的「单内撕裂」以所选明细为准,
# accept 时会把该单未还明细**整体归一**到接收人名下(见 accept_transfer
if anchor.current_holder_id is None:
raise ValueError("该借出记录的当前持有人未锚定(历史数据),无法转交,请先办理归还")
from_id = int(anchor.current_holder_id)
from_name = anchor.current_holder_name or user_display_name(SysUser.query.get(from_id))
# --- 3. 转出方 = 明细当前的持有人 ---
if record.current_holder_id is None:
raise ValueError("该明细的当前持有人未锚定(历史数据),无法转交,请先办理归还")
from_id = int(record.current_holder_id)
from_name = record.current_holder_name or user_display_name(SysUser.query.get(from_id))
# --- 4. 接收人校验 ---
to_user = SysUser.query.get(to_user_id)
@ -614,33 +602,35 @@ class TransService:
if to_user_id == from_id:
raise ValueError(f"接收人与当前持有人同为【{to_user_name}】,无需转交")
# --- 5. 同一单号只允许一条待接收流水(否则两个接收人争抢同一批实物)---
# --- 5. 唯一性下沉到明细行:同一行至多一条待接收 ---
# (同一张单的**其他**明细可以同时各自挂一条,互不影响 ——
# 这正是部分转交要表达的语义)
pending = TransBorrowTransfer.query.filter(
TransBorrowTransfer.borrow_no == borrow_no,
TransBorrowTransfer.borrow_id == record.id,
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
).first()
if pending:
raise ValueError(
f"借用单已有一条待接收的转交(接收人:"
f"物品已有一条待接收的转交(接收人:"
f"{pending.to_user_name or pending.to_user_id}),请等待对方处理"
)
# --- 6. 行级公司隔离Fail-Closed---
_assert_borrow_company_visible(anchor)
_assert_borrow_company_visible(record)
# ==================================================================
# ★ 只写台账,主表 current_holder **保持不变** —— 双向握手的关键。
# 库存字段更是一律不碰(转交是纯持有权变更,实物不出入库)。
# ==================================================================
transfer = TransBorrowTransfer(
borrow_id=anchor.id,
borrow_id=record.id,
borrow_no=borrow_no,
status=TRANSFER_STATUS_PENDING,
from_user_id=from_id,
from_user_name=from_name,
to_user_id=to_user_id,
to_user_name=to_user_name,
transfer_qty=total_pending,
transfer_qty=pending_qty,
transfer_time=beijing_time(),
operator_name=operator_name,
remark=remark,
@ -658,12 +648,12 @@ class TransService:
"""
接收转交(双向握手第二步):流水置 ACCEPTED并**正式转移持有权**。
覆盖范围 = 该单号下**全部未还明细**。正常单据各明细持有人一致,整批转移
天然无歧义;若遇历史遗留的「单内撕裂」,此处的整批归一同时把它修复 ——
一张单本就只应有一个持有人
覆盖范围 = 该转交指向的**单条明细**。同一张单的其他明细不受影响 ——
「借 2 件只转 1 件」时,那 1 件到接收人名下,另 1 件仍在原持有人手上,
这是正常业务形态
权限:仅 to_user_id 本人(这是员工对自己名下资产的确认,不是库管权限)。
返回 (transfer, 本次转移的明细行)
返回 (transfer, 转移的明细行)
"""
transfer = TransBorrowTransfer.query.with_for_update().get(transfer_id)
if not transfer:
@ -675,19 +665,22 @@ class TransService:
if not transfer.borrow_no:
raise ValueError("该转交记录缺少单号(历史数据),无法确认接收")
rows = TransService._load_slip_for_update(transfer.borrow_no)
open_rows = [r for r in rows if not r.is_returned]
if not open_rows:
raise ValueError("该借用单已全部归还,无需接收")
# ★ 只转移 transfer.borrow_id 指向的**那一行**
# 转交粒度是明细行,同单的其他明细可能挂在别人名下(部分转交),
# 整批改写会把别人手上的东西一并抢过来。
record = TransBorrow.query.with_for_update().get(transfer.borrow_id)
if not record:
raise ValueError("转交目标明细已不存在(可能已被删除)")
if record.is_returned:
raise ValueError("该明细已归还,无需接收")
to_name = transfer.to_user_name
if not to_name:
from app.models.system import SysUser
to_name = user_display_name(SysUser.query.get(transfer.to_user_id))
for r in open_rows:
r.current_holder_id = int(transfer.to_user_id)
r.current_holder_name = to_name
record.current_holder_id = int(transfer.to_user_id)
record.current_holder_name = to_name
transfer.status = TRANSFER_STATUS_ACCEPTED
try:
@ -695,7 +688,7 @@ class TransService:
except Exception as e:
db.session.rollback()
raise e
return transfer, len(open_rows)
return transfer, record
@staticmethod
def reject_transfer(transfer_id, user_id, reason=None):
@ -1430,14 +1423,16 @@ class TransService:
# 应用层保证同一单号最多一条 PENDING故 borrow_no 可直接作键。
# ====================================================================
if items_with_names:
_bnos = [d.get('borrow_no') for d in items_with_names if d.get('borrow_no')]
# ★ 按**明细行**borrow_id关联不是单号转交粒度已下沉到明细
# 同一张单可能只有其中一件挂着待接收,其余仍是原持有人。
_ids = [d.get('id') for d in items_with_names if d.get('id')]
_pending = TransBorrowTransfer.query.filter(
TransBorrowTransfer.borrow_no.in_(_bnos),
TransBorrowTransfer.borrow_id.in_(_ids),
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
).all() if _bnos else []
_pending_map = {t.borrow_no: t.to_dict() for t in _pending}
).all() if _ids else []
_pending_map = {t.borrow_id: t.to_dict() for t in _pending}
for d in items_with_names:
_pt = _pending_map.get(d.get('borrow_no'))
_pt = _pending_map.get(d.get('id'))
if _pt is not None:
# ★ is_mine 由后端判定:前端 localStorage 里只有 username
# 没有 user_id靠姓名比对既有歧义又不可靠。