Commit Graph

729 Commits

Author SHA1 Message Date
91c4ae85db V3.83,9.17推送 2026-09-17 13:40:56 +08:00
07567d2f76 fix(outbound): 「补发给谁」改为必选,并按原申请人精确预填
· 预填顺序改为:① 原出库明细记录的 applicant_id(创建出库时从审批单带出,
  主键无歧义)→ ② 历史单据该字段为 NULL 时,退而按原领用人姓名**唯一命中**
  预填;重名一律留空。
· 「补发给谁」改为**必选**:提交前校验,未选即拦下并提示。
  不再有「留空则挂当前操作人」的兜底 —— 那会把别人的需求记到库管名下。
· 文案同步:明确写「必须选择:补发是原申请人的需求,不能挂到办理退回的库管名下」。
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
9d187593eb feat(outbound): 退回弹窗增加「需要补发」勾选与补发数量
· 退回对话框新增「退回后自动生成补发单」勾选框 + 补发数量(勾选时默认 = 本次
  退回量,可调小;上限即退回量)。
· 文案明写两条关键后果:生成的是**免审批**出库单;**库存不足时整笔退回会一并
  取消**,取消勾选即可只做退回过账 —— 避免库管对着失败提示发懵。
· 提交前做同口径前置校验(数量 > 0 且 <= 退回量),不白跑一趟。
· resetReturnDialog / openReturnDialog 同步重置这两个字段。

默认**不勾选**:有些退回是项目结束退还,根本不需要补,不该默认给所有人挂上。
2026-09-17 11:56:39 +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
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
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
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
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
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
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
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
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
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
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
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
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
0e9941a2aa feat(stocktake): 明细抽屉补充「未扫需先扫码」的规则说明
平板上 title 悬浮提示不生效,只在单元格里显示灰色的「未扫」二字,
工人未必知道下一步该做什么。在抽屉搜索栏下方加一行明文字:

  💡 未扫的行需先扫描条码才能填数
2026-09-11 15:16:36 +08:00
04ed489ab5 fix(stocktake): 未扫码的行不允许直接填实盘数
【问题】盘点明细抽屉列出全部在范围内的物料,其中尚未扫码的行也可以
直接在「实盘数」列填数字。填完前端发 POST /stocktake/update-quantity,
后端只会 UPDATE 已存在的草稿行、找不到就返回 404「未找到盘点记录」,
于是工人填的数字根本没保存,只弹一句「更新失败」。

【为什么不改成 UPSERT】那等于允许不扫码就填数 —— 任何人照账面抄一遍
就能"完成"盘点,扫码这道工序形同虚设,盘盈盘亏也无从查起。
所以这里保持"必须先有草稿行"的语义,改为在 UI 上把这条路堵住。

【改动】
- 前端:draft_id 为空的行不再渲染 el-input-number,改显示灰色「未扫」;
  已扫行照常可改。失败时提示改为透传后端 msg,并 fetchInventoryList()
  回滚本地被 v-model 改动的值。
- 后端:404 文案由「未找到盘点记录」改为
  「该物料尚未扫码,请先扫描条码后再修改实盘数」,作为绕过前端时的兜底。

实测:
  draft_id 已扫行=11379 → 可编辑;未扫行=None → 不可编辑
  绕过前端直接改未扫行 → 404 该物料尚未扫码,请先扫描条码后再修改实盘数
2026-09-11 15:15:05 +08:00
e573185ea4 perf(stocktake): 库位树按公司前缀后端裁剪,树与推荐并行请求
【后端】/tree 支持 ?prefixes=Y 或 ?prefixes=C,L(逗号分隔)
只在**顶层**按 name / full_path 前缀过滤,命中即整棵子树保留 ——
不递归裁剪,避免把子树打散导致前端勾选语义错乱。不传则全量。
刻意不用懒加载:setCheckedKeys / getCheckedNodes 依赖全树已构建。

实测节点数(含子树):
  全量 3371 → IRIS (Y) 500(↓85%)→ LICA (C,L) 2871(↓15%)
IRIS 收益很大;LICA 的前缀覆盖了树的大部分分支,故提升有限。

【前端】
- getWarehouseTree(prefixes?) 透传前缀,loadLocationTree 从
  getAllowedLocPrefixes(selectedCompany) 取;后端已做过滤,
  前端不再重复过滤,删掉冗余的 filterTreeByCompany。
