Files
shanxia2.0/web-admin/src/views/bx/payment/index.vue
T
wuzilongandCursor e7f942b399 feat(bx): 报销支付按未结清余额分次冲抵和发放。
冲抵后的余额不再自动记成发放,发放和冲抵分开提交,未结清单据继续留在待处理。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 21:14:09 +08:00

1761 lines
67 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.
<!-- 支付管理(发放/冲抵) -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<el-tabs
v-model="activeTab"
class="panel-tabs"
@tab-change="handleTabChange"
>
<!-- 待处理 -->
<el-tab-pane label="待处理" name="pending">
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="单号/报销标题"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
<el-button class="btn-main" type="primary" @click="handleToolbarIssue">
<el-icon><Money /></el-icon>发放
</el-button>
<el-button class="btn-ghost" @click="openBatchIssueDialog">
<el-icon><Document /></el-icon>批量发放
</el-button>
<el-button class="btn-ghost" @click="handleToolbarOffset">
<el-icon><Switch /></el-icon>冲抵备用金
</el-button>
<el-button
v-if="hasActiveFilters"
class="btn-ghost"
@click="clearAllFilters"
>
<el-icon><Filter /></el-icon>清空列过滤
</el-button>
<ListExportDropdown
:export-fn="paymentApi.exportPending"
:get-filter-params="getPaymentPendingExportFilterParams"
:has-selection="true"
:selected-ids="selectedRows.map((r) => String(r.id))"
:visible-columns="paymentExportColumns"
filename="报销待付款.xlsx"
/>
<ListToolbarEndActions
:loading="loading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="runPendingSearch"
@reset-columns="handleResetColumns"
/>
</div>
</div>
<div class="list-body list-wrap">
<div ref="pendingPaymentTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable ref="pendingPaymentTableRef" :height="pendingPaymentTableHeight" table-key="bx-payment"
class="list-table"
:data="rows"
v-loading="loading"
stripe
@selection-change="onSelectionChange"
@row-dblclick="openView"
@header-dragend="() => pendingPaymentStatsRowRef?.resync?.()"
>
<el-table-column type="selection" width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请时间</span>
<ColumnHeaderFilter
prop="applyTime"
label="申请时间"
type="datetime"
:is-active="isFilterActive('applyTime')"
:model-value="filters.applyTime || null"
@update:model-value="(v) => setFilterState('applyTime', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
prop="applyCode"
label="单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">单号</span>
<ColumnHeaderFilter
prop="applyCode"
label="单号"
type="enum"
:options="facetOptionsMap.applyCode"
:options-loading="facetLoadingMap.applyCode"
:is-active="isFilterActive('applyCode')"
:model-value="filters.applyCode || null"
@update:model-value="(v) => setFilterState('applyCode', v)"
@open="onFilterOpen('applyCode')"
/>
</div>
</template>
<template #default="{ row }">
<el-link type="primary" @click="openView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请人</span>
<ColumnHeaderFilter
prop="applicantName"
label="申请人"
type="enum"
:options="facetOptionsMap.applicantName"
:options-loading="facetLoadingMap.applicantName"
:is-active="isFilterActive('applicantName')"
:model-value="filters.applicantName || null"
@update:model-value="(v) => setFilterState('applicantName', v)"
@open="onFilterOpen('applicantName')"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="报销标题"
min-width="180"
align="left"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">报销标题</span>
<ColumnHeaderFilter
prop="title"
label="报销标题"
type="enum"
:options="facetOptionsMap.title"
:options-loading="facetLoadingMap.title"
:is-active="isFilterActive('title')"
:model-value="filters.title || null"
@update:model-value="(v) => setFilterState('title', v)"
@open="onFilterOpen('title')"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="报销金额(元)"
:formatter="moneyTableFormatter"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label is-right">报销金额(元)</span>
<ColumnHeaderFilter
prop="amount"
label="报销金额(元)"
type="number"
:is-active="isFilterActive('amount')"
:model-value="filters.amount || null"
@update:model-value="(v) => setFilterState('amount', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('offsetAmount')"
prop="offsetAmount"
label="冲抵金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label is-right">冲抵金额(元)</span>
<ColumnHeaderFilter
prop="offsetAmount"
label="冲抵金额(元)"
type="number"
:is-active="isFilterActive('offsetAmount')"
:model-value="filters.offsetAmount || null"
@update:model-value="(v) => setFilterState('offsetAmount', v)"
/>
</div>
</template>
<template #default="{ row }">
{{ formatSumMoney(Number(row.offsetAmount || 0)) }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('payAmount')"
prop="actualAmount"
label="发放金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label is-right">发放金额(元)</span>
<ColumnHeaderFilter
prop="payAmount"
label="发放金额(元)"
type="number"
:is-active="isFilterActive('payAmount')"
:model-value="filters.payAmount || null"
@update:model-value="(v) => setFilterState('payAmount', v)"
/>
</div>
</template>
<template #default="{ row }">
{{
row.actualAmount != null && row.actualAmount !== ''
? formatSumMoney(Number(row.actualAmount))
: '—'
}}
</template>
</el-table-column>
<el-table-column
label="未结清余额(元)"
min-width="130"
align="right"
header-align="center"
class-name="col-money"
>
<template #default="{ row }">
{{ formatSumMoney(unpaidBalance(row)) }}
</template>
</el-table-column>
</ListTable>
</div>
<div ref="pendingPaymentBelowRef">
<ListColumnStatsRow
ref="pendingPaymentStatsRowRef"
storage-key="bx-payment-pending-column-stats"
:columns="pendingPaymentStatsRowColumns"
:data="rows"
:table-wrap-el="pendingPaymentTableWrapRef"
:get-cell-value="getPaymentStatCellValue"
/>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<span class="list-range">{{ rangeText }}</span>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 100, 500]"
@size-change="onSizeChange"
/>
</div>
</div>
</div>
</el-tab-pane>
<!-- 已处理 -->
<el-tab-pane label="已处理" name="processed">
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
v-model="processedQuickSearchKeyword"
placeholder="单号/报销标题"
@search="handleProcessedQuickSearch"
@clear="handleProcessedQuickSearchClear"
/>
<el-button
v-if="hasActiveFilters"
class="btn-ghost"
@click="clearAllFilters"
>
<el-icon><Filter /></el-icon>清空列过滤
</el-button>
<ListExportDropdown
:export-fn="paymentApi.exportProcessed"
:get-filter-params="getPaymentProcessedExportFilterParams"
:has-selection="false"
:selected-ids="[]"
:visible-columns="paymentExportColumns"
filename="报销已付款.xlsx"
/>
<ListToolbarEndActions
:loading="processedLoading"
:column-defs="listColumnDefs"
v-model:visible-columns="visibleColumns"
v-model:check-all="checkAll"
:is-indeterminate="isIndeterminate"
@refresh="runProcessedSearch"
@reset-columns="handleResetColumns"
/>
</div>
</div>
<div class="list-body list-wrap">
<div ref="processedPaymentTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable ref="processedPaymentTableRef" :height="processedPaymentTableHeight" table-key="bx-payment-2"
class="list-table"
:data="processedRows"
v-loading="processedLoading"
stripe
@row-dblclick="openProcessedView"
@header-dragend="() => processedPaymentStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column
v-if="isColumnVisible('applyTime')"
prop="applyTime"
label="申请时间"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请时间</span>
<ColumnHeaderFilter
prop="applyTime"
label="申请时间"
type="datetime"
:is-active="isFilterActive('applyTime')"
:model-value="filters.applyTime || null"
@update:model-value="(v) => setFilterState('applyTime', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
prop="applyCode"
label="单号"
class-name="col-apply-code"
min-width="140"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">单号</span>
<ColumnHeaderFilter
prop="applyCode"
label="单号"
type="enum"
:options="facetOptionsMap.applyCode"
:options-loading="facetLoadingMap.applyCode"
:is-active="isFilterActive('applyCode')"
:model-value="filters.applyCode || null"
@update:model-value="(v) => setFilterState('applyCode', v)"
@open="onFilterOpen('applyCode')"
/>
</div>
</template>
<template #default="{ row }">
<el-link type="primary" @click="openProcessedView(row)">{{ row.applyCode }}</el-link>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('applicantName')"
prop="applicantName"
label="申请人"
min-width="100"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">申请人</span>
<ColumnHeaderFilter
prop="applicantName"
label="申请人"
type="enum"
:options="facetOptionsMap.applicantName"
:options-loading="facetLoadingMap.applicantName"
:is-active="isFilterActive('applicantName')"
:model-value="filters.applicantName || null"
@update:model-value="(v) => setFilterState('applicantName', v)"
@open="onFilterOpen('applicantName')"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('title')"
prop="title"
label="标题"
min-width="180"
align="left"
header-align="center"
show-overflow-tooltip
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">标题</span>
<ColumnHeaderFilter
prop="title"
label="标题"
type="text"
:is-active="isFilterActive('title')"
:model-value="filters.title || null"
@update:model-value="(v) => setFilterState('title', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('amount')"
prop="amount"
label="报销金额(元)"
:formatter="moneyTableFormatter"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label is-right">报销金额(元)</span>
<ColumnHeaderFilter
prop="amount"
label="报销金额(元)"
type="number"
:is-active="isFilterActive('amount')"
:model-value="filters.amount || null"
@update:model-value="(v) => setFilterState('amount', v)"
/>
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('offsetAmount')"
prop="offsetAmount"
label="冲抵金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label is-right">冲抵金额(元)</span>
<ColumnHeaderFilter
prop="offsetAmount"
label="冲抵金额(元)"
type="number"
:is-active="isFilterActive('offsetAmount')"
:model-value="filters.offsetAmount || null"
@update:model-value="(v) => setFilterState('offsetAmount', v)"
/>
</div>
</template>
<template #default="{ row }">
{{ formatSumMoney(Number(row.offsetAmount || 0)) }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('payAmount')"
prop="actualAmount"
label="发放金额(元)"
class-name="col-money"
min-width="120"
align="right"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label is-right">发放金额(元)</span>
<ColumnHeaderFilter
prop="payAmount"
label="发放金额(元)"
type="number"
:is-active="isFilterActive('payAmount')"
:model-value="filters.payAmount || null"
@update:model-value="(v) => setFilterState('payAmount', v)"
/>
</div>
</template>
<template #default="{ row }">
{{
row.actualAmount != null && row.actualAmount !== ''
? formatSumMoney(Number(row.actualAmount))
: '—'
}}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('processStatus')"
label="处理状态"
min-width="100"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">处理状态</span>
<ColumnHeaderFilter
prop="processStatus"
label="处理状态"
type="enum"
:enum-options="enumOptionsFor('processStatus')"
:is-active="isFilterActive('processStatus')"
:model-value="filters.processStatus || null"
@update:model-value="(v) => setFilterState('processStatus', v)"
/>
</div>
</template>
<template #default="{ row }">
<el-tag :type="processedStatusTagType(row.status, row.payStatus)" size="small">
{{ processedStatusLabel(row.status, row.payStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('payTime')"
prop="payTime"
label="处理时间"
min-width="160"
align="center"
header-align="center"
>
<template #header>
<div class="col-header-with-filter">
<span class="col-header-with-filter__label">处理时间</span>
<ColumnHeaderFilter
prop="payTime"
label="处理时间"
type="datetime"
:is-active="isFilterActive('payTime')"
:model-value="filters.payTime || null"
@update:model-value="(v) => setFilterState('payTime', v)"
/>
</div>
</template>
<template #default="{ row }">
{{ row.payTime || '-' }}
</template>
</el-table-column>
<el-table-column
label="未结清余额(元)"
min-width="130"
align="right"
header-align="center"
class-name="col-money"
>
<template #default="{ row }">
{{ formatSumMoney(unpaidBalance(row)) }}
</template>
</el-table-column>
</ListTable>
</div>
<div ref="processedPaymentBelowRef">
<ListColumnStatsRow
ref="processedPaymentStatsRowRef"
storage-key="bx-payment-processed-column-stats"
:columns="processedPaymentStatsRowColumns"
:data="processedRows"
:table-wrap-el="processedPaymentTableWrapRef"
:get-cell-value="getPaymentStatCellValue"
/>
<div class="pagination list-pager-bar">
<span class="list-range">{{ processedRangeText }}</span>
<el-pagination
v-model:current-page="processedCurrentPage"
v-model:page-size="processedPageSize"
:total="processedTotal"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 100, 500]"
@size-change="processedOnSizeChange"
/>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</section>
<!-- 单据详情 -->
<el-dialog
v-model="dialogs.viewDetail"
title="单据详情"
width="1040px"
class="dialog-form-layout dialog-view-xl"
align-center
destroy-on-close
>
<div class="dlg-view">
<div class="dlg-view-hero">
<div class="dlg-view-hero-top">
<h2 class="dlg-view-title">{{ viewData.applyCode || '-' }}</h2>
<div class="dlg-view-tags">
<el-tag :type="statusTagType(viewData.status)" size="small">
{{ statusLabel(viewData.status) }}
</el-tag>
</div>
</div>
<p class="dlg-view-meta">
金额(元):<b class="money-em">{{ formatMoneyYuan(viewData.amount || 0) }}</b>
</p>
<div v-if="activeTab === 'pending'" class="dlg-view-hero-actions">
<el-button class="btn-main" type="primary" @click="openIssueDialog(viewData)">
<el-icon><Money /></el-icon>发放
</el-button>
<el-button class="btn-ghost" @click="openOffsetDialog(viewData)">
<el-icon><Switch /></el-icon>冲抵备用金
</el-button>
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">基础信息</h4>
<BxViewKvTable :fields="viewBasicKvFields" mode="triple" />
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">发放信息</h4>
<BxViewKvTable :fields="viewPayKvFields" mode="triple" />
</div>
<div v-if="viewAttachments.length" class="dlg-view-section">
<h4 class="dlg-view-section-title">相关附件</h4>
<BxAttachmentReadonly :files="viewAttachments" />
</div>
</div>
</el-dialog>
<!-- 批量发放 -->
<el-dialog
v-model="dialogs.batchIssue"
title="批量发放"
width="960px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" class="bx-dialog-form">
<h4 class="dialog-section-title">已选报销单</h4>
<el-table :data="batchLines" class="list-table" stripe max-height="320">
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column prop="applyCode" label="单号" min-width="150" align="center" header-align="center" />
<el-table-column prop="applicantName" label="申请人" min-width="90" align="center" header-align="center" />
<el-table-column
prop="title"
label="报销标题"
min-width="160"
align="left"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="发放金额(元)" min-width="130" align="right" header-align="center" class-name="col-money">
<template #default="{ row }">
{{ formatMoney(row.payAmount) }}
</template>
</el-table-column>
<el-table-column label="实际发放金额(元)" min-width="160" align="center" header-align="center">
<template #default="{ row }">
<el-input v-model="row.actualAmount" placeholder="请输入实际发放金额" />
</template>
</el-table-column>
</el-table>
<el-form-item label="备注" class="batch-issue-remark">
<el-input v-model="batchIssueRemark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
<h4 class="dialog-section-title">发放附件</h4>
<BxAttachmentUpload
ref="batchAttachmentRef"
v-model="batchFiles"
biz-type="reimburse_pay"
biz-id="0"
hint="同一批报销单共用这些发放凭证。"
/>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.batchIssue = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="loadingMap.batchIssueSubmit" @click="submitBatchIssue">
<el-icon><Check /></el-icon>确认发放
</el-button>
</template>
</el-dialog>
<!-- 发放 / 冲抵,按入口只展示对应表单 -->
<el-dialog
v-model="dialogs.offset"
:title="paymentActionTab === 'pay' ? '发放' : '冲抵备用金'"
width="960px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form label-position="top" class="bx-dialog-form">
<h4 class="dialog-section-title">报销单信息</h4>
<div class="form-grid-2">
<el-form-item label="报销单号">
<el-input :model-value="currentRow.applyCode || ''" disabled />
</el-form-item>
<el-form-item label="报销金额(元)" class="field-amount">
<el-input :model-value="String(currentRow.amount || '')" disabled />
</el-form-item>
<el-form-item label="已冲抵金额(元)" class="field-amount">
<el-input :model-value="formatMoneyYuan(currentRow.offsetAmount || 0)" disabled />
</el-form-item>
<el-form-item label="已发放金额(元)" class="field-amount">
<el-input :model-value="formatMoneyYuan(currentRow.actualAmount || 0)" disabled />
</el-form-item>
<el-form-item label="未结清余额(元)" class="field-amount">
<el-input :model-value="formatMoneyYuan(unpaidBalance(currentRow))" disabled />
</el-form-item>
</div>
<template v-if="paymentActionTab === 'offset'">
<div class="form-grid-2">
<el-form-item label="本次冲抵金额(元)" class="field-amount">
<el-input v-model="offsetAmount" placeholder="请输入本次冲抵金额" />
</el-form-item>
</div>
<h4 class="dialog-section-title">已发放备用金列表</h4>
<div class="inline-row">
<el-input v-model="offsetAdvanceFilter.keyword" placeholder="搜索单号/申请人" class="inline-grow" clearable />
<el-button class="btn-main" type="primary" :loading="offsetAdvanceLoading" @click="handleOffsetAdvanceSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button class="btn-ghost" @click="handleOffsetAdvanceReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
</div>
<ListTable table-key="bx-payment-3"
class="list-table"
:data="offsetAdvances"
v-loading="offsetAdvanceLoading"
max-height="300"
stripe
@selection-change="handleOffsetSelectionChange"
>
<el-table-column type="selection" width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column prop="applyCode" label="申请单号" min-width="130" align="center" header-align="center" />
<el-table-column prop="applicantName" label="申请人" min-width="80" align="center" header-align="center" />
<el-table-column prop="reason" label="申请原因" min-width="120" align="left" header-align="center" show-overflow-tooltip />
<el-table-column label="已发放金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(row.actualAmount || row.amount || 0) }}
</template>
</el-table-column>
<el-table-column label="已冲抵金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(row.offsetAmount || 0) }}
</template>
</el-table-column>
<el-table-column label="剩余可冲抵(元)" min-width="100" class-name="col-money" align="right" header-align="center">
<template #default="{ row }">
{{ formatMoney(Number(row.actualAmount || row.amount || 0) - Number(row.offsetAmount || 0)) }}
</template>
</el-table-column>
</ListTable>
<div class="offset-pager">
<el-pagination
v-model:current-page="offsetAdvancePage"
v-model:page-size="offsetAdvancePageSize"
:total="offsetAdvanceTotal"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 100, 500]"
@current-change="fetchOffsetAdvances"
@size-change="handleOffsetAdvanceSizeChange"
/>
</div>
<el-form-item label="备注">
<el-input v-model="offsetRemark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
<h4 class="dialog-section-title">冲抵附件(必填)</h4>
<BxAttachmentUpload
ref="offsetAttachmentRef"
v-model="uploadedFiles"
biz-type="reimburse_offset"
:biz-id="currentRow.id"
hint="请至少上传 1 个冲抵附件。"
/>
</template>
<template v-else>
<div class="form-grid-2">
<el-form-item label="本次发放金额(元)" class="field-amount">
<el-input v-model="actualAmount" placeholder="请输入本次发放金额" />
</el-form-item>
<el-form-item label="发放备注" style="grid-column: 1 / -1">
<el-input v-model="issueRemark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</div>
<h4 class="dialog-section-title">发放附件</h4>
<BxAttachmentUpload
ref="issueAttachmentRef"
v-model="payFiles"
biz-type="reimburse_pay"
:biz-id="currentRow.id"
hint="支持上传本次发放截图等凭证。"
/>
</template>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogs.offset = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button
v-if="paymentActionTab === 'pay'"
class="btn-main"
type="primary"
:loading="loadingMap.issueSubmit"
@click="submitIssue"
>
<el-icon><Check /></el-icon>确认发放
</el-button>
<el-button
v-else
class="btn-main"
type="primary"
:loading="loadingMap.offsetSubmit"
@click="submitOffset"
>
<el-icon><Check /></el-icon>确认冲抵
</el-button>
</template>
</el-dialog>
<!-- 处理详情 -->
<el-dialog
v-model="dialogs.processedDetail"
title="处理详情"
width="1040px"
class="dialog-form-layout dialog-view-xl"
align-center
destroy-on-close
>
<div class="dlg-view">
<div class="dlg-view-hero">
<div class="dlg-view-hero-top">
<h2 class="dlg-view-title">{{ processedViewData.applyCode || '-' }}</h2>
<div class="dlg-view-tags">
<el-tag :type="processedStatusTagType(processedViewData.status, processedViewData.payStatus)" size="small">
{{ processedStatusLabel(processedViewData.status, processedViewData.payStatus) }}
</el-tag>
</div>
</div>
<p class="dlg-view-meta">
金额(元):<b class="money-em">{{ formatMoneyYuan(processedViewData.amount || 0) }}</b>
</p>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">基础信息</h4>
<BxViewKvTable :fields="processedBasicKvFields" mode="triple" />
<div v-if="processedAttachments['申请附件']?.length" class="dlg-view-section-attach">
<h4 class="dlg-view-section-title">申请附件</h4>
<BxAttachmentReadonly :files="processedAttachments['申请附件']" />
</div>
</div>
<div class="dlg-view-section">
<h4 class="dlg-view-section-title">发放信息</h4>
<BxViewKvTable :fields="processedPayKvFields" mode="triple" />
<div v-if="processedAttachments['发放附件']?.length" class="dlg-view-section-attach">
<h4 class="dlg-view-section-title">发放附件</h4>
<BxAttachmentReadonly :files="processedAttachments['发放附件']" />
</div>
</div>
<div v-if="Number(processedViewData.offsetAmount || 0) > 0" class="dlg-view-section">
<h4 class="dlg-view-section-title">冲抵信息</h4>
<BxViewKvGrid :fields="processedOffsetKvFields" />
<div v-if="processedAttachments['冲抵附件']?.length" class="dlg-view-section-attach">
<h4 class="dlg-view-section-title">冲抵附件</h4>
<BxAttachmentReadonly :files="processedAttachments['冲抵附件']" />
</div>
<h4 class="dlg-view-section-title section-gap-tight">冲抵明细</h4>
<BaseDetailTableSection embedded title="冲抵明细" :data="processedOffsetRecords" empty-text="暂无冲抵明细">
<ListTable table-key="bx-payment-4" class="list-table" :data="processedOffsetRecords" stripe size="small">
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
<el-table-column prop="advanceApplyCode" label="备用金单号" min-width="130" align="center" header-align="center" />
<el-table-column prop="advanceApplicantName" label="备用金申请人" min-width="110" align="center" header-align="center" />
<el-table-column prop="advanceReason" label="申请原因" min-width="160" align="left" header-align="center" show-overflow-tooltip />
<el-table-column label="冲抵金额(元)" min-width="120" class-name="col-money" align="right" header-align="center">
<template #default="{ row: offsetRow }">
{{ formatMoneyYuan(offsetRow.offsetAmount || 0) }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="冲抵时间" min-width="160" align="center" header-align="center" />
<el-table-column prop="operator" label="操作人" min-width="100" align="center" header-align="center" />
</ListTable>
</BaseDetailTableSection>
</div>
<BxApprovalFlowTabs
:nodes="processedApprovals"
:history="processedApprovalHistory"
:loading="processedApprovalLoading"
/>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ColumnHeaderFilter from '@/components/business/column-header-filter/index.vue'
import { useServerColumnFilters } from '@/composables/useServerColumnFilters'
import { useColumnFacetOptions } from '@/composables/useColumnFacetOptions'
import { useColumnSort } from '@/composables/useColumnSort'
import { reactive, ref, computed, onMounted, nextTick, provide } from 'vue'
import { useListTableViewportHeight } from '@/composables/useListTableViewportHeight'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Money,
Document,
Switch,
Close,
Check,
Search,
RefreshLeft,
Filter
} from '@element-plus/icons-vue'
import { paymentApi, reimburseApi,
fetchReimburseColumnFacetValues
} from '@/api/bx'
import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { statusLabel, statusTagType, formatSumMoney, formatMoney, formatMoneyYuan, moneyTableFormatter } from '@/utils/bx/format'
import { useServerPaged } from '@/hooks/bx/useServerPaged'
import BxApprovalFlowTabs from '@/components/bx/base/BxApprovalFlowTabs.vue'
import BxViewKvGrid from '@/components/bx/base/BxViewKvGrid.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import BaseDetailTableSection from '@/components/bx/base/BaseDetailTableSection.vue'
import BxAttachmentUpload from '@/components/bx/base/BxAttachmentUpload.vue'
import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { paymentColumnFilterDefs } from './columnFilterDefs'
type DialogKey = 'viewDetail' | 'batchIssue' | 'offset' | 'processedDetail'
const listColumnDefs = [
{ prop: 'applyTime', label: '申请时间' },
{ prop: 'applicantName', label: '申请人' },
{ prop: 'title', label: '报销标题' },
{ prop: 'amount', label: '报销金额(元)' },
{ prop: 'offsetAmount', label: '冲抵金额(元)', defaultVisible: false },
{ prop: 'payAmount', label: '发放金额(元)' },
{ prop: 'processStatus', label: '处理状态', defaultVisible: false },
{ prop: 'payTime', label: '处理时间', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('bx-payment-list-columns', listColumnDefs)
const PAYMENT_MONEY_PROPS = new Set(['amount', 'offsetAmount', 'payAmount'])
const activeTab = ref('pending')
const pendingPaymentTableWrapRef = ref<HTMLElement | null>(null)
const pendingPaymentBelowRef = ref<HTMLElement | null>(null)
const pendingPaymentTableRef = ref<any>(null)
const { tableHeight: pendingPaymentTableHeight, recalc: recalcPendingPaymentHeight } = useListTableViewportHeight({
tableEl: pendingPaymentTableWrapRef,
pagerEl: pendingPaymentBelowRef,
tableRef: pendingPaymentTableRef,
enabled: computed(() => activeTab.value === 'pending')
})
const processedPaymentTableWrapRef = ref<HTMLElement | null>(null)
const processedPaymentBelowRef = ref<HTMLElement | null>(null)
const processedPaymentTableRef = ref<any>(null)
const { tableHeight: processedPaymentTableHeight, recalc: recalcProcessedPaymentHeight } = useListTableViewportHeight({
tableEl: processedPaymentTableWrapRef,
pagerEl: processedPaymentBelowRef,
tableRef: processedPaymentTableRef,
enabled: computed(() => activeTab.value === 'processed')
})
const pendingPaymentStatsRowRef = ref<{ resync?: () => void } | null>(null)
const processedPaymentStatsRowRef = ref<{ resync?: () => void } | null>(null)
function buildPaymentStatsColumns(opts: {
withSelection: boolean
mode: 'pending' | 'processed'
}): StatsRowColumn[] {
const cols: StatsRowColumn[] = []
if (opts.withSelection) cols.push({ key: '__selection', interactive: false })
cols.push({ key: '__index', interactive: false })
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (opts.mode === 'pending' && (def.prop === 'processStatus' || def.prop === 'payTime')) continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: PAYMENT_MONEY_PROPS.has(def.prop)
})
}
cols.push({ key: 'unpaidBalance', interactive: false, prop: 'unpaidBalance', money: true })
return cols
}
function unpaidBalance(row: any) {
const value =
Number(row?.amount || 0) - Number(row?.offsetAmount || 0) - Number(row?.actualAmount || 0)
return Math.max(0, Math.round(value * 100) / 100)
}
const pendingPaymentStatsRowColumns = computed(() =>
buildPaymentStatsColumns({ withSelection: true, mode: 'pending' })
)
const processedPaymentStatsRowColumns = computed(() =>
buildPaymentStatsColumns({ withSelection: false, mode: 'processed' })
)
function getPaymentStatCellValue(row: any, prop: string): unknown {
if (prop === 'payAmount') return row?.actualAmount
if (prop === 'unpaidBalance') return unpaidBalance(row)
if (prop === 'processStatus') return processedStatusLabel(row?.status, row?.payStatus)
return row?.[prop]
}
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
viewDetail: false,
batchIssue: false,
offset: false,
processedDetail: false
})
const { rows, total, currentPage, pageSize, rangeText, loading, refresh, search, onSizeChange } = useServerPaged(
async (params) => {
const res: any = await paymentApi.pending(params)
const raw = res.data
if (!raw) return { rows: [], total: 0 }
const flatRows = (raw.rows || []).map((item: any) => ({
...item.data,
title: item.data.title || ''
}))
return { rows: flatRows, total: raw.total || flatRows.length }
}
)
const quickSearchKeyword = ref('')
const processedQuickSearchKeyword = ref('')
const {
filters,
hasActiveFilters,
setFilterState,
clearAllFilters,
isFilterActive,
toQueryParams,
resetToInitial,
watchVisibleColumns
} = useServerColumnFilters(paymentColumnFilterDefs, {
initialFilters: {},
onChange: () => {
if (activeTab.value === 'pending') {
runPendingSearch()
} else {
runProcessedSearch()
}
}
})
const { facetOptionsMap, facetLoadingMap, onFilterOpen } = useColumnFacetOptions({
facetFields: ['applyCode', 'applicantName', 'title'],
columnFilterDefs: paymentColumnFilterDefs,
getContextParams: () => ({
...(activeTab.value === 'pending' ? buildPendingQuery() : buildProcessedQuery()),
scope: activeTab.value === 'pending' ? 'payment_pending' : 'payment_processed'
}),
fetchFacet: (payload) => fetchReimburseColumnFacetValues(payload as any)
})
const {
sortField,
sortOrder,
setSort,
clearSort,
isSortActive,
toQueryParams: toSortQueryParams,
watchVisibleColumns: watchSortVisibleColumns
} = useColumnSort({
onChange: () => {
if (activeTab.value === 'pending') {
runPendingSearch()
} else {
runProcessedSearch()
}
}
})
provide('crmColumnSort', {
setSort,
clearSort,
isSortActive,
sortField,
sortOrder
})
/** 待处理 Tab 不计处理状态/处理时间;切回待处理时自动清掉对应条件 */
const visibleFilterProps = computed(() => {
const props: string[] = ['applyCode']
for (const def of listColumnDefs) {
if (
activeTab.value === 'pending' &&
(def.prop === 'processStatus' || def.prop === 'payTime')
) {
continue
}
if (isColumnVisible(def.prop)) props.push(def.prop)
}
return props
})
watchVisibleColumns(visibleFilterProps)
watchSortVisibleColumns(visibleFilterProps)
function handleResetColumns() {
resetToDefault()
resetToInitial()
}
function enumOptionsFor(prop: string) {
const def = paymentColumnFilterDefs.find((d) => d.prop === prop)
return (def?.enumOptions || []).map((o) => ({ ...o, count: 0 }))
}
const currentRow = ref<Record<string, any>>({})
const selectedRows = ref<any[]>([])
const viewData = ref<Record<string, any>>({})
const viewBasicKvFields = computed<ViewField[]>(() => {
const d = viewData.value
return [
bxViewKv('单号', d.applyCode, { long: false }),
bxViewKv('申请人', d.applicantName, { long: false }),
bxViewKv('报销标题', d.title, { long: false }),
bxViewKv('审批完成时间', d.applyTime, { long: false }),
bxViewKvMoney('金额(元)', formatMoneyYuan(d.amount || 0), { long: false }),
bxViewKv('状态', statusLabel(d.status), { long: false, tagType: statusTagType(d.status) })
]
})
const viewPayKvFields = computed<ViewField[]>(() => {
const d = viewData.value
return [
bxViewKvMoney('已冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
bxViewKvMoney('已发放金额(元)', formatMoneyYuan(d.actualAmount || 0), { long: false }),
bxViewKvMoney('未结清余额(元)', formatMoneyYuan(unpaidBalance(d)), { long: false }),
bxViewKv('发放时间', d.payTime, { long: false }),
bxViewKv('发放操作人', d.payUserName, { long: false }),
bxViewKv('发放备注', d.payRemark, { long: false })
]
})
const issueRemark = ref('')
const actualAmount = ref('')
const batchIssueRemark = ref('')
const batchFiles = ref<BxAttachmentItem[]>([])
const batchAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
const batchLines = ref<
Array<{
id: string
applyCode: string
applicantName: string
title: string
payAmount: string
actualAmount: string
}>
>([])
const offsetRemark = ref('')
const uploadedFiles = ref<BxAttachmentItem[]>([])
const payFiles = ref<BxAttachmentItem[]>([])
const issueAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
const offsetAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
const viewAttachments = ref<BxAttachmentItem[]>([])
const offsetAdvanceFilter = reactive({ keyword: '' })
const offsetAdvances = ref<any[]>([])
const offsetAdvanceTotal = ref(0)
const offsetAdvancePage = ref(1)
const offsetAdvancePageSize = ref(5)
const offsetAdvanceLoading = ref(false)
const offsetSelectedAdvances = ref<any[]>([])
const offsetAmount = ref('')
const paymentActionTab = ref<'offset' | 'pay'>('offset')
function getPaymentPendingExportFilterParams() {
return { ...buildPendingQuery() }
}
function getPaymentProcessedExportFilterParams() {
return { ...buildProcessedQuery() }
}
const paymentExportColumns = computed(() => {
const cols = [...visibleColumns.value]
if (!cols.includes('applyCode')) cols.unshift('applyCode')
return cols
})
const tabLoaded = reactive({ pending: true, processed: false })
const {
rows: processedRows,
total: processedTotal,
currentPage: processedCurrentPage,
pageSize: processedPageSize,
rangeText: processedRangeText,
loading: processedLoading,
search: processedSearch,
onSizeChange: processedOnSizeChange
} = useServerPaged(async (params) => {
const res: any = await paymentApi.processed(params)
const raw = res.data
if (!raw) return { rows: [], total: 0 }
const flatRows = (raw.rows || []).map((item: any) => ({
...item.data,
title: item.data.title || ''
}))
return { rows: flatRows, total: raw.total || flatRows.length }
})
const processedViewData = ref<Record<string, any>>({})
const processedBasicKvFields = computed<ViewField[]>(() => {
const d = processedViewData.value
return [
bxViewKv('单号', d.applyCode, { long: false }),
bxViewKv('申请人', d.applicantName, { long: false }),
bxViewKv('报销标题', d.title || d.reason, { long: false }),
bxViewKvMoney('金额(元)', formatMoneyYuan(d.amount || 0), { long: false }),
bxViewKv('处理状态', processedStatusLabel(d.status, d.payStatus), {
long: false,
tagType: processedStatusTagType(d.status, d.payStatus)
}),
bxViewKv('处理时间', d.payTime, { long: false })
]
})
const processedPayKvFields = computed<ViewField[]>(() => {
const d = processedViewData.value
return [
bxViewKvMoney('已冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
bxViewKvMoney('已发放金额(元)', formatMoneyYuan(d.actualAmount || 0), { long: false }),
bxViewKvMoney('未结清余额(元)', formatMoneyYuan(unpaidBalance(d)), { long: false }),
bxViewKv('发放时间', d.payTime, { long: false }),
bxViewKv('发放操作人', d.payUserName, { long: false }),
bxViewKv('发放备注', d.payRemark, { long: false })
]
})
const processedOffsetKvFields = computed<ViewField[]>(() => {
const d = processedViewData.value
const fields: ViewField[] = [
bxViewKvMoney('冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0))
]
if (d.payTime) fields.push(bxViewKv('处理时间', d.payTime))
if (d.payUserName) fields.push(bxViewKv('处理操作人', d.payUserName))
return fields
})
const processedApprovals = ref<any[]>([])
const processedApprovalHistory = ref<any[]>([])
const processedApprovalLoading = ref(false)
const processedAttachments = ref<Record<string, BxAttachmentItem[]>>({})
const processedOffsetRecords = ref<any[]>([])
onMounted(() => {
runPendingSearch()
})
function onSelectionChange(rows: any[]) {
selectedRows.value = rows
}
function requireSinglePendingRow() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选一条待处理记录')
return null
}
if (selectedRows.value.length > 1) {
ElMessage.warning('只能选择一条待处理记录')
return null
}
return selectedRows.value[0]
}
function handleToolbarIssue() {
const row = requireSinglePendingRow()
if (!row) return
openIssueDialog(row)
}
function handleToolbarOffset() {
const row = requireSinglePendingRow()
if (!row) return
openOffsetDialog(row)
}
function buildPendingQuery() {
const q: Record<string, string> = { ...toQueryParams(), ...toSortQueryParams() }
delete q.statusIn
delete q.status
const kw = quickSearchKeyword.value.trim()
if (kw) q.keyword = kw
return q
}
function buildProcessedQuery() {
const q: Record<string, string> = { ...toQueryParams(), ...toSortQueryParams() }
const kw = processedQuickSearchKeyword.value.trim()
if (kw) q.keyword = kw
return q
}
function runPendingSearch() {
search(buildPendingQuery())
nextTick(() => recalcPendingPaymentHeight())
}
function runProcessedSearch() {
processedSearch(buildProcessedQuery())
nextTick(() => recalcProcessedPaymentHeight())
}
function handleQuickSearch() {
runPendingSearch()
}
function handleQuickSearchClear() {
quickSearchKeyword.value = ''
runPendingSearch()
}
function handleTabChange(tab: string | number) {
if (tab === 'processed' && !tabLoaded.processed) {
runProcessedSearch()
tabLoaded.processed = true
} else if (tab === 'pending') {
runPendingSearch()
} else if (tab === 'processed') {
runProcessedSearch()
}
}
function handleProcessedQuickSearch() {
runProcessedSearch()
}
function handleProcessedQuickSearchClear() {
processedQuickSearchKeyword.value = ''
runProcessedSearch()
}
async function openView(row: any) {
viewData.value = row
viewAttachments.value = []
try {
if (row.id) {
const [payFiles, offsetFiles] = await Promise.all([
loadBxAttachments('reimburse_pay', row.id).catch(() => []),
loadBxAttachments('reimburse_offset', row.id).catch(() => [])
])
viewAttachments.value = [...payFiles, ...offsetFiles]
}
} catch {
/* skip */
}
dialogs.viewDetail = true
}
function openIssueDialog(row: any) {
openOffsetDialog(row, 'pay')
}
function openBatchIssueDialog() {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先勾选待处理记录')
return
}
batchLines.value = selectedRows.value.map((row) => {
return {
id: String(row.id),
applyCode: row.applyCode || '',
applicantName: row.applicantName || '',
title: row.title || '',
payAmount: String(row.amount ?? ''),
actualAmount: String(unpaidBalance(row))
}
})
batchIssueRemark.value = ''
batchFiles.value = []
dialogs.batchIssue = true
}
async function submitBatchIssue() {
if (batchAttachmentRef.value?.hasUploading()) {
ElMessage.warning('附件上传中,请稍候')
return
}
const items = batchLines.value.map((line) => ({
id: line.id,
actualAmount: Number(line.actualAmount)
}))
if (items.some((item) => !Number.isFinite(item.actualAmount) || item.actualAmount < 0)) {
ElMessage.warning('请填写有效的实际发放金额')
return
}
loadingMap.batchIssueSubmit = true
try {
await paymentApi.batchPay({
payRemark: batchIssueRemark.value,
items,
attachmentIds: batchFiles.value.map((file) => file.id)
})
ElMessage.success('发放成功')
dialogs.batchIssue = false
await refresh()
} catch (e: any) {
ElMessage.error(e.message || '发放失败')
} finally {
loadingMap.batchIssueSubmit = false
}
}
async function keepOrCloseBill() {
await refresh()
const latest = rows.value.find((item) => item.id === currentRow.value.id)
if (!latest || unpaidBalance(latest) <= 0) {
dialogs.offset = false
return
}
currentRow.value = { ...latest }
}
async function submitIssue() {
if (issueAttachmentRef.value?.hasUploading()) {
ElMessage.warning('附件上传中,请稍候')
return
}
const amount = Number(actualAmount.value)
if (!Number.isFinite(amount) || amount <= 0) {
ElMessage.warning('请填写本次发放金额')
return
}
if (amount > unpaidBalance(currentRow.value) + 0.001) {
ElMessage.warning('发放金额超过未结清余额')
return
}
loadingMap.issueSubmit = true
try {
await paymentApi.pay(currentRow.value.id, {
payRemark: issueRemark.value,
actualAmount: amount
})
ElMessage.success('发放成功')
actualAmount.value = ''
issueRemark.value = ''
payFiles.value = []
await keepOrCloseBill()
} catch (e: any) {
ElMessage.error(e.message || '发放失败')
} finally {
loadingMap.issueSubmit = false
}
}
async function fetchOffsetAdvances() {
offsetAdvanceLoading.value = true
try {
const res: any = await paymentApi.advanceIssues({
page: offsetAdvancePage.value,
pageSize: offsetAdvancePageSize.value,
issueStatus: 'issued',
keyword: offsetAdvanceFilter.keyword
})
const data = res.data
if (Array.isArray(data)) {
offsetAdvances.value = data
offsetAdvanceTotal.value = data.length
} else {
offsetAdvances.value = data?.rows ?? []
offsetAdvanceTotal.value = data?.total ?? 0
}
} finally {
offsetAdvanceLoading.value = false
}
}
function handleOffsetAdvanceSearch() {
offsetAdvancePage.value = 1
fetchOffsetAdvances()
}
function handleOffsetAdvanceReset() {
offsetAdvanceFilter.keyword = ''
offsetAdvancePage.value = 1
fetchOffsetAdvances()
}
function handleOffsetAdvanceSizeChange() {
offsetAdvancePage.value = 1
fetchOffsetAdvances()
}
function getAdvanceAvailable(adv: any): number {
return Math.max(0, Number(adv.actualAmount || adv.amount || 0) - Number(adv.offsetAmount || 0))
}
function handleOffsetSelectionChange(rows: any[]) {
offsetSelectedAdvances.value = rows
}
function openOffsetDialog(row: any, tab: 'offset' | 'pay' = 'offset') {
currentRow.value = row
paymentActionTab.value = tab
offsetAdvanceFilter.keyword = ''
offsetAdvancePage.value = 1
offsetSelectedAdvances.value = []
offsetAmount.value = ''
actualAmount.value = ''
issueRemark.value = ''
offsetRemark.value = ''
uploadedFiles.value = []
payFiles.value = []
dialogs.offset = true
if (tab === 'offset') fetchOffsetAdvances()
}
function buildOffsetItems(totalOffset: number) {
const items: Array<{ advanceId: string; offsetAmount: number; applyCode: string }> = []
let remaining = totalOffset
for (const adv of offsetSelectedAdvances.value) {
if (remaining <= 0.001) break
const available = getAdvanceAvailable(adv)
if (available <= 0) continue
const toOffset = Math.min(remaining, available)
items.push({
advanceId: adv.id,
offsetAmount: Math.round(toOffset * 100) / 100,
applyCode: adv.applyCode || String(adv.id)
})
remaining -= toOffset
}
return { items, leftover: remaining }
}
async function submitOffset() {
if (offsetSelectedAdvances.value.length === 0) {
ElMessage.warning('请选择要冲抵的备用金申请单')
return
}
const totalOffset = Number(offsetAmount.value) || 0
if (totalOffset <= 0) {
ElMessage.warning('请填写冲抵金额')
return
}
if (totalOffset > unpaidBalance(currentRow.value) + 0.001) {
ElMessage.warning('冲抵金额超过未结清余额')
return
}
if (uploadedFiles.value.length === 0) {
ElMessage.warning('请至少上传 1 个冲抵附件')
return
}
if (offsetAttachmentRef.value?.hasUploading()) {
ElMessage.warning('附件上传中,请稍候')
return
}
const { items, leftover } = buildOffsetItems(totalOffset)
if (!items.length || leftover > 0.001) {
ElMessage.warning('冲抵金额超过所选备用金剩余可冲抵')
return
}
const advanceLines = items.map((it) => `${it.applyCode}(冲抵 ${formatMoneyYuan(it.offsetAmount)})`).join('\n')
const confirmMsg = [
'请确认冲抵信息:',
'',
`本次冲抵金额(元):${formatMoneyYuan(totalOffset)}`,
'冲抵备用金:',
advanceLines,
'',
'确定提交冲抵吗?'
].join('\n')
try {
await ElMessageBox.confirm(confirmMsg, '确认冲抵', {
confirmButtonText: '确定冲抵',
cancelButtonText: '取消',
type: 'warning'
})
} catch {
return
}
loadingMap.offsetSubmit = true
try {
await paymentApi.batchOffset(currentRow.value.id, {
items: items.map(({ advanceId, offsetAmount: amt }) => ({ advanceId, offsetAmount: amt })),
payAmount: 0,
remark: offsetRemark.value
})
ElMessage.success('冲抵成功')
offsetAmount.value = ''
offsetRemark.value = ''
offsetSelectedAdvances.value = []
uploadedFiles.value = []
await keepOrCloseBill()
} catch (e: any) {
ElMessage.error(e.message || '冲抵失败')
} finally {
loadingMap.offsetSubmit = false
}
}
async function openProcessedView(row: any) {
processedViewData.value = row
processedApprovals.value = []
processedApprovalHistory.value = []
processedAttachments.value = {}
processedOffsetRecords.value = []
processedApprovalLoading.value = true
dialogs.processedDetail = true
const fetchApprovalAndAttachments = async () => {
const bizId = row.id
if (!bizId) {
processedApprovalLoading.value = false
return
}
const tasks: Promise<any>[] = []
tasks.push(
Promise.all([
reimburseApi.getApprovals(bizId),
reimburseApi.getApprovalHistory(bizId)
])
.then(([flowRes, historyRes]: any[]) => {
processedApprovals.value = (flowRes.data || []).sort(
(a: any, b: any) => a.nodeOrder - b.nodeOrder
)
processedApprovalHistory.value = historyRes.data || []
})
.catch(() => {})
)
tasks.push(
paymentApi.reimburseOffsetRecords(bizId).then((res: any) => {
processedOffsetRecords.value = res.data || []
}).catch(() => {
processedOffsetRecords.value = []
})
)
const attachTypes = [
{ key: '申请附件', bizType: 'reimburse_apply' },
{ key: '发放附件', bizType: 'reimburse_pay' },
{ key: '冲抵附件', bizType: 'reimburse_offset' }
]
for (const at of attachTypes) {
tasks.push(
loadBxAttachments(at.bizType, bizId)
.then((files) => {
processedAttachments.value[at.key] = files
})
.catch(() => {
processedAttachments.value[at.key] = []
})
)
}
await Promise.all(tasks)
processedApprovalLoading.value = false
}
fetchApprovalAndAttachments()
}
function processedStatusLabel(status: string, payStatus?: string): string {
if (status === 'processed' || status === 'paid' || status === 'offset' || payStatus === 'paid' || payStatus === 'issued')
return '已处理'
return status || '-'
}
function processedStatusTagType(
status: string,
payStatus?: string
): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
if (processedStatusLabel(status, payStatus) === '已处理') return 'success'
return 'info'
}
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.batch-issue-remark {
margin-top: 16px;
}
</style>