Commit Graph

1088 Commits

Author SHA1 Message Date
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
f4a01be951 V3.81,9.16推送 2026-09-16 17:51:32 +08:00
d1337d12e1 fix(scrap): 修复扫码时库存行遮蔽在管不良品导致的「不在批准明细中」
现象
----
报废申请单指向在管不良品时,待执行清单里明明能看到该 SKU,扫码却报
「不在该报废申请单的批准明细中,禁止报废」。

根因
----
在管不良品的 SKU 是从原库存行**复制**的(退回时 sku=getattr(stock_row,'sku','')),
因此同一个条码可能同时命中 stock_buy#M 与 trans_defective_goods#N —— 这是
**两个不同的实物**。

而 ScrapService.get_stock_by_barcode 原实现按固定顺序
(stock_product → stock_semi → stock_buy → trans_defective_goods)返回
**首个命中**。原库存行只要还在,就永远遮蔽在管不良品,扫码结果恒为
stock_buy。改造前双方都用 SKU 作为匹配键,遮蔽不暴露问题;上一轮给匹配键
加上来源表后,`sku:trans_defective_goods:X` ≠ `sku:stock_buy:X`,
问题才浮出水面。

实测复现(业务报障的 SKU 0000000590):
    stock_buy#589           在库 6 件
    trans_defective_goods#24 在管 1 件(同一 SKU)
    批准明细期望 -> ('trans_defective_goods', 24)
    扫码实际返回 -> stock_buy#589   → 键不匹配 → 报错

修复
----
条码本身无法区分这两个实物,**只有单据上下文能决定该扫到哪一个**,故把
单据上下文引入扫码解析:

- get_stock_by_barcode(barcode, prefer_pairs=None):改为**收集全部候选**,
  再按 prefer_pairs(当前申请单批准明细的 (source_table, stock_id) 集合)
  优先命中;无上下文时退回固定顺序,行为与改造前一致
- GET /scrap/scan 新增可选 request_id:据此加载该单的批准明细构造优先集。
  优先集构造失败不阻断扫码,仅告警并回退默认选路
- 前端 scanBarcode(barcode, requestId) 与 create.vue 调用处带上当前申请单 id

验证(隔离数据端到端,非仅单元):
    同一 SKU 建于 stock_buy#2202 与 trans_defective_goods#25
    不带上下文扫码 -> stock_buy#2202          (命中批准明细? False ← 旧行为)
    带上下文扫码   -> trans_defective_goods#25(命中批准明细? True)
    执行后:在管 2→0、状态=已报废;**库存行 10/10 分毫未动**(未误扣错误实物)
2026-09-16 17:22:51 +08:00
6520f1c97c feat(scrap): 前端适配报废审批收口
配合后端把三条报废旁路收口到审批流。

【删除】api/scrap.ts 的 createScrap()
  对应后端已删的 POST /api/v1/scrap。该函数此前已是死代码(全仓无调用方),
  页面早已切换到申请-审批-按单执行流程。

【改造】不良品看板 views/stock/defective/index.vue
  「报废销毁」→「申请报废」:弹窗新增「指定审批人」(必填,复用
  getApproversList)。API 由 scrapDefective 改为 submitDefectiveScrapRequest。
  ★ 弹窗与成功提示都明确写出「在管数量不会立即扣减,待审批通过并执行后才
    扣减」—— 申请不预占 remaining_qty,行数据看起来毫无变化,不说明会诱发
    重复提交。

【改造】借还记录页 views/transaction/records.vue
  「报废」→「申请报废」:弹窗新增「指定审批人」(必填)。原先内联的
  request() 调用挪到 api/transaction.ts 成为 submitBorrowScrapRequest,
  与全仓 API 分层一致。确认框文案改为「提交后进入审批流程」。
  顺带清理 canScrap 里硬编码的 `username === 'IRIS'` 后门 —— 该账号在
  sys_user 表中根本不存在,属死代码。

