feat(borrow): 前端适配整单转交与双向握手

· 转交弹窗由「选择明细」改为**整单**:列出该单全部未还物品(只读),
  明确告知「发起后对方确认,责任才转移」。原先让用户挑一行,挑中就只转
  那一行 —— 正是单内撕裂的入口,现从 UI 上根除。
· 操作栏新增【接收转交】【拒绝】:仅对 pending_transfer.is_mine 的行显示,
  即「待我接收」的转交。已有 PENDING 时不再显示【转交】,避免重复发起。
· 状态列新增「转交待确认」:此时主表 current_holder 未变(东西还在原持有人
  手上),必须用状态列把「责任正在转移中」显式表达出来,否则界面上看不出
  任何变化。
· 新增 acceptBorrowTransfer / rejectBorrowTransfer API;
  拒绝时用 prompt 收集可选原因,写入转交流水备注。

错误提示仍交由 request 拦截器统一 toast,不在本地重复弹出。
This commit is contained in:
yueli
2026-09-17 10:04:16 +08:00
parent 1a8e3e3dc0
commit 263f7ba3a1
2 changed files with 149 additions and 42 deletions

View File

@ -283,6 +283,31 @@ export function getBorrowHistory(borrowId: number) {
})
}
/**
* 确认接收转交(双向握手第二步)—— 责任正式转移到接收人名下。
*
* ★ 仅该转交的 to_user_id 本人可调用(后端强校验,非库管权限)。
* ★ 副作用:该单**全部未归还明细**的当前持有人一并变更。
*/
export function acceptBorrowTransfer(transferId: number) {
return request({
url: `/v1/transactions/borrow/transfer/${transferId}/accept`,
method: 'post'
})
}
/**
* 拒绝转交 —— 主表不动,责任仍在原持有人。权限同上。
* @param reason 可选拒绝原因,写入转交流水备注
*/
export function rejectBorrowTransfer(transferId: number, reason?: string) {
return request({
url: `/v1/transactions/borrow/transfer/${transferId}/reject`,
method: 'post',
data: { reason }
})
}
/**
* 整单流转时间线(**borrow_no** 维度):借出 → 转交(可多次) → 归还 → 报废
*

View File

@ -204,9 +204,13 @@
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="110" align="center">
<template #default="{row}">
<el-tag v-if="row.status === 'scrapped'" type="info">已报废</el-tag>
<!-- 中间态已发起转交但接收人尚未确认
此时主表 current_holder **未变**东西还在原持有人手上
故必须用状态列把责任正在转移中显式表达出来 -->
<el-tag v-if="row.pending_transfer" type="warning">转交待确认</el-tag>
<el-tag v-else-if="row.status === 'scrapped'" type="info">已报废</el-tag>
<el-tag v-else-if="row.status === 'returned'" type="success">已还</el-tag>
<el-tag v-else-if="row.status === 'partial_returned'" type="warning">部分归还</el-tag>
<el-tag v-else type="danger">未还</el-tag>
@ -232,16 +236,32 @@
<!-- 操作列
· 申请报废 / 转交 仅未归还记录且分别受各自权限约束
· 流转记录 无特殊权限要求凡能看到本列表者皆可查看 -->
<el-table-column label="操作" width="210" align="center" fixed="right">
<el-table-column label="操作" width="250" align="center" fixed="right">
<template #default="{row}">
<!-- 待我接收的转交最高优先级接收人必须一眼看到并处理
东西还在对方手上责任尚未转移 见后端双向握手设计 -->
<template v-if="row.pending_transfer && row.pending_transfer.is_mine">
<el-button
type="success" link size="small"
:loading="transferActionLoading"
@click="confirmAcceptTransfer(row)"
>接收转交</el-button>
<el-button
type="danger" link size="small"
:loading="transferActionLoading"
@click="confirmRejectTransfer(row)"
>拒绝</el-button>
</template>
<el-button
v-if="canScrap && isUnreturned(row)"
type="danger" link size="small"
@click="openScrapDialog(row)"
>申请报废</el-button>
<!-- 已有待接收的转交时不再显示转交避免重复发起后端也会拒 -->
<el-button
v-if="isUnreturned(row)"
v-if="isUnreturned(row) && !row.pending_transfer"
v-permission="'borrow_transfer'"
type="primary" link size="small"
@click="openTransferDialog(row)"
@ -308,11 +328,11 @@
</template>
</el-dialog>
<!-- 借库转交弹窗一期仅支持整单全部转交 -->
<el-dialog v-model="transferDialogVisible" title="借库转交" width="640px" destroy-on-close>
<!-- 借库转交弹窗整单转交 + 双向握手 -->
<el-dialog v-model="transferDialogVisible" title="发起借库转交" width="640px" destroy-on-close>
<el-alert
title="仅支持整单全部转交"
description="一张借出单同时只能有一个持有人。部分转交会让「当前持有人」无法确定,故一期不支持;如需拆分持有,请先办理部分归还,再对剩余量转交。"
title="整单转交,且需对方确认后责任才转移"
description="发起后只生成一条「待接收」记录,东西仍在您手上、责任仍归您;对方在其借用列表中确认接收后,本单全部未还物品的持有责任才整体转移给他。"
type="warning"
:closable="false"
show-icon
@ -320,21 +340,11 @@
/>
<div style="margin-bottom: 10px;">
<div style="font-weight: 600; margin-bottom: 6px;">单号{{ transferBorrowNo }}</div>
<div style="font-size: 13px; color: #909399;">请选择要转交的明细转交后该明细的持有人整体变更为接收人</div>
<div style="font-size: 13px; color: #909399;">
以下 {{ transferCandidates.length }} 项未归还物品将<strong>一并</strong>转交 {{ transferTotalQty }}
</div>
</div>
<el-table
:data="transferCandidates"
border
size="small"
max-height="260"
highlight-current-row
@row-click="(row: any) => transferTargetId = row.id"
>
<el-table-column width="46" align="center">
<template #default="{row}">
<el-radio v-model="transferTargetId" :value="row.id" :label="row.id">&nbsp;</el-radio>
</template>
</el-table-column>
<el-table :data="transferCandidates" border size="small" max-height="240">
<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="120" align="center">
@ -371,7 +381,7 @@
</el-form>
<template #footer>
<el-button @click="transferDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="transferSubmitting" @click="confirmTransfer">确认转交</el-button>
<el-button type="primary" :loading="transferSubmitting" @click="confirmTransfer">发起转交</el-button>
</template>
</el-dialog>
@ -436,6 +446,8 @@ import { getApproversList } from '@/api/auth'
import {
submitBorrowScrapRequest,
transferBorrow,
acceptBorrowTransfer,
rejectBorrowTransfer,
getBorrowUsers,
getBorrowSlipHistory,
} from '@/api/transaction'
@ -691,13 +703,18 @@ const latest = (arr: any[]): string | null =>
const transferDialogVisible = ref(false)
const transferSubmitting = ref(false)
const transferActionLoading = ref(false) // 接收/拒绝按钮 loading
const transferBorrowNo = ref('')
const transferCandidates = ref<any[]>([])
const transferTargetId = ref<number | null>(null)
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 loadTransferUsers = async () => {
if (transferUsers.value.length) return // 名单会话内复用,避免每次开弹窗都请求
try {
@ -717,8 +734,6 @@ const openTransferDialog = (row: any) => {
}
transferBorrowNo.value = row.borrow_no
transferCandidates.value = candidates
// 实测 54/60 的单号只有 1 条明细 —— 单条时直接选中,省掉一次点击
transferTargetId.value = candidates.length === 1 ? candidates[0].id : null
transferToUserId.value = null
transferRemark.value = ''
transferDialogVisible.value = true
@ -726,25 +741,30 @@ const openTransferDialog = (row: any) => {
}
const confirmTransfer = async () => {
if (!transferTargetId.value) return ElMessage.warning('请选择要转交的明细')
if (!transferToUserId.value) return ElMessage.warning('请选择接收人')
const target = transferCandidates.value.find((c: any) => c.id === transferTargetId.value)
// ★ 整单转交:以后端为准,不再让用户挑明细。
// 原实现只转所选的那一行,一张 2 明细的单转完会出现两个持有人
// (前端按单号聚合就同时显示两个名字)—— 这是必须消灭的单内撕裂。
const receiver = transferUsers.value.find(u => u.id === transferToUserId.value)
// 提前拦截「转给自己」,省一次无效往返(后端也会拒)
if (target && receiver && target.current_holder_id === receiver.id) {
return ElMessage.warning(`接收人与当前持有人同为【${receiver.name}】,无需转交`)
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}】,无需转交`)
}
const qty = target?.pending_quantity ?? 0
const qty = transferTotalQty.value
const anchorId = transferCandidates.value[0].id
try {
await ElMessageBox.confirm(
`将【${target?.material_name || target?.sku || ''}】的持有权整单转交给【${receiver?.name || ''}】?\n\n`
+ `转交数量:${qty}(整单全量,不支持部分转交)\n`
+ `转交后,该物品须由接收人本人办理归还。`,
'转交确认',
{ confirmButtonText: '确认转交', cancelButtonText: '取消', type: 'warning' }
`将【${transferBorrowNo.value}】整单(${transferCandidates.value.length} 项,共 ${qty} 件)\n`
+ `的持有权转交给【${receiver?.name || ''}】?\n\n`
+ `发起后对方需在其借用列表中确认接收,责任才正式转移;\n`
+ `在对方确认前,物品与责任仍归当前持有人。`,
'发起转交',
{ confirmButtonText: '确认发起', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
@ -752,15 +772,15 @@ const confirmTransfer = async () => {
transferSubmitting.value = true // ★ 按钮 loading 防抖,避免重复提交
try {
const res: any = await transferBorrow(transferTargetId.value, {
transfer_qty: qty, // 一期必须等于待还量,后端二次校验
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 || '转交成功')
ElMessage.success(res?.msg || '已发起转交,等待对方确认')
transferDialogVisible.value = false
fetchData() // 刷新列表:持有人/操作列状态已变化
fetchData() // 刷新列表:状态列变为「转交待确认」
} catch {
// ★ 不重复弹错request 拦截器已按后端 msg 全局 toast此处再弹会双份
} finally {
@ -768,6 +788,68 @@ 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
try {
await ElMessageBox.confirm(
`确认接收【${pt.from_user_name || '对方'}】转交的这批资产?\n\n`
+ `单号:${row.borrow_no}${cnt} 项)\n`
+ `确认后,该单全部未归还物品的持有责任转移到您名下,归还时须由您本人办理。`,
'确认接收转交',
{ confirmButtonText: '确认接收', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
transferActionLoading.value = true
try {
const res: any = await acceptBorrowTransfer(pt.id)
ElMessage.success(res?.msg || '已接收')
fetchData()
} catch {
// 错误提示由 request 拦截器统一处理
} finally {
transferActionLoading.value = false
}
}
const confirmRejectTransfer = async (row: any) => {
const pt = row.pending_transfer
if (!pt) return
let reason = ''
try {
const r: any = await ElMessageBox.prompt(
`拒绝【${pt.from_user_name || '对方'}】转交给您的这批资产?\n拒绝后责任仍由对方承担。`,
'拒绝转交',
{
confirmButtonText: '确认拒绝',
cancelButtonText: '取消',
inputPlaceholder: '拒绝原因(可留空)',
inputValidator: () => true, // 允许留空,后端 reason 为可选
type: 'warning',
}
)
reason = r?.value || ''
} catch {
return
}
transferActionLoading.value = true
try {
const res: any = await rejectBorrowTransfer(pt.id, reason)
ElMessage.success(res?.msg || '已拒绝')
fetchData()
} catch {
// 同上
} finally {
transferActionLoading.value = false
}
}
// ==========================================================================
// ★ 流转明细时间线(借出 → 转交(可多次) → 归还 → 报废)
// ==========================================================================