Commit Graph

1064 Commits

Author SHA1 Message Date
1b6a6f5c19 fix(return): 还库签名文案统一为「领用人」,消除与页面标题的口径冲突
同一签名位的四处文案不一致:页面标题(第 7 行)写「领用人签字」,
而正文提示(125 行)与校验报错(391 行)写「库管员」、占位文案
(158 行)写「库管签名」,用户无法判断该谁签。

全部统一为「领用人」。其中第 125 行原为「签字确认入库」,与本节
分节标题「还库确认」自身矛盾,一并改为「签字确认还库」。
2026-09-18 10:15:18 +08:00
b4e43d641a V3.84,9.18推送 2026-09-18 10:10:12 +08:00
ac22c9959b feat(material): 基础信息(Odoo)补回「出库/借库需审批」列
buyOdoo.vue 是 list.vue 的手工副本。93f1497 / 4146f35 给 list.vue 加了
「出库/借库需审批」列,未同步到 Odoo 页 —— isApprovalRequired 在
list.vue 出现 9 次,在 buyOdoo.vue 为 0 次,该页既看不到也改不了这个开关。

补上表格列(带切换开关与操作权限守卫)、列显示开关、columns 与
permissionMap 条目、toggleApprovalRequired 函数及 batchSetApprovalRequired
接口引用。
2026-09-18 10:07:27 +08:00
825c088e98 feat(material): 基础信息(Odoo)补回「发起采购申请」入口
buyOdoo.vue 是 list.vue 的手工副本。31903bd 给 list.vue 弹窗加了
「发起采购申请」,未同步到 Odoo 页,两个页面的编辑弹窗头部不一致。

补上弹窗头部链接、createPurchaseForMaterial 函数及 ShoppingCart 图标。
2026-09-18 10:07:24 +08:00
f2bb95becc fix(material): 基础信息(Odoo)补回表单禁用权限守卫
buyOdoo.vue 是 list.vue 的手工副本。68fd93b 给 list.vue 加了 formDisabled
(无 material_list:operation 权限时整个表单只读),未同步到 Odoo 页。

列表页的「编辑/新增」按钮虽有权限守卫,但 URL 深链 ?edit_id= 不受守卫,
可直接打开 Odoo 页弹窗:字段能改、确定能点,提交后才被后端
@permission_required('material_list:operation') 拦下,用户白填一遍。

补上 formDisabled computed 及表单、确定按钮两处绑定,与 list.vue 对齐。
2026-09-18 10:07:21 +08:00
243355bc68 fix(material): 基础信息(Odoo)补回参考价格脏检查白名单
buyOdoo.vue 是 list.vue 的手工副本。8791914 修复「参考价格不可改」时只改了
list.vue,同一个 bug 在 Odoo 页存活至今:

- 只改参考价格:payload 无变更,提示「没有检测到数据变更,无需保存」
- 连带改其他字段:提示「修改成功」,但 payload 不含 referencePrice,价格未入库

compareFields 补上 referencePrice,与 list.vue 对齐。
2026-09-18 10:07:18 +08:00
eafdf992c7 fix(borrow): 借还记录「归还人」列错标成了经手库管,拆成两列
问题
----
列表里那一列标着「归还人」,读的却是 trans_borrow.return_operator —— 而该字段
存的是**办理还库的库管**,不是来还东西的人。两者本就是不同的人:
  · returner_id(trans_borrow_return)—— 把东西交回窗口的人,已校验 == 当时持有人
  · return_operator                    —— 经手办理的库管
实测(借用行 120):return_operator = 杜邢宸/duxingchen(库管),
而实际归还人是 returner_id = 21(测试)—— 页面却显示成了「杜邢宸」。

改动
----
一、后端 get_records 增补 returners:从 trans_borrow_return 取 returner_id 并
    反查 sys_user 得到姓名(去重按明细挂回)。批量查一次,不做 N+1。
