187 lines
16 KiB
Vue
187 lines
16 KiB
Vue
<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>
|
||
|