From 681607bd43067c714d9749c15fc9cb773abed124 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 10:46:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(borrow):=20=E6=8B=92=E6=94=B6=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E7=8B=AC=E7=AB=8B=E6=88=90=E5=88=97=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=E8=BD=AC=E4=BA=A4=E5=A4=87=E6=B3=A8=E5=BD=BB=E5=BA=95=E5=88=86?= =?UTF-8?q?=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景 ---- 拒收原因此前是**拼进 remark** 的: transfer.remark = f"{remark}\n[拒绝原因] {reason}" 前端拿到的是「3333\n[拒绝原因] 5555」这样一坨,时间线上两句挤在一起, 无法分辨哪句是发起备注、哪句是对方拒收的原因。 改动 ---- · trans_borrow_transfer 新增 reject_reason text 列; reject_transfer 改为写入该列,不再拼进 remark。 · 存量按 '[拒绝原因] ' 标记切分回填(实测仅 #22: remark 3333 / reject_reason 5555)。 · 时间线事件带出 reject_reason,前端才能分行展示。 ★ 为什么拆列而不是让前端解析字符串 1) 拼接格式是隐式契约:改分隔符或加前缀,前端解析就静默失效且难排查; 2) 用户完全可能在备注里自己打出 '[拒绝原因]' 字样,按标记切分必然误判 —— 已加测试用例锁定该场景; 3) 结构化字段才能参与查询与统计(如按拒收原因归类)。 存储层能表达的东西,不该靠字符串约定去还原。 ★ 一个迁移期踩到的坑:btrim 默认只去空格、不去换行。 拼接留下的是 '3333\n',只写 btrim(x) 会残留换行;必须显式给出字符集 btrim(x, E' \t\r\n')。已修正脚本并对存量做了一次清理。 验证(7 项断言全通过) 备注不被污染、原因写独立列、无原因时为 None、 用户备注含同名标记也不误判、库存零副作用、数据零残留。 --- .../phase4f_borrow_transfer_reject_reason.sql | 79 +++++++++++++++++++ inventory-backend/app/models/transaction.py | 6 ++ .../app/services/trans_service.py | 9 ++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 db_migrations/phase4f_borrow_transfer_reject_reason.sql diff --git a/db_migrations/phase4f_borrow_transfer_reject_reason.sql b/db_migrations/phase4f_borrow_transfer_reject_reason.sql new file mode 100644 index 0000000..7568d18 --- /dev/null +++ b/db_migrations/phase4f_borrow_transfer_reject_reason.sql @@ -0,0 +1,79 @@ +-- ============================================================================= +-- 借库转交 · 拒收原因独立成列 +-- +-- 背景 +-- 拒收原因此前是**拼进 remark** 的: +-- transfer.remark = f"{remark}\n[拒绝原因] {reason}" +-- 结果前端拿到的是「3333\n[拒绝原因] 5555」这样一坨,无法区分 +-- 「发起时写的转交备注」与「对方拒收的原因」—— 时间线上两者挤在一起, +-- 毫无辨识度。 +-- +-- --------------------------------------------------------------------------- +-- 为什么改成独立列而不是让前端解析 +-- 1) 拼接格式是**隐式契约**:一旦以后有人改拼接方式(换分隔符、加前缀), +-- 前端解析就静默失效,而且很难排查; +-- 2) 备注内容本身可能包含 '[拒绝原因] ' 字样(用户手打),按标记切分会误判; +-- 3) 结构化字段能直接参与查询与统计(例如「统计各类拒收原因」)。 +-- 存储层能表达的东西,不要靠字符串约定去还原。 +-- +-- 存量回填 +-- 按 '[拒绝原因] ' 标记切分现有 remark,把原因挪到新列,并清理 remark。 +-- 实测仅 1 条(#22),切分结果:remark='3333',reject_reason='5555'。 +-- +-- 幂等:带 IF NOT EXISTS 与 NULL 判断,可重复执行。 +-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件 +-- ============================================================================= + +BEGIN; + +ALTER TABLE trans_borrow_transfer + ADD COLUMN IF NOT EXISTS reject_reason text; + +COMMENT ON COLUMN trans_borrow_transfer.reject_reason IS + '拒收原因,独立成列。此前拼在 remark 里("...\n[拒绝原因] xxx"),前端无法区分转交备注与拒收原因'; + +-- ★ btrim 默认只去**空格**,不去换行 —— 拼接时留下的是 '3333\n', +-- 只写 btrim(x) 会残留一个换行。必须显式给出要去除的字符集。 +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; + + +-- ============================================================================= +-- 执行后核对 +-- ============================================================================= +\echo '--- 1) 新列已就位 ---' +SELECT column_name, data_type FROM information_schema.columns + WHERE table_name = 'trans_borrow_transfer' AND column_name = 'reject_reason'; + +\echo '--- 2) 备注与拒收原因已分离(#22 应为 remark=3333 / reject_reason=5555)---' +SELECT id, status, to_user_name, + coalesce(replace(remark, E'\n', '\\n'), '<空>') AS 转交备注, + coalesce(reject_reason, '<无>') AS 拒收原因 + FROM trans_borrow_transfer ORDER BY id; + +\echo '--- 3) 不应再有残留的拼接痕迹(应为 0)---' +SELECT count(*) AS 残留拼接 FROM trans_borrow_transfer + WHERE remark LIKE '%[拒绝原因] %'; + + +-- ============================================================================= +-- 回滚段 +-- ============================================================================= +-- BEGIN; +-- UPDATE trans_borrow_transfer +-- SET remark = CASE WHEN remark IS NULL THEN '[拒绝原因] ' || reject_reason +-- ELSE remark || E'\n[拒绝原因] ' || reject_reason END +-- WHERE reject_reason IS NOT NULL; +-- ALTER TABLE trans_borrow_transfer DROP COLUMN IF EXISTS reject_reason; +-- COMMIT; diff --git a/inventory-backend/app/models/transaction.py b/inventory-backend/app/models/transaction.py index fb918da..dd36d49 100644 --- a/inventory-backend/app/models/transaction.py +++ b/inventory-backend/app/models/transaction.py @@ -169,6 +169,11 @@ class TransBorrowTransfer(db.Model): # 责任链出现静默断点。故必须告知,且必须能标记「已告知」, # 否则发起方每次登录都收到同一条提醒,从提醒退化成骚扰。 reject_seen_at = db.Column(db.DateTime) + # ★ 拒收原因独立成列。此前拼在 remark 里("...\n[拒绝原因] xxx"), + # 前端拿到一坨字符串无法区分「转交备注」与「拒收原因」; + # 靠字符串约定还原结构化信息既脆弱(用户自己也可能打出该标记), + # 又没法参与查询统计。存储层能表达的东西不靠约定去猜。 + reject_reason = db.Column(db.Text) # 转出方(= 转交前的 current_holder) from_user_id = db.Column(db.Integer) @@ -198,6 +203,7 @@ class TransBorrowTransfer(db.Model): }.get(self.status, self.status), # 仅供发起方「被拒绝」提醒使用,判断是否需要告知由 reject_seen_at 决定 'reject_seen': self.reject_seen_at is not None, + 'reject_reason': self.reject_reason, 'from_user_id': self.from_user_id, 'from_user_name': self.from_user_name, 'to_user_id': self.to_user_id, diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py index 132d39e..11549db 100644 --- a/inventory-backend/app/services/trans_service.py +++ b/inventory-backend/app/services/trans_service.py @@ -717,8 +717,11 @@ class TransService: raise ValueError("只有该转交的接收人本人可以拒绝") transfer.status = TRANSFER_STATUS_REJECTED - if reason: - transfer.remark = (f"{transfer.remark}\n" if transfer.remark else "") + f"[拒绝原因] {reason}" + # ★ 原因写独立列,不再拼进 remark: + # 拼接会让前端拿到「3333\n[拒绝原因] 5555」这样一坨, + # 分不清哪句是发起备注、哪句是拒收原因;而且用户自己在备注里 + # 打出同样字样时,任何按标记切分的解析都会误判。 + transfer.reject_reason = (reason or '').strip() or None try: db.session.commit() except Exception as e: @@ -996,6 +999,8 @@ class TransService: # 长得一模一样,发起方翻记录时会以为已经交接出去。 'status': t.status, 'status_text': t.to_dict().get('status_text'), + # 拒收原因独立带出,前端才能与「转交备注」分行展示 + 'reject_reason': t.reject_reason, '_seq': 1, })