diff --git a/src/app/layouts/AppLayout.vue b/src/app/layouts/AppLayout.vue index e204ee84..29c2e392 100644 --- a/src/app/layouts/AppLayout.vue +++ b/src/app/layouts/AppLayout.vue @@ -155,7 +155,9 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId) :aria-expanded="menuExpanded" @click="handleMenuToggle" > -

{{ pageTitle }}

{{ pageDescription }}

+
+ +
作为分摊费用 + + + + + diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index e4457278..ba891da5 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -1,12 +1,25 @@ 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, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance' +import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance' describe('finance api', () => { beforeEach(() => { vi.restoreAllMocks() }) + it('queries the dedicated monthly CNY receivable statistics read model', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { + statisticsMonth: '2026-07', currency: 'CNY', totalReceivable: '300.000000', totalReceived: '100.000000', + totalOutstanding: '200.000000', totalCustomers: 1, page: 1, pageSize: 20, + records: [{ customerId: '9007199254740993', customerName: '客户A', currency: 'CNY', totalReceivable: '300.000000', totalReceived: '100.000000', totalOutstanding: '200.000000' }], + } }) + + const result = await getReceivableStatistics({ month: '2026-07', customerId: '9007199254740993', page: 1, pageSize: 20 }) + + expect(get).toHaveBeenCalledWith('/platform/finance/v1/receivable-statistics', { params: { month: '2026-07', customerId: '9007199254740993', page: 1, pageSize: 20 } }) + expect(result).toMatchObject({ currency: 'CNY', totalReceivable: '300.000000', totalReceived: '100.000000', totalOutstanding: '200.000000', records: [{ customerId: '9007199254740993', customerName: '客户A' }] }) + }) + it('uses only the formal settlement customer summary and keeps money as decimal strings', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { customerId: '9007199254740993', currency: 'CNY', totalReceivable: '123456.780000', receivedAmount: '100000.000000', outstandingReceivable: '23456.780000', pendingBilling: '0.000000', outstandingPayable: '0.000000', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null } }) @@ -18,13 +31,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: [{ 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' }], + 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, customerConfirmationStatus: 'NOT_REQUIRED', 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', 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' }) + 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, customerConfirmationStatus: 'NOT_REQUIRED', createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' }) }) it('never serializes an empty customerId for optional finance list filters', async () => { @@ -64,16 +77,16 @@ describe('finance api', () => { 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 }], + 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', 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 ' }) + const result = await listFinanceFeeSources({ page: 2, size: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', 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', + page: 2, pageSize: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', 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 }] }) + 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', statementGenerated: false }] }) }) it('uses fixed read-model paths for all five finance pages', async () => { @@ -114,24 +127,24 @@ describe('finance api', () => { expect(result.map((item) => item.feeSourceId)).toEqual(['42', null]) }) - it('uses the formal G2 detail, review and settlement-inclusion contracts', async () => { - const detail = { feeSourceId: 42, sourceType: 'INBOUND_FEE', sourceFeeId: 81, sourceVersion: 3, direction: 'RECEIVABLE', customerId: 10001, customerName: '客户A', inboundId: 91, inboundCode: 'IB-91', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 4, operationLogs: [{ occurredAt: '2026-08-11T12:00:00', action: 'FEE_SOURCE_REVIEW_APPROVE', operatorName: '财务A', result: 'SUCCESS' }] } + it('uses the formal fee-source detail, review and edit contracts', async () => { + const detail = { feeSourceId: 42, sourceType: 'INBOUND_FEE', sourceFeeId: 81, sourceVersion: 3, direction: 'RECEIVABLE', customerId: 10001, customerName: '客户A', inboundId: 91, inboundCode: 'IB-91', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 4, operationLogs: [{ occurredAt: '2026-08-11T12:00:00', action: 'FEE_SOURCE_REVIEW_APPROVE', operatorName: '财务A', result: 'SUCCESS' }] } const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g2', traceId: 'trace-g2', data: detail }) const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail }) const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'success', data: detail }) const result = await getFinanceFeeSource('42') await reviewFinanceFeeSource('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }) - await updateFinanceFeeSourceSettlementInclusion('42', { included: true, reason: '本期结算', expectedVersion: 4 }) + await editFinanceFeeSource('42', { amount: 120, reason: '更正金额', expectedVersion: 4 }) expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42') expect(post).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/review', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } }) - expect(put).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/settlement-inclusion', { included: true, reason: '本期结算', expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } }) - expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4, currency: 'CNY' }) + expect(put).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42', { amount: 120, reason: '更正金额', expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } }) + expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', 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, 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 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: 'ISSUED', version: 4, customerConfirmationStatus: 'CONFIRMED', customerConfirmedBy: 17, customerConfirmedByName: '客户确认人', customerConfirmedAt: '2026-08-05T10:00:00', 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 }) @@ -144,6 +157,7 @@ describe('finance api', () => { 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({ voucherFileIds: ['9001'], createdBy: '9', createdByName: '收款登记人' }) + expect(loaded).toMatchObject({ customerConfirmationStatus: 'CONFIRMED', customerConfirmedBy: '17', customerConfirmedByName: '客户确认人', customerConfirmedAt: '2026-08-05T10:00:00' }) expect(loaded.revisions[0]).toMatchObject({ operatorId: '10', operatorName: '调整人' }) }) @@ -241,11 +255,4 @@ describe('finance api', () => { expect(post).toHaveBeenNthCalledWith(2, '/platform/finance/v1/bills/81/void', { expectedVersion: 3, reason: '重复账单' }, { headers: { 'Idempotency-Key': expect.any(String) } }) }) - it('issues a bill with optimistic version and an idempotency key', async () => { - const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} }) - - await issueFinanceBill('81001', 3) - - expect(post).toHaveBeenCalledWith('/platform/finance/v1/bills/81001/issue', { expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } }) - }) }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 22335fd4..6ce1c52f 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 { 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' +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, ReceivableStatisticsResult, ReconciliationActionPayload } from '../types' const basePath = '/platform/finance/v1' export interface FinanceCustomerAccountSummaryResource { @@ -18,7 +18,7 @@ export const financePhase1Capabilities = { accountLedger: true, feeSourceDetail: true, feeSourceReview: true, - feeSourceSettlementInclusion: true, + feeSourceEditReviewLifecycle: true, reconciliationCreate: true, reconciliationActions: true, receivableSettlementCreate: true, @@ -26,6 +26,27 @@ export const financePhase1Capabilities = { settlementDetail: true, settlementPayment: true, } as const + +export async function getReceivableStatistics(params: { month: string; customerId?: string; page?: number; pageSize?: number }): Promise { + const response = await http.get, ApiEnvelope>(`${basePath}/receivable-statistics`, { params: { + month: params.month, + customerId: params.customerId?.trim() || undefined, + page: params.page ?? 1, + pageSize: params.pageSize ?? 20, + } }) + const item = unwrapApiResponse(response) + return { + statisticsMonth: stringValue(item.statisticsMonth), currency: 'CNY', + totalReceivable: stringValue(item.totalReceivable), totalReceived: stringValue(item.totalReceived), + totalOutstanding: stringValue(item.totalOutstanding), totalCustomers: numberValue(item.totalCustomers), + page: numberValue(item.page), pageSize: numberValue(item.pageSize), + records: (Array.isArray(item.records) ? item.records : []).map((value) => { + const row = value as RawResource + return { customerId: stringValue(row.customerId), customerName: stringValue(row.customerName), currency: 'CNY' as const, + totalReceivable: stringValue(row.totalReceivable), totalReceived: stringValue(row.totalReceived), totalOutstanding: stringValue(row.totalOutstanding) } + }), + } +} function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` } type RawResource = Record function stringValue(value: unknown) { return value == null ? '' : String(value) } @@ -122,8 +143,7 @@ export async function listFinanceFeeSources(params: { customerId?: string direction?: 'RECEIVABLE' | 'PAYABLE' status?: string - reviewStatus?: 'PENDING' | 'APPROVED' | 'REJECTED' - settlementIncluded?: boolean + reviewStatus?: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED' statementGenerated?: boolean businessDateFrom?: string businessDateTo?: string @@ -132,7 +152,7 @@ export async function listFinanceFeeSources(params: { 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, + reviewStatus: params.reviewStatus || undefined, statementGenerated: params.statementGenerated, businessDateFrom: params.businessDateFrom || undefined, businessDateTo: params.businessDateTo || undefined, keyword: params.keyword?.trim() || undefined, } @@ -159,7 +179,7 @@ function normalizeFeeSourceSummary(item: RawResource): FinanceFeeSourceSummary { orderId: nullableString(item.orderId), 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 FinanceFeeSourceDetail['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded), + reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'], sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated), createdAt: stringValue(item.businessDate), } @@ -168,7 +188,7 @@ function normalizeFeeSourceSummary(item: RawResource): FinanceFeeSourceSummary { function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail { return { ...normalizeFeeSourceSummary(item), sourceFeeId: stringValue(item.sourceFeeId), - reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined, + reviewReason: stringValue(item.reviewReason) || undefined, 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 } }), } @@ -178,8 +198,20 @@ export async function reviewFinanceFeeSource(feeSourceId: string, payload: { act const response = await http.post, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}/review`, payload, mutationHeaders('fee-source-review')) return normalizeFeeSourceDetail(unwrapApiResponse(response)) } -export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: string, payload: { included: boolean; reason: string; expectedVersion: number }): Promise { - const response = await http.put, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`, payload, mutationHeaders('fee-source-settlement')) +export async function editFinanceFeeSource(feeSourceId: string, payload: { amount: number; reason: string; expectedVersion: number }): Promise { + const response = await http.put, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}`, payload, mutationHeaders('fee-source-edit')) + return normalizeFeeSourceDetail(unwrapApiResponse(response)) +} +export async function submitFinanceFeeSource(feeSourceId: string, payload: { reason: string; expectedVersion: number }): Promise { + const response = await http.post, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}/submit`, payload, mutationHeaders('fee-source-submit')) + return normalizeFeeSourceDetail(unwrapApiResponse(response)) +} +export async function adjustFinanceFeeSource(feeSourceId: string, payload: { amount: number; reason: string; expectedVersion: number }): Promise { + const response = await http.post, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}/adjust`, payload, mutationHeaders('fee-source-adjust')) + return normalizeFeeSourceDetail(unwrapApiResponse(response)) +} +export async function voidFinanceFeeSource(feeSourceId: string, payload: { reason: string; expectedVersion: number }): Promise { + const response = await http.post, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}/void`, payload, mutationHeaders('fee-source-void')) return normalizeFeeSourceDetail(unwrapApiResponse(response)) } function normalizeReconciliationDetail(item: RawResource): FinanceReconciliationDetail { @@ -197,7 +229,7 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation 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), 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, + 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), customerConfirmationStatus: stringValue(item.customerConfirmationStatus) as FinanceBillResource['customerConfirmationStatus'], customerConfirmedBy: stringValue(item.customerConfirmedBy) || undefined, customerConfirmedByName: stringValue(item.customerConfirmedByName) || undefined, customerConfirmedAt: stringValue(item.customerConfirmedAt) || undefined, 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), 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) } }), } @@ -223,7 +255,7 @@ function normalizeSettlementLine(item: RawResource): FinanceSettlementLine { 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), + amount: numberValue(item.amount), currency: 'CNY', reviewStatus: stringValue(item.reviewStatus) as FinanceSettlementLine['reviewStatus'], sourceStatus: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated), } } @@ -262,7 +294,7 @@ export async function actionFinanceSettlement(settlementId: string, payload: imp 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.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) })) + 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), customerConfirmationStatus: stringValue(item.customerConfirmationStatus) as FinanceStatementReadModel['customerConfirmationStatus'], customerConfirmedBy: stringValue(item.customerConfirmedBy) || undefined, customerConfirmedByName: stringValue(item.customerConfirmedByName) || undefined, customerConfirmedAt: stringValue(item.customerConfirmedAt) || undefined, 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 { @@ -306,11 +338,6 @@ export async function updateFinanceCustomerTerms(customerId: string, payload: { return normalizeCustomerTerms(unwrapApiResponse(response), customerId) } -export async function createFinanceBill(payload: Record) { - const response = await http.post, ApiEnvelope>(`${basePath}/bills`, { ...payload }, mutationHeaders('bill')) - return unwrapApiResponse(response) -} - export async function getFinanceBill(billId: string) { const response = await http.get, ApiEnvelope>(`${basePath}/bills/${billId}`) return normalizeBillDetail(unwrapApiResponse(response)) @@ -369,8 +396,3 @@ export async function createFinanceCost(payload: Record, vouche const response = await http.post, ApiEnvelope>(`${basePath}/cost-entries`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('cost') } }) return unwrapApiResponse(response) } - -export async function issueFinanceBill(billId: string, expectedVersion: number) { - const response = await http.post, ApiEnvelope>(`${basePath}/bills/${billId}/issue`, { expectedVersion }, mutationHeaders('bill-issue')) - return unwrapApiResponse(response) -} diff --git a/src/modules/finance/financeDelivery.test.ts b/src/modules/finance/financeDelivery.test.ts index bf11e017..8b994e0c 100644 --- a/src/modules/finance/financeDelivery.test.ts +++ b/src/modules/finance/financeDelivery.test.ts @@ -66,22 +66,24 @@ describe('finance five-page delivery contract', () => { for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state) }) - it('connects G2 fee sources to real detail, review and settlement operations', () => { + it('connects fee sources to the real edit and review lifecycle', () => { const page = source('src/modules/finance/pages/FinanceCustomerArapView.vue') for (const marker of [ '`${basePath}/fee-sources/${feeSourceId}`', '`${basePath}/fee-sources/${feeSourceId}/review`', - '`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`', + '`${basePath}/fee-sources/${feeSourceId}/submit`', + '`${basePath}/fee-sources/${feeSourceId}/adjust`', + '`${basePath}/fee-sources/${feeSourceId}/void`', ]) expect(api).toContain(marker) - for (const operation of ['getFinanceFeeSource', 'reviewFinanceFeeSource', 'updateFinanceFeeSourceSettlementInclusion']) expect(page).toContain(operation) - for (const permission of ['platform:finance:customer-arap:list', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) expect(page).toContain(permission) + for (const operation of ['getFinanceFeeSource', 'reviewFinanceFeeSource', 'editFinanceFeeSource', 'submitFinanceFeeSource', 'adjustFinanceFeeSource', 'voidFinanceFeeSource']) expect(page).toContain(operation) + for (const permission of ['platform:finance:customer-arap:list', 'platform:finance:fee-source:review', 'platform:finance:fee-source:edit']) expect(page).toContain(permission) expect(page).toContain('expectedVersion: detail.value.operationVersion') const columns = page.slice(page.indexOf('const columns:')) expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'direction'")) expect(page).toContain('') + expect(page).toContain('') expect(page).not.toContain(' ({ listFinanceFeeSources: vi.fn(), getFinanceFeeSource: vi.fn(), reviewFinanceFeeSource: vi.fn(), - updateFinanceFeeSourceSettlementInclusion: vi.fn(), + editFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(), createReconciliationStatement: vi.fn(), })) @@ -44,14 +44,14 @@ 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, + businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', 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, + feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-11', } @@ -92,7 +92,10 @@ describe('FinanceCustomerArapView G2 operations', () => { 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 }) + vi.mocked(editFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'DRAFT', operationVersion: 4 }) + vi.mocked(submitFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'PENDING', operationVersion: 4 }) + vi.mocked(adjustFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'DRAFT', operationVersion: 4 }) + vi.mocked(voidFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, sourceStatus: 'VOIDED', status: 'VOIDED', operationVersion: 4 }) vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never) vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-3', taskNo: 'EXP-3', taskName: '客户应收应付费用来源', status: 'PENDING' } as never) vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false) @@ -109,7 +112,7 @@ describe('FinanceCustomerArapView G2 operations', () => { await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' }) expect(getFinanceFeeSource).toHaveBeenCalledWith('42') expect(reviewFinanceFeeSource).not.toHaveBeenCalled() - expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled() + expect(editFinanceFeeSource).not.toHaveBeenCalled() }) it('loads all customers by default and keeps explicit customer filtering', async () => { @@ -136,17 +139,14 @@ describe('FinanceCustomerArapView G2 operations', () => { 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:customer-arap:list', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) + it('loads detail and submits review with the returned version', async () => { + const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:fee-source:review']) await loadRows(wrapper) const vm = wrapper.vm as unknown as { openDetail(row: Record): Promise openReview(row: Record): Promise submitReview(): Promise - openInclusion(row: Record): Promise - submitInclusion(): Promise reviewForm: { action: 'APPROVE' | 'REJECT'; reason: string } - inclusionForm: { included: boolean; reason: string } } const row = { id: 'RECEIVABLE:42', feeSourceId: '42' } @@ -158,9 +158,6 @@ describe('FinanceCustomerArapView G2 operations', () => { await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview() expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }) - - await vm.openInclusion(row); vm.inclusionForm.included = true; vm.inclusionForm.reason = '本期结算'; await vm.submitInclusion() - expect(updateFinanceFeeSourceSettlementInclusion).toHaveBeenCalledWith('42', { included: true, reason: '本期结算', expectedVersion: 3 }) }) it('renders request and trace ids when detail loading fails', async () => { @@ -211,8 +208,8 @@ describe('FinanceCustomerArapView G2 operations', () => { it('renders all permitted fee-source row operations as small plain primary buttons with icons', () => { const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8') expect(page).toContain('详情') - expect(page).toContain('审核') - expect(page).toContain('参与结算') + expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"") + expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"") expect(page).not.toMatch(/