Files
admin-web/src/modules/finance/pages/FinanceCustomerArapView.vue
T
2026-09-30 14:01:58 +08:00

608 lines
39 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/adminDensity.css'
import '../financeDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Download, 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 StatusBadge from '@/shared/components/StatusBadge.vue'
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 RowActions, { type RowActionItem } from '@/shared/components/RowActions.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
sourceSystem: string
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 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 operationLogRows = computed(() => (detail.value?.operationLogs || []).map((log, index) => ({ ...log, rowKey: index })))
const operationLogColumns: DataTableColumn[] = [
{ key: 'occurredAt', label: '时间', width: 170, slotName: 'occurredAt' },
{ key: 'action', label: '操作类型', minWidth: 120, slotName: 'action' },
{ key: 'operatorName', label: '操作人', minWidth: 120, slotName: 'operatorName' },
{ key: 'result', label: '备注 / 结果', minWidth: 280, slotName: 'result' },
]
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 customerSelectVisible = 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'))
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,
filterable: true,
clearable: true,
loading: customersLoading.value,
onVisibleChange: onCustomerSelectVisible,
},
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: 'SETTLED' }, { 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': '关键词' } },
])
function onCustomerSelectVisible(visible: boolean) { customerSelectVisible.value = visible }
function closeCustomerSelectOnScroll() {
if (!customerSelectVisible.value) return
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()
}
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
}
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: '仓储费用',
OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用',
OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用',
OVERSEAS_WAREHOUSE_FEE: '海外仓服务费', OVERSEAS_CONTAINER_FEE: '海外柜子费用',
OVERSEAS_ORDER_FEE: '尾程订单费用', OVERSEAS_DELIVERY_FEE: '尾程派送费用',
OVERSEAS_RELABEL_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: '已生成对账单', SETTLED: '资金账户已结清', 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, sourceSystem: item.sourceSystem, 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: ArapRow) {
if (!row.feeSourceId) return
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: row.feeSourceId } })
}
async function openReview(row: ArapRow) {
selected.value = row
const loaded = await loadDetail(row)
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: ArapRow, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
selected.value = row
if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(row)) {
ElMessage.info(String(row.sourceSystem || '').toUpperCase() === 'LASTLEG'
? '请到对应海外业务费用中调整或作废,审核后会通过跨区事件同步到财务'
: '请到订单入库费用或柜次费用明细中调整或作废,审核后会自动同步到财务')
return
}
const loaded = await loadDetail(row)
if (!loaded) { detailOpen.value = true; return }
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
}
function sourceActions(row: ArapRow): RowActionItem[] {
const available = row.status === 'AVAILABLE'
const draft = ['DRAFT', 'REJECTED'].includes(row.reviewStatus)
const editable = canEditSource.value && !isUpstreamSource(row)
return [
{ key: 'detail', label: '详情', priority: 300, visible: canViewSource.value },
{ key: 'edit', label: '编辑', priority: 250, visible: editable && draft && available },
{ key: 'submit', label: '提交审核', priority: 200, tone: 'warning', visible: canEditSource.value && draft && available },
{ key: 'review', label: '审核', priority: 200, tone: 'warning', visible: canReviewSource.value && row.reviewStatus === 'PENDING' && available },
{ key: 'adjust', label: '调整', priority: 150, visible: editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: editable && available && !row.statementGenerated },
]
}
function handleSourceAction(command: string, row: ArapRow) {
if (command === 'detail') void openDetail(row)
else if (command === 'review') void openReview(row)
else if (command === 'edit') void openMutation(row, 'EDIT')
else if (command === 'submit') void openMutation(row, 'SUBMIT')
else if (command === 'adjust') void openMutation(row, 'ADJUST')
else if (command === 'void') void openMutation(row, 'VOID')
}
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, dense: true })
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, sourceSystem: detail.value.sourceSystem, 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 admin-density" spacing="normal">
<OperationErrorToast :details="reviewError || mutationError" />
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customersError && !loadError" 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-start><h1>客户应收应付</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
<template #notices><template v-if="stateError"><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
<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 }"><RowActions text-actions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)" :more-label="`更多费用来源操作 · ${(row as ArapRow).documentNo || '当前费用'}`" @action="handleSourceAction($event, row as ArapRow)" /><span v-else class="muted-action">手工成本</span></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 admin-density" spacing="normal">
<section class="surface-panel finance-arap-detail__surface">
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></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 density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><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-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><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 density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-form class="density-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 class="admin-density-dialog" v-model="mutationOpen" :title="mutationTitle" :confirm-text="mutationMode === 'VOID' ? '确认作废' : '确认'" :confirm-loading="mutationSaving" @confirm="submitMutation">
<el-form class="density-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%" placeholder="请输入调整金额" /></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>
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
.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;
}
.generation-actions {
display: flex;
align-items: center;
gap: var(--admin-space-3);
}
.muted-action {
color: var(--cc-muted);
}
.arap-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
gap: var(--admin-space-6);
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.arap-information,
.arap-settlement,
.operation-log {
padding: var(--admin-space-4) 0;
}
.arap-settlement {
padding-left: var(--admin-space-6);
border-left: 1px solid var(--cc-border, #e3e8ef);
}
: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: var(--admin-space-4) 0 2px;
}
.operation-log :deep(.el-empty__image) {
display: none;
}
.operation-log small {
display: block;
margin-top: var(--admin-space-1);
color: var(--cc-muted);
}
@media (min-width: 1920px) {
.arap-detail-grid {
gap: var(--admin-space-4);
}
.arap-settlement {
padding-left: var(--admin-space-4);
}
}
@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>