二、前端拆成两列,各自名副其实:
      「归还人」   ← returners(后端新增)
      「经手库管」 ← return_operators(原列改为正确标签)
三、顺带修展示口径:return_operator 存的是**完整 username**(高闯/gaochuang),
    未按全站口径截断。_display_borrow_operator 现统一归一到展示名
    (数字 id 反查 / 姓名、斜杠前段 / 已是展示名 原样),并修正其 docstring
    —— 它原本也把该字段称作「归还人」,是同一个误解的源头。

★ 历史数据的现实
  实际归还人流水是二期才建的,**历史归还没有这个记录**。这部分行的「归还人」
  显示为空并挂 tooltip 说明「该笔归还发生在实际归还人记录上线之前」——
  刻意不拿库管的名字顶上,那正是本次要修的错。

验证
  BOR-20260918-0001 → 归还人=['测试']、经手库管='杜邢宸'  ✓ 两者分开
  BOR-20260914-0001 → 归还人=[](历史)、经手库管='高闯'    ✓
  归一化:'高闯/gaochuang'→'高闯'、'21'→'测试'             ✓
  前端 vite build 通过;本次无需 DB 迁移。
2026-09-18 09:43:25 +08:00
e3fe1fc12a feat(borrow): 借还记录「已归还」页签改为按归还时间倒序
问题
----
三个页签共用同一套排序(有限期单在前 → 最早应还时间 ASC → 最早借出时间 DESC),
这套「优先关注快到期/逾期」的逻辑对「未归还」是对的,但对「已归还」正好**
反了**:已归还列表要回答的是「最近还了哪几笔」,而按应还时间排会让最近刚还的
那几笔排到最后。

改动
----
order_subq 增加 max_return_time(单号内**最晚**一次归还时间)作为排序键,
并按页签分流:
  · 已归还     → nullslast(max_return_time DESC),borrow_no DESC 兜底保证稳定
  · 全部/未归还 → 原三级排序不变

★ 为什么取「最晚」而不是「最早」一次归还时间
  多明细分批归还时,整单结清的那一刻才是有意义的节点;且与主行「归还时间」列
  的展示口径一致(前端同样取 latest),排序依据与可见值不会打架。

验证(真实数据)
  已归还页签:09-15 11:41 → 09-14 16:02 → 09-09 11:46 → 09-08 13:22 →
              09-04 17:26 → 09-04 09:45 → 09-03 15:25,第 2 页续 08-27 → …,
              严格递减且**跨页连续**;
  未归还页签:排序与改动前完全一致(回归确认)。
2026-09-18 09:34:45 +08:00
ccdd6e7306 fix(purchase): 商家地址链接放宽为 text,修超长链接保存失败
现象
----
采购管理新建采购申请时粘贴电商商品链接报错。实测该链接 **516 字符**,而
purchase_request.supplier_link 是 varchar(500) —— 超出 16 个字符,
PostgreSQL 直接拒绝(value too long for type character varying(500))。

★ 为什么用 text 而不是把 500 调大
  电商外链(淘宝/1688)普遍带很长的跟踪参数,长度没有稳定上界:本例已 516,
  再叠加一轮营销参数就可能破千。任何有限的 varchar(N) 都只是把报错往后推,
  而且**截断是静默的** —— 用户只会觉得「链接打不开」。
  同 material_base.purchase_link 的处理(那边一开始就用 text)。

附带确认(全部扫描,无需改动)
  从数据库层面扫了所有「有长度上限、且列名像 link/url/photo/image/
  signature/path」的列:
    · purchase_request.supplier_link(500)  ← 本次唯一真正有问题的
    · sys_menu.path(200)、sys_warehouse_location.full_path(500)  内部生成数据
    · stock_adjustment.linked_sku(100)/linked_outbound_no(50)     SKU 与单号
  用户粘贴外链的列仅此一处。

