diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index 5547bf7b..c9800c8e 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -18,6 +18,22 @@ describe('finance api', () => { expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 }) }) + it('never serializes an empty customerId for optional finance list filters', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] }) + + await listFinanceFeePool('') + await listFinanceStatements(' ') + await listFinanceReceivableSettlements() + await listFinancePayableSettlements('') + + expect(get.mock.calls).toEqual([ + ['/platform/finance/v1/fee-pool', { params: { customerId: undefined, direction: undefined } }], + ['/platform/finance/v1/statements', { params: { customerId: undefined } }], + ['/platform/finance/v1/receivable-settlements', { params: { customerId: undefined } }], + ['/platform/finance/v1/payable-settlements', { params: { customerId: undefined } }], + ]) + }) + it('uses the formal G1 account list path and fixed page DTO', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } }) @@ -39,7 +55,7 @@ describe('finance api', () => { 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: [] } }) - .mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ poolItemId: 9, direction: 'PAYABLE', sourceType: 'CONTAINER_COST', sourceId: 81, sourceVersion: 2, containerId: 81, feeItemName: '拖车费', amount: 680.5, status: 'OPEN' }] }) + .mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ poolItemId: 9, customerId: 10001, direction: 'PAYABLE', sourceType: 'CONTAINER_COST', sourceId: 81, sourceVersion: 2, containerId: 81, feeItemName: '拖车费', amount: 680.5, status: 'OPEN' }] }) .mockResolvedValueOnce({ code: '200', message: 'ok', data: [] }) .mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 81, customerId: 10001, settlementNo: 'AR-81', receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, status: 'PARTIALLY_PAID', version: 2 }] }) .mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 71, customerId: 10001, settlementNo: 'AP-71', costType: 'CONTAINER_SHARED', containerId: 81, feeItemName: '拖车费', payableAmount: 680.5, paidAmount: 0, outstandingAmount: 680.5, status: 'UNPAID', version: 1 }] }) @@ -58,15 +74,15 @@ describe('finance api', () => { '/platform/finance/v1/payable-settlements', ]) expect(account).toMatchObject({ customerId: '10001', currency: 'CNY', accountMode: 'MANUAL_RECEIVABLE_LEDGER', outstandingAmount: 200 }) - expect(pool[0]).toMatchObject({ poolItemId: '9', sourceId: '81', sourceVersion: 2, containerId: '81', feeItemName: '拖车费', amount: 680.5 }) + expect(pool[0]).toMatchObject({ poolItemId: '9', customerId: '10001', sourceId: '81', sourceVersion: 2, containerId: '81', feeItemName: '拖车费', amount: 680.5 }) expect(receivables[0]).toMatchObject({ id: '81', settlementNo: 'AR-81', currency: 'CNY' }) expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', feeItemName: '拖车费', payableAmount: 680.5, currency: 'CNY' }) }) it('derives the numeric fee source id without treating manual costs as reviewable sources', async () => { vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [ - { poolItemId: 'RECEIVABLE:42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, feeItemName: '运费', amount: 100, status: 'AVAILABLE' }, - { poolItemId: 'PAYABLE:43', direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: 43, sourceVersion: 1, feeItemName: '拖车费', amount: 200, status: 'POSTED' }, + { poolItemId: 'RECEIVABLE:42', customerId: 10001, direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, feeItemName: '运费', amount: 100, status: 'AVAILABLE' }, + { poolItemId: 'PAYABLE:43', customerId: 10002, direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: 43, sourceVersion: 1, feeItemName: '拖车费', amount: 200, status: 'POSTED' }, ] }) const result = await listFinanceFeePool('10001') diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 6ab7c6c9..760863aa 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -64,12 +64,12 @@ export async function getFinanceCustomerAccount(customerId: string): Promise { - const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params: { customerId, direction } }) +export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } }) return unwrapApiResponse(response).map((item) => { const poolItemId = stringValue(item.poolItemId) const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null) - return { poolItemId, feeSourceId, direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) } + return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) } }) } @@ -179,18 +179,18 @@ export async function createFinanceSettlementPayment(settlementId: string, paylo return normalizeSettlementDetail(unwrapApiResponse(response)) } -export async function listFinanceStatements(customerId: string): Promise { - const response = await http.get, ApiEnvelope>(`${basePath}/statements`, { params: { customerId } }) +export async function listFinanceStatements(customerId?: string): Promise { + const response = await http.get, ApiEnvelope>(`${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) })) } -export async function listFinanceReceivableSettlements(customerId: string): Promise { - const response = await http.get, ApiEnvelope>(`${basePath}/receivable-settlements`, { params: { customerId } }) +export async function listFinanceReceivableSettlements(customerId?: string): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/receivable-settlements`, { params: { customerId: customerId?.trim() || undefined } }) return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), receivableAmount: numberValue(item.receivableAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceReceivableSettlement['status'], version: numberValue(item.version) })) } -export async function listFinancePayableSettlements(customerId: string): Promise { - const response = await http.get, ApiEnvelope>(`${basePath}/payable-settlements`, { params: { customerId } }) +export async function listFinancePayableSettlements(customerId?: string): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/payable-settlements`, { params: { customerId: customerId?.trim() || undefined } }) return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), costType: stringValue(item.costType) as FinancePayableSettlement['costType'], orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), supplierName: stringValue(item.supplierName), feeItemName: stringValue(item.feeItemName), payableAmount: numberValue(item.payableAmount), paidAmount: numberValue(item.paidAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt), version: numberValue(item.version) })) } diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts index e63a6c9d..d90579a1 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts @@ -65,7 +65,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', 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(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(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 }) @@ -84,6 +84,27 @@ describe('FinanceCustomerArapView G2 operations', () => { expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled() }) + it('loads all customers by default and keeps explicit customer filtering', async () => { + const wrapper = await mountView(['platform:finance:bill:view']) + expect(listFinanceFeePool).toHaveBeenCalledWith(undefined, undefined) + const vm = wrapper.vm as unknown as { load(model: Record): Promise } + await vm.load({ customerId: '10001', direction: 'RECEIVABLE', status: '', keyword: '' }) + expect(listFinanceFeePool).toHaveBeenLastCalledWith('10001', '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' }, + ]) + const wrapper = await mountView(['platform:finance:bill:view']) + expect(wrapper.text()).toContain('客户A') + expect(wrapper.text()).toContain('客户B') + }) + it('loads detail and submits review and settlement changes with the returned version', async () => { const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) await loadRows(wrapper) diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index 355b9d30..18a3e726 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -65,7 +65,7 @@ const canSettleSource = computed(() => access.can('platform:finance:fee-source:s const stateError = computed(() => customersError.value || loadError.value) const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) const queryFields = computed(() => [ - { key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }, + { 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: financeArapStatusOptions }, { key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } }, @@ -90,13 +90,12 @@ async function loadCustomers() { } async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); page.value = 1; allRows.value = []; loadError.value = null - const customerId = String(filters.value.customerId || ''); if (!customerId) return - const customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || `客户 ${customerId}` + 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, direction || undefined) - allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt })) + const items = await listFinanceFeePool(customerId || undefined, direction || undefined) + allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName: customers.value.find((customer) => String(customer.id) === item.customerId)?.tenantName || `客户 ${item.customerId}`, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt })) } catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') } finally { loading.value = false } } @@ -146,7 +145,7 @@ async function submitInclusion() { function statusLabel(status: string) { return financeStatusLabel(status) } function money(value: unknown) { return Number(value || 0).toFixed(2) } -onMounted(() => void loadCustomers()) +onMounted(async () => { await loadCustomers(); await load() })