36 Commits

Author SHA1 Message Date
483fb5336a V3.82,9.17推送 2026-09-17 11:41:22 +08:00
a739f9c889 feat(material): 采购链接在编辑弹窗内可直接跳转,无需复制网址
体验问题
----
原先只能在编辑弹窗的输入框里看到网址,要跳转得复制出来、另开标签页、粘贴 ——
多三步,且现场场景下很容易贴错。

改动
----
输入框追加一个常驻的「打开」按钮:点一下在新标签页打开,同时输入框始终可编辑。
非法链接(非 http/https)时按钮置灰 + title 提示;误点给出明确 ElMessage,
不做静默失败。两处(list.vue / buyOdoo.vue)交互一致。

★ 为什么不做「双击解锁编辑」
  1) 双击是**隐藏交互**,没有任何视觉提示,用户不会发现;
  2) 双击在输入框内与「选中文字」冲突(复制片段时会误触发);
  3) 追加按钮零学习成本,且不牺牲可编辑性 —— 两者目标相同,代价更低。
  链接的「只看不改」需求若有,应走权限(只读渲染),而不是靠双击切换模式。
2026-09-17 11:33:15 +08:00
938051fe29 feat(material): 采购链接前端(表单 + 列表列),并修复 Odoo 页保存后丢搜索
一、采购链接 UI(list.vue 基础信息 / buyOdoo.vue 基础信息(Odoo))
  各补齐 6 处:columns、permissionMap、列设置复选框、表格列、表单字段、compareFields。
  · 表格列带双重守卫(columns.X.visible && hasColPermission),无权限者整列不渲染
  · 表单字段套 hasFieldPermission('purchaseLink'),无权限者整项不显示
  · ★ compareFields 必须加:编辑走的是「差异比对后只提交变更字段」,
    漏了这一项则改了链接也不会被提交(静默失败,排查成本极高)

二、采购链接的 href 白名单
  新增 safeHref():只放行 http/https。
  ★ 该字段由用户自由填写,直接绑到 href 上时 `javascript:` 伪协议会被浏览器
    当成可执行链接 —— 点一下就执行脚本。非法值的行显示「格式无效」而非链接。

三、修复 Odoo 页「保存后列表变空、必须重新搜索才恢复」
  根因:fetchOdooSummary() **无条件清空 groupCache**,却只在关键词变化时重置
  activeCategories。保存编辑后关键词未变 → 分组仍显示为「已展开」、缓存却空了,
  而 loadGroupItems 只由展开事件触发、不会重跑 —— 面板保持展开却无数据。
  修法:关键词未变时,清缓存后把**仍展开的分组重新拉一遍**(与文件内批量操作
  处既有的 delete + loadGroupItems 模式一致);关键词变了才折叠全部分组。
  基础信息页(list.vue)不受影响:它的 getList 会完整带上 queryParams。
2026-09-17 11:26:42 +08:00
6cb30a21fd feat(material): 采购链接接入读写映射与服务层
· models/base.py:新增 purchase_link 列(text)并加入 to_dict(purchaseLink)
· field_permissions.py:读过滤映射 purchaseLink → material_list:purchaseLink
· inbound/base.py:POST 与 PUT 两处 field_to_perm 同时补入(漏掉任一处,
  新增/修改就会各缺一半)
· base_service.py:create_material 写入、update_material 按字段存在与否更新

★ 写侧刻意**显式纳入映射**而非依赖「不在映射中→默认允许」的兜底分支 ——
  那个分支正是上一轮附件备注「谁都能写」的成因,新字段不再走它。

验证(6 个角色)
    SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR / INBOUND → 读✓ 写✓
    OUTBOUND / SALES                                   → 读✗ 写✗
  读写逐角色一致;超管走 material_list:* 通配分支、过滤整段跳过(已单独复核)。
2026-09-17 11:26:36 +08:00
2f658407c3 feat(db): 基础信息新增「采购链接」字段并注册权限
需求:物料主数据增加一个可直接跳转的补货地址(淘宝/1688 等),纳入字段权限管控。

★ 设计取舍:读写**共用同一个权限码** material_list:purchaseLink
  上一轮刚修完「写用 A 码、读用 B 码」造成的读写错位(附件备注),新字段刻意
  沿用 referencePrice 的既有模式 —— 一个码同时进 field_permissions.py(读过滤)
  与 base.py 的 field_to_perm(写过滤)。单码最大的好处是**结构上不可能错位**:
  能读必然能写、反之亦然,不会再有「填了看不到」或「看得到改不了」。
  若将来要求「能看不能改」,需拆两个码并同步改三处,届时一并回归验证。

★ 列类型用 text 而非 varchar(N):外链常带很长的查询串,截断后是打不开的地址,
  且截断是静默的 —— 用户只会觉得「链接坏了」。

默认授予 SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR / INBOUND(覆盖实际补货的人
与管理物料的人)。⚠ 这是本次取的默认值,业务可按需增删 —— 改 VALUES 再执行一次即可。

幂等;不含 psql 元命令,DataGrip 可直接整段执行;含核对段与回滚段。
2026-09-17 11:26:36 +08:00
2f7a81ff3d fix(material): 物料列表列加双重守卫,消除「有表头无数据」的空列
现象
----
收紧某字段读权限后,后端已把值抹成 null,但表格列照常渲染 —— 留下一列
全是「-」的空表头,白占屏幕宽度。反馈的「专业名称」即属此类。

两处前提与代码不符,先澄清
----
· 列展示设置里**已有**「专业名称」选项(list.vue:190),columns.commonName
  也**已在** columns 数组中(默认 visible: true)—— 无需补充。
  该选项与表格列都走 hasColPermission('commonName'),没有权限者看不到选项,
  与「没权限就看不见这列」的目标一致,并非缺陷。
· 真正的缺陷在同一处:**表格列只有 columns.X.visible,没有权限守卫**。
  16 个数据列里仅 isApprovalRequired 有(且用错了码)。

改动
----
一、给全部数据列补上 && hasColPermission('<key>')(两文件各 15 列;
    isApprovalRequired 原先写成 userStore.hasPermission('material_list:isApprovalRequired'),
    改为同一函数,避免复选框与表格列各用一套口径)。
二、修正 permissionMap 两处与后端读过滤的口径错位:
      isInspectionRequired: material_list:operation → material_list:isInspectionRequired
      isApprovalRequired:   material_list:operation → material_list:isApprovalRequired
    (以 app/utils/field_permissions.py 为准)

★ 为什么第 2 步是必须的:前端按 operation 判断会以为「有权限」,而后端其实
  按另一个码把字段抹成了 null —— 于是渲染出一列全是「-」的空表头。
  前端判定码必须与后端读过滤码**逐字段一致**,否则修了守卫也照样是空列。

验证
----
· 两个文件的数据列 100% 带权限守卫(grep 复核)
· 前端 permissionMap 与后端 STOCK_FIELD_RBAC_MAPPING 逐字段比对:
  16 项中 14 项完全一致;id / isEnabled 两项前端更严(后端为公开字段)——
  方向安全,不会产生空列
· 前端 vite build 通过
2026-09-17 11:20:59 +08:00
70071e111a fix(material): 附件备注输入框按写权限置灰,消除 UI 与后端口径撕裂
背景
----
读权限(material_list:files,6 角色)决定「产品图」整块是否显示;
但写权限已收紧到 material_list:remark_edit(3 角色)。若前端不区分,
入库员/出库员/销售会看到可输入的备注框,填完却被后端静默丢弃 ——
正是本次要消灭的「能填却存不进去」的撕裂。

