diff --git a/src/modules/customers/pages/CustomerDetailView.test.ts b/src/modules/customers/pages/CustomerDetailView.test.ts index b1ec158a..f27fc77e 100644 --- a/src/modules/customers/pages/CustomerDetailView.test.ts +++ b/src/modules/customers/pages/CustomerDetailView.test.ts @@ -134,6 +134,24 @@ describe('CustomerDetailView', () => { expect(document.body.textContent).not.toContain('计费规则尚未接入') }) + it('does not expose unknown order status codes', async () => { + const result = await listPlatformFirstLegOrders({ tenantId: String(tenant.id), page: 1, size: 10 }) + vi.mocked(listPlatformFirstLegOrders).mockResolvedValueOnce({ + ...result, + records: result.records.map((order) => ({ ...order, status: 'LEGACY_STATUS' as PlatformFirstLegOrderResource['status'] })), + }) + + mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + const ordersTab = [...document.querySelectorAll('.detail-tabs button')] + .find((button) => button.textContent?.trim() === '运单记录') + ordersTab?.click() + await flushPromises() + + expect(document.body.textContent).toContain('未知状态') + expect(document.body.textContent).not.toContain('LEGACY_STATUS') + }) + it('opens an authenticated qualification preview', async () => { mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) await flushPromises() diff --git a/src/modules/customers/pages/CustomerDetailView.vue b/src/modules/customers/pages/CustomerDetailView.vue index 84766e78..8fff3977 100644 --- a/src/modules/customers/pages/CustomerDetailView.vue +++ b/src/modules/customers/pages/CustomerDetailView.vue @@ -108,7 +108,7 @@ function statusMeta(status: PlatformTenantStatus) { return { label: '已停用', type: 'info' as const } } function qualificationLabel(type: string) { - return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record)[type] || type + return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record)[type] || '未知资质类型' } function qualificationStatus(status: PlatformTenantQualification['status']) { if (status === 'APPROVED') return { label: '已通过', type: 'success' as const } @@ -121,7 +121,7 @@ function qualificationName(item: PlatformTenantQualification) { catch { return `${qualificationLabel(item.qualificationType)}文件` } } function orderStatusLabel(status: PlatformFirstLegOrderStatus) { - return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record)[status] || status + return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record)[status] || '未知状态' } function transportLabel(order: PlatformFirstLegOrderResource) { if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL' diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index 4b08f21d..5547bf7b 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, 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, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance' describe('finance api', () => { beforeEach(() => { @@ -90,6 +90,39 @@ describe('finance api', () => { expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4, currency: 'CNY' }) }) + 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, lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, feeItemName: '运费', statementAmount: 100 }], paymentHistory: [], revisions: [] } + 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 }) + + const created = await createReconciliationStatement({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: '2026-08-01', periodEnd: '2026-08-31' }) + const loaded = await getReconciliationStatement('501') + await actionReconciliationStatement('501', { action: 'SUBMIT', expectedVersion: 0 }) + + expect(post.mock.calls[0]).toEqual(['/platform/finance/v1/statements', expect.objectContaining({ customerId: '10001', currency: 'CNY', generationMode: 'BY_CUTOFF' }), { headers: { 'Idempotency-Key': expect.any(String) } }]) + 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, lines: [{ statementLineId: '601', sourceFeeId: '42', amount: 100 }] }) + expect(loaded.revisions).toEqual([]) + }) + + it('uses the frozen G4 settlement create, detail and payment contracts', async () => { + const detail = { summary: { settlementId: 701, settlementNo: 'AP-701', customerId: 10001, supplierId: 81, reconciliationStatementId: 501, direction: 'PAYABLE', totalAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 0 }, lines: [], transactions: [], revisions: [] } + 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 }) + + await createReceivableSettlement({ reconciliationStatementId: '501', currency: 'CNY' }) + await createPayableSettlement({ customerId: '10001', supplierId: '81', feeSourceIds: ['42'], dueDate: '2026-09-10', currency: 'CNY' }) + const loaded = await getFinanceSettlement('701') + await createFinanceSettlementPayment('701', { transactionDate: '2026-08-11', amount: 200, currency: 'CNY', method: 'BANK_TRANSFER', voucherFileIds: ['9001'], expectedVersion: 0 }) + + expect(post.mock.calls[0]?.[0]).toBe('/platform/finance/v1/receivable-settlements') + expect(post.mock.calls[1]?.[0]).toBe('/platform/finance/v1/payable-settlements') + expect(get).toHaveBeenCalledWith('/platform/finance/v1/settlements/701') + expect(post.mock.calls[2]).toEqual(['/platform/finance/v1/settlements/701/transactions', expect.objectContaining({ amount: 200, voucherFileIds: ['9001'], expectedVersion: 0 }), { headers: { 'Idempotency-Key': expect.any(String) } }]) + expect(loaded).toMatchObject({ id: '701', settlementNo: 'AP-701', customerId: '10001', supplierId: '81', reconciliationStatementId: '501', direction: 'PAYABLE', amount: 200, status: 'ISSUED' }) + }) + it('keeps receipt and cost commands on CNY with real voucher ids', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 66956641..6ab7c6c9 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -1,5 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' -import type { FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource } from '../types' +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' const basePath = '/platform/finance/v1' export const financePhase1Capabilities = { @@ -8,20 +8,15 @@ export const financePhase1Capabilities = { feeSourceDetail: true, feeSourceReview: true, feeSourceSettlementInclusion: true, - reconciliationCreate: false, - reconciliationActions: false, - receivableSettlementCreate: false, - payableSettlementCreate: false, - settlementDetail: false, - settlementPayment: false, + reconciliationCreate: true, + reconciliationActions: true, + receivableSettlementCreate: true, + payableSettlementCreate: true, + settlementDetail: true, + settlementPayment: true, } as const function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` } type RawResource = Record -export class FinanceCapabilityBlockedError extends Error { - readonly code = 'FINANCE_NOT_IMPLEMENTED' - constructor(operation: string) { super(`接口尚未提供,暂不可操作:${operation}`); this.name = 'FinanceCapabilityBlockedError' } -} - function stringValue(value: unknown) { return value == null ? '' : String(value) } function numberValue(value: unknown) { return Number(value ?? 0) } function jsonRecord(value: unknown): RawResource { @@ -46,6 +41,7 @@ function normalizeBill(item: RawResource, customerId = stringValue(item.tenant_i } function normalizeBillLine(item: RawResource): FinanceBillLine { return { + statementLineId: stringValue(item.id ?? item.statementLineId), sourceFeeId: stringValue(item.source_id ?? item.sourceId ?? item.sourceFeeId), sourceFeeVersion: numberValue(item.source_version ?? item.sourceVersion ?? item.sourceFeeVersion) || 1, orderId: item.order_id == null && item.orderId == null ? null : stringValue(item.order_id ?? item.orderId), inboundId: item.inbound_id == null && item.inboundId == null ? null : stringValue(item.inbound_id ?? item.inboundId), @@ -129,12 +125,59 @@ export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: str const response = await http.put, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`, payload, mutationHeaders('fee-source-settlement')) return normalizeFeeSourceDetail(unwrapApiResponse(response)) } -export async function createReconciliationStatement(payload: Record) { throw new FinanceCapabilityBlockedError('createReconciliationStatement') } -export async function actionReconciliationStatement(statementId: string, payload: Record) { throw new FinanceCapabilityBlockedError('actionReconciliationStatement') } -export async function createReceivableSettlement(payload: Record) { throw new FinanceCapabilityBlockedError('createReceivableSettlement') } -export async function createPayableSettlement(payload: Record) { throw new FinanceCapabilityBlockedError('createPayableSettlement') } -export async function getFinanceSettlement(settlementId: string): Promise { throw new FinanceCapabilityBlockedError('getSettlement') } -export async function createFinanceSettlementPayment(settlementId: string, payload: Record) { throw new FinanceCapabilityBlockedError('createSettlementPayment') } +function normalizeReconciliationDetail(item: RawResource): FinanceReconciliationDetail { + const lines = (Array.isArray(item.lines) ? item.lines : []).map((entry) => { + const row = entry as RawResource + return { statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), feeItemName: stringValue(row.feeItemName), amount: numberValue(row.statementAmount) } + }) + return { + 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), 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), 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), occurredAt: stringValue(row.occurredAt) } }), + } +} + +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), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), 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)), + 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), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], 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, occurredAt: stringValue(row.occurredAt) } }), + } +} + +export async function createReconciliationStatement(payload: CreateReconciliationPayload) { + const response = await http.post, ApiEnvelope>(`${basePath}/statements`, payload, mutationHeaders('reconciliation-create')) + return normalizeReconciliationDetail(unwrapApiResponse(response)) +} +export async function actionReconciliationStatement(statementId: string, payload: ReconciliationActionPayload) { + const response = await http.post, ApiEnvelope>(`${basePath}/reconciliation-statements/${statementId}/actions`, payload, mutationHeaders('reconciliation-action')) + return normalizeReconciliationDetail(unwrapApiResponse(response)) +} +export async function getReconciliationStatement(statementId: string): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/reconciliation-statements/${statementId}`) + return normalizeReconciliationDetail(unwrapApiResponse(response)) +} +export async function createReceivableSettlement(payload: CreateReceivableSettlementPayload) { + const response = await http.post, ApiEnvelope>(`${basePath}/receivable-settlements`, payload, mutationHeaders('settlement-ar-create')) + return normalizeSettlementDetail(unwrapApiResponse(response)) +} +export async function createPayableSettlement(payload: CreatePayableSettlementPayload) { + const response = await http.post, ApiEnvelope>(`${basePath}/payable-settlements`, payload, mutationHeaders('settlement-ap-create')) + return normalizeSettlementDetail(unwrapApiResponse(response)) +} +export async function getFinanceSettlement(settlementId: string): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/settlements/${settlementId}`) + return normalizeSettlementDetail(unwrapApiResponse(response)) +} +export async function createFinanceSettlementPayment(settlementId: string, payload: CreateSettlementPaymentPayload) { + const response = await http.post, ApiEnvelope>(`${basePath}/settlements/${settlementId}/transactions`, payload, mutationHeaders('settlement-payment')) + return normalizeSettlementDetail(unwrapApiResponse(response)) +} export async function listFinanceStatements(customerId: string): Promise { const response = await http.get, ApiEnvelope>(`${basePath}/statements`, { params: { customerId } }) diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index 70122f56..1497f2d9 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -1,27 +1,215 @@ + - + + diff --git a/src/modules/finance/pages/FinanceFormalSettlementPage.vue b/src/modules/finance/pages/FinanceFormalSettlementPage.vue index f643e1cd..a7014e91 100644 --- a/src/modules/finance/pages/FinanceFormalSettlementPage.vue +++ b/src/modules/finance/pages/FinanceFormalSettlementPage.vue @@ -1,19 +1,39 @@ + - + + diff --git a/src/modules/finance/pages/FinanceG3G4Operations.test.ts b/src/modules/finance/pages/FinanceG3G4Operations.test.ts new file mode 100644 index 00000000..962123df --- /dev/null +++ b/src/modules/finance/pages/FinanceG3G4Operations.test.ts @@ -0,0 +1,143 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' +import { + actionReconciliationStatement, + createFinanceReceipt, + getFinanceSettlement, + getReconciliationStatement, + listFinancePayableSettlements, + listFinanceReceivableSettlements, + uploadFinanceVoucher, +} from '../api/finance' +import type { FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel } from '../types' +import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue' +import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue' + +vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) +vi.mock('../api/finance', () => ({ + actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(), + createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), createReconciliationStatement: vi.fn(), + getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceFeePool: vi.fn(), + listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), listFinanceStatements: vi.fn(), + uploadFinanceVoucher: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), + } +} + +function piniaWithPermissions(permissions: string[]) { + const pinia = createPinia() + setActivePinia(pinia) + const auth = useAuthStore() + auth.token = 'token' + auth.permissions = permissions + useAccessPreviewStore().initializeAfterLogin() + return pinia +} + +const statement: FinanceStatementReadModel = { + id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', + dueDate: '2026-09-30', totalAmount: 300, receivedAmount: 0, outstandingAmount: 300, + currency: 'CNY', status: 'ISSUED', version: 3, +} + +const reconciliation: FinanceReconciliationDetail = { + ...statement, paidAmount: 0, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, + remark: '', lines: [ + { statementLineId: '601', sourceFeeId: '41', sourceFeeVersion: 1, orderId: null, inboundId: '91', feeItemName: '运费', amount: 100 }, + { statementLineId: '602', sourceFeeId: '42', sourceFeeVersion: 1, orderId: null, inboundId: '92', feeItemName: '操作费', amount: 200 }, + ], paymentHistory: [], revisions: [], +} + +const receivable: FinanceSettlementDetail = { + id: '701', settlementNo: 'AR-701', customerId: '10001', reconciliationStatementId: '501', direction: 'RECEIVABLE', + amount: 300, paidAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 0, + lines: [], transactions: [], revisions: [], +} + +enableAutoUnmount(afterEach) +afterAll(() => vi.unstubAllGlobals()) + +describe('finance G3/G4 page operations', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('localStorage', createMemoryStorage()) + vi.stubGlobal('sessionStorage', createMemoryStorage()) + 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(actionReconciliationStatement).mockReset().mockResolvedValue(reconciliation) + vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(reconciliation) + vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(receivable) + vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([]) + vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([]) + vi.mocked(uploadFinanceVoucher).mockReset().mockResolvedValue({ fileId: '9001', customerId: '10001', fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 100 }) + vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 }) + }) + + it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => { + const pinia = piniaWithPermissions(['platform:finance:bill:view', 'platform:finance:reconciliation:adjust']) + const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + const vm = wrapper.vm as unknown as { + openAdjustment(row: FinanceStatementReadModel): Promise + submitAdjustment(): Promise + adjustReason: string + adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> + } + + await vm.openAdjustment(statement) + vm.adjustReason = '账期复核调整' + vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正' + vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正' + await vm.submitAdjustment() + + expect(getReconciliationStatement).toHaveBeenCalledWith('501') + expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { + action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3, + adjustmentLines: [ + { statementLineId: '601', adjustedAmount: 90, reason: '运费更正' }, + { statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' }, + ], + }) + }) + + it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => { + const pinia = piniaWithPermissions(['platform:finance:settlement-ar:view', 'platform:finance:receipt:create']) + const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + const vm = wrapper.vm as unknown as { + openReceipt(row: Record): Promise + submitReceipt(): Promise + receiptFile: File | null + receiptForm: { receivedDate: string; amount: number; paymentMethod: 'BANK_TRANSFER'; remark: string } + } + + await vm.openReceipt({ id: '701' }) + vm.receiptFile = new File(['receipt'], 'receipt.png', { type: 'image/png' }) + vm.receiptForm.receivedDate = '2026-08-11'; vm.receiptForm.amount = 300; vm.receiptForm.remark = '全额到账' + await vm.submitReceipt() + + expect(uploadFinanceVoucher).toHaveBeenCalledWith('10001', vm.receiptFile) + expect(createFinanceReceipt).toHaveBeenCalledWith({ + customerId: '10001', receivedDate: '2026-08-11', amount: 300, paymentMethod: 'BANK_TRANSFER', remark: '全额到账', + allocations: [{ billId: '501', amount: 300 }], + }, ['9001']) + }) +}) diff --git a/src/modules/finance/pages/FinanceG3G4Views.test.ts b/src/modules/finance/pages/FinanceG3G4Views.test.ts new file mode 100644 index 00000000..1053f491 --- /dev/null +++ b/src/modules/finance/pages/FinanceG3G4Views.test.ts @@ -0,0 +1,127 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' +import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data' +import { ApiResponseError } from '@/shared/api/http' +import { + actionReconciliationStatement, + createFinanceSettlementPayment, + createPayableSettlement, + createReceivableSettlement, + getFinanceSettlement, + getReconciliationStatement, + listFinanceFeePool, + listFinancePayableSettlements, + listFinanceReceivableSettlements, + listFinanceStatements, + uploadFinanceVoucher, +} from '../api/finance' +import type { FinancePayableSettlement, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types' +import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue' +import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue' + +vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) +vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() })) +vi.mock('../api/finance', () => ({ + actionReconciliationStatement: vi.fn(), createFinanceSettlementPayment: vi.fn(), createPayableSettlement: vi.fn(), + createReceivableSettlement: vi.fn(), getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(), + listFinanceFeePool: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), + listFinanceStatements: vi.fn(), uploadFinanceVoucher: vi.fn(), createReconciliationStatement: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } +function memoryStorage(): Storage { + const values = new Map() + return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } +} + +const statement: FinanceStatementReadModel = { id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '2026-09-30', totalAmount: 300, receivedAmount: 0, outstandingAmount: 300, currency: 'CNY', status: 'ISSUED', version: 3 } +const statementDetail: FinanceReconciliationDetail = { + ...statement, paidAmount: 0, remark: '', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, paymentHistory: [], revisions: [], + lines: [ + { statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 1, orderId: '11', inboundId: '21', feeItemName: '运费', amount: 100 }, + { statementLineId: '602', sourceFeeId: '82', sourceFeeVersion: 1, orderId: '12', inboundId: '22', feeItemName: '操作费', amount: 200 }, + ], +} +const payable: FinancePayableSettlement = { id: '701', customerId: '10001', settlementNo: 'AP-701', costType: 'SINGLE_ORDER', orderId: '11', inboundId: null, containerId: null, supplierName: '供应商A', feeItemName: '拖车费', payableAmount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', createdAt: '2026-08-11', version: 4 } +const settlementDetail = { id: '701', settlementNo: 'AP-701', customerId: '10001', supplierId: '301', direction: 'PAYABLE' as const, amount: 200, paidAmount: 0, outstandingAmount: 200, currency: 'CNY' as const, status: 'ISSUED', version: 4, lines: [], transactions: [], revisions: [] } + +async function mountWithAccess(component: typeof FinanceCustomerStatementsView | typeof FinanceFormalSettlementPage, permissions: string[], props: Record = {}) { + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions + useAccessPreviewStore().initializeAfterLogin() + const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + return wrapper +} + +enableAutoUnmount(afterEach) +afterAll(() => vi.unstubAllGlobals()) + +describe('Finance G3/G4 views', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('localStorage', memoryStorage()) + vi.stubGlobal('sessionStorage', memoryStorage()) + 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(listPlatformPartners).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', dataScopeId: '0', partnerCode: 'SUP-301', partnerName: '供应商A', partnerType: 'SUPPLIER', types: ['SUPPLIER'], contactName: '', contactPhone: '', contactEmail: null, countryCode: 'CN', province: null, city: '', district: null, addressLine: '', postalCode: null, status: 'ACTIVE', remark: null } as PlatformPartnerResource], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement]) + vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail) + vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail) + vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'PAYABLE:81', feeSourceId: '81', direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 1, orderId: '11', inboundId: '21', containerId: null, feeItemName: '拖车费', amount: 200, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }]) + vi.mocked(listFinancePayableSettlements).mockReset().mockResolvedValue([payable]) + vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([]) + vi.mocked(createPayableSettlement).mockReset().mockResolvedValue(settlementDetail) + vi.mocked(createReceivableSettlement).mockReset().mockResolvedValue({ ...settlementDetail, id: '801', settlementNo: 'AR-801', direction: 'RECEIVABLE', supplierId: undefined }) + vi.mocked(uploadFinanceVoucher).mockReset().mockResolvedValue({ fileId: '9001', customerId: '10001', fileName: 'voucher.png', contentType: 'image/png', sizeBytes: 1 }) + vi.mocked(createFinanceSettlementPayment).mockReset().mockResolvedValue({ ...settlementDetail, paidAmount: 100, outstandingAmount: 100, status: 'PARTIALLY_PAID', version: 5 }) + }) + + it('hides G3/G4 commands when only legacy bill view permission is present', async () => { + const statements = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:bill:view']) + const settlements = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:bill:view'], { kind: 'payable' }) + expect(statements.text()).not.toContain('生成对账单') + expect(settlements.text()).toContain('无权访问结算单应付') + expect(createPayableSettlement).not.toHaveBeenCalled() + expect(actionReconciliationStatement).not.toHaveBeenCalled() + }) + + it('submits every selected adjustment line with the detail version', async () => { + const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:bill:view', 'platform:finance:reconciliation:adjust']) + const vm = wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise; submitAdjustment(): Promise; adjustReason: string; adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> } + await vm.openAdjustment(statement) + vm.adjustReason = '月末核对更正' + vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` }) + await vm.submitAdjustment() + expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] }) + }) + + it('creates a payable settlement and registers a voucher-backed payment', async () => { + const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:view', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' }) + const vm = wrapper.vm as unknown as { load(model: Record): Promise; openCreate(): Promise; submitCreate(): Promise; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise; createForm: { supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } } + await vm.load({ customerId: '10001' }); await vm.openCreate() + expect(listPlatformPartners).toHaveBeenCalledWith({ page: 1, size: 200, type: 'SUPPLIER', status: 'ACTIVE' }) + expect(document.body.textContent).toContain('供应商A · SUP-301') + Object.assign(vm.createForm, { supplierId: '301', dueDate: '2026-09-30', feeSourceIds: ['81'] }) + await vm.submitCreate() + expect(createPayableSettlement).toHaveBeenCalledWith({ customerId: '10001', supplierId: '301', feeSourceIds: ['81'], dueDate: '2026-09-30', currency: 'CNY' }) + vm.openPayment(payable); vm.paymentFile = new File(['x'], 'voucher.png', { type: 'image/png' }); vm.paymentForm.amount = 100 + await vm.submitPayment() + expect(createFinanceSettlementPayment).toHaveBeenCalledWith('701', expect.objectContaining({ amount: 100, voucherFileIds: ['9001'], expectedVersion: 4 })) + }) + + it('renders request and trace ids when G3 detail loading fails', async () => { + vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new ApiResponseError('对账单不可用', 'FIN_404', 404, undefined, 'req-g3-error', 'trace-g3-error')) + const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:bill:view']) + const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise } + await vm.openDetail(statement); await flushPromises() + expect(document.body.textContent).toContain('req-g3-error') + expect(document.body.textContent).toContain('trace-g3-error') + }) +}) diff --git a/src/modules/finance/pages/FinancePresentation.test.ts b/src/modules/finance/pages/FinancePresentation.test.ts index 0e6f0fe8..809efc74 100644 --- a/src/modules/finance/pages/FinancePresentation.test.ts +++ b/src/modules/finance/pages/FinancePresentation.test.ts @@ -9,7 +9,7 @@ const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/Fi describe('finance presentation governance', () => { it('shows customer names instead of internal customer IDs in formal settlements', () => { - expect(settlement).toContain('customerName(selected.customerId)') + expect(settlement).toContain('customerName(detail.customerId)') expect(settlement).toContain('activeCustomers.value.map') expect(settlement).not.toContain('{{ selected.customerId }}') }) @@ -30,10 +30,15 @@ describe('finance presentation governance', () => { expect(manual).not.toContain('凭证 fileId:') }) - it('hides unavailable generation controls instead of rendering disabled buttons', () => { + it('exposes frozen G3 controls without unavailable placeholders', () => { expect(settlement).not.toContain('生成{{ title }}') expect(settlement).not.toContain('接口尚未提供,暂不可操作') - expect(statements).not.toContain('生成对账单') + expect(statements).toContain('生成对账单') + expect(statements).toContain('createReconciliationStatement') + expect(statements).toContain('actionReconciliationStatement') + expect(statements).toContain('