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 项断言
  全部通过;库存精确还原、无残留数据。
This commit is contained in:
yueli
2026-09-17 09:26:42 +08:00
parent ece5dd8d72
commit 8d83d4404e
2 changed files with 87 additions and 2 deletions

View File

@ -233,6 +233,40 @@ def reserve_for_items(items, company_limit=None, strict=True):
# ---- 立即扣减 available_quantity软锁入池----
models = stock_model_map()
# ==================================================================
# ★ 透传申请明细的「意图字段」到预占结果
#
# 背景:本函数产出的 reserved 会被调用方**直接持久化** ——
# borrow_service.submit_approval 中 `approval.set_items(reserved_items)`
# 即审批单的 items_json 就是下面 reserved.append({...}) 的内容。
# 而该 append 是**重建**字典,只保留库存定位与数量,会把调用方传入的
# expected_return_time / is_indefinite / remark 全部丢弃 ——
# 执行页(扫码发货)选中审批单后因此带不出「预计归还日期」与「长期借用」,
# 库管只能每次手工补填。
#
# 实测证据2026-09-10 预占改造b57c21a上线后新建的审批单items_json
# 中这两个字段全部消失此前的老单据id ≤ 40仍保留。分界点与该提交的
# 上线时刻09-10 14:59完全吻合。
#
# 一条申请明细会被拆到多个批次行(同物料多批次),故按 base_id 建索引,
# 把同一份「意图」回填到它拆分出的每一行上。
# ==================================================================
intent_by_base = {}
intent_by_name = {}
for it in items:
if it.get('base_id') is not None:
intent_by_base.setdefault(it.get('base_id'), it)
intent_by_name.setdefault(
(str(it.get('name') or '').strip(),
str(it.get('spec_model') or it.get('standard') or '').strip()),
it,
)
# 仅透传「申请意图」。库存定位source_table/stock_id与数量由分配结果决定
# 绝不能被原明细覆盖 —— 实扫可能换批次,这里写什么,执行页就按什么释放。
INTENT_FIELDS = ('expected_return_time', 'is_indefinite', 'remark')
reserved = []
for a in alloc_items:
take = float(a.get('allocated_qty') or 0)
@ -256,7 +290,7 @@ def reserve_for_items(items, company_limit=None, strict=True):
)
row.available_quantity = avail - take
reserved.append({
item = {
'base_id': a.get('base_id'),
'name': a.get('name') or '',
'spec_model': a.get('standard') or a.get('spec_model') or '',
@ -267,7 +301,21 @@ def reserve_for_items(items, company_limit=None, strict=True):
'allocated_qty': take,
'quantity': take, # 兼容既有字段名(申请量=预占量)
'reserved': True, # ★ 标记该行已预占,释放时据此还原
})
}
# ★ 回填申请意图(见上方 INTENT_FIELDS 说明)。
# 跳过 None无限期借用提交的 expected_return_time 就是 null
# 写入 null 无意义,前端读到的仍是 undefined行为一致。
origin = intent_by_base.get(a.get('base_id')) or intent_by_name.get(
(str(a.get('name') or '').strip(),
str(a.get('standard') or a.get('spec_model') or '').strip())
)
if origin is not None:
for f in INTENT_FIELDS:
if origin.get(f) is not None:
item[f] = origin.get(f)
reserved.append(item)
return reserved, shortages

View File

@ -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<Array<{ id: number; name: string }>>([])
let borrowerOptionsLoaded = false
// 可重复调用:已加载则直接返回;必须可 await ——
// ★ 选中审批单时要拿这份名单反查借用人,若名单尚未返回就比对,
// 会误判为「找不到」而清空选择。
const loadBorrowerOptions = async () => {
if (borrowerOptionsLoaded) return
try {
const res: any = await getBorrowUsers()
borrowerOptions.value = res?.data || []
borrowerOptionsLoaded = true
} catch {
borrowerOptions.value = []
}