Files
admin-web/src/modules/overseas-business/components/BusinessOrderFees.vue
T

218 lines
19 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 '@/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>