From c93eea89c8c916fbfa1013862fec1754f9171577 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 16:13:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/FinanceCustomerArapView.test.ts | 20 +-- .../finance/pages/FinanceCustomerArapView.vue | 54 +++--- .../FinanceCustomerStatementsView.test.ts | 43 ++++- .../pages/FinanceCustomerStatementsView.vue | 155 +++++++++++++++++- 4 files changed, 226 insertions(+), 46 deletions(-) diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts index 1c188656..e41b5bf1 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts @@ -11,7 +11,7 @@ import { createPlatformExportTask } from '@/modules/downloads' import { ApiResponseError } from '@/shared/api/http' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' -import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance' +import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance' import type { FinanceFeeSourceDetail, FinanceFeeSourceSummary } from '../types' import FinanceCustomerArapView from './FinanceCustomerArapView.vue' @@ -23,7 +23,6 @@ vi.mock('../api/finance', () => ({ getFinanceFeeSource: vi.fn(), reviewFinanceFeeSource: vi.fn(), editFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(), - createReconciliationStatement: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { @@ -62,6 +61,7 @@ async function mountView(permissions: string[], initialPath = '/finance/customer const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView }, { path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView }, + { path: '/finance/customer-statements', name: 'finance-customer-statements', component: { template: '
客户对账单
' } }, ] }) await router.push(initialPath); await router.isReady() const wrapper = mount(FinanceCustomerArapView, { @@ -96,7 +96,6 @@ describe('FinanceCustomerArapView G2 operations', () => { 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) }) @@ -254,18 +253,19 @@ describe('FinanceCustomerArapView G2 operations', () => { expect(wrapper.text()).not.toContain(customerName) }) - it('creates a reconciliation draft from selected eligible receivables', async () => { + it('continues selected eligible receivables in the customer statement generation center', async () => { const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:finance:reconciliation:create']) await loadRows(wrapper) const vm = wrapper.vm as unknown as { selectedRows: Array> - generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string } - submitGeneration(): Promise + continueSelectedGeneration(): Promise } - 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: '八月费用' }) + vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', status: 'AVAILABLE', statementGenerated: false, amount: 100, businessDate: '2026-08-11' }] + await vm.continueSelectedGeneration() + expect(wrapper.router.currentRoute.value).toMatchObject({ + name: 'finance-customer-statements', + query: { createMode: 'SELECTED', customerId: '10001', feeSourceIds: '42', periodStart: '2026-08-11', periodEnd: '2026-08-11' }, + }) }) it('creates a fee-source export task with the complete active filters and typed booleans', async () => { diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue index bcd32203..d3d7b75c 100644 --- a/src/modules/finance/pages/FinanceCustomerArapView.vue +++ b/src/modules/finance/pages/FinanceCustomerArapView.vue @@ -18,7 +18,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 { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' -import { adjustFinanceFeeSource, createReconciliationStatement, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance' +import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance' import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status' import type { FinanceFeeSourceDetail } from '../types' @@ -72,10 +72,6 @@ const mutationError = ref(null) const mutationMode = ref<'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID'>('EDIT') const mutationForm = reactive({ amount: 0, reason: '' }) const mutationTitle = computed(() => ({ EDIT: '编辑费用', ADJUST: '调整费用', SUBMIT: '提交审核', VOID: '作废费用' } as const)[mutationMode.value]) -const generationOpen = ref(false) -const generationSaving = ref(false) -const generationError = ref(null) -const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; cutoffDate: string; remark: string }>({ mode: 'SELECTED', customerId: '', period: [], cutoffDate: '', remark: '' }) const selectedRows = ref([]) const page = ref(1) const pageSize = ref(20) @@ -101,7 +97,7 @@ const queryFields = computed(() => [ { key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } }, ]) const columns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' }, + { key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' }, { key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' }, { key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' }, @@ -247,22 +243,31 @@ async function submitMutation() { function statusLabel(status: string) { return financeStatusLabel(status) } function money(value: unknown) { return Number(value || 0).toFixed(2) } function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows } -function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') { - generationForm.mode = mode - generationForm.customerId = '' - generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : [] - generationForm.cutoffDate = generationForm.period[1] || '' - generationForm.remark = '' - generationError.value = null - generationOpen.value = true +function isSelectableReceivable(row: ArapRow) { + return Boolean(row.feeSourceId) + && row.direction === 'RECEIVABLE' + && row.reviewStatus === 'APPROVED' + && row.status === 'AVAILABLE' + && row.amount > 0 + && row.statementGenerated === false } -async function submitGeneration() { - const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && row.amount > 0 && row.statementGenerated === false && row.customerId === generationForm.customerId) - const period = generationForm.period - if (!generationForm.customerId || !period[0] || !period[1]) { ElMessage.warning('请选择客户和业务期间'); return } - if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、有效且未出账的应收费用'); return } - generationSaving.value = true; generationError.value = null - try { await createReconciliationStatement({ customerId: generationForm.customerId, currency: 'CNY', generationMode: generationForm.mode, feeSourceIds: generationForm.mode === 'SELECTED' ? eligible.map((row) => row.feeSourceId as string) : undefined, periodStart: period[0], periodEnd: period[1], remark: generationForm.remark.trim() }); generationOpen.value = false; selectedRows.value = []; ElMessage.success('对账单草稿已生成'); await load() } catch (error) { generationError.value = getApiErrorDetails(error, '对账单生成失败,请重试') } finally { generationSaving.value = false } +async function continueSelectedGeneration() { + if (!selectedRows.value.length) return ElMessage.warning('请先勾选需要生成对账单的应收费用') + if (selectedRows.value.some((row) => !isSelectableReceivable(row))) return ElMessage.warning('所选费用中存在不可生成对账单的记录,请刷新后重新选择') + const customerIds = [...new Set(selectedRows.value.map((row) => row.customerId))] + if (customerIds.length !== 1) return ElMessage.warning('一次只能选择同一客户的费用生成对账单') + const businessDates = selectedRows.value.map((row) => row.businessDate).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value)).sort() + if (businessDates.length !== selectedRows.value.length) return ElMessage.warning('所选费用缺少有效业务日期,暂不能生成对账单') + await router.push({ + name: 'finance-customer-statements', + query: { + createMode: 'SELECTED', + customerId: customerIds[0], + feeSourceIds: selectedRows.value.map((row) => row.feeSourceId as string).join(','), + periodStart: businessDates[0], + periodEnd: businessDates.at(-1), + }, + }) } async function exportFeeSources() { const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : [] @@ -315,14 +320,14 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts index 16bf1994..cba0f508 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts @@ -10,8 +10,8 @@ import { useAuthStore } from '@/core/auth/auth' import { createPlatformExportTask } from '@/modules/downloads' import { listPlatformTenants } from '@/modules/iam' import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' -import { getReconciliationStatement, listFinanceStatements } from '../api/finance' -import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types' +import { createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance' +import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types' import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue' vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) @@ -19,7 +19,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) vi.mock('../api/finance', () => ({ actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(), - getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(), + getReconciliationStatement: vi.fn(), listFinanceFeeSources: vi.fn(), listFinanceStatements: vi.fn(), })) class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} } @@ -44,6 +44,13 @@ const detail: FinanceReconciliationDetail = { paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }], } +const eligibleFeeSource: FinanceFeeSourceSummary = { + poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IN-21', + sourceId: '81', sourceVersion: 2, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null, + supplierName: '', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: null, containerNo: '', businessDate: '2026-08-15', + feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 320, currency: 'CNY', reviewStatus: 'APPROVED', + sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15', +} async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') { const pinia = createPinia(); setActivePinia(pinia) @@ -73,6 +80,8 @@ describe('FinanceCustomerStatementsView', () => { vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(listFinanceStatements).mockResolvedValue([statement]) vi.mocked(getReconciliationStatement).mockResolvedValue(detail) + vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [eligibleFeeSource], total: 1, pages: 1 }) + vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({ ...detail, id: '502', billNo: 'REC-502', status: 'DRAFT' }) vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never) vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false) }) @@ -255,6 +264,34 @@ describe('FinanceCustomerStatementsView', () => { expect(listPlatformTenants).not.toHaveBeenCalled() }) + it('accepts selected fee sources from customer arap and creates them in the statement page', async () => { + const permissions = [ + 'platform:finance:customer-statement:list', + 'platform:finance:customer-arap:list', + 'platform:finance:reconciliation:create', + 'platform:customer:list', + ] + const { wrapper, router } = await mountPage(permissions, '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15') + const vm = wrapper.vm as unknown as { + createOpen: boolean + createForm: { generationMode: string; customerId: string; feeSourceIds: string[]; periodStart: string; periodEnd: string } + createStatement(): Promise + } + expect(vm.createOpen).toBe(true) + expect(vm.createForm).toMatchObject({ generationMode: 'SELECTED', customerId: '10001', feeSourceIds: ['42'], periodStart: '2026-08-15', periodEnd: '2026-08-15' }) + expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({ + customerId: '10001', direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false, + businessDateFrom: '2026-08-15', businessDateTo: '2026-08-15', + })) + + await vm.createStatement() + expect(createReconciliationStatement).toHaveBeenCalledWith({ + customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], + periodStart: '2026-08-15', periodEnd: '2026-08-15', remark: undefined, + }) + expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } }) + }) + it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => { const { wrapper } = await mountPage(['platform:finance:customer-statement:list']) await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise }).openDetail(statement) diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index 6cc8fff2..709829df 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -17,9 +17,9 @@ 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 { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog' -import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance' +import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance' import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status' -import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types' +import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types' interface AdjustmentRow { selected: boolean @@ -61,14 +61,20 @@ const activeDetailTab = ref<'lines' | 'payments'>('lines') const detailCache = new Map() const createOpen = ref(false) const createBusy = ref(false) +const createSourcesLoading = ref(false) +const createSourcesError = ref(null) +const createSourceOptions = ref([]) const exportBusy = ref(false) const actionBusy = ref(false) const createForm = ref({ + generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED', customerId: '', periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), + feeSourceIds: [] as string[], remark: '', }) +let createSourceLoadSequence = 0 const adjustOpen = ref(false) const adjustBusy = ref(false) const adjustStatement = ref(null) @@ -78,6 +84,7 @@ const adjustLines = ref([]) const canView = computed(() => access.can('platform:finance:customer-statement:list')) const canListCustomers = computed(() => access.can('platform:customer:list')) const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list')) +const canSelectSources = computed(() => access.can('platform:finance:customer-arap:list')) const canCreate = computed(() => access.can('platform:finance:reconciliation:create')) const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) const canReview = computed(() => access.can('platform:finance:reconciliation:review')) @@ -86,6 +93,8 @@ const canAdjust = computed(() => access.can('platform:finance:reconciliation:adj const canVoid = computed(() => access.can('platform:finance:reconciliation:void')) const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list')) const canGenerate = computed(() => canCreate.value && canListCustomers.value) +const selectedCreateSources = computed(() => createSourceOptions.value.filter((item) => createForm.value.feeSourceIds.includes(item.feeSourceId))) +const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((total, item) => total + Number(item.amount || 0), 0)) const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '') const isDetailRoute = computed(() => Boolean(detailRouteId.value)) const fields = computed(() => [ @@ -366,22 +375,120 @@ async function exportStatements() { } } +function defaultCreateForm() { + return { + generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED', + customerId: '', + periodStart: new Date().toISOString().slice(0, 8) + '01', + periodEnd: new Date().toISOString().slice(0, 10), + feeSourceIds: [] as string[], + remark: '', + } +} + function openCreate() { if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单') - createForm.value = { ...createForm.value, customerId: '' } + createForm.value = defaultCreateForm() + createSourceOptions.value = [] + createSourcesError.value = null createOpen.value = true } +async function loadEligibleCreateSources(preferredIds: string[] = createForm.value.feeSourceIds) { + const sequence = ++createSourceLoadSequence + createSourcesError.value = null + createSourceOptions.value = [] + if (createForm.value.generationMode !== 'SELECTED' || !createForm.value.customerId + || !createForm.value.periodStart || !createForm.value.periodEnd || !canSelectSources.value) { + createForm.value.feeSourceIds = [] + return + } + createSourcesLoading.value = true + try { + const records: FinanceFeeSourceSummary[] = [] + let currentPage = 1 + let pages = 1 + do { + const result = await listFinanceFeeSources({ + page: currentPage, + size: 200, + customerId: createForm.value.customerId, + direction: 'RECEIVABLE', + status: 'AVAILABLE', + reviewStatus: 'APPROVED', + statementGenerated: false, + businessDateFrom: createForm.value.periodStart, + businessDateTo: createForm.value.periodEnd, + }) + records.push(...result.records) + pages = Math.max(1, result.pages) + currentPage += 1 + } while (currentPage <= pages) + if (sequence !== createSourceLoadSequence) return + createSourceOptions.value = records.filter((item) => item.direction === 'RECEIVABLE' + && item.reviewStatus === 'APPROVED' && item.status === 'AVAILABLE' + && !item.statementGenerated && item.amount > 0) + const availableIds = new Set(createSourceOptions.value.map((item) => item.feeSourceId)) + createForm.value.feeSourceIds = preferredIds.filter((id) => availableIds.has(id)) + if (preferredIds.length && createForm.value.feeSourceIds.length !== preferredIds.length) { + ElMessage.warning('部分带入费用已被占用或不再符合生成条件,请重新核对') + } + } catch (cause) { + if (sequence !== createSourceLoadSequence) return + createSourcesError.value = getApiErrorDetails(cause, '可生成费用加载失败') + createForm.value.feeSourceIds = [] + } finally { + if (sequence === createSourceLoadSequence) createSourcesLoading.value = false + } +} + +function queryText(value: unknown) { + return Array.isArray(value) ? String(value[0] || '') : String(value || '') +} + +async function openSelectedCreateFromRoute() { + if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return + if (!canGenerate.value || !canSelectSources.value) { + ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单') + return + } + const feeSourceIds = queryText(route.query.feeSourceIds).split(',').map((value) => value.trim()).filter((value) => /^[1-9]\d*$/.test(value)) + const customerId = queryText(route.query.customerId) + const periodStart = queryText(route.query.periodStart) + const periodEnd = queryText(route.query.periodEnd) + if (!customerId || !feeSourceIds.length || !/^\d{4}-\d{2}-\d{2}$/.test(periodStart) || !/^\d{4}-\d{2}-\d{2}$/.test(periodEnd)) { + ElMessage.warning('带入的对账单生成参数不完整,请重新选择费用') + return + } + if (!activeCustomers.value.some((item) => String(item.id) === customerId)) { + ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用') + return + } + createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' } + await loadEligibleCreateSources(feeSourceIds) + createOpen.value = true + await router.replace({ name: 'finance-customer-statements' }) +} + async function createStatement() { if (!createForm.value.customerId) return ElMessage.warning('请先选择客户') if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期') if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期') + if (createForm.value.generationMode === 'SELECTED' && !createForm.value.feeSourceIds.length) return ElMessage.warning('请选择至少一笔应收费用') createBusy.value = true try { - await createReconciliationStatement({ customerId: String(createForm.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined }) + const created = await createReconciliationStatement({ + customerId: String(createForm.value.customerId), + currency: 'CNY', + generationMode: createForm.value.generationMode, + feeSourceIds: createForm.value.generationMode === 'SELECTED' ? createForm.value.feeSourceIds : undefined, + periodStart: createForm.value.periodStart, + periodEnd: createForm.value.periodEnd, + remark: createForm.value.remark || undefined, + }) createOpen.value = false ElMessage.success('对账单已生成') - await load(filters.value) + await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } }) } catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') } finally { createBusy.value = false } } @@ -431,7 +538,17 @@ async function submitAdjustment() { finally { adjustBusy.value = false } } -onMounted(() => { if (canListCustomers.value) void loadCustomers() }) +onMounted(async () => { + if (canListCustomers.value) await loadCustomers() + await openSelectedCreateFromRoute() +}) +watch( + () => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd], + ([open, mode]) => { + if (!open || mode !== 'SELECTED') return + void loadEligibleCreateSources() + }, +) watch(detailRouteId, (statementId) => { if (statementId) void loadDetail(statementId) else { @@ -568,7 +685,29 @@ watch(detailRouteId, (statementId) => { - + + + + + 按账期自动汇总 + 指定费用生成 + + + +
+ + +
+
@@ -580,6 +719,8 @@ watch(detailRouteId, (statementId) => { diff --git a/src/modules/unified-orders/api/inboundFeeDetails.test.ts b/src/modules/unified-orders/api/inboundFeeDetails.test.ts index 5ae03bd6..5bed6536 100644 --- a/src/modules/unified-orders/api/inboundFeeDetails.test.ts +++ b/src/modules/unified-orders/api/inboundFeeDetails.test.ts @@ -3,7 +3,7 @@ import { http } from '@/shared/api/http' import { adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions, reviewInboundFeeDetail, - updateInboundFeeDetail, voidInboundFeeDetail, type SaveInboundFeeDetailRequest, + updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail, type SaveInboundFeeDetailRequest, } from './inboundFeeDetails' vi.mock('@/shared/api/http', async () => { @@ -19,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, supplierId: '41', remark: '验收' } + const payload: SaveInboundFeeDetailRequest = { feeItemId: '31', direction: 'PAYABLE', businessDate: '2026-08-12', unitPrice: 12.5, currency: 'CNY', amount: 125, supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-31', paymentVoucherFileIds: ['9001'], 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 }) @@ -34,6 +34,14 @@ describe('inbound fee-detail API', () => { expect(http.delete).toHaveBeenCalledWith('/platform/v1/inbounds/88/fee-details/11', { params: { accountId: '100' } }) }) + it('reuses the scoped FINANCE_VOUCHER upload and fails closed on customer mismatch', async () => { + vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { fileId: 9001, tenantId: 100, fileName: '付款凭证.pdf' } }) + await expect(uploadInboundFeePaymentVoucher('100', new File(['x'], '付款凭证.pdf'))).resolves.toEqual({ fileId: '9001', fileName: '付款凭证.pdf' }) + expect(http.post).toHaveBeenCalledWith('/platform/finance/v1/vouchers', expect.any(FormData), { params: { customerId: '100' }, headers: { 'Content-Type': 'multipart/form-data' } }) + vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: { fileId: 9002, tenantId: 101, fileName: '错客户.pdf' } }) + await expect(uploadInboundFeePaymentVoucher('100', new File(['x'], '错客户.pdf'))).rejects.toThrow('付款凭证与当前客户不匹配') + }) + it('keeps an empty supplier category as null and rejects an unknown non-empty category', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [ { id: 31, feeTypeName: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehousePayable: true }, diff --git a/src/modules/unified-orders/api/inboundFeeDetails.ts b/src/modules/unified-orders/api/inboundFeeDetails.ts index 1a7a4793..f8d77e4f 100644 --- a/src/modules/unified-orders/api/inboundFeeDetails.ts +++ b/src/modules/unified-orders/api/inboundFeeDetails.ts @@ -3,6 +3,8 @@ import { listSuppliers, type SupplierCategory } from '@/modules/suppliers' export type InboundFeeDirection = 'RECEIVABLE' | 'PAYABLE' export type InboundFeeReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' +export type FeeSettlementMode = 'ON_ACCOUNT' | 'DIRECT_PAID' +export type FeePaymentMethod = 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' export interface InboundFeeDetail { id: string | number @@ -15,6 +17,11 @@ export interface InboundFeeDetail { supplierId: string | number | null supplierName: string | null supplierCategory: SupplierCategory | null + settlementMode?: FeeSettlementMode | null + paidAt?: string | null + paymentMethod?: FeePaymentMethod | null + paymentReferenceNo?: string | null + paymentVoucherFileIds?: Array | null businessDate: string customerName: string | null feeTypeNameSnapshot: string @@ -37,6 +44,11 @@ export interface SaveInboundFeeDetailRequest { currency: string amount: number supplierId: string | number | null + settlementMode: FeeSettlementMode | null + paidAt: string | null + paymentMethod: FeePaymentMethod | null + paymentReferenceNo: string | null + paymentVoucherFileIds: Array | null remark: string | null } @@ -50,6 +62,7 @@ export interface InboundFeeItemOption { } export interface InboundFeeSupplierOption { id: string; supplierName: string; category: SupplierCategory } +export interface FeePaymentVoucher { fileId: string; fileName: string } const supplierCategories: SupplierCategory[] = ['BOOKING', 'CUSTOMS', 'TRUCKING', 'CERTIFICATE'] @@ -123,3 +136,14 @@ export async function listInboundFeeSupplierOptions(category: SupplierCategory | id: supplier.id, supplierName: supplier.name, category: supplier.category, })) } + +export async function uploadInboundFeePaymentVoucher(customerId: string, file: File): Promise { + const body = new FormData(); body.append('file', file) + const raw = unwrapApiResponse(await http.post>>('/platform/finance/v1/vouchers', body, { + params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' }, + })) + const fileId = raw.fileId == null ? '' : String(raw.fileId) + const echoedCustomerId = raw.tenantId == null ? String(raw.customerId ?? customerId) : String(raw.tenantId) + if (!fileId || echoedCustomerId !== customerId) throw new Error('付款凭证与当前客户不匹配') + return { fileId, fileName: String(raw.fileName || raw.file_name || file.name) } +} diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts index 7066a41a..c84d13ac 100644 --- a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts +++ b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts @@ -10,7 +10,7 @@ import { ApiResponseError } from '@/shared/api/http' import { adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeeSupplierOptions, - reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail, + reviewInboundFeeDetail, updateInboundFeeDetail, uploadInboundFeePaymentVoucher, voidInboundFeeDetail, } from '@/modules/unified-orders/api/inboundFeeDetails' import InboundFeeDetailManager from './InboundFeeDetailManager.vue' import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails' @@ -18,12 +18,13 @@ import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDe vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({ 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(), + reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), uploadInboundFeePaymentVoucher: vi.fn(), voidInboundFeeDetail: vi.fn(), })) const pending: InboundFeeDetail = { id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1, inboundCode: 'RK-88', direction: 'RECEIVABLE', supplierId: null, supplierName: null, supplierCategory: null, + settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: 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', @@ -49,6 +50,7 @@ describe('InboundFeeDetailManager', () => { vi.mocked(createInboundFeeDetail).mockResolvedValue(pending) vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending) vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' }) + vi.mocked(uploadInboundFeePaymentVoucher).mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' }) vi.mocked(reviewInboundFeeDetail).mockResolvedValue(null) vi.mocked(deleteInboundFeeDetail).mockResolvedValue(null) vi.mocked(voidInboundFeeDetail).mockResolvedValue(null) @@ -81,6 +83,13 @@ describe('InboundFeeDetailManager', () => { for (const action of ['编辑', '审核', '驳回', '调整', '作废', '删除']) expect(buttons).not.toContain(action) }) + it('shows payable settlement meaning and a DIRECT_PAID summary without changing fee direction', async () => { + vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-11', paymentVoucherFileIds: ['9001'] }]) + const wrapper = mountManager(['firstleg:inbound:fee-detail:view']) + await flushPromises() + expect(wrapper.text()).toContain('应付'); expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-11') + }) + it('does not request or expose fee rows without view permission', async () => { mountManager([]); await flushPromises() expect(listInboundFeeDetails).not.toHaveBeenCalled() @@ -163,7 +172,7 @@ describe('InboundFeeDetailManager', () => { }) 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(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', supplierId: '41', supplierName: '测试供应商', supplierCategory: 'TRUCKING', settlementMode: 'ON_ACCOUNT' }]) 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', @@ -191,11 +200,45 @@ describe('InboundFeeDetailManager', () => { 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'; await flushPromises(); state.form.supplierId = '41'; state.form.amount = 100 + const state = (wrapper.vm.$ as unknown as { setupState: { save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState + state.form.direction = 'PAYABLE'; state.form.feeItemId = '31'; await flushPromises(); state.form.supplierId = '41'; state.form.settlementMode = 'ON_ACCOUNT'; state.form.amount = 100 await state.save(); await flushPromises() expect(listInboundFeeSupplierOptions).toHaveBeenCalledWith(null) - expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41' })) + expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ direction: 'PAYABLE', supplierId: '41', settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null })) + }) + + it('clears settlement and paid fields when switching to RECEIVABLE or ON_ACCOUNT', async () => { + 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: { form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState + Object.assign(state.form, { direction: 'PAYABLE', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'CASH', paymentReferenceNo: 'CASH-1', paymentVoucherFileIds: ['9001'] }); await flushPromises() + state.form.settlementMode = 'ON_ACCOUNT'; await flushPromises() + expect(state.form).toMatchObject({ settlementMode: 'ON_ACCOUNT', paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }) + state.form.direction = 'RECEIVABLE'; await flushPromises() + expect(state.form).toMatchObject({ direction: 'RECEIVABLE', settlementMode: null, paidAt: null, paymentMethod: null, paymentReferenceNo: null, paymentVoucherFileIds: null }) + }) + + it('requires complete DIRECT_PAID evidence and submits uploaded voucher IDs only after scoped upload succeeds', async () => { + 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; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState + Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: null, amount: 100 }); await flushPromises() + await state.save(); expect(createInboundFeeDetail).not.toHaveBeenCalled() + const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] }) + await state.selectVoucher({ target: input } as unknown as Event); await state.save() + expect(uploadInboundFeePaymentVoucher).toHaveBeenCalledWith('100', expect.objectContaining({ name: '付款凭证.pdf' })) + expect(createInboundFeeDetail).toHaveBeenCalledWith('100', '88', expect.objectContaining({ settlementMode: 'DIRECT_PAID', paymentMethod: 'BANK_TRANSFER', paymentVoucherFileIds: ['9001'] })) + }) + + it('keeps DIRECT_PAID save closed and exposes feedback when voucher upload fails', async () => { + vi.mocked(uploadInboundFeePaymentVoucher).mockRejectedValue(new ApiResponseError('File服务不可用', 'A0503', 503, null, 'request-file', 'trace-file')) + 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; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState + Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '31', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: 'ONLINE-1', amount: 100 }); await flushPromises() + const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '失败凭证.png')] }) + await state.selectVoucher({ target: input } as unknown as Event); await state.save(); await flushPromises() + expect(createInboundFeeDetail).not.toHaveBeenCalled(); expect(document.body.textContent).toContain('request-file'); expect(document.body.textContent).toContain('trace-file') }) it('refreshes fee rows and the order detail after every successful write', () => { diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.vue b/src/modules/unified-orders/components/InboundFeeDetailManager.vue index a1f64154..5dfcde6d 100644 --- a/src/modules/unified-orders/components/InboundFeeDetailManager.vue +++ b/src/modules/unified-orders/components/InboundFeeDetailManager.vue @@ -1,7 +1,7 @@ @@ -240,6 +289,8 @@ onMounted(() => void load()) + + @@ -265,6 +316,17 @@ onMounted(() => void load()) + 账期应付已付成本 + @@ -288,6 +350,7 @@ onMounted(() => void load()) .fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; } .fee-form :deep(.el-select), .fee-form :deep(.el-date-editor), .fee-form :deep(.el-input-number) { width: 100%; } .fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; } +.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; } .fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; } .fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; } @keyframes fee-refresh-spin { to { transform: rotate(360deg); } } From c91c87784e1cb2088c8bb88d44c58f0151fb4ee1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 20 Aug 2026 18:05:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loading-lists/components/ContainerFeesPanel.test.ts | 3 ++- src/modules/loading-lists/components/ContainerFeesPanel.vue | 6 +++++- .../components/InboundFeeDetailManager.test.ts | 6 ++++-- .../unified-orders/components/InboundFeeDetailManager.vue | 6 +++++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index 456bb437..a6f2e4f2 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -225,7 +225,8 @@ describe('ContainerFeesPanel contract', () => { vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }]) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list']) const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState - state.openCreate(); await flushPromises(); Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '7', supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: null, amount: 100 }); await flushPromises() + state.openCreate(); await flushPromises(); Object.assign(state.form, { direction: 'PAYABLE', feeItemId: '7' }); await flushPromises() + Object.assign(state.form, { supplierId: '41', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'ONLINE', paymentReferenceNo: null, amount: 100 }); await flushPromises() await state.save(); expect(createContainerFee).not.toHaveBeenCalled() const input = document.createElement('input'); Object.defineProperty(input, 'files', { value: [new File(['x'], '付款凭证.pdf')] }) await state.selectVoucher({ target: input } as unknown as Event); await state.save() diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index 757b278c..90c315f6 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -13,6 +13,10 @@ import { adjustContainerFee, approveContainerFee, createContainerFee, listApplic const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>() const access = useAccessPreviewStore() +const settlementModeModel = computed({ + get: () => form.settlementMode ?? '', + set: (value) => { form.settlementMode = value === 'ON_ACCOUNT' || value === 'DIRECT_PAID' ? value : null }, +}) const accountId = ref('') const fees = ref([]) const feeItems = ref([]) @@ -293,7 +297,7 @@ onMounted(() => void load()) - 账期应付已付成本 + 账期应付已付成本