- fetchRecommendLocations 改为 Promise.all 并行拉树与推荐,
  取代原来的串行 await(两段网络等待不再叠加);
  setCheckedKeys 前仍保留 await nextTick() 等树渲染完。

【顺带修一个上一轮引入的 bug】
右侧自 leafOnly 改造后只存末级路径,而推荐返回的是库存级路径、可能是
非末级,原来的逐字比对必然对不上,会把正常勾选的库位误报成
「未能勾选」。改为按「自身或其祖先」判定覆盖。

实测: prefixes 过滤正确(IRIS 8 个顶层 / LICA 25 个 / 不传 33 个)
2026-09-11 14:45:23 +08:00
d1694bf245 perf(stocktake): 去掉库位树的全展开渲染,并为推荐查询补索引
业务反馈切到抽盘面板与点【获取推荐】时卡顿。实测定位:

【真正的原因在前端】先跑 EXPLAIN ANALYZE 排除了后端 ——
推荐查询在当前数据量下仅耗时 1.3ms,全走 Seq Scan + Hash Join,
Buffers shared hit=161 全部命中缓存。所以卡顿来自 DOM:
el-tree 原先带 default-expand-all,会把全库 3371 个库位节点一次性铺进 DOM。

去掉 default-expand-all 后初始只渲染根节点。勾选状态与 getCheckedNodes
不受影响 —— el-tree 的 Node store 会预先构建全树,展开与否只影响 DOM。

【索引仍补上,但说明白】实测 pg_indexes:stock_* 三表的 warehouse_location
已有索引,但 trans_outbound / trans_borrow 只有主键,时间字段与
(source_table, stock_id) JOIN 键都缺;三张库存表的时间字段也缺。
新增迁移 add_active_location_indexes.sql 补 7 个索引。

诚实说明写在迁移头部:当前数据量下加索引后本查询**仍会走 Seq Scan**,
这是小表下的正确计划,索引是为 90 天/半年后的数据增长做前瞻,不是修当前的慢。

【loading 状态】treeLoading / recLoading 已在既有实现中就位并闭环在
try/finally 中,分别绑在 el-tree 的 v-loading 与【获取推荐】按钮的 :loading 上,
本轮复核确认,未做无谓改名。

实测: 索引后执行计划仍为 Seq Scan,Execution Time 0.769ms(预期内)
2026-09-11 14:38:40 +08:00
0b82609c55 fix(stocktake): 抽盘配置区定高不跳动,勾选只计末级库位
【固定高度】.scope-columns 设 height: 380px + display: flex,左右两栏各自
flex:1 + overflow 独立滚动。关键点是给 flex 子项加 min-height: 0 —— 不写它
flex 子项默认不收缩,overflow 不会生效,高度照样被内容撑开。
原先树用 max-height: 300px、右栏用 height: 260px 各写各的,仍会随内容跳动。

【只计末级库位】syncSelectedPaths / buildScopeConfig 均改用
getCheckedNodes(true)(leafOnly)。勾选父级货架时右侧计数与提交给后端的
scope_config.locations 都只含末级库位,不再把父节点自身算进去 ——
符合现场「货架是容器、库位才是盘点对象」的心智模型。

上一轮我曾以「非末级库位上有库存,leafOnly 会漏」为由建议不做,该判断
是错的:我把「层级」当成了「非末级」。实测这棵库位树深度不均匀,很多
level 1/2 的节点本身就是叶子 —— 真正存放在非末级库位上的库存,
IRIS 1126 行里仅 1 行(0.1%)、LICA 690 行里 0 行,排除面可忽略。

(活跃天数可编辑已在 709aeef 完成:recommendDays 默认 90、动态传参。)
2026-09-11 14:33:23 +08:00
709aeefeda feat(stocktake): 活跃天数可编辑(默认 90 天)并补充勾选提示
- 把写死的 30 天改为响应式 recommendDays(默认 90,范围 1~365),
  界面上是一个 el-input-number;【获取推荐】按 ?days=<recommendDays>&top_n=<N>
  动态传参,不再固定 30。