改动
----
list.vue(基础信息)与 buyOdoo.vue(基础信息(Odoo))各新增 canEditRemark,
两个备注输入框绑定 :disabled="!canEditRemark",并把占位文案切换为
「无编辑权限,仅可查看」,避免用户对着灰框发懵。

三处口径必须一致,已交叉核对:
    前端 list.vue / buyOdoo.vue 的 canEditRemark
    后端 base.py 的 field_to_perm(写)
    后端 field_permissions.py 的映射(读)
2026-09-17 11:07:58 +08:00
fd084bf8fd fix(material): 附件备注纳入写权限管控,废止「不在映射中→默认允许」的兜底
问题
----
POST /inbound/base/ 与 PUT /inbound/base/<id> 的 field_to_perm 里**没有**
productImageRemark / manualLinkRemark,于是这两个字段落入
「不在映射中 → 默认允许」的兜底分支 —— 任何能调通该接口的角色都能改。
配合读侧引用幽灵权限码,形成「谁都能写、除超管没人能读」的错位。

改动
----
两处映射(创建 + 修改)同时显式补入:
    'productImageRemark': 'material_list:remark_edit',
    'manualLinkRemark':   'material_list:remark_edit',
无写权限者的请求不会携带该字段进入服务层 —— 是「丢弃本次修改」而非
「写成空值」,因此不会误清既有内容。

★ 超管不受影响:base.py 的 get_current_user_permissions() 对超管返回的
  硬编码列表以 'material_list:*' 开头,命中通配符分支后整段过滤被跳过。

验证(6 个角色 × 读写,12 项断言全通过)
    超管 / 主管 / 库管    → 读✓ 写✓
    入库 / 出库 / 销售    → 读✓ 写✗
2026-09-17 11:07:57 +08:00
6b7b174e3d fix(db): 补注册附件备注的读写权限码(修「写通读断」的权限错位)
根因(比上一轮报告的更深一层)
----
field_permissions.py 的读过滤要求两个权限码:
    material_list:productImageRemark / material_list:manualLinkRemark
而这两个码**在 sys_element 里根本不存在**,也从未授予任何角色 —— 是「幽灵权限」。
除 SUPER_ADMIN(走 material_list:* 通配符绕过)外,没有任何人能通过读过滤。
配合写侧的「不在映射中 → 默认允许」兜底,形成:
    谁都能写、除超管没人能读 —— 填了存进去了,回显却被抹成 null,
    看起来就是「保存不了」。

本次改动
----
一、读侧:注册这两个元素,授予与 material_list:files **完全相同的 6 个角色**
    (INBOUND / OUTBOUND / SALES / SUPERVISOR / WAREHOUSE_MGR / SUPER_ADMIN)
    依据:备注依附于图片,「能看图的人就应该能看备注」。
二、写侧:新建 material_list:remark_edit,只授予 3 个核心管理角色
    (SUPER_ADMIN / SUPERVISOR / WAREHOUSE_MGR)。
    ★ 为何不复用 material_list:operation:它授予了 5 个角色(含 INBOUND /
      OUTBOUND),比业务要求的 3 个更宽,达不到「只有核心管理角色可改」。
    ⚠ 该码只做精确匹配,**切勿用 @permission_required 包裹** ——
      _expand_operation_perms 会前缀桥接,把 material_list:operation 的持有者
      一并放行,等于把刚收紧的口子又捅开。已在迁移与代码注释中双处警示。

幂等,可重复执行;脚本文本不含 psql 元命令,DataGrip 可直接整段执行。
2026-09-17 11:07:50 +08:00
b26b124f8b chore(db): 部署脚本改用通用 SQL 核对段,兼容 DataGrip
psql 的 \echo 是元命令,DataGrip / DBeaver 不认,整份执行会直接报语法错误。
核对段改用 SELECT '常量' AS "核对项" 输出标签行,任何客户端都能跑
(psql 下效果相同)。同步修正头部说明中「需用 psql」的过期表述。

已在临时库重跑验证:零报错、核对结果与改前一致、重跑幂等。
2026-09-17 10:51:31 +08:00
d6234622e8 chore(db): 借库转交完整部署脚本(生产可执行)
把本轮 6 个迁移(phase4 / 4b / 4c / 4d / 4e / 4f)按依赖顺序合并为一份
可直接在生产执行的脚本,并在一个模拟「部署前状态」的临时库上完整验证。

内容
----
 1. trans_borrow 补 4 列(borrower_id / current_holder_id /
    current_holder_name / dispatch_operator)+ 2 索引
 2. trans_borrow_transfer 建表(最终形态)+ 7 索引
 3. trans_borrow_return 建表 + 3 索引
 4. 回填 trans_borrow 身份锚点(仅唯一命中者,重名/无法映射留 NULL)
 5. 回填遗留转交流水的 borrow_no 与状态
 6. 回填 reject_seen_at(存量拒收标记为已告知)
 7. 从 remark 拆出被拼接的 reject_reason
 8.(注释掉)borrow_transfer 权限码 —— 已无代码引用,默认不建
+ 执行后核对段 + 回滚段

★ 验证中发现并修掉两个真实缺陷(都不是「看起来能跑」能暴露的)
  1) 顺序缺陷:第 5 段原先把遗留流水**一律**标成 ACCEPTED,导致第 6/7 段
     按 status='REJECTED' 找行时一条都匹配不到(旧结构表里 status 是刚加的
     列、全是默认 PENDING)。真正的信号在备注的 '[拒绝原因]' 标记里,
     现据此还原真实状态。
  2) 孤儿流水:第 5 段按 borrow_id 关联,来源借用行已被删除的流水匹配不上,
     会永久停在 PENDING —— 在接收人那里变成谁也处理不掉的幽灵待办。
     已加兜底把「没有单号」的遗留行一律结掉。

★ 两处 ⚠ 警示已写入脚本:第 5/6/7 段设计为**新代码上线前执行一次**;
  若在功能已投产后重跑,会把当时真实的待接收/待告知记录误标。

验证方式:建临时库复刻部署前结构(旧 trans_borrow + 旧结构转交流水表 +
重名/无法映射/已归还/孤儿等边界数据),执行脚本后核对:DDL 与索引齐全、
身份锚点按唯一性正确回填、遗留流水状态从备注还原、原因正确拆出、
孤儿流水被结掉、无报错;再执行第二遍确认幂等(结果完全一致)。
2026-09-17 10:49:34 +08:00
1ebd06e68c style(borrow): 时间线转交节点去掉冗余的「发起人」
转交已收紧为「仅当前持有人本人可发起」,发起人恒等于该节点已显示的 from_name
(「A → B」)—— 同一人出现两次是纯冗余,且第二次还是完整 username 格式
(杜邢宸/duxingchen),与全站展示名口径不一致。去掉。

数据不受影响:operator_name 仍在流水里存档,仅不再展示 —— 审计追溯仍可查。

归还 / 报废节点的「经手库管」保持不变:那里确实存在独立的窗口经手人
(归还人是 returner_id,经手库管是 operator_name),措辞正确。
2026-09-17 10:47:25 +08:00
b5f96384e9 style(borrow): 流转时间线区分成败 —— 被拒转交红点 + 红标签 + 原因独立成行
问题(截图反馈)
----
被拒收的转交和正常转交一样是黄色节点、黄色「转交」标签,拒绝原因又粗暴地
拼在备注后面(「备注: 3333 [拒绝原因] 5555」)—— 完全看不出资产交接失败。

改动
----
一、节点颜色:被拒收的转交 el-timeline-item 的 type 设为 danger(红色圆点);
    待接收(PENDING)保持 warning(进行中,不是失败);
    其余沿用动作类型配色。
