feat(borrow): 前端适配明细行粒度转交(部分转交)

主行聚合
----
「当前持有人」列改为三态:
  · 单内只有 1 个持有人 → 显示该姓名(转过交时蓝色加粗)
  · 单内有多个持有人   → 灰色标签「多人持有」
  · 全部回库           → 「已回库」
★ 多持有人不再是异常:它是「借 2 件只转了 1 件」的正常形态。主行无法一一
  列清谁拿哪件,故只做提示,具体归属下沉到明细行。

明细行
----
· 新增「当前持有人」列:能看到**具体哪件物品在谁手里**,待接收的另挂
  「转交待确认」小标签。
· 新增操作列:【转交】(权限 borrow_transfer)、【接收】【拒绝】(待我接收时)——
  转交粒度既然下沉到明细,这里才是精准入口。

转交弹窗(主行/明细行共用)
----
· 由「整单转交」改为**勾选式**:列出该单未还明细,可勾选其中若干件一并转交,
  也可只转一件。已有待接收的明细置灰不可选。
· 从明细行进入时只预选该件;从主行进入时默认全选可转交项。
· 逐项发起、逐项成立流水:刻意不做「全部成功才算成功」的伪原子 ——
  物理上每件物品本就是独立交接的,部分成功同样有意义。失败项由拦截器
  提示原因,列表刷新后未成功的仍显示为可转交。

接收/拒绝
----
明细行按钮处理自己那一条;主行按钮聚合处理该单下所有属于我的待接收
(部分转交下单内可能有多项),并汇总提示「已接收 N/M 项」。
This commit is contained in:
yueli
2026-09-17 10:12:36 +08:00
parent 7d9cdeb297
commit 1bb3f59bb7

View File

