Release/test 合并上生产 #2
@@ -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>
|
||||
|
||||
@@ -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('—')
|
||||
})
|
||||
})
|
||||
@@ -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}'`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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="暂无符合条件的费用项目">
|
||||
|
||||
@@ -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: '调整人' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,7 @@ const role: PlatformRoleResource = {
|
||||
roleType: 'SYSTEM',
|
||||
status: 'ACTIVE',
|
||||
description: '企业资料与入驻审核',
|
||||
memberCount: 3,
|
||||
permissionIds: ['5100010'],
|
||||
}
|
||||
|
||||
|
||||
@@ -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="平台权限资源列表"')
|
||||
})
|
||||
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 } }],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user