二、动作标签:被拒的显示红色「转交被拒」,不再显示黄色「转交」。
三、原因分行:转交备注灰色单独一行,拒收原因另起一行、红色加粗前缀,
    彻底解决文字挤在一起的问题。

顺带修正标签措辞
----
「经手库管」改为「发起人」:转交已收紧为「仅当前持有人本人可发起」,
该字段存的就是发起人本人,标成库管是错的。
(归还节点的「经手库管」保持不变 —— 那里确实是独立的窗口经手人。)

★ 一个可议之处:转交节点现在会同时显示「A → B」和「· 发起人:A」,
  同一个人出现两次(字段格式不同:一个展示名、一个完整 username)。
  若觉得冗余,可去掉后者 —— 但那属于展示取舍,未擅自决定。
2026-09-17 10:46:32 +08:00
681607bd43 feat(borrow): 拒收原因独立成列,与转交备注彻底分离
背景
----
拒收原因此前是**拼进 remark** 的:
    transfer.remark = f"{remark}\n[拒绝原因] {reason}"
前端拿到的是「3333\n[拒绝原因] 5555」这样一坨,时间线上两句挤在一起,
无法分辨哪句是发起备注、哪句是对方拒收的原因。

改动
----
· trans_borrow_transfer 新增 reject_reason text 列;
  reject_transfer 改为写入该列,不再拼进 remark。
· 存量按 '[拒绝原因] ' 标记切分回填(实测仅 #22:
  remark 3333 / reject_reason 5555)。
· 时间线事件带出 reject_reason,前端才能分行展示。

★ 为什么拆列而不是让前端解析字符串
  1) 拼接格式是隐式契约:改分隔符或加前缀,前端解析就静默失效且难排查;
  2) 用户完全可能在备注里自己打出 '[拒绝原因]' 字样,按标记切分必然误判 ——
     已加测试用例锁定该场景;
  3) 结构化字段才能参与查询与统计(如按拒收原因归类)。
  存储层能表达的东西,不该靠字符串约定去还原。

★ 一个迁移期踩到的坑:btrim 默认只去空格、不去换行。
  拼接留下的是 '3333\n',只写 btrim(x) 会残留换行;必须显式给出字符集
  btrim(x, E' \t\r\n')。已修正脚本并对存量做了一次清理。

验证(7 项断言全通过)
  备注不被污染、原因写独立列、无原因时为 None、
  用户备注含同名标记也不误判、库存零副作用、数据零残留。
2026-09-17 10:46:31 +08:00
f4f887c2b4 feat(borrow): 全局提醒支持双向 —— 发起方也能收到「转交被拒绝」
提醒组件从单向(待我接收)扩展为双向,一次轮询同时取回两类:

  ① 转交被拒绝 —— 我发起、对方拒收,物品责任仍在我手上
  ② 待我接收   —— 别人转给我、等我确认

弹窗(沿用中央 Modal + 遮罩,不进则已、进则打断):
  标题「转交被拒绝」,列出被拒物品(物料名 + 单号 + 接收人,超过 5 笔折叠计数),
  按钮【去处理】(跳借还记录) /【知道了】。

★ 优先级:拒绝提醒优先于待接收提醒,且**一次只弹一个弹窗**。
  前者是「责任已回到你手上」的状态变更,后者是「等你确认」的待办;
  本次弹了拒绝就直接 return,待接收那条留给下一轮(此时拒绝已 ack),
  避免两个 Modal 叠加打扰。

★ 两条退出路径都算「已知悉」并 ack —— 否则每次登录都会再弹同一条,
  从提醒退化成骚扰。ack 失败不阻断,下一轮还会再提醒(宁可多提醒一次,
  也不能漏)。

防叠加沿用上一轮的模块标志 + DOM 探测,标题白名单扩为两个。

顺带:流转明细时间线为转交节点补状态标签(已拒绝/待接收),被拒的转交
不再与成功的长得一模一样。

验证(node 复刻判定链)
  既有拒绝、又有 2 件待接收 → 弹[转交被拒绝];确认后 ack;
  下一轮 → 弹[待办通知] count=2;再轮询 → 不弹(未变)。
  拒绝优先、不叠加、ack 后待接收提醒正常补上。
2026-09-17 10:44:07 +08:00
f24797ff1f feat(borrow): 拒收须告知发起方(责任回到他手上,不能静默)
背景
----
双向握手补上了「接收人确认」,却只做了单向告知:接收人能看到待办,发起方却
对结果一无所知。**被拒绝时物品责任仍在发起方手上** —— 他若不主动查列表,
就会误以为已经交接出去,责任链出现静默断点。

(ACCEPTED 不需要告知:东西已经交出去了,发起方无需动作。)