@ -130,6 +130,37 @@
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<!-- ★ 明细级持有人:部分转交后主行只能显示「多人持有」,
到底哪件在谁手里,必须在这一层看清楚 -->
<el-table-column label="当前持有人" width="130">
<template #default="{row: c}">
<span
v-if="c.current_holder_name"
class="holder-name"
:class="{ 'is-transferred': isRowTransferred(c) }"
>{{ formatName(c.current_holder_name) }}</span>
<span v-else class="text-info">已回库</span>
<el-tag v-if="c.pending_transfer" type="warning" size="small" style="margin-left:4px">
转交待确认
</el-tag>
</template>
</el-table-column>
<!-- ★ 明细级操作:转交粒度已下沉到明细行,这里才是精准入口 -->
<el-table-column label="操作" width="170" align="center">
<template #default="{row: c}">
<template v-if="c.pending_transfer && c.pending_transfer.is_mine">
<el-button type="success" link size="small" @click="acceptOne(c)">接收</el-button>
<el-button type="danger" link size="small" @click="rejectOne(c)">拒绝</el-button>
</template>
<el-button
v-else-if="(c.pending_quantity || 0) > 0 && !c.pending_transfer"
v-permission="'borrow_transfer'"
type="primary" link size="small"
@click="openTransferDialog(props.row, c)"
>转交</el-button>
<span v-else-if="!c.pending_transfer" class="text-info">—</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
@ -147,14 +178,15 @@
标签,一屏几十个色块会把真正需要关注的「已转交」淹没。 -->
<el-table-column label="当前持有人" width="140">
<template #default="{row}">
<template v-if="currentHolderList(row).length">
<span
v-for="h in currentHolderList(row)"
:key="h.name"
class="holder-name"
:class="{ 'is-transferred': h.transferred }"
>{{ formatName(h.name) }}</span>
</template>
<!-- ★ 单内多个持有人 = 部分转交(借 2 件只转了 1 件),是**正常业务形态**,
不是异常。主行无法一一列清谁拿哪件,故用灰标签提示,
具体归属看展开后的明细行。 -->
<el-tag v-if="currentHolderList(row).length > 1" type="info" size="small">多人持有</el-tag>
<span
v-else-if="currentHolderList(row).length === 1"
class="holder-name"
:class="{ 'is-transferred': currentHolderList(row)[0].transferred }"
>{{ formatName(currentHolderList(row)[0].name) }}</span>
<span v-else-if="row.status === 'returned' || row.status === 'scrapped'" class="text-info">已回库</span>
<span v-else class="text-info">—</span>
</template>
@ -239,17 +271,18 @@
<el-table-column label="操作" width="250" align="center" fixed="right">
<template #default="{row}">
<!-- ★ 待我接收的转交:最高优先级,接收人必须一眼看到并处理。
东西还在对方手上,责任尚未转移 —— 见后端双向握手设计。 -->
<template v-if="row.pending_transfer && row.pending_transfer.is_mine">
东西还在对方手上,责任尚未转移 —— 见后端双向握手设计。
主行聚合该单下所有属于我的待接收(部分转交下单内可能有多项)。 -->
<template v-if="myPendings(row).length">
<el-button
type="success" link size="small"
:loading="transferActionLoading"
@click="confirmAcceptTransfer(row)"
@click="acceptSlip(row)"
>接收转交</el-button>
<el-button
type="danger" link size="small"
:loading="transferActionLoading"
@click="confirmRejectTransfer(row)"
@click="rejectSlip(row)"
>拒绝</el-button>
</template>
@ -259,9 +292,10 @@
@click="openScrapDialog(row)"
>申请报废</el-button>
<!-- 已有待接收的转交时不再显示「转交」,避免重复发起(后端也会拒) -->
<!-- 主行入口:进入弹窗后勾选具体物品(默认全选可转交项)。
精准的单件转交在展开后的明细行操作列里,两者共用同一个弹窗。 -->
<el-button
v-if="isUnreturned(row) && !row.pending_transfer"
v-if="isUnreturned(row)"
v-permission="'borrow_transfer'"
type="primary" link size="small"
@click="openTransferDialog(row)"
@ -341,12 +375,24 @@
<div style="margin-bottom: 10px;">
<div style="font-weight: 600; margin-bottom: 6px;">单号:{{ transferBorrowNo }}</div>
<div style="font-size: 13px; color: #909399;">
以下 {{ transferCandidates.length }} 项未归还物品将<strong>一并</strong>转交(共 {{ transferTotalQty }} 件):
勾选要转交的物品(已选 {{ transferSelected.length }} 项,共 {{ transferTotalQty }} 件)。
转交粒度是<strong>单件物品</strong>,可以只转其中一件。
</div>
</div>
<el-table :data="transferCandidates" border size="small" max-height="240">
<el-table-column width="46" align="center">
<template #default="{row}">
<el-checkbox v-model="row.selected" :disabled="row.disabled" />
</template>
</el-table-column>
<el-table-column prop="material_name" label="物料名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="110" show-overflow-tooltip />
<el-table-column label="状态" width="100" align="center">
<template #default="{row}">
<el-tag v-if="row.disabled" type="warning" size="small">转交待确认</el-tag>
<span v-else class="text-info">可转交</span>
</template>
</el-table-column>
<el-table-column label="当前持有人" width="120" align="center">
<template #default="{row}">
<span
@ -710,10 +756,21 @@ const transferToUserId = ref<number | null>(null)
const transferRemark = ref('')
const transferUsers = ref<Array<{ id: number; name: string }>>([])
// 整单待还合计 —— 与后端按 borrow_no 汇总的口径一致
const transferTotalQty = computed(() =>
transferCandidates.value.reduce((s: number, c: any) => s + (Number(c.pending_quantity) || 0), 0)
// 弹窗内已勾选的明细(默认全选可转交项)
const transferSelected = computed(() =>
transferCandidates.value.filter((c: any) => c.selected)
)
// 已勾选项的待还合计
const transferTotalQty = computed(() =>
transferSelected.value.reduce((s: number, c: any) => s + (Number(c.pending_quantity) || 0), 0)
)
// 该单下所有「待我接收」的转交 —— 主行聚合用。
// (明细行只看自己那一条,见模板里的 c.pending_transfer)
const myPendings = (row: any): any[] =>
(row.children || [])
.map((c: any) => c.pending_transfer)
.filter((p: any) => p && p.is_mine)
const loadTransferUsers = async () => {
if (transferUsers.value.length) return // 名单会话内复用,避免每次开弹窗都请求
@ -725,15 +782,29 @@ const loadTransferUsers = async () => {
}
}
const openTransferDialog = (row: any) => {
// 仅未还清的明细可转交(与后端的状态准入一致)
/**
* 打开转交弹窗。
*
* @param row 主行(单号层),用它的 children 作为候选明细
* @param detail 从**明细行**进入时传入该明细,只预选这一条;
* 从主行进入则不传,默认勾选全部可转交项。
*
* ★ 转交粒度已下沉到明细行:一张单可以只转其中一件(借 2 件转 1 件),
* 故这里用勾选而非「整单」。
*/
const openTransferDialog = (row: any, detail?: any) => {
const candidates = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
if (!candidates.length) {
ElMessage.warning('该单号下没有未归还的明细,无法转交')
return
}
transferBorrowNo.value = row.borrow_no
transferCandidates.value = candidates
// 已有待接收的明细不可再发起(后端也会拒)——直接置灰,避免用户白填一遍
transferCandidates.value = candidates.map((c: any) => ({
...c,
selected: detail ? c.id === detail.id : !c.pending_transfer,
disabled: !!c.pending_transfer,
}))
transferToUserId.value = null
transferRemark.value = ''
transferDialogVisible.value = true
@ -741,28 +812,21 @@ const openTransferDialog = (row: any) => {
}
const confirmTransfer = async () => {
const picked = transferSelected.value
if (!picked.length) return ElMessage.warning('请至少勾选一项要转交的物品')
if (!transferToUserId.value) return ElMessage.warning('请选择接收人')
// ★ 整单转交:以后端为准,不再让用户挑明细。
// 原实现只转所选的那一行,一张 2 明细的单转完会出现两个持有人
// (前端按单号聚合就同时显示两个名字)—— 这是必须消灭的单内撕裂。
const receiver = transferUsers.value.find(u => u.id === transferToUserId.value)
const holderIds = new Set(
transferCandidates.value.map((c: any) => c.current_holder_id).filter((v: any) => v != null)
)
if (holderIds.size === 1 && holderIds.has(transferToUserId.value as number)) {
return ElMessage.warning(`接收人与当前持有人同为【${receiver?.name}】,无需转交`)
if (picked.every((c: any) => c.current_holder_id === transferToUserId.value)) {
return ElMessage.warning(`所选物品的当前持有人已是【${receiver?.name}】,无需转交`)
}
const qty = transferTotalQty.value
const anchorId = transferCandidates.value[0].id
try {
await ElMessageBox.confirm(
`将【${transferBorrowNo.value}】整单(${transferCandidates.value.length} 项,共 ${qty} 件)\n`
+ `的持有权转交给【${receiver?.name || ''}】?\n\n`
`将【${transferBorrowNo.value}】下的 ${picked.length} 项物品(共 ${qty} 件)转交给【${receiver?.name || ''}】?\n\n`
+ `发起后对方需在其借用列表中确认接收,责任才正式转移;\n`
+ `在对方确认前,物品与责任仍归当前持有人。`,
+ `在对方确认前,物品与责任仍归各自的当前持有人。`,
'发起转交',
{ confirmButtonText: '确认发起', cancelButtonText: '取消', type: 'warning' }
)
@ -770,19 +834,31 @@ const confirmTransfer = async () => {
return // 用户取消
}
transferSubmitting.value = true // ★ 按钮 loading 防抖,避免重复提交
transferSubmitting.value = true
// ★ 逐项发起:转交粒度是明细行,每一项各自成立一条待接收流水。
// 刻意不做「全部成功才算成功」的伪原子 —— 物理上每件物品本就是独立交接的,
// 部分成功同样有意义。失败项由 request 拦截器 toast 具体原因,
// 列表刷新后未成功的仍显示为可转交。
let ok = 0
try {
const res: any = await transferBorrow(anchorId, {
transfer_qty: qty, // 整单待还合计,后端二次校验
to_user_id: transferToUserId.value,
to_user_name: receiver?.name,
remark: transferRemark.value,
})
ElMessage.success(res?.msg || '已发起转交,等待对方确认')
transferDialogVisible.value = false
fetchData() // 刷新列表:状态列变为「转交待确认」
} catch {
// ★ 不重复弹错:request 拦截器已按后端 msg 全局 toast,此处再弹会双份
for (const c of picked) {
try {
await transferBorrow(c.id, {
transfer_qty: Number(c.pending_quantity) || 0, // 整行转交,后端二次校验
to_user_id: transferToUserId.value,
to_user_name: receiver?.name,
remark: transferRemark.value,
})
ok++
} catch {
// 单条失败不中断其余
}
}
if (ok) {
ElMessage.success(`已发起 ${ok}/${picked.length} 项转交,等待对方确认`)
transferDialogVisible.value = false
fetchData()
}
} finally {
transferSubmitting.value = false
}
@ -790,41 +866,35 @@ const confirmTransfer = async () => {
// ==========================================================================
// ★ 双向握手第二步:接收人确认 / 拒绝
// 粒度同为明细行 —— 明细行按钮处理自己那一条;主行按钮聚合处理该单下
// 所有属于我的待接收。
// ==========================================================================
const confirmAcceptTransfer = async (row: any) => {
const pt = row.pending_transfer
if (!pt) return
const cnt = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0).length
const doAccept = async (pt: any, label: string): Promise<boolean> => {
try {
await ElMessageBox.confirm(
`确认接收【${pt.from_user_name || '对方'}】转交的这批资产?\n\n`
+ `单号:${row.borrow_no}(${cnt} 项)\n`
+ `确认后,该单全部未归还物品的持有责任转移到您名下,归还时须由您本人办理。`,
`确认接收【${pt.from_user_name || '对方'}】转交的物品?\n\n`
+ `${label}\n`
+ `确认后,该物品的持有责任转移到您名下,归还时须由您本人办理。`,
'确认接收转交',
{ confirmButtonText: '确认接收', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
return false
}
transferActionLoading.value = true
try {
const res: any = await acceptBorrowTransfer(pt.id)
ElMessage.success(res?.msg || '已接收')
fetchData()
return true
} catch {
// 错误提示由 request 拦截器统一处理
} finally {
transferActionLoading.value = false
return false // 错误提示由 request 拦截器统一处理
}
}
const confirmRejectTransfer = async (row: any) => {
const pt = row.pending_transfer
if (!pt) return
const doReject = async (pt: any, label: string): Promise<boolean> => {
let reason = ''
try {
const r: any = await ElMessageBox.prompt(
`拒绝【${pt.from_user_name || '对方'}】转交给您的这批资产?\n拒绝后责任仍由对方承担。`,
`拒绝【${pt.from_user_name || '对方'}】转交给您的物品?\n拒绝后责任仍由对方承担。\n\n${label}`,
'拒绝转交',
{
confirmButtonText: '确认拒绝',
@ -835,16 +905,97 @@ const confirmRejectTransfer = async (row: any) => {
}
)
reason = r?.value || ''
} catch {
return false
}
try {
const res: any = await rejectBorrowTransfer(pt.id, reason)
ElMessage.success(res?.msg || '已拒绝')
return true
} catch {
return false
}
}
// --- 明细行入口(精准处理自己那一条)---
const acceptOne = async (detail: any) => {
const label = `物品:${detail.material_name || detail.sku || ''}`
transferActionLoading.value = true
try {
if (await doAccept(detail.pending_transfer, label)) fetchData()
} finally {
transferActionLoading.value = false
}
}
const rejectOne = async (detail: any) => {
const label = `物品:${detail.material_name || detail.sku || ''}`
transferActionLoading.value = true
try {
if (await doReject(detail.pending_transfer, label)) fetchData()
} finally {
transferActionLoading.value = false
}
}
// --- 主行入口(聚合该单下所有属于我的待接收)---
const acceptSlip = async (row: any) => {
const mine = myPendings(row)
if (!mine.length) return
try {
await ElMessageBox.confirm(
`确认接收该单下 ${mine.length} 项转交给您的物品?\n\n单号:${row.borrow_no}\n`
+ `确认后,这些物品的持有责任一并转移到您名下。`,
'确认接收转交',
{ confirmButtonText: '确认接收', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
transferActionLoading.value = true
try {
const res: any = await rejectBorrowTransfer(pt.id, reason)
ElMessage.success(res?.msg || '已拒绝')
fetchData()
let ok = 0
for (const pt of mine) {
try {
await acceptBorrowTransfer(pt.id)
ok++
} catch {
// 单条失败不中断其余(拦截器已提示原因)
}
}
ElMessage.success(`已接收 ${ok}/${mine.length} 项`)
if (ok) fetchData()
} finally {
transferActionLoading.value = false
}
}
const rejectSlip = async (row: any) => {
const mine = myPendings(row)
if (!mine.length) return
try {
await ElMessageBox.confirm(
`拒绝该单下 ${mine.length} 项转交给您的物品?\n\n单号:${row.borrow_no}\n`
+ `拒绝后责任仍由对方承担。`,
'拒绝转交',
{ confirmButtonText: '确认拒绝', cancelButtonText: '取消', type: 'warning' }
)
} catch {
// 同上
return
}
transferActionLoading.value = true
try {
let ok = 0
for (const pt of mine) {
try {
await rejectBorrowTransfer(pt.id)
ok++
} catch {
// 同上
}
}
ElMessage.success(`已拒绝 ${ok}/${mine.length} 项`)
if (ok) fetchData()
} finally {
transferActionLoading.value = false
}