feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列)

背景
----
原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串:
「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时
return_time/return_operator/return_signature 被逐次覆盖,部分归还下
「谁在什么时候还了多少」永久丢失。

本次改动(全部增量,无破坏性 DDL,可回滚)
----
1) trans_borrow 补列
   · borrower_id / current_holder_id / current_holder_name —— 身份锚点
   · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库)
2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新
3) 新建 trans_borrow_return   —— 归还流水,逐次记录,根治部分归还失忆症
4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user;
   未归还的 32 行全部绑定 current_holder
5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms
   前缀桥接把权限放大给所有持有 op_borrow:operation 的角色)

设计取舍
----
· current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。
  这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号,
  归还校验不会对已结清单据误触发。
· 三张表都不建外键:与 trans_return / trans_defective_goods 一致 ——
  库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。
· dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的
  数据源;用申请人或借用人冒充实物交接人比留空更危险。

验证:迁移已对 inventory_db 执行,核对段全部通过。
This commit is contained in:
yueli
2026-09-17 09:17:51 +08:00
parent f4a01be951
commit b998b00889
4 changed files with 434 additions and 0 deletions

View File

@ -0,0 +1,233 @@
-- =============================================================================
-- 借库转交Borrow Transfer一期底层模型改造 + 转交/归还流水表 + 权限码
--
-- 背景
-- 原 trans_borrow 是**单行记录模式**,身份维度只有一个 borrower_name 字符串:
-- · 「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;
-- · 归还时 return_time / return_operator / return_signature 被**逐次覆盖**
-- 部分归还下「谁在什么时候还了多少」永久丢失(失忆症)。
-- 本迁移为转交功能打底并把身份锚点从「姓名」升级为「ID + 姓名快照」。
--
-- 内容分五段:
-- 1) trans_borrow 补三列borrower_id / current_holder_id / current_holder_name
-- 2) 新建 trans_borrow_transfer转交流水
-- 3) 新建 trans_borrow_return (归还流水,治失忆症)
-- 4) 历史数据回填(仅姓名能唯一映射到 sys_user 的行)
-- 5) 注册 borrow_transfer 权限码
--
-- ---------------------------------------------------------------------------
-- 1) 为什么 _id 列允许 NULL而新写入强制带 ID
-- 存量 85 行只有 borrower_name纯姓名其中 17/18 个姓名能唯一映射到
-- sys_userusername 形如「姓名/拼音」。无法映射的如「测试01」保持
-- NULL —— 不回填错误数据,也不阻断迁移。
-- 应用层execute_dispatch对**新数据**强制要求 borrower_id
-- 故 NULL 只可能出现在迁移前的历史行上,是「历史未锚定」的显式标记。
--
-- 2) 为什么 current_holder 只回填「未归还」的行
-- 语义约定current_holder_id 表示**当前实际持有人**。
-- · 未归还的行 → 持有人 = 借用人(转交会在此之上继续推进);
-- · 已全部归还的行 → 物品已回库,无人持有,保持 NULL借用人仍记在
-- borrower_id作为历史
-- 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号
-- 归还校验returner_id 必须等于 current_holder_id不会对已结清单据误触发。
--
-- 3) 为什么借用/归还/转交三张表都不建外键
-- 与 trans_return / trans_defective_goods 的处理一致:库存行会被入库模块
-- 物理删除(实测已有悬空),台账表必须能独立存活,隔离判定不依赖 join 链。
-- 故只建索引,不建 FK 约束。
--
-- 幂等:全部带 IF NOT EXISTS / WHERE NOT EXISTS可重复执行。
-- 执行docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
-- ---------------------------------------------------------------------------
-- 1) trans_borrow 补列
-- ---------------------------------------------------------------------------
-- numeric(19,4) 与 quantity / returned_quantity 同型,保证转交与归还的
-- 数量比较qty = quantity - returned_quantity不引入浮点误差。
ALTER TABLE trans_borrow
ADD COLUMN IF NOT EXISTS borrower_id integer,
ADD COLUMN IF NOT EXISTS current_holder_id integer,
ADD COLUMN IF NOT EXISTS current_holder_name varchar(100);
COMMENT ON COLUMN trans_borrow.borrower_id IS
'初始借用人IDsys_user.id。历史数据回填无法唯一映射时为 NULL';
COMMENT ON COLUMN trans_borrow.current_holder_id IS
'当前实际持有人ID。转交会推进此列已全部归还时为 NULL';
COMMENT ON COLUMN trans_borrow.current_holder_name IS
'当前持有人姓名快照。供列表展示,避免逐行回查 sys_userN+1';
CREATE INDEX IF NOT EXISTS ix_trans_borrow_borrower_id
ON trans_borrow (borrower_id);
CREATE INDEX IF NOT EXISTS ix_trans_borrow_current_holder
ON trans_borrow (current_holder_id);
-- ---------------------------------------------------------------------------
-- 2) trans_borrow_transfer —— 转交流水
--
-- 一行 = 一次转交动作,**不做覆盖式更新**(与 trans_return 同一范式)。
-- 单行模式的主表只能存「当前持有人」A→B→C 的链路全靠本表回答。
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS trans_borrow_transfer (
id serial PRIMARY KEY,
borrow_id integer NOT NULL, -- 关联 trans_borrow.id
from_user_id integer, -- 转出人(转交前的 current_holder
from_user_name varchar(100),
to_user_id integer, -- 接收人(转交后的 current_holder
to_user_name varchar(100),
transfer_qty numeric(19,4) NOT NULL DEFAULT 0, -- 一期恒等于整单待还量
transfer_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
operator_name varchar(100), -- 执行转交操作的库管
remark text
);
COMMENT ON TABLE trans_borrow_transfer IS
'借库转交流水:一行=一次转交,不做覆盖式更新,承载 A→B→C 的完整持有链';
COMMENT ON COLUMN trans_borrow_transfer.transfer_qty IS
'转交数量。一期仅支持整单全量转交,故恒等于 quantity - returned_quantity';
CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_borrow
ON trans_borrow_transfer (borrow_id);
CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_time
ON trans_borrow_transfer (transfer_time);
-- ---------------------------------------------------------------------------
-- 3) trans_borrow_return —— 归还流水(治失忆症)
--
-- 原先部分归还时主表的 return_time / return_operator / return_signature
-- 被逐次覆盖,「谁在什么时候还了多少」只剩最后一次。本表按次记录。
--
-- ★ 主表字段的去留returned_quantity / is_returned / status 仍在主表累加
-- (它们是**累计快照**聚合语义正确return_time / return_operator /
-- return_signature 降级为「最近一次归还」展示快照records.vue 依赖它们
-- 显示归还人/归还时间),权威的逐次明细以本表为准。
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS trans_borrow_return (
id serial PRIMARY KEY,
borrow_id integer NOT NULL, -- 关联 trans_borrow.id
returner_id integer, -- 实际来还东西的人(校验过==current_holder
return_qty numeric(19,4) NOT NULL DEFAULT 0,
return_time timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
operator_name varchar(100) -- 执行还库操作的库管
);
COMMENT ON TABLE trans_borrow_return IS
'借库归还流水:一行=一次归还,替代主表的覆盖式写入,保留分批归还历史';
COMMENT ON COLUMN trans_borrow_return.returner_id IS
'实际归还人ID。写入前已强校验其等于 trans_borrow.current_holder_id';
COMMENT ON COLUMN trans_borrow_return.operator_name IS
'执行还库操作的库管姓名(窗口经手人),与 returner_id 是两个人';
CREATE INDEX IF NOT EXISTS ix_trans_borrow_return_borrow
ON trans_borrow_return (borrow_id);
CREATE INDEX IF NOT EXISTS ix_trans_borrow_return_time
ON trans_borrow_return (return_time);
CREATE INDEX IF NOT EXISTS ix_trans_borrow_return_returner
ON trans_borrow_return (returner_id);
-- ---------------------------------------------------------------------------
-- 4) 历史数据回填
-- 仅在「姓名在 sys_user 中唯一命中」时回填,杜绝重名错绑。
-- ---------------------------------------------------------------------------
-- 4.1 borrower_id全部可唯一映射的行含已归还作历史留痕
UPDATE trans_borrow tb
SET borrower_id = u.id
FROM sys_user u
WHERE tb.borrower_id IS NULL
AND tb.borrower_name IS NOT NULL
AND split_part(u.username, '/', 1) = tb.borrower_name
AND (SELECT count(*) FROM sys_user u2
WHERE split_part(u2.username, '/', 1) = tb.borrower_name) = 1;
-- 4.2 current_holder仅未归还的行已归还=物品已回库,无人持有)
-- 姓名快照同步写入;不信任 borrower_name 的大小写/空格,统一取 sys_user 口径。
UPDATE trans_borrow tb
SET current_holder_id = u.id,
current_holder_name = split_part(u.username, '/', 1)
FROM sys_user u
WHERE tb.current_holder_id IS NULL
AND tb.is_returned = FALSE
AND tb.borrower_name IS NOT NULL
AND split_part(u.username, '/', 1) = tb.borrower_name
AND (SELECT count(*) FROM sys_user u2
WHERE split_part(u2.username, '/', 1) = tb.borrower_name) = 1;
-- ---------------------------------------------------------------------------
-- 5) 注册 borrow_transfer 权限码
--
-- ★ 为什么用**无冒号**形式而不是 op_borrow:transfer
-- 见 add_outbound_return_perm.sql 的论证 —— 形如 <menu>:<action> 的码会
-- 命中后端 _expand_operation_perms() 的前缀桥接,使持有 op_borrow:operation
-- 的角色被自动放行;无冒号码不触发桥接,与前端 hasPermission 精确匹配一致。
--
-- ★ 为什么授予面与 op_borrow:operation 完全一致(含 OUTBOUND
-- 「能执行借出」的人就是窗口前处理实物交接的人,转交是同一动作的延伸。
-- 若业务上要求收紧为仅库管,从下方 VALUES 中移除 'OUTBOUND' 即可。
-- ---------------------------------------------------------------------------
INSERT INTO sys_element (menu_code, name, code, element_type)
SELECT 'op_borrow', '借库转交(库管)', 'borrow_transfer', 'operation'
WHERE NOT EXISTS (
SELECT 1 FROM sys_element WHERE code = 'borrow_transfer'
);
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
SELECT v.role_code, 'borrow_transfer', 'element', NULL
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR'), ('OUTBOUND')) AS v(role_code)
WHERE NOT EXISTS (
SELECT 1 FROM sys_role_permission r
WHERE r.role_code = v.role_code
AND r.target_code = 'borrow_transfer'
AND r.type = 'element'
);
COMMIT;
-- =============================================================================
-- 执行后核对
-- =============================================================================
\echo '--- 1) trans_borrow 三个新列 ---'
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = 'trans_borrow'
AND column_name IN ('borrower_id','current_holder_id','current_holder_name')
ORDER BY column_name;
\echo '--- 2) 两张新表 ---'
SELECT table_name FROM information_schema.tables
WHERE table_name IN ('trans_borrow_transfer','trans_borrow_return')
ORDER BY table_name;
\echo '--- 3) 回填覆盖率(应 total=85borrower_id 回填 84holder 回填=未归还行数)---'
SELECT count(*) AS total,
count(borrower_id) AS borrower_id_filled,
count(*) FILTER (WHERE borrower_id IS NULL) AS borrower_id_null,
count(*) FILTER (WHERE is_returned = FALSE) AS unreturned,
count(current_holder_id) AS holder_filled
FROM trans_borrow;
\echo '--- 4) 未能锚定的历史行预期测试01---'
SELECT id, borrow_no, borrower_name, is_returned
FROM trans_borrow WHERE borrower_id IS NULL ORDER BY id;
\echo '--- 5) 权限码与授权(应 1 元素 + 4 角色)---'
SELECT e.code, e.name, e.menu_code, e.element_type
FROM sys_element e WHERE e.code = 'borrow_transfer';
SELECT role_code, COALESCE(company_name, '<NULL>') AS company_name
FROM sys_role_permission WHERE target_code = 'borrow_transfer' ORDER BY role_code;
-- =============================================================================
-- 回滚段
-- =============================================================================
-- BEGIN;
-- DELETE FROM sys_role_permission WHERE target_code = 'borrow_transfer';
-- DELETE FROM sys_element WHERE code = 'borrow_transfer';
-- DROP TABLE IF EXISTS trans_borrow_return;
-- DROP TABLE IF EXISTS trans_borrow_transfer;
-- ALTER TABLE trans_borrow
-- DROP COLUMN IF EXISTS borrower_id,
-- DROP COLUMN IF EXISTS current_holder_id,
-- DROP COLUMN IF EXISTS current_holder_name;
-- COMMIT;