218 lines
19 KiB
Vue
218 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
import '@/styles/adminDensity.css'
|
||
import '../overseasFeeDetails.css'
|
||
import { computed, reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { Plus } from '@lucide/vue'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import RowActions from '@/shared/components/RowActions.vue'
|
||
import OverseasFeeEditor, { type FeeItemOption } from './OverseasFeeEditor.vue'
|
||
import OverseasFeeRetryHistory from './OverseasFeeRetryHistory.vue'
|
||
import StorageRentFeeDetails from './StorageRentFeeDetails.vue'
|
||
import StorageRentFeeActions from './StorageRentFeeActions.vue'
|
||
import { useFeeStatusRefresh } from '../useFeeStatusRefresh'
|
||
import { actOnOverseasFee, getOverseasFee, listBusinessOverseasFees, listOverseasFeeItems, type BusinessFeeOption, type OverseasFee, type OverseasFeeDirection } from '../api/overseasFees'
|
||
import { actionLabels, feeActions, feeMoney, feePermissions, feeProcessingLabel, feeTime, feeReviewLabel, feeFundingErrorLabel } from '../overseasFeePresentation'
|
||
|
||
const props = withDefaults(defineProps<{ business: BusinessFeeOption; relatedBusinesses?: BusinessFeeOption[]; readonly?: boolean; canCreate?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { readonly: false, canCreate: true, direction: 'RECEIVABLE', hideWhenEmpty: false })
|
||
const emit = defineEmits<{ changed: [] }>()
|
||
const access = useAccessPreviewStore()
|
||
const canEdit = computed(() => !props.readonly && access.can(feePermissions.edit))
|
||
const loading = ref(false), error = ref<ApiErrorDetails | null>(null), itemsError = ref<ApiErrorDetails | null>(null), itemsLoading = ref(false)
|
||
const page = ref(1), pageSize = ref(20), total = ref(0), rows = ref<OverseasFee[]>([]), items = ref<FeeItemOption[]>([])
|
||
const detailLoading = ref(''), detail = ref<OverseasFee | null>(null), detailOpen = ref(false)
|
||
const editorOpen = ref(false), editFee = ref<OverseasFee | null>(null)
|
||
const actionOpen = ref(false), saving = ref(false), actionError = ref<ApiErrorDetails | null>(null)
|
||
const actionForm = reactive({ action: '', reason: '', requestKey: '', requestFingerprint: '' })
|
||
const actionFee = ref<OverseasFee | null>(null)
|
||
const rentFee = ref<OverseasFee | null>(null), rentAction = ref(''), rentOpen = ref(false)
|
||
const actionTitle = computed(() => actionForm.action === 'REVIEW' ? '审核' : actionLabels[actionForm.action] || '确认操作')
|
||
const actionConfirmText = computed(() => actionLabels[actionForm.action] || '确认操作')
|
||
const actionConfirmType = computed(() => ['REJECT', 'VOID'].includes(actionForm.action) ? 'danger' : 'primary')
|
||
const businesses = computed(() => [props.business, ...(props.relatedBusinesses || [])].filter(scope => scope.customerId === props.business.customerId))
|
||
const businessKey = computed(() => props.direction + '|' + businesses.value.map(scope => [scope.businessType, scope.businessId, scope.customerId].join(':')).join('|'))
|
||
const feeBusiness = (fee: OverseasFee) => businesses.value.find(scope => fee.businessId === scope.businessId && fee.businessType === scope.businessType && fee.customerId === scope.customerId)
|
||
const belongsToBusiness = (fee: OverseasFee) => fee.direction === props.direction && !!feeBusiness(fee)
|
||
const editorBusiness = computed(() => editFee.value ? feeBusiness(editFee.value) || props.business : props.business)
|
||
function rowActions(fee: OverseasFee) {
|
||
return feeActions(fee, permission => !props.readonly && access.can(permission), !!detailLoading.value || saving.value)
|
||
}
|
||
const collectionLabel = (fee: OverseasFee) => fee.direction === 'PAYABLE' ? '推送结算' : fee.collectionMode === 'BILL' ? '账单结算' : '余额/授信'
|
||
const columns = computed<DataTableColumn[]>(() => [
|
||
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
|
||
{ prop: 'feeItemName', label: '费用项目', minWidth: 150 },
|
||
{ prop: 'reviewStatus', label: '审核状态', width: 100, slotName: 'reviewStatus', afterColumn: 'feeItemName' },
|
||
{ key: 'chargeStatus', label: '费用状态', width: 120, formatter: feeProcessingLabel, afterColumn: 'feeItemName' },
|
||
...(props.direction === 'PAYABLE' ? [{ prop: 'supplierName', label: '供应商', minWidth: 180 }] : []),
|
||
{ key: 'amount', label: '金额', width: 150, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.originalAmount, row.originalCurrency) },
|
||
{ key: 'settlementAmount', label: '结算金额(CNY)', width: 145, align: 'right', formatter: (row: OverseasFee) => feeMoney(row.amount) },
|
||
{ prop: 'collectionMode', label: '结算方式', width: 110, formatter: collectionLabel },
|
||
{ prop: 'reviewerName', label: '审核人', width: 120, formatter: (row: OverseasFee) => row.reviewerName || row.reviewerId || '—' },
|
||
{ prop: 'reviewedAt', label: '审核时间', width: 160, formatter: (row: OverseasFee) => feeTime(row.reviewedAt) },
|
||
{ prop: 'businessDate', label: '费用日期', width: 110 },
|
||
])
|
||
let loadSequence = 0, detailSequence = 0, itemsSequence = 0
|
||
const statusRefresh = useFeeStatusRefresh(rows, () => load(true), computed(() => !error.value))
|
||
async function load(background = false) {
|
||
if (!background) statusRefresh.reset()
|
||
const current = ++loadSequence; if (!background) loading.value = true; error.value = null
|
||
try {
|
||
const data = await listBusinessOverseasFees(businesses.value, { page: page.value, size: pageSize.value }, props.direction)
|
||
if (current !== loadSequence) return
|
||
if (page.value > 1 && page.value > data.pages) { page.value = Math.max(1, data.pages); return }
|
||
rows.value = data.records.filter(belongsToBusiness); 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 loadItems() {
|
||
const current = ++itemsSequence; itemsLoading.value = true; itemsError.value = null
|
||
try {
|
||
const result = await listOverseasFeeItems(props.direction)
|
||
if (current !== itemsSequence) return false
|
||
items.value = result
|
||
return true
|
||
} catch (failure) { if (current === itemsSequence) itemsError.value = getApiErrorDetails(failure, '费用项目加载失败'); return false }
|
||
finally { if (current === itemsSequence) itemsLoading.value = false }
|
||
}
|
||
async function saved() { emit('changed'); await load() }
|
||
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 create() { if (!canEdit.value || !props.canCreate || props.direction === 'PAYABLE') return; if (!await loadItems() || !canEdit.value || !props.canCreate) return; editFee.value = null; editorOpen.value = true }
|
||
async function handleAction(row: OverseasFee, action: string) {
|
||
if (detailLoading.value || saving.value) return
|
||
const current = await freshFee(row)
|
||
if (!current) return
|
||
if (!belongsToBusiness(current)) { ElMessage.warning('费用所属单据已变化,请重新查询'); await load(); return }
|
||
if (action === 'DETAIL') { detail.value = current; detailOpen.value = true; return }
|
||
if (!rowActions(current).some(item => item.key === action)) {
|
||
ElMessage.warning('费用状态已更新,请重新查询'); await load(); return
|
||
}
|
||
detailOpen.value = false
|
||
if (current.storageRent && ['REVIEW', 'ADJUST_RENT'].includes(action)) { rentFee.value = current; rentAction.value = action; rentOpen.value = true; return }
|
||
if (action === 'ADJUST') { if (!await loadItems() || !canEdit.value || !belongsToBusiness(current)) return; editFee.value = current; editorOpen.value = true; return }
|
||
actionFee.value = current; actionForm.action = action
|
||
actionForm.reason = ''; actionForm.requestKey = ''; actionForm.requestFingerprint = ''; actionError.value = null; actionOpen.value = true
|
||
}
|
||
async function confirmAction() {
|
||
const fee = actionFee.value, action = actionForm.action
|
||
if (!fee || saving.value || !belongsToBusiness(fee)) return
|
||
if (action === 'REVIEW' || !rowActions(fee).some(item => item.key === action)) return
|
||
if (action === 'VOID' && !actionForm.reason.trim()) { ElMessage.warning('请填写原因'); return }
|
||
saving.value = true; actionError.value = null
|
||
try {
|
||
const body = action === 'VOID' ? { expectedVersion: fee.sourceVersion, reason: actionForm.reason.trim() }
|
||
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: fee.deliveryEventId } : {}
|
||
const fingerprint = JSON.stringify(body)
|
||
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) { actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID() }
|
||
const result = await actOnOverseasFee(fee.sourceFeeId, action, { ...body, idempotencyKey: actionForm.requestKey })
|
||
if (detail.value?.sourceFeeId === result.sourceFeeId) detail.value = result
|
||
actionOpen.value = false; ElMessage.success('操作已提交'); emit('changed'); await load()
|
||
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用操作失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
async function submitReview(decision: 'APPROVE' | 'REJECT') {
|
||
const fee = actionFee.value
|
||
if (!fee || saving.value || !belongsToBusiness(fee)) return
|
||
if (!rowActions(fee).some(item => item.key === 'REVIEW') || !fee.allowedActions.includes(decision)) return
|
||
if (decision === 'REJECT' && !actionForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||
const body = { decision, reason: actionForm.reason.trim() || undefined, expectedVersion: fee.sourceVersion }
|
||
const fingerprint = JSON.stringify(body)
|
||
if (actionForm.requestFingerprint !== fingerprint || !actionForm.requestKey) {
|
||
actionForm.requestFingerprint = fingerprint; actionForm.requestKey = crypto.randomUUID()
|
||
}
|
||
saving.value = true; actionError.value = null
|
||
try {
|
||
const result = await actOnOverseasFee(fee.sourceFeeId, decision, { ...body, idempotencyKey: actionForm.requestKey })
|
||
actionFee.value = result
|
||
actionOpen.value = false
|
||
ElMessage.success(decision === 'APPROVE' ? '费用审核通过' : '费用已驳回')
|
||
emit('changed'); await load()
|
||
} catch (failure) { actionError.value = getApiErrorDetails(failure, '费用审核失败') }
|
||
finally { saving.value = false }
|
||
}
|
||
watch([page, pageSize], () => void load())
|
||
watch(businessKey, () => {
|
||
loadSequence++; detailSequence++; itemsSequence++; detailLoading.value = ''; itemsLoading.value = false
|
||
rows.value = []; total.value = 0; detail.value = null; actionFee.value = null; rentOpen.value = false; rentFee.value = null
|
||
editorOpen.value = false; detailOpen.value = false; actionOpen.value = false; itemsError.value = null
|
||
if (page.value === 1) void load(); else page.value = 1
|
||
}, { immediate: true })
|
||
watch(canEdit, allowed => { if (!allowed) editorOpen.value = false })
|
||
watch(() => actionFee.value && rowActions(actionFee.value).some(item => item.key === actionForm.action), allowed => { if (!allowed && !saving.value) actionOpen.value = false })
|
||
defineExpose({ refresh: load })
|
||
</script>
|
||
|
||
<template>
|
||
<section v-show="!hideWhenEmpty || !!error || total > 0" class="business-order-fees admin-density admin-workbench" :aria-label="heading">
|
||
<h3 v-if="heading" class="business-fee-heading">{{ heading }}</h3>
|
||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" appearance="integrated" :preference-key="`business-order-fees-${direction.toLowerCase()}`" row-key="sourceFeeId" empty-text="暂无费用明细">
|
||
<template #toolbar-actions>
|
||
<slot name="toolbar-actions">
|
||
<el-button v-if="canEdit && canCreate && direction === 'RECEIVABLE'" type="primary" :loading="itemsLoading" @click="create"><Plus :size="16" />新增费用</el-button>
|
||
</slot>
|
||
</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="itemsError" title="费用项目加载失败" :message="itemsError.message" :loading="itemsLoading" @retry="loadItems"><template #details><ApiErrorMeta :details="itemsError" /></template></LoadErrorState>
|
||
</template>
|
||
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as OverseasFee)" @action="handleAction(row as OverseasFee, $event)" /></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>
|
||
<StorageRentFeeActions v-model="rentOpen" :fee="rentFee" :action="rentAction" :readonly="readonly" @saved="saved" />
|
||
<OverseasFeeEditor v-model="editorOpen" :fee="editFee" :items="items" :business="editorBusiness" @saved="saved" />
|
||
<AppDialog append-to-body 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.feeItemName }}</el-descriptions-item>
|
||
<el-descriptions-item v-if="detail.feeRemark" label="费用说明" :span="2">{{ detail.feeRemark }}</el-descriptions-item>
|
||
<el-descriptions-item label="金额">{{ feeMoney(detail.originalAmount, detail.originalCurrency) }}</el-descriptions-item><el-descriptions-item label="结算方式">{{ collectionLabel(detail) }}</el-descriptions-item>
|
||
<el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '—' }}</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 v-if="detail.reviewerName || detail.reviewerId" label="审核人">{{ detail.reviewerName || detail.reviewerId }}</el-descriptions-item><el-descriptions-item v-if="detail.reviewedAt" label="审核时间">{{ feeTime(detail.reviewedAt) }}</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>
|
||
<StorageRentFeeDetails :fee="detail" />
|
||
<OverseasFeeRetryHistory v-if="detailOpen" :fee-id="detail.sourceFeeId" :refresh-key="[detail.deliveryStatus, detail.deliveryErrorMessage, detail.walletStatus].join('|')" />
|
||
</template>
|
||
</AppDialog>
|
||
<AppDialog append-to-body v-model="actionOpen" :title="actionTitle" width="520px" class="admin-density-dialog admin-workbench is-dialog" :confirm-text="actionConfirmText" :confirm-type="actionConfirmType" :confirm-loading="saving" @confirm="confirmAction">
|
||
<el-descriptions v-if="actionFee" :column="1" border class="density-descriptions"><el-descriptions-item label="业务单号">{{ actionFee.businessNo }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ actionFee.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ feeMoney(actionFee.originalAmount, actionFee.originalCurrency) }}</el-descriptions-item><el-descriptions-item v-if="actionFee.feeRemark" label="费用说明">{{ actionFee.feeRemark }}</el-descriptions-item></el-descriptions>
|
||
<el-form v-if="actionForm.action === 'REVIEW' || actionForm.action === 'VOID'" label-position="top" class="fee-action-reason density-form" :disabled="saving"><el-form-item :label="actionForm.action === 'REVIEW' ? '驳回原因(驳回时必填)' : '原因'" :required="actionForm.action === 'VOID'"><el-input v-model="actionForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="actionForm.action === 'REVIEW' ? '审核通过可不填,驳回时请填写原因' : ''" /></el-form-item></el-form>
|
||
<el-alert v-if="actionForm.action === 'REVIEW' && actionFee?.direction === 'RECEIVABLE' && actionFee.businessType === 'LASTLEG_ORDER' && actionFee.collectionMode === 'BILL'" title="请核对原报价,已含费用请勿重复收取。" type="warning" :closable="false" />
|
||
<el-alert v-if="actionError" :title="actionError.message" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert>
|
||
<template #footer="{ close, submitting }">
|
||
<div v-if="actionForm.action === 'REVIEW'" class="app-dialog__footer">
|
||
<el-button v-if="actionFee?.allowedActions.includes('REJECT')" type="danger" :disabled="submitting || !actionForm.reason.trim()" @click="submitReview('REJECT')">驳回</el-button>
|
||
<el-button v-if="actionFee?.allowedActions.includes('APPROVE')" type="primary" :loading="submitting" :disabled="submitting" @click="submitReview('APPROVE')">审核通过</el-button>
|
||
</div>
|
||
<div v-else class="app-dialog__footer">
|
||
<el-button :disabled="submitting" @click="close">取消</el-button>
|
||
<el-button :type="actionConfirmType" :loading="submitting" @click="confirmAction">{{ actionConfirmText }}</el-button>
|
||
</div>
|
||
</template>
|
||
</AppDialog>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.fee-action-reason { margin-top: 16px; }
|
||
.business-fee-heading { margin: var(--admin-space-3) 0; font-size: var(--admin-font-section); }
|
||
</style>
|