Files
KCGL/inventory-web/src/views/transaction/records.vue
yueli 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

1074 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="userStore.hasPermission('op_records')" class="app-container">
<!-- ★ 高级搜索:与出库记录 / 报废记录统一版式 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item>
<el-radio-group v-model="status" @change="handleSearchImmediate">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="borrowed">未归还</el-radio-button>
<el-radio-button label="returned">已归还</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="搜索">
<el-input
v-model="keyword"
placeholder="输入关键词"
style="width: 240px"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="searchType" placeholder="类型" style="width: 110px" @change="handleSearchImmediate">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="借用人" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="借出日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
<el-popover
v-model:visible="advancedFilterVisible"
placement="bottom"
title="高级筛选"
width="600"
trigger="manual"
>
<template #reference>
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
高级筛选
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
</el-button>
</template>
<div class="advanced-filter">
<div
v-for="(condition, index) in advancedConditions"
:key="index"
class="condition-row"
>
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
</el-select>
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
</el-select>
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
</div>
<div style="margin-top:12px">
<el-button type="primary" link @click="addCondition">添加条件</el-button>
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
<el-button @click="resetAdvancedFilter">重置</el-button>
</div>
</div>
</el-popover>
</el-form-item>
</el-form>
<el-table
:data="list"
border
stripe
style="margin-top:20px"
v-loading="loading"
type="expand"
>
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px;">
<h4 style="margin: 0 0 10px; font-size: 14px; color: #606266;">借出明细</h4>
<el-table :data="props.row.children" border size="small">
<el-table-column prop="material_name" label="物料名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="借出数量" width="80" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已还数量" width="80" align="center">
<template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="80" align="center">
<template #default="{row}">
<el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag>
<el-tag v-else type="success">0</el-tag>
</template>
</el-table-column>
<el-table-column prop="location" label="借出库位" min-width="120">
<template #default="{row}">
<span v-if="row.location">{{ row.location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column prop="return_location" label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrower_name')" label="借用人" width="100">
<template #default="{row}">
<span class="name-fixed">{{ formatName(row.borrower_name) || '—' }}</span>
</template>
</el-table-column>
<!-- ★ 当前持有人:转交后可能与「借用人」不是同一人;一张单的明细持有人
也可能各不相同,故去重后逐个展示。
表达上做了降噪:未转交=普通文本,转过交=主题色加粗。原先一律用橙色
标签,一屏几十个色块会把真正需要关注的「已转交」淹没。 -->
<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>
<span v-else-if="row.status === 'returned' || row.status === 'scrapped'" class="text-info">已回库</span>
<span v-else class="text-info">—</span>
</template>
</el-table-column>
<!-- ★ 去掉 sortable:这是 Element Plus 的**前端排序**,只会重排当前页的
10 条,反而打乱后端按单号聚合的「逾期优先」排序,让列表看起来没规律。
若将来确实需要按其他字段排序,应改为 sortable="custom" 并由后端接管。 -->
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" />
<el-table-column label="借出物品" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.children ? row.children.length : 0 }} 项</el-tag>
</template>
</el-table-column>
<!-- ★ 归还人改为整单汇总:主行原取「首条明细」的归还人,多明细分批归还时
会只显示其中一位,令人误以为其他人没还过。 -->
<el-table-column v-if="hasColumnPermission('return_operator')" label="归还人" width="110">
<template #default="{row}">
<template v-if="row.return_operators && row.return_operators.length">
<span v-for="op in row.return_operators" :key="op" class="name-fixed">{{ formatName(op) }}</span>
</template>
<span v-else class="text-info">—</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
<template #default="{row}">
<div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag>
{{ row.return_time || '-' }}
</div>
<!-- ★ 降噪:「无限期」不是异常,不该用蓝色标签抢视觉重心 ——
把注意力留给红色的「已逾期」。改为灰色小字。(沿用系统既有术语
「无限期」,与借出页的「无限期/长期借用」保持一致) -->
<div v-else-if="!row.expected_return_time">
<span class="text-info">无限期</span>
</div>
<div v-else>
<span>预计 {{ formatExpectedTime(row.expected_return_time).text }} </span>
<span :style="{
color: formatExpectedTime(row.expected_return_time).style.color || 'inherit',
fontWeight: formatExpectedTime(row.expected_return_time).style.fontWeight || 'normal',
marginLeft: '4px'
}">
{{ formatExpectedTime(row.expected_return_time).diffText }}
</span>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="110" align="center">
<template #default="{row}">
<!-- ★ 中间态:已发起转交但接收人尚未确认。
此时主表 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>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" label="电子签名" width="140" align="center">
<template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature && hasColumnPermission('borrow_signature')" width="220">
<template #reference><el-tag size="small">借</el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
<el-popover trigger="hover" placement="top" v-if="row.return_signature && hasColumnPermission('return_signature')" width="220">
<template #reference><el-tag type="success" size="small">还</el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
</div>
</template>
</el-table-column>
<!-- ★ 操作列
· 申请报废 / 转交 —— 仅未归还记录,且分别受各自权限约束
· 流转记录 —— 无特殊权限要求,凡能看到本列表者皆可查看 -->
<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) && !row.pending_transfer"
v-permission="'borrow_transfer'"
type="primary" link size="small"
@click="openTransferDialog(row)"
>转交</el-button>
<el-button
type="info" link size="small"
@click="openHistoryDrawer(row)"
>流转记录</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
background
layout="prev, pager, next"
:total="total"
@current-change="handlePage"
style="margin-top:10px; text-align:right"
/>
<!-- ★ 借库报废**申请**弹窗(需审批,不再直报) -->
<el-dialog v-model="scrapDialogVisible" title="申请借库报废(未归还)" width="650px" destroy-on-close>
<el-alert
title="提交后进入审批流程:审批通过并执行后,该笔借出才不再追讨归还,并从总库存中扣除。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<div style="margin-bottom: 12px;">
<div style="font-weight: 600; margin-bottom: 6px;">单号: {{ scrapBorrowNo }}</div>
<div style="font-size: 13px; color: #909399;">请勾选要报废的未归还明细:</div>
</div>
<el-table :data="scrapCandidates" border size="small" max-height="300" @selection-change="handleScrapSelection">
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="material_name" label="物料名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="100" show-overflow-tooltip />
<el-table-column label="待还数量" width="80" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.pending_quantity }}</span>
</template>
</el-table-column>
<el-table-column prop="borrow_time" label="借出时间" width="130" />
</el-table>
<el-form label-width="80px" style="margin-top: 14px;">
<el-form-item label="报废原因" required>
<el-input
v-model="scrapReason"
type="textarea"
:rows="2"
placeholder="请填写报废原因,如:物品丢失 / 损坏无法归还"
/>
</el-form-item>
<el-form-item label="审批人" required>
<el-select v-model="scrapApproverId" placeholder="请选择审批人" style="width: 100%" filterable>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scrapDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="scrapSubmitting" @click="confirmScrap">提交报废申请</el-button>
</template>
</el-dialog>
<!-- ★ 借库转交弹窗(整单转交 + 双向握手) -->
<el-dialog v-model="transferDialogVisible" title="发起借库转交" width="640px" destroy-on-close>
<el-alert
title="整单转交,且需对方确认后责任才转移"
description="发起后只生成一条「待接收」记录,东西仍在您手上、责任仍归您;对方在其借用列表中确认接收后,本单全部未还物品的持有责任才整体转移给他。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 14px"
/>
<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 }} 件):
</div>
</div>
<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">
<template #default="{row}">
<span
v-if="row.current_holder_name"
class="holder-name"
:class="{ 'is-transferred': isRowTransferred(row) }"
>{{ formatName(row.current_holder_name) }}</span>
<span v-else class="text-info">—</span>
</template>
</el-table-column>
<el-table-column label="待还数量" width="90" align="center">
<template #default="{row}">
<span style="color:#F56C6C; font-weight:bold">{{ row.pending_quantity }}</span>
</template>
</el-table-column>
</el-table>
<el-form label-width="80px" style="margin-top: 14px;">
<el-form-item label="接收人" required>
<el-select v-model="transferToUserId" placeholder="请选择接收人" style="width: 100%" filterable>
<el-option v-for="u in transferUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="转交备注">
<el-input
v-model="transferRemark"
type="textarea"
:rows="2"
placeholder="如:项目交接 / 人员离岗,可留空"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="transferSubmitting" @click="confirmTransfer">发起转交</el-button>
</template>
</el-dialog>
<!-- ★ 流转明细时间线:借出 → 转交(可多次) → 归还 → 报废,倒序展示 -->
<el-drawer v-model="historyDrawerVisible" title="流转明细" size="580px" destroy-on-close>
<div v-loading="historyLoading">
<div style="font-weight: 600; margin-bottom: 4px;">单号:{{ historyBorrowNo }}</div>
<div style="font-size: 12px; color: #909399; margin-bottom: 16px;">
按时间倒序,最新动作在最上方
</div>
<el-empty v-if="!historyEvents.length" description="暂无流转记录" :image-size="70" />
<el-timeline v-else>
<el-timeline-item
v-for="(e, i) in historyEvents"
:key="i"
:timestamp="e.time"
placement="top"
:type="eventMeta(e.type).type"
:hollow="e.type === 'borrow'"
>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:4px;">
<el-tag :type="eventMeta(e.type).type" size="small">{{ eventMeta(e.type).label }}</el-tag>
<span style="font-size: 13px; color:#606266;">{{ e.material_name || e.sku }}</span>
<span style="font-size: 13px; color:#909399;">× {{ e.quantity }}</span>
</div>
<div style="font-size: 13px; color:#303133;">
<template v-if="e.type === 'borrow'">
借用人:<b>{{ e.actor_name || '—' }}</b>
<span v-if="e.operator_name" style="color:#909399;"> · 发货库管:{{ e.operator_name }}</span>
</template>
<template v-else-if="e.type === 'transfer'">
<b>{{ e.from_name || '—' }}</b> → <b>{{ e.actor_name || '—' }}</b>
<span v-if="e.operator_name" style="color:#909399;"> · 经手库管:{{ e.operator_name }}</span>
</template>
<template v-else-if="e.type === 'return'">
归还人:<b>{{ e.actor_name || '—' }}</b>
<span v-if="e.operator_name" style="color:#909399;"> · 经手库管:{{ e.operator_name }}</span>
</template>
<template v-else>
报废 · 经手库管:<b>{{ e.operator_name || '—' }}</b>
</template>
</div>
<div v-if="e.remark" style="font-size: 12px; color:#909399; margin-top:2px;">
备注:{{ e.remark }}
</div>
</el-timeline-item>
</el-timeline>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
import { useUserStore } from '@/stores/user'
import { getApproversList } from '@/api/auth'
import {
submitBorrowScrapRequest,
transferBorrow,
acceptBorrowTransfer,
rejectBorrowTransfer,
getBorrowUsers,
getBorrowSlipHistory,
} from '@/api/transaction'
import { formatName } from '@/utils/format'
const userStore = useUserStore()
// 防抖定时器
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// 防抖搜索函数
const debouncedSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
page.value = 1
fetchData()
}, 500)
}
// 清空搜索时立即触发查询
const handleClearSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
page.value = 1
fetchData()
}
// 列与权限Code的映射关系(数据库中的code)
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
borrow_no: null,
borrower_name: null,
sku: null,
borrow_time: null,
return_time: null,
return_operator: null,
status: null,
expected_return_time: null,
return_location: null,
// 签名列 — 始终可见(sys_element 补齐后再改为显式权限码)
borrow_signature: null,
return_signature: null,
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
const list = ref<any[]>([])
const total = ref(0)
// ★ 修改点:默认状态改为 'borrowed' (未归还)
const status = ref('borrowed')
const keyword = ref('')
const searchType = ref('all')
const dateRange = ref<string[]>([])
const page = ref(1)
const loading = ref(false)
// --- ★ 高级筛选 ---
const advancedFilterVisible = ref(false)
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
const appliedConditions = ref<any[]>([])
const advancedFilters = ref<any[]>([])
const fieldOptions = [
{ value: 'no', label: '单号' },
{ value: 'sku', label: 'SKU' },
{ value: 'material_name', label: '物料名称' },
{ value: 'borrower_name', label: '操作人' },
]
const operatorOptions = [
{ value: 'contains', label: '包含' },
{ value: 'eq', label: '等于' },
{ value: 'not_contains', label: '不包含' },
{ value: 'ne', label: '不等于' },
]
const addCondition = () => {
advancedConditions.value.push({ field: '', operator: '', value: '' })
}
const removeCondition = (index: number) => {
advancedConditions.value.splice(index, 1)
}
const applyAdvancedFilter = () => {
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
advancedFilters.value = valid
appliedConditions.value = valid
advancedFilterVisible.value = false
page.value = 1
fetchData()
}
const resetAdvancedFilter = () => {
advancedConditions.value = [{ field: '', operator: '', value: '' }]
advancedFilters.value = []
appliedConditions.value = []
advancedFilterVisible.value = false
page.value = 1
fetchData()
}
// ★ 借库报废**申请**相关(需审批,不再直报)
const canScrap = computed(() =>
userStore.role === 'SUPER_ADMIN' ||
userStore.hasPermission('op_return:operation')
)
const scrapDialogVisible = ref(false)
const scrapSubmitting = ref(false)
const scrapBorrowNo = ref('')
const scrapCandidates = ref<any[]>([])
const scrapSelected = ref<any[]>([])
const scrapReason = ref('')
const scrapApproverId = ref<number | null>(null)
const approvers = ref<any[]>([])
const loadApprovers = async () => {
try {
const res = await getApproversList()
approvers.value = res.data || []
} catch (e) {
console.error('[借库报废申请] 审批人列表加载失败', e)
}
}
// 打开报废申请弹窗:收集该单号下所有未归还明细
const openScrapDialog = (row: any) => {
const unreturned = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
scrapBorrowNo.value = row.borrow_no
scrapCandidates.value = unreturned
scrapSelected.value = []
scrapReason.value = ''
scrapApproverId.value = null
scrapDialogVisible.value = true
loadApprovers()
}
// 勾选变更
const handleScrapSelection = (rows: any[]) => {
scrapSelected.value = rows
}
// 提交报废申请
const confirmScrap = async () => {
if (scrapSelected.value.length === 0) return ElMessage.warning('请至少选择一条要申请报废的借出记录')
if (!scrapReason.value.trim()) return ElMessage.warning('请填写报废原因')
if (!scrapApproverId.value) return ElMessage.warning('请选择审批人')
try {
await ElMessageBox.confirm(
`确定对 ${scrapSelected.value.length} 条借出记录提交报废申请吗?\n\n`
+ '提交后进入审批流程,审批通过并执行后才会扣减总库存。',
'报废申请确认',
{ confirmButtonText: '提交申请', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return // 用户取消
}
scrapSubmitting.value = true
try {
const recordIds = scrapSelected.value.map((c: any) => c.id)
const res = await submitBorrowScrapRequest({
record_ids: recordIds,
reason: scrapReason.value,
approver_id: scrapApproverId.value,
})
// ★ 提示里点明「尚未扣减」—— 申请不立即改状态,否则用户会以为没生效而重复提交
ElMessage.success(
`${res?.msg || '报废申请已提交'}:审批通过并执行后才会标记为已报废`,
)
scrapDialogVisible.value = false
fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '提交报废申请失败')
} finally {
scrapSubmitting.value = false
}
}
// ==========================================================================
// ★ 借库转交(一期:仅支持整单全部转交)
// ==========================================================================
// 未归还判定:主表状态下 borrowed / partial_returned 都还有实物未回库
const isUnreturned = (row: any) =>
row.status === 'borrowed' || row.status === 'partial_returned'
// 当前持有人:整单下各明细可能持有人不同(转交是逐条明细进行的),去重后展示。
// ★ 同时标出该持有人是否「因转交而来」(≠ 初始借用人)—— 转交过的用主题色加粗,
// 未转交的保持普通文本,靠颜色而非色块承担区分度。
const currentHolderList = (row: any): Array<{ name: string; transferred: boolean }> => {
const borrower = String(row.borrower_name || '').split('/')[0].trim()
const seen = new Map<string, boolean>()
;(row.children || []).forEach((c: any) => {
const name = c.current_holder_name
if (!name) return
const transferred = String(name).split('/')[0].trim() !== borrower
// 同名只要有一次是「转交而来」就按转交样式展示
seen.set(name, (seen.get(name) || false) || transferred)
})
return Array.from(seen, ([name, transferred]) => ({ name, transferred }))
}
// 单条明细是否已转交 —— 供转交弹窗的行内展示使用。
// (组行有 children,请用上面的 currentHolderList)
const isRowTransferred = (row: any): boolean => {
const holder = String(row.current_holder_name || '').split('/')[0].trim()
const borrower = String(row.borrower_name || '').split('/')[0].trim()
return !!holder && holder !== borrower
}
// ==========================================================================
// ★ 整单汇总:主行字段原样取「首条明细」,多明细单会失真,此处按整单重算
//
// 失真场景:一张单有 2 条明细,第一条已还清、第二条未还 ——
// · 主行状态显示「已还」,却仍出现在「未归还」页签里
// (该页签由后端按整单聚合过滤:单号下至少一条未还);
// · 归还人只显示第一条的经手人,另一位经手人被隐藏。
// ==========================================================================
const aggregateStatus = (children: any[]): string => {
if (!children.length) return 'borrowed'
// 全部报废
if (children.every(c => c.status === 'scrapped')) return 'scrapped'
// 全部结清(已还 + 已报废;报废在后端口径里同样计入 is_returned)
const isClosed = (c: any) => c.status === 'returned' || c.status === 'scrapped'
if (children.every(isClosed)) return 'returned'
// 仍有未还:若已还过一部分,则呈现「部分归还」而非笼统的「未还」
const returnedSome = children.some(
(c: any) => c.status === 'returned' || c.status === 'partial_returned'
)
return returnedSome ? 'partial_returned' : 'borrowed'
}
// 归还人汇总:去重(同一库管经手多条明细时只显示一次)
const aggregateReturnOperators = (children: any[]): string[] => {
const ops = new Set<string>()
children.forEach((c: any) => {
if (c.return_operator) ops.add(String(c.return_operator))
})
return Array.from(ops)
}
// 最早 / 最晚时间取值:字符串为 'YYYY-MM-DD HH:mm' 格式,可直接比较
const earliest = (arr: any[]): string | null =>
arr.filter(Boolean).sort().shift() ?? null
const latest = (arr: any[]): string | null =>
arr.filter(Boolean).sort().pop() ?? null
const transferDialogVisible = ref(false)
const transferSubmitting = ref(false)
const transferActionLoading = ref(false) // 接收/拒绝按钮 loading
const transferBorrowNo = ref('')
const transferCandidates = ref<any[]>([])
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 {
const res: any = await getBorrowUsers()
transferUsers.value = res?.data || []
} catch {
transferUsers.value = []
}
}
const openTransferDialog = (row: 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
transferToUserId.value = null
transferRemark.value = ''
transferDialogVisible.value = true
loadTransferUsers()
}
const confirmTransfer = async () => {
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}】,无需转交`)
}
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`
+ `发起后对方需在其借用列表中确认接收,责任才正式转移;\n`
+ `在对方确认前,物品与责任仍归当前持有人。`,
'发起转交',
{ confirmButtonText: '确认发起', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
}
transferSubmitting.value = true // ★ 按钮 loading 防抖,避免重复提交
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,此处再弹会双份
} finally {
transferSubmitting.value = false
}
}
// ==========================================================================
// ★ 双向握手第二步:接收人确认 / 拒绝
// ==========================================================================
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
}
}
// ==========================================================================
// ★ 流转明细时间线(借出 → 转交(可多次) → 归还 → 报废)
// ==========================================================================
const historyDrawerVisible = ref(false)
const historyLoading = ref(false)
const historyBorrowNo = ref('')
const historyEvents = ref<any[]>([])
const EVENT_META: Record<string, { label: string; type: any }> = {
borrow: { label: '借出', type: 'primary' },
transfer: { label: '转交', type: 'warning' },
return: { label: '归还', type: 'success' },
scrap: { label: '报废', type: 'danger' },
}
const eventMeta = (t: string) => EVENT_META[t] || { label: t, type: 'info' }
const openHistoryDrawer = async (row: any) => {
historyBorrowNo.value = row.borrow_no
historyEvents.value = []
historyDrawerVisible.value = true
historyLoading.value = true
try {
const res: any = await getBorrowSlipHistory(row.borrow_no)
historyEvents.value = res?.data?.events || []
} catch {
// 同上:错误提示由 request 拦截器统一处理
} finally {
historyLoading.value = false
}
}
const fetchData = async () => {
loading.value = true
try {
const params: any = {
page: page.value,
status: status.value,
keyword: keyword.value,
search_type: searchType.value,
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
advancedFilters: JSON.stringify(advancedFilters.value || []),
}
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const res = await request({
url: '/v1/transactions/records',
method: 'get',
params
})
// ★ 按 borrow_no 分组聚合为主子表结构
const groupMap = new Map()
;(res.data.items || []).forEach(item => {
if (!groupMap.has(item.borrow_no)) {
groupMap.set(item.borrow_no, {
...item,
children: []
})
}
groupMap.get(item.borrow_no).children.push(item)
})
// ★ 整单汇总重算:主行字段默认取「首条明细」,多明细单在部分归还/分批归还时
// 会失真,故对状态、归还人、时间字段按整单重算(详见 aggregateStatus 注释)
list.value = Array.from(groupMap.values()).map(group => {
const children = group.children || []
group.status = aggregateStatus(children)
group.return_operators = aggregateReturnOperators(children)
// 预计归还时间取**最早**:后端排序用的正是单内 min(expected_return_time),
// 主行若显示首条明细的值,多明细单的可见列就会与排序不一致 ——
// 看上去「排得没规律」,实则列里显示的根本不是排序依据。
group.expected_return_time = earliest(children.map((c: any) => c.expected_return_time))
// 归还时间取**最晚**:状态为「已还」时代表整单结清的那一刻
group.return_time = latest(children.map((c: any) => c.return_time))
return group
})
total.value = res.data.total
} finally { loading.value = false }
}
const handlePage = (val: number) => {
page.value = val
fetchData()
}
// 立即查询(状态/类型切换用,取消防抖)
const handleSearchImmediate = () => {
if (debounceTimer) clearTimeout(debounceTimer)
page.value = 1
fetchData()
}
const resetFilter = () => {
if (debounceTimer) clearTimeout(debounceTimer)
status.value = 'borrowed'
keyword.value = ''
searchType.value = 'all'
dateRange.value = []
advancedFilters.value = []
advancedConditions.value = [{ field: '', operator: '', value: '' }]
appliedConditions.value = []
page.value = 1
fetchData()
}
// ★ 新增:格式化预计归还时间及倒计时逻辑(交通灯预警系统)
const formatExpectedTime = (timeStr: string) => {
if (!timeStr) return { text: '无限期', diffText: '', style: {} }
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss,我们只取日期部分比较
const expected = dayjs(timeStr).startOf('day')
const today = dayjs().startOf('day')
const diffDays = expected.diff(today, 'day')
let diffText = ''
let style: Record<string, string> = {}
// 这里的 timeStr 只展示前10位 (日期),或者展示完整
const displayTime = timeStr.substring(0, 10)
if (diffDays < 0) {
// 已逾期 - 红色加粗
diffText = ` (已逾期 ${Math.abs(diffDays)} 天)`
style = { color: '#F56C6C', fontWeight: 'bold' }
} else if (diffDays <= 1) {
// 极其紧急(剩 0-1 天)- 警告黄加粗
diffText = diffDays === 0 ? ` (今天到期)` : ` (剩 ${diffDays} 天)`
style = { color: '#E6A23C', fontWeight: 'bold' }
} else if (diffDays <= 7) {
// 即将到期(剩 2-7 天)- 安全绿
diffText = ` (剩 ${diffDays} 天)`
style = { color: '#67C23A' }
} else {
// 安全/无期限 - 默认颜色
diffText = ` (剩 ${diffDays} 天)`
style = {}
}
return { text: displayTime, diffText, style }
}
onMounted(fetchData)
// 组件销毁前清理定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</script>
<style scoped>
/* ★ 统一高级搜索版式(与出库记录 / 报废记录一致) */
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
/* 高级筛选条件行 */
.condition-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
/* ==========================================================================
★ 姓名定宽对齐
中文姓名以「字」为单位占宽,1 个全角字 ≈ 1em,故统一占 3em:
「石利」与「郭俊玥」占同样的横向空间,一列名字纵向对齐、不再参差。
两字姓名由 formatName() 在**渲染副本**上补全角空格撑成三字宽(见
src/utils/format.ts),这里是兜底:单字姓名等异常长度也不会撑破列对齐。
row.borrower_name 等底层数据始终是原值。
========================================================================== */
.name-fixed {
display: inline-block;
min-width: 3em;
/* 一格内有多个姓名时(同一单的明细持有人/归还人不同)留出间隔,
否则两个 3em 的盒子会紧贴成「高 雪张 三」难以分辨 */
margin-right: 6px;
}
/* ==========================================================================
★ 当前持有人
业务方要求该列**始终高亮** —— 「东西现在在谁手上」比「当初谁借的」更需要
一眼看到,故基础态即主题色加粗,不再区分是否转交。
发生过转交时额外加浅蓝底 + 细边框,强调「经手人已变更」。
(浅蓝而非实心色块:保持降噪基调,只做一层轻标记)
========================================================================== */
.holder-name {
display: inline-block;
min-width: 3em;
margin-right: 6px;
color: var(--el-color-primary);
font-weight: 600;
}
.holder-name.is-transferred {
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-5);
border-radius: 4px;
padding: 1px 6px;
}
/* 中性/次要信息:灰字,不抢视觉重心 */
.text-info {
color: #909399;
font-size: 12px;
}
</style>