Files
KCGL/db_migrations/phase4_borrow_transfer.sql
yueli b998b00889 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 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00

234 lines
12 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- =============================================================================
-- 借库转交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;