【改造】按单报废执行页 views/operation/scrap/create.vue
  按 scrap_mode 把批准明细分流:
    · scan 项(库存行 / 在管不良品)—— 走原有扫码购物车,逻辑不变
    · auto 项(借出未还)—— 实物在借用人手上、无法扫码,放入**只读区块**
      展示,不进购物车(购物车语义是「扫码证据」,混入会让扫码校验失效)
  纯 auto 单隐藏整个扫码区并提示「本单无可扫码物料,将按批准数量直接执行」;
  扫码进度只统计 scan 项(否则进度条永远满不了,会让操作员误以为没扫完);
  提交守卫放宽为「购物车与 auto 项都为空才拦」;确认框追加
  「另有 N 项将按批准数量自动执行」,避免操作员误以为只报废了扫到的那些。
  matchKey 同步加入来源表(与后端 _match_key 保持逐字同口径),
  sourceLabel 补两种新来源的中文名。

存量单据没有 scrap_mode 字段,前端回落为 scan,行为与改造前完全一致。
2026-09-16 17:14:29 +08:00
58fa42bff3 feat(scrap): 报废全链路收口到审批流
系统自陈的规则是「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL = True),
但实际有 4 条写 TransScrap 的路径,其中 3 条绕过审批。本提交把三条旁路
全部收口,只保留「申请 → 审批 → 执行」一条写入路径。

【删除】直接报废 POST /api/v1/scrap
  同时移除 ScrapService.process_scrap()。该路径的一个连带影响是
  「维修件报废」能力随之消失 —— process_scrap 的 trans_repair 分支是
  repair_status='报废转出' 的唯一写入点。实测该能力零使用(报废转出 0 条、
  trans_scrap 来源 0 条),且早已半死:审批流的扫码校验会拒绝 trans_*
  来源。repair_service.py 的误导文案(原文指引操作员「前往报废管理进行
  扫码操作」,而那条路根本不通)已改为「维修件报废暂未开放」。
  权限元素 scrap_create:operation 保留不删 —— add_scrap_perm.sql 以它为
  scrap_apply/scrap_execute 的授权来源。

【改造】借库转报废 POST /borrow/scrap → POST /borrow/scrap-request
  TransService.scrap_borrow() 删除,逻辑迁入 BorrowScrapAdapter。
  沿用 op_return:operation 权限(零授权变更)。已归还/已报废的记录改为
  **直接报错**,不再静默 continue 返回 count=0(原缺陷:用户以为成功)。

【改造】不良品报废 POST /defective/<id>/scrap → POST /defective/<id>/scrap-request
  申请时**不预占** remaining_qty(与报废模块「仅锁定意向,不扣库存」的
  既有哲学一致)。副作用:同一批坏件可重复提交多张申请单,执行期由适配器
  按 Fail-Closed 拒绝超额的那几张。已在该接口注释与前端提示中写明。

【服务层】ScrapApprovalService 接入来源适配层
  - submit_approval 改由 get_adapter() 分派,并新增整单 scrap_mode 一致性
    校验(混合「需扫码/免扫码」两类来源直接拒绝,让 execute 分流保持简单)
  - _build_scanned_index 的来源校验改为 is_scan_source():语义上是**收窄**
    而非放宽,扫码通道永远不接纳 trans_* 来源
  - execute 按模式分流:scan 项走原有扫码匹配(索引只由 scan 项构建),
    auto 项按批准量执行。签名与调用契约不变。
  - _match_key 加入来源表:不良品 SKU 是从原库存行复制的,不带来源时
    同一张单里的两者会**必然串键**,扫码量算到错误对象上。纯库存单两侧
    同源、键仍匹配,对既有流程零行为变更。

【修复】approve() 的 fail-open
  原实现 `if user_entries and str(operator_id) not in user_entries` ——
  allowed_approvers 为空时条件短路为假,**任何登录用户都能审批**。
  这与「报废一律需审批」直接矛盾,留这扇门等于没有审批。改为无名单即拒绝。
  已实测存量「无审批人」的在途单为 0 张,不会卡死历史数据。

【扫码通道】scrap/scan 的 trans_repair 分支改为 trans_defective_goods
  在管不良品按 SKU 匹配,在管量回填到既有字段形状,前端无需按来源分支。

【清理】scrap_approval_service 的 _stock_models 与 TransScrap 导入已移除
  (来源差异全部收敛到适配层);trans_service / inventory_reservation 中
  指向已删方法的注释已更新指向 BorrowScrapAdapter。
