From 7d9cdeb2975f093f067275eb1a21af602355e2b4 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 10:12:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(borrow):=20=E8=BD=AC=E4=BA=A4=E7=B2=92?= =?UTF-8?q?=E5=BA=A6=E4=B8=8B=E6=B2=89=E5=88=B0=E6=98=8E=E7=BB=86=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E9=83=A8=E5=88=86=E8=BD=AC=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景(业务方推翻上一轮约束) ---- 上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个 持有人」当作 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 无变化,库存精确还原、零残留数据 --- .../phase4d_borrow_transfer_item_level.sql | 74 ++++++++++ inventory-backend/app/api/v1/transactions.py | 13 +- .../app/services/trans_service.py | 137 +++++++++--------- 3 files changed, 147 insertions(+), 77 deletions(-) create mode 100644 db_migrations/phase4d_borrow_transfer_item_level.sql diff --git a/db_migrations/phase4d_borrow_transfer_item_level.sql b/db_migrations/phase4d_borrow_transfer_item_level.sql new file mode 100644 index 0000000..ba701da --- /dev/null +++ b/db_migrations/phase4d_borrow_transfer_item_level.sql @@ -0,0 +1,74 @@ +-- ============================================================================= +-- 借库转交 · 粒度下沉到明细行(部分转交) +-- +-- 背景(业务方推翻上一轮约束) +-- 上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现 +-- 多个持有人」当作 bug 去修。业务方验收后明确纠正: +-- 物理现场经常只转交部分工具(借了 2 件,只把 1 件转给别人), +-- **单内多持有人才是符合现实的正常状态**。 +-- 故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。 +-- +-- --------------------------------------------------------------------------- +-- 本次改动的实质 +-- 代码层:transfer_borrow / accept_transfer 只操作**一行**明细, +-- 唯一性约束从「单号最多一条 PENDING」改为「明细行最多一条 PENDING」。 +-- 数据层:**无需改动任何表结构** —— borrow_id(明细行)本就是流水的主键 +-- 关联列,borrow_no 继续保留作单据归属与展示分组用。 +-- 仅补一个复合索引,支撑「按明细行查待接收流水」这一新查询路径。 +-- +-- ★ 为什么不需要新的列 +-- 转交粒度既然回到明细行,覆盖范围就是 borrow_id 指向的那一行本身 —— +-- 不需要额外的「覆盖清单」来表达范围,borrow_no 退化为分组/展示用途。 +-- +-- ★ 存量数据不动(业务方选择) +-- BOR-20260917-0001 的「测试 / 杜邢宸」双持有人状态予以保留: +-- 它现在不再是 bug,而是「部分转交」的正常业务形态。 +-- +-- 幂等:带 IF NOT EXISTS,可重复执行。 +-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= + +BEGIN; + +-- 支撑「该明细行是否已有待接收流水」的唯一性检查,以及按明细行批量取待接收 +CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_borrow_status + ON trans_borrow_transfer (borrow_id, status); + +COMMENT ON COLUMN trans_borrow_transfer.borrow_no IS + '借用单号。仅用于单据归属与列表分组展示;转交的**覆盖范围**是 borrow_id 指向的单个明细行'; +COMMENT ON COLUMN trans_borrow_transfer.borrow_id IS + '转交目标明细行ID(trans_borrow.id)。转交粒度 = 明细行,一行最多一条待接收流水'; + +COMMIT; + + +-- ============================================================================= +-- 执行后核对 +-- ============================================================================= +\echo '--- 1) 复合索引已就位 ---' +SELECT indexname FROM pg_indexes + WHERE tablename = 'trans_borrow_transfer' + AND indexname = 'ix_trans_borrow_transfer_borrow_status'; + +\echo '--- 2) 存量流水(borrow_id / borrow_no / status)---' +SELECT id, borrow_id, borrow_no, status, from_user_name, to_user_name + FROM trans_borrow_transfer ORDER BY id; + +\echo '--- 3) 各明细行的待接收流水数(应全部 <= 1)---' +SELECT borrow_id, count(*) AS 待接收数 + FROM trans_borrow_transfer WHERE status = 'PENDING' + GROUP BY borrow_id HAVING count(*) > 1; + +\echo '--- 4) 单内多持有人的单号(现在属正常业务形态,不再视为异常)---' +SELECT borrow_no, count(DISTINCT current_holder_id) AS 持有人数, + string_agg(DISTINCT coalesce(current_holder_name,'NULL'), ', ') AS 持有人 + FROM trans_borrow WHERE is_returned = FALSE + GROUP BY borrow_no HAVING count(DISTINCT current_holder_id) > 1; + + +-- ============================================================================= +-- 回滚段 +-- ============================================================================= +-- BEGIN; +-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_borrow_status; +-- COMMIT; diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py index 74828da..b3593c8 100644 --- a/inventory-backend/app/api/v1/transactions.py +++ b/inventory-backend/app/api/v1/transactions.py @@ -636,8 +636,9 @@ def transfer_borrow(borrow_id): 东西还没到接收人手上,责任仍归原持有人 —— 接收人在自己的列表里确认 (POST /borrow/transfer//accept)后才真正转移。 - ★ 覆盖范围是**整张单**(borrow_no)的全部未还明细,不是传入的这一行, - 避免同一张单出现两个持有人。 + ★ 转交粒度 = **明细行**(传入的 borrow_id 就是目标)。同一张单的其他明细 + 不受影响,故「借 2 件只转 1 件」得到天然支持;同单不同明细归属不同持有人 + 是正常业务形态。 ★ 严禁触碰库存:转交是纯持有权变更,实物不出入库, stock_buy / stock_semi / stock_product 的任何字段都不会被修改。 @@ -677,17 +678,17 @@ def accept_borrow_transfer(transfer_id): ★ 权限:**不加 permission_required**。这不是库管职权,而是员工对自己名下 资产的确认动作;service 层强校验当前登录人 == to_user_id 本人。 - ★ 副作用:该单号下全部未还明细的 current_holder 一并改为接收人。 - 转交是整单行为,不允许单内出现两个持有人。 + ★ 副作用:**仅**该转交指向的那一条明细的 current_holder 改为接收人。 + 同单的其他明细可能挂在别人名下(部分转交),一律不动。 """ try: - transfer, affected = TransService.accept_transfer( + transfer, record = TransService.accept_transfer( transfer_id=transfer_id, user_id=get_jwt_identity(), ) return jsonify({ 'code': 200, - 'msg': f'已接收,{affected} 项资产的持有权已转移到您名下', + 'msg': f'已接收,物品【{record.sku}】的持有权已转移到您名下', 'data': transfer.to_dict(), }), 200 except ValueError as e: diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 9fa6579..05af917 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -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,靠姓名比对既有歧义又不可靠。