From 8d83d4404e1f6872afc473d92524774676e1b6c7 Mon Sep 17 00:00:00 2001 From: yueli Date: Thu, 17 Sep 2026 09:26:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(borrow):=20=E6=81=A2=E5=A4=8D=E9=80=89?= =?UTF-8?q?=E4=B8=AD=E5=AE=A1=E6=89=B9=E5=8D=95=E5=90=8E=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E5=B8=A6=E5=87=BA=E3=80=8D=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E9=A2=86=E7=94=A8=E4=BA=BA=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象 ---- 借库执行页选中一张已通过的审批单后,下方的「预计归还日期」「备注说明」 不再自动带出;「领用人」在改为用户下拉后也恒为空,库管每次都得手工补填。 根因(不在前端下拉框,而在后端持久化) ---- 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 项断言 全部通过;库存精确还原、无残留数据。 --- .../app/services/inventory_reservation.py | 52 ++++++++++++++++++- .../src/views/transaction/borrow.vue | 37 +++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/inventory-backend/app/services/inventory_reservation.py b/inventory-backend/app/services/inventory_reservation.py index 2ef7d73..754c7a5 100644 --- a/inventory-backend/app/services/inventory_reservation.py +++ b/inventory-backend/app/services/inventory_reservation.py @@ -233,6 +233,40 @@ def reserve_for_items(items, company_limit=None, strict=True): # ---- 立即扣减 available_quantity(软锁入池)---- models = stock_model_map() + + # ================================================================== + # ★ 透传申请明细的「意图字段」到预占结果 + # + # 背景:本函数产出的 reserved 会被调用方**直接持久化** —— + # borrow_service.submit_approval 中 `approval.set_items(reserved_items)`, + # 即审批单的 items_json 就是下面 reserved.append({...}) 的内容。 + # 而该 append 是**重建**字典,只保留库存定位与数量,会把调用方传入的 + # expected_return_time / is_indefinite / remark 全部丢弃 —— + # 执行页(扫码发货)选中审批单后因此带不出「预计归还日期」与「长期借用」, + # 库管只能每次手工补填。 + # + # 实测证据:2026-09-10 预占改造(b57c21a)上线后新建的审批单,items_json + # 中这两个字段全部消失;此前的老单据(id ≤ 40)仍保留。分界点与该提交的 + # 上线时刻(09-10 14:59)完全吻合。 + # + # 一条申请明细会被拆到多个批次行(同物料多批次),故按 base_id 建索引, + # 把同一份「意图」回填到它拆分出的每一行上。 + # ================================================================== + intent_by_base = {} + intent_by_name = {} + for it in items: + if it.get('base_id') is not None: + intent_by_base.setdefault(it.get('base_id'), it) + intent_by_name.setdefault( + (str(it.get('name') or '').strip(), + str(it.get('spec_model') or it.get('standard') or '').strip()), + it, + ) + + # 仅透传「申请意图」。库存定位(source_table/stock_id)与数量由分配结果决定, + # 绝不能被原明细覆盖 —— 实扫可能换批次,这里写什么,执行页就按什么释放。 + INTENT_FIELDS = ('expected_return_time', 'is_indefinite', 'remark') + reserved = [] for a in alloc_items: take = float(a.get('allocated_qty') or 0) @@ -256,7 +290,7 @@ def reserve_for_items(items, company_limit=None, strict=True): ) row.available_quantity = avail - take - reserved.append({ + item = { 'base_id': a.get('base_id'), 'name': a.get('name') or '', 'spec_model': a.get('standard') or a.get('spec_model') or '', @@ -267,7 +301,21 @@ def reserve_for_items(items, company_limit=None, strict=True): 'allocated_qty': take, 'quantity': take, # 兼容既有字段名(申请量=预占量) 'reserved': True, # ★ 标记该行已预占,释放时据此还原 - }) + } + + # ★ 回填申请意图(见上方 INTENT_FIELDS 说明)。 + # 跳过 None:无限期借用提交的 expected_return_time 就是 null, + # 写入 null 无意义,前端读到的仍是 undefined,行为一致。 + origin = intent_by_base.get(a.get('base_id')) or intent_by_name.get( + (str(a.get('name') or '').strip(), + str(a.get('standard') or a.get('spec_model') or '').strip()) + ) + if origin is not None: + for f in INTENT_FIELDS: + if origin.get(f) is not None: + item[f] = origin.get(f) + + reserved.append(item) return reserved, shortages diff --git a/inventory-web/src/views/transaction/borrow.vue b/inventory-web/src/views/transaction/borrow.vue index 1791641..7daa100 100644 --- a/inventory-web/src/views/transaction/borrow.vue +++ b/inventory-web/src/views/transaction/borrow.vue @@ -532,6 +532,37 @@ const handleApprovalChange = async (val: number | null) => { } // ★ 自动关联:把该借库申请单的“申请原因(remark)”自动带入执行备注,库管无需重复填写(可手动改) form.remark = (req && req.remark) || '' + + // ★ 领用人自动回填 + // + // 口径差异:审批单上的 borrower_name 存的是**完整 username** + // (形如「杜邢宸/duxingchen」),而人员名单返回的是展示名(「杜邢宸」, + // 后端已按斜杠前段截断)。两边都归一化到「斜杠前段」再比对, + // 否则永远匹配不上、下拉框恒为空。 + // + // 优先级:borrower_name 优先 —— 它才是「谁要借」的意图;库管代建时 + // applicant_id 是库管本人,回退到它会选错人。仅当审批单没记借用人姓名时 + // 才回退到申请人。 + // + // 匹配不到时**保持未选**,不回退到自由文本:借用人 ID 是转交/归还责任链的 + // 唯一锚点,宁可让库管手动选,也不能猜一个。 + await loadBorrowerOptions() + form.borrower_id = null + form.borrower_name = '' + if (req) { + const wantName = String(req.borrower_name || '').split('/')[0].trim() + let hit = wantName + ? borrowerOptions.value.find(u => String(u.name || '').split('/')[0].trim() === wantName) + : undefined + if (!hit && req.applicant_id) { + hit = borrowerOptions.value.find(u => u.id === req.applicant_id) + } + if (hit) { + form.borrower_id = hit.id + form.borrower_name = hit.name + } + } + cartItems.value = [] signatureFile.value = null signaturePreviewUrl.value = '' @@ -734,11 +765,17 @@ const form = reactive({ // ★ 借用人名单:与转交接收人、归还人共用同一数据源,按公司隔离 const borrowerOptions = ref>([]) +let borrowerOptionsLoaded = false +// 可重复调用:已加载则直接返回;必须可 await —— +// ★ 选中审批单时要拿这份名单反查借用人,若名单尚未返回就比对, +// 会误判为「找不到」而清空选择。 const loadBorrowerOptions = async () => { + if (borrowerOptionsLoaded) return try { const res: any = await getBorrowUsers() borrowerOptions.value = res?.data || [] + borrowerOptionsLoaded = true } catch { borrowerOptions.value = [] }