2026-09-16 17:14:17 +08:00
e4d2b2ec68 feat(scrap): 报废来源适配层(纯新增,零行为变更)
把「报废来源差异」从审批服务里抽出来,为后续把借出未还、在管不良品
接入审批流铺路。

背景
----
报废审批流原先只认三张库存表(ScrapApprovalService._stock_models 硬编码),
导致另外两类来源只能各走直报接口绕过审批 —— 与系统自陈的
「报废一律需审批」(SCRAP_ALWAYS_REQUIRES_APPROVAL)冲突,构成职责分离
漏洞:同一个库管可自行宣告实物销毁而无人复核。

三类来源的语义差异
------------------
  维度        库存行            在管不良品        借出未还
  执行模式    scan              scan              auto(免扫码)
  可报废量    available_qty     remaining_qty     quantity-returned
  扣减        available 与      只动在管台账,    只扣 stock_quantity
              stock 同扣       不碰任何库存表    (可用量已在借出时冻结)
  成本        沿用现口径        defective_unit_cost  0/0

★ 为什么在管不良品保留扫码、借出未还免扫码:
  坏件实物就在仓库里、有 SKU,扫码是有效的「申请报 A、实际毁 B」防护;
  借出物在借用人手上,物理上不可能扫到,且执行只改台账与总库存、
  不产生任何可被挪用的可用库存,风险等级不同量级。

其他要点
--------
- defective_unit_cost() 从 api/v1/inbound/stock.py 迁入本模块:服务层不得
  反向 import API 层。复用 inventory_reservation.stock_model_map(),
  避免第四份库存表字典。
- 提交期新增 submit_guard 钩子,用于修复「提交只校验 available、执行却校验
  both」的校验不对称(会出现申请通过、执行必失败的单据)。
- is_scan_source() 对未知来源返回 False(Fail-Closed):扫码通道只接纳明确
  声明为 scan 的来源,杜绝 trans_repair 那类「扫得到、执行却拒绝」的错配。

本步不触碰任何现有路由,现网仍走老路径,可独立评审与验证。
2026-09-16 17:14:05 +08:00
511e822d12 feat(ui): 按钮级权限守卫与数量显示格式化
按钮级权限(与后端 @permission_required 用同一批权限码):
- 出库记录页「退回」按钮   -> v-permission="'outbound_return'"
- 不良品看板「修复回库」   -> v-permission="'defective_restock'"
- 不良品看板「报废销毁」   -> v-permission="'defective_scrap'"

  两个处置按钮是**独立权限**,可能有人只有其一,故分别判定、分别隐藏;
  两者都无时显示「无处置权限」。均保留响应式的 v-if 兜底——v-permission
  只在 mounted 执行一次,而 Element Plus 表格行会重渲染,两者用同一权限码
  判定一致。hasPermission() 内部已对 SUPER_ADMIN 放行,与后端装饰器的
  超管旁路对齐。

  另外去掉了出库记录页原本套用的 username === 'IRIS' 特例放行:退回是实物
  交接的 SOP 动作,后端不认这个特例,前端也不该认,否则会出现「按钮可见但
  接口 403」的错位。

数量显示格式化(新增 utils/format.ts,该文件原本是 0 字节空文件):
- formatQty()    显示用:1.0000 -> '1',2.5000 -> '2.5'
- normalizeQty() 数值用:同上但返回 number,供 el-input-number 的
                 v-model / :max —— 喂字符串会让步进与边界比较退化

  应用于 不良品看板(退回总数、剩余待处理、两个弹窗的在管数量与输入框)、
  出库记录页(已退 N 提示、退回弹窗三项数字)、退回记录页(退回数量列)。

  同时移除三个 el-input-number 上的 :precision="4" —— 它会把 1 强制渲染成
  1.0000,是截图上多余小数点的直接来源。数量本身仍支持小数(后端按
  numeric(19,4) 收),只是不再强制补零。

  表格列原本不受影响(接口返回 JSON 数字,1.0 经 JS 解析即 1),但后端有一批
  运算是 float 累加,可能吐出 0.30000000000000004 这类尾巴,格式化按
  numeric(19,4) 量纲先收敛到 4 位再做归一。