改动
----
· trans_borrow_transfer 新增 reject_seen_at(NULL 且 REJECTED = 尚未告知)。
  ★ 为什么需要持久标记而不是前端去重:换台电脑、换个浏览器就会重新提醒;
    而这条信息的分量(责任归属)值得一个持久标记。
  ★ 存量已拒绝的流水一律标记为已告知:它们产生于本功能上线之前,
    追溯提醒只会打扰(实测仅 1 条:#22,验收时的测试数据)。
· get_unseen_rejects(user_id):返回「我发起、被拒、尚未告知我」的转交,
  并批量解析物料名 —— 只说「某笔转交被拒」发起方仍不知是哪件东西还在
  自己手上,必须让他一眼认出来。
· ack_rejects(user_id, ids):发起方确认后写 reject_seen_at,幂等。
· GET .../transfer/pending-count 的响应并入 rejects:与待接收数量共用同一次
  轮询,前端不必多打一个请求。
· POST .../transfer/reject-ack:无 permission_required,同 accept/reject。

顺带补一处同源显示缺口
----
流转时间线里,被拒绝的转交与成功的长得一模一样 —— 发起方翻记录时同样会
误判。现将转交状态一并带出时间线事件。

验证(15 项断言全通过)
----
发起方收到待告知的拒绝(含物料名/接收人/拒绝原因);接收人与无关人看不到;
ack 后不再提醒且幂等;ACCEPTED 不产生告知;None/非法 user_id 均安全返回;
库存零副作用、数据零残留。
2026-09-17 10:44:00 +08:00
b53e22f536 style(borrow): 待办提醒改为中央强弹窗(Modal + 遮罩)
背景
----
右上角 ElNotification 在仓库作业现场视觉提示太弱,极易被操作员忽略,
待接收的物品就一直在系统里悬着。

改动
----
ElNotification -> ElMessageBox.confirm:
· 屏幕正中 + 灰色半透明遮罩,强制打断注意力;
· 标题「待办通知:借库转交」,内容「您有 X 件物品等待接收确认,请及时处理。」;
· 按钮【去处理】/【稍后处理】;前者 router.push('/operation/records'),
  后者仅收起弹窗,不阻断当前工作;
· closeOnClickModal=false(点遮罩不关,避免误触即消失),
  但保留 showClose 与 Esc —— 强提醒不等于关不掉,用户始终有明确退出路径。

★ 防叠加(两道,模块级标志 + DOM 探测)
  reminderOpen 用**模块级**变量而非组件级:即便组件被重复挂载
  (HMR、多 Layout 实例)也能保证同一时刻只有一个提醒弹窗。
  另按标题探测屏幕上是否已有同名弹窗,覆盖标志失效的极端情况。

★ 一处必须修正的时序隐患:记录时机
  原逻辑是「先写 sessionStorage 已提醒数量,再弹窗」。加了并发防护后,
  弹窗可能被跳过(已有弹窗在屏),而数量却已记下 —— 数量不变 → 下次不再弹,
  **这条提醒被永久吞掉**。
  现改为「先弹,弹成功了才记账」:showReminder() 返回是否真的弹出,
  没弹出就不记账,留给下一轮轮询重试。

验证(node 复刻判定链,全场景通过)
  3 → 弹;仍 3 → 不弹;弹窗开着时变 4 → 不弹且不记账 →
  收起后轮询 4 → 弹;归零 → 清记录;新来 2 → 弹。
  弹窗序列 [3,4,2],无重复、无叠加;同一轮内并发两次 check 只弹一次。
2026-09-17 10:40:00 +08:00
c566d1b728 style(borrow): 借还记录表格响应式改造,消除横向滚动与操作列不可达
背景
----
主表共 11 列,原先除 2 列外用 width 硬编码,最小总宽约 1528px;
1440 笔记本可用宽度只有约 1240px(屏幕 - 180 侧边栏 - 20 内边距),
必然撑出横向滚动条,操作按钮被推出屏幕,用户得拖滚动条才能点转交。

一、固定操作列
----
主表操作列原本已有 fixed="right";**明细表的操作列没有**,本次补上。
子表嵌在主表展开区内,容器更窄(两侧各留 40px),不固定更容易被挤出屏幕。
操作列宽度 250 -> 230(实际按钮最多 3 个并存,230 足够)。

二、弹性列宽
----
按「内容长度是否确定」划界:
  用 min-width(可伸缩)—— 单号 150 / 借用人 90 / 当前持有人 120 /
                            归还人 90 / 归还时间·预计 200
  用 width(定长)    —— 借出时间 160 / 借出物品 80 / 状态 110 /
                            电子签名 100(原 140,两个小标签用不了那么宽)
  展开列本身约 48px,不可控
明细表同步:SKU 120 -> min-width 110;当前持有人 130 -> min-width 120。

主表最小总宽由约 1528px 降到 1378px。

三、长文本省略
----
物料名称、SKU、单号均已带 show-overflow-tooltip —— 超长物料名自动省略号,
悬浮显示全称,不会因单条数据撑爆整表。

★ 如实说明:11 列的表格在 1600px 以上屏幕可完整铺开;1440/1366 笔记本上
  仍会保留一条横向滚动条(最小 1378px vs 可用约 1240px),这是列数决定的,
  不删列就无法根除。但操作列两级都已吸附最右侧,**用户不再需要滚动即可操作** ——
  这正是本次要解决的核心痛点。若要做到零滚动条,需另议「窄屏隐藏次要列
  (电子签名/借出物品)」或侧边栏可折叠。
2026-09-17 10:35:11 +08:00
2c732a9a3a feat(borrow): 全局待办强提醒(接收人不再处于盲区)
新增无渲染组件 PendingTransferNotifier,挂在 Layout(路由切换常驻、且只在
已登录区域渲染,天然保证「有 token 才查」)。

UI
----
ElNotification,type=warning、duration=0(不自动关闭,须用户处理或手动点掉):
  标题:待办通知:借库转交
  内容:您有 X 件物品等待接收确认,请及时处理。【去处理 >】
点击【去处理】关闭通知并 router.push('/operation/records')(借还记录页)。
message 用 VNode 构造而非 dangerouslyUseHTMLString —— 不必把数量拼进 HTML 字符串。

防骚扰(两道)
----
· 会话级去重:sessionStorage 记「上次已提醒过的数量」,只有数量**变化**才再弹。
  用 sessionStorage 而非 Pinia —— 前者跨刷新存活,后者会重置,刷新即轰炸。
· 数量归零时清掉记录,下次新转交能重新提醒。

★ 加了 2 分钟低频轮询(超出需求所写,但需求目标需要它):
  需求只要求「初始化时查一次」,而 SPA 只在首次进入时初始化 —— 已打开页面的
  用户永远收不到提醒,与「第一时间响应」的目标相悖。有去重逻辑兜底,
  轮询不会造成重复打扰。不需要的话删掉定时器即可。

错误一律静默:提醒是锦上添花,不能因接口抖动弹错误框刷屏。

验证:去重状态机用 node 复刻验证 —— 3→2→1 各弹一次,刷新与轮询均不重复,
归零后再来新转交能重新提醒。
2026-09-17 10:30:13 +08:00
1c58789fd9 feat(borrow): 新增「待我接收」转交数量接口
GET /api/v1/transactions/borrow/transfer/pending-count -> { count: X }

用途:双向握手引入后,发起方提交了转交,接收人若不来借还记录页主动查看就
完全处于盲区 —— 物品挂着「待接收」,责任悬空。该接口供前端做全局强提醒。

设计
----
· 刻意做成极轻量:一次 count,不联表、不解析物料名。轮询接口必须便宜,
  否则会从「提醒」变成「后台噪音」。
· 无 permission_required:接收人可能是普通员工,待办提醒必须人人可见
  (与 accept/reject 同级 —— 员工处置自己名下资产,非库管职权)。
· user_id 为 None / 非法时返回 0 而不是抛错:提醒类接口不该因边界输入 500。

路由无冲突
----
「transfer」匹配不了 <int:borrow_id>,「pending-count」也匹配不了
<int:transfer_id>,Werkzeug 按转换器精确分派。实测:
  GET /borrow/transfer/pending-count -> 401(已注册且受 JWT 保护)
  GET /borrow/11/transfer            -> 405(路径命中但方法不符,证无冲突)

验证(9 项断言全通过)
----
发起后接收人计数 +1、非接收人不变;拒绝/接收后均回落;
None 与非法 user_id 返回 0 不报错;库存零副作用、数据零残留。
2026-09-17 10:30:07 +08:00
4bd6765ab4 feat(borrow): 转交发起收紧为「仅当前持有人本人」(责任链隔离)
背景
----
此前【转交】只要持有 borrow_transfer 权限就可见可调,与「当前持有人」无关 ——
任何库管都能把别人保管的资产转给第三方,责任链形同虚设。业务方确认改为
**只有该物品的当前持有人本人可以发起**。

改动(前后端同改,缺一不可)
----
· service.transfer_borrow 新增 caller_user_id,强校验其 == 该明细
  current_holder_id;传 None 一律拒绝,不做「系统内部调用」的隐式放行。
· get_records 为每条明细附加 can_transfer(当前持有人 == 我)—— 前端
  localStorage 里只有 username 没有 user_id,故与 is_mine 一样由后端判定。
· 前端明细行【转交】改判 can_transfer;主行【转交】改为「该单下存在由我持有
  的未还物品」时才出现;弹窗候选也过滤为「由我持有」,不是我的不列进来
  (后端会拒,列出来只会误导)。

★ 连带调整:移除 route 上的 permission_required('borrow_transfer')
  责任链规则既然是「持有人本人」,而持有人是普通员工、通常不持有库管权限,
  再加一道库管权限,实际能发起的人变成「持有人 ∩ 库管」,绝大多数持有人
  反而发不了 —— 功能形同虚设。这与 accept/reject 同级:员工处置自己名下资产。
  真正的边界是 service 层的 caller_user_id 强校验,不是界面遮挡。

  ⚠ 由此 borrow_transfer 权限码已无任何代码引用(sys_element 中的定义与
    4 个角色的授权仍在,属无害冗余)。若后续需要「管理员代办」入口,
    可在此基础上加豁免;若确定不需要,该权限码可择期下线。

验证(13 项断言全通过)
----
· 非持有人发起被拒;未传调用者被拒;持有人转给自己被拒
· 持有人本人发起成功,from_user_id 正确记为持有人
· can_transfer:持有人 True / 接收人 False;接收转移后新持有人变 True
· 接收环节不受影响;库存零副作用、数据零残留
2026-09-17 10:20:34 +08:00
5f0cdc3adb fix(borrow): 转交弹窗文案纠偏 + 表头一键全选
一、修掉弹窗内的过期文案(与「按件转交」自相矛盾)
----
顶部 Alert 仍写着上一版「整单转交」的描述,而下方的交互早已改为按件勾选。
现按业务方给的文案改为:
  标题:「按件转交,对方确认后责任正式转移」
  描述:「发起后将生成「待接收」记录,对方在系统中确认接收前,物品仍挂在您的名下。」
同时修掉同处一处过期注释(「整单转交 + 双向握手」)。

二、勾选改用 Element Plus 标准 selection 列
----
原先逐行手写 el-checkbox + computed 收集,**没有表头全选**。现改用
<el-table-column type="selection" width="55" :selectable="..." />:
· 表头自带一键全选 / 取消全选;
· selectable 钩子把「已有待接收」的明细排除在可选之外 ——
  表头全选也会自动跳过它们,不会出现「整列勾上却提交失败」;
· 选中状态交由 el-table 内部托管(@selection-change),不再与数据源字段
  两套并存导致不同步。

★ 预选通过表格 API(toggleRowSelection)在 nextTick 后写入,不直接改数据源:
  弹窗是 destroy-on-close,每次打开表格都是新建的,故每次都要重新预选。
2026-09-17 10:17:48 +08:00
1bb3f59bb7 feat(borrow): 前端适配明细行粒度转交(部分转交)
主行聚合
----
「当前持有人」列改为三态:
  · 单内只有 1 个持有人 → 显示该姓名(转过交时蓝色加粗)
  · 单内有多个持有人   → 灰色标签「多人持有」
  · 全部回库           → 「已回库」
★ 多持有人不再是异常:它是「借 2 件只转了 1 件」的正常形态。主行无法一一
  列清谁拿哪件,故只做提示,具体归属下沉到明细行。

明细行
----
· 新增「当前持有人」列:能看到**具体哪件物品在谁手里**,待接收的另挂
  「转交待确认」小标签。
· 新增操作列:【转交】(权限 borrow_transfer)、【接收】【拒绝】(待我接收时)——
  转交粒度既然下沉到明细,这里才是精准入口。

转交弹窗(主行/明细行共用)
----
· 由「整单转交」改为**勾选式**:列出该单未还明细,可勾选其中若干件一并转交,
  也可只转一件。已有待接收的明细置灰不可选。
· 从明细行进入时只预选该件;从主行进入时默认全选可转交项。
· 逐项发起、逐项成立流水:刻意不做「全部成功才算成功」的伪原子 ——
  物理上每件物品本就是独立交接的,部分成功同样有意义。失败项由拦截器
  提示原因,列表刷新后未成功的仍显示为可转交。

接收/拒绝
----
明细行按钮处理自己那一条;主行按钮聚合处理该单下所有属于我的待接收
(部分转交下单内可能有多项),并汇总提示「已接收 N/M 项」。
2026-09-17 10:12:36 +08:00
7d9cdeb297 feat(borrow): 转交粒度下沉到明细行,支持部分转交
背景(业务方推翻上一轮约束)
----
上一轮按「一张单同时只能有一个持有人」实现了**整单转交**,并把「单内出现多个
持有人」当作 bug 去修。业务方验收后明确纠正:

    物理现场经常只转交部分工具(借了 2 件、只把 1 件转给别人),
    单内多持有人才是符合现实的正常状态。

故转交粒度从 borrow_no 下沉回 trans_borrow.id(明细行)。

改动
----
· transfer_borrow:只操作传入的那**一行**明细,不再按单号整批覆盖。
  转出方 = 该行当前持有人;数量 = 该行待还量。
· accept_transfer:只转移 transfer.borrow_id 指向的那一行 ——
  整批改写会把别人手上的东西一并抢过来(部分转交下同单明细分属不同人)。
· 唯一性约束从「单号至多一条 PENDING」下沉为「明细行至多一条」:
  同单的其他明细可以同时各自挂着待接收,互不阻塞 —— 这正是部分转交的语义。
· get_records 的 pending_transfer 改按 borrow_id 关联(原按 borrow_no),
  否则同单多项待接收会互相覆盖。
· 删除已无用的 _load_slip_for_update。

★ 数量粒度:一行只支持**整行转交**。一行只能有一个 current_holder_id,
  「同一行只转一部分」需要把这行拆成两行 —— 经业务确认,现场场景中
  「借 2 件转 1 件」的两件本就是两条明细行,故该限制不影响实际使用;
  接口对传入的非整行数量会明确提示「应另立一条明细行」。

数据层
----
无需改表结构:borrow_id 本就是流水的关联列,borrow_no 退化为单据归属与
分组展示用。仅补 (borrow_id, status) 复合索引支撑新的查询路径。
存量撕裂数据(BOR-20260917-0001 的「测试 / 杜邢宸」)按业务方选择**保留不动**
—— 它现在不再是 bug,而是部分转交的正常形态。

验证(合成 2 明细单,21 项断言全通过)
----
· 只转工具A:工具B 完全不受影响
· 同一张单可同时挂两条待接收,互不阻塞;同一明细重复发起被拒
· accept 工具A 后:A→测试,B 仍是杜邢宸(单内两个持有人)
· 两个持有人、以及待接收人,三方各自都能在列表中看到该单
· pending_transfer 挂在正确的明细行上,is_mine 判定正确
· reject 后主表持有人不变;非整行数量被拒并提示拆行
· 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:12:31 +08:00
263f7ba3a1 feat(borrow): 前端适配整单转交与双向握手
· 转交弹窗由「选择明细」改为**整单**:列出该单全部未还物品(只读),
  明确告知「发起后对方确认,责任才转移」。原先让用户挑一行,挑中就只转
  那一行 —— 正是单内撕裂的入口,现从 UI 上根除。
· 操作栏新增【接收转交】【拒绝】:仅对 pending_transfer.is_mine 的行显示,
  即「待我接收」的转交。已有 PENDING 时不再显示【转交】,避免重复发起。
· 状态列新增「转交待确认」:此时主表 current_holder 未变(东西还在原持有人
  手上),必须用状态列把「责任正在转移中」显式表达出来,否则界面上看不出
  任何变化。
· 新增 acceptBorrowTransfer / rejectBorrowTransfer API;
  拒绝时用 prompt 收集可选原因,写入转交流水备注。

错误提示仍交由 request 拦截器统一 toast,不在本地重复弹出。
2026-09-17 10:04:16 +08:00
1a8e3e3dc0 feat(borrow): 转交改为整单覆盖 + 双向握手,并修复接收人可见性
一、整单覆盖(修复漏行 / 单内撕裂)
----
transfer_borrow 现在按 borrow_no 定位**整张单**,覆盖全部未还明细;accept 时
整批转移持有权。原先只改传入的那一行,2 明细的单转完会出现两个持有人。

新增 _load_slip_for_update():按单号锁整单,且按 id 升序取行锁 —— 并发下所有
事务以相同顺序加锁,避免与归还/转交交叉加锁死锁。

二、双向握手
----
· transfer_borrow(发起):只落一条 PENDING 流水,**不再改主表 current_holder**。
  东西还没到对方手上,责任仍归原持有人 —— 这是与旧实现最本质的区别。
  同一单号已有 PENDING 时拒绝再次发起,避免两个接收人争抢同一批实物。
· accept_transfer:流水置 ACCEPTED,把该单**全部未还明细**的持有人改为接收人。
. reject_transfer:流水置 REJECTED,主表不动。
  两者都强校验「当前登录人 == to_user_id 本人」。

★ accept/reject 刻意**不加 permission_required**:这不是库管职权,而是员工对
  自己名下资产的确认动作,加库管权限会把接收人挡在门外。

三、接收人可见性(OR 过滤)
----
get_records 普通用户过滤原先只比对 borrower_name,接收人在自己的列表里看不到
已经接收的东西。现改为三种关系任一成立:
    ① 我是借用人
    ② 我是**当前持有人**(转交接收后)
    ③ 有一条**待我接收**的 PENDING 转交 —— 东西还在对方手上、主表尚未转移,
       ② 匹配不到,必须单独并入,否则接收人看不到待办、无从确认
ID 与姓名双口径并存,兼容只有姓名没有 ID 的历史行。

列表项附加 pending_transfer(含后端判定的 is_mine)—— 前端 localStorage 里
只有 username 没有 user_id,靠姓名比对既有歧义又不可靠,故由后端标记。

四、验证(合成 2 明细单,25 项断言全通过)
----
· 发起后两条明细持有人均未变(责任未转移)
· 非接收人无法 accept / reject;重复发起被拒
· ★ accept 后**两条明细**持有人一并转移(漏行修复的核心)
· 接收前凭 PENDING 分支可见、接收后凭 current_holder 可见
· reject 后主表持有人不变
· 全程 available_quantity 无变化,库存精确还原、零残留数据
2026-09-17 10:04:12 +08:00
b271ca3a49 feat(borrow): 转交流水表补 borrow_no 与 status(双向握手数据层)
背景
----
1) **单内撕裂**:原 /transfer 收的是**明细行 ID**,只更新那一行。一张单有
   2 条明细时,转交后一条归接收人、另一条仍是原借用人 —— 前端按 borrow_no
   聚合便同时显示两个名字(实测 BOR-20260917-0001:108→测试 / 109→杜邢宸)。
