-- ============================================================================= -- 借库转交(Borrow Transfer)完整部署脚本 -- -- 本文档把本轮全部库变更按依赖顺序合并为一个脚本,可直接在生产执行。 -- 对应开发环境已执行的 6 个脚本:phase4 / 4b / 4c / 4d / 4e / 4f。 -- -- 执行 -- docker exec -i psql -U <用户> -d <库名> -v ON_ERROR_STOP=1 < 本文件 -- 脚本不含 psql 专有元命令,psql / DataGrip / DBeaver 均可直接整份执行。 -- -- 幂等 -- 全部 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; -- ============================================================================= -- 执行后核对 -- -- ★ 这里刻意**不用 psql 的 \echo**(DataGrip / DBeaver 等客户端不认元命令, -- 会直接报语法错误)。改用 SELECT 输出一行带标签的常量,任何客户端都能跑。 -- ============================================================================= SELECT '=== 1) trans_borrow 四个新列(应 4 行)===' AS "核对项"; 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; SELECT '=== 2) 两张新表(应 2 行)===' AS "核对项"; SELECT table_name FROM information_schema.tables WHERE table_name IN ('trans_borrow_transfer','trans_borrow_return') ORDER BY table_name; SELECT '=== 3) 索引总数(transfer 应 8 个含主键 / return 应 4 个含主键)===' AS "核对项"; SELECT tablename, count(*) AS 索引数 FROM pg_indexes WHERE tablename IN ('trans_borrow_transfer','trans_borrow_return') GROUP BY tablename ORDER BY tablename; SELECT '=== 4) 身份锚点回填覆盖率 ===' AS "核对项"; SELECT count(*) AS 借用总行数, count(borrower_id) AS 已锚定借用人, count(*) FILTER (WHERE is_returned = FALSE) AS 未归还行, count(current_holder_id) AS 已锚定持有人 FROM trans_borrow; SELECT '=== 5) 同单多持有人单数(部分转交的正常形态,无需处理)===' AS "核对项"; 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; SELECT '=== 6) 遗留拼接痕迹(应为 0)===' AS "核对项"; SELECT count(*) AS 残留拼接 FROM trans_borrow_transfer WHERE remark LIKE '%[拒绝原因] %'; SELECT '=== 7) 待告知发起方的拒绝(存量已标记,应为 0)===' AS "核对项"; 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;