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
@@ -27,6 +27,8 @@ describe('CustomerAuditLogsPanel', () => {
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20 })
expect(wrapper.text()).toContain('编辑客户')
expect(wrapper.text()).toContain('127.0.0.1')
expect(wrapper.text()).toContain('编辑客户档案')
expect(wrapper.text()).not.toContain('PLATFORM_TENANT_UPDATE')
})
it('shows the server total instead of the current page row count', async () => {
@@ -9,6 +9,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getPlatformAuditLog, listPlatformAuditLogs, type PlatformAuditLogDetail, type PlatformAuditLogListItem, type PlatformAuditLogQuery } from '../api/platformAudit'
import { auditEventLabel } from '../presentation'
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
@@ -37,7 +38,7 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'description', label: '操作内容', minWidth: 190, slotName: 'description' },
{ key: 'clientIp', label: 'IP 地址', width: 140, slotName: 'clientIp' },
{ key: 'eventType', prop: 'eventType', label: '事件类型', minWidth: 220 },
{ key: 'eventType', label: '事件类型', minWidth: 220, slotName: 'eventType' },
{ key: 'username', label: '操作人', width: 140, slotName: 'username' },
{ key: 'serviceName', label: '来源服务', width: 150, slotName: 'serviceName' },
{ key: 'success', label: '结果', width: 90, slotName: 'success' },
@@ -134,6 +135,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)"><Eye :size="15" />详情</el-button></template>
<template #description="{ row }">{{ display((row as PlatformAuditLogListItem).description) }}</template>
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
<template #eventType="{ row }">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</template>
<template #username="{ row }">{{ display((row as PlatformAuditLogListItem).username) }}</template>
<template #serviceName="{ row }">{{ display((row as PlatformAuditLogListItem).serviceName) }}</template>
<template #success="{ row }"><el-tag :type="(row as PlatformAuditLogListItem).success ? 'success' : 'danger'">{{ (row as PlatformAuditLogListItem).success ? '成功' : '失败' }}</el-tag></template>
@@ -148,7 +150,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
<el-descriptions :column="2" border>
<el-descriptions-item label="操作内容">{{ display(detail.description) }}</el-descriptions-item><el-descriptions-item label="结果">{{ detail.success ? '成功' : '失败' }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ display(detail.username) }}</el-descriptions-item><el-descriptions-item label="发生时间">{{ formatDate(detail.occurredAt) }}</el-descriptions-item>
<el-descriptions-item label="事件类型">{{ detail.eventType }}</el-descriptions-item><el-descriptions-item label="资源">{{ detail.resourceType }} / {{ display(detail.resourceId) }}</el-descriptions-item>
<el-descriptions-item label="事件类型">{{ auditEventLabel(detail.eventType) }}</el-descriptions-item><el-descriptions-item label="资源">{{ detail.resourceType }} / {{ display(detail.resourceId) }}</el-descriptions-item>
<el-descriptions-item label="请求">{{ display(detail.httpMethod) }} {{ display(detail.requestUri) }}</el-descriptions-item><el-descriptions-item label="耗时">{{ detail.durationMs == null ? '—' : `${detail.durationMs} ms` }}</el-descriptions-item>
<el-descriptions-item label="来源服务">{{ display(detail.serviceName) }}</el-descriptions-item><el-descriptions-item label="请求标识">{{ display(detail.requestId) }}</el-descriptions-item>
<el-descriptions-item v-if="!detail.success" label="失败原因" :span="2">{{ display(detail.errorMessage || detail.errorCode) }}</el-descriptions-item>
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { auditEventLabel } from './presentation'
describe('audit event presentation', () => {
it('uses customer-facing labels for known event codes', () => {
expect(auditEventLabel('PLATFORM_TENANT_UPDATE')).toBe('编辑客户档案')
expect(auditEventLabel('TENANT_REVIEW')).toBe('客户入驻审核')
})
it('keeps unknown event codes visible for diagnosis', () => {
expect(auditEventLabel('FUTURE_CUSTOMER_EVENT')).toBe('FUTURE_CUSTOMER_EVENT')
expect(auditEventLabel('')).toBe('—')
})
})
+22
View File
@@ -0,0 +1,22 @@
const auditEventLabels: Record<string, string> = {
TENANT_REGISTER: '客户注册',
TENANT_REVIEW: '客户入驻审核',
PLATFORM_TENANT_UPDATE: '编辑客户档案',
PLATFORM_TENANT_STATUS_CHANGE: '启停客户',
PLATFORM_TENANT_SUBMIT_REVIEW: '提交客户审核',
TENANT_PROFILE_UPDATE: '编辑企业资料',
PLATFORM_CUSTOMER_ARCHIVE: '归档客户',
PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET: '重置企业管理员密码',
PLATFORM_CUSTOMER_IMPERSONATION_TICKET_CREATE: '创建客户代登录授权',
TENANT_ADDRESS_CREATE: '新增客户地址',
TENANT_QUALIFICATION_UPLOAD: '上传企业资质',
PLATFORM_TENANT_QUALIFICATION_UPLOAD: '平台上传企业资质',
TENANT_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD: '上传必填资质包',
PLATFORM_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD: '平台上传必填资质包',
}
export function auditEventLabel(value?: string | null) {
const code = value?.trim()
if (!code) return '—'
return auditEventLabels[code] ?? code
}
@@ -68,8 +68,9 @@ describe('DownloadCenterView', () => {
expect(document.body.textContent).toContain('客户列表')
expect(document.querySelector('.data-table')?.textContent).not.toContain('失败原因')
expect(document.querySelector('.data-table')?.textContent).not.toContain('完成时间')
expect(document.querySelector('.data-table')?.textContent).not.toContain('过期时间')
expect(document.querySelector('.data-table')?.textContent).toContain('格式')
expect(document.querySelector('.data-table')?.textContent).toContain('完成时间')
expect(document.querySelector('.data-table')?.textContent).toContain('过期时间')
expect(document.querySelector('.failure-cell')).toBeNull()
const button = [...document.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent?.trim() === '下载')
button?.click()
@@ -74,11 +74,11 @@ const columns: DataTableColumn[] = [
{ key: 'type', label: '任务类型', width: 120, slotName: 'type' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'progress', label: '数据量', width: 120, slotName: 'progress' },
{ key: 'format', prop: 'format', label: '格式', width: 80, defaultVisible: false },
{ key: 'format', prop: 'format', label: '格式', width: 80 },
{ key: 'requester', label: '申请人', minWidth: 130, slotName: 'requester' },
{ key: 'requestedAt', label: '申请时间', width: 170, slotName: 'requestedAt' },
{ key: 'completedAt', label: '完成时间', width: 170, slotName: 'completedAt', defaultVisible: false },
{ key: 'expiresAt', label: '过期时间', width: 170, slotName: 'expiresAt', defaultVisible: false },
{ key: 'completedAt', label: '完成时间', width: 170, slotName: 'completedAt' },
{ key: 'expiresAt', label: '过期时间', width: 170, slotName: 'expiresAt' },
]
const hasActiveTasks = computed(() => result.value.records.some((task) => ['PENDING', 'RUNNING'].includes(task.status)))
@@ -158,6 +158,7 @@ const selectedChannelRates = computed(() => {
const rates = detail.value?.rates || []
return selectedChannelCode.value ? rates.filter((rate) => rate.channelCode === selectedChannelCode.value) : rates
})
const selectedChannel = computed(() => detail.value?.channels.find((channel) => channel.channelCode === selectedChannelCode.value) || null)
const mappingBlockingIssueCodes = new Set([
'ORIGIN_MAPPING_REQUIRED',
'ORIGIN_WAREHOUSE_INVALID',
@@ -792,13 +793,20 @@ onMounted(() => {
<el-tabs v-model="selectedChannelCode" class="channel-tabs">
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :label="channel.channelName" :name="channel.channelCode" />
</el-tabs>
<div class="channel-meta"><span>{{ priceModelLabel(detail.channels.find((channel) => channel.channelCode === selectedChannelCode)?.priceModel || '') }}</span><span>表页:{{ detail.channels.find((channel) => channel.channelCode === selectedChannelCode)?.sourceSheetName || '-' }}</span><span v-if="detail.channels.find((channel) => channel.channelCode === selectedChannelCode)?.estimatedDepartureText">预计开船:{{ detail.channels.find((channel) => channel.channelCode === selectedChannelCode)?.estimatedDepartureText }}</span></div>
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '-' }}</span></div>
<el-table :data="selectedChannelRates" size="small" max-height="360" border stripe aria-label="渠道价格行预览">
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
<el-table-column prop="chargeUnit" label="单位" width="78" />
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table>
</template>
@@ -118,5 +118,6 @@ describe('FeeItemsView public master data and access', () => {
expect(source).not.toContain('pricingMode')
expect(source).not.toContain('计价模式')
expect(source).not.toContain('固定费用')
for (const label of ['创建人', '创建日期', '更新人', '更新日期']) expect(source).toContain(`label: '${label}'`)
})
})
+4 -2
View File
@@ -119,7 +119,10 @@ const columns: DataTableColumn[] = [
{ key: 'warehouse', label: '仓库', minWidth: 160, slotName: 'warehouse' },
{ key: 'allocationFee', label: '分摊费', width: 100, slotName: 'allocationFee' },
{ key: 'description', label: '说明', minWidth: 220, slotName: 'description', defaultVisible: false },
{ key: 'updatedAt', label: '更新时间', minWidth: 155, slotName: 'updatedAt', defaultVisible: false },
{ key: 'createdByName', label: '创建人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.createdByName || '—' },
{ key: 'createdAt', label: '创建日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.createdAt) },
{ key: 'updatedByName', label: '更新人', minWidth: 120, formatter: (row: PlatformFeeItemResource) => row.updatedByName || '—' },
{ key: 'updatedAt', label: '更新日期', minWidth: 155, formatter: (row: PlatformFeeItemResource) => formatTime(row.updatedAt) },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
@@ -395,7 +398,6 @@ onMounted(() => {
<template #warehouse="{ row }">{{ directionText((row as PlatformFeeItemResource).warehousePayable, (row as PlatformFeeItemResource).warehouseReceivable) }}</template>
<template #allocationFee="{ row }">{{ (row as PlatformFeeItemResource).allocationFee ? '是' : '否' }}</template>
<template #description="{ row }"><span class="description-cell">{{ (row as PlatformFeeItemResource).description || '—' }}</span></template>
<template #updatedAt="{ row }">{{ formatTime((row as PlatformFeeItemResource).updatedAt) }}</template>
<template #status="{ row }"><el-tag :type="(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as PlatformFeeItemResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #empty>
<el-empty v-if="!loading" description="暂无符合条件的费用项目">
+20 -5
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
@@ -18,13 +18,13 @@ describe('finance api', () => {
it('uses the customer statement read-model path', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2, createdBy: 7, createdByName: '创建人A', updatedBy: 8, updatedByName: '更新人B' }],
code: '200', message: 'ok', data: [{ statementId: 1, customerId: 10001, statementNo: 'B-1', customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, submittedAt: '2026-08-02T09:00:00', reviewedAt: '2026-08-03T09:00:00', issuedAt: null, status: 'DRAFT', version: 2, createdBy: 7, createdByName: '创建人A', updatedBy: 8, updatedByName: '更新人B' }],
})
const result = await listFinanceStatements('10001')
expect(get).toHaveBeenCalledWith('/platform/finance/v1/statements', { params: { customerId: '10001' } })
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2, createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', periodStart: '2026-08-01', periodEnd: '2026-08-31', lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, currency: 'CNY', status: 'DRAFT', version: 2, createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
})
it('never serializes an empty customerId for optional finance list filters', async () => {
@@ -61,6 +61,21 @@ describe('finance api', () => {
expect(result.records[0]).toEqual({ ledgerId: '81', occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: '91', documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' })
})
it('uses the formal paged fee-source path and preserves independent business fields', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({
code: '200', message: 'success', requestId: 'req-fees', traceId: 'trace-fees',
data: [{ feeSourceId: 42, sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IB-91', sourceFeeId: 81, sourceVersion: 3, direction: 'PAYABLE', customerId: 10001, customerName: '客户A', supplierId: 71, supplierName: '供应商A', orderId: 11, inboundId: 91, inboundCode: 'IB-91', containerId: 31, containerNo: 'CONT-31', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false }],
page: { page: 2, pageSize: 50, total: 51 },
})
const result = await listFinanceFeeSources({ page: 2, size: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: ' IB-91 ' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources', { params: {
page: 2, pageSize: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: 'IB-91',
} })
expect(result).toMatchObject({ total: 51, pages: 2, records: [{ feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceBusinessId: 'IB-91', supplierName: '供应商A', inboundCode: 'IB-91', containerNo: 'CONT-31', businessDate: '2026-08-11', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false }] })
})
it('uses fixed read-model paths for all five finance pages', async () => {
const get = vi.spyOn(http, 'get')
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { customerId: 10001, totalReceivable: 300, totalReceived: 100, outstandingAmount: 200, entries: [] } })
@@ -116,7 +131,7 @@ describe('finance api', () => {
})
it('uses the frozen G3 reconciliation create and action contracts', async () => {
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: 9, createdByName: '收款登记人', createdAt: '2026-08-02T11:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 120, afterAmount: 100, reason: '优惠', operatorId: 10, operatorName: '调整人', occurredAt: '2026-08-02T12:00:00' }] }
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', 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: 4, unitPrice: 30, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: [9001], createdBy: 9, createdByName: '收款登记人', createdAt: '2026-08-02T11:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 120, afterAmount: 100, reason: '优惠', operatorId: 10, operatorName: '调整人', occurredAt: '2026-08-02T12:00:00' }] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
@@ -128,7 +143,7 @@ describe('finance api', () => {
expect(post.mock.calls[1]).toEqual(['/platform/finance/v1/reconciliation-statements/501/actions', { action: 'SUBMIT', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.any(String) } }])
expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501')
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] })
expect(loaded.paymentHistory[0]).toMatchObject({ createdBy: '9', createdByName: '收款登记人' })
expect(loaded.paymentHistory[0]).toMatchObject({ voucherFileIds: ['9001'], createdBy: '9', createdByName: '收款登记人' })
expect(loaded.revisions[0]).toMatchObject({ operatorId: '10', operatorName: '调整人' })
})
+64 -14
View File
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource, ReconciliationActionPayload } from '../types'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceFeeSourceSummary, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceSettlementLine, FinanceStatementReadModel, FinanceVoucherResource, ReconciliationActionPayload } from '../types'
const basePath = '/platform/finance/v1'
export interface FinanceCustomerAccountSummaryResource {
@@ -116,17 +116,44 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function listFinanceFeeSources(params: {
page?: number
size?: number
customerId?: string
direction?: 'RECEIVABLE' | 'PAYABLE'
status?: string
reviewStatus?: 'PENDING' | 'APPROVED' | 'REJECTED'
settlementIncluded?: boolean
statementGenerated?: boolean
businessDateFrom?: string
businessDateTo?: string
keyword?: string
} = {}): Promise<{ records: FinanceFeeSourceSummary[]; total: number; pages: number }> {
const query = {
page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined,
direction: params.direction || undefined, status: params.status || undefined,
reviewStatus: params.reviewStatus || undefined, settlementIncluded: params.settlementIncluded,
statementGenerated: params.statementGenerated, businessDateFrom: params.businessDateFrom || undefined,
businessDateTo: params.businessDateTo || undefined, keyword: params.keyword?.trim() || undefined,
}
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/fee-sources`, { params: query })
const records = unwrapApiResponse(response).map(normalizeFeeSourceSummary)
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function getFinanceFeeSource(feeSourceId: string): Promise<FinanceFeeSourceDetail> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}`)
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
function normalizeFeeSourceSummary(item: RawResource): FinanceFeeSourceSummary {
const nullableString = (value: unknown) => value == null ? null : stringValue(value)
const nullableNumber = (value: unknown) => value == null ? null : numberValue(value)
return {
poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId),
sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceFeeId), sourceFeeId: stringValue(item.sourceFeeId),
poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId), sourceSystem: stringValue(item.sourceSystem),
sourceType: stringValue(item.sourceType), sourceBusinessId: stringValue(item.sourceBusinessId), sourceId: stringValue(item.sourceFeeId),
sourceVersion: numberValue(item.sourceVersion), direction: stringValue(item.direction) as FinanceFeeSourceDetail['direction'],
customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName),
orderId: nullableString(item.orderId), inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode),
@@ -134,8 +161,15 @@ function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice), amount: numberValue(item.amount), currency: 'CNY',
reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded),
sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
createdAt: stringValue(item.businessDate),
}
}
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
return {
...normalizeFeeSourceSummary(item), sourceFeeId: stringValue(item.sourceFeeId),
reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined,
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion), createdAt: stringValue(item.businessDate),
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion),
operationLogs: (Array.isArray(item.operationLogs) ? item.operationLogs : []).map((entry) => { const row = entry as RawResource; return { occurredAt: stringValue(row.occurredAt), action: stringValue(row.action), operatorName: stringValue(row.operatorName), result: stringValue(row.result), remark: stringValue(row.remark) || undefined } }),
}
}
@@ -153,7 +187,10 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
const row = entry as RawResource
return {
statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), sourceHash: stringValue(row.sourceHash) || undefined,
orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), containerId: row.containerId == null ? null : stringValue(row.containerId),
direction: stringValue(row.direction) as 'RECEIVABLE' | 'PAYABLE', sourceType: stringValue(row.sourceType), sourceNo: stringValue(row.sourceNo),
orderId: row.orderId == null ? null : stringValue(row.orderId), inboundId: row.inboundId == null ? null : stringValue(row.inboundId), containerId: row.containerId == null ? null : stringValue(row.containerId),
inboundCode: stringValue(row.inboundCode), containerNo: stringValue(row.containerNo), businessDate: stringValue(row.businessDate),
quantity: row.quantity == null ? null : numberValue(row.quantity), unitPrice: row.unitPrice == null ? null : numberValue(row.unitPrice),
feeItemName: stringValue(row.feeItemName), sourceAmount: numberValue(row.sourceAmount), amount: numberValue(row.statementAmount), adjustmentReason: stringValue(row.adjustmentReason) || undefined,
}
})
@@ -161,7 +198,7 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
billDate: stringValue(item.periodEnd), dueDate: '', periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: numberValue(item.creditDays), currency: 'CNY',
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt), lines,
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
}
}
@@ -169,14 +206,27 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
function normalizeSettlementDetail(item: RawResource): FinanceSettlementDetail {
const summary = item.summary && typeof item.summary === 'object' ? item.summary as RawResource : item
return {
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), customerName: stringValue(summary.customerName), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), supplierName: stringValue(summary.supplierName), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), businessPeriod: stringValue(summary.businessPeriod), generatedDate: stringValue(summary.generatedDate), dueDate: stringValue(summary.dueDate), paymentStatus: stringValue(summary.paymentStatus) as FinanceSettlementDetail['paymentStatus'], direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), version: numberValue(summary.version),
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeFeeSourceDetail(line as RawResource)),
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), customerName: stringValue(summary.customerName), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), supplierName: stringValue(summary.supplierName), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), reconciliationStatementNo: stringValue(summary.reconciliationStatementNo) || undefined, businessPeriod: stringValue(summary.businessPeriod), feeItemSummary: stringValue(summary.feeItemSummary) || undefined, generatedDate: stringValue(summary.generatedDate), dueDate: stringValue(summary.dueDate), paymentStatus: stringValue(summary.paymentStatus) as FinanceSettlementDetail['paymentStatus'], direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), createdBy: stringValue(summary.createdBy) || undefined, createdByName: stringValue(summary.createdByName) || undefined, createdAt: stringValue(summary.createdAt) || undefined, updatedAt: stringValue(summary.updatedAt) || undefined, lastPaymentAt: stringValue(summary.lastPaymentAt) || undefined, version: numberValue(summary.version),
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeSettlementLine(line as RawResource)),
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), createdAt: stringValue(row.createdAt), remark: stringValue(row.remark) || undefined } }),
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
}
}
function normalizeSettlementLine(item: RawResource): FinanceSettlementLine {
const nullableString = (value: unknown) => value == null ? null : stringValue(value)
const nullableNumber = (value: unknown) => value == null ? null : numberValue(value)
return {
feeSourceId: stringValue(item.feeSourceId), sourceType: stringValue(item.sourceType), sourceFeeId: stringValue(item.sourceFeeId), sourceVersion: numberValue(item.sourceVersion),
direction: stringValue(item.direction) as FinanceSettlementLine['direction'], customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName), orderId: nullableString(item.orderId), orderNo: stringValue(item.orderNo),
inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode), containerId: nullableString(item.containerId), containerNo: stringValue(item.containerNo),
businessDate: stringValue(item.businessDate), feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice),
amount: numberValue(item.amount), currency: 'CNY', reviewStatus: stringValue(item.reviewStatus) as FinanceSettlementLine['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded), sourceStatus: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
}
}
export async function createReconciliationStatement(payload: CreateReconciliationPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/statements`, payload, mutationHeaders('reconciliation-create'))
return normalizeReconciliationDetail(unwrapApiResponse(response))
@@ -212,14 +262,14 @@ export async function actionFinanceSettlement(settlementId: string, payload: imp
export async function listFinanceStatements(customerId?: string): Promise<FinanceStatementReadModel[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/statements`, { params: { customerId: customerId?.trim() || undefined } })
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt) }))
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.statementId ?? item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.statementNo ?? item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.periodEnd ?? item.billDate), dueDate: stringValue(item.dueDate), periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: item.creditDays == null ? undefined : numberValue(item.creditDays), lineCount: item.lineCount == null ? undefined : numberValue(item.lineCount), sourceCount: item.sourceCount == null ? undefined : numberValue(item.sourceCount), sourceAmount: item.sourceAmount == null ? undefined : numberValue(item.sourceAmount), adjustmentAmount: item.adjustmentAmount == null ? undefined : numberValue(item.adjustmentAmount), totalAmount: numberValue(item.totalReceivable ?? item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', settled: item.settled == null ? undefined : Boolean(item.settled), lastReceiptAt: stringValue(item.lastReceiptAt), submittedAt: stringValue(item.submittedAt), reviewedAt: stringValue(item.reviewedAt), issuedAt: stringValue(item.issuedAt), status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt) }))
}
export async function listFinanceReceivableSettlements(customerId?: string): Promise<FinanceReceivableSettlement[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/receivable-settlements`, { params: { customerId: customerId?.trim() || undefined } })
return unwrapApiResponse(response).map((item) => ({
id: stringValue(item.settlementId ?? item.id), customerId: stringValue(item.customerId),
customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), reconciliationStatementId: item.reconciliationStatementId == null ? undefined : stringValue(item.reconciliationStatementId), businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), billDate: stringValue(item.generatedDate ?? item.billDate),
customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), reconciliationStatementId: item.reconciliationStatementId == null ? undefined : stringValue(item.reconciliationStatementId), reconciliationStatementNo: stringValue(item.reconciliationStatementNo) || undefined, businessPeriod: stringValue(item.businessPeriod), feeItemSummary: stringValue(item.feeItemSummary) || undefined, generatedDate: stringValue(item.generatedDate), createdBy: stringValue(item.createdBy) || undefined, createdByName: stringValue(item.createdByName) || undefined, createdAt: stringValue(item.createdAt) || undefined, updatedAt: stringValue(item.updatedAt) || undefined, lastPaymentAt: stringValue(item.lastPaymentAt) || undefined, billDate: stringValue(item.generatedDate ?? item.billDate),
dueDate: stringValue(item.dueDate), receivableAmount: numberValue(item.totalAmount ?? item.receivableAmount),
receivedAmount: numberValue(item.paidAmount ?? item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount),
paymentStatus: stringValue(item.paymentStatus) as FinanceReceivableSettlement['paymentStatus'], currency: 'CNY', status: stringValue(item.status) as FinanceReceivableSettlement['status'], version: numberValue(item.version),
@@ -231,9 +281,9 @@ export async function listFinancePayableSettlements(customerId?: string): Promis
return unwrapApiResponse(response).map((item) => ({
id: stringValue(item.settlementId ?? item.id), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), supplierId: item.supplierId == null ? undefined : stringValue(item.supplierId),
costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null,
supplierName: stringValue(item.supplierName), feeItemName: '', businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), dueDate: stringValue(item.dueDate), payableAmount: numberValue(item.totalAmount ?? item.payableAmount),
supplierName: stringValue(item.supplierName), feeItemName: stringValue(item.feeItemSummary), feeItemSummary: stringValue(item.feeItemSummary) || undefined, businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), dueDate: stringValue(item.dueDate), payableAmount: numberValue(item.totalAmount ?? item.payableAmount),
paidAmount: numberValue(item.paidAmount), outstandingAmount: numberValue(item.outstandingAmount), paymentStatus: stringValue(item.paymentStatus) as FinancePayableSettlement['paymentStatus'], currency: 'CNY',
status: stringValue(item.status), createdAt: stringValue(item.generatedDate ?? item.createdAt), version: numberValue(item.version),
status: stringValue(item.status), createdAt: stringValue(item.createdAt), createdBy: stringValue(item.createdBy) || undefined, createdByName: stringValue(item.createdByName) || undefined, updatedAt: stringValue(item.updatedAt) || undefined, lastPaymentAt: stringValue(item.lastPaymentAt) || undefined, version: numberValue(item.version),
}))
}
@@ -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>
+89 -17
View File
@@ -62,6 +62,24 @@ export interface FinanceFeePoolItem {
createdAt: string
}
export interface FinanceFeeSourceSummary extends FinanceFeePoolItem {
feeSourceId: string
sourceSystem: string
sourceBusinessId: string
customerName: string
supplierId: string | null
supplierName: string
inboundCode: string
containerNo: string
businessDate: string
quantity: number | null
unitPrice: number | null
reviewStatus: FinanceReviewStatus
settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
}
export interface FinanceStatementReadModel {
id: string
customerId: string
@@ -69,12 +87,25 @@ export interface FinanceStatementReadModel {
customerName: string
billDate: string
dueDate: string
periodStart?: string
periodEnd?: string
creditDays?: number
lineCount?: number
sourceCount?: number
sourceAmount?: number
adjustmentAmount?: number
totalAmount: number
receivedAmount: number
outstandingAmount: number
currency: 'CNY'
status: FinanceBillStatus
version: number
settled?: boolean
lastReceiptAt?: string
submittedAt?: string
reviewedAt?: string
issuedAt?: string
remark?: string
createdBy?: string
createdByName?: string
createdAt?: string
@@ -89,8 +120,15 @@ export interface FinanceReceivableSettlement {
settlementNo: string
customerName?: string
reconciliationStatementId?: string
reconciliationStatementNo?: string
businessPeriod?: string
feeItemSummary?: string
generatedDate?: string
createdBy?: string
createdByName?: string
createdAt?: string
updatedAt?: string
lastPaymentAt?: string
billDate: string
dueDate: string
receivableAmount: number
@@ -114,6 +152,7 @@ export interface FinancePayableSettlement {
containerId: string | null
supplierName: string
feeItemName: string
feeItemSummary?: string
businessPeriod?: string
generatedDate?: string
dueDate?: string
@@ -124,6 +163,10 @@ export interface FinancePayableSettlement {
currency: 'CNY'
status: string
createdAt: string
createdBy?: string
createdByName?: string
updatedAt?: string
lastPaymentAt?: string
version: number
}
@@ -155,22 +198,8 @@ export interface FinanceLedgerEntry {
remark: string
}
export interface FinanceFeeSourceDetail extends FinanceFeePoolItem {
feeSourceId: string
customerId: string
customerName: string
supplierId: string | null
supplierName: string
export interface FinanceFeeSourceDetail extends FinanceFeeSourceSummary {
sourceFeeId: string
inboundCode: string
containerNo: string
businessDate: string
quantity: number | null
unitPrice: number | null
reviewStatus: FinanceReviewStatus
settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
reviewReason?: string
settlementReason?: string
sourceHash?: string
@@ -178,6 +207,34 @@ export interface FinanceFeeSourceDetail extends FinanceFeePoolItem {
operationVersion: number
}
export interface FinanceSettlementLine {
feeSourceId: string
sourceType: string
sourceFeeId: string
sourceVersion: number
direction: 'RECEIVABLE' | 'PAYABLE'
customerId: string
customerName: string
supplierId: string | null
supplierName: string
orderId: string | null
orderNo: string
inboundId: string | null
inboundCode: string
containerId: string | null
containerNo: string
businessDate: string
feeItemName: string
quantity: number | null
unitPrice: number | null
amount: number
currency: 'CNY'
reviewStatus: FinanceReviewStatus
settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
}
export interface FinanceSettlementDetail {
id: string
settlementNo: string
@@ -186,7 +243,9 @@ export interface FinanceSettlementDetail {
customerName?: string
supplierName?: string
reconciliationStatementId?: string
reconciliationStatementNo?: string
businessPeriod?: string
feeItemSummary?: string
generatedDate?: string
dueDate?: string
paymentStatus?: FinancePaymentStatus
@@ -196,8 +255,13 @@ export interface FinanceSettlementDetail {
outstandingAmount: number
currency: 'CNY'
status: string
createdBy?: string
createdByName?: string
createdAt?: string
updatedAt?: string
lastPaymentAt?: string
version: number
lines: FinanceFeeSourceDetail[]
lines: FinanceSettlementLine[]
transactions: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; voucherFileIds: string[]; createdBy: string; createdByName: string; createdAt: string; remark?: string }>
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
}
@@ -219,7 +283,7 @@ export interface FinanceReconciliationDetail extends FinanceBillDetail {
updatedBy?: string
updatedByName?: string
updatedAt?: string
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; createdByName: string; occurredAt: string; remark?: string }>
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; voucherFileIds: string[]; createdBy: string; createdByName: string; occurredAt: string; remark?: string }>
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
}
@@ -287,6 +351,14 @@ export interface FinanceBillLine {
orderId: string | null
inboundId: string | null
containerId?: string | null
direction?: 'RECEIVABLE' | 'PAYABLE'
sourceType?: string
sourceNo?: string
inboundCode?: string
containerNo?: string
businessDate?: string
quantity?: number | null
unitPrice?: number | null
feeItemName: string
sourceAmount?: number
amount: number
+1
View File
@@ -58,6 +58,7 @@ const role: PlatformRoleResource = {
roleType: 'SYSTEM',
status: 'ACTIVE',
description: '企业资料与入驻审核',
memberCount: 3,
permissionIds: ['5100010'],
}
+1
View File
@@ -171,6 +171,7 @@ export interface PlatformRoleResource {
roleType: 'SYSTEM' | 'CUSTOM' | string
status: 'ACTIVE' | 'INACTIVE' | string
description: string | null
memberCount: number
permissionIds?: Array<number | string>
}
@@ -103,8 +103,9 @@ describe('PermissionsView IAM mode', () => {
})
it('keeps technical permission fields out of the default list and labels the table', () => {
expect(permissionsSource).toContain("{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code', defaultVisible: false }")
expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint', defaultVisible: false }")
expect(permissionsSource).toContain("{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' }")
expect(permissionsSource).toContain("{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' }")
expect(permissionsSource).toContain("{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' }")
expect(permissionsSource).toContain('aria-label="平台权限资源列表"')
})
+3 -3
View File
@@ -88,10 +88,10 @@ const parentOptions = computed(() => permissions.value.filter((item) => (
const permissionColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code', defaultVisible: false },
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
{ key: 'type', prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint', defaultVisible: false },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right', defaultVisible: false },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
+6 -7
View File
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPlatformRole, listPlatformInternalUsers, listPlatformPermissions, listPlatformRoles, type PlatformRoleResource } from '@/modules/iam/api/platformIam'
import { createPlatformRole, listPlatformPermissions, listPlatformRoles, type PlatformRoleResource } from '@/modules/iam/api/platformIam'
import { useAuthStore } from '@/core/auth/auth'
import RolesView from './RolesView.vue'
@@ -11,7 +11,6 @@ const rolesSource = readFileSync(resolve(process.cwd(), 'src/modules/iam/pages/R
vi.mock('@/modules/iam/api/platformIam', () => ({
createPlatformRole: vi.fn(),
listPlatformInternalUsers: vi.fn(),
listPlatformPermissions: vi.fn(),
listPlatformRoles: vi.fn(),
updatePlatformRole: vi.fn(),
@@ -29,6 +28,7 @@ const platformRole: PlatformRoleResource = {
roleType: 'SYSTEM',
status: 'ACTIVE',
description: '企业资料与入驻审核',
memberCount: 3,
}
function mountView(pinia = createPinia()) {
@@ -86,9 +86,7 @@ describe('RolesView', () => {
setActivePinia(createPinia())
vi.mocked(listPlatformRoles).mockReset()
vi.mocked(listPlatformPermissions).mockReset()
vi.mocked(listPlatformInternalUsers).mockReset()
vi.mocked(listPlatformPermissions).mockResolvedValue([])
vi.mocked(listPlatformInternalUsers).mockResolvedValue([])
vi.mocked(createPlatformRole).mockReset()
})
@@ -98,8 +96,8 @@ describe('RolesView', () => {
expect(wrapper.get('[data-testid="permission-tree"]').attributes('data-check-strictly')).toBe('true')
})
it('keeps the role code available but hidden from the default list layout', () => {
expect(rolesSource).toContain("{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true, defaultVisible: false }")
it('shows the role code in the default list layout', () => {
expect(rolesSource).toContain("{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true }")
expect(rolesSource).toContain('<el-form-item label="角色编码"')
expect(rolesSource).not.toContain('<small>{{ (row as Role).code }}</small>')
expect(rolesSource).toContain('aria-label="平台角色列表"')
@@ -126,7 +124,8 @@ describe('RolesView', () => {
await flushPromises()
expect(listPlatformRoles).toHaveBeenCalledTimes(1)
expect(listPlatformInternalUsers).toHaveBeenCalledTimes(1)
expect(rolesSource).toContain('userCount: role.memberCount')
expect(rolesSource).not.toContain('listPlatformInternalUsers')
expect(wrapper.get('[data-testid="row-count"]').text()).toBe('1')
expect(wrapper.text()).not.toContain('角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色')
expect(wrapper.find('[data-testid="permission-tree"]').exists()).toBe(false)
+5 -14
View File
@@ -6,7 +6,6 @@ import {
changePlatformRoleStatus,
createPlatformRole,
deletePlatformRole,
listPlatformInternalUsers,
listPlatformPermissions,
listPlatformRoles,
replacePlatformRolePermissions,
@@ -81,7 +80,7 @@ const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pag
const roleColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'role', label: '角色名称', minWidth: 180, slotName: 'role' },
{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true, defaultVisible: false },
{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true },
{ key: 'description', prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true },
{ key: 'permissionSummary', label: '授权范围', minWidth: 170, slotName: 'permissionSummary' },
{ key: 'userCount', prop: 'userCount', label: '用户数', width: 86, align: 'right' },
@@ -97,13 +96,13 @@ function applyFilters() {
void loadRoles()
}
function mapPlatformRole(role: PlatformRoleResource, userCount = 0): Role {
function mapPlatformRole(role: PlatformRoleResource): Role {
return {
id: String(role.id),
name: role.roleName,
code: role.roleCode,
description: role.description ?? '暂无角色说明',
userCount,
userCount: role.memberCount,
dataScope: '全部数据',
status: role.status === 'ACTIVE' ? '启用' : '停用',
permissionIds: (role.permissionIds ?? []).map(String),
@@ -117,20 +116,12 @@ async function loadRoles() {
serverRoles.value = []
serverPermissions.value = []
try {
const [roleRecords, permissionRecords, memberRecords] = await Promise.all([
const [roleRecords, permissionRecords] = await Promise.all([
listPlatformRoles(),
listPlatformPermissions(),
listPlatformInternalUsers(),
])
const roleMemberCounts = new Map<string, number>()
for (const member of memberRecords) {
for (const roleId of member.roleIds) {
const key = String(roleId)
roleMemberCounts.set(key, (roleMemberCounts.get(key) ?? 0) + 1)
}
}
serverPermissions.value = permissionRecords.map(mapPlatformPermission)
serverRoles.value = roleRecords.map((role) => mapPlatformRole(role, roleMemberCounts.get(String(role.id)) ?? 0))
serverRoles.value = roleRecords.map(mapPlatformRole)
} catch (error) {
loadError.value = getApiErrorDetails(error, '平台角色列表加载失败,请稍后重试')
} finally {
@@ -28,18 +28,24 @@ const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'product', label: '产品名称', minWidth: 190, slotName: 'product' },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 },
{ key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 },
{ key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 86, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 86, align: 'right' },
{ key: 'productQuantity', prop: 'productQuantity', label: '产品数', width: 96, align: 'right' },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, align: 'right', slotName: 'grossWeight' },
{ key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, align: 'right', slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, align: 'right', slotName: 'grossWeight' },
{ key: 'dimensionalWeightKg', label: '材积重(kg)', width: 115, align: 'right', slotName: 'dimensionalWeight' },
{ key: 'productQuantity', prop: 'productQuantity', label: '产品数', width: 96, align: 'right' },
{ key: 'unitsPerPackage', prop: 'unitsPerPackage', label: '每箱产品数', width: 110, align: 'right' },
{ key: 'fbaTrackingNo', prop: 'fbaTrackingNo', label: 'FBA 追踪编码', minWidth: 155 },
{ key: 'shipmentNo', prop: 'shipmentNo', label: '货件号', minWidth: 140 },
{ key: 'shippingMark', prop: 'shippingMark', label: '唛头', minWidth: 130 },
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(内部)', minWidth: 160, showOverflowTooltip: true },
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160, showOverflowTooltip: true },
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160, showOverflowTooltip: true },
{ key: 'customsDocument', label: '报关资料', minWidth: 170, slotName: 'customsDocument' },
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
]
@@ -145,10 +151,10 @@ function formatNumber(value?: number | null, digits = 3) {
empty-text="暂无装柜货物明细"
aria-label="装柜货物明细"
>
<template #product="{ row }"><strong>{{ row.chineseName || row.englishName || '—' }}</strong><small v-if="row.englishName && row.chineseName">{{ row.englishName }}</small></template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
<template #dimensionalWeight="{ row }">{{ formatNumber(row.dimensionalWeightKg) }}</template>
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
<template #customsDocument><span class="document-cell">{{ detail.customsDocument?.fileName || '未上传' }}</span></template>
</DataTable>
@@ -141,6 +141,36 @@ describe('LoadingListsView', () => {
expect(detailSource).toContain(':columns="itemColumns"\n stripe')
})
it('shows every prototype detail field that is backed by the loading item contract', () => {
const fields = [
'chineseName',
'englishName',
'netWeightKg',
'packageCount',
'palletCount',
'volumeCbm',
'grossWeightKg',
'dimensionalWeightKg',
'productQuantity',
'unitsPerPackage',
'fbaTrackingNo',
'shipmentNo',
'shippingMark',
'internalRemark',
'customerRemark',
'warehouseRemark',
'customsDocument',
]
let previous = detailSource.indexOf("key: 'customerName'")
for (const field of fields) {
const current = detailSource.indexOf(`key: '${field}'`, previous + 1)
expect(current, field).toBeGreaterThan(previous)
previous = current
}
expect(detailSource).not.toContain("key: 'goodsImageUrl'")
expect(detailSource).not.toContain("label: '退税资料'")
})
it('does not expose unknown history action codes', () => {
expect(detailSource).toContain("CONFIRM_PRELOAD: '确认预装'")
expect(detailSource).toContain("[action] || '状态更新'")
@@ -45,6 +45,10 @@ describe('notification center contract UI', () => {
expect(management).toContain('任务失败原因')
expect(management).toContain('收件人投递明细')
expect(management).toContain('failureReason')
expect(management).toContain('notificationCategoryLabel((row as NotificationTask).category)')
expect(management).toContain('notificationCategoryLabel(detail.category)')
expect(management).toContain('notificationAppLabel(row.appCode)')
expect(management).toContain('notificationRecipientStatusLabel(row.status)')
})
it('uses the prototype composer width and exposes the real send command', () => {
@@ -4,9 +4,10 @@ import { Eye, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDrawer from '@/shared/components/AppDrawer.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import MessageComposerDialog from '../components/MessageComposerDialog.vue'; import { cancelTask, getTask, listTasks, listTemplates, retryTask } from '../api/notificationCenter'; import type { NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate } from '../types'; import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { notificationAppLabel, notificationCategoryLabel, notificationRecipientStatusLabel } from '../presentation'
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),composerOpen=ref(false),detailOpen=ref(false),detailLoading=ref(false),detailError=ref<ApiErrorDetails|null>(null),detail=ref<NotificationTaskDetail|null>(null),templates=ref<NotificationTemplate[]>([]),result=ref({records:[] as NotificationTask[],total:0,pages:0}),filters=ref<QueryModel>({category:'',status:'',keyword:''})
const queryFields:QueryField[]=[{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'status',type:'select',label:'任务状态',placeholder:'全部状态',options:['CREATED','SCHEDULED','DISPATCHING','RETRYING','SENT','DEAD','CANCELLED'].map(value=>({label:statusLabel(value as NotificationTaskStatus),value}))},{key:'keyword',type:'input',label:'任务号或标题',placeholder:'任务号/消息标题'}]
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:240,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',prop:'category',label:'业务分类',width:130},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:110,slotName:'status'}]
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:240,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',label:'业务分类',width:130,slotName:'category'},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:110,slotName:'status'}]
function statusLabel(v:NotificationTaskStatus){return {CREATED:'已创建',SCHEDULED:'待定时发送',DISPATCHING:'发送中',RETRYING:'重试中',SENT:'已发送',DEAD:'发送失败',CANCELLED:'已取消'}[v]}
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
async function load(){loading.value=true;error.value=null;try{const keyword=String(filters.value.keyword||'').trim();const data=await listTasks({page:page.value,size:pageSize.value,category:String(filters.value.category||'').trim()||undefined,status:(filters.value.status||undefined) as NotificationTaskStatus|undefined,...(keyword.startsWith('NT')?{taskNo:keyword}:{title:keyword||undefined})});result.value={records:data.records,total:data.total,pages:data.pages}}catch(e){error.value=getApiErrorDetails(e,'消息任务加载失败')}finally{loading.value=false}}
@@ -16,5 +17,5 @@ function search(){page.value===1?void load():page.value=1}
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
</script>
<template><AdminPage><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template><template v-if="error" #state><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationTask)"><Eye :size="15"/>查看</el-button><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" link type="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')"><XCircle :size="15"/>取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" link type="primary" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')"><RotateCcw :size="15"/>重试</el-button></div></template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/><AppDrawer v-model="detailOpen" title="消息任务详情" size="min(820px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close><LoadErrorState v-if="detailError" title="消息任务详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><dl class="detail-grid"><div><dt>任务号</dt><dd>{{detail.taskNo}}</dd></div><div><dt>状态</dt><dd>{{statusLabel(detail.status)}}</dd></div><div><dt>业务分类</dt><dd>{{detail.category}}</dd></div><div><dt>接收人数</dt><dd>{{detail.recipientCount}}</dd></div><div><dt>尝试次数</dt><dd>{{detail.attemptCount}}</dd></div><div><dt>发送时间</dt><dd>{{formatDate(detail.sentAt)}}</dd></div></dl><section v-if="detail.lastError" class="failure-panel" role="alert"><strong>任务失败原因</strong><p>{{detail.lastError}}</p></section><h3 class="recipient-heading">收件人投递明细</h3><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"><el-table-column prop="displayName" label="收件人" min-width="150"/><el-table-column prop="appCode" label="应用端" width="110"/><el-table-column prop="status" label="投递状态" width="120"/><el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column><el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}">{{row.failureReason||'—'}}</template></el-table-column></el-table></template></AppDrawer></AdminPage></template>
<template><AdminPage><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template><template v-if="error" #state><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationTask)"><Eye :size="15"/>查看</el-button><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" link type="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')"><XCircle :size="15"/>取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" link type="primary" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')"><RotateCcw :size="15"/>重试</el-button></div></template><template #category="{row}">{{notificationCategoryLabel((row as NotificationTask).category)}}</template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/><AppDrawer v-model="detailOpen" title="消息任务详情" size="min(820px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close><LoadErrorState v-if="detailError" title="消息任务详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><dl class="detail-grid"><div><dt>任务号</dt><dd>{{detail.taskNo}}</dd></div><div><dt>状态</dt><dd>{{statusLabel(detail.status)}}</dd></div><div><dt>业务分类</dt><dd>{{notificationCategoryLabel(detail.category)}}</dd></div><div><dt>接收人数</dt><dd>{{detail.recipientCount}}</dd></div><div><dt>尝试次数</dt><dd>{{detail.attemptCount}}</dd></div><div><dt>发送时间</dt><dd>{{formatDate(detail.sentAt)}}</dd></div></dl><section v-if="detail.lastError" class="failure-panel" role="alert"><strong>任务失败原因</strong><p>{{detail.lastError}}</p></section><h3 class="recipient-heading">收件人投递明细</h3><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"><el-table-column prop="displayName" label="收件人" min-width="150"/><el-table-column label="应用端" width="110"><template #default="{row}">{{notificationAppLabel(row.appCode)}}</template></el-table-column><el-table-column label="投递状态" width="120"><template #default="{row}">{{notificationRecipientStatusLabel(row.status)}}</template></el-table-column><el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column><el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}">{{row.failureReason||'—'}}</template></el-table-column></el-table></template></AppDrawer></AdminPage></template>
<style scoped>.table-actions{display:flex;min-height:32px;align-items:center;gap:8px}.table-actions :deep(.el-button+.el-button){margin-left:0}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:0 0 20px}.detail-grid div{min-width:0;padding:12px;border:1px solid var(--border-color,#e5e7eb);border-radius:6px}.detail-grid dt{color:var(--text-secondary,#64748b);font-size:12px}.detail-grid dd{margin:6px 0 0;overflow-wrap:anywhere;color:var(--text-primary,#1f2937);font-weight:600}.failure-panel{margin-bottom:20px;padding:12px;border:1px solid #f2b8b5;border-radius:6px;background:#fff7f6;color:#9f2d27}.failure-panel p{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.recipient-heading{margin:0 0 12px;font-size:15px}@media(max-width:720px){.detail-grid{grid-template-columns:1fr}}</style>
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { notificationAppLabel, notificationCategoryLabel, notificationRecipientStatusLabel } from './presentation'
describe('notification presentation', () => {
it('maps known notification values to user-facing Chinese labels', () => {
expect(notificationCategoryLabel('ORDER')).toBe('订单消息')
expect(notificationAppLabel('MERCHANT')).toBe('企业客户端')
expect(notificationRecipientStatusLabel('DELIVERED')).toBe('已送达')
})
it('falls back to the original code for values added by the backend later', () => {
expect(notificationCategoryLabel('FUTURE_CATEGORY')).toBe('FUTURE_CATEGORY')
expect(notificationAppLabel('MOBILE')).toBe('MOBILE')
expect(notificationRecipientStatusLabel('QUEUED')).toBe('QUEUED')
})
})
+39
View File
@@ -0,0 +1,39 @@
const categoryLabels: Record<string, string> = {
SYSTEM: '系统通知',
SYSTEM_NOTICE: '系统通知',
BUSINESS: '业务提醒',
BUSINESS_REMINDER: '业务提醒',
ALERT: '异常告警',
EXCEPTION_ALERT: '异常告警',
WARNING: '异常告警',
TASK: '任务待办',
TASK_TODO: '任务待办',
ORDER: '订单消息',
INBOUND: '入库消息',
FINANCE: '财务消息',
CUSTOMS: '报关消息',
ACCEPTANCE: '验收消息',
CLIENT_ACCEPTANCE: '验收消息',
}
const appLabels: Record<string, string> = {
PLATFORM: '管理后台',
ADMIN: '管理后台',
MERCHANT: '企业客户端',
}
const recipientStatusLabels: Record<string, string> = {
RESOLVED: '待投递',
DELIVERED: '已送达',
FAILED: '投递失败',
}
function mappedLabel(value: string | null | undefined, labels: Record<string, string>) {
const code = value?.trim()
if (!code) return '—'
return labels[code.toUpperCase()] ?? code
}
export const notificationCategoryLabel = (value?: string | null) => mappedLabel(value, categoryLabels)
export const notificationAppLabel = (value?: string | null) => mappedLabel(value, appLabels)
export const notificationRecipientStatusLabel = (value?: string | null) => mappedLabel(value, recipientStatusLabels)
@@ -165,8 +165,8 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('aria-label="基本信息"')
expect(source).toContain('aria-label="航线船期"')
expect(source).toContain('aria-label="条款与真实柜"')
expect(source).toContain('grid-template-columns:minmax(0,1.4fr) repeat(2,minmax(0,1fr))')
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:12px}')
expect(source).toContain('grid-template-columns:minmax(0,1.5fr) repeat(2,minmax(0,1fr))')
expect(source).toContain('.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px;row-gap:8px}')
expect(source).toContain('class="booking-field--wide" label="交货地点"')
expect(source).toContain('@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}')
expect(source).toContain('@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}')
@@ -7,7 +7,7 @@ describe('OceanBookingsView prototype field matrix', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
it('keeps the frozen business field sequence after the first action column', () => {
const fields = ['bookingNo', 'customerName', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'status', 'updatedAt']
const fields = ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'status', 'updatedAt']
let previous = source.indexOf("key: 'actions'")
for (const field of fields) {
const current = source.indexOf(`prop: '${field}'`, previous + 1)
@@ -17,6 +17,7 @@ describe('OceanBookingsView prototype field matrix', () => {
})
it('formats schedule and update timestamps without adding print actions', () => {
expect(source).toContain("prop: 'billOfLadingNo', label: '提单号'")
expect(source).toContain('<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>')
expect(source).toContain('<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>')
expect(source).not.toContain('打印')
@@ -39,6 +39,7 @@ const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 100, slotName: 'actions', hideable: false, movable: false },
{ prop: 'bookingNo', label: '订舱号', minWidth: 150 },
{ prop: 'customerName', label: '客户', minWidth: 160 },
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155 },
{ prop: 'carrierName', label: '船公司', minWidth: 130 },
{ prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
{ prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' },
@@ -116,11 +116,15 @@ describe('TruckAppointmentsView', () => {
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
})
it('prioritizes outbound business fields and keeps finance amounts out of the list', () => {
it('shows the prototype-backed trucking fee snapshots in the list', () => {
expect(source).toContain("key: 'customerName'")
expect(source).toContain("key: 'updatedAt'")
expect(source).not.toContain("key: 'payableAmount'")
expect(source).not.toContain("key: 'receivableAmount'")
expect(source).toContain("key: 'payableAmount', label: '应付拖车费'")
expect(source).toContain("key: 'receivableAmount', label: '应收拖车费'")
expect(source.indexOf("key: 'store'")).toBeLessThan(source.indexOf("key: 'payableAmount'"))
expect(source.indexOf("key: 'payableAmount'")).toBeLessThan(source.indexOf("key: 'receivableAmount'"))
expect(source.indexOf("key: 'receivableAmount'")).toBeLessThan(source.indexOf("key: 'plannedLoadingAt'"))
expect(source).toContain("toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })")
})
it('filters with an active name-only customer selector and stable customerId', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
@@ -36,12 +36,20 @@ const columns: DataTableColumn[] = [
{ key: 'driver', label: '司机', minWidth: 130, slotName: 'driver' },
{ key: 'vehicleNo', prop: 'vehicleNo', label: '车头号', minWidth: 120, formatter: (row: TruckAppointmentResource) => row.vehicleNo || '—' },
{ key: 'store', label: '门店', minWidth: 140, slotName: 'store' },
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.payableAmount, row.currency) },
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, align: 'right', formatter: (row: TruckAppointmentResource) => formatAmount(row.receivableAmount, row.currency) },
{ key: 'plannedLoadingAt', label: '预计装箱日期', minWidth: 170, formatter: (row: TruckAppointmentResource) => formatDateTime(row.plannedLoadingAt) },
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
{ key: 'attachments', label: '附件', width: 90, formatter: (row: TruckAppointmentResource) => String(row.attachments?.length || 0) },
{ key: 'updatedAt', label: '更新时间', minWidth: 160, formatter: (row: TruckAppointmentResource) => formatDateTime(row.updatedAt) },
]
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatAmount(value?: string | null, currency?: string | null) {
if (value == null || value === '') return '—'
const amount = Number(value)
const display = Number.isFinite(amount) ? amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value
return currency ? `${display} ${currency}` : display
}
function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointmentResource) {
const identifier = [row.appointmentNo, row.id]
.map((value) => String(value ?? '').trim())
@@ -70,11 +70,11 @@ describe('unified orders API contract', () => {
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', actualGrossWeightKg: '12.5', actualVolumeCbm: '0.45', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', locationCodes: 'A0-01, B2-03', actualGrossWeightKg: '12.5', actualVolumeCbm: '0.45', totalPalletCount: '3', totalNetWeightKg: '11.2', chargeableVolumeCbm: '0.5', totalQuantity: '48', skuNos: ['SKU-1', ''], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: 90001, parentOrderId: null, splitSequence: null, childCount: '2' }, measurement: null, preload: null, loading: null, delivery: null, transport: null }],
page: 1, size: 20, total: 1, pages: 1,
} })
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, recommendedPalletType: 'STANDARD', locationCodes: 'A0-01, B2-03', actualGrossWeightKg: 12.5, actualVolumeCbm: 0.45, customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, locationCodes: 'A0-01, B2-03', actualGrossWeightKg: 12.5, actualVolumeCbm: 0.45, totalPalletCount: 3, totalNetWeightKg: 11.2, chargeableVolumeCbm: 0.5, totalQuantity: 48, skuNos: ['SKU-1'], requestedPickupAt: '2026-08-18T09:30:00', customerRemark: '客户备注', latestInternalNote: '最新内部备注', relation: { role: 'PARENT', rootOrderId: '90001', childCount: 2 } }],
})
})
+20 -14
View File
@@ -89,7 +89,6 @@ export interface UnifiedOrderResource {
inboundCode: string | null
expectedShipDate: string | null
totalPackageCount: number | null
recommendedPalletType: string | null
locationCodes: string | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
@@ -100,14 +99,14 @@ export interface UnifiedOrderResource {
transport: UnifiedOrderTransportSummary | null
createdAt: string | null
updatedAt: string | null
/** Optional fields returned by newer first-leg/inbound projections. */
expectedArrivalAt?: string | null
feeReviewStatus?: string | null
expectedArrivalAt: string | null
feeReviewStatus: string | null
holdCargo?: boolean | null
holdReason?: string | null
companyName?: string | null
businessType?: string | null
pickupMethod?: string | null
requestedPickupAt: string | null
pickupAt?: string | null
pickupContactPhone?: string | null
pickupAddress?: string | null
@@ -116,24 +115,27 @@ export interface UnifiedOrderResource {
taxMode?: string | null
fullContainer?: boolean | null
customsInspection?: boolean | null
/** Legacy aliases retained while older order responses are still deployed. */
totalPalletCount?: number | null
chargeableVolumeCbm?: number | null
volumetricWeightKg?: number | null
totalNetWeightKg?: number | null
totalPalletCount: number | null
chargeableVolumeCbm: number | null
volumetricWeightKg: number | null
totalNetWeightKg: number | null
location?: string | null
destinationType?: string | null
destinationAddress?: string | null
destinationPostalCode?: string | null
destinationContact?: string | null
destinationContactPhone?: string | null
measurementCompletedAt?: string | null
inboundCompletedAt?: string | null
measurementCompletedAt: string | null
inboundCompletedAt: string | null
associatedFiles?: string[] | null
markFiles?: string[] | null
pcOrderFiles?: string[] | null
expressCompany?: string | null
expressTrackingNo?: string | null
expressCompany: string | null
expressTrackingNo: string | null
skuNos: string[] | null
totalQuantity: number | null
shipmentId: string | null
referenceId: string | null
remark?: string | null
customerRemark?: string | null
latestInternalNote?: string | null
@@ -339,7 +341,11 @@ function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
actualGrossWeightKg: numberValue(raw.actualGrossWeightKg),
actualVolumeCbm: numberValue(raw.actualVolumeCbm),
totalPalletCount: nullableInteger(raw.totalPalletCount),
recommendedPalletType: raw.recommendedPalletType ?? null,
chargeableVolumeCbm: numberValue(raw.chargeableVolumeCbm),
volumetricWeightKg: numberValue(raw.volumetricWeightKg),
totalNetWeightKg: numberValue(raw.totalNetWeightKg),
totalQuantity: nullableInteger(raw.totalQuantity),
skuNos: raw.skuNos?.filter((value) => value.trim()) ?? null,
locationCodes: raw.locationCodes ?? raw.location ?? null,
measurement: raw.measurement ? {
...raw.measurement,
@@ -96,20 +96,29 @@ describe('UnifiedOrdersView contract', () => {
const columnSource = source.slice(source.indexOf('const columns ='), source.indexOf('function emptyFilters'))
const expectedColumnKeys = [
'actions', 'orderNo', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
'destinationWarehouseName', 'channelName', 'totalPackageCount', 'recommendedPalletType', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg',
'actualVolumeCbm', 'chargeableWeightKg', 'expectedShipDate', 'loadingStatus', 'containerNo', 'cabinetNo',
'destinationWarehouseName', 'channelName', 'totalPackageCount', 'totalPalletCount', 'requestedPickupAt', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg',
'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate',
'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo',
'shipmentId', 'referenceId', 'containerNo', 'cabinetNo',
'sailingAt', 'portArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
]
for (const key of expectedColumnKeys) expect(columnSource).toContain(`key: '${key}'`)
for (const uncontractedKey of [
'internalOrderStatus', 'expectedArrivalAt', 'feeReviewStatus', 'holdCargo', 'companyName',
'pickupMethod', 'chargeableVolumeCbm', 'associatedFiles', 'createdBy',
'internalOrderStatus', 'holdCargo', 'companyName', 'pickupMethod', 'associatedFiles', 'createdBy',
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'orderNo'"))
expect(columnSource.indexOf("key: 'orderNo'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'")
expect(columnSource).toContain("{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field', defaultVisible: false }")
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(10)
expect(columnSource).not.toContain("key: 'loadingStatus'")
expect(columnSource).not.toContain("key: 'businessDate'")
expect(columnSource).not.toContain("label: '业务日期'")
expect(columnSource).not.toContain("label: '配载状态'")
expect(columnSource).not.toContain("key: 'recommendedPalletType'")
expect(columnSource).toContain("key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间'")
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(16)
expect(source).toContain("property === 'requestedPickupAt' && (row.pickupMethod !== 'PICKUP' || !value)")
expect(source).toContain("property === 'skuNos' ? value.join('、')")
expect(source).not.toContain('<dt>推荐托盘类型</dt>')
expect(source).toContain('.table-card { overflow: hidden; }')
expect(source).toContain('slotName: \'measurementReviewStatus\'')
expect(source).toContain('unifiedOrderReviewStatusLabel(value, \'未录入\')')
@@ -246,14 +246,24 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'destinationWarehouseName', prop: 'destinationWarehouseName', label: '目的仓', minWidth: 150, slotName: 'field' },
{ key: 'channelName', prop: 'channelName', label: '渠道', minWidth: 140, slotName: 'field' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', minWidth: 100, slotName: 'field' },
{ key: 'recommendedPalletType', prop: 'recommendedPalletType', label: '推荐托盘类型', minWidth: 140, slotName: 'field', defaultVisible: false },
{ key: 'totalPalletCount', prop: 'totalPalletCount', label: '总托数', minWidth: 100, slotName: 'field' },
{ key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间', minWidth: 160, slotName: 'field' },
{ key: 'locationCodes', prop: 'locationCodes', label: '库位位置', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'measurementReviewStatus' },
{ key: 'actualGrossWeightKg', prop: 'actualGrossWeightKg', label: '实测毛重(kg)', minWidth: 130, slotName: 'field' },
{ key: 'actualVolumeCbm', prop: 'actualVolumeCbm', label: '实测体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'totalNetWeightKg', prop: 'totalNetWeightKg', label: '货物净重(kg)', minWidth: 130, slotName: 'field' },
{ key: 'chargeableWeightKg', prop: 'chargeableWeightKg', label: '计费毛重', minWidth: 120, slotName: 'field' },
{ key: 'chargeableVolumeCbm', prop: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 130, slotName: 'field' },
{ key: 'expectedShipDate', prop: 'expectedShipDate', label: '预计出运日期', minWidth: 150, slotName: 'field' },
{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field', defaultVisible: false },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'feeReviewStatus', prop: 'feeReviewStatus', label: '费用审核状态', minWidth: 140, slotName: 'field', defaultVisible: false },
{ key: 'skuNos', prop: 'skuNos', label: 'SKU 编码', minWidth: 180, slotName: 'field', defaultVisible: false },
{ key: 'totalQuantity', prop: 'totalQuantity', label: '货物总数量', minWidth: 120, slotName: 'field', defaultVisible: false },
{ key: 'expressCompany', prop: 'expressCompany', label: '快递公司', minWidth: 140, slotName: 'field', defaultVisible: false },
{ key: 'expressTrackingNo', prop: 'expressTrackingNo', label: '快递单号', minWidth: 170, slotName: 'field', defaultVisible: false },
{ key: 'shipmentId', prop: 'shipmentId', label: 'Shipment ID', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'referenceId', prop: 'referenceId', label: 'Reference ID', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'containerNo', prop: 'containerNo', label: '真实集装箱号', minWidth: 160, slotName: 'field', defaultVisible: false },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '内部柜次号', minWidth: 150, slotName: 'field', defaultVisible: false },
{ key: 'sailingAt', prop: 'sailingAt', label: '开船时间', minWidth: 150, slotName: 'field', defaultVisible: false },
@@ -365,7 +375,7 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) {
const source = row as unknown as Record<string, unknown>
const nested: Record<string, string> = {
measurementReviewStatus: 'measurement.reviewStatus', expectedArrivalAt: 'expectedArrivalAt',
loadingStatus: 'transport.loadingStatus', containerNo: 'transport.containerNo', cabinetNo: 'transport.cabinetNo',
containerNo: 'transport.containerNo', cabinetNo: 'transport.cabinetNo',
sailingAt: 'transport.sailingAt', portArrivalAt: 'transport.portArrivalAt',
chargeableWeightKg: 'measurement.chargeableWeightKg', measurementCompletedAt: 'measurement.completedAt',
expressTrackingNo: 'delivery.trackingNo',
@@ -375,15 +385,15 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) {
current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined
), source)
if ((value === null || value === undefined || value === '') && property === 'internalOrderStatus') value = source.mainStatus
if (property === 'requestedPickupAt' && (row.pickupMethod !== 'PICKUP' || !value)) return '—'
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'boolean') return value ? '是' : '否'
if (Array.isArray(value)) return value.length ? `${value.length} 个文件` : '—'
if (Array.isArray(value)) return value.length ? (property === 'skuNos' ? value.join('、') : `${value.length} 个文件`) : '—'
if (typeof value === 'number') return formatNumber(value)
if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value))
if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value))
if (property === 'internalOrderStatus') return orderStatusLabel(String(value))
if (property === 'feeReviewStatus') return feeReviewLabel(String(value))
if (property === 'loadingStatus') return unifiedOrderLoadingStatusLabel(String(value))
if (property === 'businessType') return firstLegBusinessTypeLabel(String(value))
if (property === 'pickupMethod') return firstLegPickupMethodLabel(String(value))
if (property === 'taxMode') return firstLegTaxModeLabel(String(value))
@@ -939,7 +949,6 @@ onBeforeUnmount(() => {
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(detail.order.order?.containerMode) }}</dd></div>
<div><dt>商检验</dt><dd>{{ displayBoolean(detail.order.order?.customsInspection) }}</dd></div>
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱</dd></div>
<div><dt>推荐托盘类型</dt><dd>{{ detail.summary.recommendedPalletType || '—' }}</dd></div>
<div><dt>总毛重</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'grossWeightKg') ?? detail.summary.measurement?.actualGrossWeightKg) }} kg</dd></div>
<div><dt>总净重</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'netWeightKg')) }} kg</dd></div>
<div><dt>总体积</dt><dd>{{ formatNumber(cargoTotal(detail.order.cargoes, 'volumeCbm') ?? detail.summary.measurement?.actualVolumeCbm, 3) }} m³</dd></div>
@@ -167,11 +167,14 @@ interface WarehouseRow extends Warehouse {
const rows = ref<WarehouseRow[]>([])
const warehouseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'warehouse', label: '仓库', minWidth: 190, slotName: 'warehouse' },
{ key: 'warehouse', label: '仓库名称', minWidth: 190, slotName: 'warehouse' },
{ key: 'code', prop: 'code', label: '仓库编码', minWidth: 145 },
{ key: 'warehouseType', label: '仓库类型', width: 120, slotName: 'warehouseType' },
{ key: 'countryCode', label: '国家/地区', width: 140, slotName: 'countryCode' },
{ key: 'timezone', prop: 'timezone', label: '时区', width: 110, defaultVisible: false },
{ key: 'city', label: '所在城市', minWidth: 140, slotName: 'city' },
{ key: 'province', prop: 'province', label: '州/省', minWidth: 130 },
{ key: 'city', prop: 'city', label: '城市', minWidth: 140 },
{ key: 'postalCode', prop: 'postalCode', label: '邮编', minWidth: 110 },
{ key: 'address', label: '地址', minWidth: 280, slotName: 'address', defaultVisible: false },
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact', defaultVisible: false },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
@@ -724,11 +727,10 @@ onMounted(() => void loadWarehouses())
</div>
<span v-else class="warehouse-subline">仅查看</span>
</template>
<template #warehouse="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong><small>{{ (row as WarehouseRow).code }}</small></div></div></template>
<template #warehouse="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong></div></div></template>
<template #warehouseType="{ row }"><el-tag effect="plain">{{ warehouseTypeLabels[(row as WarehouseRow).warehouseType] }}</el-tag></template>
<template #countryCode="{ row }">{{ formatCountryDisplay((row as WarehouseRow).countryCode) }}</template>
<template #city="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" /><span>{{ (row as WarehouseRow).address || '-' }}<small v-if="(row as WarehouseRow).postalCode">{{ (row as WarehouseRow).postalCode }}</small></span></div></template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" /><span>{{ (row as WarehouseRow).address || '-' }}</span></div></template>
<template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>