2026-09-16 16:46:20 +08:00
27436f5efe feat(ui): 菜单权限过滤与退回记录页
侧边栏支持按权限隐藏菜单(Sidebar/index.vue):
- 新增 canSee():读取 meta.permissions(数组,满足任一即可)
- ★ 同时修了一个既有缺陷:原实现只过滤顶层路由,**子路由的权限判定不生效**,
  未授权用户仍能在菜单里看到入口。现改为递归过滤,父菜单的子项被过滤光后
  父菜单本身也不再显示。
- ★ 刻意只认 meta.permissions(新键),历史遗留的 meta.permission(单数,
  如「维修管理」的 inbound_repair)保持不生效——一旦开始消费它,会让若干
  菜单对部分角色突然消失,属于超出本次范围的静默行为变更。两者若要统一,
  建议单独排期逐个核对影响面。

路由守卫(router/index.ts):
- 新增 meta.permissions 校验,未授权时提示并跳首页。侧边栏负责「菜单看不见」,
  守卫负责「直接敲 URL 也进不去」,后端负责「绕过前端直接调接口也不行」,
  三层任一失效都不至于越权。
- 顺带修了原守卫的分支结构:roles 通过时直接 next(),会跳过后续检查;
  改为不通过才中断,落到末尾统一 next()。

新增「退回记录」页(views/outbound/returns/index.vue):
- 纯只读台账,无任何操作按钮,供库管核对历史退回
- 展示 退回时间 / 原出库单号 / 物料名称 / 规格 / SKU / 退回数量 /
  退回类型 / 退回原因 / 操作人
- 退回类型用标签区分(不良品=红,良品=绿)
- 源库存行或原单被删除时显示占位文案而非空白,台账不留哑行

api/inbound/return.ts 补 getReturnList()。
2026-09-16 16:46:02 +08:00
9eb4792d4a feat(return): 退回流水看板接口与权限收口
新增只读台账接口:
- GET /api/v1/outbound/returns  退回流水(分页 + 关键词 + 类型 + 时间过滤)
  返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 /
  操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按
  多态来源批量解析,均为批量查询无 N+1。

权限收口(配合 db_migrations 里的三个权限码):
- return-from-outbound   inventory_stocktake:operation -> outbound_return
- GET /stock/defective   inventory_stocktake           -> defective_list
- restock                inventory_stocktake:operation -> defective_restock
- scrap                  inventory_stocktake:operation -> defective_scrap
- change-status          inventory_stocktake:operation -> stock_change_status

  原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。
  实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份
  不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后,
  实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。

