From d6234622e8b22159f3506a096c3106bb4a1de8f9 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 10:49:34 +0800 Subject: [PATCH] =?UTF-8?q?chore(db):=20=E5=80=9F=E5=BA=93=E8=BD=AC?= =?UTF-8?q?=E4=BA=A4=E5=AE=8C=E6=95=B4=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=88=E7=94=9F=E4=BA=A7=E5=8F=AF=E6=89=A7=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把本轮 6 个迁移(phase4 / 4b / 4c / 4d / 4e / 4f)按依赖顺序合并为一份 可直接在生产执行的脚本,并在一个模拟「部署前状态」的临时库上完整验证。 内容 ---- 1. trans_borrow 补 4 列(borrower_id / current_holder_id / current_holder_name / dispatch_operator)+ 2 索引 2. trans_borrow_transfer 建表(最终形态)+ 7 索引 3. trans_borrow_return 建表 + 3 索引 4. 回填 trans_borrow 身份锚点(仅唯一命中者,重名/无法映射留 NULL) 5. 回填遗留转交流水的 borrow_no 与状态 6. 回填 reject_seen_at(存量拒收标记为已告知) 7. 从 remark 拆出被拼接的 reject_reason 8.(注释掉)borrow_transfer 权限码 —— 已无代码引用,默认不建 + 执行后核对段 + 回滚段 ★ 验证中发现并修掉两个真实缺陷(都不是「看起来能跑」能暴露的) 1) 顺序缺陷:第 5 段原先把遗留流水**一律**标成 ACCEPTED,导致第 6/7 段 按 status='REJECTED' 找行时一条都匹配不到(旧结构表里 status 是刚加的 列、全是默认 PENDING)。真正的信号在备注的 '[拒绝原因]' 标记里, 现据此还原真实状态。 2) 孤儿流水:第 5 段按 borrow_id 关联,来源借用行已被删除的流水匹配不上, 会永久停在 PENDING —— 在接收人那里变成谁也处理不掉的幽灵待办。 已加兜底把「没有单号」的遗留行一律结掉。 ★ 两处 ⚠ 警示已写入脚本:第 5/6/7 段设计为**新代码上线前执行一次**; 若在功能已投产后重跑,会把当时真实的待接收/待告知记录误标。 验证方式:建临时库复刻部署前结构(旧 trans_borrow + 旧结构转交流水表 + 重名/无法映射/已归还/孤儿等边界数据),执行脚本后核对:DDL 与索引齐全、 身份锚点按唯一性正确回填、遗留流水状态从备注还原、原因正确拆出、 孤儿流水被结掉、无报错;再执行第二遍确认幂等(结果完全一致)。 --- db_migrations/deploy_borrow_transfer_all.sql | 326 +++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 db_migrations/deploy_borrow_transfer_all.sql diff --git a/db_migrations/deploy_borrow_transfer_all.sql b/db_migrations/deploy_borrow_transfer_all.sql new file mode 100644 index 0000000..3073663 --- /dev/null +++ b/db_migrations/deploy_borrow_transfer_all.sql @@ -0,0 +1,326 @@ +-- ============================================================================= +-- 借库转交(Borrow Transfer)完整部署脚本 +-- +-- 本文档把本轮全部库变更按依赖顺序合并为一个脚本,可直接在生产执行。 +-- 对应开发环境已执行的 6 个脚本:phase4 / 4b / 4c / 4d / 4e / 4f。 +-- +-- 执行 +-- docker exec -i psql -U <用户> -d <库名> -v ON_ERROR_STOP=1 < 本文件 +-- (含 \echo 核对段,需用 psql;其他客户端请删掉 \echo 行) +-- +-- 幂等 +-- 全部 DDL 带 IF NOT EXISTS,可重复执行。 +-- ★ 但三个「回填」段设计为**在新代码上线前执行一次**,见各自注释中的警示。 +-- +-- 影响面 +-- 纯增量:只加列、加表、加索引,不改任何既有列的类型或含义, +-- 不改动任何库存数量字段。全部包在一个事务里,失败自动回滚。 +-- ============================================================================= + +BEGIN; + +-- ============================================================================= +-- 第 1 段:trans_borrow 补 4 列(身份锚点 + 发货操作人) +-- +-- 借用人身份从「姓名」升级为「ID 锚点」:姓名无法唯一锚定一个人(重名即 +-- 责任链断裂)。current_holder_* 表示「东西现在在谁手上」,转交会推进它。 +-- +-- dispatch_operator 补齐「谁经手发货」—— 此前 execute_dispatch 收到该参数 +-- 却从未落库。 +-- ============================================================================= +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), + ADD COLUMN IF NOT EXISTS dispatch_operator varchar(100); + +COMMENT ON COLUMN trans_borrow.borrower_id IS + '初始借用人ID(sys_user.id)。历史数据回填,无法唯一映射时为 NULL'; +COMMENT ON COLUMN trans_borrow.current_holder_id IS + '当前实际持有人ID。转交会推进此列;已全部归还时为 NULL'; +COMMENT ON COLUMN trans_borrow.current_holder_name IS + '当前持有人姓名快照。供列表展示,避免逐行回查 sys_user(N+1)'; +COMMENT ON COLUMN trans_borrow.dispatch_operator IS + '执行借出(扫码发货)的库管操作人姓名。仅展示/追溯用,不参与权限判定;' + '该列上线前的历史记录为 NULL'; + +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 段:转交流水表(最终形态一次建全) +-- +-- 转交粒度 = **明细行**(borrow_id):物理现场经常只转交部分工具 +-- (借 2 件只把 1 件给别人),一张单下不同明细归属不同持有人是正常业务形态。 +-- +-- 状态机:PENDING ──accept──> ACCEPTED(主表 current_holder 正式转移) +-- └──reject──> REJECTED(主表不动,责任仍在原持有人) +-- +-- 不建外键:与 trans_return / trans_defective_goods 一致 —— 库存行会被 +-- 入库模块物理删除,台账表必须能独立存活。 +-- ============================================================================= +CREATE TABLE IF NOT EXISTS trans_borrow_transfer ( + id serial PRIMARY KEY, + borrow_id integer NOT NULL, -- 转交目标明细行 trans_borrow.id + from_user_id integer, -- 发起人(= 发起时的当前持有人) + from_user_name varchar(100), + to_user_id integer, -- 接收人 + 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), -- 发起人(完整 username,存档备查) + remark text, -- 转交备注(发起时填写) + borrow_no varchar(100) -- 单据归属,仅供分组展示 +); + +-- 补列:兼容本表已存在的场景 +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', + ADD COLUMN IF NOT EXISTS reject_seen_at timestamp without time zone, + ADD COLUMN IF NOT EXISTS reject_reason text; + +COMMENT ON COLUMN trans_borrow_transfer.borrow_id IS + '转交目标明细行ID(trans_borrow.id)。转交粒度 = 明细行,一行最多一条待接收流水'; +COMMENT ON COLUMN trans_borrow_transfer.borrow_no IS + '借用单号。仅用于单据归属与列表分组展示;覆盖范围是 borrow_id 指向的单个明细行'; +COMMENT ON COLUMN trans_borrow_transfer.status IS + 'PENDING 待接收 / ACCEPTED 已接收(主表已转移)/ REJECTED 已拒绝(主表未动)'; +COMMENT ON COLUMN trans_borrow_transfer.reject_seen_at IS + '发起方看到「被拒绝」提醒并确认的时间。NULL 且 status=REJECTED 表示尚未告知'; +COMMENT ON COLUMN trans_borrow_transfer.reject_reason IS + '拒收原因,独立成列(此前拼在 remark 里,前端无法区分转交备注与拒收原因)'; + +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); +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); +CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_borrow_status ON trans_borrow_transfer (borrow_id, status); +CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_from_status ON trans_borrow_transfer (from_user_id, status); + + +-- ============================================================================= +-- 第 3 段:归还流水表 +-- +-- 原先部分归还时,主表的 return_time / return_operator / return_signature +-- 被**逐次覆盖**,「谁在什么时候还了多少」永久丢失。本表按次记录根治。 +-- +-- 主表字段保持维护:returned_quantity 是累计快照(列表「未还/已还」判定 +-- 依赖它),return_time 等降级为「最近一次归还」展示快照;逐次权威以本表为准。 +-- ============================================================================= +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 段:回填 trans_borrow 的身份锚点(存量数据) +-- +-- ⚠ 幂等:仅回填仍为 NULL 的行,重跑无副作用。 +-- 只回填「姓名在 sys_user 中唯一命中」的行,杜绝重名错绑;命中不了就留 NULL, +-- 不猜测、不阻断 —— 前端对 NULL 有「历史数据」的降级处理。 +-- ============================================================================= +-- 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 OR u.username = tb.borrower_name) + AND (SELECT count(*) FROM sys_user u2 + WHERE split_part(u2.username, '/', 1) = tb.borrower_name + OR u2.username = tb.borrower_name) = 1; + +-- 4.2 current_holder:仅未归还的行(已归还 = 物品已回库,无人持有,保持 NULL)。 +-- 这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号, +-- 归还校验不会对已结清单据误触发。 +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 OR u.username = tb.borrower_name) + AND (SELECT count(*) FROM sys_user u2 + WHERE split_part(u2.username, '/', 1) = tb.borrower_name + OR u2.username = tb.borrower_name) = 1; + + +-- ============================================================================= +-- 第 5 段:回填转交流水的 borrow_no 与状态(仅针对旧版遗留流水) +-- +-- ⚠⚠ 本段设计为**在新代码上线前执行一次**。 +-- 旧版实现是「发起即生效」(写流水的同时就改了主表 current_holder), +-- 故这些遗留流水应标为 ACCEPTED;若标 PENDING,接收人会收到一条 +-- 早已生效的待办。 +-- +-- 安全护栏:只处理 borrow_no IS NULL 的行 —— 那是「本列刚加、由旧代码写入」 +-- 的唯一特征。新代码写入的流水必然带 borrow_no,因此**重跑不会误伤** +-- 上线后产生的真实待接收记录。 +-- +-- ★ status 不能一律填 ACCEPTED:旧结构表里 status 是刚加的列、全是默认值 +-- PENDING,若把遗留行统统标成 ACCEPTED,后面按 status='REJECTED' 做的 +-- 回填就一条都匹配不到(实测踩到)。真正的信号在备注里 —— +-- 旧版拒收会把原因拼成 "[拒绝原因] xxx",据此还原真实状态。 +-- ============================================================================= +UPDATE trans_borrow_transfer t + SET borrow_no = tb.borrow_no, + status = CASE WHEN t.remark LIKE '%[拒绝原因] %' + THEN 'REJECTED' ELSE 'ACCEPTED' END + FROM trans_borrow tb + WHERE t.borrow_id = tb.id + AND t.borrow_no IS NULL; + +-- ★ 兜底:上面按 borrow_id 关联,若某条流水的来源借用行**已被删除**, +-- JOIN 匹配不上,它会永久停在默认的 PENDING —— 在接收人那里变成一条 +-- 谁也处理不掉的幽灵待办(点开发现物品早已不存在)。 +-- 这里把「没有单号」的遗留行一律结掉;新代码写入的流水必然带 borrow_no, +-- 不会被误伤。 +UPDATE trans_borrow_transfer + SET status = CASE WHEN remark LIKE '%[拒绝原因] %' + THEN 'REJECTED' ELSE 'ACCEPTED' END + WHERE borrow_no IS NULL + AND status = 'PENDING'; + + +-- ============================================================================= +-- 第 6 段:回填 reject_seen_at(存量已拒绝的流水标记为「已告知」) +-- +-- ⚠ 本段同样设计为**上线前执行一次**。 +-- 存量拒绝产生于本功能上线之前,追溯提醒只会打扰;新产生的拒绝才会触发提醒。 +-- +-- ⚠ 若在功能已投产后重跑:会把当时「真实的、尚未告知发起方」的拒绝记录 +-- 一并标记为已告知,导致发起方收不到提醒(提醒接口再无第二次机会)。 +-- 故请务必在维护窗口、新代码上线前执行。 +-- ============================================================================= +UPDATE trans_borrow_transfer + SET reject_seen_at = CURRENT_TIMESTAMP + WHERE status = 'REJECTED' + AND reject_seen_at IS NULL; + + +-- ============================================================================= +-- 第 7 段:回填 reject_reason(从 remark 里拆出被拼接的拒收原因) +-- +-- 旧实现把原因拼进备注:"3333\n[拒绝原因] 5555",前端无法区分两句话。 +-- 按 '[拒绝原因] ' 标记切分:原因挪到新列,备注还原。 +-- +-- ★ btrim 默认只去**空格**、不去换行 —— 拼接留下的是 '3333\n', +-- 只写 btrim(x) 会残留一个换行,必须显式给出字符集。 +-- ⚠ 与第 6 段同理:仅在上线前执行一次。 +-- ============================================================================= +UPDATE trans_borrow_transfer + SET reject_reason = nullif(btrim(split_part(remark, '[拒绝原因] ', 2), E' \t\r\n'), ''), + remark = nullif( + btrim(replace(remark, + '[拒绝原因] ' || split_part(remark, '[拒绝原因] ', 2), + ''), + E' \t\r\n'), + '') + WHERE status = 'REJECTED' + AND remark LIKE '%[拒绝原因] %' + AND reject_reason IS NULL; + +COMMIT; + + +-- ============================================================================= +-- 第 8 段(可选,默认不执行):borrow_transfer 权限码 +-- +-- ⚠ 本轮最终**取消了**该权限码的使用 —— 转交的责任链规则是 +-- 「只有该物品的当前持有人本人可以发起」,而持有人是普通员工, +-- 通常不持有库管权限;再加一道库管权限,能发起的人变成「持有人 ∩ 库管」, +-- 绝大多数持有人反而发不了,功能形同虚设。真正的边界是 service 层的 +-- caller_user_id 强校验。 +-- +-- 因此默认不创建,避免在权限管理界面留下一个无代码引用的死权限。 +-- 若将来要加「管理员代办」入口,取消下面注释即可(幂等,可重复执行)。 +-- ----------------------------------------------------------------------------- +-- BEGIN; +-- 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 四个新列(应 4 行)===' +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','dispatch_operator') + ORDER BY column_name; + +\echo '=== 2) 两张新表(应 2 行)===' +SELECT table_name FROM information_schema.tables + WHERE table_name IN ('trans_borrow_transfer','trans_borrow_return') ORDER BY table_name; + +\echo '=== 3) 索引总数(transfer 应 8 个含主键 / return 应 4 个含主键)===' +SELECT tablename, count(*) AS 索引数 FROM pg_indexes + WHERE tablename IN ('trans_borrow_transfer','trans_borrow_return') + GROUP BY tablename ORDER BY tablename; + +\echo '=== 4) 身份锚点回填覆盖率 ===' +SELECT count(*) AS 借用总行数, + count(borrower_id) AS 已锚定借用人, + count(*) FILTER (WHERE is_returned = FALSE) AS 未归还行, + count(current_holder_id) AS 已锚定持有人 + FROM trans_borrow; + +\echo '=== 5) 同单多持有人单数(部分转交的正常形态,无需处理)===' +SELECT count(*) AS 拆分持有单数 FROM ( + SELECT borrow_no FROM trans_borrow WHERE is_returned = FALSE + GROUP BY borrow_no HAVING count(DISTINCT current_holder_id) > 1 +) t; + +\echo '=== 6) 遗留拼接痕迹(应为 0)===' +SELECT count(*) AS 残留拼接 FROM trans_borrow_transfer + WHERE remark LIKE '%[拒绝原因] %'; + +\echo '=== 7) 待告知发起方的拒绝(存量已标记,应为 0)===' +SELECT count(*) AS 待告知 FROM trans_borrow_transfer + WHERE status = 'REJECTED' AND reject_seen_at IS NULL; + + +-- ============================================================================= +-- 回滚段(整段取消注释执行;⚠ 会丢弃转交/归还流水,请先备份) +-- ============================================================================= +-- BEGIN; +-- 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, +-- DROP COLUMN IF EXISTS dispatch_operator; +-- COMMIT;