2) **单向强塞**:原实现发起即生效,接收人在毫不知情的情况下背上资产责任。

本次改动
----
· 新增 borrow_no —— 单据身份。一次转交要覆盖该单**多行**,单行 ID 表达不了
  覆盖范围;accept 时据此批量更新。borrow_id 保留为「发起时的代表明细」供追溯。
· 新增 status —— PENDING / ACCEPTED / REJECTED 状态机。
· 存量 1 行按旧语义(发起即生效)标记为 ACCEPTED 并回填 borrow_no:
  它事实上已经生效,若标 PENDING,接收人会收到一条早已生效的待办。
· 建 borrow_no / status / (to_user_id,status) 索引,后者支撑「待我接收」查询。

同单号同一时刻只允许一条 PENDING(应用层强制),避免两个接收人争抢同一批实物。
2026-09-17 10:04:05 +08:00
0eca1805b6 style(borrow): 当前持有人列改为常亮蓝色,转交时加浅蓝底与细边框
背景
----
上一轮按「只有发生过转交才高亮」实现后,业务方反馈「当前持有人那里也应该
高亮才对」—— 该列回答的是「东西现在在谁手上」,比「当初谁借的」更需要一眼
看到,不该只在转交时才着色。本轮按新要求调整。

改动
----
· 基础态:当前持有人一律主题色加粗(不再区分是否转交);
· 转交态:额外加浅蓝底 + 细边框(--el-color-primary-light-9 / light-5),
  强调「经手人已变更,归还时必须由他本人来还」。
  用浅蓝而非实心色块,保持上一轮确立的降噪基调,只做一层轻标记。

