Files
admin-web/src/modules/overseas-business/pages/OverseasFeesView.vue
T
2026-10-26 13:43:08 +08:00

187 lines
16 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.
<script setup lang="ts">
import '../overseasFeeDetails.css'
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions from '@/shared/components/RowActions.vue'
import type { FeeItemOption } from '../components/OverseasFeeEditor.vue'
import StorageRentFeeDetails from '../components/StorageRentFeeDetails.vue'
import StorageRentPanel from '../components/StorageRentPanel.vue'
import OverseasFeeRetryHistory from '../components/OverseasFeeRetryHistory.vue'
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
import { actOnOverseasFee, getOverseasFee, listOverseasFeeCustomers, listOverseasFeeItems, listOverseasFees, type OverseasFee } from '../api/overseasFees'
import { actionLabels, businessLabels, feeBusinessLabel, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel, processingLabels, reviewLabels } from '../overseasFeePresentation'
const tab = ref('fees'), loading = ref(false), error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([])
const customers = ref<Array<{ customerId: string; customerName: string }>>([]), items = ref<FeeItemOption[]>([])
const contextLoading = ref(false), detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
const filters = ref<QueryModel>({ customerId: '', businessNo: '', businessType: '', feeItemCode: '', reviewStatus: '', processingStatus: '', direction: 'RECEIVABLE' })
const applied = ref({ ...filters.value })
const access = useAccessPreviewStore()
const retryFee = ref<OverseasFee | null>(null), retryOpen = ref(false), retryAction = ref(''), retryKey = ref(''), retrySaving = ref(false), retryError = ref<ApiErrorDetails | null>(null)
const retryActions = ['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY']
function rowActions(fee: OverseasFee) {
const disabled = !!detailLoading.value || retrySaving.value
const actions = [{ key: 'DETAIL', label: '详情', disabled }]
const closed = fee.reviewStatus === 'REJECTED' || ['VOIDED', 'SUPERSEDED', 'CANCELLED'].includes(fee.sourceStatus)
if (access.can(feePermissions.edit)) for (const action of retryActions) {
if (closed && !['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'].includes(action)) continue
if (fee.allowedActions.includes(action)) actions.push({ key: action, label: actionLabels[action]!, disabled })
}
return actions
}
const fields = computed<QueryField[]>(() => [
{ key: 'direction', type: 'select', label: '收付方向', placeholder: '收付方向', options: [{ value: 'RECEIVABLE', label: '应收客户' }, { value: 'PAYABLE', label: '应付供应商' }], props: { 'aria-label': '收付方向', clearable: false } },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '客户', width: 200, options: customers.value.map(row => ({ value: row.customerId, label: row.customerName })), props: { filterable: true, loading: contextLoading.value, 'aria-label': '客户' } },
{ key: 'businessNo', type: 'input', label: '业务单号', placeholder: '业务单号', width: 220, props: { 'aria-label': '业务单号' } },
{ key: 'businessType', type: 'select', label: '业务类型', placeholder: '业务类型', options: Object.entries(businessLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '业务类型' } },
{ key: 'feeItemCode', type: 'select', label: '费用项目', placeholder: '费用项目', options: items.value.map(row => ({ value: row.feeCode, label: row.feeTypeName })), props: { filterable: true, 'aria-label': '费用项目' } },
{ key: 'reviewStatus', type: 'select', label: '审核状态', placeholder: '审核状态', options: Object.entries(reviewLabels).map(([value, label]) => ({ value, label })), props: { 'aria-label': '审核状态' } },
{ key: 'processingStatus', type: 'select', label: '费用状态', placeholder: '费用状态', options: Object.entries(processingLabels).map(([value, label]) => ({ value, label: value === 'COMPLETED' && filters.value.direction === 'PAYABLE' ? '推送成功' : label })), props: { 'aria-label': '费用状态' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'businessNo', label: '业务单号', minWidth: 185 },
{ prop: 'businessType', label: '业务类型', width: 115, formatter: feeBusinessLabel },
{ prop: 'feeItemName', label: '费用项目', minWidth: 145 },
{ prop: 'supplierName', label: '供应商', minWidth: 150, defaultVisible: false },
{ key: 'amount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
{ prop: 'collectionMode', label: '结算方式', width: 115, formatter: (row: OverseasFee) => row.direction === 'PAYABLE' ? '推送结算' : row.collectionMode === 'BILL' ? '账单结算' : '余额/授信' },
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus' },
{ key: 'chargeStatus', label: '费用状态', minWidth: 115, formatter: feeProcessingLabel },
{ prop: 'businessDate', label: '费用日期', width: 110 },
{ prop: 'createdAt', label: '创建时间', minWidth: 165, defaultVisible: false, formatter: (row: OverseasFee) => feeTime(row.createdAt) },
]
let loadSequence = 0, detailSequence = 0
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => tab.value !== 'rent' && !error.value))
async function load(background = false) {
if (!background) statusRefresh.reset()
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
try {
const params = Object.fromEntries(Object.entries(applied.value).map(([key, value]) => [key, value ? String(value).trim() : undefined]))
const data = await listOverseasFees({ ...params, page: page.value, size: pageSize.value, direction: params.direction || 'RECEIVABLE' })
if (current === loadSequence) {
rows.value = data.records; total.value = data.total
const latestDetail = rows.value.find(row => row.sourceFeeId === detail.value?.sourceFeeId)
if (latestDetail) detail.value = latestDetail
}
} catch (failure) { if (current === loadSequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(failure, '费用列表加载失败') } }
finally { if (current === loadSequence) loading.value = false }
}
async function loadContext() {
contextLoading.value = true; contextError.value = null
const results = await Promise.allSettled([listOverseasFeeCustomers(), listOverseasFeeItems()])
if (results[0].status === 'fulfilled') customers.value = results[0].value
else contextError.value = getApiErrorDetails(results[0].reason, '客户选项加载失败')
if (results[1].status === 'fulfilled') items.value = results[1].value
else contextError.value = getApiErrorDetails(results[1].reason, '费用项目加载失败')
contextLoading.value = false
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
async function freshFee(row: OverseasFee) {
const current = ++detailSequence; detailLoading.value = row.sourceFeeId
try { const data = await getOverseasFee(row.sourceFeeId); return current === detailSequence ? data : null }
catch (failure) { ElMessage.error(getApiErrorDetails(failure, '费用详情加载失败').message); return null }
finally { if (current === detailSequence) detailLoading.value = '' }
}
async function showDetail(row: OverseasFee) {
if (detailLoading.value) return
const current = await freshFee(row)
if (current) { detail.value = current; detailOpen.value = true }
}
async function onAction(action: string, row: OverseasFee) {
if (detailLoading.value || retrySaving.value) return
if (action === 'DETAIL') { await showDetail(row); return }
if (!retryActions.includes(action) || !rowActions(row).some(item => item.key === action)) return
const current = await freshFee(row)
if (!current) return
if (current.sourceFeeId !== row.sourceFeeId || current.customerId !== row.customerId || !rowActions(current).some(item => item.key === action)) {
ElMessage.warning('费用状态已变化,请刷新后重试'); await load(); return
}
retryFee.value = current; retryAction.value = action; retryKey.value = crypto.randomUUID(); retryError.value = null; retryOpen.value = true
}
async function confirmRetry() {
const fee = retryFee.value, action = retryAction.value
if (!fee || retrySaving.value || !rowActions(fee).some(item => item.key === action) || !access.can(feePermissions.edit)) return
const delivery = ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action)
if (delivery && !fee.deliveryEventId) return
retrySaving.value = true; retryError.value = null
try {
await actOnOverseasFee(fee.sourceFeeId, action, { idempotencyKey: retryKey.value,
...(delivery ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}),
})
retryOpen.value = false; ElMessage.success('重试已提交'); await load()
} catch (failure) { retryError.value = getApiErrorDetails(failure, '重试失败') }
finally { retrySaving.value = false }
}
// 返回费用页签时更新审核与资金结果,保留用户原来的查询条件。
watch(tab, () => { if (tab.value === 'fees') void load() })
watch([page, pageSize], () => { if (tab.value !== 'rent') void load() })
onMounted(() => { void load(); void loadContext() })
</script>
<template>
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="海外费用管理">
<section class="workbench-table is-page-surface">
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
<el-tab-pane label="费用明细" name="fees">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" toolbar-placement="filters" preference-key="overseas-fees" row-key="sourceFeeId" empty-text="暂无费用明细">
<template #filters><QueryToolbar v-model="filters" ariaLabel="海外费用筛选" :fields="fields" :loading="loading" @search="search" /></template>
<template #notices>
<LoadErrorState v-if="error" title="费用列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="contextError" title="筛选选项加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadContext"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="onAction($event, row as OverseasFee)" /></template>
<template #reviewStatus="{ row }"><el-tag :type="(row as OverseasFee).reviewStatus === 'APPROVED' ? 'success' : (row as OverseasFee).reviewStatus === 'REJECTED' ? 'danger' : 'info'">{{ feeReviewLabel(row as OverseasFee) }}</el-tag></template>
<template #pagination-left>共 {{ total }} 条费用</template>
</DataTable>
</el-tab-pane>
<el-tab-pane label="仓租库存" name="rent" lazy><StorageRentPanel :customers="customers" /></el-tab-pane>
</el-tabs>
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" title="费用详情" width="860px" class="admin-density-dialog admin-workbench is-dialog" :show-confirm="false" cancel-text="关闭" fullscreenable>
<template v-if="detail">
<el-descriptions :column="2" border class="density-descriptions overseas-fee-descriptions">
<el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="业务单号">{{ detail.businessNo }}</el-descriptions-item>
<el-descriptions-item label="业务类型">{{ feeBusinessLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item>
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ detail.direction === 'PAYABLE' ? '推送结算' : detail.collectionMode === 'BILL' ? '账单结算' : '余额/授信' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算金额">{{ feeMoney(detail.amount, detail.currency) }}</el-descriptions-item><el-descriptions-item v-if="detail.originalCurrency !== detail.currency" label="结算汇率">{{ detail.exchangeRate || '—' }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item>
<el-descriptions-item label="审核状态">{{ feeReviewLabel(detail) }}</el-descriptions-item><el-descriptions-item label="费用状态">{{ feeProcessingLabel(detail) }}</el-descriptions-item>
<el-descriptions-item label="费用日期">{{ detail.businessDate }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ feeTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.periodStart || detail.periodEnd" label="计费期间" :span="2">{{ detail.periodStart }} — {{ detail.periodEnd }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reviewReason" label="审核意见" :span="2">{{ detail.reviewReason }}</el-descriptions-item>
<el-descriptions-item v-if="detail.walletErrorMessage" :label="feeFundingErrorLabel(detail)" :span="2">{{ detail.walletErrorMessage }}<span v-if="detail.sourceStatus === 'VOIDED'">(此费用已作废,无需继续审核)</span></el-descriptions-item>
<el-descriptions-item v-if="detail.deliveryErrorMessage" label="推送异常" :span="2">{{ detail.deliveryErrorMessage }}</el-descriptions-item>
</el-descriptions>
<StorageRentFeeDetails :fee="detail" />
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
</template>
</AppDialog>
<AppDialog v-model="retryOpen" :title="actionLabels[retryAction] || '重试'" width="560px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionLabels[retryAction] || '重试'" :confirm-loading="retrySaving" lock-while-submitting @confirm="confirmRetry">
<el-descriptions v-if="retryFee" :column="1" border class="density-descriptions">
<el-descriptions-item label="业务单号">{{ retryFee.businessNo }}</el-descriptions-item>
<el-descriptions-item label="费用项目">{{ retryFee.feeItemName }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ feeMoney(retryFee.amount, retryFee.currency) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">{{ feeProcessingLabel(retryFee) }}</el-descriptions-item>
</el-descriptions>
<LoadErrorState v-if="retryError" title="重试失败" :message="retryError.message" :loading="retrySaving" @retry="confirmRetry"><template #details><ApiErrorMeta :details="retryError" /></template></LoadErrorState>
</AppDialog>
</AdminPage>
</template>