This commit is contained in:
2026-08-17 19:52:04 +08:00
parent d618683467
commit ee4cd35927
42 changed files with 702 additions and 226 deletions
@@ -136,12 +136,25 @@ describe('FinanceCustomerAccountsView delivery', () => {
await flushPromises()
expect(wrapper.text()).toContain('客户A')
expect(wrapper.text()).not.toContain('C-001')
expect(wrapper.text()).toContain('C-001')
expect(wrapper.text()).toContain('张三')
expect(wrapper.text()).toContain('13800000000')
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
wrapper.unmount()
})
it('shows explicit master-data empty states without exposing the customer id', async () => {
vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ customerId: '10001', customerCode: '', customerName: '客户A', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 })
const wrapper = await mountAccountsView(['platform:finance:customer-account:list'])
expect(wrapper.text()).toContain('客户编号')
expect(wrapper.text()).toContain('联系人')
expect(wrapper.text()).toContain('联系电话')
expect(wrapper.text().match(/未维护/g)?.length).toBeGreaterThanOrEqual(3)
expect(wrapper.text()).not.toContain('客户10001')
wrapper.unmount()
})
it('formats known ledger codes, operator ids and timestamps for display', async () => {
vi.mocked(listFinanceAccountLedger).mockResolvedValue({ records: [{ ledgerId: '2087867025978863617', occurredAt: '2026-08-13T10:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL', documentId: '2', documentNo: 'REC-00000000000000000001', receivableChange: 10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '结算单生效' }], total: 1, pages: 1 })
const pinia = createPinia(); setActivePinia(pinia)
@@ -62,6 +62,9 @@ const queryFields = computed<QueryField[]>(() => [
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
@@ -176,6 +179,7 @@ function formatDateTime(value: unknown) {
return Number.isNaN(date.getTime()) ? '时间数据缺失' : date.toLocaleString('zh-CN', { hour12: false })
}
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
function customerField(value: unknown) { return String(value || '').trim() || '未维护' }
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
async function finishExport(task: Awaited<ReturnType<typeof createPlatformExportTask>>) {
@@ -257,6 +261,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
@@ -270,6 +275,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户汇总"><div class="account-metric is-receivable"><span>待收金额</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div></section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
@@ -11,15 +11,15 @@ import { createPlatformExportTask } from '@/modules/downloads'
import { ApiResponseError } from '@/shared/api/http'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import type { FinanceFeeSourceDetail } from '../types'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import type { FinanceFeeSourceDetail, FinanceFeeSourceSummary } from '../types'
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({
listFinanceFeePool: vi.fn(),
listFinanceFeeSources: vi.fn(),
getFinanceFeeSource: vi.fn(),
reviewFinanceFeeSource: vi.fn(),
updateFinanceFeeSourceSettlementInclusion: vi.fn(),
@@ -42,10 +42,18 @@ function createMemoryStorage(): Storage {
const detail: FinanceFeeSourceDetail = {
poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3,
sourceSystem: 'FIRSTLEG', sourceBusinessId: 'IB-91',
customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '',
businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
}
const feeSummary: FinanceFeeSourceSummary = {
poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IB-91',
sourceId: '81', sourceVersion: 3, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null,
supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '', businessDate: '2026-08-11',
feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-11',
}
async function mountView(permissions: string[], initialPath = '/finance/customer-arap') {
const pinia = createPinia(); setActivePinia(pinia)
@@ -81,7 +89,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
document.body.innerHTML = ''
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }])
vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [feeSummary], total: 1, pages: 1 })
vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail)
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
@@ -106,20 +114,20 @@ describe('FinanceCustomerArapView G2 operations', () => {
it('loads all customers by default and keeps explicit customer filtering', async () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:customer:list'])
expect(listFinanceFeePool).toHaveBeenCalledWith(undefined, undefined)
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, customerId: undefined, direction: undefined }))
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
await vm.load({ customerId: '10001', direction: 'RECEIVABLE', status: '', keyword: '' })
expect(listFinanceFeePool).toHaveBeenLastCalledWith('10001', 'RECEIVABLE')
expect(listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: '10001', direction: 'RECEIVABLE' }))
expect(wrapper.text()).not.toContain('全客户查询暂不可用')
})
it('maps each all-customer fee row with its backend customerId', async () => {
const customerB = { id: '10002', tenantName: '客户B', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource, customerB], page: 1, size: 200, total: 2, pages: 1 })
vi.mocked(listFinanceFeePool).mockResolvedValue([
{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' },
{ poolItemId: 'PAYABLE:43', feeSourceId: null, customerId: '10002', direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: '43', sourceVersion: 1, orderId: '11', inboundId: null, containerId: null, feeItemName: '拖车费', amount: 200, currency: 'CNY', status: 'POSTED', createdAt: '2026-08-10' },
])
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [
feeSummary,
{ ...feeSummary, poolItemId: '43', feeSourceId: '43', sourceBusinessId: 'CONT-43', customerId: '10002', customerName: '客户B', direction: 'PAYABLE', sourceType: 'CONTAINER_FEE', sourceId: '82', orderId: '11', inboundId: null, inboundCode: '', containerId: '31', containerNo: 'CONT-43', supplierId: '71', supplierName: '供应商A', feeItemName: '拖车费', amount: 200 },
], total: 2, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list'])
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
await vm.load({ customerId: '', direction: '', status: '', keyword: '' })
@@ -211,15 +219,18 @@ describe('FinanceCustomerArapView G2 operations', () => {
})
it('hides technical source identifiers and versions from list rows', async () => {
vi.mocked(listFinanceFeePool).mockResolvedValue([
{ poolItemId: 'PAYABLE:358', feeSourceId: '358', customerId: '10001', direction: 'PAYABLE', sourceType: 'CONTAINER_FEE', sourceId: '2088285842638942210', sourceVersion: 1, orderId: null, inboundId: null, containerId: '2088285834627821569', feeItemName: 'ADMIN-API-20260814152244615-整柜成本', amount: 300, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-14T23:25:19.684' },
])
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [{
...feeSummary, poolItemId: '358', feeSourceId: '358', sourceType: 'CONTAINER_FEE', sourceBusinessId: 'CONT-20260814', sourceId: '2088285842638942210', sourceVersion: 1,
direction: 'PAYABLE', orderId: null, inboundId: null, inboundCode: '', containerId: '2088285834627821569', containerNo: 'CONT-20260814', supplierId: '71', supplierName: '供应商A',
feeItemName: 'ADMIN-API-20260814152244615-整柜成本', amount: 300, businessDate: '2026-08-14', createdAt: '2026-08-14',
}], total: 1, pages: 1 })
const wrapper = await mountView(['platform:finance:customer-arap:list'])
await (wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }).load({ customerId: '', direction: '', status: '', keyword: '' })
const text = wrapper.text()
expect(text).toContain('应付费用 · 整柜成本')
expect(text).toContain('柜次费用 · 关联柜次')
expect(text).toContain('2026/8/14 23:25:19')
expect(text).toContain('整柜成本')
expect(text).toContain('柜次 CONT-20260814')
expect(text).toContain('供应商A')
expect(text).toContain('2026/8/14')
expect(text).not.toContain('PAYABLE:358')
expect(text).not.toContain('2088285842638942210')
expect(text).not.toContain('/v1')
@@ -17,7 +17,7 @@ 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 { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
@@ -28,11 +28,15 @@ interface ArapRow {
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
@@ -49,7 +53,7 @@ const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ customerId: '', direction: '', reviewStatus: '', settlementIncluded: '', statementGenerated: '', businessRange: [], keyword: '' })
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', settlementIncluded: '', statementGenerated: '', businessRange: [], keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -73,6 +77,7 @@ const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: st
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'))
@@ -87,16 +92,18 @@ const activeCustomers = computed(() => customers.value.filter((item) => item.sta
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', 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: 'settlementIncluded', type: 'select', label: '参与结算', width: 140, placeholder: '全部', options: [{ label: '参与', value: 'true' }, { label: '不参与', value: 'false' }] },
{ 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: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
{ 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: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
{ 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: 'settlementIncluded', label: '参与结算', width: 110, slotName: 'included' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
]
function customerName(customerId: string, snapshot?: string) {
@@ -129,23 +136,35 @@ function sourceSummary(sourceType: unknown, orderId: unknown, inboundId: unknown
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 filteredRows = computed(() => {
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const reviewStatus = String(filters.value.reviewStatus || '')
const included = String(filters.value.settlementIncluded || ''); const generated = String(filters.value.statementGenerated || ''); const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!reviewStatus || row.reviewStatus === reviewStatus) && (!included || String(row.settlementIncluded) === included) && (!generated || String(row.statementGenerated) === generated) && (!range[0] || row.businessDate >= range[0]) && (!range[1] || row.businessDate <= range[1]) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
})
const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const rows = computed(() => allRows.value)
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + row.amount, 0))
async function loadCustomers() {
@@ -156,16 +175,26 @@ async function loadCustomers() {
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model); page.value = 1; allRows.value = []; loadError.value = null
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 items = await listFinanceFeePool(customerId || undefined, direction || undefined)
allRows.value = items.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), sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
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 'PENDING' | 'APPROVED' | 'REJECTED' | undefined,
settlementIncluded: String(filters.value.settlementIncluded || '') ? String(filters.value.settlementIncluded) === 'true' : 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, settlementIncluded: item.settlementIncluded, 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 }
@@ -263,7 +292,7 @@ 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), sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', 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, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
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, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
finally { detailLoading.value = false }
}
@@ -285,18 +314,18 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<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="load" /></template>
<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="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" 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><template v-if="canCreateStatement"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></template></div></template>
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :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 #documentNo="{ row }">{{ feeDocumentLabel(row.direction, row.feeName) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #sourceId="{ row }">{{ row.sourceId }}</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 #included="{ row }">{{ row.settlementIncluded == null ? '待加载' : row.settlementIncluded ? '参与' : '不参与' }}</template>
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
<template #businessDate="{ row }">{{ formatDateTime(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</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">
@@ -305,7 +334,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<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="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :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>{{ detail.settlementIncluded ? '是' : '否' }}</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 || '—' }}</el-descriptions-item><el-descriptions-item label="业务日期" :span="2">{{ formatDateTime(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="费用状态">{{ statusLabel(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-item label="结算调整原因">{{ detail.settlementReason || '-' }}</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>
<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-item label="结算调整原因">{{ detail.settlementReason || '未填写' }}</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>
@@ -10,7 +10,7 @@ import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
@@ -19,7 +19,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
getFinanceFeeSource: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
@@ -30,14 +30,17 @@ function memoryStorage(): Storage {
const statement: FinanceStatementReadModel = {
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '',
periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 1, sourceCount: 1,
sourceAmount: 320, adjustmentAmount: -20, settled: false, lastReceiptAt: '2026-08-20 10:00:00',
submittedAt: '2026-08-02 09:00:00', reviewedAt: '2026-08-03 09:00:00', issuedAt: '2026-08-04 09:00:00', remark: '八月账单',
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
}
const detail: FinanceReconciliationDetail = {
...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单',
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', orderId: '11', inboundId: '21', containerId: '31', feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceNo: 'CONT-31', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15', quantity: 10, unitPrice: 30, feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
}
@@ -71,13 +74,6 @@ describe('FinanceCustomerStatementsView', () => {
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
vi.mocked(getFinanceFeeSource).mockResolvedValue({
poolItemId: '81', feeSourceId: '81', customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '',
direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceId: 'FEE-81', sourceFeeId: 'FEE-81', sourceVersion: 2,
orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15',
feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true,
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: true, operationVersion: 2, createdAt: '2026-08-15',
})
})
it('opens statement details on a dedicated route instead of below the list', async () => {
@@ -101,7 +97,7 @@ describe('FinanceCustomerStatementsView', () => {
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(11)
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(17)
expect(document.querySelector('.statement-detail-head')?.textContent).toContain('REC-501')
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
@@ -216,12 +212,12 @@ describe('FinanceCustomerStatementsView', () => {
expect(listPlatformTenants).not.toHaveBeenCalled()
})
it('uses statement list permission for fee-source details without exposing technical trace fields', async () => {
it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => {
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
await flushPromises()
expect(getFinanceFeeSource).toHaveBeenCalledWith('81')
expect(document.body.textContent).toContain('CONT-31')
expect(document.body.textContent).toContain('订单 11')
expect(document.body.textContent).not.toContain('hash-81')
})
@@ -17,7 +17,7 @@ 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 { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
@@ -30,7 +30,7 @@ interface AdjustmentRow {
reason: string
}
interface StatementRow extends FinanceStatementReadModel {
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
periodStart: string
periodEnd: string
creditDays: number | null
@@ -39,15 +39,6 @@ interface StatementRow extends FinanceStatementReadModel {
remark: string
}
interface StatementLineSource {
containerNo: string
inboundCode: string
businessDate: string
quantity: number | null
unitPrice: number | null
sourceType: string
}
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
@@ -66,7 +57,6 @@ const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const activeDetailTab = ref<'lines' | 'payments'>('lines')
const detailCache = new Map<string, FinanceReconciliationDetail>()
const lineSourceCache = new Map<string, StatementLineSource>()
const createOpen = ref(false)
const createBusy = ref(false)
const exportBusy = ref(false)
@@ -118,7 +108,11 @@ const columns: DataTableColumn[] = [
{ key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 },
{ key: 'lineCount', label: '费用明细数', width: 110 },
{ key: 'sourceCount', label: '来源数量', width: 100 },
{ key: 'currency', label: '货币', width: 86 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
@@ -127,10 +121,17 @@ const columns: DataTableColumn[] = [
{ key: 'remark', label: '备注', minWidth: 180 },
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
{ key: 'submittedAt', label: '提交时间', minWidth: 170, slotName: 'submittedAt' },
{ key: 'reviewedAt', label: '审核时间', minWidth: 170, slotName: 'reviewedAt' },
{ key: 'issuedAt', label: '出账时间', minWidth: 170, slotName: 'issuedAt' },
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
]
const detailLineColumns: DataTableColumn[] = [
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
{ key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'sourceNo', label: '业务单号', minWidth: 160 },
{ key: 'orderId', label: '订单', minWidth: 150, slotName: 'orderId' },
{ key: 'inboundCode', label: '入仓号', minWidth: 150, slotName: 'inboundCode' },
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
@@ -150,6 +151,7 @@ const paymentColumns: DataTableColumn[] = [
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
{ key: 'createdByName', label: '登记人', minWidth: 140, slotName: 'createdByName' },
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
{ key: 'remark', label: '备注', minWidth: 200 },
@@ -180,12 +182,12 @@ function formatDateTime(value?: string) {
}
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || ''
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || row.lastReceiptAt || ''
return {
...row,
periodStart: current?.periodStart || '', periodEnd: current?.periodEnd || row.billDate,
creditDays: current ? current.creditDays : null, settled: current ? current.settled : null,
lastPaymentAt: latestPayment, remark: current?.remark || '',
periodStart: current?.periodStart || row.periodStart || '', periodEnd: current?.periodEnd || row.periodEnd || row.billDate,
creditDays: current ? current.creditDays : row.creditDays ?? null, settled: current ? current.settled : row.settled ?? null,
lastPaymentAt: latestPayment, remark: current?.remark || row.remark || '',
createdBy: current?.createdBy || row.createdBy, createdByName: current?.createdByName || row.createdByName, createdAt: current?.createdAt || row.createdAt,
updatedBy: current?.updatedBy || row.updatedBy, updatedByName: current?.updatedByName || row.updatedByName, updatedAt: current?.updatedAt || row.updatedAt,
}
@@ -207,29 +209,13 @@ const filteredRows = computed(() => rows.value.filter((row) => {
return (!status || String(row.status).toLowerCase() === status)
&& (!billNo || row.billNo.toLowerCase().includes(billNo))
&& (!settled || String(row.settled) === settled)
&& (!containerNo || Boolean(current?.lines.some((line) => lineSourceCache.get(line.sourceFeeId)?.containerNo.toLowerCase().includes(containerNo))))
&& (!containerNo || Boolean(current?.lines.some((line) => line.containerNo?.toLowerCase().includes(containerNo))))
&& inDateRange(row.periodStart, 'periodStartRange')
&& inDateRange(row.periodEnd, 'periodEndRange')
&& inDateRange(row.lastPaymentAt, 'lastPaymentRange')
&& inDateRange(row.createdAt || '', 'createdRange')
}))
async function hydrateLineSources(current: FinanceReconciliationDetail) {
if (!canViewFeeSource.value) return
await Promise.all(current.lines.map(async (line) => {
if (!line.sourceFeeId || lineSourceCache.has(line.sourceFeeId)) return
try {
const source = await getFinanceFeeSource(line.sourceFeeId)
lineSourceCache.set(line.sourceFeeId, {
containerNo: source.containerNo, inboundCode: source.inboundCode, businessDate: source.businessDate,
quantity: source.quantity, unitPrice: source.unitPrice, sourceType: source.sourceType,
})
} catch {
lineSourceCache.set(line.sourceFeeId, { containerNo: '', inboundCode: '', businessDate: '', quantity: null, unitPrice: null, sourceType: '' })
}
}))
}
async function loadCustomers() {
if (!canListCustomers.value) return
customerError.value = null
@@ -249,7 +235,6 @@ async function load(model?: QueryModel) {
selectedId.value = ''
detail.value = null
detailCache.clear()
lineSourceCache.clear()
error.value = null
loading.value = true
try {
@@ -260,7 +245,6 @@ async function load(model?: QueryModel) {
try {
const current = await getReconciliationStatement(row.id)
detailCache.set(row.id, current)
await hydrateLineSources(current)
return detailToRow(row, current)
} catch (cause) {
detailErrors.push(getApiErrorDetails(cause, `对账单 ${row.billNo || row.id} 的筛选字段加载失败`))
@@ -283,7 +267,6 @@ async function fetchDetail(statementId: string, force = false) {
if (!force && detailCache.has(statementId)) return detailCache.get(statementId)!
const current = await getReconciliationStatement(statementId)
detailCache.set(statementId, current)
await hydrateLineSources(current)
return current
}
@@ -321,6 +304,14 @@ function revisionActionLabel(action: string) {
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[action] || action || '—'
}
function revisionTime(actions: string[]) {
return detail.value?.revisions.filter((item) => actions.includes(item.action)).map((item) => item.occurredAt).sort().at(-1) || ''
}
function detailSourceAmount() {
return detail.value?.lines.reduce((total, line) => total + (line.sourceAmount ?? line.amount), 0) ?? 0
}
async function exportStatements() {
const customerId = String(filters.value.customerId || '').trim()
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
@@ -461,10 +452,16 @@ watch(detailRouteId, (statementId) => {
<div class="statement-summary-item"><span>已收金额</span><strong>{{ detail.paidAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>未收金额</span><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>账期</span><strong>{{ detail.creditDays }} 天</strong></div>
<div class="statement-summary-item"><span>费用明细 / 来源</span><strong>{{ detail.lines.length }} / {{ new Set(detail.lines.map((line) => line.sourceFeeId)).size }}</strong></div>
<div class="statement-summary-item"><span>调整前金额</span><strong>{{ detailSourceAmount().toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>调整金额</span><strong>{{ (detail.totalAmount - detailSourceAmount()).toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>货币</span><strong>{{ detail.currency }}</strong></div>
<div class="statement-summary-item"><span>是否结清</span><strong>{{ detail.settled ? '是' : '否' }}</strong></div>
<div class="statement-summary-item"><span>创建人</span><strong>{{ operatorDisplayName(detail.createdByName) }}</strong></div>
<div class="statement-summary-item"><span>创建时间</span><strong>{{ formatDateTime(detail.createdAt) }}</strong></div>
<div class="statement-summary-item"><span>提交时间</span><strong>{{ formatDateTime(revisionTime(['SUBMIT'])) }}</strong></div>
<div class="statement-summary-item"><span>审核时间</span><strong>{{ formatDateTime(revisionTime(['APPROVE', 'REJECT'])) }}</strong></div>
<div class="statement-summary-item"><span>出账时间</span><strong>{{ formatDateTime(revisionTime(['ISSUE'])) }}</strong></div>
<div class="statement-summary-item"><span>更新人</span><strong>{{ operatorDisplayName(detail.updatedByName) }}</strong></div>
<div class="statement-summary-item"><span>更新时间</span><strong>{{ formatDateTime(detail.updatedAt) }}</strong></div>
<div class="statement-summary-item statement-summary-item--remark"><span>备注</span><strong>{{ detail.remark || '—' }}</strong></div>
@@ -478,11 +475,14 @@ watch(detailRouteId, (statementId) => {
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
<div class="detail-table-scroll">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
<template #orderId="{ row }">{{ row.orderId ? `订单 ${row.orderId}` : '—' }}</template>
<template #inboundCode="{ row }">{{ row.inboundCode || '—' }}</template>
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
<template #businessDate="{ row }">{{ row.businessDate || '—' }}</template>
<template #quantity="{ row }">{{ row.quantity ?? '—' }}</template>
<template #unitPrice="{ row }">{{ row.unitPrice ?? '—' }}</template>
<template #billNo>{{ detail.billNo }}</template>
<template #periodStart>{{ detail.periodStart || '—' }}</template>
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
@@ -498,6 +498,7 @@ watch(detailRouteId, (statementId) => {
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
<template #voucherFileIds="{ row }">{{ row.voucherFileIds.length ? `${row.voucherFileIds.length} 个附件` : (row.referenceNo || '—') }}</template>
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
</DataTable>
@@ -535,6 +536,9 @@ watch(detailRouteId, (statementId) => {
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
<template #submittedAt="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
<template #issuedAt="{ row }">{{ formatDateTime(row.issuedAt) }}</template>
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
@@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
const pageSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
afterEach(() => vi.restoreAllMocks())
describe('formal settlement field contract', () => {
it('maps list business fields without exposing internal versions', async () => {
const get = vi.spyOn(http, 'get')
.mockResolvedValueOnce({ code: '200', data: [{
settlementId: 701, settlementNo: 'AR-701', direction: 'RECEIVABLE', customerId: 10001,
customerName: '客户A', reconciliationStatementId: 501, reconciliationStatementNo: 'REC-501',
businessPeriod: '2026-08', feeItemSummary: '海运费、拖车费', currency: 'CNY', totalAmount: 300,
paidAmount: 100, outstandingAmount: 200, generatedDate: '2026-08-17', dueDate: '2026-09-16',
paymentStatus: 'PARTIALLY_PAID', status: 'PARTIALLY_PAID', createdBy: 7, createdByName: '财务员',
createdAt: '2026-08-17T09:00:00', updatedAt: '2026-08-17T10:00:00', lastPaymentAt: '2026-08-17T10:00:00', version: 1,
}] } as never)
.mockResolvedValueOnce({ code: '200', data: [{
settlementId: 801, settlementNo: 'AP-801', direction: 'PAYABLE', customerId: 10001,
customerName: '客户A', supplierId: 81, supplierName: '承运供应商', businessPeriod: '2026-08',
feeItemSummary: '拖车费', currency: 'CNY', totalAmount: 300, paidAmount: 0, outstandingAmount: 300,
generatedDate: '2026-08-17', dueDate: '2026-09-16', paymentStatus: 'UNPAID', status: 'ISSUED',
createdBy: 7, createdByName: '财务员', createdAt: '2026-08-17T09:00:00', updatedAt: '2026-08-17T09:00:00', version: 0,
}] } as never)
const [receivable] = await listFinanceReceivableSettlements('10001')
const [payable] = await listFinancePayableSettlements('10001')
expect(receivable).toMatchObject({ reconciliationStatementNo: 'REC-501', feeItemSummary: '海运费、拖车费', createdByName: '财务员', lastPaymentAt: '2026-08-17T10:00:00' })
expect(payable).toMatchObject({ customerName: '客户A', supplierName: '承运供应商', feeItemSummary: '拖车费', createdAt: '2026-08-17T09:00:00' })
expect(get).toHaveBeenNthCalledWith(1, '/platform/finance/v1/receivable-settlements', { params: { customerId: '10001' } })
})
it('maps immutable source identity and payment evidence in detail', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
settlementId: 801, settlementNo: 'AP-801', direction: 'PAYABLE', customerId: 10001, customerName: '客户A',
supplierId: 81, supplierName: '承运供应商', feeItemSummary: '拖车费', totalAmount: 300, paidAmount: 100,
outstandingAmount: 200, currency: 'CNY', generatedDate: '2026-08-17', dueDate: '2026-09-16',
paymentStatus: 'PARTIALLY_PAID', status: 'PARTIALLY_PAID', createdBy: 7, createdByName: '财务员',
createdAt: '2026-08-17T09:00:00', updatedAt: '2026-08-17T10:00:00', lastPaymentAt: '2026-08-17T10:00:00', version: 1,
lines: [{ feeSourceId: 21, sourceType: 'CONTAINER_FEE', sourceFeeId: 9001, sourceVersion: 1, direction: 'PAYABLE', customerId: 10001, supplierId: 81, supplierName: '承运供应商', orderId: 11, orderNo: 'FL-11', inboundId: 12, inboundCode: 'IN-12', containerId: 13, containerNo: 'CONT-13', businessDate: '2026-08-15', feeItemName: '拖车费', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: true }],
transactions: [{ transactionId: 31, transactionType: 'PAYMENT', transactionDate: '2026-08-17', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'BANK-31', voucherFileIds: [41], createdBy: 7, createdByName: '财务员', createdAt: '2026-08-17T10:00:00' }],
revisions: [{ revisionNo: 1, action: 'CREATE', beforeAmount: 0, afterAmount: 300, operatorId: 7, operatorName: '财务员', occurredAt: '2026-08-17T09:00:00' }],
} } as never)
const detail = await getFinanceSettlement('801')
expect(detail.lines[0]).toMatchObject({ orderId: '11', orderNo: 'FL-11', inboundCode: 'IN-12', containerNo: 'CONT-13', feeItemName: '拖车费', amount: 300 })
expect(detail.transactions[0]).toMatchObject({ method: 'BANK_TRANSFER', referenceNo: 'BANK-31', voucherFileIds: ['41'], createdByName: '财务员' })
})
it('renders the confirmed list and detail fields as business labels', () => {
for (const label of ['关联对账单', '结算周期', '创建时间', '最后收款时间', '最后付款时间', '订单号', '银行流水号/凭证号', '修订记录']) {
expect(pageSource).toContain(label)
}
expect(pageSource).toContain("BANK_TRANSFER: '银行转账'")
expect(pageSource).not.toContain('prop="sourceVersion" label="来源版本"')
expect(pageSource).not.toContain('{{ detail.version }}')
})
})
@@ -127,30 +127,53 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 },
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
{ key: 'billDate', label: '生成日期', width: 130, slotName: 'billDate' },
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
{ key: 'status', label: '收款状态', width: 120, slotName: 'status' },
{ key: 'paymentStatus', label: '收款状态', width: 120, slotName: 'paymentStatus' },
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
{ key: 'lastPaymentAt', label: '最后收款时间', width: 170, slotName: 'lastPaymentAt' },
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
]
: [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
{ key: 'supplierName', label: '供应商', minWidth: 180, slotName: 'supplier' },
{ key: 'feeItemName', label: '费用类型', minWidth: 150, slotName: 'feeType' },
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
{ key: 'currency', label: '币种', width: 80 },
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
{ key: 'createdAt', label: '生成日期', width: 130, slotName: 'createdAt' },
{ key: 'status', label: '付款状态', width: 120, slotName: 'status' },
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
{ key: 'paymentStatus', label: '付款状态', width: 120, slotName: 'paymentStatus' },
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
{ key: 'lastPaymentAt', label: '最后付款时间', width: 170, slotName: 'lastPaymentAt' },
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
])
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
function displayDate(value?: string) { return value || '—' }
function displayDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function settlementPaymentStatusLabel(status?: string) {
if (status === 'UNPAID') return props.kind === 'receivable' ? '待收款' : '待付款'
if (status === 'PARTIALLY_PAID') return props.kind === 'receivable' ? '部分收款' : '部分付款'
if (status === 'PAID') return '已结清'
return '—'
}
function paymentMethodLabel(method?: string) {
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(method || '')] || '—'
}
function operatorDisplayName(value?: string) {
const candidate = String(value || '').trim()
return candidate && !/^(?:operator-)?\d+$/i.test(candidate) ? candidate : '名称数据缺失'
@@ -488,12 +511,18 @@ onMounted(() => {
<el-descriptions :column="4" class="settlement-detail-info">
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
<el-descriptions-item v-if="kind === 'payable'" label="客户">{{ customerName(detail.customerId, detail.customerName) }}</el-descriptions-item>
<el-descriptions-item label="币种">CNY</el-descriptions-item>
<el-descriptions-item label="收付款状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="关联账单">{{ detail.reconciliationStatementId || '—' }}</el-descriptions-item>
<el-descriptions-item label="业务期间">{{ detail.businessPeriod || '—' }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '收款状态' : '付款状态'"><el-tag :type="financeStatusTone(detail.paymentStatus)">{{ settlementPaymentStatusLabel(detail.paymentStatus) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="结算状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="关联对账单">{{ detail.reconciliationStatementNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="结算周期">{{ detail.businessPeriod || '—' }}</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ detail.feeItemSummary || '—' }}</el-descriptions-item>
<el-descriptions-item label="生成日期">{{ displayDate(detail.generatedDate) }}</el-descriptions-item>
<el-descriptions-item label="到期日">{{ displayDate(detail.dueDate) }}</el-descriptions-item>
<el-descriptions-item label="制单人">{{ operatorDisplayName(detail.createdByName) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ displayDateTime(detail.createdAt) }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
</el-descriptions>
</FormSection>
<FormSection title="费用明细">
@@ -501,6 +530,7 @@ onMounted(() => {
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="160"><template #default="scope">{{ scope.row.orderNo || '—' }}</template></el-table-column>
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
@@ -513,11 +543,11 @@ onMounted(() => {
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
<el-table-column prop="referenceNo" label="支付流水号" min-width="160"><template #default="scope">{{ scope.row.referenceNo || '—' }}</template></el-table-column>
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130"><template #default="scope">{{ paymentMethodLabel(scope.row.method) }}</template></el-table-column>
<el-table-column prop="referenceNo" label="银行流水号/凭证号" min-width="180"><template #default="scope">{{ scope.row.referenceNo || '—' }}</template></el-table-column>
<el-table-column label="登记人" min-width="140"><template #default="scope">{{ operatorDisplayName(scope.row.createdByName) }}</template></el-table-column>
<el-table-column label="登记时间" min-width="170"><template #default="scope">{{ displayDateTime(scope.row.createdAt) }}</template></el-table-column>
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
<el-table-column label="凭证" width="130"><template #default="scope">{{ scope.row.voucherFileIds.length ? `已上传 ${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
</el-table>
</div>
@@ -566,14 +596,19 @@ onMounted(() => {
</template>
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
<template #supplier="{ row }">{{ row.supplierName || '—' }}</template>
<template #feeType="{ row }">{{ row.feeItemName || '详见费用明细' }}</template>
<template #feeItemSummary="{ row }">{{ row.feeItemSummary || '—' }}</template>
<template #money="{ row }">{{ money(rowTotal(row)) }}</template>
<template #paidMoney="{ row }">{{ money(rowPaid(row)) }}</template>
<template #outstandingMoney="{ row }">{{ money(rowOutstanding(row)) }}</template>
<template #billDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).billDate) }}</template>
<template #reconciliationStatementNo="{ row }">{{ row.reconciliationStatementNo || '—' }}</template>
<template #businessPeriod="{ row }">{{ row.businessPeriod || '—' }}</template>
<template #generatedDate="{ row }">{{ displayDate(row.generatedDate) }}</template>
<template #dueDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).dueDate) }}</template>
<template #createdAt="{ row }">{{ displayDate((row as FinancePayableSettlement).createdAt) }}</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #paymentStatus="{ row }"><el-tag :type="financeStatusTone(row.paymentStatus)">{{ settlementPaymentStatusLabel(row.paymentStatus) }}</el-tag></template>
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
<template #createdAt="{ row }">{{ displayDateTime(row.createdAt) }}</template>
<template #lastPaymentAt="{ row }">{{ displayDateTime(row.lastPaymentAt) }}</template>
<template #settlementStatus="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable>
</section>