trans_return 补 company_name 快照:
  退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除
  (实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户
  静默消失。改由退回时落快照,隔离不再依赖任何 join。
2026-09-16 16:45:52 +08:00
a252013573 feat(perm): 逆向物流专用权限码与菜单授权
为逆向物流的每个动作拆出独立权限码,终止此前复用其它模块权限的做法。

权限码一览(全部为无冒号形式,见下方说明):

  outbound_return       出库退回
  defective_list        不良品台账查看
  defective_restock     不良品回库
  defective_scrap       不良品报废
  stock_change_status   库存状态变更
  outbound_return_list  退回记录查看

另补建两个菜单:defective_goods(不良品在管台账)、outbound_returns(退回记录),
前者原先只有前端路由、sys_menu 中无对应行,导致页面在权限管理界面不可见、
其下权限也无从授予(sys_element.menu_code 有指向 sys_menu 的外键)。

默认授予:SUPER_ADMIN、SUPERVISOR、WAREHOUSE_MGR。
刻意不含 OUTBOUND —— 业务确认出库员只负责正向拣货发货,不参与逆向物流。

★ 为什么全部用无冒号形式,而不是 <menu>:<action>:
  后端 _expand_operation_perms() 对带冒号的权限码做**前缀桥接** —— 只要用户
  持有该菜单下任一以 :operation/:edit/:delete/... 结尾的权限就被放行。
  实测验证过这个放大效应:

      outbound_list:return  <- 持有 outbound_list:operation   ->  True
      outbound_return        <- 持有 outbound_list:operation   ->  False

  且前端 hasPermission() 是精确匹配,用冒号码会出现「接口能调、按钮却看不到」
  的错位。无冒号码不触发桥接,前后端判定完全一致。

★ add_return_view_support.sql 另含一处 schema 变更:trans_return 补
  company_name 快照列。退回流水的多租户隔离原先只能靠 join 链推
  (trans_return → trans_outbound → 库存表 → 物料主表),而库存行会被入库
  模块物理删除(实测 1077 条出库记录中已有 7 条悬空),链路一断该记录就会
  对普通用户静默消失。审计视图静默丢数据不可接受,故落快照。
  表当前为空,无需回填。

三个脚本均为幂等,含预检、回滚段与执行后核对。
2026-09-16 16:44:46 +08:00
fd99d33a0d feat(return): 前端退回入口与不良品在管台账看板
出库记录页(views/outbound/index.vue):
- 明细行新增「退回」列,returnable_quantity <= 0 时按钮置灰,行内显示「已退 N」
- 对话框展示 原出库数量 / 已退回数量 / 本次可退最大,默认带入可退最大值
- 退回类型用下拉单选:良品(加回库存)/ 不良品(转入异常待处理)
- 退回原因必填,提交前三重校验;按钮 :loading + 函数内 submitting 双保险防抖
- 成功后刷新列表。错误提示不重复弹——request 拦截器对 HTTP 400 已取
  data.msg 展示,对话框 catch 只收尾

配套后端(services/outbound_service.py):
- get_grouped_list 的出库明细补 id / returned_quantity / returnable_quantity。
  原先明细不带 id,退回接口无从指定 outbound_id

新增页面(views/stock/defective/index.vue,路由 /inventory/defective):
- 展示 物料名称/规格/SKU/退回时间/操作人/退回总数/剩余待处理/状态
- 顶部 alert 提示在管总量,并明确「这批实物不在库存表中,盘点请以本台账
  为准」——在管坏件对盘点不可见是本方案的固有盲区,必须在页面上主动提醒
- 仅对 remaining_qty > 0 的行提供「修复回库」「报废销毁」,终态行显示已结案
- 两个弹窗均带数量上限约束与 loading 防抖,成功后刷新

新增 api/inbound/return.ts 承载四个逆向物流接口。

侧边栏由 router/index.ts 驱动,加路由即入菜单;注意侧边栏只过滤
meta.hidden、不看 meta.permission,故菜单对所有角色可见,访问控制由后端
接口负责(与既有「维修管理」一致)。
2026-09-16 15:45:40 +08:00
dda6e4c787 feat(return): 退回、回库、报废与在管台账接口
打通逆向物流的全部后端入口。

新增接口(app/api/v1/inbound/stock.py):
- POST /stock/<id>/change-status      库存状态变更(在库/冻结/不良品)
- POST /stock/return-from-outbound    通用原单退回
- POST /stock/defective/<id>/restock  不良品修好回库(支持部分回库)
- POST /stock/defective/<id>/scrap    不良品报废销毁
- GET  /stock/defective               在管台账分页查询

设计要点:
- 良品退回加回原库存行;不良品退回则库存表分毫不动,只写独立在管台账。
  这样坏件从根上不会混进可分配池
- 全链路 Fail-Closed 守卫:良品退回到非「在库」行会被拒(status 是行级
  属性,加回已冻结/不良品的行会让良品被连带隔离);原库存行已不存在会被
  拒(入库模块会物理删除库存行,实测 1077 条出库记录中已有 7 条悬空)
- 三个写接口均加 with_for_update 行锁 + prevent_double_submit 幂等锁。
  装饰器顺序为 permission_required → prevent_double_submit,顺序颠倒会因
  JWT 未验证而抛错、被自身 except 捕获后 fail-open 降级
- 报废同时写 trans_scrap 台账(source_table 用 trans_defective_goods 并
  存台账自身主键,与 trans_borrow/trans_repair 作来源时的约定一致),
  成本按原库存行 best-effort 取价,取不到记 0 而不中断报废

报废报表集成(app/api/v1/scrap.py):
- _resolve_materials 补 trans_defective_goods 分支。物料名已在台账冗余存储,
  不联表——坏件的原库存行可能已被删除,联表取名称会得到空值
- 公司隔离补 defective_subq 分支。原先按 source_table 逐个构造子查询,
  未知来源会被整体过滤,导致这类记录对普通用户静默消失
- 无审批单号的分组前缀按来源分流:不良品直报不再套用 LEGACY-(它是新业务
  记录,不是历史脏数据)。分组键同步带上来源标记,且 _order_key_pred 的
  SQL 谓词改为同口径,否则页面分组与「按单筛选」结果会对不上
2026-09-16 15:45:31 +08:00
69c38a1bf7 feat(return): 逆向物流数据模型与迁移
新增原单退回与不良品在管的持久化结构。

- TransOutbound 增 returned_quantity(numeric(19,4),非 float):该值参与
  「return_qty <= quantity - returned_quantity」判等,浮点误差会让反复部分
  退回后出现「已退满却判定未退满」的错判
- 新增 TransReturn:退回流水,每次退回写一条而非覆盖式更新。刻意与
  trans_borrow 划清界限——后者部分归还时会覆盖 return_time/operator,
  导致归还历史永久丢失
- 新增 TransDefectiveGoods:不良品在管台账。坏件全程不入库存表,因为
  status 是行级属性而质量是件级属性,把坏件加回原行只能整行打不良
  (实测 stock_buy 单行最大 4789 件、中位 8 件,整行打不良会凭空损失良品)
- 状态机:待处理 → 处理中 → {已回库|已报废|已闭环}。终态由累计去向推导
  而非「最后一次动作」——一批坏件可能既回库过又报废过,按最后动作定状态
  会产生误导
- restocked_qty/scrapped_qty 两列:二期用 quantity-remaining_qty 反推回库量,
  三期加入报废出口后该反推失效
- 审计白名单与模型预加载同步登记(监听器绑定 18 → 20 个模型)

迁移脚本均为纯追加式 DDL,含预检、回滚段与执行后核对。首个脚本用
COALESCE 包裹数量列——库存表允许数量为 NULL,而「NULL 大于 0」求值为
NULL 而非真,裸写会让脏行在预览与诊断两次查询里凭空消失。
2026-09-16 15:45:22 +08:00
fbc9296056 feat(stock): status 硬隔离与出库通道封堵
激活库存表长期「只写不读」的 status 列,使其成为分配准入的硬门槛。

- inventory_reservation: 新增状态语义单一事实来源(STOCK_STATUS_*/
  allocatable_filter/is_allocatable);verify_scanned 增加状态准入校验
  (覆盖出库与借库两条提交路径)
- outbound: _allocate_bom_requirements 过滤条件加 allocatable_filter,
  非「在库」一律不进入候选集;扫码路由把 ValueError 转为 400
- outbound_service: create_outbound_batch 强制 request_id 必填。原先无单
  时会跳到所谓「散单」分支,而该分支的扣减逻辑从未落地(注释点名的
  _apply_reservation_override 全仓不存在),实测只写台账不扣库存,
  同一批货可被反复出库;get_stock_by_barcode 接入扫码准入;维修单扫码
  补排除「报废转出」;预占再平衡块补 rollback 兜底
- fill_missing_stock_status.sql: 把存量行刷回「在库」。该过滤是
  Fail-Closed 的,不先洗刷会让全库无法出库

破坏性变更:不传 request_id 的出库请求现在会被拒绝。
2026-09-16 15:45:02 +08:00
ffd3e2652c V3.80,9.15推送 2026-09-15 13:27:42 +08:00
fe54d01391 fix(outbound): 序列号物料出库被误判超出计划数量
现象:申请单里同一个物料有 2 个,扫第 2 个时报
「超出计划数量(计划: 1,已扫: 1,本次: 1)」。

根因:计划侧与已扫侧用了**不同的聚合方式**。
  已扫侧 .filter().reduce()  → 把【所有】匹配行加总
  计划侧 .find()             → 只取【第一条】匹配行
序列号物料在申请单里是「一物一行、每行 quantity=1」(因为每个序列号对应
一条独立库存行),于是 planQty 只拿到 1,而 alreadyScanned 是全部之和,
1 + 1 > 1 直接判超发。普通数量制物料计划单只有一行,两边一致,所以一直
没暴露 —— 只有序列号物料会踩中。

同一缺陷还有第二处:unscannedList 逐行比较 planQty 与「累计已扫」,
导致两行各自都被同一笔扫码"满足",扫 1 个就把整个物料判为扫完、未扫清单漏报。

修复:
- validateAgainstPlan:计划侧改为 .filter().reduce() 汇总所有匹配行
- unscannedList:先按「名称+规格」合并计划行,再与同口径汇总的已扫数比较

用申请单 410(Opt1025 两行各 1 个)的真实数据模拟验证:
  修复前 扫第2个 → 超出计划数量(计划:1,已扫:1,本次:1)  ← 与现场报错逐字一致
  修复后 扫第2个 → 通过
  修复后 扫第3个 → 仍被拦(计划:2,已扫:2,本次:1),超发保护未失效
2026-09-15 13:21:36 +08:00
b55119acbc V3.79,9.14推送 2026-09-14 17:35:07 +08:00
0481f917ee fix(stocktake): 修正页面容器高度 —— 真正解决按钮被裁的真根因
前几轮都在 .idle-card 内层打转(min-height:0 / overflow / padding / 去居中),
全都没打到点上。真根因在页面容器**之外**:

  .app-wrapper  { height:100vh; overflow:hidden }        App.vue
    .app-content { flex:1; min-height:0; overflow:hidden }  ← 高度 = 100vh − 90px
      .app-main  { position:relative }                       ← 无高度
        .app-container { height:100vh }                      ← 就是本页

App.vue 里 .app-header 是 60px、.app-footer 是 30px,所以 .app-content 的实际
高度是 100vh − 90px。而本页容器写死 height:100vh,**比父容器高出整整 90px**,
超出的那一截被 .app-content 的 overflow:hidden 裁掉 —— 【开始新盘点】按钮
恰好就在那一截里,所以看不见也滚不到(滚动条属于被裁掉的那部分)。

改法:height 改为 calc(100vh - 60px - 30px),两个高度提成 CSS 变量并注明
对应 App.vue 的哪个类,便于外壳改动时同步。

为何不用 height:100%:.app-main 是 auto 高度,百分比无从解析,会退化成 auto。

同时保留前几轮的成果(它们各自都是对的,只是不足以单独解决问题):
- .idle-card { min-height:0; display:block; overflow:hidden auto }
- .idle-content { margin:0 auto; padding:20px 20px 60px }
- .scope-columns 高度 380 → 260,再省 120px
2026-09-14 11:10:42 +08:00
66e0728957 fix(stocktake): 欢迎页补足上下安全边距,显式压过 el-card 的 overflow:hidden
b7cdd5e 的 min-height:0 基础上补两处:

1. 顶部安全边距缺失:内容超高时 .idle-content 的 margin:auto 会塌缩为 0,
   文字直接贴着屏幕顶端。改用**容器自身的 padding: 40px 0** —— padding
   不参与 auto 塌缩,滚到顶/底都稳定保留 40px 呼吸空间。

2. 显式声明两轴 overflow:Element Plus 的 .el-card 默认 `overflow: hidden`
   (实测其 dist/index.css 确有该声明)。原先只写 overflow-y: auto,
   虽然 scoped 选择器(.idle-card[data-v-x],特异性 0,2,0)能压过
   .el-card(0,1,0),但意图不够显式。改为 `overflow: hidden auto` 一次写清
   「横向裁掉、纵向可滚」。

实测 style 块编译通过(12799 字符),min-height:0 / padding:40px 0 /
overflow:hidden auto / margin:auto 均已进入产物。
2026-09-14 11:05:12 +08:00
b7cdd5e58b fix(stocktake): 补 min-height:0,真正解决欢迎页按钮被裁不可达
上一轮只加了 overflow-y: auto 和 padding-bottom,没有生效。完整根因链:

  html,body { height:100%; overflow:hidden }   ← src/style.css:61 全局裁剪
    #app { height:100% }
      .app-container { height:100vh; flex column }     ← 自身无 overflow
        .idle-card { flex:1; overflow-y:auto }         ← 缺 min-height:0

flex 子项默认 min-height: auto,会**拒绝收缩到内容高度以下**。于是卡片被内容
撑破 100vh 容器,向上冒泡到 body 的 overflow:hidden 被裁掉 —— 卡片自己的
overflow-y 永远不会触发(它从没比内容矮过),所以按钮既看不见也滚不到。

补上 min-height: 0 后,卡片被约束为「容器剩余高度」,内部滚动才真正生效;
配合子元素的 margin:auto,内容不高时仍居中,超高时从顶部排下且保留
40px 底部安全边距。另加 -webkit-overflow-scrolling: touch 让平板惯性滚动。

注意:没有采用「把 .app-container 改成 overflow-y: auto」的方案 ——
该容器与扫码页的 .active-card 共用,改动会波及扫码态的整屏布局。
本次把修复严格限制在 .idle-card 内。

校验: style 块编译通过;min-height:0 / padding-bottom:40px / margin:auto
      四条关键规则均已进入产物。
2026-09-14 11:04:06 +08:00
996ec43360 fix(stocktake): 欢迎页可滚动、明细列合并、弹窗备注组件统一
【1. 修小屏按钮被截断】
根因不只是缺 overflow —— .idle-card 用了 justify-content: center,
flex 居中在内容超出容器时会把上下两端**同时裁掉**,这才是按钮只剩一半的原因。
改为:容器 overflow-y: auto + padding-bottom: 40px,居中交给子元素的
margin: auto(不高时正常居中,超高时 auto 塌缩为 0,可完整滚动到顶与底)。

【2. 批次/序列号合并为一列】
「批次 / 序列号」min-width=120,两者都有显示「批次 / SN」,只有一个只显示它,
都没有显示 '-'。

【3. 规格列压缩】min-width=120 → width=100 show-overflow-tooltip,给备注让位。

【4. 录入实盘数量弹窗的备注统一为 el-select】
textarea 换成与明细抽屉完全相同的 filterable allow-create
default-first-option 组件,共用同一份 REMARK_OPTIONS。
保存链路不变:inputRemark → syncToBackend → /draft/add(后端本就存 remark)。

★ 顺带修一个数据正确性 bug:openQtyDialog 从不重置 inputRemark,
  工人扫 A 填了备注后**取消**弹窗,再扫 B 时 A 的备注会残留并可能被写进 B。
  改成下拉选项后这个陷阱更隐蔽(离散值不像 textarea 那样扎眼)。
  现两处开窗分支都先清空 inputRemark。
2026-09-14 10:57:29 +08:00
b695592178 feat(stocktake): 明细去 SKU 改显批号/序列号,备注可选可填,列宽适配平板
【1. 去 SKU】明细表删除 SKU 列,新增「批次号」「序列号」。
SKU 是账务口径的物料主键,现场照着它抄数等于把账面信息递给盘点人。
(搜索框仍支持按 SKU 搜,只是列表不展示。)

后端 merged-list 的 UNION ALL 需要小心:stock_product 表**没有
batch_number 列**(只有 serial_number),故该分支必须写 '' AS batch_number
占位,否则列数不齐 / column does not exist。实测成品行 batch='' serial='205'。

【2. 列宽】名称/规格改 min-width=120(超长省略+tooltip);库位固定 120;
账面数/实盘数/差异固定 100,保证平板上输入框有足够触控面积;备注 min-width=160。

【3. 备注升级为可选项】el-input 换成 el-select(filterable allow-create
default-first-option),预设 ['包装破损','找不到实物','标签丢失','账实不符','实物变质'],
可点选也可自行输入。

★ 顺带修一个真 bug:handleRemarkChange 原本是个空函数,只 console.log,
  备注填了**根本没落库**。现复用 update-quantity 接口保存,后端新增可选的
  remark 字段(用 `remark is not None` 判断,只改数量时不会清空备注)。
  未扫过的行没有草稿记录可挂,备注框同样禁用。

实测: 备注落库='包装破损';只改数量后备注仍在(未被清空)。
2026-09-14 09:45:38 +08:00
af7be6a051 fix(stocktake): 明细里改实盘数时「差异」列实时联动
原 handleQuantityChange 只发请求,不回写 diff_qty,差异列要重新打开抽屉
才会刷新。

现改为在发请求的同时本地计算:
  row.diff_qty = val - row.stock_qty   (保留 4 位小数,与后端 round 一致,
                                        避免浮点尾差显示成 4.999999999)

★ 盲盘保护:后端在 blind 模式下把 stock_qty 置为 null(绝不下发账面数),
此时**完全跳过**减法,保持 diff_qty 原状(null → 渲染为「—」)。
若不判断,null 参与运算会算出错误差异,等于从 UI 侧绕过了盲盘限制。

请求失败时原有的 fetchInventoryList() 会拉回服务端数据,本地算出的差异
随之被覆盖,不会留下错误值。
2026-09-11 15:21:45 +08:00