From b271ca3a497ceeb6cf2040fa5d934c78b5128ec9 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 10:04:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(borrow):=20=E8=BD=AC=E4=BA=A4=E6=B5=81?= =?UTF-8?q?=E6=B0=B4=E8=A1=A8=E8=A1=A5=20borrow=5Fno=20=E4=B8=8E=20status?= =?UTF-8?q?=EF=BC=88=E5=8F=8C=E5=90=91=E6=8F=A1=E6=89=8B=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 1) **单内撕裂**:原 /transfer 收的是**明细行 ID**,只更新那一行。一张单有 2 条明细时,转交后一条归接收人、另一条仍是原借用人 —— 前端按 borrow_no 聚合便同时显示两个名字(实测 BOR-20260917-0001:108→测试 / 109→杜邢宸)。 2) **单向强塞**:原实现发起即生效,接收人在毫不知情的情况下背上资产责任。 本次改动 ---- · 新增 borrow_no —— 单据身份。一次转交要覆盖该单**多行**,单行 ID 表达不了 覆盖范围;accept 时据此批量更新。borrow_id 保留为「发起时的代表明细」供追溯。 · 新增 status —— PENDING / ACCEPTED / REJECTED 状态机。 · 存量 1 行按旧语义(发起即生效)标记为 ACCEPTED 并回填 borrow_no: 它事实上已经生效,若标 PENDING,接收人会收到一条早已生效的待办。 · 建 borrow_no / status / (to_user_id,status) 索引,后者支撑「待我接收」查询。 同单号同一时刻只允许一条 PENDING(应用层强制),避免两个接收人争抢同一批实物。 --- .../phase4c_borrow_transfer_handshake.sql | 93 +++++++++++++++++++ inventory-backend/app/models/transaction.py | 21 ++++- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 db_migrations/phase4c_borrow_transfer_handshake.sql diff --git a/db_migrations/phase4c_borrow_transfer_handshake.sql b/db_migrations/phase4c_borrow_transfer_handshake.sql new file mode 100644 index 0000000..8d22bd7 --- /dev/null +++ b/db_migrations/phase4c_borrow_transfer_handshake.sql @@ -0,0 +1,93 @@ +-- ============================================================================= +-- 借库转交 · 双向握手 + 整单覆盖 +-- +-- 本次解决两个已发生的问题: +-- 1) **单内撕裂**:原 /transfer 收的是**明细行 ID**,只更新那一行。 +-- 一张单有 2 条明细时,转交后 108 归属接收人、109 仍是原借用人 —— +-- 前端按 borrow_no 聚合,两个名字同时出现(实测 BOR-20260917-0001)。 +-- 修复方向:转交必须按 **borrow_no** 覆盖该单全部未还明细。 +-- 2) **单向强塞**:原实现发起即生效,接收人「被通知式」地背上资产责任。 +-- 改为双向握手:发起只落 PENDING,接收人 accept 后才真正转移责任。 +-- +-- --------------------------------------------------------------------------- +-- 1) 为什么新增 borrow_no 而不是复用 borrow_id +-- borrow_id 是**明细行**主键,一条转交要覆盖该单多行,用单行 ID 无法表达 +-- 「本次转交覆盖了哪些行」。borrow_no 是单据身份,accept 时据此批量更新。 +-- borrow_id 保留为「发起时的代表明细」,供追溯与旧前端兼容。 +-- +-- 2) 为什么存量行标记为 ACCEPTED +-- 旧语义是「发起即生效」—— 写流水的同时就改了主表 current_holder。 +-- 本表现有 1 行(BOR-20260917-0001)已是生效状态,标为 ACCEPTED 才符合 +-- 事实;若标 PENDING,接收人会收到一条早已生效的待办。 +-- +-- 3) 状态机 +-- PENDING ──accept──> ACCEPTED (主表 current_holder 正式转移) +-- └────reject──> REJECTED (主表不动,责任仍在原持有人) +-- 同一单号同一时刻只允许一条 PENDING(应用层强制),避免两个接收人 +-- 争抢同一批实物。 +-- +-- 幂等:带 IF NOT EXISTS,可重复执行。 +-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= + +BEGIN; + +ALTER TABLE trans_borrow_transfer + ADD COLUMN IF NOT EXISTS borrow_no varchar(100), + ADD COLUMN IF NOT EXISTS status varchar(20) NOT NULL DEFAULT 'PENDING'; + +COMMENT ON COLUMN trans_borrow_transfer.borrow_no IS + '借用单号(单据身份)。一次转交覆盖该单号下全部未还明细,accept 时据此批量更新'; +COMMENT ON COLUMN trans_borrow_transfer.status IS + 'PENDING 待接收 / ACCEPTED 已接收(主表已转移)/ REJECTED 已拒绝(主表未动)'; +COMMENT ON COLUMN trans_borrow_transfer.borrow_id IS + '发起转交时的代表明细ID(trans_borrow.id),仅供追溯;覆盖范围以 borrow_no 为准'; + +-- 存量行:按旧语义(发起即生效)回填单号并标记为已接收 +UPDATE trans_borrow_transfer t + SET borrow_no = tb.borrow_no, + status = 'ACCEPTED' + FROM trans_borrow tb + WHERE t.borrow_id = tb.id + AND t.status = 'PENDING'; + +CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_no + ON trans_borrow_transfer (borrow_no); +CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_status + ON trans_borrow_transfer (status); +-- 支撑「待我接收」的可见性与列表查询 +CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_to_user + ON trans_borrow_transfer (to_user_id, status); + +COMMIT; + + +-- ============================================================================= +-- 执行后核对 +-- ============================================================================= +\echo '--- 1) 新列已就位 ---' +SELECT column_name, data_type, column_default + FROM information_schema.columns + WHERE table_name = 'trans_borrow_transfer' + AND column_name IN ('borrow_no', 'status') + ORDER BY column_name; + +\echo '--- 2) 存量行状态(应为 ACCEPTED 且 borrow_no 已回填)---' +SELECT id, borrow_id, borrow_no, status, from_user_name, to_user_name + FROM trans_borrow_transfer ORDER BY id; + +\echo '--- 3) 不应存在无单号的流水(应为 0)---' +SELECT count(*) AS 缺单号 FROM trans_borrow_transfer WHERE borrow_no IS NULL; + + +-- ============================================================================= +-- 回滚段 +-- ============================================================================= +-- BEGIN; +-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_no; +-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_status; +-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_to_user; +-- ALTER TABLE trans_borrow_transfer +-- DROP COLUMN IF EXISTS borrow_no, +-- DROP COLUMN IF EXISTS status; +-- COMMIT; diff --git a/inventory-backend/app/models/transaction.py b/inventory-backend/app/models/transaction.py index 666c537..fcf3028 100644 --- a/inventory-backend/app/models/transaction.py +++ b/inventory-backend/app/models/transaction.py @@ -143,15 +143,27 @@ class TransBorrow(db.Model): # ============================================================================= +# 转交状态机:发起只落 PENDING,接收人 accept 后才真正转移责任 +TRANSFER_STATUS_PENDING = 'PENDING' # 待接收(主表 current_holder 未动) +TRANSFER_STATUS_ACCEPTED = 'ACCEPTED' # 已接收(主表已转移) +TRANSFER_STATUS_REJECTED = 'REJECTED' # 已拒绝(主表不动,责任仍在原持有人) + + class TransBorrowTransfer(db.Model): - """借库转交流水:一行 = 一次转交,不做覆盖式更新。""" + """借库转交流水:一行 = 一次转交动作,不做覆盖式更新。""" __tablename__ = 'trans_borrow_transfer' id = db.Column(db.Integer, primary_key=True) # 关联主表(不建 FK:与 trans_return 一致,台账必须能独立存活) + # ★ 代表明细:一次转交要覆盖该单号下**多行**,单行 ID 表达不了覆盖范围, + # 故覆盖范围以 borrow_no 为准,此列仅供追溯。 borrow_id = db.Column(db.Integer, nullable=False, index=True) + # ★ 单据身份:accept 时据此批量更新该单全部未还明细的持有人 + borrow_no = db.Column(db.String(100), index=True) + # ★ 状态机:见文件顶部常量 + status = db.Column(db.String(20), nullable=False, default=TRANSFER_STATUS_PENDING, index=True) # 转出方(= 转交前的 current_holder) from_user_id = db.Column(db.Integer) @@ -172,6 +184,13 @@ class TransBorrowTransfer(db.Model): return { 'id': self.id, 'borrow_id': self.borrow_id, + 'borrow_no': self.borrow_no, + 'status': self.status, + 'status_text': { + TRANSFER_STATUS_PENDING: '待接收', + TRANSFER_STATUS_ACCEPTED: '已接收', + TRANSFER_STATUS_REJECTED: '已拒绝', + }.get(self.status, self.status), 'from_user_id': self.from_user_id, 'from_user_name': self.from_user_name, 'to_user_id': self.to_user_id,