Files
admin-web/src/modules/finance/pages/FinanceCustomerArapView.vue
T
2026-08-25 22:08:59 +08:00

704 lines
38 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.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 { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
interface ArapRow {
id: string
feeSourceId: string | null
customerId: string
customerName: string
direction: 'RECEIVABLE' | 'PAYABLE'
documentNo: string
sourceBusinessId: string
sourceType: string
sourceId: string
orderId: string
inboundId: string
containerId: string
inboundCode: string
containerNo: string
supplierName: string
feeName: string
amount: number
status: string
occurredAt: string
businessDate: string
reviewStatus: string
statementGenerated: boolean | null
}
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const customerSelectOpen = ref(false)
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', statementGenerated: '', businessRange: [], keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const selected = ref<ArapRow | null>(null)
const detail = ref<FinanceFeeSourceDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const reviewOpen = ref(false)
const reviewSaving = ref(false)
const reviewError = ref<ApiErrorDetails | null>(null)
const reviewForm = reactive<{ action: 'APPROVE' | 'REJECT'; reason: string }>({ action: 'APPROVE', reason: '' })
const mutationOpen = ref(false)
const mutationSaving = ref(false)
const mutationError = ref<ApiErrorDetails | null>(null)
const mutationMode = ref<'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID'>('EDIT')
const mutationForm = reactive({ amount: 0, reason: '' })
const mutationTitle = computed(() => ({ EDIT: '编辑费用', ADJUST: '调整费用', SUBMIT: '提交审核', VOID: '作废费用' } as const)[mutationMode.value])
const selectedRows = ref<ArapRow[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const exportBusy = ref(false)
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
const canViewSource = computed(() => access.can('platform:finance:customer-arap:list'))
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
const canEditSource = computed(() => access.can('platform:finance:fee-source:edit'))
const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create'))
const canExport = computed(() => access.can('platform:finance:customer-arap:export') && access.can('platform:download:list'))
function isFirstlegSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
const system = String(source.sourceSystem || '').trim().toUpperCase()
const type = String(source.sourceType || '').trim().toUpperCase()
return system === 'FIRSTLEG' || ['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)
}
const stateError = computed(() => customersError.value || loadError.value)
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
function onCustomerSelectVisible(visible: boolean) {
customerSelectOpen.value = visible
}
function closeCustomerSelectOnScroll() {
if (!customerSelectOpen.value || typeof document === 'undefined') return
const input = document.querySelector('.finance-customer-select input') as HTMLInputElement | null
input?.blur()
customerSelectOpen.value = false
}
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
label: '客户名称',
width: 260,
placeholder: '请选择客户',
props: {
'aria-label': '客户名称',
class: 'finance-customer-select',
popperClass: 'finance-customer-arap-popper',
teleported: false,
onVisibleChange: onCustomerSelectVisible,
filterable: true,
clearable: true,
loading: customersLoading.value,
},
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
},
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '请选择收付方向', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '请选择来源状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '请选择审核状态', options: financeArapStatusOptions },
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '请选择对账单状态', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
]
function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
const candidate = String(snapshot || '').trim()
const placeholder = [String(customerId), `客户 ${customerId}`, `客户${customerId}`].includes(candidate)
return masterName || (candidate && !placeholder ? candidate : '客户名称暂不可用')
}
function sourceTypeDisplay(value: unknown) {
const code = String(value || '').trim().toUpperCase()
const labels: Record<string, string> = {
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_COST: '整柜成本', CONTAINER_FEE: '柜次费用',
MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
}
return labels[code] || (code ? '其他费用来源' : '来源类型暂不可用')
}
function feeDocumentLabel(direction: ArapRow['direction'], feeName: unknown) {
const prefix = direction === 'RECEIVABLE' ? '应收费用' : '应付费用'
const name = displayFeeName(feeName)
return name ? `${prefix} · ${name}` : prefix
}
function displayFeeName(value: unknown) {
const name = String(value || '').trim()
if (!name) return ''
if (/^ADMIN-API-\d+-整柜成本$/i.test(name)) return '整柜成本'
return name
}
function sourceSummary(sourceType: unknown, orderId: unknown, inboundId: unknown, containerId: unknown) {
const relations = [inboundId ? '关联入库单' : '', orderId ? '关联订单' : '', containerId ? '关联柜次' : ''].filter(Boolean)
const type = sourceTypeDisplay(sourceType)
return relations.length ? `${type} · ${relations.join('、')}` : type
}
function businessReference(row: ArapRow) {
const references = [row.inboundCode ? `入仓码 ${row.inboundCode}` : '', row.containerNo ? `柜次 ${row.containerNo}` : '']
const businessId = row.sourceBusinessId.trim()
if (businessId && ![row.inboundCode, row.containerNo].includes(businessId)) references.push(`业务单号 ${businessId}`)
return references.filter(Boolean).join(' · ') || '业务关联待补充'
}
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
function sourceStatusDisplay(value: unknown) {
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
return labels[String(value || '').toUpperCase()] || '状态待确认'
}
function formatDateTime(value: unknown) {
const raw = String(value || '').trim()
if (!raw) return '时间暂不可用'
const date = new Date(raw)
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
}
function formatBusinessDate(value: unknown) {
const raw = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return '业务日期待补充'
const [year, month, day] = raw.split('-')
return `${year}/${Number(month)}/${Number(day)}`
}
function displayOperator(value: unknown) {
const operator = String(value || '').trim()
if (!operator) return '名称数据缺失'
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
}
const rows = computed(() => allRows.value)
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + row.amount, 0))
async function loadCustomers() {
if (!canListCustomers.value) return
customersLoading.value = true; customersError.value = null
try { const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1; do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); customers.value = records }
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model); allRows.value = []; loadError.value = null
const customerId = String(filters.value.customerId || '').trim()
loading.value = true
try {
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
const result = await listFinanceFeeSources({
page: page.value, size: pageSize.value, customerId: customerId || undefined, direction: direction || undefined,
status: String(filters.value.status || '') || undefined,
reviewStatus: (String(filters.value.reviewStatus || '') || undefined) as 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' | undefined,
statementGenerated: String(filters.value.statementGenerated || '') ? String(filters.value.statementGenerated) === 'true' : undefined,
businessDateFrom: range[0], businessDateTo: range[1], keyword: String(filters.value.keyword || '').trim() || undefined,
})
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, statementGenerated: item.statementGenerated }))
total.value = result.total
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
}
function search(model: QueryModel) { page.value = 1; void load(model) }
async function loadDetail(row: ArapRow) {
detailLoading.value = true; detailError.value = null; detail.value = null
if (!row.feeSourceId) { detailError.value = { message: '该手工成本没有可审核的费用来源详情' }; detailLoading.value = false; return null }
try { detail.value = await getFinanceFeeSource(row.feeSourceId); return detail.value }
catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试'); return null }
finally { detailLoading.value = false }
}
async function openDetail(row: Record<string, unknown>) {
const target = row as unknown as ArapRow
if (!target.feeSourceId) return
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: target.feeSourceId } })
}
async function openReview(row: Record<string, unknown>) {
const target = row as unknown as ArapRow; selected.value = target
const loaded = await loadDetail(target)
if (!loaded) { detailOpen.value = true; return }
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
}
async function submitReview() {
if (!detail.value) return
if (reviewForm.action === 'REJECT' && !reviewForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
reviewSaving.value = true; reviewError.value = null
try {
detail.value = await reviewFinanceFeeSource(detail.value.feeSourceId, { action: reviewForm.action, reason: reviewForm.reason.trim() || undefined, expectedVersion: detail.value.operationVersion })
reviewOpen.value = false; ElMessage.success(reviewForm.action === 'APPROVE' ? '费用来源已审核通过' : '费用来源已驳回'); await load()
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
finally { reviewSaving.value = false }
}
async function openMutation(row: Record<string, unknown>, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
const target = row as unknown as ArapRow; selected.value = target
if ((mode === 'EDIT' || mode === 'ADJUST') && isFirstlegSource(target)) {
ElMessage.info('请到订单入库费用或柜次费用明细中调整,审核通过后会自动同步到财务')
return
}
const loaded = await loadDetail(target)
if (!loaded) { detailOpen.value = true; return }
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
}
async function submitMutation() {
if (!detail.value) return
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
if ((mutationMode.value === 'EDIT' || mutationMode.value === 'ADJUST') && mutationForm.amount <= 0) { ElMessage.warning('金额必须大于 0'); return }
mutationSaving.value = true; mutationError.value = null
try {
const action = { reason: mutationForm.reason.trim(), expectedVersion: detail.value.operationVersion }
if (mutationMode.value === 'EDIT') detail.value = await editFinanceFeeSource(detail.value.feeSourceId, { ...action, amount: mutationForm.amount })
else if (mutationMode.value === 'ADJUST') detail.value = await adjustFinanceFeeSource(detail.value.feeSourceId, { ...action, amount: mutationForm.amount })
else if (mutationMode.value === 'SUBMIT') detail.value = await submitFinanceFeeSource(detail.value.feeSourceId, action)
else detail.value = await voidFinanceFeeSource(detail.value.feeSourceId, action)
mutationOpen.value = false; ElMessage.success(({ EDIT: '费用已保存为草稿', ADJUST: '费用调整已保存,待重新提交审核', SUBMIT: '费用已提交审核', VOID: '费用已作废' } as const)[mutationMode.value]); await load()
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用操作失败,请重试') }
finally { mutationSaving.value = false }
}
function statusLabel(status: string) { return financeStatusLabel(status) }
function money(value: unknown) { return Number(value || 0).toFixed(2) }
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
function isSelectableReceivable(row: ArapRow) {
return Boolean(row.feeSourceId)
&& row.direction === 'RECEIVABLE'
&& row.reviewStatus === 'APPROVED'
&& row.status === 'AVAILABLE'
&& row.amount > 0
&& row.statementGenerated === false
}
async function continueSelectedGeneration() {
if (!selectedRows.value.length) return ElMessage.warning('请先勾选需要生成对账单的应收费用')
if (selectedRows.value.some((row) => !isSelectableReceivable(row))) return ElMessage.warning('所选费用中存在不可生成对账单的记录,请刷新后重新选择')
const customerIds = [...new Set(selectedRows.value.map((row) => row.customerId))]
if (customerIds.length !== 1) return ElMessage.warning('一次只能选择同一客户的费用生成对账单')
const businessDates = selectedRows.value.map((row) => row.businessDate).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort()
if (businessDates.length !== selectedRows.value.length) return ElMessage.warning('所选费用缺少有效业务日期,暂不能生成对账单')
await router.push({
name: 'finance-customer-statements',
query: {
createMode: 'SELECTED',
customerId: customerIds[0],
feeSourceIds: selectedRows.value.map((row) => row.feeSourceId as string).join(','),
periodStart: businessDates[0],
periodEnd: businessDates.at(-1),
},
})
}
async function exportFeeSources() {
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
const customerId = String(filters.value.customerId || '').trim()
const direction = String(filters.value.direction || '').trim()
const reviewStatus = String(filters.value.reviewStatus || '').trim()
const generated = String(filters.value.statementGenerated || '')
const keyword = String(filters.value.keyword || '').trim()
exportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
parameters: {
...(customerId ? { customerId } : {}),
...(direction ? { direction } : {}),
...(reviewStatus ? { reviewStatus } : {}),
...(generated ? { statementGenerated: generated === 'true' } : {}),
...(range[0] ? { businessDateFrom: range[0] } : {}),
...(range[1] ? { businessDateTo: range[1] } : {}),
...(keyword ? { keyword } : {}),
},
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户应收应付导出任务创建失败')) }
finally { exportBusy.value = false }
}
async function closeDetail() { detailOpen.value = false; selected.value = null; detail.value = null; await router.push({ name: 'finance-customer-arap' }) }
async function restoreFeeSourceDetail(feeSourceId: string) {
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
try {
detail.value = await getFinanceFeeSource(feeSourceId)
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, statementGenerated: detail.value.statementGenerated }
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
finally { detailLoading.value = false }
}
onMounted(async () => {
document.addEventListener('scroll', closeCustomerSelectOnScroll, true)
const feeSourceId = String(route.params.feeSourceId || '')
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
if (canListCustomers.value) await loadCustomers()
await load()
})
onBeforeUnmount(() => {
document.removeEventListener('scroll', closeCustomerSelectOnScroll, true)
})
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
if (!previous) return
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
else { detailOpen.value = false; selected.value = null; detail.value = null; void load() }
})
</script>
<template>
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<OperationErrorToast :details="reviewError || mutationError" />
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain @click="openMutation(row, 'EDIT')"><Pencil :size="15" />编辑</el-button><el-button v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" plain type="primary" @click="openMutation(row, 'SUBMIT')"><Send :size="15" />提交审核</el-button><el-button v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" plain type="success" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canEditSource && !isFirstlegSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain @click="openMutation(row, 'ADJUST')"><Pencil :size="15" />调整</el-button><el-button v-if="canEditSource && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" plain type="danger" @click="openMutation(row, 'VOID')"><Ban :size="15" />作废</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
<section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout" aria-label="费用来源详情">
<template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canEditSource && detail && !isFirstlegSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>费用金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>来源状态</span><strong>{{ sourceStatusDisplay(detail.sourceStatus) }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template>
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
<el-form label-position="top"><el-form-item v-if="mutationMode === 'EDIT' || mutationMode === 'ADJUST'" label="金额(CNY)"><el-input-number v-model="mutationForm.amount" :min="0.01" :precision="2" :controls="false" style="width:100%" /></el-form-item><el-form-item label="操作原因"><el-input v-model="mutationForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请填写操作原因" show-word-limit /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.finance-arap-page {
min-width: 0;
}
/* Customer names can contain an order suffix; keep the selector readable without changing the query value. */
:deep(.finance-customer-select .el-select__selected-item) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.finance-customer-arap-popper) {
width: max(260px, min(360px, calc(100vw - 32px))) !important;
max-width: calc(100vw - 32px);
}
:deep(.finance-customer-arap-popper .el-select-dropdown__item) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.finance-arap-detail {
width: 100%;
min-width: 0;
}
.finance-arap-detail__surface {
width: min(100%, 1920px);
margin: 0 auto;
overflow: hidden;
}
.finance-arap-detail__layout {
width: 100%;
margin: 0 auto;
}
.finance-arap-detail__layout :deep(.detail-page-layout) {
gap: 0;
}
.finance-arap-detail__layout :deep(.detail-page-layout__header) {
min-height: 64px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.finance-arap-detail__layout :deep(.detail-page-layout__summary) {
padding: 10px 16px 0;
}
.finance-arap-detail__layout :deep(.detail-page-layout__content) {
padding: 0 16px 12px;
}
.row-actions {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.row-actions .el-button {
margin-left: 0;
}
.generation-actions,
.detail-heading,
.detail-actions {
display: flex;
align-items: center;
gap: 8px;
}
.detail-heading {
min-width: 0;
align-items: flex-start;
}
.detail-title {
min-width: 0;
}
.detail-heading h2 {
margin: 0;
font-size: 18px;
}
.detail-heading p {
overflow: hidden;
margin: 3px 0 0;
color: var(--cc-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-actions {
margin-left: auto;
}
.muted-action {
color: var(--cc-muted);
}
.arap-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
padding: 0 0 12px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.arap-metric {
min-width: 0;
padding: 0 12px;
}
.arap-metric:first-child {
padding-left: 0;
}
.arap-metric + .arap-metric {
border-left: 1px solid var(--cc-border, #e3e8ef);
}
.arap-metric span {
display: block;
color: var(--cc-muted);
font-size: 12px;
}
.arap-metric strong {
display: inline-block;
margin-top: 6px;
color: var(--cc-text);
font-size: 20px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.arap-metric small {
margin-left: 6px;
color: var(--cc-muted);
font-size: 11px;
}
.arap-metric.is-amount strong {
color: #4f46e5;
}
.arap-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: 16px;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.arap-information,
.arap-settlement,
.operation-log {
padding: 10px 0;
}
.arap-settlement {
padding-left: 16px;
border-left: 1px solid var(--cc-border, #e3e8ef);
}
.operation-log {
min-height: 150px;
}
:deep(.arap-detail .el-descriptions__content) {
min-width: 0;
overflow-wrap: anywhere;
}
:deep(.arap-detail .el-descriptions__label) {
width: 112px;
color: var(--cc-muted);
}
.operation-log :deep(.el-empty) {
padding: 10px 0 2px;
}
.operation-log :deep(.el-empty__image) {
display: none;
}
.operation-log small {
display: block;
margin-top: 4px;
color: var(--cc-muted);
}
@media (min-width: 1920px) {
.finance-arap-detail__layout :deep(.detail-page-layout) {
gap: 8px;
}
.finance-arap-detail__layout :deep(.detail-page-layout__header) {
min-height: 56px;
padding: 10px 14px;
}
.finance-arap-detail__layout :deep(.detail-page-layout__summary) {
padding: 8px 14px 0;
}
.finance-arap-detail__layout :deep(.detail-page-layout__content) {
padding: 0 14px 10px;
}
.finance-arap-detail__layout :deep(.form-section) {
padding: 10px 0;
}
.finance-arap-detail__layout :deep(.form-section__heading) {
margin-bottom: 8px;
}
.arap-metrics {
gap: 8px;
}
.arap-metric {
padding: 0 10px;
}
.arap-detail-grid {
gap: 10px;
}
.arap-settlement {
padding-left: 10px;
}
}
@media (max-width: 1080px) {
.finance-arap-detail__surface {
width: 100%;
}
.arap-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.arap-metric:nth-child(3) {
padding-left: 0;
border-left: 0;
}
.arap-detail-grid {
grid-template-columns: 1fr;
gap: 0;
}
.arap-settlement {
padding-left: 0;
border-left: 0;
border-top: 1px solid var(--cc-border, #e3e8ef);
}
}
@media (max-width: 720px) {
.detail-heading {
align-items: stretch;
flex-direction: column;
}
.detail-actions {
margin-left: 0;
}
.arap-metrics {
grid-template-columns: 1fr;
gap: 12px;
}
.arap-metric,
.arap-metric:first-child,
.arap-metric:nth-child(3) {
padding-left: 0;
}
.arap-metric + .arap-metric {
border-left: 0;
}
:deep(.arap-detail .el-descriptions__body .el-descriptions__table) {
table-layout: fixed;
}
}
@media (max-width: 480px) {
:deep(.arap-detail .el-descriptions__cell) {
display: block;
width: 100%;
}
}
</style>