覆盖三处「当前持有人」:借还记录列表、还库页、转交弹窗(弹窗按单条明细比对,
故新增 isRowTransferred;组行仍用 currentHolderList)。

★ 数据侧的事实(供验收参考):trans_borrow_transfer 目前 0 笔,34 条未还
  记录的「当前持有人 = 借用人」、53 条已还记录的持有人为空。
  也就是说转交态(浅蓝框)在产生第一笔真实转交前不会出现。
2026-09-17 09:53:31 +08:00
15d0c6b97a fix(borrow): 修复借还记录排序被静默丢弃,无限期改按借出时间从近到远
现象
----
借还记录列表的排序看起来毫无规律:无限期单排在有限期前面,有限期内
10-01 排在 11-01 之后。业务方反馈「不是逾期的、剩余天数最近的排前面吗?」

根因
----
三级复合排序(trans_service get_records 步骤 2)算得完全正确,但**结果被
后面一步覆盖**:

    # 步骤 2:算出分页用的 page_borrow_nos(顺序正确)
    # 步骤 3:再按集合把明细拉回来 ——
    detail_records = TransBorrow.query.filter(borrow_no.in_(page_borrow_nos))
                          .order_by(TransBorrow.borrow_no.asc(), ...)

单号形如 BOR-YYYYMMDD-NNNN,**它的字母序恰好等于借出日期序**。于是这 10 条
明细被重排成「按借出日期升序」,那份精心设计的排序被整套丢弃。

实测(修复前,未归还页签第 1 页):
    1 BOR-20260413-0001  无限期  04-13   ← 无限期在最前
    4 BOR-20260611-0001  无限期  06-11
    5 BOR-20260903-0010  逾期    09-10   ← 逾期单反而最后
    8 BOR-20260904-0005  10-01            ← 10-01 排在 11-01 之后

★ 该功能自上线起从未生效:
    1450e6c (06-16) 引入按 borrow_no 重排的明细拉取
    73510d3 (09-04) 才加入三级复合排序 —— 加在了被覆盖的路径上,
                    提交信息「借还记录默认排序重构」名存实亡。

修复
----
按 page_borrow_nos 的顺序还原输出(明细内部仍按 id 升序,即扫码顺序)。
同时按业务方要求调整第二梯队方向:

  ① 有限期单在前(有任何明细含预计归还时间)
  ② 有限期内按单内最早预计归还时间**升序** —— 逾期优先,其后剩余天数由近到远
  ③ 无限期内按单内最早借出时间**降序**(从近到远)
     ★ 原为升序「借出越久越靠前,暴露呆滞借用」,业务方明确要求反转

修复后实测(未归还页签):
    有限期 09-10(逾期7天) → 09-11(逾期6天) → 09-15(逾期2天) → 10-01 → 11-01 → 11-27
    无限期 09-17 → 09-14 → 09-11 → 09-10 → … → 04-13(跨页连续)

验证:borrowed / returned 两个页签各 3 页顺序全部核对通过;关键词、物料名、
高级筛选、日期范围、空结果六条过滤路径冒烟通过;同单号明细未被跨单号打散。
2026-09-17 09:53:26 +08:00
b1f39acb97 style(borrow): 持有人高亮统一为条件蓝色,姓名按两字补全角空格等宽
一、当前持有人高亮策略统一
----
还库页(return.vue)该列原为橙色标签(type="warning"),与列表页在上一轮
已完成的条件高亮不一致。现统一为:
  · 当前持有人 ≠ 借用人(确曾转交)→ 主题色 var(--el-color-primary) 加粗
  · 两者同一人(常态)→ 普通深色文本,不做任何高亮

比对前把两侧都归一化到「斜杠前段」:历史数据可能混入「姓名/拼音」的完整
username 写法,不归一化会把同一人误判成转交,让整列无故飘蓝。

(records.vue 该列在 ba5394f 已是此策略,本轮仅同步调用 formatName。)

二、姓名两字补全角空格,实现等宽对齐
----
新增 formatName()(放在 src/utils/format.ts,而非各组件内复制 —— 列表页与
还库页都要用,规则改动只需改一处):

  '高雪'     -> '高 雪'    U+3000 全角空格,占一个汉字宽
  '郭俊玥'   -> '郭俊玥'
  '欧阳娜娜' -> '欧阳娜娜'
  'AB'       -> 'AB'        纯拉丁不补,否则渲染出 'A B' 怪相
  null/'测试01'/混合字符 -> 原样

