转交 UI(借还记录页) ---- · 操作列新增【转交】,v-permission="'borrow_transfer'" + 按钮 loading 防抖 · 弹窗明示「仅支持整单全部转交」与「转交后须由接收人本人归还」,并在确认框 复述接收人与转交数量 · 列表是 borrow_no 主子表结构,trans_borrow.id 在**明细行**上,而一张单的明细 可各有不同持有人(转交是逐条明细进行的),故弹窗先让用户选定明细;单条明细 时自动选中(实测 54/60 的单号只有 1 条,等于零额外点击)。 该「单号按钮 → 弹窗选明细」范式沿用本页既有的「申请报废」。 流转明细时间线 ---- · 操作列新增【流转记录】,无特殊权限要求(页面本身已由 op_records 把守) · Drawer + el-timeline 倒序展示 借出 → 转交(可多次) → 归还 → 报废, 每节点显示时间、动作、当事人(借用人 / 转出→接收 / 实际归还人)与经手库管, 转交备注一并展示 · 主列表新增「当前持有人」列:转交后可能与「借用人」不是同一人;一张单的明细 持有人可能不同,去重后逐个展示,已全部回库显示「已回库」 身份锚点适配 ---- · 借出页:借用人由「自由输入姓名」改为「用户下拉」,提交 borrower_id (姓名无法唯一锚定一个人,重名即责任链断裂) · 归还页:新增「实际归还人」下拉,默认取首件物品的当前持有人,提交 returner_id; 主表新增「当前持有人」列提示库管该由谁来还 · 错误提示不再本地重复弹出 —— request 拦截器已按后端 msg 全局 toast, 再弹一次会双份 验证:vite build 通过;接口契约经后端回归用例校验。
861 lines
34 KiB
Vue
861 lines
34 KiB
Vue
<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')" prop="borrower_name" label="借用人" width="100" />
|
||
<!-- ★ 当前持有人:转交后可能与「借用人」不是同一人。
|
||
一张单的明细可能持有人各不相同,故去重后逐个展示。 -->
|
||
<el-table-column label="当前持有人" width="120">
|
||
<template #default="{row}">
|
||
<template v-if="currentHolders(row).length">
|
||
<el-tag
|
||
v-for="h in currentHolders(row)"
|
||
:key="h"
|
||
type="warning"
|
||
size="small"
|
||
style="margin: 1px 2px"
|
||
>{{ h }}</el-tag>
|
||
</template>
|
||
<span v-else-if="row.status === 'returned'" style="color: #67C23A; font-size: 12px">已回库</span>
|
||
<span v-else style="color: #c0c4cc">—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
|
||
<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')" prop="return_operator" label="归还人" width="100" />
|
||
|
||
<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">
|
||
<el-tag type="primary" size="small">无限期</el-tag>
|
||
</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="100" align="center">
|
||
<template #default="{row}">
|
||
<el-tag v-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="210" align="center" fixed="right">
|
||
<template #default="{row}">
|
||
<el-button
|
||
v-if="canScrap && isUnreturned(row)"
|
||
type="danger" link size="small"
|
||
@click="openScrapDialog(row)"
|
||
>申请报废</el-button>
|
||
|
||
<el-button
|
||
v-if="isUnreturned(row)"
|
||
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;">请选择要转交的明细(转交后该明细的持有人整体变更为接收人):</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"> </el-radio>
|
||
</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 type="warning" size="small">{{ row.current_holder_name || '—' }}</el-tag>
|
||
</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,
|
||
getBorrowUsers,
|
||
getBorrowSlipHistory,
|
||
} from '@/api/transaction'
|
||
|
||
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 currentHolders = (row: any): string[] => {
|
||
const names = (row.children || [])
|
||
.map((c: any) => c.current_holder_name)
|
||
.filter((n: any) => !!n)
|
||
return Array.from(new Set(names)) as string[]
|
||
}
|
||
|
||
const transferDialogVisible = ref(false)
|
||
const transferSubmitting = ref(false)
|
||
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 }>>([])
|
||
|
||
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
|
||
// 实测 54/60 的单号只有 1 条明细 —— 单条时直接选中,省掉一次点击
|
||
transferTargetId.value = candidates.length === 1 ? candidates[0].id : null
|
||
transferToUserId.value = null
|
||
transferRemark.value = ''
|
||
transferDialogVisible.value = true
|
||
loadTransferUsers()
|
||
}
|
||
|
||
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)
|
||
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 qty = target?.pending_quantity ?? 0
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`将【${target?.material_name || target?.sku || ''}】的持有权整单转交给【${receiver?.name || ''}】?\n\n`
|
||
+ `转交数量:${qty}(整单全量,不支持部分转交)\n`
|
||
+ `转交后,该物品须由接收人本人办理归还。`,
|
||
'转交确认',
|
||
{ confirmButtonText: '确认转交', cancelButtonText: '取消', type: 'warning' }
|
||
)
|
||
} catch {
|
||
return // 用户取消
|
||
}
|
||
|
||
transferSubmitting.value = true // ★ 按钮 loading 防抖,避免重复提交
|
||
try {
|
||
const res: any = await transferBorrow(transferTargetId.value, {
|
||
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 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)
|
||
})
|
||
|
||
// ★ 整单状态重算:所有明细都已报废 → 整单标记 scrapped
|
||
list.value = Array.from(groupMap.values()).map(group => {
|
||
const allScrapped = group.children.length > 0 &&
|
||
group.children.every((c: any) => c.status === 'scrapped')
|
||
if (allScrapped) {
|
||
group.status = 'scrapped'
|
||
}
|
||
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;
|
||
}
|
||
</style>
|