From c690a304228c11afeef2867f890bc2429fc639fe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 19:35:28 +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 --- .../CustomerOverseasStorageFeePanel.vue | 7 +- .../pages/OverseasStorageFeesView.vue | 11 +- src/modules/finance/api/finance.test.ts | 29 +++- src/modules/finance/api/finance.ts | 44 +++++- src/modules/finance/financeDelivery.test.ts | 19 +++ .../pages/FinanceCustomerAccountsView.vue | 3 +- .../pages/FinanceCustomerArapView.test.ts | 125 ++++++++++++++++++ .../finance/pages/FinanceCustomerArapView.vue | 96 ++++++++++++-- .../pages/FinanceCustomerStatementsView.vue | 5 +- .../pages/FinanceFormalSettlementPage.vue | 13 +- .../finance/pages/FinanceManualPage.test.ts | 8 ++ .../finance/pages/FinanceManualPage.vue | 15 ++- .../finance/pages/FinancePresentation.test.ts | 28 ++++ src/modules/finance/status.test.ts | 37 ++++++ src/modules/finance/status.ts | 86 ++++++++++++ src/modules/finance/types.ts | 17 ++- .../PlatformFirstLegOrdersPanel.test.ts | 6 + .../PlatformFirstLegOrdersPanel.vue | 12 +- .../components/InboundWorkflowWorkspace.vue | 16 +-- .../inbounds/core/InboundHistoryPanel.vue | 3 +- .../inbounds/core/InboundSummaryPanel.test.ts | 10 ++ .../inbounds/core/InboundSummaryPanel.vue | 34 ++--- .../inbounds/fee/InboundFeePanel.test.ts | 1 + src/modules/inbounds/fee/InboundFeePanel.vue | 9 +- .../measurement/InboundMeasurementPanel.vue | 6 +- .../inbounds/pages/InboundOrdersView.vue | 31 +++-- .../review/MeasurementReviewPanel.test.ts | 2 + .../review/MeasurementReviewPanel.vue | 23 ++-- src/modules/inbounds/status.test.ts | 14 ++ src/modules/inbounds/status.ts | 47 +++++++ .../weight/components/WeightOcrPanel.vue | 9 +- .../components/LoadingListDetailDrawer.vue | 9 +- .../components/LoadingListsView.test.ts | 11 +- .../components/LoadingListsView.vue | 7 +- src/modules/loading-lists/index.ts | 1 + src/modules/loading-lists/status.test.ts | 11 ++ src/modules/loading-lists/status.ts | 11 ++ .../pages/TruckAppointmentDetailView.vue | 2 +- .../unified-orders/api/unifiedOrders.ts | 2 +- .../OrderFulfillmentTreePanel.test.ts | 7 + .../components/OrderFulfillmentTreePanel.vue | 11 +- src/modules/unified-orders/index.ts | 9 ++ .../pages/UnifiedOrdersView.test.ts | 9 +- .../pages/UnifiedOrdersView.vue | 67 ++++++---- src/modules/unified-orders/status.test.ts | 31 +++++ src/modules/unified-orders/status.ts | 98 ++++++++++++++ .../utils/firstLegBusinessLabels.test.ts | 22 +++ src/shared/utils/firstLegBusinessLabels.ts | 32 +++++ 48 files changed, 935 insertions(+), 171 deletions(-) create mode 100644 src/modules/finance/pages/FinanceCustomerArapView.test.ts create mode 100644 src/modules/finance/pages/FinancePresentation.test.ts create mode 100644 src/modules/finance/status.test.ts create mode 100644 src/modules/finance/status.ts create mode 100644 src/modules/inbounds/status.test.ts create mode 100644 src/modules/inbounds/status.ts create mode 100644 src/modules/loading-lists/status.test.ts create mode 100644 src/modules/loading-lists/status.ts create mode 100644 src/modules/unified-orders/status.test.ts create mode 100644 src/modules/unified-orders/status.ts create mode 100644 src/shared/utils/firstLegBusinessLabels.test.ts create mode 100644 src/shared/utils/firstLegBusinessLabels.ts diff --git a/src/modules/customers/CustomerOverseasStorageFeePanel.vue b/src/modules/customers/CustomerOverseasStorageFeePanel.vue index 3235a78c..0cd94b24 100644 --- a/src/modules/customers/CustomerOverseasStorageFeePanel.vue +++ b/src/modules/customers/CustomerOverseasStorageFeePanel.vue @@ -86,7 +86,10 @@ onMounted(load) diff --git a/src/modules/customers/pages/OverseasStorageFeesView.vue b/src/modules/customers/pages/OverseasStorageFeesView.vue index 7def74fd..e3e5197e 100644 --- a/src/modules/customers/pages/OverseasStorageFeesView.vue +++ b/src/modules/customers/pages/OverseasStorageFeesView.vue @@ -92,9 +92,14 @@ onMounted(() => { void load() }) .tier-item { display:flex; align-items:center; justify-content:space-between; gap:6px; min-height:24px; padding:2px 6px; border-left:2px solid #409eff; background:#f6f9fd; } .tier-item span { color:var(--cc-muted); font-size:12px; white-space:nowrap; } .tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; } -.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; } +.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; } +.groups > div { min-width:0; } +.groups :deep(.el-table) { width:100%; } +.groups :deep(.el-input-number) { width:100%; min-width:0; } .groups h3 { margin:0 0 10px; font-size:15px; } .group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; } -.group-heading h3 { margin:0; } -@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .groups,.tier-list { grid-template-columns:1fr; } } +.group-heading h3 { min-width:0; margin:0; } +.group-heading :deep(.el-button) { flex:0 0 auto; } +@media (max-width:900px) { .groups { grid-template-columns:1fr; } } +@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } .groups > div { overflow-x:auto; } .groups :deep(.el-table) { min-width:560px; } } diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts index 81fca95c..4b08f21d 100644 --- a/src/modules/finance/api/finance.test.ts +++ b/src/modules/finance/api/finance.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance' +import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, getFinanceFeeSource, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance' describe('finance api', () => { beforeEach(() => { @@ -63,6 +63,33 @@ describe('finance api', () => { expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', feeItemName: '拖车费', payableAmount: 680.5, currency: 'CNY' }) }) + it('derives the numeric fee source id without treating manual costs as reviewable sources', async () => { + vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [ + { poolItemId: 'RECEIVABLE:42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, feeItemName: '运费', amount: 100, status: 'AVAILABLE' }, + { poolItemId: 'PAYABLE:43', direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: 43, sourceVersion: 1, feeItemName: '拖车费', amount: 200, status: 'POSTED' }, + ] }) + + const result = await listFinanceFeePool('10001') + + expect(result.map((item) => item.feeSourceId)).toEqual(['42', null]) + }) + + it('uses the formal G2 detail, review and settlement-inclusion contracts', async () => { + const detail = { feeSourceId: 42, sourceType: 'INBOUND_FEE', sourceFeeId: 81, sourceVersion: 3, direction: 'RECEIVABLE', customerId: 10001, customerName: '客户A', inboundId: 91, inboundCode: 'IB-91', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 4, operationLogs: [{ occurredAt: '2026-08-11T12:00:00', action: 'FEE_SOURCE_REVIEW_APPROVE', operatorName: '财务A', result: 'SUCCESS' }] } + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g2', traceId: 'trace-g2', data: detail }) + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail }) + const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'success', data: detail }) + + const result = await getFinanceFeeSource('42') + await reviewFinanceFeeSource('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }) + await updateFinanceFeeSourceSettlementInclusion('42', { included: true, reason: '本期结算', expectedVersion: 4 }) + + expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42') + expect(post).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/review', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } }) + expect(put).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/settlement-inclusion', { included: true, reason: '本期结算', expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } }) + expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4, currency: 'CNY' }) + }) + it('keeps receipt and cost commands on CNY with real voucher ids', async () => { const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} }) diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts index 64156434..66956641 100644 --- a/src/modules/finance/api/finance.ts +++ b/src/modules/finance/api/finance.ts @@ -5,9 +5,9 @@ const basePath = '/platform/finance/v1' export const financePhase1Capabilities = { accountList: true, accountLedger: true, - feeSourceDetail: false, - feeSourceReview: false, - feeSourceSettlementInclusion: false, + feeSourceDetail: true, + feeSourceReview: true, + feeSourceSettlementInclusion: true, reconciliationCreate: false, reconciliationActions: false, receivableSettlementCreate: false, @@ -70,7 +70,11 @@ export async function getFinanceCustomerAccount(customerId: string): Promise { const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params: { customerId, direction } }) - return unwrapApiResponse(response).map((item) => ({ poolItemId: stringValue(item.poolItemId), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) })) + return unwrapApiResponse(response).map((item) => { + const poolItemId = stringValue(item.poolItemId) + const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null) + return { poolItemId, feeSourceId, direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) } + }) } export async function listFinanceAccounts(params: { page?: number; size?: number; keyword?: string; status?: string } = {}): Promise<{ records: FinanceAccountSummary[]; total: number; pages: number }> { @@ -94,11 +98,37 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag } export async function getFinanceFeeSource(feeSourceId: string): Promise { - throw new FinanceCapabilityBlockedError('getFinanceFeeSource') + const response = await http.get, ApiEnvelope>(`${basePath}/fee-sources/${feeSourceId}`) + return normalizeFeeSourceDetail(unwrapApiResponse(response)) } -export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('reviewFinanceFeeSource') } -export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: string, payload: { included: boolean; reason: string; expectedVersion: number }) { throw new FinanceCapabilityBlockedError('updateFeeSourceSettlementInclusion') } +function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail { + const nullableString = (value: unknown) => value == null ? null : stringValue(value) + const nullableNumber = (value: unknown) => value == null ? null : numberValue(value) + return { + poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId), + sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceFeeId), sourceFeeId: stringValue(item.sourceFeeId), + sourceVersion: numberValue(item.sourceVersion), direction: stringValue(item.direction) as FinanceFeeSourceDetail['direction'], + customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName), + orderId: nullableString(item.orderId), inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode), + containerId: nullableString(item.containerId), containerNo: stringValue(item.containerNo), businessDate: stringValue(item.businessDate), + feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice), amount: numberValue(item.amount), currency: 'CNY', + reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded), + sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated), + reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined, + sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion), createdAt: stringValue(item.businessDate), + operationLogs: (Array.isArray(item.operationLogs) ? item.operationLogs : []).map((entry) => { const row = entry as RawResource; return { occurredAt: stringValue(row.occurredAt), action: stringValue(row.action), operatorName: stringValue(row.operatorName), result: stringValue(row.result), remark: stringValue(row.remark) || undefined } }), + } +} + +export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }): Promise { + 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')) + return normalizeFeeSourceDetail(unwrapApiResponse(response)) +} export async function createReconciliationStatement(payload: Record) { throw new FinanceCapabilityBlockedError('createReconciliationStatement') } export async function actionReconciliationStatement(statementId: string, payload: Record) { throw new FinanceCapabilityBlockedError('actionReconciliationStatement') } export async function createReceivableSettlement(payload: Record) { throw new FinanceCapabilityBlockedError('createReceivableSettlement') } diff --git a/src/modules/finance/financeDelivery.test.ts b/src/modules/finance/financeDelivery.test.ts index ad4457b5..50449aca 100644 --- a/src/modules/finance/financeDelivery.test.ts +++ b/src/modules/finance/financeDelivery.test.ts @@ -57,6 +57,25 @@ describe('finance five-page delivery contract', () => { expect(page.indexOf("key: 'actions'")).toBeLessThan(page.indexOf("key: 'customerName'")) for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state) }) + + it('connects G2 fee sources to real detail, review and settlement operations', () => { + 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`', + ]) expect(api).toContain(marker) + for (const operation of ['getFinanceFeeSource', 'reviewFinanceFeeSource', 'updateFinanceFeeSourceSettlementInclusion']) expect(page).toContain(operation) + for (const permission of ['platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) 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(' void load()) diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts new file mode 100644 index 00000000..e63a6c9d --- /dev/null +++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts @@ -0,0 +1,125 @@ +import ElementPlus from 'element-plus' +import { createPinia, setActivePinia } from 'pinia' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccessPreviewStore } from '@/core/access/accessPreview' +import { useAuthStore } from '@/core/auth/auth' +import { ApiResponseError } from '@/shared/api/http' +import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' +import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance' +import type { FinanceFeeSourceDetail } from '../types' +import FinanceCustomerArapView from './FinanceCustomerArapView.vue' + +vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) +vi.mock('../api/finance', () => ({ + listFinanceFeePool: vi.fn(), + getFinanceFeeSource: vi.fn(), + reviewFinanceFeeSource: vi.fn(), + updateFinanceFeeSourceSettlementInclusion: vi.fn(), +})) + +class ResizeObserverStub implements ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +function createMemoryStorage(): Storage { + const values = new Map() + return { + get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), + } +} + +const detail: FinanceFeeSourceDetail = { + poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3, + 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, + sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [], +} + +async function mountView(permissions: string[]) { + const pinia = createPinia(); setActivePinia(pinia) + const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions + useAccessPreviewStore().initializeAfterLogin() + const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } }) + await flushPromises() + return wrapper +} + +async function loadRows(wrapper: Awaited>) { + const vm = wrapper.vm as unknown as { load(model: Record): Promise } + await vm.load({ customerId: '10001', direction: '', status: '', keyword: '' }) + await flushPromises() +} + +enableAutoUnmount(afterEach) +afterAll(() => vi.unstubAllGlobals()) + +describe('FinanceCustomerArapView G2 operations', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverStub) + vi.stubGlobal('localStorage', createMemoryStorage()) + vi.stubGlobal('sessionStorage', createMemoryStorage()) + document.body.innerHTML = '' + const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource + vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }]) + vi.mocked(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 }) + }) + + it('does not expose or call G2 operations without their permissions', async () => { + const wrapper = await mountView(['platform:finance:bill:view']) + await loadRows(wrapper) + + const buttonLabels = wrapper.findAll('button').map((button) => button.text()) + expect(buttonLabels).not.toContain('详情') + expect(buttonLabels).not.toContain('审核') + expect(buttonLabels).not.toContain('结算') + expect(getFinanceFeeSource).not.toHaveBeenCalled() + expect(reviewFinanceFeeSource).not.toHaveBeenCalled() + expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled() + }) + + it('loads detail and submits review and settlement changes with the returned version', async () => { + const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) + await loadRows(wrapper) + 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' } + + await vm.openDetail(row); await flushPromises() + expect(getFinanceFeeSource).toHaveBeenCalledWith('42') + expect(document.body.textContent).toContain('IB-91') + expect(document.body.textContent).toContain('100.00 CNY') + + 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 () => { + vi.mocked(getFinanceFeeSource).mockRejectedValueOnce(new ApiResponseError('费用来源不可用', 'FIN-FEE-404', 404, undefined, 'req-g2-error', 'trace-g2-error')) + const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view']) + await loadRows(wrapper) + const vm = wrapper.vm as unknown as { openDetail(row: Record): Promise } + + await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' }); await flushPromises() + + expect(document.body.textContent).toContain('费用来源不可用') + expect(document.body.textContent).toContain('req-g2-error') + expect(document.body.textContent).toContain('trace-g2-error') + }) +}) diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index cbb84520..c1686d93 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -1,20 +1,25 @@ @@ -96,13 +156,28 @@ onMounted(() => void loadCustomers())
- +
- {{ selected.direction === 'RECEIVABLE' ? '应收' : '应付' }}{{ statusLabel(selected.status) }}{{ selected.customerName }}{{ selected.documentNo }}{{ selected.sourceType }}{{ selected.sourceId }}{{ selected.feeName }}{{ selected.amount }} CNY{{ selected.occurredAt }} + + + + + + + + + + + + + @@ -110,7 +185,10 @@ onMounted(() => void loadCustomers()) .finance-arap-page { min-width: 0; } .row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; } .row-actions .el-button { margin-left: 0; } +.muted-action { color: var(--cc-muted); } .arap-detail { margin-bottom: 16px; } +.operation-log h3 { margin: 0 0 14px; font-size: 15px; } +.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); } @media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } } @media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } } diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index 2c767989..0851c424 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -12,15 +12,16 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import { getFinanceBill, listFinanceStatements } from '../api/finance' +import { financeStatusLabel, financeStatusTone } from '../status' import type { FinanceBillDetail, FinanceStatementReadModel } from '../types' const access = useAccessPreviewStore(); const customers = ref([]); const filters = reactive({ customerId: '' }); const rows = ref([]); const loading = ref(false); const error = ref(null); const customerError = ref(null); const drawerOpen = ref(false); const detail = ref(null); const detailError = ref(null) -const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }] +const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }] async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } } async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, '客户对账单加载失败,请重试') } finally { loading.value = false } } async function openDetail(row: FinanceStatementReadModel) { drawerOpen.value = true; detail.value = null; detailError.value = null; try { detail.value = await getFinanceBill(row.id) } catch (e) { detailError.value = getApiErrorDetails(e, '对账单详情加载失败,请重试') } } onMounted(() => void loadCustomers()) diff --git a/src/modules/finance/pages/FinanceFormalSettlementPage.vue b/src/modules/finance/pages/FinanceFormalSettlementPage.vue index 06ae95d2..49f2c8d6 100644 --- a/src/modules/finance/pages/FinanceFormalSettlementPage.vue +++ b/src/modules/finance/pages/FinanceFormalSettlementPage.vue @@ -12,6 +12,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import { financePhase1Capabilities, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance' +import { financeStatusLabel, financeStatusTone } from '../status' import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types' const props = defineProps<{ kind: 'receivable' | 'payable' }>() @@ -28,14 +29,16 @@ const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:fi const canView = computed(() => access.can(viewPermission.value)) const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付') const blocked = computed(() => props.kind === 'receivable' ? !financePhase1Capabilities.receivableSettlementCreate : !financePhase1Capabilities.payableSettlementCreate) -const fields = computed(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]) +const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) +const fields = computed(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]) const columns = computed(() => props.kind === 'receivable' - ? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }] - : [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'customerId', label: '客户', minWidth: 140 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120 }]) + ? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }] + : [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]) async function loadCustomers() { try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } } async function load(model: QueryModel = filters) { Object.assign(filters, model); rows.value = []; error.value = null; if (!filters.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.customerId)) : await listFinancePayableSettlements(String(filters.customerId)) } catch (e) { error.value = getApiErrorDetails(e, `${title.value}加载失败,请重试`) } finally { loading.value = false } } function openDetail(row: FinanceReceivableSettlement | FinancePayableSettlement) { selected.value = row; drawerOpen.value = true } function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount } +function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' } onMounted(() => void loadCustomers()) - + @@ -498,7 +499,7 @@ onMounted(() => { void load(); void loadCustomerOptions() }) 返回订单列表 diff --git a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue index 713fb1d2..17bfbd82 100644 --- a/src/modules/inbounds/components/InboundWorkflowWorkspace.vue +++ b/src/modules/inbounds/components/InboundWorkflowWorkspace.vue @@ -14,6 +14,7 @@ import { InboundMeasurementPanel } from '../measurement' import { WeightOcrPanel } from '../weight' import { MeasurementReviewPanel } from '../review' import { InboundFeePanel } from '../fee' +import { inboundProcessStatusLabels, inboundStatusLabel } from '../status' const props = defineProps<{ customerId: string @@ -35,20 +36,7 @@ let disposed = false const title = computed(() => detail.value?.summary.inboundCode || props.inboundId) const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view')) -const statusLabel = computed(() => { - const status = detail.value?.summary.processStatus || '' - return ({ - WAIT_MEASURE: '待测量', - MEASURING: '测量中', - MEASURE_PENDING: '待审核', - MEASURE_REJECTED: '测量已驳回', - WAIT_PRICING: '待计费', - FEE_CONFIRMING: '费用确认中', - INBOUNDING: '入库处理中', - INBOUNDED: '已入库', - VOIDED: '已作废', - } as Record)[status] || status || '未知状态' -}) +const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态')) async function loadDetail() { const serial = ++requestSerial diff --git a/src/modules/inbounds/core/InboundHistoryPanel.vue b/src/modules/inbounds/core/InboundHistoryPanel.vue index 5a06668d..4b03447a 100644 --- a/src/modules/inbounds/core/InboundHistoryPanel.vue +++ b/src/modules/inbounds/core/InboundHistoryPanel.vue @@ -1,5 +1,6 @@