迁移用 DO 块先判断当前类型,可重复执行;含核对段与回滚段。
验证:把那条 516 字符的真实链接写入再读回,长度一致、内容未截断。
2026-09-18 09:34:44 +08:00
1eaa203eba fix(db): 修正存量「归还时间/报废时间」的 8 小时时区偏差(附自检)
问题
----
process_return 与 scrap_sources.deduct 原先用 datetime.now()(容器本地时间,
Docker 默认 UTC)写 return_time / operation_time,而同一行的 borrow_time 由
beijing_time() 写入 —— 同一行两个时钟,差 8 小时。

实测(开发库)trans_borrow 有 53 行呈现「归还时间早于借出时间」这种物理上
不可能的数据,例如 id=80 借出 09-09 11:04、归还 09-09 03:46。

代码侧已修(f7c49f4),新数据已是北京时间 —— 本次只处理存量。

★ 脚本刻意做了「先自检、后修正」的两段式
  服务器 API 容器的 TZ 未必是 UTC(若本就是 Asia/Shanghai,datetime.now()
  一直是北京时间,则**根本无需修正**)。若不做自检直接 +8h,会把正确的时间
  改错。故第 0 步先输出:异常行数、以及按分界切出的「UTC 段/空档/北京段」
  三段计数,由使用人据结果决定是否执行第 2 步。

★ 分界与幂等性都在脚本里写明
  · 分界 = 修复代码在该服务器**部署生效的时刻**(不是提交时刻),需按实际调整;
  · 脚本**不幂等** —— 重复执行会再加 8 小时,已显式警示并给出回滚写法。

开发库已按此逻辑修正:53 行 trans_borrow + 1 行 trans_scrap,
修正后「归还早于借出」为 0,抽样时间落在正常工作时段。
2026-09-18 09:27:17 +08:00
91c4ae85db V3.83,9.17推送 2026-09-17 13:40:56 +08:00
191724a176 fix(outbound): 修复扫码出库 500 —— applicant_id 用了尚未赋值的 approval
现象
----
所有扫码出库报 500(前端 create.vue 提交即失败)。

根因
----
上一个提交把 'applicant_id': approval.applicant_id 写进了 common_data,
但 common_data 构造在第 234 行,而 approval 直到第 269 行(「强制按单出库」
那一段)才查询赋值 —— 典型的变量先用后赋,直接 UnboundLocalError。
它影响的是**每一条**出库请求,属于必然复现而非偶发。

修复
----
把赋值移到 approval 取出并校验**之后**:common_data['applicant_id'] = ...
并在原处留注释说明为什么不能写在字典字面量里,避免后人搬回去。

★ 我的测试为什么没抓到
  上一轮只测了「退回 → 补发」这条路径,而 applicant_id 的写入在**扫码出库**
  路径上 —— 两条路径不重合,所以漏了。本次补测了完整的
  「出库申请(预占) → 扫码出库(扣减)」链路。