- 推荐条数默认由 50 调整为 10(与「前 N 个」的实际使用节奏一致)。
- el-tree 上方加一行灰色小字提示:
  「💡 提示:勾选父级货架,将自动涵盖其下属的所有末级库位及库存。」
  对应已确认的决策——保持 el-tree 原生级联、后端精确匹配,
  勾父节点时其全部子孙 full_path 都会进入提交范围,故提示属实。

已确认的两项决策(本轮无代码改动,记录备查):
- 不使用 check-strictly,保留原生级联;右侧计数已足以提示规模
- 后端保持精确匹配,不做前缀匹配
2026-09-11 14:29:13 +08:00
80bd45d5d4 feat(stocktake): 抽盘库位选择改为左右双栏,已选明细可逐个移除
原库位树是单列、层级很深,用户勾完看不出到底选了哪些库位。

现改为左右双栏:
- 左 60%:el-tree(勾选),@check 触发 syncSelectedPaths
- 右 40%:已选明细面板 —— 顶部「已选 N 个库位」+ 清空按钮,
  el-scrollbar 内平铺 el-tag 展示完整路径(等宽字体)

双向互动:点击右侧标签的 × 会调 treeRef.setChecked(path, false, true)
同步取消左侧勾选后重算列表。注意 el-tree 的 setCheckedKeys / setChecked
都**不会**触发 @check 事件,因此这两处都显式调用了 syncSelectedPaths(),
保证左右状态强同步(获取推荐后右侧也会立刻渲染)。

★ 右侧刻意**不过滤非末级节点**,展示的就是提交给后端的 scope_config.locations
全集。原因:后端按 warehouse_location 精确匹配,而库存的库位可能落在层级 1/2
(实测 level1 有 9 个、level2 有 57 个),只提交叶子路径会把这些库存静默排除
在抽盘范围之外。面板所见 = 实际范围,才谈得上「强同步」。

配套:抽盘配置区是双栏、400px 窄容器装不下,故在展开该配置时把欢迎页
放宽到 780px(.idle-content.is-wide),其他状态维持原宽度。
2026-09-11 14:22:14 +08:00
e9468673c0 feat(stocktake): 抽盘范围改为「系统推荐 + 人工在库位树上勾选」
【后端】
- 新增 GET /stocktake/recommend-locations?days=30&top_n=50
  调 get_active_locations 返回推荐库位 full_path 列表 + moves/sku_count/
  last_move 明细。只推荐不落库,days/top_n 做范围钳制(1~365 / 1~500)。
- /draft/start-new 在 scope_type=active 时不再自行计算活跃库位,改为读取
  payload 的 scope_config.locations 并校验归一化(去空白、去重、保序、
  空列表 400、上限 2000)。范围决定权交还前端 UI —— 否则用户手改的勾选
  会被后端覆盖。

【前端】欢迎页选中「活跃库位抽盘」时展开配置区:
- 「近 30 天最活跃的前 [N] 个库位」+【获取推荐】
- el-tree(show-checkbox)数据取自 /v1/warehouse/tree,按公司前缀过滤
  (IRIS 只留 Y*,LICA 只留 C*/L*,复用 getAllowedLocPrefixes)
- 获取推荐后 setCheckedKeys 自动勾选;用户可自由增删
- 提交时 getCheckedNodes().map(n => n.full_path) 打包进 scope_config.locations

两个实现细节:
- 推荐里有、但树上勾不到的库位(不在当前公司前缀内等)会明确告警并打印,
  不让它们静默落选 —— 否则工人以为盘到了、实际没进范围。
- 已选库位数实时显示,因为 el-tree 默认级联:勾一个父节点会连带勾中整棵
  子树,规模可能远超推荐数量,需要让用户看得见。

实测:
  recommend-locations(top_n=10) → 10 个库位 + 明细
  start-new 传 3 个库位 → 落库正是这 3 个,总品项 36(全仓 1126)
  不传 locations → 400;勾选为空 → 400
  公司前缀过滤: IRIS→8 个(Y1~Y8),LICA→25 个,无越界
2026-09-11 14:13:10 +08:00
325cc44469 fix(stocktake): 拉开主副按钮层级防误触,明细增加库位列
【按钮层级】场景 A(已有进行中的盘点)
原实现主按钮是「加入当前盘点」,次入口是个不起眼的文字链接
「开启新一轮盘点」—— 二者视觉权重接近,而后者会终结本司其他 PDA
正在使用的会话,误触代价高。