★ 纯展示层:只在模板 {{ formatName(row.borrower_name) }} 中调用,
  row.borrower_name 等底层对象**一个字节都没动**。
  姓名是转交/归还责任链的匹配键(records.vue 的 currentHolderList、
  return.vue 的 isTransferred),往数据里插不可见字符会让比对、检索、
  导出、复制全部带上它 —— 这正是上一轮拒绝「插空格」的原因,本轮改用
  渲染期格式化,视觉要求与数据纯洁性同时满足。

★ 为什么用 U+3000 而非普通空格:普通空格宽度随字体浮动(约半个汉字),
  补进去反而难对齐;U+3000 稳定占一个汉字宽,与「1 汉字 ≈ 1em」一致。

三、配套
----
· 归还人 / 当前持有人 / 借用人三列(含转交弹窗的持有人列)统一走 formatName;
· .name-fixed 增加 margin-right,避免同一格内多个姓名(同一单持有人/归还人
  不同)紧贴成「高 雪张 三」。
2026-09-17 09:45:34 +08:00
ba5394f70c style(borrow): 借还记录页视觉降噪、移除前端假排序、主行改按整单汇总
一、视觉降噪
----
· 「当前持有人」由橙色标签改为纯文本:
    未转交 → 普通文本;发生过转交(当前持有人 ≠ 借用人)→ 主题色加粗。
  原先一律用色块,一屏几十个标签会把真正需要关注的「已转交」淹没。
· 「无限期」由蓝色标签改为灰色小字:它不是异常,不该和红色的「已逾期」抢
  视觉重心。(沿用系统既有术语「无限期」,与借出页「无限期/长期借用」一致)
· 转交弹窗内的持有人列同步降噪,保持同一表达。

二、移除前端假排序
----
「借出时间」列的 sortable 是 Element Plus 的**前端排序**,只会重排当前页的
10 条,反而打乱后端按单号聚合的「逾期优先」排序(有限期在前、按最早应还
升序、无限期按最早借出升序)。已移除。若将来需要按其他字段排序,应改用
sortable="custom" 并由后端接管。

三、主行改按整单汇总
----
主行字段原样取「首条明细」(groupMap 首次遇到即定型),多明细单会失真。
现对四项做整单聚合:
  · 状态     —— 全部报废→已报废;全部结清→已还;仍有未还且已还过一部分→
                部分归还;全部未还→未还。
                修复「首条已还清、其余未还 → 主行显示已还,却仍出现在未归还
                页签」的口径矛盾(该页签由后端按整单聚合过滤)。
  · 归还人   —— 去重汇总(原只显示首条明细的经手人)
  · 归还时间 —— 取**最晚**(整单结清的那一刻;原显示最早那笔)
  · 应还时间 —— 取**最早**(与后端排序键 min(expected_return_time) 对齐)

四、姓名定宽对齐
----
中文 1 字 ≈ 1em,故姓名统一占 3em(.name-fixed):「石利」与「郭俊玥」占同样
横向空间,一列名字纵向对齐。
★ 刻意用 CSS 而非往数据里插全角空格:插字符会污染真实数据(复制、导出、检索、
  姓名比对全带着它),而转交/归还的责任链恰恰依赖姓名比对。

★ 如实说明:上述聚合里,状态/归还人/应还时间三项在当前数据上**尚无可见变化**
  —— 实测单号内状态完全一致、归还人同为一人、应还时间因同批发货天然相同。
  它们是防住「部分归还 + 多明细」组合的前瞻性修正。真正改变显示的只有「归还
  时间取最晚」:BOR-20260616-0001(21 条明细、21 个不同归还时间)等 2 张单。
2026-09-17 09:38:19 +08:00
8d83d4404e fix(borrow): 恢复选中审批单后的「一键带出」,并补上领用人自动回填
现象
----
借库执行页选中一张已通过的审批单后,下方的「预计归还日期」「备注说明」
不再自动带出;「领用人」在改为用户下拉后也恒为空,库管每次都得手工补填。

根因(不在前端下拉框,而在后端持久化)
----
borrow_service.submit_approval 会把 reserve_for_items() 的返回值直接
set_items() 落库 —— 即审批单的 items_json **就是预占结果**。

而 reserve_for_items 内部是 `reserved.append({...})` **重建**字典,只保留
库存定位与数量,把申请端按明细传上来的 expected_return_time / is_indefinite
全部丢弃。执行页读的正是 firstItem.expected_return_time,读不到就落到
「清空」分支。

这是 2026-09-10 库存预占改造引入的回退:

    b57c21a  fix 前:  approval.set_items(items)           # 原始申请明细
    b57c21a  fix 后:  approval.set_items(reserved_items)  # 重建字典,字段丢失

数据完全吻合该提交的上线时刻(09-10 14:59):
    最后一张「有日期」的审批单  id=40  创建于 09-10 09:30
    第一张「丢失日期」的审批单 id=41  创建于 09-10 16:20

修复
----
1) inventory_reservation.reserve_for_items:透传申请「意图字段」
   (expected_return_time / is_indefinite / remark)到预占结果。
   · 一条申请明细会被拆到多个批次行(同物料多批次),故按 base_id 建索引,
     把同一份意图回填到它拆分出的每一行;
   · **只透传意图,不透传库存定位与数量** —— 实扫可能换批次,
     这里写什么执行页就按什么释放,绝不能覆盖分配结果;
   · 跳过 None:无限期借用提交的 expected_return_time 本就是 null。

2) borrow.vue handleApprovalChange:实现领用人自动回填
   · 口径差异:审批单上的 borrower_name 是**完整 username**
     (「杜邢宸/duxingchen」),人员名单返回的是展示名(「杜邢宸」)。
     两边归一化到「斜杠前段」再比对,否则永远匹配不上;
   · borrower_name 优先,匹配不到才回退 applicant_id —— 库管代建时申请人是
     库管本人,回退到它会选错人;
   · 名单加载改为可重复 await:选中单据时要拿它反查,名单没回来就比对会
     误判为「找不到」而清空;
   · 匹配不到时保持未选,不回退到自由文本 —— 借用人 ID 是转交/归还责任链的
     唯一锚点,宁可让库管手选也不能猜。

可编辑性
----
三个字段均保持可改:领用人下拉、备注文本框无 disabled;日期选择器仅在勾选
「无限期/长期借用」时置灰(原有语义,取消勾选即可重新填写)。

影响与验证
----
· 存量:仅 1 张待执行审批单(id=46)受影响,其日期在库中已无任何留存,
  无法回填,需库管手工补一次;已完结单据不受影响。
· 回归:reserve_for_items 透传 9 项断言、submit_approval 端到端 5 项断言
  全部通过;库存精确还原、无残留数据。
2026-09-17 09:26:42 +08:00
ece5dd8d72 feat(borrow): 前端转交/流转明细 UI 与借用人选人适配
转交 UI(借还记录页)
----
· 操作列新增【转交】,v-permission="'borrow_transfer'" + 按钮 loading 防抖
· 弹窗明示「仅支持整单全部转交」与「转交后须由接收人本人归还」,并在确认框
  复述接收人与转交数量
· 列表是 borrow_no 主子表结构,trans_borrow.id 在**明细行**上,而一张单的明细
  可各有不同持有人(转交是逐条明细进行的),故弹窗先让用户选定明细;单条明细
  时自动选中(实测 54/60 的单号只有 1 条,等于零额外点击)。
  该「单号按钮 → 弹窗选明细」范式沿用本页既有的「申请报废」。

