This commit is contained in:
2026-08-11 19:35:28 +08:00
parent 886c7d3529
commit c690a30422
48 changed files with 935 additions and 171 deletions
+28 -1
View File
@@ -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, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
@@ -63,6 +63,33 @@ describe('finance api', () => {
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' },
] })
const result = await listFinanceFeePool('10001')
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' }] }
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 })
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' })
})
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: {} })
+37 -7
View File
@@ -5,9 +5,9 @@ const basePath = '/platform/finance/v1'
export const financePhase1Capabilities = {
accountList: true,
accountLedger: true,
feeSourceDetail: false,
feeSourceReview: false,
feeSourceSettlementInclusion: false,
feeSourceDetail: true,
feeSourceReview: true,
feeSourceSettlementInclusion: true,
reconciliationCreate: false,
reconciliationActions: false,
receivableSettlementCreate: false,
@@ -70,7 +70,11 @@ export async function getFinanceCustomerAccount(customerId: string): Promise<Fin
export async function listFinanceFeePool(customerId: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise<FinanceFeePoolItem[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params: { customerId, direction } })
return unwrapApiResponse(response).map((item) => ({ poolItemId: stringValue(item.poolItemId), 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 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) }
})
}
export async function listFinanceAccounts(params: { page?: number; size?: number; keyword?: string; status?: string } = {}): Promise<{ records: FinanceAccountSummary[]; total: number; pages: number }> {
@@ -94,11 +98,37 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
}
export async function getFinanceFeeSource(feeSourceId: string): Promise<FinanceFeeSourceDetail> {
throw new FinanceCapabilityBlockedError('getFinanceFeeSource')
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}`)
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('reviewFinanceFeeSource') }
export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: string, payload: { included: boolean; reason: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('updateFeeSourceSettlementInclusion') }
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
const nullableString = (value: unknown) => value == null ? null : stringValue(value)
const nullableNumber = (value: unknown) => value == null ? null : numberValue(value)
return {
poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId),
sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceFeeId), sourceFeeId: stringValue(item.sourceFeeId),
sourceVersion: numberValue(item.sourceVersion), direction: stringValue(item.direction) as FinanceFeeSourceDetail['direction'],
customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName),
orderId: nullableString(item.orderId), inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode),
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),
sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined,
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion), createdAt: stringValue(item.businessDate),
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 } }),
}
}
export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${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<FinanceFeeSourceDetail> {
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`, payload, mutationHeaders('fee-source-settlement'))
return normalizeFeeSourceDetail(unwrapApiResponse(response))
}
export async function createReconciliationStatement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createReconciliationStatement') }
export async function actionReconciliationStatement(statementId: string, payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('actionReconciliationStatement') }
export async function createReceivableSettlement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createReceivableSettlement') }