验证(6 项断言全通过,走真实接口链路)
  申请单创建(status=1,申请人=12,预占 stock_buy#2058 两件)
    → 扫码出库不再抛 500
    → 出库明细生成且 applicant_id = 12(不再是 NULL)
    → consumer_name 照常写入
    → 审批单置为已完成
    → 库存实际扣减 2
  清理后库存与数据零残留。

  ★ 顺带在真实数据上得到印证:生产库中已有一笔由业务方重试成功的出库
    (OUT-20260917-1333-0001),其 applicant_id 正确等于所关联申请单的申请人。
2026-09-17 13:35:47 +08:00
07567d2f76 fix(outbound): 「补发给谁」改为必选,并按原申请人精确预填
· 预填顺序改为:① 原出库明细记录的 applicant_id(创建出库时从审批单带出,
  主键无歧义)→ ② 历史单据该字段为 NULL 时,退而按原领用人姓名**唯一命中**
  预填;重名一律留空。
· 「补发给谁」改为**必选**:提交前校验,未选即拦下并提示。
  不再有「留空则挂当前操作人」的兜底 —— 那会把别人的需求记到库管名下。
· 文案同步:明确写「必须选择:补发是原申请人的需求,不能挂到办理退回的库管名下」。
2026-09-17 12:07:21 +08:00
0005a689dc fix(outbound): 补发申请人取原申请人,绝不再回退为库管
问题
----
上一版在库管未指定「补发给谁」时,把补发单申请人回退成了**当前操作人(库管)**。
但补发是**原申请人的需求**,挂到库管名下逻辑不通 —— 那张单会出现在库管的
「我的申请」里,而真正该拿东西的人什么也看不到。

根因
----
trans_outbound 只有 consumer_name(**扫码时前端自由填写**的领用人/客户名,
既不可靠也可能是外部客户),没有任何指回原审批单的关联,所以当时只能退而求其次。

根治
----
一、trans_outbound 新增 applicant_id,**创建出库时从关联审批单带出**
    (request_id 已强制必填、approval 恒非 None,故新单据必然有值)。
    落这一列后,「退回 → 补发」即可自动找回真正的原申请人。
    ⚠ 存量行为 NULL —— 存量出库与其来源审批单之间没有任何可用关联,无从回填。
      刻意留 NULL 而不是按姓名猜(consumer_name 是自由文本,会重名错绑),
      与 dispatch_operator 同一取舍:宁可留空,也不猜。

二、退回接口的申请人优先级改为:
      ① 前端显式指定 reissue_applicant_id
      ② 原出库明细记录的 applicant_id(真实原申请人)
      ③ 都没有 → **报错要求指定**
    ★ 彻底移除「回退为当前操作人」—— 那正是本次要修的逻辑错误。

三、出库列表明细返回 applicant_id,供前端精确预填。

验证(9 项断言全通过)
  · 新单据 → 申请人 = 原申请人(12),绝不是库管(7)
  · 显式指定优先于原申请人
  ★ 历史单据未指定 → 接口拒绝、要求选择「补发给谁」、整笔回滚、
    且**未生成任何挂在库管名下的补发单**
  · 历史单据 + 显式指定 → 正常
  库存与数据零残留。
2026-09-17 12:07:21 +08:00
05524c988a feat(outbound): 退回弹窗增加「补发给谁」选择器
· 勾选「需要补发」后出现「补发给谁」下拉(filterable + clearable),
  并自动带上补发数量。
· 预填策略:按原领用人 consumer_name **唯一命中**才预选;重名一律留空让现场
  自己选 —— 与借用人回填同口径,宁可多一步也不猜错人(补发单会挂到错的人名下)。
· 留空时后端回退为当前操作人,文案已写明。
· 人员名单**勾选补发时才加载**(多数退回不需要补,避免无谓请求),加载后缓存。
· 新增 api/common/users.ts 的 getActiveUsers():走中性的
  /v1/common/active-users,而不是复用借库专用路径。
2026-09-17 12:01:12 +08:00
27e5589a5e feat(outbound,common): 补发可指定「补发给谁」+ 抽出通用人员名单接口
一、补发申请人可选择(原单退回)
   退回接口新增 reissue_applicant_id:
     ① 前端指定 → 校验用户存在后落库;
     ② 未指定 → 回退为**当前操作人**(原行为不变,向后兼容)。
   为何不自动推断原申请人:trans_outbound **没有申请人字段,也没有指回原审批单
   的关联**(扫码出库时只把审批单状态置为 3),按 consumer_name 反查会重蹈
   「重名错绑」的覆辙(借用人姓名回填那轮刚踩过)。故把选择权交给现场,不猜。

二、抽出中性人员名单 GET /api/v1/common/active-users
   实现抽到 common.active_user_options(),借库的 /transactions/borrow/users
   改为调同一函数 —— 实现只有一份,但出库补发走**中性路径**,不再出现
   「出库为什么在调借库的接口」这种跨模块语义错位。
   仅要求登录、只返回 id 与姓名(与 /auth/users/approvers 同一处理)。

★ 本次无需 DB 迁移:未新增任何列,补发申请人是复用已有的
  outbound_approval.applicant_id。

验证(打桩/真实 token 直连接口,12 项断言全通过)
  · 名单只含 id/name,无邮箱/角色/部门;借库原路径返回值与新路径完全一致
  · 指定「补发给谁」→ 补发单申请人 = 指定的人;备注仍含原领用人
  · 不指定 → 回退为当前操作人
  ★ 指定不存在的用户 → 被拒,且整笔退回回滚(流水未落库)
  库存与数据零残留。
2026-09-17 12:01:11 +08:00
9d187593eb feat(outbound): 退回弹窗增加「需要补发」勾选与补发数量
· 退回对话框新增「退回后自动生成补发单」勾选框 + 补发数量(勾选时默认 = 本次
  退回量,可调小;上限即退回量)。
· 文案明写两条关键后果:生成的是**免审批**出库单;**库存不足时整笔退回会一并
  取消**,取消勾选即可只做退回过账 —— 避免库管对着失败提示发懵。
· 提交前做同口径前置校验(数量 > 0 且 <= 退回量),不白跑一趟。
· resetReturnDialog / openReturnDialog 同步重置这两个字段。

默认**不勾选**:有些退回是项目结束退还,根本不需要补,不该默认给所有人挂上。
2026-09-17 11:56:39 +08:00
7071c89305 feat(outbound): 原单退回后可自动生成补发单
背景
----
原单退回只做两件事:良品加回库存 / 不良品转在管台账。但**申请人的需求并没有
被满足** —— 东西交回来了(甚至还是坏的),系统却不提醒任何人、无单据承载
「我要重新领一份」,退回与后续再出库之间也毫无关联。现场只能靠人记住再手建
一张出库申请,而那张单与原单看不出任何关系。

改动
----
· outbound_approval 新增 source_return_id(非空 = 补发单),把「退回 → 补发」
  串成闭环。
· POST /inbound/stock/return-from-outbound 新增 need_reissue / reissue_qty:
  勾选即自动生成一张**免审批**出库单(status=1,直接进入待执行),
  沿用原单出库类型,并关联回本笔退回。

★ 为什么只存退回单 ID,不加 is_reissue 布尔列
  「是不是补发」完全由来源是否存在决定,再加一列就是同一事实的两处存储,
  必然有不同步的一天。也不冗余存原出库单 ID:trans_return 已有 outbound_id。

★ 库存不足 → 整笔回滚(关键取舍)
  补发走 reserve_for_items(strict=True),与出库申请同一口径。不足时抛错,
  退回也一并回滚 —— 若只让补发静默失败,「需要补发」的意图就丢了,
  那正是本功能要解决的问题。库管看到提示后取消勾选即可只做退回过账。

★ 一个被发现的数据约束(改变了原设计)
  原打算把补发单的申请人设为「原出库单的申请人」,但 **trans_outbound 既没有
  申请人字段,也没有指回原审批单的关联**(扫码出库时只把审批单状态置为 3)。
  按 consumer_name 反查会重蹈「重名错绑」的覆辙。故申请人取**当前操作人**,
  原领用人写入备注供人工追溯。
  ⚠ 若业务要求补发单挂在原领用人名下,需要前端在退回弹窗里加一个「补发给谁」
    的人员选择 —— 请确认是否需要。

验证(打桩 JWT 直连真实接口,22 项断言全通过)
  勾选补发 → 免审批单生成、关联退回、预占库存、原领用人入备注;
  不勾选 → 不生成补发单、良品正常回库;
  ★ 库存不足 → 接口拒绝且**退回流水/补发单/退回额度全部未落库**(整笔回滚);
  补发量 > 退回量被拒;库存与数据零残留。
2026-09-17 11:56:38 +08:00
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