流转明细时间线
----
· 操作列新增【流转记录】,无特殊权限要求(页面本身已由 op_records 把守)
· Drawer + el-timeline 倒序展示 借出 → 转交(可多次) → 归还 → 报废,
  每节点显示时间、动作、当事人(借用人 / 转出→接收 / 实际归还人)与经手库管,
  转交备注一并展示
· 主列表新增「当前持有人」列:转交后可能与「借用人」不是同一人;一张单的明细
  持有人可能不同,去重后逐个展示,已全部回库显示「已回库」

身份锚点适配
----
· 借出页:借用人由「自由输入姓名」改为「用户下拉」,提交 borrower_id
  (姓名无法唯一锚定一个人,重名即责任链断裂)
· 归还页:新增「实际归还人」下拉,默认取首件物品的当前持有人,提交 returner_id;
  主表新增「当前持有人」列提示库管该由谁来还
· 错误提示不再本地重复弹出 —— request 拦截器已按后端 msg 全局 toast,
  再弹一次会双份

验证:vite build 通过;接口契约经后端回归用例校验。
2026-09-17 09:18:06 +08:00
f7c49f41a8 fix(borrow): 统一归还/报废时间口径为北京时间,并修正过期注释
时间口径(与归还流水同时引入,故随本轮一并修)
----
process_return 与 scrap_sources.deduct 原用 datetime.now() 写 return_time,
而 datetime.now() 取的是**容器本地时间**(Docker 下为 UTC);同一行的
borrow_time 却由 beijing_time() 写入 —— 两个字段差 8 小时,台账时间线
自相矛盾,并会让新做的流转时间线出现「先借出、后归还,却显示归还更早」
的倒序假象。

统一改用 beijing_time(),并与新增的归还流水共用同一个时间戳,保证主表快照
与流水逐笔完全对齐。

(与 db_migrations/unify_approval_timezone.sql 处理的是同一类问题:aware/naive
与本地/北京时间的口径混用。)

注释修正
----
borrow_service.submit_approval 的 docstring 写着「仅存储意向,不扣库存」,
但 Phase 1 起该函数已调用 reserve_for_items() 预占库存(扣 available_quantity)。
过期注释会误导后续开发者(尤其是做转交的人以为库存没动过),已改写为实际的
预占语义与三种释放路径(驳回 / 撤回 / 扫码执行)。
2026-09-17 09:18:02 +08:00
cccd6f6081 feat(borrow): 转交接口、身份ID锚点与流转时间线后端
责任链收口
----
· execute_dispatch:强制 borrower_id,姓名一律由 sys_user 反查,**不回退**到
  申请单姓名 —— 申请单上的姓名是「申请意向」,与扫码时实际来领的人本就可能
  不同(库管代建场景尤甚),回退会让 current_holder 从第一刻就记错人。
  同时落库 dispatch_operator,补齐「谁经手发货」。
· process_return:新增 returner_id,记录有 current_holder_id 时强校验二者相等,
  不符即整单回滚(原实现只要持有 op_return:operation 即可归还任何人借出的
  物品,函数内从不读取 borrower_name,归还环节责任链是断的);
  每次归还写 trans_borrow_return 流水;全量归还清空 current_holder
  (borrower_id 保留作历史)。
· transfer_borrow(新):整单全量转交,写转交流水 + 推进主表 current_holder。

为什么一期只允许整单全量转交
----
trans_borrow 是单行模型,只能存一个 current_holder_id。部分转交(借 10 转 5)
会让这一行同时表示两个人,语义直接撕裂,且归还时无法判定该由谁还。
若未来需支持,必须改为按数量**拆行**(新建一条承接转出量、原行扣减),
而不是在单行上加字段打补丁。

为什么转交绝不触碰库存
----
借出期间 available 已冻结、stock 仍含借出未还量(deduct_stock=False)。
转交若动库存,会同时破坏两个既有假设:「归还只加 available」会算多,
「借库转报废在确认损失时扣 stock」(scrap_sources.BorrowScrapAdapter)
会重复扣减。

接口
----
· POST /borrow/<id>/transfer      转交(borrow_transfer 权限 + 防抖锁 + 行锁)
· GET  /borrow/<id>/history       单品流转历史(供精确追溯)
· GET  /borrow/slip/<no>/history  整单时间线(实测单号最多 21 条明细,
                                  逐条调用会产生 21 个请求,故聚合返回)
· GET  /borrow/users              人员名单(借出/转交/归还共用,公司隔离)

一个隐蔽缺陷(本轮发现并修复)
----
整单时间线初版按展示用的时间字符串排序,而该字符串截断到**秒** —— 同一秒内的
连续动作(如「转交 A→B 紧接着转交 B→C」)会退化为并列,顺序取决于数据库返回
顺序,时间线随机错乱。已改为按真实 datetime 排序,并加回归用例锁定。
2026-09-17 09:17:57 +08:00
b998b00889 feat(borrow): 借库转交一期数据层(迁移、转交/归还流水表、发货操作人列)
背景
----
原 trans_borrow 是单行记录模式,身份维度只有 borrower_name 一个字符串:
「谁借的」与「谁现在还拿着」是同一个字段,无法表达持有权变更;归还时
return_time/return_operator/return_signature 被逐次覆盖,部分归还下
「谁在什么时候还了多少」永久丢失。

本次改动(全部增量,无破坏性 DDL,可回滚)
----
1) trans_borrow 补列
   · borrower_id / current_holder_id / current_holder_name —— 身份锚点
   · dispatch_operator —— 执行借出的库管(operator_name 形参此前被接收却从未落库)
2) 新建 trans_borrow_transfer —— 转交流水,一行=一次转交,不做覆盖式更新
3) 新建 trans_borrow_return   —— 归还流水,逐次记录,根治部分归还失忆症
4) 历史回填(已执行):85 行中 84 行 borrower_id 唯一命中 sys_user;
   未归还的 32 行全部绑定 current_holder
5) 注册 borrow_transfer 权限码(无冒号形式,避免 _expand_operation_perms
   前缀桥接把权限放大给所有持有 op_borrow:operation 的角色)

设计取舍
----
· current_holder 只回填「未归还」行:已归还=物品已回库、无人持有,保持 NULL。
  这样「current_holder_id IS NOT NULL」本身就是「仍在某人手上」的有效信号,
  归还校验不会对已结清单据误触发。
· 三张表都不建外键:与 trans_return / trans_defective_goods 一致 ——
  库存行会被入库模块物理删除(实测已有悬空),台账必须能独立存活。
· dispatch_operator 不回填历史:执行人信息从未被采集,系统中不存在可回填的
  数据源;用申请人或借用人冒充实物交接人比留空更危险。

验证:迁移已对 inventory_db 执行,核对段全部通过。
2026-09-17 09:17:51 +08:00
30 changed files with 4055 additions and 113 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -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

View File

@ -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'
}

View File

@ -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})

View File

@ -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),
# 强制质检标记

View File

@ -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'

View File

@ -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

View File

@ -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'])

View File

@ -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

View File

@ -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) 扣总库存(该物品确认损失);可用量已在借出时冻结,不重复扣

View File

@ -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,

View File

@ -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",

View File

@ -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>

View File

@ -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'
})
}
/**
* 提交「借出未归还」的**报废申请**(需审批人审批,通过后由库管执行报废)。
*

View 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>

View File

@ -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>

View File

@ -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
}

View File

@ -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; }

View File

@ -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];

View File

@ -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(() => {

View File

@ -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>

View File

@ -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>