现改为:
- 主按钮:绿色「继续当前盘点」,高度放到 66px、字号 19px
  (新增 .action-btn-hero),平板大屏上一眼可辨、一按即中
- 次按钮:红色镂空「结束当前并开启新盘点」,点击走 10 秒倒计时强确认
- 确认弹窗措辞由「原有数据将保留」改为明确告知**会话将被终结**:
  「该会话将标记为已结束,其他设备不能再加入」
- 倒计时确认通过后展开配置表单(而非直接开会话),让用户仍能选模式/范围;
  表单提交不再二次弹窗,避免重复确认

【库位列】明细抽屉新增「库位」列,紧跟规格之后 —— 找货刚需。
后端 merged-list 已下发该字段,实测单行返回含 warehouse_location='Y2/4/3/4',
无需改动。
2026-09-11 13:53:53 +08:00
eabe70b00a feat(stocktake): 落地动盘(活跃库位抽盘)与导出盲盘保护
【导出收尾】/export-stocktake
会话 active 且 mode=blind 时,汇总表/差异明细/账实相符/未盘点四张 Sheet 的
账面数与差异数一律输出 '—',实盘数照常输出。会话 finished 后自动解锁 ——
盘点结束后核对差异是正常流程,否则报告本身没法用。
批量替换时误伤了 update_stocktake_quantity 里的差异计算,被静态检查脚本
抓出(会拿占位符 '—' 做减法),已改回按真实账面值计算。

【动盘算法】get_active_locations(company_name, days=30, top_n=50)
近 N 天异动最频繁的库位。出库经 (source_table, stock_id) 回查库存表取库位
(trans_outbound.warehouse_location 历史上 900 条里 829 条为空,实测回查
覆盖率 298/299);入库以库存表自身的 in_date / production_date 计时;
借出用 trans_borrow.location。报废/维修表无库位字段,未纳入。

【范围冻结】/draft/start-new
移除 scope_type=active 的 400 拦截,改为在开单时算出活跃库位并**冻结**进
scope_config。必须开单时算好 —— 若各接口实时计算,两次调用之间活跃度变化
会导致范围漂移,结束盘点时把已掉出范围的库存误判成盘亏。

【同源过滤】all-items / merged-list / generate-missing
三者都读同一份冻结的 scope_config。generate-missing 是重中之重:漏盘比对
只针对范围内库存,绝不把范围外未盘库存标成盘亏。
merged-list 一并加了过滤(原指令未提),否则抽屉仍会列出范围外物资,
与「总品项」对不上。

【前端】移除「活跃库位抽盘」的 disabled。

实测(IRIS,全仓 1126 → 抽盘 537):
  生成盘亏 537 条 | 落在抽盘范围内 537 | 落在范围外 0
  导出: 盲盘 active → 账面数/差异数 = '—';盲盘 finished → 76 / -76(解锁)
2026-09-11 13:41:56 +08:00
97c1a9131c feat(stocktake): 明/盲盘由后端强制驱动,前端解除屏蔽
原实现是前端把「账面数」「差异」两列用 HTML 注释藏掉 —— 数据仍在
HTTP 响应里明文下发,抓包或用改过的客户端即可看到,等于没有屏蔽。

【后端】/draft/merged-list 先查该 session_id 的 StocktakeSession.mode:
- blind:stock_qty 与 diff_qty 一律置 None,真实账面数不出现在响应中
- open :正常下发
差异必须一并置空 —— diff_qty 由账面数算出,下发差异等于变相泄漏账面数。
响应新增 data.mode 供前端提示。

Fail-Safe:查不到会话行时按盲盘处理。宁可界面少显示两列,也不能因为
会话记录缺失就把账面数泄漏出去。

【前端】删除屏蔽用的 HTML 注释,恢复两列正常渲染,不再承担数据屏蔽职责。
但 null 必须渲染为「—」而非 0:原 差异 模板用 `row.diff_qty || 0`,
会把「未下发」显示成「无差异」,工人会误以为账实相符。
另在抽屉表头加模式标签,便于核对。

实测:
  盲盘 → 账面数=None 差异=None   mode=blind
  明盘 → 账面数=1.0  差异=-1.0   mode=open
  未知 session_id → 同盲盘(Fail-Safe 生效)
2026-09-11 13:34:36 +08:00