Compare commits
36 Commits
f4a01be951
...
483fb5336a
| Author | SHA1 | Date | |
|---|---|---|---|
| 483fb5336a | |||
| a739f9c889 | |||
| 938051fe29 | |||
| 6cb30a21fd | |||
| 2f658407c3 | |||
| 2f7a81ff3d | |||
| 70071e111a | |||
| fd084bf8fd | |||
| 6b7b174e3d | |||
| b26b124f8b | |||
| d6234622e8 | |||
| 1ebd06e68c | |||
| b5f96384e9 | |||
| 681607bd43 | |||
| f4f887c2b4 | |||
| f24797ff1f | |||
| b53e22f536 | |||
| c566d1b728 | |||
| 2c732a9a3a | |||
| 1c58789fd9 | |||
| 4bd6765ab4 | |||
| 5f0cdc3adb | |||
| 1bb3f59bb7 | |||
| 7d9cdeb297 | |||
| 263f7ba3a1 | |||
| 1a8e3e3dc0 | |||
| b271ca3a49 | |||
| 0eca1805b6 | |||
| 15d0c6b97a | |||
| b1f39acb97 | |||
| ba5394f70c | |||
| 8d83d4404e | |||
| ece5dd8d72 | |||
| f7c49f41a8 | |||
| cccd6f6081 | |||
| b998b00889 |
329
db_migrations/deploy_borrow_transfer_all.sql
Normal file
329
db_migrations/deploy_borrow_transfer_all.sql
Normal file
@ -0,0 +1,329 @@
|
||||
-- =============================================================================
|
||||
-- 借库转交(Borrow Transfer)完整部署脚本
|
||||
--
|
||||
-- 本文档把本轮全部库变更按依赖顺序合并为一个脚本,可直接在生产执行。
|
||||
-- 对应开发环境已执行的 6 个脚本:phase4 / 4b / 4c / 4d / 4e / 4f。
|
||||
--
|
||||
-- 执行
|
||||
-- docker exec -i <db容器> 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;
|
||||
233
db_migrations/phase4_borrow_transfer.sql
Normal file
233
db_migrations/phase4_borrow_transfer.sql
Normal 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_user(username 形如「姓名/拼音」)。无法映射的(如「测试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
|
||||
'初始借用人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)';
|
||||
|
||||
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=85,borrower_id 回填 84,holder 回填=未归还行数)---'
|
||||
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;
|
||||
58
db_migrations/phase4b_borrow_dispatch_operator.sql
Normal file
58
db_migrations/phase4b_borrow_dispatch_operator.sql
Normal file
@ -0,0 +1,58 @@
|
||||
-- =============================================================================
|
||||
-- 借库转交一期收口:trans_borrow 补 dispatch_operator(发货操作人)
|
||||
--
|
||||
-- 背景
|
||||
-- 责任链上「谁借的 / 谁现在拿着 / 谁还的」在此前改造中已全部锚定到 ID,
|
||||
-- 唯独**执行借出的库管操作人**一直没落库 —— execute_dispatch 的
|
||||
-- operator_name 形参被接收后从未写入任何字段(首轮调研已标记该缺口)。
|
||||
-- 本迁移补齐最后一环,使一次借出在台账上能回答四个问题:
|
||||
-- 借用人 borrower_id / 当前持有人 current_holder_id /
|
||||
-- 发货操作人 dispatch_operator / 归还人 trans_borrow_return.returner_id
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ★ 为什么存「姓名」而不是 ID
|
||||
-- execute_dispatch 的 operator_name 由 API 层经 _current_username() 取得,
|
||||
-- 本身就是展示口径的字符串(与 trans_borrow_return.operator_name 一致)。
|
||||
-- 该字段仅用于台账展示与追溯,不参与任何权限判定或持有人校验,
|
||||
-- 故不引入 ID —— 需要精确到人时,审计日志(audit_listener)另有记录。
|
||||
--
|
||||
-- ★ 为什么不回填历史值
|
||||
-- 改造前的执行人信息**从未被采集**,系统中不存在任何可回填的数据源
|
||||
-- (trans_borrow 没有对应列,TransBorrow 也没有其它操作人字段)。
|
||||
-- 刻意留空而非猜测填充 —— 用申请人或借用人冒充实物交接人,
|
||||
-- 比留空更危险。NULL 即表示「本次借出发生在该字段上线之前」。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE trans_borrow
|
||||
ADD COLUMN IF NOT EXISTS dispatch_operator varchar(100);
|
||||
|
||||
COMMENT ON COLUMN trans_borrow.dispatch_operator IS
|
||||
'执行借出(扫码发货)的库管操作人姓名。仅展示/追溯用,不参与权限判定;'
|
||||
'该列上线前的历史记录为 NULL';
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
\echo '--- 1) 新列已就位 ---'
|
||||
SELECT column_name, data_type, character_maximum_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'trans_borrow' AND column_name = 'dispatch_operator';
|
||||
|
||||
\echo '--- 2) 存量行均为 NULL(预期:全部为 NULL,历史数据无从回填)---'
|
||||
SELECT count(*) AS total, count(dispatch_operator) AS filled FROM trans_borrow;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- ALTER TABLE trans_borrow DROP COLUMN IF EXISTS dispatch_operator;
|
||||
-- COMMIT;
|
||||
93
db_migrations/phase4c_borrow_transfer_handshake.sql
Normal file
93
db_migrations/phase4c_borrow_transfer_handshake.sql
Normal file
@ -0,0 +1,93 @@
|
||||
-- =============================================================================
|
||||
-- 借库转交 · 双向握手 + 整单覆盖
|
||||
--
|
||||
-- 本次解决两个已发生的问题:
|
||||
-- 1) **单内撕裂**:原 /transfer 收的是**明细行 ID**,只更新那一行。
|
||||
-- 一张单有 2 条明细时,转交后 108 归属接收人、109 仍是原借用人 ——
|
||||
-- 前端按 borrow_no 聚合,两个名字同时出现(实测 BOR-20260917-0001)。
|
||||
-- 修复方向:转交必须按 **borrow_no** 覆盖该单全部未还明细。
|
||||
-- 2) **单向强塞**:原实现发起即生效,接收人「被通知式」地背上资产责任。
|
||||
-- 改为双向握手:发起只落 PENDING,接收人 accept 后才真正转移责任。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 1) 为什么新增 borrow_no 而不是复用 borrow_id
|
||||
-- borrow_id 是**明细行**主键,一条转交要覆盖该单多行,用单行 ID 无法表达
|
||||
-- 「本次转交覆盖了哪些行」。borrow_no 是单据身份,accept 时据此批量更新。
|
||||
-- borrow_id 保留为「发起时的代表明细」,供追溯与旧前端兼容。
|
||||
--
|
||||
-- 2) 为什么存量行标记为 ACCEPTED
|
||||
-- 旧语义是「发起即生效」—— 写流水的同时就改了主表 current_holder。
|
||||
-- 本表现有 1 行(BOR-20260917-0001)已是生效状态,标为 ACCEPTED 才符合
|
||||
-- 事实;若标 PENDING,接收人会收到一条早已生效的待办。
|
||||
--
|
||||
-- 3) 状态机
|
||||
-- PENDING ──accept──> ACCEPTED (主表 current_holder 正式转移)
|
||||
-- └────reject──> REJECTED (主表不动,责任仍在原持有人)
|
||||
-- 同一单号同一时刻只允许一条 PENDING(应用层强制),避免两个接收人
|
||||
-- 争抢同一批实物。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
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';
|
||||
|
||||
COMMENT ON COLUMN trans_borrow_transfer.borrow_no IS
|
||||
'借用单号(单据身份)。一次转交覆盖该单号下全部未还明细,accept 时据此批量更新';
|
||||
COMMENT ON COLUMN trans_borrow_transfer.status IS
|
||||
'PENDING 待接收 / ACCEPTED 已接收(主表已转移)/ REJECTED 已拒绝(主表未动)';
|
||||
COMMENT ON COLUMN trans_borrow_transfer.borrow_id IS
|
||||
'发起转交时的代表明细ID(trans_borrow.id),仅供追溯;覆盖范围以 borrow_no 为准';
|
||||
|
||||
-- 存量行:按旧语义(发起即生效)回填单号并标记为已接收
|
||||
UPDATE trans_borrow_transfer t
|
||||
SET borrow_no = tb.borrow_no,
|
||||
status = 'ACCEPTED'
|
||||
FROM trans_borrow tb
|
||||
WHERE t.borrow_id = tb.id
|
||||
AND t.status = 'PENDING';
|
||||
|
||||
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);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
\echo '--- 1) 新列已就位 ---'
|
||||
SELECT column_name, data_type, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'trans_borrow_transfer'
|
||||
AND column_name IN ('borrow_no', 'status')
|
||||
ORDER BY column_name;
|
||||
|
||||
\echo '--- 2) 存量行状态(应为 ACCEPTED 且 borrow_no 已回填)---'
|
||||
SELECT id, borrow_id, borrow_no, status, from_user_name, to_user_name
|
||||
FROM trans_borrow_transfer ORDER BY id;
|
||||
|
||||
\echo '--- 3) 不应存在无单号的流水(应为 0)---'
|
||||
SELECT count(*) AS 缺单号 FROM trans_borrow_transfer WHERE borrow_no IS NULL;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_no;
|
||||
-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_status;
|
||||
-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_to_user;
|
||||
-- ALTER TABLE trans_borrow_transfer
|
||||
-- DROP COLUMN IF EXISTS borrow_no,
|
||||
-- DROP COLUMN IF EXISTS status;
|
||||
-- COMMIT;
|
||||
74
db_migrations/phase4d_borrow_transfer_item_level.sql
Normal file
74
db_migrations/phase4d_borrow_transfer_item_level.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- =============================================================================
|
||||
-- 借库转交 · 粒度下沉到明细行(部分转交)
|
||||
--
|
||||
-- 背景(业务方推翻上一轮约束)
|
||||
-- 上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现
|
||||
-- 多个持有人」当作 bug 去修。业务方验收后明确纠正:
|
||||
-- 物理现场经常只转交部分工具(借了 2 件,只把 1 件转给别人),
|
||||
-- **单内多持有人才是符合现实的正常状态**。
|
||||
-- 故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 本次改动的实质
|
||||
-- 代码层:transfer_borrow / accept_transfer 只操作**一行**明细,
|
||||
-- 唯一性约束从「单号最多一条 PENDING」改为「明细行最多一条 PENDING」。
|
||||
-- 数据层:**无需改动任何表结构** —— borrow_id(明细行)本就是流水的主键
|
||||
-- 关联列,borrow_no 继续保留作单据归属与展示分组用。
|
||||
-- 仅补一个复合索引,支撑「按明细行查待接收流水」这一新查询路径。
|
||||
--
|
||||
-- ★ 为什么不需要新的列
|
||||
-- 转交粒度既然回到明细行,覆盖范围就是 borrow_id 指向的那一行本身 ——
|
||||
-- 不需要额外的「覆盖清单」来表达范围,borrow_no 退化为分组/展示用途。
|
||||
--
|
||||
-- ★ 存量数据不动(业务方选择)
|
||||
-- BOR-20260917-0001 的「测试 / 杜邢宸」双持有人状态予以保留:
|
||||
-- 它现在不再是 bug,而是「部分转交」的正常业务形态。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 支撑「该明细行是否已有待接收流水」的唯一性检查,以及按明细行批量取待接收
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_borrow_status
|
||||
ON trans_borrow_transfer (borrow_id, status);
|
||||
|
||||
COMMENT ON COLUMN trans_borrow_transfer.borrow_no IS
|
||||
'借用单号。仅用于单据归属与列表分组展示;转交的**覆盖范围**是 borrow_id 指向的单个明细行';
|
||||
COMMENT ON COLUMN trans_borrow_transfer.borrow_id IS
|
||||
'转交目标明细行ID(trans_borrow.id)。转交粒度 = 明细行,一行最多一条待接收流水';
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
\echo '--- 1) 复合索引已就位 ---'
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'trans_borrow_transfer'
|
||||
AND indexname = 'ix_trans_borrow_transfer_borrow_status';
|
||||
|
||||
\echo '--- 2) 存量流水(borrow_id / borrow_no / status)---'
|
||||
SELECT id, borrow_id, borrow_no, status, from_user_name, to_user_name
|
||||
FROM trans_borrow_transfer ORDER BY id;
|
||||
|
||||
\echo '--- 3) 各明细行的待接收流水数(应全部 <= 1)---'
|
||||
SELECT borrow_id, count(*) AS 待接收数
|
||||
FROM trans_borrow_transfer WHERE status = 'PENDING'
|
||||
GROUP BY borrow_id HAVING count(*) > 1;
|
||||
|
||||
\echo '--- 4) 单内多持有人的单号(现在属正常业务形态,不再视为异常)---'
|
||||
SELECT borrow_no, count(DISTINCT current_holder_id) AS 持有人数,
|
||||
string_agg(DISTINCT coalesce(current_holder_name,'NULL'), ', ') AS 持有人
|
||||
FROM trans_borrow WHERE is_returned = FALSE
|
||||
GROUP BY borrow_no HAVING count(DISTINCT current_holder_id) > 1;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_borrow_status;
|
||||
-- COMMIT;
|
||||
75
db_migrations/phase4e_borrow_transfer_reject_notice.sql
Normal file
75
db_migrations/phase4e_borrow_transfer_reject_notice.sql
Normal file
@ -0,0 +1,75 @@
|
||||
-- =============================================================================
|
||||
-- 借库转交 · 拒收须告知发起方
|
||||
--
|
||||
-- 背景
|
||||
-- 双向握手补上了「接收人确认」这一环,但只做了单向告知:接收人能看到待办,
|
||||
-- 发起方却对结果一无所知 —— 尤其是**被拒绝**时,物品责任仍在发起方手上,
|
||||
-- 他若不查列表就会误以为已经交接出去,责任链出现静默断点。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 为什么需要 reject_seen_at 这一列
|
||||
-- ACCEPTED 不需要告知(东西已经交出去了,发起方无需动作);
|
||||
-- REJECTED 必须告知,且**必须能标记「已告知」** —— 否则发起方每次登录
|
||||
-- 都会收到同一条提醒,从「提醒」退化成「骚扰」。
|
||||
--
|
||||
-- 不宜用前端 sessionStorage 去重:换台电脑、换个浏览器就会重新提醒,
|
||||
-- 而这条信息的分量(责任归属)值得一个持久标记。
|
||||
--
|
||||
-- 语义
|
||||
-- reject_seen_at IS NULL 且 status='REJECTED' → 尚未告知发起方
|
||||
-- 发起方在前端看到提醒并确认后,写当前时间
|
||||
--
|
||||
-- ★ 存量已拒绝的流水一律标记为「已告知」
|
||||
-- 它们产生于本功能上线之前,追溯提醒只会打扰(实测仅 1 条:
|
||||
-- #22 测试 → 小龙虾,是验收时的测试数据)。新产生的拒绝才会触发提醒。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE trans_borrow_transfer
|
||||
ADD COLUMN IF NOT EXISTS reject_seen_at timestamp without time zone;
|
||||
|
||||
COMMENT ON COLUMN trans_borrow_transfer.reject_seen_at IS
|
||||
'发起方看到「被拒绝」提醒并确认的时间。NULL 且 status=REJECTED 表示尚未告知';
|
||||
|
||||
-- 存量已拒绝的流转为已告知(本功能上线前的历史,不追溯打扰)
|
||||
UPDATE trans_borrow_transfer
|
||||
SET reject_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE status = 'REJECTED'
|
||||
AND reject_seen_at IS NULL;
|
||||
|
||||
-- 支撑「待告知发起方的拒绝」查询
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_borrow_transfer_from_status
|
||||
ON trans_borrow_transfer (from_user_id, status);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
\echo '--- 1) 新列已就位 ---'
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name = 'trans_borrow_transfer' AND column_name = 'reject_seen_at';
|
||||
|
||||
\echo '--- 2) 拒绝流水均已标记为已告知(应为 0 行待告知)---'
|
||||
SELECT id, borrow_no, status, from_user_name, to_user_name, reject_seen_at
|
||||
FROM trans_borrow_transfer
|
||||
WHERE status = 'REJECTED' AND reject_seen_at IS NULL;
|
||||
|
||||
\echo '--- 3) 全部流水一览 ---'
|
||||
SELECT id, status, from_user_name, to_user_name,
|
||||
CASE WHEN reject_seen_at IS NULL THEN '' ELSE '已告知' END AS 拒绝告知
|
||||
FROM trans_borrow_transfer ORDER BY id;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DROP INDEX IF EXISTS ix_trans_borrow_transfer_from_status;
|
||||
-- ALTER TABLE trans_borrow_transfer DROP COLUMN IF EXISTS reject_seen_at;
|
||||
-- COMMIT;
|
||||
79
db_migrations/phase4f_borrow_transfer_reject_reason.sql
Normal file
79
db_migrations/phase4f_borrow_transfer_reject_reason.sql
Normal file
@ -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;
|
||||
136
db_migrations/phase5_material_remark_rbac.sql
Normal file
136
db_migrations/phase5_material_remark_rbac.sql
Normal file
@ -0,0 +1,136 @@
|
||||
-- =============================================================================
|
||||
-- 基础信息 · 附件备注的读写权限修复
|
||||
--
|
||||
-- 现象
|
||||
-- 主管(SUPERVISOR)在基础信息里填「产品图备注 / 说明书备注」,点确定后
|
||||
-- 刷新页面又是空的 —— 看起来像「保存不了」。
|
||||
--
|
||||
-- 根因:写通、读断,且读侧引用的是**两个从未注册过的权限码**
|
||||
-- 写侧:PUT /inbound/base/<id> 的 field_to_perm 里**没有**这两个字段,
|
||||
-- 落入「不在映射中 → 默认允许」的兜底分支 → 直接落库。**保存是成功的。**
|
||||
-- 读侧:列表/搜索接口用 apply_strict_rbac 按 field_permissions.py 过滤,
|
||||
-- 该映射要求 material_list:productImageRemark / material_list:manualLinkRemark。
|
||||
-- 而这两个码 **在 sys_element 里根本不存在**,也从未授予任何角色 ——
|
||||
-- 除 SUPER_ADMIN(走 material_list:* 通配符绕过)外,**没有任何人能读到**。
|
||||
-- 于是:填了 → 存进去了 → 回显被抹成 null → 看起来没保存。
|
||||
--
|
||||
-- 本次修复(读写双向)
|
||||
-- 一、读侧:注册这两个元素,并授予与 material_list:files **完全相同的 6 个角色**
|
||||
-- —— 逻辑是「能看图的人,就应该能看备注」。
|
||||
-- 二、写侧:新建 material_list:remark_edit,只授予 3 个核心管理角色。
|
||||
-- 后端同时把这两个字段显式纳入 field_to_perm 映射,废止那个兜底分支。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ★ 为什么写侧要单独建码,而不复用 material_list:operation
|
||||
-- material_list:operation 授予了 INBOUND / OUTBOUND / SUPERVISOR /
|
||||
-- WAREHOUSE_MGR / SUPER_ADMIN 共 5 个角色,比业务要求的 3 个更宽。
|
||||
-- 复用会让出库员、入库员也能改备注,达不到「只有核心管理角色可改」。
|
||||
--
|
||||
-- ★ 为什么码用冒号形式(material_list:remark_edit)
|
||||
-- 本系统有两类权限码:动作类(无冒号,如 outbound_return)与字段类
|
||||
-- (冒号形式,如 material_list:files)。这是**字段级**权限,与
|
||||
-- material_list:* 家族保持一致,便于在权限管理界面归组。
|
||||
-- ⚠ 它只用于 base.py 里的**精确匹配**(perm_code in user_permissions),
|
||||
-- **切勿用 @permission_required('material_list:remark_edit') 包裹路由** ——
|
||||
-- _expand_operation_perms() 会做前缀桥接,凡持有 material_list:operation
|
||||
-- 的角色都会被放行,等于把刚收紧的口子又捅开。
|
||||
--
|
||||
-- 幂等:全部带存在性判断,可重复执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 一、读侧:注册两个字段权限元素(此前从未注册,是「幽灵权限」)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'material_list', v.name, v.code, 'element'
|
||||
FROM (VALUES
|
||||
('产品图备注', 'material_list:productImageRemark'),
|
||||
('说明书备注', 'material_list:manualLinkRemark')
|
||||
) AS v(name, code)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element e WHERE e.code = v.code);
|
||||
|
||||
-- 授予与 material_list:files 完全相同的 6 个角色
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT r.role_code, e.code, 'element', NULL
|
||||
FROM (VALUES ('material_list:productImageRemark'), ('material_list:manualLinkRemark')) AS e(code)
|
||||
CROSS JOIN (VALUES
|
||||
('INBOUND'), ('OUTBOUND'), ('SALES'),
|
||||
('SUPERVISOR'), ('WAREHOUSE_MGR'), ('SUPER_ADMIN')
|
||||
) AS r(role_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission x
|
||||
WHERE x.role_code = r.role_code AND x.target_code = e.code AND x.type = 'element'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 二、写侧:新建「附件备注编辑」权限码,只授予 3 个核心管理角色
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'material_list', '附件备注编辑(产品图/说明书)', 'material_list:remark_edit', 'element'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'material_list:remark_edit');
|
||||
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT r.role_code, 'material_list:remark_edit', 'element', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR')) AS r(role_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission x
|
||||
WHERE x.role_code = r.role_code
|
||||
AND x.target_code = 'material_list:remark_edit'
|
||||
AND x.type = 'element'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
SELECT '=== 1) 三个权限元素已注册(应 3 行)===' AS "核对项";
|
||||
SELECT code, name, menu_code, element_type FROM sys_element
|
||||
WHERE code IN ('material_list:productImageRemark','material_list:manualLinkRemark','material_list:remark_edit')
|
||||
ORDER BY code;
|
||||
|
||||
SELECT '=== 2) 读权限:应各 6 个角色,与 material_list:files 一致 ===' AS "核对项";
|
||||
SELECT target_code AS 权限码, count(*) AS 角色数,
|
||||
string_agg(role_code, ', ' ORDER BY role_code) AS 角色
|
||||
FROM sys_role_permission
|
||||
WHERE target_code IN ('material_list:files','material_list:productImageRemark','material_list:manualLinkRemark')
|
||||
AND type = 'element'
|
||||
GROUP BY target_code ORDER BY target_code;
|
||||
|
||||
SELECT '=== 3) 写权限:应 3 个角色(SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR)===' AS "核对项";
|
||||
SELECT role_code FROM sys_role_permission
|
||||
WHERE target_code = 'material_list:remark_edit' AND type = 'element' ORDER BY role_code;
|
||||
|
||||
SELECT '=== 4) 三组角色集合是否完全对齐(应全为 true)===' AS "核对项";
|
||||
SELECT
|
||||
(SELECT bool_and(a = b) FROM (
|
||||
SELECT
|
||||
(SELECT string_agg(role_code,',' ORDER BY role_code) FROM sys_role_permission
|
||||
WHERE target_code='material_list:files' AND type='element') AS a,
|
||||
(SELECT string_agg(role_code,',' ORDER BY role_code) FROM sys_role_permission
|
||||
WHERE target_code='material_list:productImageRemark' AND type='element') AS b
|
||||
) t) AS 产品图备注读权与files一致,
|
||||
(SELECT bool_and(a = b) FROM (
|
||||
SELECT
|
||||
(SELECT string_agg(role_code,',' ORDER BY role_code) FROM sys_role_permission
|
||||
WHERE target_code='material_list:files' AND type='element') AS a,
|
||||
(SELECT string_agg(role_code,',' ORDER BY role_code) FROM sys_role_permission
|
||||
WHERE target_code='material_list:manualLinkRemark' AND type='element') AS b
|
||||
) t) AS 说明书备注读权与files一致;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DELETE FROM sys_role_permission WHERE target_code = 'material_list:remark_edit';
|
||||
-- DELETE FROM sys_element WHERE code = 'material_list:remark_edit';
|
||||
-- DELETE FROM sys_role_permission
|
||||
-- WHERE target_code IN ('material_list:productImageRemark','material_list:manualLinkRemark');
|
||||
-- DELETE FROM sys_element
|
||||
-- WHERE code IN ('material_list:productImageRemark','material_list:manualLinkRemark');
|
||||
-- COMMIT;
|
||||
94
db_migrations/phase6_material_purchase_link.sql
Normal file
94
db_migrations/phase6_material_purchase_link.sql
Normal file
@ -0,0 +1,94 @@
|
||||
-- =============================================================================
|
||||
-- 基础信息 · 新增「采购链接」字段 + 权限注册
|
||||
--
|
||||
-- 需求
|
||||
-- 物料主数据上增加一个可直接跳转的补货地址(淘宝 / 1688 等),
|
||||
-- 并纳入字段级权限管控。
|
||||
--
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- ★ 设计取舍:读写**共用同一个权限码** material_list:purchaseLink
|
||||
-- 上一轮刚修完「写用了 A 码、读用了 B 码」造成的读写错位(附件备注),
|
||||
-- 新字段刻意沿用 referencePrice 的既有模式 —— 一个码同时进
|
||||
-- field_permissions.py(读过滤)与 base.py 的 field_to_perm(写过滤)。
|
||||
-- 单码最大的好处是**结构上不可能出现口径错位**:能读必然能写、反之亦然,
|
||||
-- 不会再有「填了看不到」或「看得到改不了」。
|
||||
--
|
||||
-- 若业务后续要求「能看不能改」,再拆成两个码并同步改三处
|
||||
-- (field_permissions.py / base.py 的 POST 与 PUT),届时请一并回归验证。
|
||||
--
|
||||
-- ★ 为什么列类型用 text 而不是 varchar(N)
|
||||
-- 外链(尤其是电商)常带很长的查询串,varchar 截断后得到的是打不开的地址,
|
||||
-- 而且截断是静默的 —— 用户只会觉得"链接坏了"。
|
||||
--
|
||||
-- 默认授予:SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR / INBOUND
|
||||
-- —— 覆盖「实际补货的人」与「管理物料的人」。
|
||||
-- ⚠ 这是本次取的默认值,业务可按需增删;增删只需改下面的 VALUES 再执行一次。
|
||||
--
|
||||
-- 幂等:带 IF NOT EXISTS / WHERE NOT EXISTS,可重复执行。
|
||||
-- 脚本文本不含 psql 元命令,DataGrip / DBeaver 可直接整段执行。
|
||||
-- 执行:docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 一、加列
|
||||
-- ---------------------------------------------------------------------------
|
||||
ALTER TABLE material_base
|
||||
ADD COLUMN IF NOT EXISTS purchase_link text DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN material_base.purchase_link IS
|
||||
'采购链接:补货时直接跳转的购买地址。受 material_list:purchaseLink 字段权限管控(读写同码)';
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 二、注册字段权限元素
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'material_list', '采购链接', 'material_list:purchaseLink', 'element'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'material_list:purchaseLink');
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 三、默认授予
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT v.role_code, 'material_list:purchaseLink', 'element', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR'), ('WAREHOUSE_MGR'), ('INBOUND')) 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 = 'material_list:purchaseLink'
|
||||
AND r.type = 'element'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 执行后核对
|
||||
-- =============================================================================
|
||||
SELECT '=== 1) 新列已就位 ===' AS "核对项";
|
||||
SELECT column_name, data_type, column_default FROM information_schema.columns
|
||||
WHERE table_name = 'material_base' AND column_name = 'purchase_link';
|
||||
|
||||
SELECT '=== 2) 权限元素已注册 ===' AS "核对项";
|
||||
SELECT code, name, menu_code, element_type FROM sys_element
|
||||
WHERE code = 'material_list:purchaseLink';
|
||||
|
||||
SELECT '=== 3) 已授予角色(默认应为 4 个)===' AS "核对项";
|
||||
SELECT role_code FROM sys_role_permission
|
||||
WHERE target_code = 'material_list:purchaseLink' AND type = 'element' ORDER BY role_code;
|
||||
|
||||
SELECT '=== 4) 存量数据回填为空的应占多数 ===' AS "核对项";
|
||||
SELECT count(*) AS 物料总数,
|
||||
count(*) FILTER (WHERE coalesce(purchase_link,'') = '') AS 未填链接
|
||||
FROM material_base;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 回滚段
|
||||
-- =============================================================================
|
||||
-- BEGIN;
|
||||
-- DELETE FROM sys_role_permission WHERE target_code = 'material_list:purchaseLink';
|
||||
-- DELETE FROM sys_element WHERE code = 'material_list:purchaseLink';
|
||||
-- ALTER TABLE material_base DROP COLUMN IF EXISTS purchase_link;
|
||||
-- COMMIT;
|
||||
@ -314,9 +314,12 @@ def create_app():
|
||||
# 预加载:审计监听器按表名从 db.metadata 取模型,未预加载的表在
|
||||
# create_app() 完成时尚未映射,会漏绑审计(虽然后续惰性补绑能兜底,
|
||||
# 但预加载更可靠)。
|
||||
# ★ TransBorrowTransfer / TransBorrowReturn 为一期借库转交新增,
|
||||
# 同理必须预加载 —— 转交与归还都是责任链上的关键动作,审计绝不能漏。
|
||||
from app.models.transaction import (
|
||||
TransBorrow, TransRepair, TransScrap,
|
||||
TransReturn, TransDefectiveGoods,
|
||||
TransBorrowTransfer, TransBorrowReturn,
|
||||
)
|
||||
# ★ 审批单模型(原仅在函数体内延迟导入,会导致审计监听器漏绑)
|
||||
from app.models.outbound import OutboundApproval
|
||||
|
||||
@ -264,6 +264,21 @@ def create():
|
||||
'availableCount': 'material_list:availableCount',
|
||||
'generalManual': 'material_list:files',
|
||||
'generalImage': 'material_list:files',
|
||||
# ★ 附件备注的写权限单独收紧(原先这两个字段**不在映射中**,落入
|
||||
# 下面「不在映射中 → 默认允许」的兜底分支 —— 任何能调通本接口的
|
||||
# 角色都能改;而读侧又要求一个从未注册过的权限码,造成
|
||||
# 「谁都能写、除超管没人能读」的读写错位。现双向对齐:
|
||||
# 读 → material_list:productImageRemark / manualLinkRemark(6 角色)
|
||||
# 写 → material_list:remark_edit(3 个核心管理角色)
|
||||
# 无写权限的请求不会携带该字段进入服务层,于是既不改动它、
|
||||
# 也不会写成空值 —— 是「丢弃本次修改」而非「误清既有内容」。
|
||||
# ⚠ 该码只做精确匹配,**不要**用 @permission_required 包裹 ——
|
||||
# 会被 _expand_operation_perms 前缀桥接放大给 material_list:operation。
|
||||
'productImageRemark': 'material_list:remark_edit',
|
||||
'manualLinkRemark': 'material_list:remark_edit',
|
||||
# 采购链接:与读过滤 field_permissions.py 用**同一个码**,
|
||||
# 避免「写用一个码、读用另一个码」再次造成读写错位。
|
||||
'purchaseLink': 'material_list:purchaseLink',
|
||||
'referencePrice': 'material_list:referencePrice',
|
||||
'isEnabled': 'material_list:isEnabled'
|
||||
}
|
||||
@ -319,6 +334,21 @@ def update(id):
|
||||
'availableCount': 'material_list:availableCount',
|
||||
'generalManual': 'material_list:files',
|
||||
'generalImage': 'material_list:files',
|
||||
# ★ 附件备注的写权限单独收紧(原先这两个字段**不在映射中**,落入
|
||||
# 下面「不在映射中 → 默认允许」的兜底分支 —— 任何能调通本接口的
|
||||
# 角色都能改;而读侧又要求一个从未注册过的权限码,造成
|
||||
# 「谁都能写、除超管没人能读」的读写错位。现双向对齐:
|
||||
# 读 → material_list:productImageRemark / manualLinkRemark(6 角色)
|
||||
# 写 → material_list:remark_edit(3 个核心管理角色)
|
||||
# 无写权限的请求不会携带该字段进入服务层,于是既不改动它、
|
||||
# 也不会写成空值 —— 是「丢弃本次修改」而非「误清既有内容」。
|
||||
# ⚠ 该码只做精确匹配,**不要**用 @permission_required 包裹 ——
|
||||
# 会被 _expand_operation_perms 前缀桥接放大给 material_list:operation。
|
||||
'productImageRemark': 'material_list:remark_edit',
|
||||
'manualLinkRemark': 'material_list:remark_edit',
|
||||
# 采购链接:与读过滤 field_permissions.py 用**同一个码**,
|
||||
# 避免「写用一个码、读用另一个码」再次造成读写错位。
|
||||
'purchaseLink': 'material_list:purchaseLink',
|
||||
'referencePrice': 'material_list:referencePrice',
|
||||
'isEnabled': 'material_list:isEnabled'
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ from flask import Blueprint, jsonify, request # .material -> .base refactor che
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required, prevent_double_submit, is_privileged_viewer
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.trans_service import TransService
|
||||
from app.services.trans_service import TransService, user_display_name
|
||||
from app.services.borrow_service import BorrowApprovalService
|
||||
import traceback
|
||||
|
||||
@ -101,11 +101,30 @@ def scan_borrowed_item():
|
||||
@jwt_required()
|
||||
@permission_required('op_return:operation')
|
||||
def submit_return():
|
||||
data = request.get_json()
|
||||
"""
|
||||
还库提交。
|
||||
|
||||
请求体:
|
||||
{
|
||||
"items": [...], # 待还明细,含 trans_borrow.id 与 return_qty
|
||||
"signature_path": "...", # 库管签字
|
||||
"returner_id": 12 # ★ 实际归还人ID(一期转交改造新增)
|
||||
}
|
||||
|
||||
★ operator_name(库管)与 returner_id(归还人)是**两个人**:
|
||||
前者是窗口经手人,取自 JWT;后者是实际把物品交回来的人,由前端选择。
|
||||
记录有 current_holder_id 时,service 层强校验 returner_id 必须等于它,
|
||||
不匹配即整单回滚 —— 这是转交上线后责任链的关键一环。
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
# ★ 归还人存"姓名",而非 JWT 数字 ID
|
||||
operator_name = _current_username()
|
||||
try:
|
||||
TransService.process_return(data, operator_name=operator_name)
|
||||
TransService.process_return(
|
||||
data,
|
||||
operator_name=operator_name,
|
||||
returner_id=data.get('returner_id'),
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '还库成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
@ -227,21 +246,32 @@ def get_records():
|
||||
from app.utils.advanced_filter import parse_advanced_filters
|
||||
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
|
||||
|
||||
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;管理者看全部
|
||||
# ★ 数据权限:普通用户只看与自己有关的记录(借用人 / 当前持有人 / 待我接收的
|
||||
# 转交),管理者看全部。两个口径都要传下去:
|
||||
# viewer_user_id —— 精确锚点,覆盖转交接收人(此前只按姓名过滤,
|
||||
# 接收人在自己的列表里看不到东西)
|
||||
# borrower_name —— 姓名口径,兼容只有姓名、没有 ID 的历史行
|
||||
borrower_name = None
|
||||
viewer_user_id = None
|
||||
current_user_id = None
|
||||
_identity = get_jwt_identity()
|
||||
if _identity:
|
||||
current_user_id = int(_identity) # 供「是否待我接收」判定,与可见性无关
|
||||
if not is_privileged_viewer():
|
||||
_identity = get_jwt_identity()
|
||||
if _identity:
|
||||
from app.models.system import SysUser
|
||||
_u = SysUser.query.get(int(_identity))
|
||||
_uname = _u.username if _u else ''
|
||||
borrower_name = _uname.split('/')[0].strip() if _uname else None
|
||||
if _u:
|
||||
viewer_user_id = _u.id
|
||||
_uname = _u.username or ''
|
||||
borrower_name = _uname.split('/')[0].strip() if _uname else None
|
||||
|
||||
res = TransService.get_records(
|
||||
page=page, limit=10, status=status, keyword=keyword,
|
||||
search_type=search_type, borrower_name=borrower_name,
|
||||
start_date=start_date, end_date=end_date,
|
||||
advanced_filters=advanced_filters,
|
||||
advanced_filters=advanced_filters, viewer_user_id=viewer_user_id,
|
||||
current_user_id=current_user_id,
|
||||
)
|
||||
|
||||
# ★ service 层异常时:code==500 的字典(带 traceback),需要直通到前端,便于排查
|
||||
@ -512,7 +542,8 @@ def dispatch_borrow():
|
||||
],
|
||||
// ★ 审批上限校验在 service 层完成:以 (name, spec_model) 为物料维度聚合
|
||||
// 锁定 stock 行后从 material_base 表取真实 (name, spec_model) 与审批单比对
|
||||
borrower_name: str,
|
||||
borrower_id: int, // ★ 实际借用人ID(一期转交改造后必填)
|
||||
borrower_name: str, // 仅作展示/兼容,落库姓名以 borrower_id 反查为准
|
||||
signature_path: str,
|
||||
remark: str,
|
||||
expected_return_time: str
|
||||
@ -529,6 +560,8 @@ def dispatch_borrow():
|
||||
items=data.get('items', []),
|
||||
operator_name=_current_username(),
|
||||
borrower_name=data.get('borrower_name'),
|
||||
# ★ 强制借用人ID:service 层缺失即拒绝(不静默回退到申请单姓名)
|
||||
borrower_id=data.get('borrower_id'),
|
||||
signature=data.get('signature_path'),
|
||||
remark=data.get('remark'),
|
||||
expected_return_time=data.get('expected_return_time')
|
||||
@ -541,3 +574,261 @@ def dispatch_borrow():
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 借库转交(Borrow Transfer)一期
|
||||
# ==============================================================================
|
||||
# --- 借库链路人员选择器(借用人 / 转交接收人 / 实际归还人)---
|
||||
@trans_bp.route('/borrow/users', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_borrow_user_options():
|
||||
"""
|
||||
借库责任链上的人员名单:借用人、转交接收人、实际归还人共用一个数据源。
|
||||
|
||||
★ 为什么只做 @jwt_required() 而不加 permission_required:
|
||||
同一份名单被三个页面共用 —— 借出(op_borrow:operation)、
|
||||
归还(op_return:operation)、转交(发起人是**持有人本人**,可能不具备任何
|
||||
库管权限)。绑定其中任一权限码,
|
||||
另外两个页面都会 403。此处沿用 /auth/users/approvers 的既有处理,
|
||||
且**只返回 id 与姓名**,不含邮箱/角色/部门等字段,最小披露。
|
||||
|
||||
★ 公司隔离与借用台账同口径(get_current_company_filter):
|
||||
否则 A 公司库管能在选择器里看到 B 公司人员,虽转交时会被 company
|
||||
校验二次拦截,但名单本身已属越权披露。
|
||||
"""
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
from app.models.system import SysUser
|
||||
|
||||
company_limit = get_current_company_filter()
|
||||
query = SysUser.query.filter(SysUser.status == 'active')
|
||||
if company_limit is not None:
|
||||
# 与 borrow_service.get_request_list 一致:SysUser.department 即公司维度
|
||||
query = query.filter(SysUser.department == company_limit)
|
||||
|
||||
users = query.order_by(SysUser.username).all()
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': [{'id': u.id, 'name': user_display_name(u)} for u in users],
|
||||
})
|
||||
|
||||
|
||||
# --- 发起借库转交(双向握手第一步)---
|
||||
@trans_bp.route('/borrow/<int:borrow_id>/transfer', methods=['POST'])
|
||||
@jwt_required()
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def transfer_borrow(borrow_id):
|
||||
# ★ 为什么不加 permission_required('borrow_transfer'):
|
||||
# 转交的责任链隔离规则是「**只有当前持有人本人**可以发起」,而持有人是
|
||||
# 普通员工,通常并不持有库管权限。若再挂一道库管权限,实际能发起的人
|
||||
# 变成「持有人 ∩ 库管」,绝大多数持有人反而发不了 —— 功能形同虚设。
|
||||
# 这与 accept / reject 同级:员工处置自己名下资产,不是库管职权。
|
||||
# 真正的边界在 service 层的 caller_user_id 强校验。
|
||||
"""
|
||||
发起借库转交:把一张借出单的持有权**整单**转给另一人,等待对方确认。
|
||||
|
||||
请求体:
|
||||
{
|
||||
"transfer_qty": 10, # 可选,传入时须等于整单待还量(一致性校验)
|
||||
"to_user_id": 12, # 必需,接收人ID(唯一身份锚点)
|
||||
"to_user_name": "张三", # 可选,仅作兼容;落库姓名以 to_user_id 反查为准
|
||||
"remark": "..." # 可选
|
||||
}
|
||||
|
||||
★ 双向握手:本接口**只落一条 PENDING 流水,不改主表 current_holder**。
|
||||
东西还没到接收人手上,责任仍归原持有人 —— 接收人在自己的列表里确认
|
||||
(POST /borrow/transfer/<id>/accept)后才真正转移。
|
||||
|
||||
★ 转交粒度 = **明细行**(传入的 borrow_id 就是目标)。同一张单的其他明细
|
||||
不受影响,故「借 2 件只转 1 件」得到天然支持;同单不同明细归属不同持有人
|
||||
是正常业务形态。
|
||||
|
||||
★ 责任链隔离:**只有该物品的当前持有人本人**可以发起转交 —— 物品在谁手上,
|
||||
就只能由谁把它交出去。这不是库管代办的场景(那是借出环节的职责),
|
||||
否则任何人都能把别人保管的资产「转」给第三方。
|
||||
|
||||
★ 严禁触碰库存:转交是纯持有权变更,实物不出入库,
|
||||
stock_buy / stock_semi / stock_product 的任何字段都不会被修改。
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
transfer = TransService.transfer_borrow(
|
||||
borrow_id=borrow_id,
|
||||
to_user_id=data.get('to_user_id'),
|
||||
transfer_qty=data.get('transfer_qty'),
|
||||
operator_name=_current_username(),
|
||||
remark=data.get('remark'),
|
||||
# ★ 责任链隔离:service 层强校验调用者就是该物品的当前持有人本人。
|
||||
# 前端隐藏按钮只是降噪,这里才是真正的边界。
|
||||
caller_user_id=get_jwt_identity(),
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': f'已发起转交,等待【{transfer.to_user_name}】确认接收',
|
||||
'data': transfer.to_dict(),
|
||||
}), 200
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 确认接收转交(双向握手第二步)---
|
||||
@trans_bp.route('/borrow/transfer/<int:transfer_id>/accept', methods=['POST'])
|
||||
@jwt_required()
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def accept_borrow_transfer(transfer_id):
|
||||
"""
|
||||
接收人确认接收转交 —— 责任正式转移。
|
||||
|
||||
★ 权限:**不加 permission_required**。这不是库管职权,而是员工对自己名下
|
||||
资产的确认动作;service 层强校验当前登录人 == to_user_id 本人。
|
||||
|
||||
★ 副作用:**仅**该转交指向的那一条明细的 current_holder 改为接收人。
|
||||
同单的其他明细可能挂在别人名下(部分转交),一律不动。
|
||||
"""
|
||||
try:
|
||||
transfer, record = TransService.accept_transfer(
|
||||
transfer_id=transfer_id,
|
||||
user_id=get_jwt_identity(),
|
||||
)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': f'已接收,物品【{record.sku}】的持有权已转移到您名下',
|
||||
'data': transfer.to_dict(),
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 拒绝转交 ---
|
||||
@trans_bp.route('/borrow/transfer/<int:transfer_id>/reject', methods=['POST'])
|
||||
@jwt_required()
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def reject_borrow_transfer(transfer_id):
|
||||
"""
|
||||
接收人拒绝转交 —— 主表不动,责任仍在原持有人。
|
||||
权限同 accept:仅 to_user_id 本人。
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
transfer = TransService.reject_transfer(
|
||||
transfer_id=transfer_id,
|
||||
user_id=get_jwt_identity(),
|
||||
reason=data.get('reason'),
|
||||
)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '已拒绝该转交',
|
||||
'data': transfer.to_dict(),
|
||||
}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 待我接收的转交数量(全局待办提醒)---
|
||||
@trans_bp.route('/borrow/transfer/pending-count', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_pending_transfer_count():
|
||||
"""
|
||||
待我接收的转交数量,供前端全局待办强提醒使用。
|
||||
|
||||
★ 无 permission_required:接收人可能是普通员工,待办提醒必须人人可见
|
||||
(与 accept/reject 同级的理由 —— 这是员工处置自己名下资产,非库管职权)。
|
||||
|
||||
★ 路由不与 /borrow/<int:borrow_id>/transfer 等冲突:
|
||||
「transfer」无法匹配 <int:borrow_id>,「pending-count」也无法匹配
|
||||
<int:transfer_id>,Werkzeug 会按转换器精确分派。
|
||||
"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
count = TransService.count_pending_transfers(identity)
|
||||
# ★ 一并返回「我发起、被对方拒绝、尚未告知我」的转交:
|
||||
# 被拒时物品责任仍在我手上,不告知就会误以为已经交接出去。
|
||||
# 与待接收数量合并进同一次轮询,避免前端多打一个请求。
|
||||
rejects = TransService.get_unseen_rejects(identity)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'count': count,
|
||||
'rejects': rejects,
|
||||
'data': {'count': count, 'rejects': rejects},
|
||||
}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 确认已知悉「转交被拒」(清除发起方的待告知提醒)---
|
||||
@trans_bp.route('/borrow/transfer/reject-ack', methods=['POST'])
|
||||
@jwt_required()
|
||||
def ack_transfer_rejects():
|
||||
"""
|
||||
发起方在前端看到「您的转交被拒绝」提醒并确认后调用,写 reject_seen_at。
|
||||
|
||||
★ 为什么需要这个接口:提醒必须能标记「已告知」,否则发起方每次登录都会
|
||||
收到同一条 —— 从「提醒」退化成「骚扰」。
|
||||
★ 无 permission_required:同 accept/reject,是员工处置自己名下资产。
|
||||
请求体:{ ids: [12, 13] },留空表示该用户全部待告知的拒绝。
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
marked = TransService.ack_rejects(get_jwt_identity(), data.get('ids'))
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': {'marked': marked}}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 借出单的流转历史(转交链 + 逐次归还)---
|
||||
@trans_bp.route('/borrow/<int:borrow_id>/history', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('op_records')
|
||||
def get_borrow_history(borrow_id):
|
||||
"""
|
||||
查看一张借出单的**转交链**与**逐次归还明细**。
|
||||
|
||||
★ 存在意义:转交与归还都是「流水式」记录,主表只保留最终快照
|
||||
(current_holder / returned_quantity)。要回答「这台设备从 A 到 B 再到 C
|
||||
都经过了谁的手」「分批归还时每一笔是谁还的」,只能查流水表 ——
|
||||
这也正是本功能一期要解决的核心问题。
|
||||
"""
|
||||
try:
|
||||
data = TransService.get_borrow_history(borrow_id)
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
|
||||
|
||||
# --- 整单流转时间线(借出 → 转交(可多次) → 归还 → 报废)---
|
||||
@trans_bp.route('/borrow/slip/<borrow_no>/history', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('op_records')
|
||||
def get_borrow_slip_history(borrow_no):
|
||||
"""
|
||||
一张借用单(borrow_no)的完整生命周期事件流,按时间倒序。
|
||||
|
||||
与 /borrow/<id>/history 的分工:
|
||||
· /borrow/<id>/history —— **单品**维度,用于精确追溯某个序列号/批次;
|
||||
· /borrow/slip/<no>/history —— **整单**维度,一次返回该单号下所有明细的
|
||||
合并时间线。列表页是 borrow_no 主子表结构(实测单张单最多 21 条明细),
|
||||
若逐条明细调用单品接口会产生 21 个请求,故提供此聚合入口。
|
||||
"""
|
||||
try:
|
||||
data = TransService.get_slip_history(borrow_no)
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
|
||||
@ -33,6 +33,10 @@ class MaterialBase(db.Model):
|
||||
product_image_remark = db.Column(db.Text, default='', comment='产品图备注')
|
||||
manual_link_remark = db.Column(db.Text, default='', comment='说明书备注')
|
||||
|
||||
# 采购链接:补货时直接跳转的购买地址(淘宝 / 1688 等)。
|
||||
# ★ 用 Text 而非 String(N):外链常带很长的查询串,截断后会得到打不开的地址。
|
||||
purchase_link = db.Column(db.Text, default='', comment='采购链接')
|
||||
|
||||
# 启用状态
|
||||
is_enabled = db.Column(db.Boolean, default=True, index=True, comment='是否启用') # ★ 状态过滤高频列
|
||||
|
||||
@ -98,6 +102,7 @@ class MaterialBase(db.Model):
|
||||
'generalImage': parse_list(self.product_image),
|
||||
'productImageRemark': self.product_image_remark or '',
|
||||
'manualLinkRemark': self.manual_link_remark or '',
|
||||
'purchaseLink': self.purchase_link or '',
|
||||
# 【核心修改】:直接返回布尔值,不再转成 1 或 0
|
||||
'isEnabled': bool(self.is_enabled),
|
||||
# 强制质检标记
|
||||
|
||||
@ -37,6 +37,11 @@ class TransBorrow(db.Model):
|
||||
borrower_name = db.Column(db.String(100))
|
||||
borrow_time = db.Column(db.DateTime, default=beijing_time)
|
||||
borrow_signature = db.Column(db.Text)
|
||||
# [一期收口] 执行借出(扫码发货)的**库管操作人**姓名。
|
||||
# 此前 execute_dispatch 的 operator_name 形参被接收后从未落库,责任链上
|
||||
# 「谁经手发货的」一直缺失。仅展示/追溯用,不参与权限判定或持有人校验。
|
||||
# ★ 该列上线前的历史记录为 NULL(执行人信息从未被采集,无从回填)。
|
||||
dispatch_operator = db.Column(db.String(100))
|
||||
expected_return_time = db.Column(db.DateTime)
|
||||
is_returned = db.Column(db.Boolean, default=False)
|
||||
return_time = db.Column(db.DateTime)
|
||||
@ -48,6 +53,29 @@ class TransBorrow(db.Model):
|
||||
status = db.Column(db.String(20), default='borrowed')
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
# =========================================================================
|
||||
# 借库转交(一期)—— 身份 ID 锚点
|
||||
#
|
||||
# ★ 为什么 ID 与姓名快照并存:
|
||||
# ID 是**唯一**的身份锚点( borrower_name 是字符串,实测 85 行里 18 个姓名
|
||||
# 映射到 18 个人,一旦重名责任链即断);姓名字段保留为**展示快照**,
|
||||
# 避免列表页逐行回查 sys_user 造成 N+1,且用户行被删除后台账仍可读
|
||||
# (与 trans_return.company_name 同一处理方式)。
|
||||
#
|
||||
# ★ 为什么 _id 允许 NULL:
|
||||
# 仅迁移前的历史行可能为 NULL —— 迁移脚本按「姓名唯一命中 sys_user」回填,
|
||||
# 85 行中 84 行成功,1 行(「测试01」)姓名无法映射故留空。
|
||||
# 新数据由 execute_dispatch 强制写入 borrower_id,不存在 NULL。
|
||||
#
|
||||
# ★ borrower_id 与 current_holder_id 的区别:
|
||||
# borrower_id = 初始借用人,写一次不再变(回答「这单最初谁借的」);
|
||||
# current_holder_id = 当前实际持有人,转交会推进它(回答「东西现在在谁手上」)。
|
||||
# 归还走完后 current_holder_id 被清空,borrower_id 保留作历史。
|
||||
# =========================================================================
|
||||
borrower_id = db.Column(db.Integer, index=True) # 初始借用人ID
|
||||
current_holder_id = db.Column(db.Integer, index=True) # 当前持有人ID
|
||||
current_holder_name = db.Column(db.String(100)) # 当前持有人姓名快照
|
||||
|
||||
def to_dict(self):
|
||||
returned_qty = float(self.returned_quantity) if self.returned_quantity is not None else 0
|
||||
total_qty = float(self.quantity) if self.quantity is not None else 0
|
||||
@ -62,8 +90,14 @@ class TransBorrow(db.Model):
|
||||
'quantity': total_qty,
|
||||
'returned_quantity': returned_qty,
|
||||
'pending_quantity': pending_qty,
|
||||
'borrower_id': self.borrower_id,
|
||||
'borrower_name': self.borrower_name,
|
||||
# ★ 当前持有人:转交后前端据此展示「东西在谁手上」并预填归还人
|
||||
'current_holder_id': self.current_holder_id,
|
||||
'current_holder_name': self.current_holder_name,
|
||||
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
|
||||
# 执行借出的库管操作人(一期收口;历史行为 NULL)
|
||||
'dispatch_operator': self.dispatch_operator,
|
||||
'borrow_signature': self.borrow_signature,
|
||||
'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None,
|
||||
'is_returned': self.is_returned,
|
||||
@ -90,6 +124,144 @@ class TransBorrow(db.Model):
|
||||
return float(result) if result is not None else 0.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 借库转交流水(一期)
|
||||
# =============================================================================
|
||||
# 设计要点
|
||||
# --------
|
||||
# 1. **流水不覆盖**:一行 = 一次转交动作。trans_borrow 是单行模型,只能存
|
||||
# 「当前持有人」一个值;A→B→C 的完整持有链只能由本表回答。
|
||||
# 若改为在 trans_borrow 上覆盖 current_holder,等于重演归还失忆症。
|
||||
#
|
||||
# 2. **一期仅支持整单全量转交**:单行模型无法同时追踪两个持有人,
|
||||
# 部分转交会让 current_holder 语义撕裂(半单归 A、半单归 B)。
|
||||
# 故 transfer_qty 一期恒等于 quantity - returned_quantity,
|
||||
# 服务层对部分转交直接拒绝。未来若需部分转交,需改为按 quantity 拆行。
|
||||
#
|
||||
# 3. **不碰库存**:转交是纯持有权变更,实物不出入库,全程不得触碰
|
||||
# available_quantity / stock_quantity。
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# 转交状态机:发起只落 PENDING,接收人 accept 后才真正转移责任
|
||||
TRANSFER_STATUS_PENDING = 'PENDING' # 待接收(主表 current_holder 未动)
|
||||
TRANSFER_STATUS_ACCEPTED = 'ACCEPTED' # 已接收(主表已转移)
|
||||
TRANSFER_STATUS_REJECTED = 'REJECTED' # 已拒绝(主表不动,责任仍在原持有人)
|
||||
|
||||
|
||||
class TransBorrowTransfer(db.Model):
|
||||
"""借库转交流水:一行 = 一次转交动作,不做覆盖式更新。"""
|
||||
|
||||
__tablename__ = 'trans_borrow_transfer'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# 关联主表(不建 FK:与 trans_return 一致,台账必须能独立存活)
|
||||
# ★ 代表明细:一次转交要覆盖该单号下**多行**,单行 ID 表达不了覆盖范围,
|
||||
# 故覆盖范围以 borrow_no 为准,此列仅供追溯。
|
||||
borrow_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
# ★ 单据身份:accept 时据此批量更新该单全部未还明细的持有人
|
||||
borrow_no = db.Column(db.String(100), index=True)
|
||||
# ★ 状态机:见文件顶部常量
|
||||
status = db.Column(db.String(20), nullable=False, default=TRANSFER_STATUS_PENDING, index=True)
|
||||
# ★ 发起方看到「被拒绝」提醒并确认的时间。
|
||||
# 被拒绝时物品责任仍在发起方手上 —— 他若不查列表就会误以为已经交接出去,
|
||||
# 责任链出现静默断点。故必须告知,且必须能标记「已告知」,
|
||||
# 否则发起方每次登录都收到同一条提醒,从提醒退化成骚扰。
|
||||
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)
|
||||
from_user_name = db.Column(db.String(100))
|
||||
|
||||
# 接收方(= 转交后的 current_holder)
|
||||
to_user_id = db.Column(db.Integer)
|
||||
to_user_name = db.Column(db.String(100))
|
||||
|
||||
# 一期恒等于 quantity - returned_quantity(整单全量转交)
|
||||
transfer_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0)
|
||||
|
||||
transfer_time = db.Column(db.DateTime, default=beijing_time)
|
||||
operator_name = db.Column(db.String(100)) # 执行转交的库管
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'borrow_id': self.borrow_id,
|
||||
'borrow_no': self.borrow_no,
|
||||
'status': self.status,
|
||||
'status_text': {
|
||||
TRANSFER_STATUS_PENDING: '待接收',
|
||||
TRANSFER_STATUS_ACCEPTED: '已接收',
|
||||
TRANSFER_STATUS_REJECTED: '已拒绝',
|
||||
}.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,
|
||||
'to_user_name': self.to_user_name,
|
||||
'transfer_qty': float(self.transfer_qty) if self.transfer_qty is not None else 0,
|
||||
'transfer_time': self.transfer_time.strftime('%Y-%m-%d %H:%M:%S') if self.transfer_time else None,
|
||||
'operator_name': self.operator_name,
|
||||
'remark': self.remark,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 借库归还流水
|
||||
# =============================================================================
|
||||
# 为什么需要它
|
||||
# ------------
|
||||
# trans_borrow 原先在部分归还时把 return_time / return_operator /
|
||||
# return_signature **逐次覆盖**,导致「谁在什么时候还了多少」只剩最后一次
|
||||
# (出库退回模块的 TransReturn 已就同一问题另建流水,见本文件上方注释)。
|
||||
# 本表按次记录归还动作,根治该失忆症。
|
||||
#
|
||||
# ★ 主表字段的定位(未废弃,但降级):
|
||||
# returned_quantity / is_returned / status —— 仍是**累计快照**,聚合语义正确,
|
||||
# 列表页的「未还/已还」tab 判定依赖它们,继续维护;
|
||||
# return_time / return_operator / return_signature —— 降级为「最近一次归还」
|
||||
# 展示快照,records.vue 的归还人/归还时间列依赖它们,继续刷新;
|
||||
# 逐次明细的**权威来源**是本表。
|
||||
#
|
||||
# ★ returner_id 与 operator_name 是两个人:
|
||||
# returner_id = 实际把东西交回窗口的人,写入前已强校验 == current_holder_id;
|
||||
# operator_name = 经手办理还库的库管。
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TransBorrowReturn(db.Model):
|
||||
"""借库归还流水:一行 = 一次归还动作。"""
|
||||
|
||||
__tablename__ = 'trans_borrow_return'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
borrow_id = db.Column(db.Integer, nullable=False, index=True) # 关联 trans_borrow.id
|
||||
returner_id = db.Column(db.Integer, index=True) # 实际归还人(==current_holder)
|
||||
return_qty = db.Column(db.Numeric(19, 4), nullable=False, default=0)
|
||||
return_time = db.Column(db.DateTime, default=beijing_time)
|
||||
operator_name = db.Column(db.String(100)) # 经手库管
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'borrow_id': self.borrow_id,
|
||||
'returner_id': self.returner_id,
|
||||
'return_qty': float(self.return_qty) if self.return_qty is not None else 0,
|
||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
|
||||
'operator_name': self.operator_name,
|
||||
}
|
||||
|
||||
|
||||
class TransRepair(db.Model):
|
||||
__tablename__ = 'trans_repair'
|
||||
|
||||
|
||||
@ -64,7 +64,16 @@ class BorrowApprovalService:
|
||||
def submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None,
|
||||
borrower_name=None, force_approval=False):
|
||||
"""
|
||||
提交借库申请(仅存储意向,不扣库存)
|
||||
提交借库申请。
|
||||
|
||||
★ 库存语义:**提交即预占**(Phase 1 起)。
|
||||
本函数会调用 reserve_for_items() 锁定具体批次并扣减 available_quantity,
|
||||
不动 stock_quantity —— 借出期间该批货不可再被他人领用。
|
||||
预占的释放时机:
|
||||
· 驳回 / 撤回 → release_reserved() 全额归还;
|
||||
· 扫码执行 → restore_then_deduct() 先全量释放、再按实扫批次扣减。
|
||||
(原注释写「仅存储意向,不扣库存」,是 Phase 1 改造前的旧描述,
|
||||
与下方实现不符,已修正。)
|
||||
|
||||
Args:
|
||||
applicant_id: 申请人ID
|
||||
|
||||
@ -622,6 +622,7 @@ class MaterialBaseService:
|
||||
product_image=json.dumps(data.get('generalImage', [])),
|
||||
product_image_remark=data.get('productImageRemark', ''),
|
||||
manual_link_remark=data.get('manualLinkRemark', ''),
|
||||
purchase_link=data.get('purchaseLink', ''),
|
||||
reference_price=data.get('referencePrice'),
|
||||
is_enabled=is_enabled_val
|
||||
)
|
||||
@ -668,6 +669,7 @@ class MaterialBaseService:
|
||||
|
||||
if 'productImageRemark' in data: material.product_image_remark = data['productImageRemark']
|
||||
if 'manualLinkRemark' in data: material.manual_link_remark = data['manualLinkRemark']
|
||||
if 'purchaseLink' in data: material.purchase_link = data['purchaseLink']
|
||||
|
||||
if 'generalManual' in data:
|
||||
material.manual_link = json.dumps(data['generalManual'])
|
||||
|
||||
@ -233,6 +233,40 @@ def reserve_for_items(items, company_limit=None, strict=True):
|
||||
|
||||
# ---- 立即扣减 available_quantity(软锁入池)----
|
||||
models = stock_model_map()
|
||||
|
||||
# ==================================================================
|
||||
# ★ 透传申请明细的「意图字段」到预占结果
|
||||
#
|
||||
# 背景:本函数产出的 reserved 会被调用方**直接持久化** ——
|
||||
# borrow_service.submit_approval 中 `approval.set_items(reserved_items)`,
|
||||
# 即审批单的 items_json 就是下面 reserved.append({...}) 的内容。
|
||||
# 而该 append 是**重建**字典,只保留库存定位与数量,会把调用方传入的
|
||||
# expected_return_time / is_indefinite / remark 全部丢弃 ——
|
||||
# 执行页(扫码发货)选中审批单后因此带不出「预计归还日期」与「长期借用」,
|
||||
# 库管只能每次手工补填。
|
||||
#
|
||||
# 实测证据:2026-09-10 预占改造(b57c21a)上线后新建的审批单,items_json
|
||||
# 中这两个字段全部消失;此前的老单据(id ≤ 40)仍保留。分界点与该提交的
|
||||
# 上线时刻(09-10 14:59)完全吻合。
|
||||
#
|
||||
# 一条申请明细会被拆到多个批次行(同物料多批次),故按 base_id 建索引,
|
||||
# 把同一份「意图」回填到它拆分出的每一行上。
|
||||
# ==================================================================
|
||||
intent_by_base = {}
|
||||
intent_by_name = {}
|
||||
for it in items:
|
||||
if it.get('base_id') is not None:
|
||||
intent_by_base.setdefault(it.get('base_id'), it)
|
||||
intent_by_name.setdefault(
|
||||
(str(it.get('name') or '').strip(),
|
||||
str(it.get('spec_model') or it.get('standard') or '').strip()),
|
||||
it,
|
||||
)
|
||||
|
||||
# 仅透传「申请意图」。库存定位(source_table/stock_id)与数量由分配结果决定,
|
||||
# 绝不能被原明细覆盖 —— 实扫可能换批次,这里写什么,执行页就按什么释放。
|
||||
INTENT_FIELDS = ('expected_return_time', 'is_indefinite', 'remark')
|
||||
|
||||
reserved = []
|
||||
for a in alloc_items:
|
||||
take = float(a.get('allocated_qty') or 0)
|
||||
@ -256,7 +290,7 @@ def reserve_for_items(items, company_limit=None, strict=True):
|
||||
)
|
||||
row.available_quantity = avail - take
|
||||
|
||||
reserved.append({
|
||||
item = {
|
||||
'base_id': a.get('base_id'),
|
||||
'name': a.get('name') or '',
|
||||
'spec_model': a.get('standard') or a.get('spec_model') or '',
|
||||
@ -267,7 +301,21 @@ def reserve_for_items(items, company_limit=None, strict=True):
|
||||
'allocated_qty': take,
|
||||
'quantity': take, # 兼容既有字段名(申请量=预占量)
|
||||
'reserved': True, # ★ 标记该行已预占,释放时据此还原
|
||||
})
|
||||
}
|
||||
|
||||
# ★ 回填申请意图(见上方 INTENT_FIELDS 说明)。
|
||||
# 跳过 None:无限期借用提交的 expected_return_time 就是 null,
|
||||
# 写入 null 无意义,前端读到的仍是 undefined,行为一致。
|
||||
origin = intent_by_base.get(a.get('base_id')) or intent_by_name.get(
|
||||
(str(a.get('name') or '').strip(),
|
||||
str(a.get('standard') or a.get('spec_model') or '').strip())
|
||||
)
|
||||
if origin is not None:
|
||||
for f in INTENT_FIELDS:
|
||||
if origin.get(f) is not None:
|
||||
item[f] = origin.get(f)
|
||||
|
||||
reserved.append(item)
|
||||
|
||||
return reserved, shortages
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
"""
|
||||
import logging
|
||||
|
||||
from app.extensions import db
|
||||
from app.extensions import db, beijing_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -368,7 +368,6 @@ class BorrowScrapAdapter(ScrapSourceAdapter):
|
||||
(base.spec_model if base else '') or '')
|
||||
|
||||
def deduct(self, row_id, qty, req, operator_name):
|
||||
from datetime import datetime
|
||||
from app.models.transaction import TransScrap, TransBorrow
|
||||
|
||||
record = TransBorrow.query.with_for_update().get(row_id)
|
||||
@ -390,7 +389,10 @@ class BorrowScrapAdapter(ScrapSourceAdapter):
|
||||
# 1) 标记借用记录:不再追讨归还
|
||||
record.is_returned = True
|
||||
record.status = 'scrapped'
|
||||
record.return_time = datetime.now()
|
||||
# ★ 时间口径:与 borrow_time / 归还流水统一取北京时间。
|
||||
# 原用 datetime.now()(容器 UTC),会让报废时间在台账上比实际早 8 小时,
|
||||
# 与同一行的 borrow_time 自相矛盾,也让流转时间线出现倒序假象。
|
||||
record.return_time = beijing_time()
|
||||
record.return_operator = operator_name
|
||||
|
||||
# 2) 扣总库存(该物品确认损失);可用量已在借出时冻结,不重复扣
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
from app.models.transaction import TransBorrow
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.transaction import (
|
||||
TransBorrow, TransBorrowTransfer, TransBorrowReturn,
|
||||
TRANSFER_STATUS_PENDING, TRANSFER_STATUS_ACCEPTED, TRANSFER_STATUS_REJECTED,
|
||||
)
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
@ -11,6 +14,49 @@ from sqlalchemy import desc, func, nullslast, asc, or_, and_, case
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
|
||||
def user_display_name(user):
|
||||
"""
|
||||
用户名展示口径:'姓名/拼音' → '姓名'。
|
||||
|
||||
★ 与迁移脚本 phase4_borrow_transfer.sql 的回填口径
|
||||
(split_part(username,'/',1))及 borrow_service 的通知口径三处保持一致 ——
|
||||
否则同一人在台账里会出现「石利」与「石利/shili」两种写法,按姓名检索即失准。
|
||||
"""
|
||||
if not user:
|
||||
return ''
|
||||
username = str(user.username or '')
|
||||
return username.split('/')[0] if '/' in username else username
|
||||
|
||||
|
||||
def _assert_borrow_company_visible(record):
|
||||
"""
|
||||
行级公司隔离:确认一条借用记录属于当前用户可见的公司。
|
||||
|
||||
★ 隔离链路与 get_records 完全一致:
|
||||
trans_borrow → (source_table, stock_id) → 库存表 → material_base.company_name
|
||||
不另起一套口径,避免列表能看到的单、接口却判定为越权(或反之)。
|
||||
|
||||
★ Fail-Closed:链路断裂(源库存行被入库模块物理删除,实测已有先例)时
|
||||
**拒绝**而不是放行 —— 持有权变更直接改变责任归属,宁可让库管走人工,
|
||||
也不在无法判定归属时越权操作。
|
||||
"""
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is None: # 超管 / crossDomain → 全量跨域
|
||||
return
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
stock = ModelClass.query.get(record.stock_id) if (ModelClass and record.stock_id) else None
|
||||
base = stock.base if stock else None
|
||||
|
||||
if base is None:
|
||||
raise ValueError(
|
||||
"该借用记录的来源库存已不存在,无法完成公司隔离校验,请联系管理员处理"
|
||||
)
|
||||
if (base.company_name or '') != company_limit:
|
||||
raise ValueError("无权操作其他公司的借用记录")
|
||||
|
||||
|
||||
class TransService:
|
||||
|
||||
@staticmethod
|
||||
@ -32,7 +78,8 @@ class TransService:
|
||||
|
||||
@staticmethod
|
||||
def execute_dispatch(approval_id, items, operator_name='System', borrower_name=None,
|
||||
signature=None, remark=None, expected_return_time=None):
|
||||
signature=None, remark=None, expected_return_time=None,
|
||||
borrower_id=None):
|
||||
"""
|
||||
执行借库扣减(审批通过后调用)
|
||||
流程:锁审批单 → 构建审批上限字典 → 锁库存行 → 名称规格校验 → 扣减库存 → 生成 TransBorrow 记录 → 标记审批单完成
|
||||
@ -41,6 +88,10 @@ class TransService:
|
||||
借库申请是按【名称 + 规格型号】发起的(borrow_service 强制要求 name/spec_model/quantity 三字段),
|
||||
申请时尚未绑定具体库存行;扫码出库时通过锁定 stock 行回查 material_base 表,
|
||||
用 (name, spec_model) 与审批单做物料维度聚合比对,避免 sku 维度坍塌或绕过。
|
||||
|
||||
★ borrower_id(一期转交改造后为**必填**):
|
||||
实际借用人ID。borrower_name 参数仅为向后兼容而保留 —— 落库时一律
|
||||
以 borrower_id 反查 sys_user 得到的姓名为准,传参中的姓名被忽略。
|
||||
"""
|
||||
from app.models.borrow import BorrowApproval
|
||||
|
||||
@ -57,11 +108,30 @@ class TransService:
|
||||
status_map = {0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'}
|
||||
raise ValueError(f"审批单状态为【{status_map.get(approval.status, approval.status)}】,无法执行借库")
|
||||
|
||||
# ★ borrower_name 兜底:优先用前端传参,其次从审批单读取(申请时填写的姓名)
|
||||
# ==============================================
|
||||
# ★ 借用人身份锚定(一期转交改造):强制 ID
|
||||
#
|
||||
# 原实现只收 borrower_name 字符串,责任链从落库那刻起就存在重名歧义
|
||||
# (实测 85 行 / 18 个姓名,纯姓名无法唯一锚定一个人)。
|
||||
# 现强制要求 borrower_id,并以 sys_user 为**唯一事实来源**反查姓名:
|
||||
# · borrower_name 降级为展示快照,不再接受前端自由输入;
|
||||
# · 传了 borrower_id 但用户不存在 → 直接拒绝,不静默放行。
|
||||
#
|
||||
# ★ 为什么不回退到 approval.borrower_name:
|
||||
# 申请单上的姓名是「申请意向」,与「扫码时实际来领的人」本就可能不同
|
||||
# (库管代建场景尤甚)。若回退,current_holder_id 会从第一刻就记错人,
|
||||
# 转交与归还的整条责任链都会建立在错误的锚点上。宁可让库管重选。
|
||||
# ==============================================
|
||||
if not borrower_id:
|
||||
raise ValueError("缺少借用人 borrower_id,请重新选择借用人后再提交")
|
||||
|
||||
from app.models.system import SysUser
|
||||
borrower = SysUser.query.get(int(borrower_id))
|
||||
if not borrower:
|
||||
raise ValueError(f"借用人不存在(ID:{borrower_id}),请重新选择")
|
||||
borrower_name = user_display_name(borrower)
|
||||
if not borrower_name:
|
||||
borrower_name = approval.borrower_name
|
||||
if not borrower_name:
|
||||
raise ValueError("审批单中未记录借库人姓名,请联系管理员补录")
|
||||
raise ValueError(f"借用人(ID:{borrower_id})用户名为空,无法生成台账快照")
|
||||
|
||||
# ==============================================
|
||||
# ★ 防线2:构建审批上限字典
|
||||
@ -178,8 +248,19 @@ class TransService:
|
||||
stock_id=stock.id,
|
||||
barcode=stock.barcode,
|
||||
quantity=qty,
|
||||
# ★ 借出即由借用人持有:borrower_id 为初始借用人(写一次不再变),
|
||||
# current_holder_* 为当前持有人 —— 转交会在此之上继续推进。
|
||||
borrower_id=int(borrower_id),
|
||||
borrower_name=borrower_name,
|
||||
current_holder_id=int(borrower_id),
|
||||
current_holder_name=borrower_name,
|
||||
# ★ 显式置 0:归还逻辑按 returned_quantity 累加并判断是否还清,
|
||||
# 不依赖 DB 列默认值(避免 ORM/DB 默认值口径不一致时算错待还量)。
|
||||
returned_quantity=0,
|
||||
borrow_signature=signature,
|
||||
# ★ 发货操作人:执行本次借出的库管。operator_name 此前被接收
|
||||
# 却从未落库,责任链上「谁经手发货」一直缺失,此处补齐。
|
||||
dispatch_operator=operator_name,
|
||||
remark=remark,
|
||||
expected_return_time=expected_return_time,
|
||||
# [新增] 记录借出时的库位快照
|
||||
@ -210,6 +291,7 @@ class TransService:
|
||||
items=data.get('items', []),
|
||||
operator_name=operator_name,
|
||||
borrower_name=data.get('borrower_name'),
|
||||
borrower_id=data.get('borrower_id'),
|
||||
signature=data.get('signature_path'),
|
||||
remark=data.get('remark'),
|
||||
expected_return_time=data.get('expected_return_time')
|
||||
@ -242,7 +324,7 @@ class TransService:
|
||||
return res_dict
|
||||
|
||||
@staticmethod
|
||||
def process_return(data, operator_name):
|
||||
def process_return(data, operator_name, returner_id=None):
|
||||
"""
|
||||
还库逻辑(支持部分归还)- 已优化,消除 N+1 和长事务死锁风险
|
||||
四步走策略:
|
||||
@ -250,6 +332,13 @@ class TransService:
|
||||
2. 批量锁定借用记录
|
||||
3. 收集库存ID并批量锁定库存
|
||||
4. 内存中完成业务逻辑
|
||||
|
||||
参数
|
||||
----
|
||||
operator_name : 经手办理还库的**库管**姓名(窗口操作人)
|
||||
returner_id : 实际把物品交回窗口的**归还人**ID(一期转交改造新增)。
|
||||
记录有 current_holder_id 时强制校验二者一致 ——
|
||||
详见循环内的持有人校验块。
|
||||
"""
|
||||
items = data.get('items', [])
|
||||
signature = data.get('signature_path') # 库管签字
|
||||
@ -309,6 +398,11 @@ class TransService:
|
||||
# ==========================================
|
||||
# ★ 优化步骤 4:内存中完成业务逻辑
|
||||
# ==========================================
|
||||
# ★ 时间口径修复:原实现用 datetime.now()(容器本地时间,Docker 下为
|
||||
# UTC),而同一行的 borrow_time 由 beijing_time 写入 —— 两个字段
|
||||
# 差 8 小时,台账时间线自相矛盾。统一取北京时间,并与下方归还流水
|
||||
# 共用同一个时间戳,保证主表快照与流水完全对齐。
|
||||
return_now = beijing_time()
|
||||
for borrow_id, item_data in item_map.items():
|
||||
return_qty = item_data['return_qty']
|
||||
final_location = item_data['final_location']
|
||||
@ -317,6 +411,35 @@ class TransService:
|
||||
if not record:
|
||||
continue
|
||||
|
||||
# ==========================================
|
||||
# ★ 持有人强校验(一期转交改造)
|
||||
#
|
||||
# 原实现只要持有 op_return:operation 权限的库管即可归还**任何人**
|
||||
# 借出的物品,函数内从不读取 record.borrower_name,归还环节的责任链
|
||||
# 是断的。转交上线后物品会在 A→B→C 之间流转,若不校验归还人,
|
||||
# 「谁还的」将与「谁该还」彻底脱钩。
|
||||
#
|
||||
# 规则:记录有 current_holder_id(= 物品仍在某人手上)时,
|
||||
# 归还人必须**就是**该持有人。
|
||||
#
|
||||
# ★ 为什么 current_holder_id 为 NULL 时跳过:
|
||||
# 仅迁移前无法锚定姓名的历史行会是 NULL,跳过以兼容存量数据、
|
||||
# 不阻断其正常归还;新数据(execute_dispatch 落库)必有值,
|
||||
# 即新流程**不存在**绕过校验的路径。
|
||||
# ==========================================
|
||||
if record.current_holder_id is not None:
|
||||
holder_label = record.current_holder_name or f"用户({record.current_holder_id})"
|
||||
if returner_id is None:
|
||||
raise ValueError(
|
||||
f"缺少归还人:物品【{record.sku}】当前由【{holder_label}】持有,"
|
||||
f"请选择归还人后再提交"
|
||||
)
|
||||
if int(returner_id) != int(record.current_holder_id):
|
||||
raise ValueError(
|
||||
f"归还人与当前持有人不符,禁止归还:物品【{record.sku}】的"
|
||||
f"当前持有人为【{holder_label}】,而非所选归还人"
|
||||
)
|
||||
|
||||
# 计算待还数量
|
||||
returned_qty = float(record.returned_quantity) if record.returned_quantity else 0
|
||||
total_qty = float(record.quantity) if record.quantity else 0
|
||||
@ -337,32 +460,600 @@ class TransService:
|
||||
if final_location:
|
||||
stock.warehouse_location = final_location
|
||||
|
||||
# ==========================================
|
||||
# 更新归还数量和状态
|
||||
#
|
||||
# ★ 主表字段的定位(一期转交改造后):
|
||||
# returned_quantity / is_returned / status 是**累计快照**,
|
||||
# 聚合语义正确,列表页「未还/已还」判定依赖它们 → 继续维护;
|
||||
# return_time / return_operator / return_signature 降级为
|
||||
# 「最近一次归还」**展示快照**(records.vue 的归还人/归还时间列
|
||||
# 依赖它们)→ 继续刷新;
|
||||
# 逐次归还的**权威明细**改由 trans_borrow_return 承载 ——
|
||||
# 原先只写主表时,部分归还下「谁在什么时候还了多少」会被
|
||||
# 逐次覆盖而永久丢失(失忆症),流水表根治该问题。
|
||||
# ==========================================
|
||||
new_returned_qty = returned_qty + return_qty
|
||||
record.returned_quantity = new_returned_qty
|
||||
|
||||
if new_returned_qty >= total_qty:
|
||||
record.is_returned = True
|
||||
record.status = 'returned'
|
||||
# ★ 已全部还清:物品回到仓库,无人持有。
|
||||
# 清空 current_holder 使「current_holder_id IS NOT NULL」
|
||||
# 成为「仍在某人手上」的有效信号(与迁移脚本对已归还历史行
|
||||
# 不回填 holder 的口径一致)。借款人仍留在 borrower_id 作历史。
|
||||
record.current_holder_id = None
|
||||
record.current_holder_name = None
|
||||
else:
|
||||
record.is_returned = False
|
||||
record.status = 'partial_returned'
|
||||
|
||||
record.return_time = datetime.now()
|
||||
record.return_time = return_now
|
||||
record.return_operator = operator_name
|
||||
record.return_signature = signature
|
||||
if final_location:
|
||||
record.return_location = final_location
|
||||
|
||||
# ★ 逐次归还落流水(治失忆症)
|
||||
# returner_id 与 operator_name 是两个人:前者是交回物品的人,
|
||||
# 后者是经手办理的库管。上面已强校验前者 == current_holder_id。
|
||||
db.session.add(TransBorrowReturn(
|
||||
borrow_id=record.id,
|
||||
returner_id=int(returner_id) if returner_id is not None else None,
|
||||
return_qty=return_qty,
|
||||
return_time=return_now,
|
||||
operator_name=operator_name,
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ==========================================================================
|
||||
# 借库转交(双向握手 + 明细行粒度)
|
||||
#
|
||||
# 状态机:
|
||||
# PENDING ──accept──> ACCEPTED (主表 current_holder 正式转移)
|
||||
# └───reject──> REJECTED (主表不动,责任仍在原持有人)
|
||||
#
|
||||
# ★ 转交粒度 = **明细行**(trans_borrow.id),不是整张单。
|
||||
# 物理现场经常只转交部分工具(借了 2 件、只把 1 件给别人),
|
||||
# 一张单下的不同明细归属不同持有人是**正常业务形态**,不是需要修复的
|
||||
# 「单内撕裂」。前端按单号聚合时需自行处理「多人持有」的展示。
|
||||
#
|
||||
# (唯一性约束也随之从「单号至多一条 PENDING」下沉为「明细行至多一条」,
|
||||
# 故同单的其他明细可以同时各自挂着待接收,互不阻塞。)
|
||||
# ==========================================================================
|
||||
@staticmethod
|
||||
def transfer_borrow(borrow_id, to_user_id, transfer_qty=None, operator_name='System', remark=None,
|
||||
caller_user_id=None):
|
||||
"""
|
||||
发起转交(双向握手第一步):只落一条 PENDING 流水,**不动物权**。
|
||||
|
||||
东西还没到接收人手上,责任仍由原持有人承担 —— 这是与旧实现最本质的
|
||||
区别:旧实现「发起即生效」,接收人在毫不知情的情况下被强塞了资产责任。
|
||||
|
||||
参数
|
||||
----
|
||||
borrow_id : 该单**任一明细行**的 ID,仅用于解析单据身份(borrow_no)
|
||||
to_user_id : 接收人ID
|
||||
transfer_qty : 可选。传入时须等于该明细待还量,仅作一致性校验
|
||||
operator_name: 操作人展示名(写入流水备查)
|
||||
remark : 转交备注
|
||||
caller_user_id: **调用者本人ID**,强校验其必须是该明细的当前持有人。
|
||||
传 None 一律拒绝,不做「系统内部调用」的隐式放行。
|
||||
|
||||
返回已 commit 的 TransBorrowTransfer
|
||||
异常 ValueError
|
||||
"""
|
||||
from app.models.system import SysUser
|
||||
|
||||
if to_user_id is None:
|
||||
raise ValueError("缺少接收人 to_user_id")
|
||||
try:
|
||||
to_user_id = int(to_user_id)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("接收人 to_user_id 格式无效,应为数字ID")
|
||||
|
||||
# ★ 转交粒度 = **明细行**(trans_borrow.id),不是整张单。
|
||||
# 物理现场经常只转交部分工具(借了 2 件、只把 1 件给别人),
|
||||
# 一张单下的不同明细本就允许归属不同持有人 —— 那是正常业务形态,
|
||||
# 不是需要修复的「撕裂」。
|
||||
record = TransBorrow.query.with_for_update().get(borrow_id)
|
||||
if not record:
|
||||
raise ValueError("借出记录不存在")
|
||||
borrow_no = record.borrow_no
|
||||
|
||||
# --- 1. 状态准入:这一行必须还在外 ---
|
||||
if record.is_returned:
|
||||
raise ValueError("该明细已归还,无可转交的实物")
|
||||
if record.status == 'scrapped':
|
||||
raise ValueError("该明细已转入报废流程,不可转交")
|
||||
|
||||
# --- 2. 数量:整行转交 ---
|
||||
# 一行只能有一个 current_holder_id,故不支持「同一行只转一部分」——
|
||||
# 那需要把这行拆成两行。经业务确认,现场场景中「借 2 件转 1 件」
|
||||
# 的两件本就是两条明细行,故此限制不影响实际使用。
|
||||
pending_qty = float(record.quantity or 0) - float(record.returned_quantity or 0)
|
||||
if pending_qty <= 0:
|
||||
raise ValueError("该明细待还数量为 0,无可转交的实物")
|
||||
if transfer_qty is not None:
|
||||
try:
|
||||
transfer_qty = float(transfer_qty)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("转交数量格式无效,应为数字")
|
||||
if abs(transfer_qty - pending_qty) > 1e-6:
|
||||
raise ValueError(
|
||||
f"转交粒度是整条明细:该明细待还 {pending_qty},"
|
||||
f"本次填写 {transfer_qty}。若需转交其中一部分,"
|
||||
f"该部分应为另一条明细行。"
|
||||
)
|
||||
|
||||
# --- 3. 转出方 = 该明细当前的持有人 ---
|
||||
if record.current_holder_id is None:
|
||||
raise ValueError("该明细的当前持有人未锚定(历史数据),无法转交,请先办理归还")
|
||||
from_id = int(record.current_holder_id)
|
||||
from_name = record.current_holder_name or user_display_name(SysUser.query.get(from_id))
|
||||
|
||||
# --- 3.5 责任链隔离:只有当前持有人本人可以发起转交 ---
|
||||
# 物品在谁手上,就只能由谁把它交出去 —— 否则任何人都能把别人保管的
|
||||
# 资产「转」给第三方,责任链形同虚设。
|
||||
# ★ 前端隐藏按钮只是降噪,**这里才是真正的边界**:接口可被直接调用。
|
||||
if caller_user_id is None or int(caller_user_id) != from_id:
|
||||
raise ValueError(
|
||||
f"只有该物品的当前持有人【{from_name}】本人可以发起转交"
|
||||
)
|
||||
|
||||
# --- 4. 接收人校验 ---
|
||||
to_user = SysUser.query.get(to_user_id)
|
||||
if not to_user:
|
||||
raise ValueError(f"接收人不存在(ID:{to_user_id})")
|
||||
to_user_name = user_display_name(to_user)
|
||||
if to_user_id == from_id:
|
||||
raise ValueError(f"接收人与当前持有人同为【{to_user_name}】,无需转交")
|
||||
|
||||
# --- 5. 唯一性下沉到明细行:同一行至多一条待接收 ---
|
||||
# (同一张单的**其他**明细可以同时各自挂一条,互不影响 ——
|
||||
# 这正是部分转交要表达的语义)
|
||||
pending = TransBorrowTransfer.query.filter(
|
||||
TransBorrowTransfer.borrow_id == record.id,
|
||||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||||
).first()
|
||||
if pending:
|
||||
raise ValueError(
|
||||
f"该物品已有一条待接收的转交(接收人:"
|
||||
f"{pending.to_user_name or pending.to_user_id}),请等待对方处理"
|
||||
)
|
||||
|
||||
# --- 6. 行级公司隔离(Fail-Closed)---
|
||||
_assert_borrow_company_visible(record)
|
||||
|
||||
# ==================================================================
|
||||
# ★ 只写台账,主表 current_holder **保持不变** —— 双向握手的关键。
|
||||
# 库存字段更是一律不碰(转交是纯持有权变更,实物不出入库)。
|
||||
# ==================================================================
|
||||
transfer = TransBorrowTransfer(
|
||||
borrow_id=record.id,
|
||||
borrow_no=borrow_no,
|
||||
status=TRANSFER_STATUS_PENDING,
|
||||
from_user_id=from_id,
|
||||
from_user_name=from_name,
|
||||
to_user_id=to_user_id,
|
||||
to_user_name=to_user_name,
|
||||
transfer_qty=pending_qty,
|
||||
transfer_time=beijing_time(),
|
||||
operator_name=operator_name,
|
||||
remark=remark,
|
||||
)
|
||||
db.session.add(transfer)
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
return transfer
|
||||
|
||||
@staticmethod
|
||||
def accept_transfer(transfer_id, user_id):
|
||||
"""
|
||||
接收转交(双向握手第二步):流水置 ACCEPTED,并**正式转移持有权**。
|
||||
|
||||
覆盖范围 = 该转交指向的**单条明细行**。同一张单的其他明细不受影响 ——
|
||||
「借 2 件只转 1 件」时,那 1 件到接收人名下,另 1 件仍在原持有人手上,
|
||||
这是正常业务形态。
|
||||
|
||||
权限:仅 to_user_id 本人(这是员工对自己名下资产的确认,不是库管权限)。
|
||||
返回 (transfer, 被转移的明细行)
|
||||
"""
|
||||
transfer = TransBorrowTransfer.query.with_for_update().get(transfer_id)
|
||||
if not transfer:
|
||||
raise ValueError("转交记录不存在")
|
||||
if transfer.status != TRANSFER_STATUS_PENDING:
|
||||
raise ValueError(f"该转交已【{transfer.to_dict()['status_text']}】,无法重复处理")
|
||||
if transfer.to_user_id is None or int(transfer.to_user_id) != int(user_id):
|
||||
raise ValueError("只有该转交的接收人本人可以确认接收")
|
||||
if not transfer.borrow_no:
|
||||
raise ValueError("该转交记录缺少单号(历史数据),无法确认接收")
|
||||
|
||||
# ★ 只转移 transfer.borrow_id 指向的**那一行**:
|
||||
# 转交粒度是明细行,同单的其他明细可能挂在别人名下(部分转交),
|
||||
# 整批改写会把别人手上的东西一并抢过来。
|
||||
record = TransBorrow.query.with_for_update().get(transfer.borrow_id)
|
||||
if not record:
|
||||
raise ValueError("转交目标明细已不存在(可能已被删除)")
|
||||
if record.is_returned:
|
||||
raise ValueError("该明细已归还,无需接收")
|
||||
|
||||
to_name = transfer.to_user_name
|
||||
if not to_name:
|
||||
from app.models.system import SysUser
|
||||
to_name = user_display_name(SysUser.query.get(transfer.to_user_id))
|
||||
|
||||
record.current_holder_id = int(transfer.to_user_id)
|
||||
record.current_holder_name = to_name
|
||||
|
||||
transfer.status = TRANSFER_STATUS_ACCEPTED
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
return transfer, record
|
||||
|
||||
@staticmethod
|
||||
def reject_transfer(transfer_id, user_id, reason=None):
|
||||
"""
|
||||
拒绝转交:流水置 REJECTED,**主表不动** —— 责任仍在原持有人。
|
||||
权限:仅 to_user_id 本人。
|
||||
"""
|
||||
transfer = TransBorrowTransfer.query.with_for_update().get(transfer_id)
|
||||
if not transfer:
|
||||
raise ValueError("转交记录不存在")
|
||||
if transfer.status != TRANSFER_STATUS_PENDING:
|
||||
raise ValueError(f"该转交已【{transfer.to_dict()['status_text']}】,无法重复处理")
|
||||
if transfer.to_user_id is None or int(transfer.to_user_id) != int(user_id):
|
||||
raise ValueError("只有该转交的接收人本人可以拒绝")
|
||||
|
||||
transfer.status = TRANSFER_STATUS_REJECTED
|
||||
# ★ 原因写独立列,不再拼进 remark:
|
||||
# 拼接会让前端拿到「3333\n[拒绝原因] 5555」这样一坨,
|
||||
# 分不清哪句是发起备注、哪句是拒收原因;而且用户自己在备注里
|
||||
# 打出同样字样时,任何按标记切分的解析都会误判。
|
||||
transfer.reject_reason = (reason or '').strip() or None
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
return transfer
|
||||
|
||||
@staticmethod
|
||||
def count_pending_transfers(user_id):
|
||||
"""
|
||||
「待我接收」的转交数量 —— 供全局待办提醒在初始化与轮询时调用。
|
||||
|
||||
★ 刻意做成极轻量:一次 count,不联表、不解析物料名。
|
||||
轮询接口必须便宜,否则会从「提醒」变成「后台噪音」。
|
||||
"""
|
||||
if user_id is None:
|
||||
return 0
|
||||
try:
|
||||
return TransBorrowTransfer.query.filter(
|
||||
TransBorrowTransfer.to_user_id == int(user_id),
|
||||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||||
).count()
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_unseen_rejects(user_id, limit=20):
|
||||
"""
|
||||
「我发起、被对方拒绝、且尚未告知我」的转交 —— 供全局提醒使用。
|
||||
|
||||
为什么必须告知发起方
|
||||
-------------------
|
||||
双向握手补上了「接收人确认」,但只做了单向告知:接收人能看到待办,
|
||||
发起方却对结果一无所知。**被拒绝时物品责任仍在发起方手上** ——
|
||||
他若不主动查列表,就会误以为已经交接出去,责任链出现静默断点。
|
||||
(ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。)
|
||||
|
||||
★ 为什么用持久标记而不是前端去重:
|
||||
换台电脑、换个浏览器就会重新提醒;而这条信息的分量(责任归属)
|
||||
值得一个持久标记。前端确认后调 ack_rejects 写 reject_seen_at。
|
||||
|
||||
★ 同时解析出物料名:只说「某笔转交被拒」发起方仍不知是哪件东西还在
|
||||
自己手上,必须让他一眼认出来。批量查一次,不做 N+1。
|
||||
"""
|
||||
if user_id is None:
|
||||
return []
|
||||
try:
|
||||
uid = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
rows = (TransBorrowTransfer.query
|
||||
.filter(TransBorrowTransfer.from_user_id == uid,
|
||||
TransBorrowTransfer.status == TRANSFER_STATUS_REJECTED,
|
||||
TransBorrowTransfer.reject_seen_at.is_(None))
|
||||
.order_by(TransBorrowTransfer.id.asc())
|
||||
.limit(limit)
|
||||
.all())
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# 批量解析物料名(含 SKU 兜底),与列表页同口径
|
||||
records = {r.id: r for r in TransBorrow.query.filter(
|
||||
TransBorrow.id.in_({t.borrow_id for t in rows if t.borrow_id})
|
||||
).all()}
|
||||
stock_ids_by_table = {}
|
||||
for r in records.values():
|
||||
if r.source_table and r.stock_id:
|
||||
stock_ids_by_table.setdefault(r.source_table, set()).add(r.stock_id)
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
name_map = {}
|
||||
for table_name, ids in stock_ids_by_table.items():
|
||||
ModelClass = model_map.get(table_name)
|
||||
if not ModelClass:
|
||||
continue
|
||||
for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter(
|
||||
ModelClass.id.in_(ids)).all():
|
||||
name_map[(table_name, stock.id)] = stock.base.name if stock.base else ''
|
||||
|
||||
out = []
|
||||
for t in rows:
|
||||
d = t.to_dict()
|
||||
rec = records.get(t.borrow_id)
|
||||
d['sku'] = rec.sku if rec else None
|
||||
d['material_name'] = (
|
||||
name_map.get((rec.source_table, rec.stock_id), '') if rec else ''
|
||||
) or (rec.sku if rec else '')
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def ack_rejects(user_id, ids=None):
|
||||
"""
|
||||
标记「被拒绝」提醒已告知 —— 由发起方在前端确认后调用。
|
||||
|
||||
ids 为空则标记该用户全部待告知的拒绝(前端一次确认通常就是全部)。
|
||||
返回本次标记的条数。不做「未告知就重复弹」以外的任何副作用。
|
||||
"""
|
||||
if user_id is None:
|
||||
return 0
|
||||
try:
|
||||
uid = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
q = TransBorrowTransfer.query.filter(
|
||||
TransBorrowTransfer.from_user_id == uid,
|
||||
TransBorrowTransfer.status == TRANSFER_STATUS_REJECTED,
|
||||
TransBorrowTransfer.reject_seen_at.is_(None),
|
||||
)
|
||||
if ids:
|
||||
q = q.filter(TransBorrowTransfer.id.in_(ids))
|
||||
|
||||
now = beijing_time()
|
||||
marked = 0
|
||||
for t in q.all():
|
||||
t.reject_seen_at = now
|
||||
marked += 1
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
return marked
|
||||
|
||||
@staticmethod
|
||||
def get_transfer_history(borrow_id):
|
||||
"""某条借出记录的转交历史(按时间正序,便于还原 A→B→C 链路)"""
|
||||
rows = TransBorrowTransfer.query.filter_by(borrow_id=borrow_id) \
|
||||
.order_by(asc(TransBorrowTransfer.transfer_time), asc(TransBorrowTransfer.id)).all()
|
||||
return [r.to_dict() for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_return_history(borrow_id):
|
||||
"""某条借出记录的归还历史(按时间正序,替代被覆盖的主表字段)"""
|
||||
rows = TransBorrowReturn.query.filter_by(borrow_id=borrow_id) \
|
||||
.order_by(asc(TransBorrowReturn.return_time), asc(TransBorrowReturn.id)).all()
|
||||
return [r.to_dict() for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def get_slip_history(borrow_no):
|
||||
"""
|
||||
整张借用单(borrow_no 维度)的完整生命周期事件流,按时间**倒序**返回。
|
||||
|
||||
事件来源
|
||||
--------
|
||||
borrow ← trans_borrow 自身(借出时间 / 借用人 / 发货操作人 / 数量)
|
||||
transfer ← trans_borrow_transfer(转出人 → 接收人、经手库管、备注)
|
||||
return ← trans_borrow_return(实际归还人、经手库管、数量)
|
||||
scrap ← trans_borrow.status == 'scrapped'(报废是终态,没有独立流水表,
|
||||
由主表的终态字段 + return_time/return_operator 还原)
|
||||
|
||||
★ 为什么整单聚合,而不是让前端逐条明细调用单品接口:
|
||||
借用记录列表是 borrow_no 主子表结构,**实测单张单最多 21 条明细**,
|
||||
逐条调用会产生 21 个请求,且各条时间线无法全局排序。此处一次合并。
|
||||
|
||||
★ 公司隔离:任一条明细不可见即整单拒绝(Fail-Closed),与转交同口径。
|
||||
|
||||
异常:ValueError(单据不存在 / 越权 / 隔离链路断裂)
|
||||
"""
|
||||
from app.models.system import SysUser
|
||||
|
||||
records = TransBorrow.query.filter_by(borrow_no=borrow_no).all()
|
||||
if not records:
|
||||
raise ValueError("借用单不存在")
|
||||
|
||||
# 任一条明细越权 → 整单拒绝(与转交同口径,避免两套可见性标准分叉)
|
||||
for r in records:
|
||||
_assert_borrow_company_visible(r)
|
||||
|
||||
record_ids = [r.id for r in records]
|
||||
by_id = {r.id: r for r in records}
|
||||
|
||||
# --- 批量取流水,避免逐条查询 ---
|
||||
transfers = TransBorrowTransfer.query.filter(
|
||||
TransBorrowTransfer.borrow_id.in_(record_ids)
|
||||
).all()
|
||||
returns = TransBorrowReturn.query.filter(
|
||||
TransBorrowReturn.borrow_id.in_(record_ids)
|
||||
).all()
|
||||
|
||||
# --- 批量解析物料名(与 get_records 同口径,含 SKU 兜底) ---
|
||||
material_map = {}
|
||||
stock_ids_by_table = {}
|
||||
for r in records:
|
||||
if r.source_table and r.stock_id:
|
||||
stock_ids_by_table.setdefault(r.source_table, set()).add(r.stock_id)
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
for table_name, ids in stock_ids_by_table.items():
|
||||
ModelClass = model_map.get(table_name)
|
||||
if not ModelClass:
|
||||
continue
|
||||
for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter(
|
||||
ModelClass.id.in_(ids)).all():
|
||||
material_map[(table_name, stock.id)] = stock.base.name if stock.base else ''
|
||||
|
||||
empty_sku = {r.sku for r in records
|
||||
if r.sku and not material_map.get((r.source_table, r.stock_id))}
|
||||
sku_name_map = {}
|
||||
if empty_sku:
|
||||
for ModelClass in (StockProduct, StockSemi, StockBuy):
|
||||
for stock in ModelClass.query.options(joinedload(ModelClass.base)).filter(
|
||||
ModelClass.sku.in_(empty_sku)).all():
|
||||
if stock.sku not in sku_name_map and stock.base:
|
||||
sku_name_map[stock.sku] = stock.base.name
|
||||
|
||||
# --- 批量解析归还人姓名(归还流水只存 returner_id,无姓名快照) ---
|
||||
returner_ids = {t.returner_id for t in returns if t.returner_id}
|
||||
user_name_map = {}
|
||||
if returner_ids:
|
||||
for u in SysUser.query.filter(SysUser.id.in_(returner_ids)).all():
|
||||
user_name_map[u.id] = user_display_name(u)
|
||||
|
||||
def _label(rec):
|
||||
name = material_map.get((rec.source_table, rec.stock_id)) or sku_name_map.get(rec.sku, '')
|
||||
return name or rec.sku or ''
|
||||
|
||||
def _sort_key(e):
|
||||
# ★ 必须按**真实 datetime** 排序,不能用展示用的 '%Y-%m-%d %H:%M:%S' 字符串:
|
||||
# 后者截断到秒,同一秒内发生的多个动作(库管连续操作的常见情形,
|
||||
# 如「转交 A→B 紧接着转交 B→C」)会退化成并列,排序结果取决于
|
||||
# 数据库返回顺序 —— 时间线会随机错乱。
|
||||
# datetime.min 兜底:无时间的脏数据排到最后。
|
||||
return (e['_dt'] or datetime.min, e['_seq'])
|
||||
|
||||
events = []
|
||||
for r in records:
|
||||
label = _label(r)
|
||||
qty = float(r.quantity or 0)
|
||||
# 借出事件
|
||||
events.append({
|
||||
'type': 'borrow',
|
||||
'time': r.borrow_time.strftime('%Y-%m-%d %H:%M:%S') if r.borrow_time else None,
|
||||
'_dt': r.borrow_time,
|
||||
'borrow_id': r.id,
|
||||
'sku': r.sku,
|
||||
'material_name': label,
|
||||
'quantity': qty,
|
||||
'actor_name': r.borrower_name, # 借用人
|
||||
'operator_name': r.dispatch_operator, # 发货库管
|
||||
'remark': r.remark,
|
||||
'_seq': 0,
|
||||
})
|
||||
# 报废事件(终态,无独立流水表)
|
||||
if r.status == 'scrapped':
|
||||
events.append({
|
||||
'type': 'scrap',
|
||||
'time': r.return_time.strftime('%Y-%m-%d %H:%M:%S') if r.return_time else None,
|
||||
'_dt': r.return_time,
|
||||
'borrow_id': r.id,
|
||||
'sku': r.sku,
|
||||
'material_name': label,
|
||||
'quantity': qty - float(r.returned_quantity or 0),
|
||||
'actor_name': r.borrower_name,
|
||||
'operator_name': r.return_operator,
|
||||
'remark': None,
|
||||
'_seq': 3,
|
||||
})
|
||||
|
||||
for t in transfers:
|
||||
rec = by_id.get(t.borrow_id)
|
||||
events.append({
|
||||
'type': 'transfer',
|
||||
'time': t.transfer_time.strftime('%Y-%m-%d %H:%M:%S') if t.transfer_time else None,
|
||||
'_dt': t.transfer_time,
|
||||
'borrow_id': t.borrow_id,
|
||||
'sku': rec.sku if rec else None,
|
||||
'material_name': _label(rec) if rec else '',
|
||||
'quantity': float(t.transfer_qty or 0),
|
||||
'actor_name': t.to_user_name, # 接收人(转交后的持有人)
|
||||
'from_name': t.from_user_name, # 转出人
|
||||
'operator_name': t.operator_name,
|
||||
'remark': t.remark,
|
||||
# ★ 转交状态:不带出来的话,被拒绝的转交在时间线上与成功的
|
||||
# 长得一模一样,发起方翻记录时会以为已经交接出去。
|
||||
'status': t.status,
|
||||
'status_text': t.to_dict().get('status_text'),
|
||||
# 拒收原因独立带出,前端才能与「转交备注」分行展示
|
||||
'reject_reason': t.reject_reason,
|
||||
'_seq': 1,
|
||||
})
|
||||
|
||||
for rt in returns:
|
||||
rec = by_id.get(rt.borrow_id)
|
||||
events.append({
|
||||
'type': 'return',
|
||||
'time': rt.return_time.strftime('%Y-%m-%d %H:%M:%S') if rt.return_time else None,
|
||||
'_dt': rt.return_time,
|
||||
'borrow_id': rt.borrow_id,
|
||||
'sku': rec.sku if rec else None,
|
||||
'material_name': _label(rec) if rec else '',
|
||||
'quantity': float(rt.return_qty or 0),
|
||||
'actor_name': user_name_map.get(rt.returner_id), # 实际归还人
|
||||
'operator_name': rt.operator_name,
|
||||
'remark': None,
|
||||
'_seq': 2,
|
||||
})
|
||||
|
||||
events.sort(key=_sort_key, reverse=True)
|
||||
for e in events:
|
||||
e.pop('_seq', None)
|
||||
e.pop('_dt', None)
|
||||
|
||||
return {
|
||||
'borrow_no': borrow_no,
|
||||
'events': events,
|
||||
'records': [r.to_dict() for r in records],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_borrow_history(borrow_id):
|
||||
"""
|
||||
一张借出单的完整流转视图:主表快照 + 转交链 + 逐次归还明细。
|
||||
|
||||
★ 含行级公司隔离(Fail-Closed),与转交同口径 ——
|
||||
否则「能看到哪条记录」与「能转交哪条记录」两套标准会分叉。
|
||||
异常:ValueError(记录不存在 / 越权 / 隔离链路断裂),由调用方转成 4xx。
|
||||
"""
|
||||
record = TransBorrow.query.get(borrow_id)
|
||||
if not record:
|
||||
raise ValueError("借出记录不存在")
|
||||
_assert_borrow_company_visible(record)
|
||||
return {
|
||||
'record': record.to_dict(),
|
||||
'transfers': TransService.get_transfer_history(borrow_id),
|
||||
'returns': TransService.get_return_history(borrow_id),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
|
||||
borrower_name=None, start_date=None, end_date=None,
|
||||
advanced_filters=None):
|
||||
advanced_filters=None, viewer_user_id=None, current_user_id=None):
|
||||
"""
|
||||
获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize)
|
||||
|
||||
@ -607,21 +1298,54 @@ class TransService:
|
||||
# ====================================================================
|
||||
borrow_no_q = db.session.query(order_subq.c.borrow_no)
|
||||
|
||||
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;
|
||||
# 兼容库里存成“姓名/xiaolongxia”全名(姓名 + '/' 前缀)的情况
|
||||
if borrower_name:
|
||||
# ====================================================================
|
||||
# ★ 数据权限:普通用户能看到「与自己有关」的借还记录,三种关系任一成立:
|
||||
# ① 我是借用人
|
||||
# ② 我是**当前持有人** —— 转交接收后东西在我手上,此前只比对
|
||||
# borrower_name,接收人在自己的列表里根本看不到该单
|
||||
# ③ 有一条**待我接收**的转交(PENDING)—— 东西还在对方手上、
|
||||
# 主表尚未转移,②匹配不到,必须单独并入,否则接收人看不到
|
||||
# 待办、无从确认
|
||||
# ID 与姓名双口径并存:新数据有 ID 锚点,历史行只有姓名。
|
||||
# ====================================================================
|
||||
if borrower_name or viewer_user_id:
|
||||
own_conds = []
|
||||
if borrower_name:
|
||||
# 兼容库里存成「姓名/xiaolongxia」全名的情况
|
||||
own_conds.append(TransBorrow.borrower_name == borrower_name)
|
||||
own_conds.append(TransBorrow.borrower_name.like(f"{borrower_name}/%"))
|
||||
own_conds.append(TransBorrow.current_holder_name == borrower_name)
|
||||
own_conds.append(TransBorrow.current_holder_name.like(f"{borrower_name}/%"))
|
||||
if viewer_user_id:
|
||||
own_conds.append(TransBorrow.borrower_id == viewer_user_id)
|
||||
own_conds.append(TransBorrow.current_holder_id == viewer_user_id)
|
||||
|
||||
own_borrow_nos_subq = (
|
||||
db.session.query(TransBorrow.borrow_no)
|
||||
.filter(or_(
|
||||
TransBorrow.borrower_name == borrower_name,
|
||||
TransBorrow.borrower_name.like(f"{borrower_name}/%")
|
||||
))
|
||||
.filter(or_(*own_conds))
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
borrow_no_q = borrow_no_q.filter(
|
||||
order_subq.c.borrow_no.in_(own_borrow_nos_subq)
|
||||
)
|
||||
|
||||
if viewer_user_id:
|
||||
pending_to_me_subq = (
|
||||
db.session.query(TransBorrowTransfer.borrow_no)
|
||||
.filter(
|
||||
TransBorrowTransfer.to_user_id == viewer_user_id,
|
||||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||||
TransBorrowTransfer.borrow_no.isnot(None),
|
||||
)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
borrow_no_q = borrow_no_q.filter(or_(
|
||||
order_subq.c.borrow_no.in_(own_borrow_nos_subq),
|
||||
order_subq.c.borrow_no.in_(pending_to_me_subq),
|
||||
))
|
||||
else:
|
||||
borrow_no_q = borrow_no_q.filter(
|
||||
order_subq.c.borrow_no.in_(own_borrow_nos_subq)
|
||||
)
|
||||
|
||||
# 关键词过滤
|
||||
if keyword_borrow_nos_subq is not None:
|
||||
@ -717,7 +1441,10 @@ class TransService:
|
||||
borrow_no_q = borrow_no_q.order_by(
|
||||
case((order_subq.c.has_finite == 0, 1), else_=0).asc(),
|
||||
nullslast(asc(order_subq.c.sort_key)),
|
||||
asc(order_subq.c.min_borrow_time)
|
||||
# ★ 无限期梯队内按借出时间**从近到远**(desc)。
|
||||
# 原实现是 asc「借出越久越靠前」,设计意图是暴露呆滞借用;
|
||||
# 业务方明确要求改为从近到远,故反转。
|
||||
desc(order_subq.c.min_borrow_time)
|
||||
)
|
||||
|
||||
# 分页(基准 = borrow_no 单号数)
|
||||
@ -812,6 +1539,61 @@ class TransService:
|
||||
item_dict['material_name'] = material_name
|
||||
items_with_names.append(item_dict)
|
||||
|
||||
# ====================================================================
|
||||
# ★ 恢复业务排序(此前被静默丢弃)
|
||||
#
|
||||
# detail_records 是按 `borrow_no ASC` 重新拉取的,而单号形如
|
||||
# BOR-YYYYMMDD-NNNN —— 它的**字母序恰好等于借出日期序**。
|
||||
# 于是上面步骤 2 辛苦算出的「逾期优先」分页顺序(page_borrow_nos)
|
||||
# 被这次重排**整套覆盖**:无限期单排到了最前,有限期里 10-01 排在
|
||||
# 11-01 之后,看起来完全随机。那份 ORDER BY 一直是死代码。
|
||||
#
|
||||
# 这里按 page_borrow_nos 的顺序还原输出。明细内部仍按 id 升序
|
||||
# (同一次发货写入的明细,id 序即扫码顺序,便于阅读)。
|
||||
# ====================================================================
|
||||
_order_idx = {bn: i for i, bn in enumerate(page_borrow_nos)}
|
||||
items_with_names.sort(
|
||||
key=lambda d: (_order_idx.get(d.get('borrow_no'), len(_order_idx)), d.get('id') or 0)
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# ★ 附加「待接收的转交」:前端据此渲染【接收转交】【拒绝】按钮与
|
||||
# 「转交待确认」状态。批量查一次,避免逐单 N+1。
|
||||
# 应用层保证同一单号最多一条 PENDING,故 borrow_no 可直接作键。
|
||||
# ====================================================================
|
||||
if items_with_names:
|
||||
# ★ 按**明细行**(borrow_id)关联,不是单号:转交粒度已下沉到明细,
|
||||
# 同一张单可能只有其中一件挂着待接收,其余仍是原持有人。
|
||||
_ids = [d.get('id') for d in items_with_names if d.get('id')]
|
||||
_pending = TransBorrowTransfer.query.filter(
|
||||
TransBorrowTransfer.borrow_id.in_(_ids),
|
||||
TransBorrowTransfer.status == TRANSFER_STATUS_PENDING,
|
||||
).all() if _ids else []
|
||||
_pending_map = {t.borrow_id: t.to_dict() for t in _pending}
|
||||
for d in items_with_names:
|
||||
_pt = _pending_map.get(d.get('id'))
|
||||
if _pt is not None:
|
||||
# ★ is_mine 由后端判定:前端 localStorage 里只有 username
|
||||
# 没有 user_id,靠姓名比对既有歧义又不可靠。
|
||||
# (viewer_user_id 对管理者为 None,故另取 current_user_id)
|
||||
_pt['is_mine'] = (
|
||||
current_user_id is not None
|
||||
and _pt.get('to_user_id') is not None
|
||||
and int(_pt['to_user_id']) == int(current_user_id)
|
||||
)
|
||||
d['pending_transfer'] = _pt
|
||||
# ★ 谁能发起转交:**只有该明细当前的持有人本人**。
|
||||
# 前端据此显示【转交】,后端 transfer_borrow 做同样的强校验 ——
|
||||
# 界面遮挡不是安全边界,两处必须同口径。
|
||||
# 同样由后端判定:前端 localStorage 里没有 user_id。
|
||||
d['can_transfer'] = (
|
||||
current_user_id is not None
|
||||
and d.get('current_holder_id') is not None
|
||||
and int(d['current_holder_id']) == int(current_user_id)
|
||||
)
|
||||
else:
|
||||
_pending_map = {}
|
||||
|
||||
return {
|
||||
'items': items_with_names,
|
||||
'total': total_orders,
|
||||
|
||||
@ -18,6 +18,9 @@ STOCK_FIELD_RBAC_MAPPING = {
|
||||
"generalImage": "material_list:files", "generalManual": "material_list:files",
|
||||
"productImageRemark": "material_list:productImageRemark",
|
||||
"manualLinkRemark": "material_list:manualLinkRemark",
|
||||
# 采购链接:读写**共用同一个码**(与 referencePrice 同模式)——
|
||||
# 从根上避免「写用了 A 码、读用了 B 码」导致的口径错位。
|
||||
"purchaseLink": "material_list:purchaseLink",
|
||||
"referencePrice": "material_list:referencePrice",
|
||||
"inventoryCount": "material_list:inventoryCount",
|
||||
"availableCount": "material_list:availableCount",
|
||||
|
||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
||||
<footer v-if="!isLoginPage" class="app-footer">
|
||||
<span class="version-tag">
|
||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||
当前版本:V3.81
|
||||
当前版本:V3.82
|
||||
</span>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -215,12 +215,18 @@ export function getBorrowStockList(params: { page?: number; pageSize?: number; k
|
||||
|
||||
/**
|
||||
* 执行借库扣减(审批通过后调用)
|
||||
* @param data approval_id + 扫码选中的物品 + 借用人信息 + 签名
|
||||
*
|
||||
* ★ borrower_id 为**必填**:借用人身份从「姓名」升级为「ID 锚点」后,
|
||||
* 后端以 borrower_id 反查 sys_user 生成姓名快照;缺失会被直接拒绝。
|
||||
* borrower_name 仅作兼容保留,后端不采信。
|
||||
*
|
||||
* @param data approval_id + 扫码选中的物品 + 借用人ID + 签名
|
||||
*/
|
||||
export function dispatchBorrow(data: {
|
||||
approval_id: number
|
||||
items: Array<any>
|
||||
borrower_name: string
|
||||
borrower_id: number
|
||||
borrower_name?: string
|
||||
signature_path: string
|
||||
remark?: string
|
||||
expected_return_time?: string | null
|
||||
@ -232,6 +238,121 @@ export function dispatchBorrow(data: {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 借库责任链人员名单(借用人 / 转交接收人 / 实际归还人共用)
|
||||
* @returns [{ id, name }]
|
||||
*/
|
||||
export function getBorrowUsers() {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/users',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 借库转交(一期:仅支持整单全量转交)
|
||||
*
|
||||
* ★ transfer_qty 必须严格等于待还量,部分转交会被后端拒绝 ——
|
||||
* 单行模型无法同时追踪两个持有人。
|
||||
* ★ 转交是纯持有权变更,不触碰任何库存字段。
|
||||
*
|
||||
* @param borrowId trans_borrow.id
|
||||
* @param data transfer_qty + to_user_id + remark
|
||||
*/
|
||||
export function transferBorrow(borrowId: number, data: {
|
||||
transfer_qty: number
|
||||
to_user_id: number
|
||||
to_user_name?: string
|
||||
remark?: string
|
||||
}) {
|
||||
return request({
|
||||
url: `/v1/transactions/borrow/${borrowId}/transfer`,
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 借出单的流转历史(**单品**维度):转交链 + 逐次归还明细
|
||||
* @returns { record, transfers[], returns[] }
|
||||
*/
|
||||
export function getBorrowHistory(borrowId: number) {
|
||||
return request({
|
||||
url: `/v1/transactions/borrow/${borrowId}/history`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认接收转交(双向握手第二步)—— 责任正式转移到接收人名下。
|
||||
*
|
||||
* ★ 仅该转交的 to_user_id 本人可调用(后端强校验,非库管权限)。
|
||||
* ★ 副作用:该单**全部未归还明细**的当前持有人一并变更。
|
||||
*/
|
||||
export function acceptBorrowTransfer(transferId: number) {
|
||||
return request({
|
||||
url: `/v1/transactions/borrow/transfer/${transferId}/accept`,
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝转交 —— 主表不动,责任仍在原持有人。权限同上。
|
||||
* @param reason 可选拒绝原因,写入转交流水备注
|
||||
*/
|
||||
export function rejectBorrowTransfer(transferId: number, reason?: string) {
|
||||
return request({
|
||||
url: `/v1/transactions/borrow/transfer/${transferId}/reject`,
|
||||
method: 'post',
|
||||
data: { reason }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局待办强提醒的轮询数据源(初始化 + 轮询)。
|
||||
*
|
||||
* ★ 一次请求返回两类信息,避免前端多打一个请求:
|
||||
* count —— 待**我接收**的转交数量
|
||||
* rejects —— **我发起、被对方拒绝、尚未告知我**的转交
|
||||
* (被拒时物品责任仍在我手上,不告知就会误以为已交接出去)
|
||||
*
|
||||
* @returns { count, rejects: [{ id, borrow_no, sku, material_name, to_user_name, remark, ... }] }
|
||||
*/
|
||||
export function getPendingTransferCount() {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/transfer/pending-count',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认已知悉「转交被拒绝」—— 写 reject_seen_at,之后不再提醒。
|
||||
* @param ids 留空表示该用户全部待告知的拒绝
|
||||
*/
|
||||
export function ackTransferRejects(ids?: number[]) {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/transfer/reject-ack',
|
||||
method: 'post',
|
||||
data: { ids }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 整单流转时间线(**borrow_no** 维度):借出 → 转交(可多次) → 归还 → 报废
|
||||
*
|
||||
* ★ 与 getBorrowHistory 的分工:列表页是 borrow_no 主子表结构(单张单最多
|
||||
* 21 条明细),逐条明细调用单品接口会产生 21 个请求,且各条时间线无法全局
|
||||
* 排序。本接口一次返回合并后的事件流,按时间**倒序**。
|
||||
*
|
||||
* @returns { borrow_no, events[], records[] }
|
||||
*/
|
||||
export function getBorrowSlipHistory(borrowNo: string) {
|
||||
return request({
|
||||
url: `/v1/transactions/borrow/slip/${encodeURIComponent(borrowNo)}/history`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。
|
||||
*
|
||||
|
||||
194
inventory-web/src/components/PendingTransferNotifier/index.vue
Normal file
194
inventory-web/src/components/PendingTransferNotifier/index.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<!-- 无渲染组件:只负责全局待办强提醒,不产生任何 DOM -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 借库转交 · 全局强提醒(双向)
|
||||
*
|
||||
* 两类提醒,一次轮询同时取回(GET .../transfer/pending-count):
|
||||
*
|
||||
* ① 转交被拒绝 —— **我发起**、对方拒收。物品责任仍在我手上,
|
||||
* 不告知就会误以为已经交接出去,责任链出现静默断点。
|
||||
* 用 reject_seen_at 做持久标记,确认后不再提醒。
|
||||
* ② 待我接收 —— **别人转给我**、等我确认。用 sessionStorage 按数量去重。
|
||||
*
|
||||
* ★ 为什么用 ElMessageBox 而不是 ElNotification:
|
||||
* Notification 缩在右上角,在仓库作业现场极易被忽略。改为**中央 Modal +
|
||||
* 遮罩**,强制打断注意力,符合蓝领作业现场的触达要求。
|
||||
*
|
||||
* ★ 为什么挂在 Layout 而非登录页:
|
||||
* Layout 只在已登录区域渲染,天然保证「有 token 才查」;
|
||||
* 且它是路由切换时**常驻**的组件(AppMain 换页不会卸载它),
|
||||
* 轮询定时器不会被反复创建销毁。
|
||||
*
|
||||
* ★ 防叠加(模块级标志 + DOM 探测):见 isReminderOnScreen。
|
||||
*/
|
||||
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { getPendingTransferCount, ackTransferRejects } from '@/api/transaction'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const SS_KEY = 'pendingTransferNotifiedCount'
|
||||
const REMINDER_TITLES = ['待办通知:借库转交', '转交被拒绝']
|
||||
// 轮询间隔:需求只要求「初始化时查一次」,但那样已打开的页面永远收不到提醒
|
||||
// (SPA 只在首次进入时初始化)。加一轮低频轮询才能真正做到「第一时间响应」;
|
||||
// 有去重逻辑兜底,轮询不会造成重复打扰。不需要的话删掉定时器即可。
|
||||
const POLL_MS = 2 * 60 * 1000
|
||||
|
||||
// ★ 模块级而非组件级:即便组件被重复挂载(HMR、多 Layout 实例),
|
||||
// 也能保证同一时刻只有一个提醒弹窗。
|
||||
let reminderOpen = false
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 按标题探测屏幕上是否已有本组件的弹窗(覆盖模块标志失效的极端情况,
|
||||
// 例如 HMR 后旧实例残留的弹窗)
|
||||
const isReminderOnScreen = (): boolean => {
|
||||
if (reminderOpen) return true
|
||||
try {
|
||||
return Array.from(document.querySelectorAll('.el-message-box__title'))
|
||||
.some(el => REMINDER_TITLES.some(t => (el.textContent || '').includes(t)))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出「转交被拒绝」提醒。
|
||||
* @returns 是否真的弹出了
|
||||
*/
|
||||
const showRejectNotice = (rejects: any[]): boolean => {
|
||||
if (isReminderOnScreen()) return false
|
||||
reminderOpen = true
|
||||
|
||||
const shown = rejects.slice(0, 5)
|
||||
const lines = shown.map(r =>
|
||||
`· ${r.material_name || r.sku || '物品'}(${r.borrow_no})—— 接收人:${r.to_user_name || '—'}`
|
||||
)
|
||||
const more = rejects.length > shown.length ? `\n…另有 ${rejects.length - shown.length} 笔` : ''
|
||||
|
||||
ElMessageBox.confirm(
|
||||
`以下转交已被对方拒绝,物品仍在您名下,请另行安排:\n\n${lines.join('\n')}${more}`,
|
||||
'转交被拒绝',
|
||||
{
|
||||
confirmButtonText: '去处理',
|
||||
cancelButtonText: '知道了',
|
||||
type: 'warning',
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: true,
|
||||
showClose: true,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
router.push('/operation/records')
|
||||
})
|
||||
.catch(() => {
|
||||
// 【知道了】/ 关闭:仅收起弹窗
|
||||
})
|
||||
.finally(async () => {
|
||||
reminderOpen = false
|
||||
// ★ 两条路径都算「已知悉」:否则每次登录都会再弹同一条。
|
||||
// 若 ack 失败,下一轮轮询还会再提醒 —— 宁可多提醒一次,也不能漏。
|
||||
try {
|
||||
await ackTransferRejects(rejects.map((r: any) => r.id))
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹出「待我接收」提醒。
|
||||
* @returns 是否真的弹出了 —— 调用方据此决定要不要记 sessionStorage,
|
||||
* 避免「记了却没弹」把提醒永久吞掉。
|
||||
*/
|
||||
const showPendingReminder = (count: number): boolean => {
|
||||
if (isReminderOnScreen()) return false
|
||||
reminderOpen = true
|
||||
|
||||
ElMessageBox.confirm(
|
||||
`您有 ${count} 件物品等待接收确认,请及时处理。`,
|
||||
'待办通知:借库转交',
|
||||
{
|
||||
confirmButtonText: '去处理',
|
||||
cancelButtonText: '稍后处理',
|
||||
type: 'warning',
|
||||
// 强提醒:点遮罩不关(避免误触即消失),但保留右上角关闭按钮与 Esc,
|
||||
// 用户始终有明确的退出路径。
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: true,
|
||||
showClose: true,
|
||||
distinguishCancelAndClose: false,
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
router.push('/operation/records') // 【去处理】
|
||||
})
|
||||
.catch(() => {
|
||||
// 【稍后处理】/ 关闭:仅收起弹窗,不阻断用户当前工作
|
||||
})
|
||||
.finally(() => {
|
||||
reminderOpen = false
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const check = async () => {
|
||||
if (!userStore.token) return
|
||||
let count = 0
|
||||
let rejects: any[] = []
|
||||
try {
|
||||
const res: any = await getPendingTransferCount()
|
||||
count = Number(res?.count ?? res?.data?.count ?? 0)
|
||||
rejects = Array.isArray(res?.rejects)
|
||||
? res.rejects
|
||||
: (Array.isArray(res?.data?.rejects) ? res.data.rejects : [])
|
||||
} catch {
|
||||
return // 静默:不打扰用户,也不刷屏报错
|
||||
}
|
||||
|
||||
// ★ 拒绝提醒优先于待接收提醒:前者是「责任已回到你手上」的状态变更,
|
||||
// 后者是「等你确认」的待办;而且一次只弹一个弹窗,避免叠加打扰。
|
||||
// 本次弹了拒绝提醒就直接返回,待接收的那条留给下一轮(此时拒绝已 ack)。
|
||||
if (rejects.length && showRejectNotice(rejects)) return
|
||||
|
||||
if (!count) {
|
||||
// 已处理完:清掉记录,下次再来新转交能重新提醒
|
||||
sessionStorage.removeItem(SS_KEY)
|
||||
return
|
||||
}
|
||||
|
||||
const notified = Number(sessionStorage.getItem(SS_KEY) || 0)
|
||||
if (count === notified) return // 数量没变 → 本会话已提醒过,不再轰炸
|
||||
|
||||
// ★ 先弹,弹成功了才记账。反过来会把提醒永久吞掉。
|
||||
if (showPendingReminder(count)) {
|
||||
sessionStorage.setItem(SS_KEY, String(count))
|
||||
}
|
||||
// 没弹出(已有弹窗在屏)时不记账:留给下一轮轮询重试
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check()
|
||||
timer = setInterval(check, POLL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
// 组件卸载时若弹窗还开着,一并收掉,避免遮罩残留挡住界面
|
||||
if (reminderOpen) {
|
||||
try { ElMessageBox.close() } catch { /* 忽略 */ }
|
||||
reminderOpen = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -8,6 +8,10 @@
|
||||
|
||||
<!-- 全局规格连号助手 -->
|
||||
<SpecHelper />
|
||||
|
||||
<!-- 全局待办强提醒:借库转交的接收人若不来查看,责任会一直悬空。
|
||||
Layout 常驻(路由切换不卸载),轮询定时器放这里最稳。 -->
|
||||
<PendingTransferNotifier />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -15,6 +19,7 @@
|
||||
import Sidebar from './components/Sidebar/index.vue'
|
||||
import AppMain from './components/AppMain.vue'
|
||||
import SpecHelper from '@/components/SpecHelper/index.vue'
|
||||
import PendingTransferNotifier from '@/components/PendingTransferNotifier/index.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -34,3 +34,37 @@ export function normalizeQty(value: any, fallback = 0): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? Number(n.toFixed(4)) : fallback
|
||||
}
|
||||
|
||||
// 中日韩统一表意文字。用于判断一个字符是否占一个「汉字宽」。
|
||||
const CJK_CHAR = /[㐀-䶿一-鿿豈-]/
|
||||
|
||||
/**
|
||||
* 姓名等宽显示格式化 —— 两个字的中文姓名在中间补一个全角空格,
|
||||
* 视觉上撑成三个字宽,与三字姓名纵向对齐。
|
||||
*
|
||||
* '高雪' -> '高 雪' (中间是 U+3000,占一个汉字宽)
|
||||
* '郭俊玥' -> '郭俊玥'
|
||||
* '欧阳娜娜' -> '欧阳娜娜' (4 字不动)
|
||||
*
|
||||
* ★ 纯展示层函数:只在模板渲染时调用,**绝不写回数据**。
|
||||
* 姓名是转交/归还责任链的匹配键(见 records.vue 的 currentHolderList
|
||||
* 与 return.vue 的 isTransferred),往数据里插不可见字符会让比对、检索、
|
||||
* 导出、复制全部带上它。本函数返回的是「显示用副本」,
|
||||
* row.borrower_name 始终是原值。
|
||||
*
|
||||
* ★ 为什么用 U+3000 而不是普通空格:
|
||||
* 普通空格宽度随字体变化(通常约半个汉字),补进去反而更难对齐;
|
||||
* U+3000 是东亚字体的全角空格,稳定占一个汉字宽 —— 与「1 个汉字 ≈ 1em」
|
||||
* 的等宽前提一致。
|
||||
*
|
||||
* ★ 为什么限定两个字符都是汉字:
|
||||
* 这条规则的目的是「与三字中文姓名对齐」。若姓名为两个拉丁字符(如 'AB'),
|
||||
* 补一个全角空格并不会让它获得汉字宽度,只会渲染出 'A B' 这样的怪相。
|
||||
* 故非汉字组合一律原样返回。
|
||||
*/
|
||||
export function formatName(name: any): string {
|
||||
if (name === null || name === undefined) return ''
|
||||
const s = String(name)
|
||||
if (s.length !== 2) return s
|
||||
return CJK_CHAR.test(s[0]) && CJK_CHAR.test(s[1]) ? `${s[0]} ${s[1]}` : s
|
||||
}
|
||||
|
||||
@ -201,6 +201,7 @@
|
||||
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
|
||||
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
|
||||
<el-checkbox v-if="hasColPermission('referencePrice')" v-model="columns.referencePrice.visible" label="参考价格" />
|
||||
<el-checkbox v-if="hasColPermission('purchaseLink')" v-model="columns.purchaseLink.visible" label="采购链接" />
|
||||
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
|
||||
</div>
|
||||
</el-popover>
|
||||
@ -241,15 +242,15 @@
|
||||
style="width: 100%; border-top: none;"
|
||||
>
|
||||
<el-table-column v-if="isBatchMode" type="selection" width="55" :reserve-selection="true" />
|
||||
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
|
||||
<el-table-column v-if="columns.id.visible && hasColPermission('id')" prop="id" label="ID" min-width="80" align="center" fixed="left" />
|
||||
|
||||
<el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.companyName.visible && hasColPermission('companyName')" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.companyName || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.name.visible" label="名称" min-width="160" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.name.visible && hasColPermission('name')" label="名称" min-width="160" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="userStore.hasPermission('material_list:operation')" class="clickable-text" @click="handleEdit(scope.row)">
|
||||
{{ scope.row.name }}
|
||||
@ -258,35 +259,35 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="专业名称" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.commonName.visible && hasColPermission('commonName')" prop="commonName" label="专业名称" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.category.visible && hasColPermission('category')" prop="category" label="类别" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">{{ scope.row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.type.visible && hasColPermission('type')" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">{{ scope.row.type || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip sortable="custom" />
|
||||
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" sortable="custom" />
|
||||
<el-table-column v-if="columns.spec.visible && hasColPermission('spec')" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip sortable="custom" />
|
||||
<el-table-column v-if="columns.unit.visible && hasColPermission('unit')" prop="unit" label="单位" min-width="80" align="center" sortable="custom" />
|
||||
|
||||
<el-table-column v-if="columns.inventory.visible" prop="inventoryCount" label="库存数" min-width="100" align="center" sortable="custom">
|
||||
<el-table-column v-if="columns.inventory.visible && hasColPermission('inventory')" prop="inventoryCount" label="库存数" min-width="100" align="center" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.inventoryCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.available.visible" prop="availableCount" label="可用数" min-width="100" align="center" sortable="custom">
|
||||
<el-table-column v-if="columns.available.visible && hasColPermission('available')" prop="availableCount" label="可用数" min-width="100" align="center" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : 'inherit' }">{{ row.availableCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.files.visible" label="资料" min-width="140" align="center">
|
||||
<el-table-column v-if="columns.files.visible && hasColPermission('files')" label="资料" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
||||
<div v-if="getImagesOnly(row.generalImage).length > 0" class="file-preview-cell">
|
||||
@ -330,7 +331,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
|
||||
<el-table-column v-if="columns.isEnabled.visible && hasColPermission('isEnabled')" prop="isEnabled" label="是否启用" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.isEnabled"
|
||||
@ -342,20 +343,34 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.isInspectionRequired.visible" prop="isInspectionRequired" label="强制质检" min-width="100" align="center">
|
||||
<el-table-column v-if="columns.isInspectionRequired.visible && hasColPermission('isInspectionRequired')" prop="isInspectionRequired" label="强制质检" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isInspectionRequired ? 'danger' : 'info'" size="small">
|
||||
{{ scope.row.isInspectionRequired ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.referencePrice.visible" prop="referencePrice" label="参考价格" min-width="120" align="center" sortable="custom">
|
||||
<el-table-column v-if="columns.referencePrice.visible && hasColPermission('referencePrice')" prop="referencePrice" label="参考价格" min-width="120" align="center" sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.referencePrice != null" class="money-text">{{ scope.row.referencePrice?.toFixed(2) }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible" label="预警状态" width="120" align="center">
|
||||
<!-- 采购链接:href 必须先过 safeHref 白名单,防止 javascript: 伪协议 -->
|
||||
<el-table-column v-if="columns.purchaseLink.visible && hasColPermission('purchaseLink')" label="采购链接" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="safeHref(row.purchaseLink)"
|
||||
type="primary"
|
||||
:href="safeHref(row.purchaseLink)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>打开链接</el-link>
|
||||
<span v-else-if="row.purchaseLink" style="color: #909399;" :title="row.purchaseLink">格式无效</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.warningStatus === 2">
|
||||
<el-tag type="danger" size="small">红色预警</el-tag>
|
||||
@ -522,6 +537,26 @@
|
||||
<el-input-number v-model="form.referencePrice" :precision="2" :min="0" controls-position="right" style="width: 100%" placeholder="请输入参考价格" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<!-- 采购链接:读写共用 material_list:purchaseLink 一个码 -->
|
||||
<el-form-item label="采购链接" prop="purchaseLink" v-if="hasFieldPermission('purchaseLink')">
|
||||
<!-- ★ 可编辑 + 可直达(与 list.vue 同款交互):
|
||||
不用复制网址另开标签页;链接非法时按钮置灰并提示。 -->
|
||||
<el-input
|
||||
v-model="form.purchaseLink"
|
||||
placeholder="补货用的购买地址,如淘宝/1688 商品链接"
|
||||
clearable
|
||||
>
|
||||
<template #append>
|
||||
<el-button
|
||||
:disabled="!safeHref(form.purchaseLink)"
|
||||
:title="safeHref(form.purchaseLink) ? '在新标签页打开' : '请填写以 http:// 或 https:// 开头的链接'"
|
||||
@click="openLink(form.purchaseLink)"
|
||||
>打开</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
|
||||
@ -546,7 +581,8 @@
|
||||
v-model="form.productImageRemark"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
placeholder="请输入产品图备注信息"
|
||||
:disabled="!canEditRemark"
|
||||
:placeholder="canEditRemark ? '请输入产品图备注信息' : '无编辑权限,仅可查看'"
|
||||
style="margin-top: 8px;"
|
||||
clearable
|
||||
/>
|
||||
@ -600,7 +636,8 @@
|
||||
v-model="form.manualLinkRemark"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
placeholder="请输入说明书备注信息"
|
||||
:disabled="!canEditRemark"
|
||||
:placeholder="canEditRemark ? '请输入说明书备注信息' : '无编辑权限,仅可查看'"
|
||||
style="margin-top: 8px;"
|
||||
clearable
|
||||
/>
|
||||
@ -742,6 +779,16 @@ import { imageSearch as imageSearchApi, type ImageSearchItem } from '@/api/commo
|
||||
const userStore = useUserStore();
|
||||
const isSuperAdmin = computed(() => userStore.role === 'SUPER_ADMIN');
|
||||
|
||||
// ★ 附件备注(产品图备注 / 说明书备注)的**写**权限,与读权限分离。
|
||||
// 与 list.vue、后端 base.py 三处同口径:
|
||||
// 读 → material_list:files (区块整体可见,6 个角色)
|
||||
// 写 → material_list:remark_edit (3 个核心管理角色)
|
||||
// 无写权限时输入框置灰但仍可见 —— 读权限决定可见性。
|
||||
const canEditRemark = computed(() => {
|
||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
|
||||
return userStore.hasPermission('material_list:remark_edit');
|
||||
});
|
||||
|
||||
// --- 类型定义 ---
|
||||
interface MaterialBaseVO {
|
||||
id: number;
|
||||
@ -992,7 +1039,8 @@ const columns = reactive({
|
||||
commonName: { visible: true }, category: { visible: true }, type: { visible: true },
|
||||
spec: { visible: true }, unit: { visible: true }, inventory: { visible: true },
|
||||
available: { visible: true }, files: { visible: true }, isEnabled: { visible: true },
|
||||
isInspectionRequired: { visible: true }, referencePrice: { visible: true }, warningStatus: { visible: true }
|
||||
isInspectionRequired: { visible: true }, referencePrice: { visible: true }, warningStatus: { visible: true },
|
||||
purchaseLink: { visible: true }
|
||||
});
|
||||
|
||||
const permissionMap: Record<string, string> = {
|
||||
@ -1000,7 +1048,10 @@ const permissionMap: Record<string, string> = {
|
||||
commonName: 'material_list:commonName', category: 'material_list:category', type: 'material_list:type',
|
||||
spec: 'material_list:spec', unit: 'material_list:unit', inventory: 'material_list:inventoryCount',
|
||||
available: 'material_list:availableCount', files: 'material_list:files', isEnabled: 'material_list:isEnabled',
|
||||
isInspectionRequired: 'material_list:operation', referencePrice: 'material_list:referencePrice',
|
||||
// ★ 与后端读过滤(field_permissions.py)对齐;原先写 material_list:operation
|
||||
// 会与后端口径不一致,渲染出一列全是「-」的空表头
|
||||
isInspectionRequired: 'material_list:isInspectionRequired', referencePrice: 'material_list:referencePrice',
|
||||
purchaseLink: 'material_list:purchaseLink',
|
||||
warningStatus: 'material_list:view_warning'
|
||||
};
|
||||
|
||||
@ -1052,6 +1103,23 @@ const initColumnPermissions = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 采购链接的可点击地址白名单(与 list.vue 同名同实现)。
|
||||
* ★ 该字段由用户自由填写,直接绑 href 时 `javascript:` 伪协议会被当成
|
||||
* 可执行链接 —— 只放行 http/https。
|
||||
*/
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim();
|
||||
return /^https?:\/\//i.test(s) ? s : null;
|
||||
};
|
||||
|
||||
// 编辑弹窗里的「打开」按钮(与 list.vue 同款)。
|
||||
const openLink = (url: any) => {
|
||||
const href = safeHref(url);
|
||||
if (!href) return ElMessage.warning('请填写以 http:// 或 https:// 开头的链接');
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const hasFieldPermission = (field: string) => {
|
||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
|
||||
const code = permissionMap[field];
|
||||
@ -1093,7 +1161,7 @@ const initForm = {
|
||||
id: undefined, companyName: '', name: '', commonName: '', category: '', type: '', spec: '', unit: '',
|
||||
visibilityLevel: 0, generalManual: [] as string[], generalImage: [] as string[], isEnabled: true,
|
||||
referencePrice: undefined as number | undefined,
|
||||
productImageRemark: '', manualLinkRemark: ''
|
||||
productImageRemark: '', manualLinkRemark: '', purchaseLink: ''
|
||||
};
|
||||
const form = ref({...initForm});
|
||||
|
||||
@ -1169,13 +1237,22 @@ const fetchOdooSummary = async () => {
|
||||
const res: any = await getOdooSummary(params);
|
||||
if (res?.code === 200) {
|
||||
groupSummary.value = res.data ?? [];
|
||||
// 搜索条件变更 → 清除旧缓存,折叠所有分组
|
||||
const keywordChanged = queryParams.keyword !== lastKeyword.value;
|
||||
// 缓存一律作废:分组摘要变了,明细可能已过期
|
||||
groupCache.value = new Map();
|
||||
groupLoadingMap.value = new Map();
|
||||
if (queryParams.keyword !== lastKeyword.value) {
|
||||
if (keywordChanged) {
|
||||
// 搜索条件变了 → 折叠全部分组,等用户自己展开
|
||||
activeCategories.value = [];
|
||||
lastKeyword.value = queryParams.keyword;
|
||||
return;
|
||||
}
|
||||
// ★ 搜索条件没变(典型场景:保存编辑后刷新):
|
||||
// 必须把**仍处于展开状态**的分组重新拉一遍。
|
||||
// 原先只清缓存不重拉,而 loadGroupItems 只由展开事件触发、不会重跑,
|
||||
// 于是面板保持「展开」却没有任何数据 —— 看起来像被清空了,
|
||||
// 用户只能重新输入搜索条件才恢复。
|
||||
activeCategories.value.forEach(cat => loadGroupItems(cat));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取 Odoo 摘要失败', err);
|
||||
@ -1336,7 +1413,7 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
|
||||
|
||||
const buildPartialPayload = (current: any, original: any): any => {
|
||||
const payload: any = { id: current.id };
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark'];
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark', 'purchaseLink'];
|
||||
for (const key of compareFields) {
|
||||
const currentVal = current[key]; const originalVal = original[key];
|
||||
if (Array.isArray(currentVal) && Array.isArray(originalVal)) { if (!isArraysEqual(currentVal, originalVal)) payload[key] = currentVal; }
|
||||
|
||||
@ -198,6 +198,7 @@
|
||||
<el-checkbox v-if="hasColPermission('isEnabled')" v-model="columns.isEnabled.visible" label="状态" />
|
||||
<el-checkbox v-if="hasColPermission('isInspectionRequired')" v-model="columns.isInspectionRequired.visible" label="强制质检" />
|
||||
<el-checkbox v-if="hasColPermission('referencePrice')" v-model="columns.referencePrice.visible" label="参考价格" />
|
||||
<el-checkbox v-if="hasColPermission('purchaseLink')" v-model="columns.purchaseLink.visible" label="采购链接" />
|
||||
<el-checkbox v-if="hasColPermission('warningStatus')" v-model="columns.warningStatus.visible" label="预警状态" />
|
||||
</div>
|
||||
</el-popover>
|
||||
@ -218,15 +219,15 @@
|
||||
style="width: 100%; margin-top: 15px"
|
||||
>
|
||||
<el-table-column v-if="isBatchMode" type="selection" width="55" :reserve-selection="true" />
|
||||
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
|
||||
<el-table-column v-if="columns.id.visible && hasColPermission('id')" prop="id" label="ID" min-width="80" align="center" fixed="left" />
|
||||
|
||||
<el-table-column v-if="columns.companyName.visible" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.companyName.visible && hasColPermission('companyName')" prop="companyName" label="所属公司" min-width="100" align="center" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.companyName || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.name.visible" label="名称" min-width="160" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.name.visible && hasColPermission('name')" label="名称" min-width="160" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="userStore.hasPermission('material_list:operation')" class="clickable-text" @click="handleEdit(scope.row)">
|
||||
{{ scope.row.name }}
|
||||
@ -235,35 +236,35 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="专业名称" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.commonName.visible && hasColPermission('commonName')" prop="commonName" label="专业名称" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.category.visible && hasColPermission('category')" prop="category" label="类别" min-width="140" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">{{ scope.row.category || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip sortable="custom">
|
||||
<el-table-column v-if="columns.type.visible && hasColPermission('type')" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip sortable="custom">
|
||||
<template #default="scope">{{ scope.row.type || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip sortable="custom" />
|
||||
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" sortable="custom" />
|
||||
<el-table-column v-if="columns.spec.visible && hasColPermission('spec')" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip sortable="custom" />
|
||||
<el-table-column v-if="columns.unit.visible && hasColPermission('unit')" prop="unit" label="单位" min-width="80" align="center" sortable="custom" />
|
||||
|
||||
<el-table-column v-if="columns.inventory.visible" prop="inventoryCount" label="库存数" min-width="100" align="center" sortable="custom">
|
||||
<el-table-column v-if="columns.inventory.visible && hasColPermission('inventory')" prop="inventoryCount" label="库存数" min-width="100" align="center" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.inventoryCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.available.visible" prop="availableCount" label="可用数" min-width="100" align="center" sortable="custom">
|
||||
<el-table-column v-if="columns.available.visible && hasColPermission('available')" prop="availableCount" label="可用数" min-width="100" align="center" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ fontWeight: 'bold', color: row.availableCount > 0 ? '#409EFF' : 'inherit' }">{{ row.availableCount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.files.visible" label="资料" min-width="140" align="center">
|
||||
<el-table-column v-if="columns.files.visible && hasColPermission('files')" label="资料" min-width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<div style="display: flex; gap: 8px; justify-content: center;">
|
||||
<div v-if="getImagesOnly(row.generalImage).length > 0" class="file-preview-cell">
|
||||
@ -309,7 +310,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
|
||||
<el-table-column v-if="columns.isEnabled.visible && hasColPermission('isEnabled')" prop="isEnabled" label="是否启用" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.isEnabled"
|
||||
@ -321,14 +322,14 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.isInspectionRequired.visible" prop="isInspectionRequired" label="强制质检" min-width="100" align="center">
|
||||
<el-table-column v-if="columns.isInspectionRequired.visible && hasColPermission('isInspectionRequired')" prop="isInspectionRequired" label="强制质检" min-width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isInspectionRequired ? 'danger' : 'info'" size="small">
|
||||
{{ scope.row.isInspectionRequired ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.isApprovalRequired.visible && userStore.hasPermission('material_list:isApprovalRequired')" label="出库/借库需审批" min-width="130" align="center">
|
||||
<el-table-column v-if="columns.isApprovalRequired.visible && hasColPermission('isApprovalRequired')" label="出库/借库需审批" min-width="130" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
:model-value="!!scope.row.isApprovalRequired"
|
||||
@ -337,13 +338,28 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.referencePrice.visible" prop="referencePrice" label="参考价格" min-width="120" align="center" sortable="custom">
|
||||
<el-table-column v-if="columns.referencePrice.visible && hasColPermission('referencePrice')" prop="referencePrice" label="参考价格" min-width="120" align="center" sortable="custom">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.referencePrice != null" class="money-text">{{ scope.row.referencePrice?.toFixed(2) }}</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible" label="预警状态" width="120" align="center">
|
||||
<!-- 采购链接:地址由用户自由填写,href 必须先过 safeHref 白名单,
|
||||
否则 javascript: 之类的伪协议会被当成可点击链接执行 -->
|
||||
<el-table-column v-if="columns.purchaseLink.visible && hasColPermission('purchaseLink')" label="采购链接" min-width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="safeHref(row.purchaseLink)"
|
||||
type="primary"
|
||||
:href="safeHref(row.purchaseLink)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>打开链接</el-link>
|
||||
<span v-else-if="row.purchaseLink" style="color: #909399;" :title="row.purchaseLink">格式无效</span>
|
||||
<span v-else style="color: #ccc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="columns.warningStatus.visible && hasColPermission('warningStatus')" label="预警状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.warningStatus === 2">
|
||||
<el-tag type="danger" size="small">红色预警</el-tag>
|
||||
@ -529,6 +545,30 @@
|
||||
<el-input-number v-model="form.referencePrice" :precision="2" :min="0" controls-position="right" style="width: 100%" placeholder="请输入参考价格" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<!-- ★ 采购链接:读写共用 material_list:purchaseLink 一个码
|
||||
(与参考价格同模式),无权限者整项不显示 -->
|
||||
<el-form-item label="采购链接" prop="purchaseLink" v-if="hasFieldPermission('purchaseLink')">
|
||||
<!-- ★ 可编辑 + 可直达:不必把网址复制出来、另开标签页再粘贴。
|
||||
刻意**不采用**「双击解锁编辑」—— 双击是隐藏交互,用户发现不了,
|
||||
而且会与输入框内的「选中文字」冲突;一个常驻的追加按钮
|
||||
始终可见、始终可编辑,零学习成本。
|
||||
链接非法(非 http/https)时按钮置灰并给出提示,不做静默失败。 -->
|
||||
<el-input
|
||||
v-model="form.purchaseLink"
|
||||
placeholder="补货用的购买地址,如淘宝/1688 商品链接"
|
||||
clearable
|
||||
>
|
||||
<template #append>
|
||||
<el-button
|
||||
:disabled="!safeHref(form.purchaseLink)"
|
||||
:title="safeHref(form.purchaseLink) ? '在新标签页打开' : '请填写以 http:// 或 https:// 开头的链接'"
|
||||
@click="openLink(form.purchaseLink)"
|
||||
>打开</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="产品图" prop="generalImage" v-if="hasFieldPermission('files')">
|
||||
@ -549,11 +589,16 @@
|
||||
<el-icon><Camera /></el-icon><span class="text">拍照</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ★ 备注的**读**与**写**是两个权限:
|
||||
读 = material_list:files(本区块整体可见,6 个角色)
|
||||
写 = material_list:remark_edit(3 个核心管理角色)
|
||||
无写权限时置灰但仍可见 —— 读权限决定可见性,能看到既有内容。 -->
|
||||
<el-input
|
||||
v-model="form.productImageRemark"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
placeholder="请输入产品图备注信息"
|
||||
:disabled="!canEditRemark"
|
||||
:placeholder="canEditRemark ? '请输入产品图备注信息' : '无编辑权限,仅可查看'"
|
||||
style="margin-top: 8px;"
|
||||
clearable
|
||||
/>
|
||||
@ -607,7 +652,8 @@
|
||||
v-model="form.manualLinkRemark"
|
||||
type="textarea"
|
||||
:rows="1"
|
||||
placeholder="请输入说明书备注信息"
|
||||
:disabled="!canEditRemark"
|
||||
:placeholder="canEditRemark ? '请输入说明书备注信息' : '无编辑权限,仅可查看'"
|
||||
style="margin-top: 8px;"
|
||||
clearable
|
||||
/>
|
||||
@ -970,6 +1016,7 @@ const columns = reactive({
|
||||
isInspectionRequired: { visible: true },
|
||||
isApprovalRequired: { visible: true },
|
||||
referencePrice: { visible: true },
|
||||
purchaseLink: { visible: true },
|
||||
warningStatus: { visible: true }
|
||||
});
|
||||
|
||||
@ -987,9 +1034,14 @@ const permissionMap: Record<string, string> = {
|
||||
available: 'material_list:availableCount',
|
||||
files: 'material_list:files',
|
||||
isEnabled: 'material_list:isEnabled',
|
||||
isInspectionRequired: 'material_list:operation',
|
||||
isApprovalRequired: 'material_list:operation',
|
||||
// ★ 这两处原先都写成 material_list:operation,与后端**读过滤实际要求的码不一致**
|
||||
// (见 app/utils/field_permissions.py)。前端按 operation 判断会以为「有权限」,
|
||||
// 而后端其实把字段抹成了 null —— 于是渲染出一列全是「-」的空表头尸体。
|
||||
// 现与后端逐一对齐:
|
||||
isInspectionRequired: 'material_list:isInspectionRequired',
|
||||
isApprovalRequired: 'material_list:isApprovalRequired',
|
||||
referencePrice: 'material_list:referencePrice',
|
||||
purchaseLink: 'material_list:purchaseLink',
|
||||
warningStatus: 'material_list:view_warning'
|
||||
};
|
||||
|
||||
@ -1078,6 +1130,38 @@ const hasFieldPermission = (field: string) => {
|
||||
return userStore.hasPermission(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* 采购链接的可点击地址白名单。
|
||||
*
|
||||
* ★ 为什么必须过滤:该字段由用户自由填写,直接绑到 href 上时,
|
||||
* `javascript:alert(1)` 这类伪协议会被浏览器当成可执行链接 ——
|
||||
* 点一下就执行脚本。只放行 http/https,其余一律不渲染成链接。
|
||||
* (列表与表单两处共用,保证判定口径一致。)
|
||||
*/
|
||||
const safeHref = (url: any): string | null => {
|
||||
const s = String(url || '').trim();
|
||||
return /^https?:\/\//i.test(s) ? s : null;
|
||||
};
|
||||
|
||||
// 编辑弹窗里的「打开」按钮:在表单里直接跳转,不必复制网址另开标签页。
|
||||
// 走 safeHref 白名单 —— 非法值给出明确提示,而不是静默无反应。
|
||||
const openLink = (url: any) => {
|
||||
const href = safeHref(url);
|
||||
if (!href) return ElMessage.warning('请填写以 http:// 或 https:// 开头的链接');
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
// ★ 附件备注(产品图备注 / 说明书备注)的**写**权限,与读权限分离。
|
||||
// 两端必须与后端同口径:
|
||||
// 读 → material_list:files (本区块整体可见,6 个角色)
|
||||
// 写 → material_list:remark_edit (后端 base.py 的 field_to_perm 同码,3 个角色)
|
||||
// 后端对无写权限者会**丢弃**该字段(而非写成空值),故前端置灰后
|
||||
// 既不会误提交,也不会误清既有内容。
|
||||
const canEditRemark = computed(() => {
|
||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true;
|
||||
return userStore.hasPermission('material_list:remark_edit');
|
||||
});
|
||||
|
||||
// 表单全局禁用:没有 material_list:operation 权限时,所有表单字段不可编辑
|
||||
const formDisabled = computed(() => {
|
||||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return false;
|
||||
@ -1168,6 +1252,7 @@ const initForm = {
|
||||
referencePrice: undefined as number | undefined,
|
||||
productImageRemark: '',
|
||||
manualLinkRemark: '',
|
||||
purchaseLink: '',
|
||||
};
|
||||
|
||||
const form = ref({...initForm});
|
||||
@ -1488,7 +1573,7 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
|
||||
|
||||
const buildPartialPayload = (current: any, original: any): any => {
|
||||
const payload: any = { id: current.id };
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'referencePrice', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark'];
|
||||
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'referencePrice', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark', 'purchaseLink'];
|
||||
|
||||
for (const key of compareFields) {
|
||||
const currentVal = current[key];
|
||||
|
||||
@ -207,8 +207,25 @@
|
||||
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
|
||||
<el-row :gutter="15">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="领用人/借用人" prop="borrower_name">
|
||||
<el-input v-model="form.borrower_name" placeholder="请输入姓名" size="large" />
|
||||
<!-- ★ 借用人由「自由输入姓名」改为「选择用户」:
|
||||
姓名无法唯一锚定一个人(重名即责任链断裂),
|
||||
转交/归还的持有人校验都依赖 borrower_id 这个唯一锚点。 -->
|
||||
<el-form-item label="领用人/借用人" prop="borrower_id">
|
||||
<el-select
|
||||
v-model="form.borrower_id"
|
||||
filterable
|
||||
placeholder="请选择借用人"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
@change="handleBorrowerChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in borrowerOptions"
|
||||
:key="u.id"
|
||||
:label="u.name"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
@ -360,7 +377,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue'
|
||||
import QrScanner from '@/components/QrScanner/index.vue'
|
||||
import { getStockByBarcode, getStockAlternatives, getScanDraft, saveScanDraft, clearScanDraft } from '@/api/outbound'
|
||||
import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
|
||||
import { dispatchBorrow, getBorrowApprovalList, getBorrowUsers } from '@/api/transaction'
|
||||
import { uploadFile } from '@/api/common/upload'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@ -515,6 +532,37 @@ const handleApprovalChange = async (val: number | null) => {
|
||||
}
|
||||
// ★ 自动关联:把该借库申请单的“申请原因(remark)”自动带入执行备注,库管无需重复填写(可手动改)
|
||||
form.remark = (req && req.remark) || ''
|
||||
|
||||
// ★ 领用人自动回填
|
||||
//
|
||||
// 口径差异:审批单上的 borrower_name 存的是**完整 username**
|
||||
// (形如「杜邢宸/duxingchen」),而人员名单返回的是展示名(「杜邢宸」,
|
||||
// 后端已按斜杠前段截断)。两边都归一化到「斜杠前段」再比对,
|
||||
// 否则永远匹配不上、下拉框恒为空。
|
||||
//
|
||||
// 优先级:borrower_name 优先 —— 它才是「谁要借」的意图;库管代建时
|
||||
// applicant_id 是库管本人,回退到它会选错人。仅当审批单没记借用人姓名时
|
||||
// 才回退到申请人。
|
||||
//
|
||||
// 匹配不到时**保持未选**,不回退到自由文本:借用人 ID 是转交/归还责任链的
|
||||
// 唯一锚点,宁可让库管手动选,也不能猜一个。
|
||||
await loadBorrowerOptions()
|
||||
form.borrower_id = null
|
||||
form.borrower_name = ''
|
||||
if (req) {
|
||||
const wantName = String(req.borrower_name || '').split('/')[0].trim()
|
||||
let hit = wantName
|
||||
? borrowerOptions.value.find(u => String(u.name || '').split('/')[0].trim() === wantName)
|
||||
: undefined
|
||||
if (!hit && req.applicant_id) {
|
||||
hit = borrowerOptions.value.find(u => u.id === req.applicant_id)
|
||||
}
|
||||
if (hit) {
|
||||
form.borrower_id = hit.id
|
||||
form.borrower_name = hit.name
|
||||
}
|
||||
}
|
||||
|
||||
cartItems.value = []
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
@ -709,13 +757,37 @@ const lastX = ref(0)
|
||||
const lastY = ref(0)
|
||||
|
||||
const form = reactive({
|
||||
borrower_name: '',
|
||||
borrower_id: null as number | null, // ★ 唯一身份锚点,提交给后端
|
||||
borrower_name: '', // 由所选用户派生,仅用于本地展示
|
||||
expected_return_time: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// ★ 借用人名单:与转交接收人、归还人共用同一数据源,按公司隔离
|
||||
const borrowerOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
let borrowerOptionsLoaded = false
|
||||
|
||||
// 可重复调用:已加载则直接返回;必须可 await ——
|
||||
// ★ 选中审批单时要拿这份名单反查借用人,若名单尚未返回就比对,
|
||||
// 会误判为「找不到」而清空选择。
|
||||
const loadBorrowerOptions = async () => {
|
||||
if (borrowerOptionsLoaded) return
|
||||
try {
|
||||
const res: any = await getBorrowUsers()
|
||||
borrowerOptions.value = res?.data || []
|
||||
borrowerOptionsLoaded = true
|
||||
} catch {
|
||||
borrowerOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleBorrowerChange = (id: number) => {
|
||||
const hit = borrowerOptions.value.find(u => u.id === id)
|
||||
form.borrower_name = hit ? hit.name : ''
|
||||
}
|
||||
|
||||
const rules = computed(() => ({
|
||||
borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }],
|
||||
borrower_id: [{ required: true, message: '请选择借用人', trigger: 'change' }],
|
||||
expected_return_time: [
|
||||
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
|
||||
]
|
||||
@ -870,6 +942,7 @@ const clearAll = () => {
|
||||
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
|
||||
.then(() => {
|
||||
cartItems.value = []
|
||||
form.borrower_id = null
|
||||
form.borrower_name = ''
|
||||
form.remark = ''
|
||||
form.expected_return_time = ''
|
||||
@ -933,7 +1006,8 @@ const submitForm = async () => {
|
||||
await dispatchBorrow({
|
||||
approval_id: selectedApprovalId.value,
|
||||
items: itemsPayload,
|
||||
borrower_name: form.borrower_name,
|
||||
borrower_id: form.borrower_id as number, // ★ 唯一身份锚点
|
||||
borrower_name: form.borrower_name, // 仅作兼容,后端不采信
|
||||
signature_path: signatureUrl,
|
||||
remark: form.remark,
|
||||
expected_return_time: isIndefinite.value ? null : form.expected_return_time
|
||||
@ -947,6 +1021,7 @@ const submitForm = async () => {
|
||||
}
|
||||
|
||||
cartItems.value = []
|
||||
form.borrower_id = null
|
||||
form.borrower_name = ''
|
||||
form.expected_return_time = ''
|
||||
form.remark = ''
|
||||
@ -1043,6 +1118,7 @@ const handleSignCancel = () => { showSignatureDialog.value = false }
|
||||
import { onMounted } from 'vue'
|
||||
onMounted(() => {
|
||||
loadApprovalRequests()
|
||||
loadBorrowerOptions()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@ -101,7 +101,7 @@
|
||||
<h4 style="margin: 0 0 10px; font-size: 14px; color: #606266;">借出明细</h4>
|
||||
<el-table :data="props.row.children" border size="small">
|
||||
<el-table-column prop="material_name" label="物料名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="sku" label="SKU" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="借出数量" width="80" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag type="info">{{ row.quantity }}</el-tag>
|
||||
@ -130,20 +130,95 @@
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- ★ 明细级持有人:部分转交后主行只能显示「多人持有」,
|
||||
到底哪件在谁手里,必须在这一层看清楚 -->
|
||||
<el-table-column label="当前持有人" min-width="120">
|
||||
<template #default="{row: c}">
|
||||
<span
|
||||
v-if="c.current_holder_name"
|
||||
class="holder-name"
|
||||
:class="{ 'is-transferred': isRowTransferred(c) }"
|
||||
>{{ formatName(c.current_holder_name) }}</span>
|
||||
<span v-else class="text-info">已回库</span>
|
||||
<el-tag v-if="c.pending_transfer" type="warning" size="small" style="margin-left:4px">
|
||||
转交待确认
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- ★ 明细级操作:转交粒度已下沉到明细行,这里才是精准入口。
|
||||
fixed="right" 同样吸附最右侧 —— 子表嵌在主表内层,
|
||||
容器更窄,不固定的话按钮更容易被挤到屏幕外。 -->
|
||||
<el-table-column label="操作" width="170" align="center" fixed="right">
|
||||
<template #default="{row: c}">
|
||||
<template v-if="c.pending_transfer && c.pending_transfer.is_mine">
|
||||
<el-button type="success" link size="small" @click="acceptOne(c)">接收</el-button>
|
||||
<el-button type="danger" link size="small" @click="rejectOne(c)">拒绝</el-button>
|
||||
</template>
|
||||
<!-- ★ 只有该物品的**当前持有人本人**能发起转交(can_transfer
|
||||
由后端判定:前端 localStorage 里没有 user_id)。
|
||||
后端 transfer_borrow 有同样的强校验 —— 这里只是降噪,
|
||||
真正的边界在服务端。 -->
|
||||
<el-button
|
||||
v-else-if="(c.pending_quantity || 0) > 0 && !c.pending_transfer && c.can_transfer"
|
||||
type="primary" link size="small"
|
||||
@click="openTransferDialog(props.row, c)"
|
||||
>转交</el-button>
|
||||
<span v-else-if="!c.pending_transfer" class="text-info">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
|
||||
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
|
||||
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
|
||||
<el-table-column label="借出物品" width="90" align="center">
|
||||
<!-- ★ 弹性列宽:内容长度可变的列一律用 min-width,由 Element Plus 按屏幕
|
||||
剩余空间自动伸缩;只有「日期 / 数量 / 标签」这类长度确定的列才用
|
||||
width 硬编码。原先除两列外全用 width,合计约 1528px —— 1440 笔记本
|
||||
可用宽度只有约 1240px,必然撑出横向滚动条。 -->
|
||||
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column v-if="hasColumnPermission('borrower_name')" label="借用人" min-width="90">
|
||||
<template #default="{row}">
|
||||
<span class="name-fixed">{{ formatName(row.borrower_name) || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- ★ 当前持有人:转交后可能与「借用人」不是同一人;一张单的明细持有人
|
||||
也可能各不相同,故去重后逐个展示。
|
||||
表达上做了降噪:未转交=普通文本,转过交=主题色加粗。原先一律用橙色
|
||||
标签,一屏几十个色块会把真正需要关注的「已转交」淹没。 -->
|
||||
<el-table-column label="当前持有人" min-width="120">
|
||||
<template #default="{row}">
|
||||
<!-- ★ 单内多个持有人 = 部分转交(借 2 件只转了 1 件),是**正常业务形态**,
|
||||
不是异常。主行无法一一列清谁拿哪件,故用灰标签提示,
|
||||
具体归属看展开后的明细行。 -->
|
||||
<el-tag v-if="currentHolderList(row).length > 1" type="info" size="small">多人持有</el-tag>
|
||||
<span
|
||||
v-else-if="currentHolderList(row).length === 1"
|
||||
class="holder-name"
|
||||
:class="{ 'is-transferred': currentHolderList(row)[0].transferred }"
|
||||
>{{ formatName(currentHolderList(row)[0].name) }}</span>
|
||||
<span v-else-if="row.status === 'returned' || row.status === 'scrapped'" class="text-info">已回库</span>
|
||||
<span v-else class="text-info">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- ★ 去掉 sortable:这是 Element Plus 的**前端排序**,只会重排当前页的
|
||||
10 条,反而打乱后端按单号聚合的「逾期优先」排序,让列表看起来没规律。
|
||||
若将来确实需要按其他字段排序,应改为 sortable="custom" 并由后端接管。 -->
|
||||
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" />
|
||||
<el-table-column label="借出物品" width="80" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag type="info">{{ row.children ? row.children.length : 0 }} 项</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="hasColumnPermission('return_operator')" prop="return_operator" label="归还人" width="100" />
|
||||
<!-- ★ 归还人改为整单汇总:主行原取「首条明细」的归还人,多明细分批归还时
|
||||
会只显示其中一位,令人误以为其他人没还过。 -->
|
||||
<el-table-column v-if="hasColumnPermission('return_operator')" label="归还人" min-width="90">
|
||||
<template #default="{row}">
|
||||
<template v-if="row.return_operators && row.return_operators.length">
|
||||
<span v-for="op in row.return_operators" :key="op" class="name-fixed">{{ formatName(op) }}</span>
|
||||
</template>
|
||||
<span v-else class="text-info">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
|
||||
<template #default="{row}">
|
||||
@ -151,8 +226,11 @@
|
||||
<el-tag type="success" size="small">实际</el-tag>
|
||||
{{ row.return_time || '-' }}
|
||||
</div>
|
||||
<!-- ★ 降噪:「无限期」不是异常,不该用蓝色标签抢视觉重心 ——
|
||||
把注意力留给红色的「已逾期」。改为灰色小字。(沿用系统既有术语
|
||||
「无限期」,与借出页的「无限期/长期借用」保持一致) -->
|
||||
<div v-else-if="!row.expected_return_time">
|
||||
<el-tag type="primary" size="small">无限期</el-tag>
|
||||
<span class="text-info">无限期</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span>预计 {{ formatExpectedTime(row.expected_return_time).text }} </span>
|
||||
@ -167,16 +245,20 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
||||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="110" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.status === 'scrapped'" type="info">已报废</el-tag>
|
||||
<!-- ★ 中间态:已发起转交但接收人尚未确认。
|
||||
此时主表 current_holder **未变**(东西还在原持有人手上),
|
||||
故必须用状态列把「责任正在转移中」显式表达出来。 -->
|
||||
<el-tag v-if="row.pending_transfer" type="warning">转交待确认</el-tag>
|
||||
<el-tag v-else-if="row.status === 'scrapped'" type="info">已报废</el-tag>
|
||||
<el-tag v-else-if="row.status === 'returned'" type="success">已还</el-tag>
|
||||
<el-tag v-else-if="row.status === 'partial_returned'" type="warning">部分归还</el-tag>
|
||||
<el-tag v-else type="danger">未还</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" label="电子签名" width="140" align="center">
|
||||
<el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" label="电子签名" width="100" align="center">
|
||||
<template #default="{row}">
|
||||
<div style="display:flex; justify-content: center; gap:10px">
|
||||
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature && hasColumnPermission('borrow_signature')" width="220">
|
||||
@ -192,14 +274,48 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- ★ 报废操作列:仅未归还记录 + 库管/主管可见 -->
|
||||
<el-table-column v-if="canScrap" label="操作" width="90" align="center" fixed="right">
|
||||
<!-- ★ 操作列
|
||||
· 接收转交 / 拒绝 —— 待我接收时出现(最高优先级)
|
||||
· 申请报废 / 转交 —— 仅未归还记录,各自受权限约束
|
||||
· 流转记录 —— 无特殊权限要求
|
||||
fixed="right":无论前面多少列、屏幕多窄,操作按钮永远吸附在最右侧,
|
||||
用户不必为了点按钮去拖动横向滚动条。 -->
|
||||
<el-table-column label="操作" width="230" align="center" fixed="right">
|
||||
<template #default="{row}">
|
||||
<!-- ★ 待我接收的转交:最高优先级,接收人必须一眼看到并处理。
|
||||
东西还在对方手上,责任尚未转移 —— 见后端双向握手设计。
|
||||
主行聚合该单下所有属于我的待接收(部分转交下单内可能有多项)。 -->
|
||||
<template v-if="myPendings(row).length">
|
||||
<el-button
|
||||
type="success" link size="small"
|
||||
:loading="transferActionLoading"
|
||||
@click="acceptSlip(row)"
|
||||
>接收转交</el-button>
|
||||
<el-button
|
||||
type="danger" link size="small"
|
||||
:loading="transferActionLoading"
|
||||
@click="rejectSlip(row)"
|
||||
>拒绝</el-button>
|
||||
</template>
|
||||
|
||||
<el-button
|
||||
v-if="row.status === 'borrowed' || row.status === 'partial_returned'"
|
||||
v-if="canScrap && isUnreturned(row)"
|
||||
type="danger" link size="small"
|
||||
@click="openScrapDialog(row)"
|
||||
>申请报废</el-button>
|
||||
|
||||
<!-- 主行入口:仅当该单下有**由我持有**的未还物品时才出现
|
||||
(can_transfer 由后端按「当前持有人 == 我」判定)。 -->
|
||||
<el-button
|
||||
v-if="canTransferAny(row)"
|
||||
type="primary" link size="small"
|
||||
@click="openTransferDialog(row)"
|
||||
>转交</el-button>
|
||||
|
||||
<el-button
|
||||
type="info" link size="small"
|
||||
@click="openHistoryDrawer(row)"
|
||||
>流转记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -256,11 +372,152 @@
|
||||
<el-button type="danger" :loading="scrapSubmitting" @click="confirmScrap">提交报废申请</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ★ 借库转交弹窗(按件转交 + 双向握手,主行/明细行共用) -->
|
||||
<el-dialog v-model="transferDialogVisible" title="发起借库转交" width="640px" destroy-on-close>
|
||||
<el-alert
|
||||
title="按件转交,对方确认后责任正式转移"
|
||||
description="发起后将生成「待接收」记录,对方在系统中确认接收前,物品仍挂在您的名下。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 14px"
|
||||
/>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<div style="font-weight: 600; margin-bottom: 6px;">单号:{{ transferBorrowNo }}</div>
|
||||
<div style="font-size: 13px; color: #909399;">
|
||||
勾选要转交的物品(已选 {{ transferSelected.length }} 项,共 {{ transferTotalQty }} 件)。
|
||||
转交粒度是<strong>单件物品</strong>,可以只转其中一件。
|
||||
</div>
|
||||
</div>
|
||||
<!-- ★ 用 Element Plus 标准的 selection 列:自带表头「全选 / 取消全选」,
|
||||
比逐行 checkbox 多出一键操作能力。已有待接收的明细由 selectable
|
||||
钩子置为不可选,表头全选也会自动跳过它们。 -->
|
||||
<el-table
|
||||
ref="transferTableRef"
|
||||
:data="transferCandidates"
|
||||
border
|
||||
size="small"
|
||||
max-height="240"
|
||||
row-key="id"
|
||||
@selection-change="handleTransferSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" :selectable="transferSelectable" />
|
||||
<el-table-column prop="material_name" label="物料名称" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="sku" label="SKU" width="110" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="row.disabled" type="warning" size="small">转交待确认</el-tag>
|
||||
<span v-else class="text-info">可转交</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前持有人" width="120" align="center">
|
||||
<template #default="{row}">
|
||||
<span
|
||||
v-if="row.current_holder_name"
|
||||
class="holder-name"
|
||||
:class="{ 'is-transferred': isRowTransferred(row) }"
|
||||
>{{ formatName(row.current_holder_name) }}</span>
|
||||
<span v-else class="text-info">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="待还数量" width="90" align="center">
|
||||
<template #default="{row}">
|
||||
<span style="color:#F56C6C; font-weight:bold">{{ row.pending_quantity }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-form label-width="80px" style="margin-top: 14px;">
|
||||
<el-form-item label="接收人" required>
|
||||
<el-select v-model="transferToUserId" placeholder="请选择接收人" style="width: 100%" filterable>
|
||||
<el-option v-for="u in transferUsers" :key="u.id" :label="u.name" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="转交备注">
|
||||
<el-input
|
||||
v-model="transferRemark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="如:项目交接 / 人员离岗,可留空"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="transferSubmitting" @click="confirmTransfer">发起转交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ★ 流转明细时间线:借出 → 转交(可多次) → 归还 → 报废,倒序展示 -->
|
||||
<el-drawer v-model="historyDrawerVisible" title="流转明细" size="580px" destroy-on-close>
|
||||
<div v-loading="historyLoading">
|
||||
<div style="font-weight: 600; margin-bottom: 4px;">单号:{{ historyBorrowNo }}</div>
|
||||
<div style="font-size: 12px; color: #909399; margin-bottom: 16px;">
|
||||
按时间倒序,最新动作在最上方
|
||||
</div>
|
||||
<el-empty v-if="!historyEvents.length" description="暂无流转记录" :image-size="70" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="(e, i) in historyEvents"
|
||||
:key="i"
|
||||
:timestamp="e.time"
|
||||
placement="top"
|
||||
:type="eventNodeType(e)"
|
||||
:hollow="e.type === 'borrow'"
|
||||
>
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
|
||||
<!-- ★ 被拒收的转交换红色文案:原先和正常转交一样是黄色「转交」,
|
||||
一眼看不出资产交接失败 -->
|
||||
<el-tag v-if="isRejectedTransfer(e)" type="danger" size="small">转交被拒</el-tag>
|
||||
<el-tag v-else :type="eventMeta(e.type).type" size="small">{{ eventMeta(e.type).label }}</el-tag>
|
||||
<span style="font-size: 13px; color:#606266;">{{ e.material_name || e.sku }}</span>
|
||||
<span style="font-size: 13px; color:#909399;">× {{ e.quantity }}</span>
|
||||
</div>
|
||||
<div style="font-size: 13px; color:#303133;">
|
||||
<template v-if="e.type === 'borrow'">
|
||||
借用人:<b>{{ e.actor_name || '—' }}</b>
|
||||
<span v-if="e.operator_name" style="color:#909399;"> · 发货库管:{{ e.operator_name }}</span>
|
||||
</template>
|
||||
<template v-else-if="e.type === 'transfer'">
|
||||
<!-- ★ 不再单独显示「发起人」:转交已收紧为「仅当前持有人本人可发起」,
|
||||
发起人恒等于这里的 from_name —— 同一人出现两次是纯冗余。
|
||||
(完整 username 也不再展示,展示名口径与全站一致。) -->
|
||||
<b>{{ e.from_name || '—' }}</b> → <b>{{ e.actor_name || '—' }}</b>
|
||||
<!-- 待接收(PENDING)仍用黄色提示;已接收不再重复打标 -->
|
||||
<el-tag
|
||||
v-if="e.status === 'PENDING'"
|
||||
type="warning" size="small" style="margin-left:6px"
|
||||
>{{ e.status_text || '待接收' }}</el-tag>
|
||||
</template>
|
||||
<template v-else-if="e.type === 'return'">
|
||||
归还人:<b>{{ e.actor_name || '—' }}</b>
|
||||
<span v-if="e.operator_name" style="color:#909399;"> · 经手库管:{{ e.operator_name }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
报废 · 经手库管:<b>{{ e.operator_name || '—' }}</b>
|
||||
</template>
|
||||
</div>
|
||||
<!-- ★ 备注与拒收原因分行展示:原先后端把原因拼进备注
|
||||
("3333\n[拒绝原因] 5555"),两句挤在一起无法分辨 -->
|
||||
<div v-if="e.remark" style="font-size: 12px; color:#909399; margin-top:2px;">
|
||||
转交备注:{{ e.remark }}
|
||||
</div>
|
||||
<div
|
||||
v-if="e.reject_reason"
|
||||
style="font-size: 12px; color: #F56C6C; margin-top: 2px;"
|
||||
>
|
||||
<strong>拒收原因:</strong>{{ e.reject_reason }}
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/utils/request'
|
||||
import dayjs from 'dayjs'
|
||||
@ -268,7 +525,15 @@ import 'dayjs/locale/zh-cn'
|
||||
dayjs.locale('zh-cn')
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getApproversList } from '@/api/auth'
|
||||
import { submitBorrowScrapRequest } from '@/api/transaction'
|
||||
import {
|
||||
submitBorrowScrapRequest,
|
||||
transferBorrow,
|
||||
acceptBorrowTransfer,
|
||||
rejectBorrowTransfer,
|
||||
getBorrowUsers,
|
||||
getBorrowSlipHistory,
|
||||
} from '@/api/transaction'
|
||||
import { formatName } from '@/utils/format'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
@ -450,6 +715,401 @@ const confirmScrap = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ★ 借库转交(一期:仅支持整单全部转交)
|
||||
// ==========================================================================
|
||||
// 未归还判定:主表状态下 borrowed / partial_returned 都还有实物未回库
|
||||
const isUnreturned = (row: any) =>
|
||||
row.status === 'borrowed' || row.status === 'partial_returned'
|
||||
|
||||
// 当前持有人:整单下各明细可能持有人不同(转交是逐条明细进行的),去重后展示。
|
||||
// ★ 同时标出该持有人是否「因转交而来」(≠ 初始借用人)—— 转交过的用主题色加粗,
|
||||
// 未转交的保持普通文本,靠颜色而非色块承担区分度。
|
||||
const currentHolderList = (row: any): Array<{ name: string; transferred: boolean }> => {
|
||||
const borrower = String(row.borrower_name || '').split('/')[0].trim()
|
||||
const seen = new Map<string, boolean>()
|
||||
;(row.children || []).forEach((c: any) => {
|
||||
const name = c.current_holder_name
|
||||
if (!name) return
|
||||
const transferred = String(name).split('/')[0].trim() !== borrower
|
||||
// 同名只要有一次是「转交而来」就按转交样式展示
|
||||
seen.set(name, (seen.get(name) || false) || transferred)
|
||||
})
|
||||
return Array.from(seen, ([name, transferred]) => ({ name, transferred }))
|
||||
}
|
||||
|
||||
// 单条明细是否已转交 —— 供转交弹窗的行内展示使用。
|
||||
// (组行有 children,请用上面的 currentHolderList)
|
||||
const isRowTransferred = (row: any): boolean => {
|
||||
const holder = String(row.current_holder_name || '').split('/')[0].trim()
|
||||
const borrower = String(row.borrower_name || '').split('/')[0].trim()
|
||||
return !!holder && holder !== borrower
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ★ 整单汇总:主行字段原样取「首条明细」,多明细单会失真,此处按整单重算
|
||||
//
|
||||
// 失真场景:一张单有 2 条明细,第一条已还清、第二条未还 ——
|
||||
// · 主行状态显示「已还」,却仍出现在「未归还」页签里
|
||||
// (该页签由后端按整单聚合过滤:单号下至少一条未还);
|
||||
// · 归还人只显示第一条的经手人,另一位经手人被隐藏。
|
||||
// ==========================================================================
|
||||
const aggregateStatus = (children: any[]): string => {
|
||||
if (!children.length) return 'borrowed'
|
||||
// 全部报废
|
||||
if (children.every(c => c.status === 'scrapped')) return 'scrapped'
|
||||
// 全部结清(已还 + 已报废;报废在后端口径里同样计入 is_returned)
|
||||
const isClosed = (c: any) => c.status === 'returned' || c.status === 'scrapped'
|
||||
if (children.every(isClosed)) return 'returned'
|
||||
// 仍有未还:若已还过一部分,则呈现「部分归还」而非笼统的「未还」
|
||||
const returnedSome = children.some(
|
||||
(c: any) => c.status === 'returned' || c.status === 'partial_returned'
|
||||
)
|
||||
return returnedSome ? 'partial_returned' : 'borrowed'
|
||||
}
|
||||
|
||||
// 归还人汇总:去重(同一库管经手多条明细时只显示一次)
|
||||
const aggregateReturnOperators = (children: any[]): string[] => {
|
||||
const ops = new Set<string>()
|
||||
children.forEach((c: any) => {
|
||||
if (c.return_operator) ops.add(String(c.return_operator))
|
||||
})
|
||||
return Array.from(ops)
|
||||
}
|
||||
|
||||
// 最早 / 最晚时间取值:字符串为 'YYYY-MM-DD HH:mm' 格式,可直接比较
|
||||
const earliest = (arr: any[]): string | null =>
|
||||
arr.filter(Boolean).sort().shift() ?? null
|
||||
const latest = (arr: any[]): string | null =>
|
||||
arr.filter(Boolean).sort().pop() ?? null
|
||||
|
||||
const transferDialogVisible = ref(false)
|
||||
const transferSubmitting = ref(false)
|
||||
const transferActionLoading = ref(false) // 接收/拒绝按钮 loading
|
||||
const transferBorrowNo = ref('')
|
||||
const transferCandidates = ref<any[]>([])
|
||||
const transferToUserId = ref<number | null>(null)
|
||||
const transferRemark = ref('')
|
||||
const transferUsers = ref<Array<{ id: number; name: string }>>([])
|
||||
|
||||
// 弹窗内勾选的明细由 el-table 的 selection 机制托管(含表头一键全选)。
|
||||
// ★ 不用逐行 checkbox + computed:那样拿不到「全选」能力,且选中状态与
|
||||
// 表格内部状态两套并存,容易不同步。
|
||||
const transferTableRef = ref<any>(null)
|
||||
const transferSelected = ref<any[]>([])
|
||||
|
||||
const handleTransferSelectionChange = (rows: any[]) => {
|
||||
transferSelected.value = rows
|
||||
}
|
||||
|
||||
// 已有待接收的明细不可再发起(后端也会拒)—— 置为不可选,
|
||||
// 表头「全选」会自动跳过它们,不会出现整列勾上却提交失败的情况。
|
||||
const transferSelectable = (row: any) => !row.disabled
|
||||
|
||||
// 已勾选项的待还合计
|
||||
const transferTotalQty = computed(() =>
|
||||
transferSelected.value.reduce((s: number, c: any) => s + (Number(c.pending_quantity) || 0), 0)
|
||||
)
|
||||
|
||||
// 该单下是否有「由我持有、可发起转交」的未还物品 —— 主行【转交】按钮的显示条件。
|
||||
// can_transfer 由后端按「当前持有人 == 我」判定(前端 localStorage 无 user_id)。
|
||||
const canTransferAny = (row: any): boolean =>
|
||||
(row.children || []).some((c: any) => (c.pending_quantity || 0) > 0 && c.can_transfer)
|
||||
|
||||
// 该单下所有「待我接收」的转交 —— 主行聚合用。
|
||||
// (明细行只看自己那一条,见模板里的 c.pending_transfer)
|
||||
const myPendings = (row: any): any[] =>
|
||||
(row.children || [])
|
||||
.map((c: any) => c.pending_transfer)
|
||||
.filter((p: any) => p && p.is_mine)
|
||||
|
||||
const loadTransferUsers = async () => {
|
||||
if (transferUsers.value.length) return // 名单会话内复用,避免每次开弹窗都请求
|
||||
try {
|
||||
const res: any = await getBorrowUsers()
|
||||
transferUsers.value = res?.data || []
|
||||
} catch {
|
||||
transferUsers.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开转交弹窗。
|
||||
*
|
||||
* @param row 主行(单号层),用它的 children 作为候选明细
|
||||
* @param detail 从**明细行**进入时传入该明细,只预选这一条;
|
||||
* 从主行进入则不传,默认勾选全部可转交项。
|
||||
*
|
||||
* ★ 转交粒度已下沉到明细行:一张单可以只转其中一件(借 2 件转 1 件),
|
||||
* 故这里用勾选而非「整单」。
|
||||
*/
|
||||
const openTransferDialog = async (row: any, detail?: any) => {
|
||||
// ★ 只列出「由我持有」的未还物品:不是我的,后端会拒,列在弹窗里只会误导。
|
||||
const candidates = (row.children || []).filter(
|
||||
(c: any) => (c.pending_quantity || 0) > 0 && c.can_transfer
|
||||
)
|
||||
if (!candidates.length) {
|
||||
ElMessage.warning('该单号下没有由您持有的未归还物品,无法转交')
|
||||
return
|
||||
}
|
||||
transferBorrowNo.value = row.borrow_no
|
||||
transferCandidates.value = candidates.map((c: any) => ({
|
||||
...c,
|
||||
// 预选标记:从明细行进入只预选该件,从主行进入预选全部可转交项
|
||||
_preselect: detail ? c.id === detail.id : !c.pending_transfer,
|
||||
disabled: !!c.pending_transfer,
|
||||
}))
|
||||
transferToUserId.value = null
|
||||
transferRemark.value = ''
|
||||
transferSelected.value = []
|
||||
transferDialogVisible.value = true
|
||||
loadTransferUsers()
|
||||
|
||||
// ★ 等表格渲染完再落预选:selection 状态由 el-table 内部持有,
|
||||
// 必须通过它的 API(toggleRowSelection)写,不能改数据源字段。
|
||||
// 弹窗是 destroy-on-close,每次打开表格都是新建的,故每次都要重新预选。
|
||||
await nextTick()
|
||||
const tbl = transferTableRef.value
|
||||
if (!tbl) return
|
||||
tbl.clearSelection()
|
||||
transferCandidates.value.forEach((c: any) => {
|
||||
if (c._preselect) tbl.toggleRowSelection(c, true)
|
||||
})
|
||||
}
|
||||
|
||||
const confirmTransfer = async () => {
|
||||
const picked = transferSelected.value
|
||||
if (!picked.length) return ElMessage.warning('请至少勾选一项要转交的物品')
|
||||
if (!transferToUserId.value) return ElMessage.warning('请选择接收人')
|
||||
|
||||
const receiver = transferUsers.value.find(u => u.id === transferToUserId.value)
|
||||
if (picked.every((c: any) => c.current_holder_id === transferToUserId.value)) {
|
||||
return ElMessage.warning(`所选物品的当前持有人已是【${receiver?.name}】,无需转交`)
|
||||
}
|
||||
|
||||
const qty = transferTotalQty.value
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将【${transferBorrowNo.value}】下的 ${picked.length} 项物品(共 ${qty} 件)转交给【${receiver?.name || ''}】?\n\n`
|
||||
+ `发起后对方需在其借用列表中确认接收,责任才正式转移;\n`
|
||||
+ `在对方确认前,物品与责任仍归各自的当前持有人。`,
|
||||
'发起转交',
|
||||
{ confirmButtonText: '确认发起', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
|
||||
transferSubmitting.value = true
|
||||
// ★ 逐项发起:转交粒度是明细行,每一项各自成立一条待接收流水。
|
||||
// 刻意不做「全部成功才算成功」的伪原子 —— 物理上每件物品本就是独立交接的,
|
||||
// 部分成功同样有意义。失败项由 request 拦截器 toast 具体原因,
|
||||
// 列表刷新后未成功的仍显示为可转交。
|
||||
let ok = 0
|
||||
try {
|
||||
for (const c of picked) {
|
||||
try {
|
||||
await transferBorrow(c.id, {
|
||||
transfer_qty: Number(c.pending_quantity) || 0, // 整行转交,后端二次校验
|
||||
to_user_id: transferToUserId.value,
|
||||
to_user_name: receiver?.name,
|
||||
remark: transferRemark.value,
|
||||
})
|
||||
ok++
|
||||
} catch {
|
||||
// 单条失败不中断其余
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
ElMessage.success(`已发起 ${ok}/${picked.length} 项转交,等待对方确认`)
|
||||
transferDialogVisible.value = false
|
||||
fetchData()
|
||||
}
|
||||
} finally {
|
||||
transferSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ★ 双向握手第二步:接收人确认 / 拒绝
|
||||
// 粒度同为明细行 —— 明细行按钮处理自己那一条;主行按钮聚合处理该单下
|
||||
// 所有属于我的待接收。
|
||||
// ==========================================================================
|
||||
const doAccept = async (pt: any, label: string): Promise<boolean> => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认接收【${pt.from_user_name || '对方'}】转交的物品?\n\n`
|
||||
+ `${label}\n`
|
||||
+ `确认后,该物品的持有责任转移到您名下,归还时须由您本人办理。`,
|
||||
'确认接收转交',
|
||||
{ confirmButtonText: '确认接收', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const res: any = await acceptBorrowTransfer(pt.id)
|
||||
ElMessage.success(res?.msg || '已接收')
|
||||
return true
|
||||
} catch {
|
||||
return false // 错误提示由 request 拦截器统一处理
|
||||
}
|
||||
}
|
||||
|
||||
const doReject = async (pt: any, label: string): Promise<boolean> => {
|
||||
let reason = ''
|
||||
try {
|
||||
const r: any = await ElMessageBox.prompt(
|
||||
`拒绝【${pt.from_user_name || '对方'}】转交给您的物品?\n拒绝后责任仍由对方承担。\n\n${label}`,
|
||||
'拒绝转交',
|
||||
{
|
||||
confirmButtonText: '确认拒绝',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '拒绝原因(可留空)',
|
||||
inputValidator: () => true, // 允许留空,后端 reason 为可选
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
reason = r?.value || ''
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const res: any = await rejectBorrowTransfer(pt.id, reason)
|
||||
ElMessage.success(res?.msg || '已拒绝')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 明细行入口(精准处理自己那一条)---
|
||||
const acceptOne = async (detail: any) => {
|
||||
const label = `物品:${detail.material_name || detail.sku || ''}`
|
||||
transferActionLoading.value = true
|
||||
try {
|
||||
if (await doAccept(detail.pending_transfer, label)) fetchData()
|
||||
} finally {
|
||||
transferActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const rejectOne = async (detail: any) => {
|
||||
const label = `物品:${detail.material_name || detail.sku || ''}`
|
||||
transferActionLoading.value = true
|
||||
try {
|
||||
if (await doReject(detail.pending_transfer, label)) fetchData()
|
||||
} finally {
|
||||
transferActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 主行入口(聚合该单下所有属于我的待接收)---
|
||||
const acceptSlip = async (row: any) => {
|
||||
const mine = myPendings(row)
|
||||
if (!mine.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认接收该单下 ${mine.length} 项转交给您的物品?\n\n单号:${row.borrow_no}\n`
|
||||
+ `确认后,这些物品的持有责任一并转移到您名下。`,
|
||||
'确认接收转交',
|
||||
{ confirmButtonText: '确认接收', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
transferActionLoading.value = true
|
||||
try {
|
||||
let ok = 0
|
||||
for (const pt of mine) {
|
||||
try {
|
||||
await acceptBorrowTransfer(pt.id)
|
||||
ok++
|
||||
} catch {
|
||||
// 单条失败不中断其余(拦截器已提示原因)
|
||||
}
|
||||
}
|
||||
ElMessage.success(`已接收 ${ok}/${mine.length} 项`)
|
||||
if (ok) fetchData()
|
||||
} finally {
|
||||
transferActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const rejectSlip = async (row: any) => {
|
||||
const mine = myPendings(row)
|
||||
if (!mine.length) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`拒绝该单下 ${mine.length} 项转交给您的物品?\n\n单号:${row.borrow_no}\n`
|
||||
+ `拒绝后责任仍由对方承担。`,
|
||||
'拒绝转交',
|
||||
{ confirmButtonText: '确认拒绝', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
transferActionLoading.value = true
|
||||
try {
|
||||
let ok = 0
|
||||
for (const pt of mine) {
|
||||
try {
|
||||
await rejectBorrowTransfer(pt.id)
|
||||
ok++
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
ElMessage.success(`已拒绝 ${ok}/${mine.length} 项`)
|
||||
if (ok) fetchData()
|
||||
} finally {
|
||||
transferActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// ★ 流转明细时间线(借出 → 转交(可多次) → 归还 → 报废)
|
||||
// ==========================================================================
|
||||
const historyDrawerVisible = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const historyBorrowNo = ref('')
|
||||
const historyEvents = ref<any[]>([])
|
||||
|
||||
const EVENT_META: Record<string, { label: string; type: any }> = {
|
||||
borrow: { label: '借出', type: 'primary' },
|
||||
transfer: { label: '转交', type: 'warning' },
|
||||
return: { label: '归还', type: 'success' },
|
||||
scrap: { label: '报废', type: 'danger' },
|
||||
}
|
||||
const eventMeta = (t: string) => EVENT_META[t] || { label: t, type: 'info' }
|
||||
|
||||
// 被拒收的转交 —— 时间线上要按「异常」呈现,不能和正常转交一个模样
|
||||
const isRejectedTransfer = (e: any): boolean =>
|
||||
e.type === 'transfer' && e.status === 'REJECTED'
|
||||
|
||||
// 时间线圆点颜色:被拒收的转交用红色(danger),其余沿用动作类型配色。
|
||||
// 待接收(PENDING)保持黄色 warning —— 它是「进行中」不是「失败」。
|
||||
const eventNodeType = (e: any): any => {
|
||||
if (isRejectedTransfer(e)) return 'danger'
|
||||
if (e.type === 'transfer' && e.status === 'PENDING') return 'warning'
|
||||
return eventMeta(e.type).type
|
||||
}
|
||||
|
||||
const openHistoryDrawer = async (row: any) => {
|
||||
historyBorrowNo.value = row.borrow_no
|
||||
historyEvents.value = []
|
||||
historyDrawerVisible.value = true
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const res: any = await getBorrowSlipHistory(row.borrow_no)
|
||||
historyEvents.value = res?.data?.events || []
|
||||
} catch {
|
||||
// 同上:错误提示由 request 拦截器统一处理
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -483,13 +1143,21 @@ const fetchData = async () => {
|
||||
groupMap.get(item.borrow_no).children.push(item)
|
||||
})
|
||||
|
||||
// ★ 整单状态重算:所有明细都已报废 → 整单标记 scrapped
|
||||
// ★ 整单汇总重算:主行字段默认取「首条明细」,多明细单在部分归还/分批归还时
|
||||
// 会失真,故对状态、归还人、时间字段按整单重算(详见 aggregateStatus 注释)
|
||||
list.value = Array.from(groupMap.values()).map(group => {
|
||||
const allScrapped = group.children.length > 0 &&
|
||||
group.children.every((c: any) => c.status === 'scrapped')
|
||||
if (allScrapped) {
|
||||
group.status = 'scrapped'
|
||||
}
|
||||
const children = group.children || []
|
||||
group.status = aggregateStatus(children)
|
||||
group.return_operators = aggregateReturnOperators(children)
|
||||
|
||||
// 预计归还时间取**最早**:后端排序用的正是单内 min(expected_return_time),
|
||||
// 主行若显示首条明细的值,多明细单的可见列就会与排序不一致 ——
|
||||
// 看上去「排得没规律」,实则列里显示的根本不是排序依据。
|
||||
group.expected_return_time = earliest(children.map((c: any) => c.expected_return_time))
|
||||
|
||||
// 归还时间取**最晚**:状态为「已还」时代表整单结清的那一刻
|
||||
group.return_time = latest(children.map((c: any) => c.return_time))
|
||||
|
||||
return group
|
||||
})
|
||||
|
||||
@ -587,4 +1255,48 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
★ 姓名定宽对齐
|
||||
中文姓名以「字」为单位占宽,1 个全角字 ≈ 1em,故统一占 3em:
|
||||
「石利」与「郭俊玥」占同样的横向空间,一列名字纵向对齐、不再参差。
|
||||
|
||||
两字姓名由 formatName() 在**渲染副本**上补全角空格撑成三字宽(见
|
||||
src/utils/format.ts),这里是兜底:单字姓名等异常长度也不会撑破列对齐。
|
||||
row.borrower_name 等底层数据始终是原值。
|
||||
========================================================================== */
|
||||
.name-fixed {
|
||||
display: inline-block;
|
||||
min-width: 3em;
|
||||
/* 一格内有多个姓名时(同一单的明细持有人/归还人不同)留出间隔,
|
||||
否则两个 3em 的盒子会紧贴成「高 雪张 三」难以分辨 */
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
★ 当前持有人
|
||||
业务方要求该列**始终高亮** —— 「东西现在在谁手上」比「当初谁借的」更需要
|
||||
一眼看到,故基础态即主题色加粗,不再区分是否转交。
|
||||
发生过转交时额外加浅蓝底 + 细边框,强调「经手人已变更」。
|
||||
(浅蓝而非实心色块:保持降噪基调,只做一层轻标记)
|
||||
========================================================================== */
|
||||
.holder-name {
|
||||
display: inline-block;
|
||||
min-width: 3em;
|
||||
margin-right: 6px;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.holder-name.is-transferred {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-color-primary-light-5);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* 中性/次要信息:灰字,不抢视觉重心 */
|
||||
.text-info {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -45,7 +45,24 @@
|
||||
<div class="cart-section">
|
||||
<div v-if="returnList.length > 0">
|
||||
<el-table :data="returnList" border stripe style="width: 100%">
|
||||
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
|
||||
<el-table-column v-if="hasColumnPermission('borrower_name')" label="借用人" width="90" show-overflow-tooltip>
|
||||
<template #default="{row}">
|
||||
<span class="name-fixed">{{ formatName(row.borrower_name) || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- ★ 当前持有人:转交后可能与「借用人」不是同一人,归还时必须由他本人来还。
|
||||
表达上做条件高亮 —— 只有**确实发生过转交**(当前持有人 ≠ 借用人)时
|
||||
才用主题色加粗;两者同一人时是常态,用普通深色文本,不制造视觉噪音。 -->
|
||||
<el-table-column label="当前持有人" width="110" show-overflow-tooltip>
|
||||
<template #default="{row}">
|
||||
<span
|
||||
v-if="row.current_holder_name"
|
||||
class="holder-name"
|
||||
:class="{ 'is-transferred': isTransferred(row) }"
|
||||
>{{ formatName(row.current_holder_name) }}</span>
|
||||
<span v-else class="text-info">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="hasColumnPermission('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
|
||||
<el-table-column label="借出数量" width="80" align="center">
|
||||
<template #default="{row}">
|
||||
@ -109,6 +126,27 @@
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<!-- ★ 实际归还人:后端强校验其必须等于物品的当前持有人,
|
||||
不匹配会整单拒绝。默认取首件物品的当前持有人,可手动改。 -->
|
||||
<el-form-item label="实际归还人" required>
|
||||
<el-select
|
||||
v-model="returnerId"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择实际把物品交回的人"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
:disabled="!userStore.hasPermission('op_return:operation')"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in returnerOptions"
|
||||
:key="u.id"
|
||||
:label="u.name"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item required>
|
||||
<div class="signature-box" @click="openSignatureDialog" v-if="userStore.hasPermission('op_return:operation')">
|
||||
<div v-if="signaturePreviewUrl" class="signed-img">
|
||||
@ -194,8 +232,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onUnmounted } from 'vue'
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
import { getBorrowUsers } from '@/api/transaction'
|
||||
import { formatName } from '@/utils/format'
|
||||
import { uploadFile } from '@/api/common/upload'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||
@ -228,6 +268,34 @@ const loading = ref(false)
|
||||
const showCamera = ref(false)
|
||||
const barcodeRef = ref()
|
||||
|
||||
// ★ 是否发生过转交:当前持有人 ≠ 借用人。
|
||||
// 两者都是后端 to_dict 的展示名口径,仍归一化到「斜杠前段」再比对 ——
|
||||
// 历史数据里可能混入「姓名/拼音」的完整 username 写法,不归一化会把同一人
|
||||
// 误判成转交,让整列无故飘蓝。
|
||||
const isTransferred = (row: any): boolean => {
|
||||
const holder = String(row.current_holder_name || '').split('/')[0].trim()
|
||||
const borrower = String(row.borrower_name || '').split('/')[0].trim()
|
||||
return !!holder && holder !== borrower
|
||||
}
|
||||
|
||||
// ★ 实际归还人(一期转交改造):必须等于物品的当前持有人,后端强校验。
|
||||
// 与借出页的借用人下拉共用同一份公司隔离名单。
|
||||
const returnerId = ref<number | null>(null)
|
||||
const returnerOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
|
||||
const loadReturnerOptions = async () => {
|
||||
try {
|
||||
const res: any = await getBorrowUsers()
|
||||
returnerOptions.value = res?.data || []
|
||||
} catch {
|
||||
returnerOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadReturnerOptions()
|
||||
})
|
||||
|
||||
// 签名状态
|
||||
const showSignatureDialog = ref(false)
|
||||
const signaturePreviewUrl = ref('')
|
||||
@ -285,6 +353,12 @@ const scanItem = async () => {
|
||||
item.return_qty = item.pending_quantity
|
||||
|
||||
returnList.value.push(item)
|
||||
// ★ 首次扫码时用物品的当前持有人预填归还人(可手动改):
|
||||
// 绝大多数场景就是本人来还,预填省一步操作;若清单里混入了他人持有的
|
||||
// 物品,后端会拒绝并明确指出是谁持有的,不会被静默放过。
|
||||
if (returnerId.value === null && item.current_holder_id) {
|
||||
returnerId.value = item.current_holder_id
|
||||
}
|
||||
barcode.value = ''
|
||||
ElMessage.success('识别成功')
|
||||
} catch(e) {
|
||||
@ -305,6 +379,7 @@ const clearAll = () => {
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
barcode.value = ''
|
||||
returnerId.value = null
|
||||
})
|
||||
}
|
||||
|
||||
@ -317,6 +392,12 @@ const preSubmitCheck = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// ★ 归还人必须选择:后端要求其等于每件物品的当前持有人
|
||||
if (!returnerId.value) {
|
||||
ElMessage.error('请选择实际归还人')
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 处理空输入:默认为原库位
|
||||
returnList.value.forEach(item => {
|
||||
if (!item.return_location || item.return_location.trim() === '') {
|
||||
@ -362,7 +443,8 @@ const submitReturn = async () => {
|
||||
method: 'post',
|
||||
data: {
|
||||
items: returnList.value,
|
||||
signature_path: upRes.data.url
|
||||
signature_path: upRes.data.url,
|
||||
returner_id: returnerId.value // ★ 实际归还人,后端强校验 == 当前持有人
|
||||
}
|
||||
})
|
||||
ElMessage.success('还库成功')
|
||||
@ -370,6 +452,7 @@ const submitReturn = async () => {
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
showCamera.value = false
|
||||
returnerId.value = null
|
||||
} catch(e: any) {
|
||||
ElMessage.error(e.response?.data?.msg || '提交失败')
|
||||
} finally {
|
||||
@ -568,4 +651,41 @@ onUnmounted(() => {
|
||||
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
|
||||
.sidebar-actions .el-button { flex: 1; height: 40px; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
★ 姓名等宽对齐
|
||||
中文 1 字 ≈ 1em。formatName() 已把两字姓名补成全角三字宽,这里再加一个
|
||||
最小宽度兜底,保证单字姓名及后续可能出现的异常长度也不破坏列对齐。
|
||||
(视觉对齐只做在样式与展示副本上,row.borrower_name 始终是原值)
|
||||
========================================================================== */
|
||||
.name-fixed {
|
||||
display: inline-block;
|
||||
min-width: 3em;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
★ 当前持有人
|
||||
该列**始终高亮** ——「东西现在在谁手上」比「当初谁借的」更需要一眼看到,
|
||||
故基础态即主题色加粗。发生过转交时额外加浅蓝底 + 细边框,
|
||||
强调「经手人已变更,归还时必须由他本人来还」。
|
||||
========================================================================== */
|
||||
.holder-name {
|
||||
display: inline-block;
|
||||
min-width: 3em;
|
||||
margin-right: 6px;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.holder-name.is-transferred {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-color-primary-light-5);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* 中性/次要信息:灰字,不抢视觉重心 */
|
||||
.text-info {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user