From 80cfca5056a11a11ed39b0e14901f501928a60d3 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 19 Aug 2026 09:14:35 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/layouts/AppLayout.vue | 4 +-
.../pages/GlobalSmartLoadingView.test.ts | 4 +-
.../fee-items/api/platformFeeItems.test.ts | 1 +
src/modules/fee-items/api/platformFeeItems.ts | 3 +
src/modules/fee-items/pages/FeeItemsView.vue | 16 +
src/modules/finance/api/finance.test.ts | 47 +-
src/modules/finance/api/finance.ts | 66 +-
src/modules/finance/financeDelivery.test.ts | 14 +-
.../pages/FinanceCustomerArapView.test.ts | 35 +-
.../finance/pages/FinanceCustomerArapView.vue | 73 +-
.../FinanceCustomerStatementsView.test.ts | 5 +-
.../pages/FinanceCustomerStatementsView.vue | 22 +-
.../FinanceFormalSettlementFields.test.ts | 2 +-
.../FinanceFormalSettlementLayout.test.ts | 21 +-
.../pages/FinanceFormalSettlementPage.vue | 51 +-
.../finance/pages/FinanceManualPage.test.ts | 101 ---
.../finance/pages/FinanceManualPage.vue | 621 ------------------
.../finance/pages/FinancePresentation.test.ts | 20 +-
.../FinanceReceivableStatisticsView.test.ts | 22 +
.../pages/FinanceReceivableStatisticsView.vue | 132 ++++
src/modules/finance/status.test.ts | 2 +-
src/modules/finance/status.ts | 8 +-
src/modules/finance/types.ts | 35 +-
.../inbounds/fee/InboundFeePanel.test.ts | 42 +-
src/modules/inbounds/fee/InboundFeePanel.vue | 64 +-
src/modules/inbounds/fee/api.test.ts | 23 +-
src/modules/inbounds/fee/api.ts | 30 +-
.../loading-lists/api/containerFees.test.ts | 23 +-
.../loading-lists/api/containerFees.ts | 33 +-
.../components/ContainerFeesPanel.test.ts | 86 ++-
.../components/ContainerFeesPanel.vue | 85 ++-
.../api/inboundFeeDetails.test.ts | 32 +-
.../unified-orders/api/inboundFeeDetails.ts | 42 +-
.../unified-orders/api/unifiedOrders.ts | 1 -
.../InboundFeeDetailManager.test.ts | 48 +-
.../components/InboundFeeDetailManager.vue | 84 ++-
36 files changed, 854 insertions(+), 1044 deletions(-)
delete mode 100644 src/modules/finance/pages/FinanceManualPage.test.ts
delete mode 100644 src/modules/finance/pages/FinanceManualPage.vue
create mode 100644 src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts
create mode 100644 src/modules/finance/pages/FinanceReceivableStatisticsView.vue
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(/]*\blink\b/)
expect(page).toContain('white-space: nowrap')
})
@@ -263,7 +260,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
submitGeneration(): Promise
}
- vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
+ vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', statementGenerated: false, amount: 100 }]
Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' })
await vm.submitGeneration()
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
@@ -273,14 +270,14 @@ describe('FinanceCustomerArapView G2 operations', () => {
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:customer-arap:export', 'platform:download:list'])
const vm = wrapper.vm as unknown as { filters: Record; exportFeeSources(): Promise }
Object.assign(vm.filters, {
- customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: 'true',
+ customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED',
statementGenerated: 'false', businessRange: ['2026-08-01', '2026-08-31'], keyword: ' 运费 ',
})
await vm.exportFeeSources()
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'FINANCE_FEE_SOURCE_LIST', format: 'XLSX', taskName: '客户应收应付费用来源',
parameters: {
- customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true,
+ customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED',
statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: '运费',
},
})
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue
index af1360d7..f41f3a0e 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.vue
+++ b/src/modules/finance/pages/FinanceCustomerArapView.vue
@@ -1,7 +1,7 @@
-
-
-
-
-
-
-
-
-
-
- {{ props.mode === 'bills' ? '新建对账单' : props.mode === 'receipts' ? '登记收款' : '登记应付成本' }}
-
-
-
查看
-
编辑
-
调整
-
作废
-
-
- 出账作废
-
-
- 查看
-
- {{ financeStatusLabel(row.status) }}
- {{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}
-
- 币种:CNY · 数据由平台财务人工维护
-
-
-
-
-
-
-
- {{ selectedBill.billNo }}v{{ selectedBill.version }}
- {{ selectedBill.customerName }}{{ financeStatusLabel(selectedBill.status) }}
- {{ selectedBill.billDate }}{{ selectedBill.dueDate }}
- {{ selectedBill.totalAmount }} CNY{{ selectedBill.outstandingAmount }} CNY
-
-
- {{ line.feeItemName }}{{ line.inboundId ? `入库单 ${line.inboundId} · ` : '' }}{{ line.orderId ? `订单 ${line.orderId} · ` : '' }}来源 {{ line.sourceFeeId }}/v{{ line.sourceFeeVersion }}{{ line.amount }} CNY
-
-
-
-
-
-
-
-
- 调整前版本 v{{ revision.version }}{{ revision.createdAt || '时间未知' }}
- 原因:{{ revision.reason || '未填写' }}操作人ID:{{ revision.createdBy || '—' }}状态:{{ financeStatusLabel(revision.status) }}金额:{{ revision.totalAmount }} CNY
- {{ line.feeItemName || `费用来源 ${line.sourceFeeId}` }}{{ line.amount }} CNY
- 该版本没有费用明细快照
-
-
-
-
-
-
- {{ selectedLedgerRow.settlementNo }}{{ customerNameById(selectedLedgerRow.customerId) }}
- {{ selectedLedgerRow.billDate }}{{ selectedLedgerRow.dueDate }}{{ selectedLedgerRow.amount }} CNY{{ selectedLedgerRow.paidAmount }} CNY{{ selectedLedgerRow.outstandingAmount }} CNY{{ financeStatusLabel(selectedLedgerRow.status) }}
- {{ selectedLedgerRow.feeName }}{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}{{ selectedLedgerRow.referenceNo }}{{ selectedLedgerRow.amount }} CNY{{ selectedLedgerRow.paidAmount }} CNY{{ selectedLedgerRow.outstandingAmount }} CNY{{ selectedLedgerRow.createdAt }}{{ financeStatusLabel(selectedLedgerRow.status) }}
-
-
-
-
-
-
-
-
-
-
- 费用明细
- 重新加载客户订单
- 先选择客户订单,再选择该入库单已生成的费用来源;系统会自动带出费用项目、版本和金额。
-
-
-
-
-
-
-
- 添加费用明细
-
-
-
-
-
-
-
- 重新加载业务对象
-
-
-
-
- 账单分配
- 重新加载未结清账单
- 选择未结清对账单后自动带出未收金额,可按本次实际到账金额调整分配。
-
- 添加账单分配
-
-
-
-
-
-
-
-
- {{ voucherError }}
- 凭证已上传,创建失败时可直接重试。
-
-
-
-
-
-
-
-
diff --git a/src/modules/finance/pages/FinancePresentation.test.ts b/src/modules/finance/pages/FinancePresentation.test.ts
index 2d815fcc..509f7682 100644
--- a/src/modules/finance/pages/FinancePresentation.test.ts
+++ b/src/modules/finance/pages/FinancePresentation.test.ts
@@ -7,7 +7,6 @@ const financeRoutes = readFileSync(resolve(process.cwd(), 'src/modules/finance/r
const accounts = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
const statements = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
-const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
describe('finance presentation governance', () => {
it('shows customer names instead of internal customer IDs in formal settlements', () => {
@@ -32,7 +31,7 @@ describe('finance presentation governance', () => {
it('uses one continuous desktop detail surface with balanced summary and scroll-safe tables', () => {
expect(settlement).toContain('')
@@ -49,13 +48,13 @@ describe('finance presentation governance', () => {
expect(settlement).toContain('column-gap: 14px;')
})
- it('keeps settlement totals and list actions in one compact toolbar', () => {
- expect(settlement).toContain('class="settlement-list-toolbar"')
+ it('keeps settlement totals and list actions in the table toolbar', () => {
+ expect(settlement).toContain('')
expect(settlement).toContain('class="settlement-summary" aria-label="结算汇总"')
+ expect(settlement).toContain('')
expect(settlement).toContain('class="settlement-list-actions"')
- expect(settlement).toContain('min-height: 54px; align-items: center; justify-content: space-between')
expect(settlement).toContain('.settlement-summary .summary-item + .summary-item')
- expect(settlement).not.toContain('')
+ expect(settlement).not.toContain('class="settlement-list-toolbar"')
})
it('keeps settlement details focused on finance-facing labels and fields', () => {
@@ -91,12 +90,7 @@ describe('finance presentation governance', () => {
expect(arap).toContain('financeDirectionLabel(detail.direction)')
expect(arap).not.toContain("detail.direction === 'RECEIVABLE' ? '应收' : '应付'")
expect(arap).toContain('请填写驳回原因')
- expect(arap).toContain('请说明参与或排除结算的原因')
- })
-
- it('does not expose the uploaded voucher file ID in the form', () => {
- expect(manual).toContain('凭证已上传,创建失败时可直接重试')
- expect(manual).not.toContain('凭证 fileId:')
+ expect(arap).toContain('请填写操作原因')
})
it('exposes frozen G3 controls without unavailable placeholders', () => {
@@ -153,7 +147,7 @@ describe('finance presentation governance', () => {
expect(arap).toContain('border-left: 1px solid')
expect(arap).not.toContain('.arap-metric.is-amount { border-color:')
expect(arap).not.toContain('description="费用来源与关联业务摘要"')
- expect(arap).not.toContain('description="当前审核、参与结算和出账状态"')
+ expect(arap).not.toContain('description="当前审核和出账状态"')
expect(arap).not.toContain('description="按时间查看审核与结算调整记录"')
})
})
diff --git a/src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts b/src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts
new file mode 100644
index 00000000..373fe106
--- /dev/null
+++ b/src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts
@@ -0,0 +1,22 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8')
+
+describe('receivable statistics page', () => {
+ it('renders only the approved CNY derived metrics and current-filter actions', () => {
+ for (const label of ['统计月份', '应收单位', '应收总额', '已收合计', '余额未收', '打印', '导出Excel']) expect(page).toContain(label)
+ expect(page).toContain("exportType: 'FINANCE_RECEIVABLE_STATISTICS'")
+ expect(page).toContain('window.print()')
+ for (const excluded of ['FBA直发', 'FBA专线', '零头减免', 'USD', '>编辑<']) expect(page).not.toContain(excluded)
+ })
+
+ it('keeps query reset, loading, empty, error and permission states explicit', () => {
+ expect(page).toContain('function reset()')
+ expect(page).toContain('无权访问应收款统计表')
+ expect(page).toContain('
+import { computed, onMounted, ref } from 'vue'
+import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
+import { ElMessage } from 'element-plus'
+import { useRouter } from 'vue-router'
+import { useAccessPreviewStore } from '@/core/access/accessPreview'
+import { createPlatformExportTask } from '@/modules/downloads'
+import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
+import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
+import AdminPage from '@/shared/components/AdminPage.vue'
+import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
+import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
+import LoadErrorState from '@/shared/components/LoadErrorState.vue'
+import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
+import { getReceivableStatistics } from '../api/finance'
+import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../types'
+
+const now = new Date()
+const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
+const access = useAccessPreviewStore()
+const router = useRouter()
+const month = ref(defaultMonth)
+const customerId = ref('')
+const customers = ref([])
+const page = ref(1)
+const pageSize = ref(20)
+const loading = ref(false)
+const exporting = ref(false)
+const error = ref(null)
+const result = ref(null)
+
+const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
+const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
+const rows = computed(() => result.value?.records ?? [])
+const columns: DataTableColumn[] = [
+ { key: 'customerName', label: '应收单位', minWidth: 240 },
+ { key: 'currency', label: '币种', width: 100 },
+ { key: 'totalReceivable', label: '应收总额(CNY)', width: 180, slotName: 'totalReceivable' },
+ { key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' },
+ { key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, slotName: 'totalOutstanding' },
+]
+
+async function load() {
+ if (!canView.value || !month.value) return
+ loading.value = true
+ error.value = null
+ try {
+ result.value = await getReceivableStatistics({ month: month.value, customerId: customerId.value || undefined, page: page.value, pageSize: pageSize.value })
+ } catch (reason) { error.value = getApiErrorDetails(reason, '应收款统计加载失败,请重试') }
+ finally { loading.value = false }
+}
+
+async function loadCustomers() {
+ try {
+ const records: PlatformTenantResource[] = []
+ let current = 1; let pages = 1
+ do { const response = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...response.records); pages = response.pages; current += 1 } while (current <= pages)
+ customers.value = records
+ } catch { /* 客户筛选失败不阻断统计,仍可查询全部客户。 */ }
+}
+
+function search() { page.value = 1; void load() }
+function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
+function printCurrent() { window.print() }
+function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
+async function exportCurrent() {
+ exporting.value = true
+ try {
+ const task = await createPlatformExportTask({ exportType: 'FINANCE_RECEIVABLE_STATISTICS', format: 'XLSX', taskName: `应收款统计表-${month.value}`,
+ parameters: { month: month.value, ...(customerId.value ? { customerId: customerId.value } : {}) } })
+ const open = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
+ if (open) await router.push({ name: 'download-center' })
+ } catch (reason) { ElMessage.error(getApiErrorMessage(reason, '应收款统计导出任务创建失败')) }
+ finally { exporting.value = false }
+}
+function money(value?: string) { return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
+
+onMounted(() => { void loadCustomers(); void load() })
+
+
+
+
+
+
+
+
+
+
+ 应收总额CNY {{ money(result?.totalReceivable) }}
+ 已收合计CNY {{ money(result?.totalReceived) }}
+ 余额未收CNY {{ money(result?.totalOutstanding) }}
+
+
+ 应收款明细
共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单
+
+ {{ money(row.totalReceivable) }}{{ money(row.totalReceived) }}{{ money(row.totalOutstanding) }}
+ 统计月份:{{ result?.statisticsMonth }} · 币种:CNY
+
+
+
+
+
+
+
+
diff --git a/src/modules/finance/status.test.ts b/src/modules/finance/status.test.ts
index b39bf415..ddfa83c8 100644
--- a/src/modules/finance/status.test.ts
+++ b/src/modules/finance/status.test.ts
@@ -38,7 +38,7 @@ describe('finance status presentation', () => {
expect(financeBusinessTypeLabel('NEW_BUSINESS')).toBe('未知业务类型')
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
- expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('费用审核通过')
+ expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('审核通过')
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
diff --git a/src/modules/finance/status.ts b/src/modules/finance/status.ts
index 7184bdd8..0fa80ba4 100644
--- a/src/modules/finance/status.ts
+++ b/src/modules/finance/status.ts
@@ -24,6 +24,8 @@ const financeStatuses: Record = {
BILLED: { label: '已入账', tone: 'success' },
RECORDED: { label: '已登记', tone: 'primary' },
CONFIRMED: { label: '已确认', tone: 'success' },
+ PENDING_CONFIRMATION: { label: '待客户确认', tone: 'warning' },
+ NOT_REQUIRED: { label: '无需确认', tone: 'info' },
}
export const financeBillStatusOptions = [
@@ -78,9 +80,9 @@ const financeDocumentTypeLabels: Record = {
BILL: '对账单', RECEIPT: '收款单', COST: '成本单', SETTLEMENT: '结算单', REVISION: '调整记录',
}
const financeOperationActionLabels: Record = {
- CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', INCLUDE: '参与结算', EXCLUDE: '排除结算', UPDATE: '更新',
- FEE_SOURCE_REVIEW_APPROVE: '费用审核通过', FEE_SOURCE_REVIEW_REJECT: '费用审核驳回',
- FEE_INCLUDED: '纳入结算', FEE_EXCLUDED: '排除结算',
+ CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', UPDATE: '更新',
+ FEE_SOURCE_EDIT: '编辑费用', FEE_SOURCE_SUBMIT: '提交审核', FEE_SOURCE_REVIEW_APPROVE: '审核通过',
+ FEE_SOURCE_REVIEW_REJECT: '审核驳回', FEE_SOURCE_ADJUST: '调整费用', FEE_SOURCE_VOID: '作废费用',
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
diff --git a/src/modules/finance/types.ts b/src/modules/finance/types.ts
index efc50d21..562ec893 100644
--- a/src/modules/finance/types.ts
+++ b/src/modules/finance/types.ts
@@ -2,10 +2,11 @@ export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' |
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
-export type FinanceReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
+export type FinanceReviewStatus = 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'
export type FinanceReconciliationStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'VOIDED'
export type FinanceSettlementStatus = 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export type FinancePaymentStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID'
+export type FinanceCustomerConfirmationStatus = 'NOT_REQUIRED' | 'PENDING_CONFIRMATION' | 'CONFIRMED'
export interface FinanceVoucherResource {
fileId: string
@@ -75,7 +76,6 @@ export interface FinanceFeeSourceSummary extends FinanceFeePoolItem {
quantity: number | null
unitPrice: number | null
reviewStatus: FinanceReviewStatus
- settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
}
@@ -100,6 +100,10 @@ export interface FinanceStatementReadModel {
currency: 'CNY'
status: FinanceBillStatus
version: number
+ customerConfirmationStatus?: FinanceCustomerConfirmationStatus
+ customerConfirmedBy?: string
+ customerConfirmedByName?: string
+ customerConfirmedAt?: string
settled?: boolean
lastReceiptAt?: string
submittedAt?: string
@@ -184,6 +188,27 @@ export interface FinanceAccountSummary {
lastChangedAt: string
}
+export interface ReceivableStatisticsRow {
+ customerId: string
+ customerName: string
+ currency: 'CNY'
+ totalReceivable: string
+ totalReceived: string
+ totalOutstanding: string
+}
+
+export interface ReceivableStatisticsResult {
+ statisticsMonth: string
+ currency: 'CNY'
+ totalReceivable: string
+ totalReceived: string
+ totalOutstanding: string
+ totalCustomers: number
+ page: number
+ pageSize: number
+ records: ReceivableStatisticsRow[]
+}
+
export interface FinanceLedgerEntry {
ledgerId: string
occurredAt: string
@@ -201,7 +226,6 @@ export interface FinanceLedgerEntry {
export interface FinanceFeeSourceDetail extends FinanceFeeSourceSummary {
sourceFeeId: string
reviewReason?: string
- settlementReason?: string
sourceHash?: string
operationLogs?: Array<{ occurredAt: string; action: string; operatorName: string; result: string; remark?: string }>
operationVersion: number
@@ -230,7 +254,6 @@ export interface FinanceSettlementLine {
amount: number
currency: 'CNY'
reviewStatus: FinanceReviewStatus
- settlementIncluded: boolean
sourceStatus: string
statementGenerated: boolean
}
@@ -341,6 +364,10 @@ export interface FinanceBillResource {
outstandingAmount: number
status: FinanceBillStatus
version: number
+ customerConfirmationStatus?: FinanceCustomerConfirmationStatus
+ customerConfirmedBy?: string
+ customerConfirmedByName?: string
+ customerConfirmedAt?: string
}
export interface FinanceBillLine {
diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts
index d6f3c56c..7ed571a6 100644
--- a/src/modules/inbounds/fee/InboundFeePanel.test.ts
+++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts
@@ -4,12 +4,15 @@ import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ElMessageBox } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import {
adjustPublishedInboundFee,
+ confirmInboundFee,
getLatestInboundFee,
+ sendInboundFee,
} from './api'
import type { InboundFeeSnapshot } from './types'
import InboundFeePanel from './InboundFeePanel.vue'
@@ -20,6 +23,8 @@ vi.mock('./api', async () => {
...actual,
getLatestInboundFee: vi.fn(),
adjustPublishedInboundFee: vi.fn(),
+ confirmInboundFee: vi.fn(),
+ sendInboundFee: vi.fn(),
}
})
@@ -52,7 +57,12 @@ function setupPermissions(permissions: string[]) {
return pinia
}
-function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) {
+function mountPanel(permissions = [
+ 'firstleg:fba-pricing:view',
+ 'firstleg:fba-pricing:adjust',
+ 'firstleg:fba-pricing:confirm',
+ 'firstleg:inbound:send-fee',
+]) {
const pinia = setupPermissions(permissions)
return mount(InboundFeePanel, {
attachTo: document.body,
@@ -67,6 +77,8 @@ describe('InboundFeePanel', () => {
document.body.innerHTML = ''
vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot)
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' })
+ vi.mocked(confirmInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED', confirmedAt: '2026-08-04T10:01:00', sentAt: null })
+ vi.mocked(sendInboundFee).mockResolvedValue(snapshot)
})
it('shows the immutable calculation basis, versions, amount, and timestamps', async () => {
@@ -88,6 +100,7 @@ describe('InboundFeePanel', () => {
expect(document.body.textContent).toContain('时间记录')
expect(document.body.textContent).toContain('发布')
expect(document.body.textContent).not.toContain('重新试算')
+ expect(document.body.textContent).not.toContain('确认费用')
expect(document.body.textContent).not.toContain('发送费用')
})
@@ -201,6 +214,33 @@ describe('InboundFeePanel', () => {
expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true)
})
+ it('confirms and then sends a calculated fee through the implemented backend transitions', async () => {
+ vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
+ vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
+ const wrapper = mountPanel()
+ await flushPromises()
+
+ await wrapper.get('[data-testid="confirm-fee-button"]').trigger('click')
+ await flushPromises()
+ expect(confirmInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-confirm-/))
+ expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(true)
+
+ await wrapper.get('[data-testid="send-fee-button"]').trigger('click')
+ await flushPromises()
+ expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/))
+ expect(wrapper.emitted('changed')).toHaveLength(2)
+ expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
+ })
+
+ it('does not expose fee transitions without their independent permissions', async () => {
+ vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
+ const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust'])
+ await flushPromises()
+
+ expect(wrapper.find('[data-testid="confirm-fee-button"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false)
+ })
+
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' })
const wrapper = mountPanel()
diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue
index 6b512802..4d2280d0 100644
--- a/src/modules/inbounds/fee/InboundFeePanel.vue
+++ b/src/modules/inbounds/fee/InboundFeePanel.vue
@@ -1,7 +1,7 @@
@@ -198,40 +215,38 @@ onMounted(() => void load())
费用明细{{ fees.length }} 条
{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}
- {{ row.direction === 'PAYABLE' ? row.partnerName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}
- {{ money(row.amount) }}{{ row.settlementIncluded ? '是' : '否' }}
+ {{ row.direction === 'PAYABLE' ? row.supplierName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}
+ {{ money(row.amount) }}
- {{ cost.feeItemName || '柜次成本' }}往来单位:{{ cost.supplierName || '服务端未返回' }}金额:¥{{ money(cost.amount) }}
{{ row.volumeCbm.toFixed(3) }}{{ (row.allocationRate * 100).toFixed(2) }}%{{ money(row.allocatedAmount) }}
+ {{ cost.feeItemName || '柜次成本' }}供应商:{{ cost.supplierName || '服务端未返回' }}金额:¥{{ money(cost.amount) }}
{{ row.volumeCbm.toFixed(3) }}{{ (row.allocationRate * 100).toFixed(2) }}%{{ money(row.allocatedAmount) }}
-
调整仅允许修改业务日期和金额;如需变更方向、费用项目、往来单位或其他信息,请先作废后新增。
+
调整仅允许修改业务日期和金额;如需变更方向、费用项目、供应商或其他信息,请先作废后新增。
{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}
{{ target.feeItemName }}
- {{ target.partnerName || '—' }}
+ {{ target.supplierName || '—' }}
{{ target.currency }}
- {{ target.settlementIncluded ? '是' : '否' }}
{{ target.remark || '—' }}
-
-
+
+
-
+
-
+
-
diff --git a/src/modules/unified-orders/api/inboundFeeDetails.test.ts b/src/modules/unified-orders/api/inboundFeeDetails.test.ts
index 3a34dde9..8c29634f 100644
--- a/src/modules/unified-orders/api/inboundFeeDetails.test.ts
+++ b/src/modules/unified-orders/api/inboundFeeDetails.test.ts
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails,
- reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
+ listInboundFeeItemOptions, listInboundFeeSupplierOptions, reviewInboundFeeDetail,
+ updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest,
} from './inboundFeeDetails'
vi.mock('@/shared/api/http', async () => {
@@ -18,7 +19,7 @@ describe('inbound fee-detail API', () => {
})
it('maps the complete write contract and optimistic version', async () => {
- const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, settlementIncluded: true, partnerId: '41', partnerName: '测试供应商', remark: '验收' }
+ const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, supplierId: '41', remark: '验收' }
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: payload })
@@ -32,4 +33,31 @@ describe('inbound fee-detail API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11/void', { expectedSourceVersion: 3 }, { params: { accountId: '100' } })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11', { params: { accountId: '100' } })
})
+
+ it('fails closed when a fee item has no recognized supplier category', async () => {
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [
+ { id: 31, feeTypeName: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehousePayable: true },
+ { id: 32, feeTypeName: '未知费用', feeCode: 'UNMAPPED', supplierCategory: 'UNKNOWN', warehousePayable: true },
+ { id: 33, feeTypeName: '缺少类别', feeCode: 'MISSING', warehousePayable: true },
+ ] } })
+ await expect(listInboundFeeItemOptions()).resolves.toMatchObject([
+ { id: 31, feeCode: 'TRUCKING', supplierCategory: 'TRUCKING' },
+ { id: 32, feeCode: 'UNMAPPED', supplierCategory: null },
+ { id: 33, feeCode: 'MISSING', supplierCategory: null },
+ ])
+ })
+
+ it('requests and retains only ACTIVE suppliers in the required category', async () => {
+ vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [
+ { id: 41, tenantId: 0, name: '承运商A', category: 'TRUCKING', status: 'ACTIVE', version: 1, deleted: false },
+ { id: 42, tenantId: 0, name: '停用承运商', category: 'TRUCKING', status: 'INACTIVE', version: 1, deleted: false },
+ { id: 43, tenantId: 0, name: '报关行', category: 'CUSTOMS', status: 'ACTIVE', version: 1, deleted: false },
+ ], page: 1, size: 200, total: 3, pages: 1 } })
+ await expect(listInboundFeeSupplierOptions('TRUCKING')).resolves.toEqual([
+ { id: '41', supplierName: '承运商A', category: 'TRUCKING' },
+ ])
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers', { params: {
+ page: 1, size: 200, category: 'TRUCKING', region: undefined, status: 'ACTIVE', keyword: undefined,
+ } })
+ })
})
diff --git a/src/modules/unified-orders/api/inboundFeeDetails.ts b/src/modules/unified-orders/api/inboundFeeDetails.ts
index e126bdc5..9d65f0d2 100644
--- a/src/modules/unified-orders/api/inboundFeeDetails.ts
+++ b/src/modules/unified-orders/api/inboundFeeDetails.ts
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import { listSuppliers, type SupplierCategory } from '@/modules/suppliers'
export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
@@ -11,8 +12,9 @@ export interface InboundFeeDetail {
sourceFeeVersion: number
inboundCode: string | null
direction: InboundFeeDirection
- partnerId: string | number | null
- partnerName: string | null
+ supplierId: string | number | null
+ supplierName: string | null
+ supplierCategory: SupplierCategory | null
businessDate: string
customerName: string | null
feeTypeNameSnapshot: string
@@ -25,7 +27,6 @@ export interface InboundFeeDetail {
reviewerName: string | null
reviewedAt: string | null
publicationStatus: string
- settlementIncluded: boolean
}
export interface SaveInboundFeeDetailRequest {
@@ -35,20 +36,26 @@ export interface SaveInboundFeeDetailRequest {
unitPrice: number
currency: string
amount: number
- settlementIncluded: boolean
- partnerId: string | number | null
- partnerName: string | null
+ supplierId: string | number | null
remark: string | null
}
export interface InboundFeeItemOption {
id: string | number
feeTypeName: string
+ feeCode: string
+ supplierCategory: SupplierCategory | null
warehousePayable: boolean
warehouseReceivable: boolean
}
-export interface InboundFeePartnerOption { id: string | number; partnerName: string }
+export interface InboundFeeSupplierOption { id: string; supplierName: string; category: SupplierCategory }
+
+const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE']
+
+function supplierCategory(value: unknown): SupplierCategory | null {
+ return supplierCategories.includes(value as SupplierCategory) ? value as SupplierCategory : null
+}
function basePath(inboundId: string) {
const identifier = String(inboundId ?? '').trim()
@@ -95,15 +102,22 @@ export async function voidInboundFeeDetail(accountId: string, inboundId: string,
}
export async function listInboundFeeItemOptions() {
- const response = await http.get>(
+ const response = await http.get[] }>>(
'/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } },
)
- return unwrapApiResponse(response).records || []
+ return (unwrapApiResponse(response).records || []).map((raw): InboundFeeItemOption => ({
+ id: raw.id as string | number,
+ feeTypeName: String(raw.feeTypeName || ''),
+ feeCode: String(raw.feeCode || ''),
+ supplierCategory: supplierCategory(raw.supplierCategory),
+ warehousePayable: Boolean(raw.warehousePayable),
+ warehouseReceivable: Boolean(raw.warehouseReceivable),
+ }))
}
-export async function listInboundFeePartnerOptions() {
- const response = await http.get>(
- '/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } },
- )
- return unwrapApiResponse(response).records || []
+export async function listInboundFeeSupplierOptions(category: SupplierCategory) {
+ const page = await listSuppliers({ status: 'ACTIVE', category, page: 1, size: 200 })
+ return page.records.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === category).map((supplier): InboundFeeSupplierOption => ({
+ id: supplier.id, supplierName: supplier.name, category: supplier.category,
+ }))
}
diff --git a/src/modules/unified-orders/api/unifiedOrders.ts b/src/modules/unified-orders/api/unifiedOrders.ts
index 701f1b08..8e184c75 100644
--- a/src/modules/unified-orders/api/unifiedOrders.ts
+++ b/src/modules/unified-orders/api/unifiedOrders.ts
@@ -255,7 +255,6 @@ export interface UnifiedOrderFeeDetailResource {
inboundedAt: string | null
outboundAt?: string | null
storageDays?: number | null
- settlementIncluded: boolean | null
billNo?: string | null
settlementStartDate?: string | null
settlementEndDate?: string | null
diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
index 6c1e2933..d0b1b2d8 100644
--- a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
+++ b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts
@@ -9,24 +9,24 @@ import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
- listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
+ listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import InboundFeeDetailManager from './InboundFeeDetailManager.vue'
import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails'
vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({
- listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeePartnerOptions: vi.fn(),
+ listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeeSupplierOptions: vi.fn(),
createInboundFeeDetail: vi.fn(), updateInboundFeeDetail: vi.fn(), deleteInboundFeeDetail: vi.fn(),
reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), voidInboundFeeDetail: vi.fn(),
}))
const pending: InboundFeeDetail = {
id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1,
- inboundCode: 'RK-88', direction: 'RECEIVABLE', partnerId: null, partnerName: null,
+ inboundCode: 'RK-88', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null,
businessDate: '2026-08-12', customerName: '测试客户', feeTypeNameSnapshot: '操作费',
unitPrice: 10, currency: 'CNY', amount: 100, remark: 'marker', reviewStatus: 'PENDING',
- reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', settlementIncluded: true,
+ reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT',
}
const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' }
const source = readFileSync(join(process.cwd(), 'src/modules/unified-orders/components/InboundFeeDetailManager.vue'), 'utf8')
@@ -44,8 +44,8 @@ describe('InboundFeeDetailManager', () => {
beforeEach(() => {
vi.clearAllMocks(); document.body.innerHTML = ''
vi.mocked(listInboundFeeDetails).mockResolvedValue([pending, approved])
- vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', warehouseReceivable: true, warehousePayable: true }])
- vi.mocked(listInboundFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '测试往来单位' }])
+ vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true }])
+ vi.mocked(listInboundFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '测试供应商', category: 'TRUCKING' }])
vi.mocked(createInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending)
vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' })
@@ -144,14 +144,14 @@ describe('InboundFeeDetailManager', () => {
expect(source).toContain('@keyframes fee-refresh-spin')
})
- it('loads RECEIVABLE fee items without requesting counterparties', async () => {
+ it('loads RECEIVABLE fee items without requesting suppliers', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
- expect(listInboundFeePartnerOptions).not.toHaveBeenCalled()
- expect(document.body.textContent).toContain('缺少往来单位查看权限')
+ expect(listInboundFeeSupplierOptions).not.toHaveBeenCalled()
+ expect(document.body.textContent).toContain('缺少供应商查看权限')
expect(document.body.textContent).toContain('应收费用不受影响')
})
@@ -162,25 +162,41 @@ describe('InboundFeeDetailManager', () => {
expect(listInboundFeeItemOptions).not.toHaveBeenCalled()
})
- it('keeps fee items usable when the independent counterparty request fails', async () => {
- vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', partnerId: '41', partnerName: '测试往来单位' }])
- vi.mocked(listInboundFeePartnerOptions).mockRejectedValue(new ApiResponseError('往来单位不可用', 'A0403', 403, null, 'request-partner', 'trace-partner'))
+ it('keeps fee items usable but blocks payable save when the supplier request fails', async () => {
+ vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING' }])
+ vi.mocked(listInboundFeeSupplierOptions).mockRejectedValue(new ApiResponseError('供应商不可用', 'A0403', 403, null, 'request-supplier', 'trace-supplier'))
const wrapper = mountManager([
'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
- 'firstleg:fee-item:view', 'platform:counterparty:list',
+ 'firstleg:fee-item:view', 'platform:supplier:list',
])
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('编辑'))?.trigger('click')
await flushPromises()
expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1)
- expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1)
+ expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith('TRUCKING')
expect(document.body.textContent).toContain('当前账号无权访问该数据')
expect(document.body.textContent).toContain('A0403')
- expect(document.body.textContent).toContain('request-partner')
- expect(document.body.textContent).toContain('trace-partner')
+ expect(document.body.textContent).toContain('request-supplier')
+ expect(document.body.textContent).toContain('trace-supplier')
expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false)
})
+ it('fails closed when a payable fee item has no supplier category', async () => {
+ vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true }])
+ const wrapper = mountManager([
+ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit',
+ 'firstleg:fee-item:view', 'platform:supplier:list',
+ ])
+ await flushPromises()
+ await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click')
+ await flushPromises()
+ const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
+ state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; state.form.supplierId = '41'; state.form.amount = 100; await flushPromises()
+ await state.save(); await flushPromises()
+ expect(createInboundFeeDetail).not.toHaveBeenCalled()
+ expect(document.body.textContent).toContain('该应付费用项目未配置供应商类别,已阻断保存')
+ })
+
it('refreshes fee rows and the order detail after every successful write', () => {
expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')")
expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/)
diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.vue b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
index 5b15d022..3409b3ce 100644
--- a/src/modules/unified-orders/components/InboundFeeDetailManager.vue
+++ b/src/modules/unified-orders/components/InboundFeeDetailManager.vue
@@ -10,12 +10,12 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
- listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions,
+ listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions,
reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail,
} from '@/modules/unified-orders/api/inboundFeeDetails'
import type {
InboundFeeDetail, InboundFeeItemOption,
- InboundFeePartnerOption, SaveInboundFeeDetailRequest,
+ InboundFeeSupplierOption, SaveInboundFeeDetailRequest,
} from '@/modules/unified-orders/api/inboundFeeDetails'
const props = defineProps<{ accountId: string; inboundId: string }>()
@@ -24,12 +24,12 @@ const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>()
const access = useAccessPreviewStore()
const rows = ref([])
const feeItems = ref([])
-const partners = ref([])
+const suppliers = ref([])
const feeItemsLoading = ref(false)
-const partnersLoading = ref(false)
+const suppliersLoading = ref(false)
+const loadedSupplierCategory = ref(null)
const feeItemsError = ref(null)
-const partnersError = ref(null)
-const partnersLoadAttempted = ref(false)
+const suppliersError = ref(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref(null)
@@ -40,11 +40,12 @@ const target = ref(null)
const formRef = ref()
const rejectOpen = ref(false)
const rejectReason = ref('')
+let supplierLoadSequence = 0
const emptyForm = (): SaveInboundFeeDetailRequest => ({
feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
- unitPrice: 0, currency: 'CNY', amount: 0, settlementIncluded: true,
- partnerId: null, partnerName: null, remark: null,
+ unitPrice: 0, currency: 'CNY', amount: 0,
+ supplierId: null, remark: null,
})
const form = reactive(emptyForm())
const rules: FormRules = {
@@ -60,12 +61,13 @@ const normalizedInboundId = computed(() => {
return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : ''
})
const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view'))
-const canViewPartners = computed(() => access.can('platform:counterparty:list'))
+const canViewSuppliers = computed(() => access.can('platform:supplier:list'))
const directionOptions = computed(() => [
{ label: '应收', value: 'RECEIVABLE' },
- { label: '应付', value: 'PAYABLE', disabled: !canViewPartners.value },
+ { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers.value },
])
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
+const selectedFeeItem = computed(() => feeItems.value.find((item) => String(item.id) === String(form.feeItemId)))
function isManual(row: InboundFeeDetail) { return row.sourceType === 'MANUAL' }
function canEdit(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'PENDING' }
@@ -84,7 +86,7 @@ async function load() {
loading.value = true; loadError.value = null
try {
rows.value = await listInboundFeeDetails(props.accountId, normalizedInboundId.value)
- const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' && row.settlementIncluded)
+ const approved = rows.value.filter((row) => isManual(row) && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED')
emit('summary', {
currency: approved.find((row) => row.currency)?.currency || 'CNY',
approvedReceivable: approved.filter((row) => row.direction === 'RECEIVABLE').reduce((sum, row) => sum + Number(row.amount || 0), 0),
@@ -103,37 +105,42 @@ async function loadFeeItems() {
finally { feeItemsLoading.value = false }
}
-async function loadPartners() {
- if (!canViewPartners.value || partnersLoading.value || partners.value.length || partnersLoadAttempted.value) return
- partnersLoadAttempted.value = true
- partnersLoading.value = true; partnersError.value = null
- try { partners.value = await listInboundFeePartnerOptions() }
- catch (error) { partnersError.value = getApiErrorDetails(error, '往来单位加载失败') }
- finally { partnersLoading.value = false }
+async function loadSuppliers() {
+ const sequence = ++supplierLoadSequence
+ const item = selectedFeeItem.value
+ suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null
+ if (!canViewSuppliers.value || form.direction !== 'PAYABLE' || !item?.supplierCategory) return
+ const requestedCategory = item.supplierCategory
+ suppliersLoading.value = true
+ try {
+ const result = await listInboundFeeSupplierOptions(requestedCategory)
+ if (sequence === supplierLoadSequence && form.direction === 'PAYABLE' && selectedFeeItem.value?.supplierCategory === requestedCategory) {
+ suppliers.value = result
+ loadedSupplierCategory.value = requestedCategory
+ }
+ }
+ catch (error) { if (sequence === supplierLoadSequence) suppliersError.value = getApiErrorDetails(error, '供应商加载失败') }
+ finally { if (sequence === supplierLoadSequence) suppliersLoading.value = false }
}
function assignForm(row?: InboundFeeDetail) {
Object.assign(form, row ? {
feeItemId: row.feeItemId, direction: row.direction, businessDate: row.businessDate,
unitPrice: Number(row.unitPrice), currency: row.currency, amount: Number(row.amount),
- settlementIncluded: Boolean(row.settlementIncluded), partnerId: row.partnerId,
- partnerName: row.partnerName, remark: row.remark,
+ supplierId: row.supplierId, remark: row.remark,
} : emptyForm())
}
async function openEditor(mode: 'create' | 'edit' | 'adjust', row?: InboundFeeDetail) {
- partnersLoadAttempted.value = false
editorMode.value = mode; target.value = row || null; assignForm(row); actionError.value = null
- feeItemsError.value = null; partnersError.value = null
+ feeItemsError.value = null; suppliersError.value = null
editorOpen.value = true
if (!feeItems.value.length) await loadFeeItems()
- if (form.direction === 'PAYABLE') await loadPartners()
+ if (form.direction === 'PAYABLE') await loadSuppliers()
}
function normalizePayload(): SaveInboundFeeDetailRequest {
- const partner = partners.value.find((item) => String(item.id) === String(form.partnerId))
- return { ...form, currency: 'CNY', partnerId: form.direction === 'PAYABLE' ? form.partnerId : null,
- partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || form.partnerName : null,
+ return { ...form, currency: 'CNY', supplierId: form.direction === 'PAYABLE' ? form.supplierId : null,
remark: form.remark?.trim() || null }
}
@@ -144,8 +151,14 @@ async function finishAction(message: string) {
async function save() {
if (!await formRef.value?.validate()) return
if (!canViewFeeItems.value) { actionError.value = { message: '当前账号缺少费用项目查看权限,无法保存费用' }; return }
- if (form.direction === 'PAYABLE' && !canViewPartners.value) { actionError.value = { message: '当前账号缺少往来单位查看权限,无法维护应付费用' }; return }
- if (form.direction === 'PAYABLE' && !form.partnerId) { actionError.value = { message: '应付费用必须选择往来单位' }; return }
+ const selectedItem = feeItems.value.find((item) => String(item.id) === String(form.feeItemId))
+ if (form.direction === 'PAYABLE' && !canViewSuppliers.value) { actionError.value = { message: '当前账号缺少供应商查看权限,无法维护应付费用' }; return }
+ if (form.direction === 'PAYABLE' && !selectedItem?.supplierCategory) { actionError.value = { message: '该应付费用项目未配置供应商类别,已阻断保存' }; return }
+ if (form.direction === 'PAYABLE' && suppliersError.value) { actionError.value = { message: '供应商列表加载失败,无法校验应付费用供应商' }; return }
+ if (form.direction === 'PAYABLE' && loadedSupplierCategory.value !== selectedItem?.supplierCategory) { actionError.value = { message: '供应商候选尚未按费用项目类别加载完成,无法保存' }; return }
+ if (form.direction === 'PAYABLE' && !form.supplierId) { actionError.value = { message: '应付费用必须选择供应商' }; return }
+ const selectedSupplier = suppliers.value.find((supplier) => supplier.id === String(form.supplierId))
+ if (form.direction === 'PAYABLE' && (!selectedSupplier || selectedSupplier.category !== selectedItem?.supplierCategory)) { actionError.value = { message: '所选供应商与费用项目要求的供应商类别不匹配' }; return }
saving.value = true; actionError.value = null
try {
const payload = normalizePayload()
@@ -188,9 +201,10 @@ async function voidRow(row: InboundFeeDetail) {
watch(() => [props.accountId, normalizedInboundId.value], () => void load())
watch(() => form.direction, (direction) => {
- if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null }
- else void loadPartners()
+ if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; suppliersLoading.value = false }
+ else void loadSuppliers()
})
+watch(() => form.feeItemId, () => { form.supplierId = null; suppliers.value = []; loadedSupplierCategory.value = null; suppliersError.value = null; if (form.direction === 'PAYABLE') void loadSuppliers() })
onMounted(() => void load())
@@ -228,12 +242,11 @@ onMounted(() => void load())
{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}
{{ directionLabel(row.direction) }}
- {{ row.direction === 'PAYABLE' ? row.partnerName : row.customerName || '—' }}
+ {{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}
{{ Number(row.amount).toFixed(2) }}
{{ Number(row.unitPrice).toFixed(4) }}
- {{ row.settlementIncluded ? '是' : '否' }}
@@ -245,19 +258,18 @@ onMounted(() => void load())
-
+
{{ feeItemsError.message }}
- {{ partnersError.message }}
+ {{ suppliersError.message }}
{{ actionError.message }}
-
+
-