diff --git a/src/modules/finance/pages/FinanceArapStatementDialogs.test.ts b/src/modules/finance/pages/FinanceArapStatementDialogs.test.ts index 887b9570..d3ee2dc5 100644 --- a/src/modules/finance/pages/FinanceArapStatementDialogs.test.ts +++ b/src/modules/finance/pages/FinanceArapStatementDialogs.test.ts @@ -60,7 +60,7 @@ function setFee(overrides: Partial = {}) { vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 }) vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current) } -async function mountPage(kind: 'arap' | 'statements') { +async function mountPage(kind: 'arap' | 'statements', cachedWorkspace = false) { 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 }, @@ -69,7 +69,10 @@ async function mountPage(kind: 'arap' | 'statements') { ] }) await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements') await router.isReady() - const wrapper = mount(kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) + const component = cachedWorkspace + ? { template: '' } + : kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView + const wrapper = mount(component, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() return { wrapper, router } } @@ -99,17 +102,6 @@ async function fillCreation(dialog: VueWrapper, selected = false) { await flushPromises() await dialog.get('textarea').setValue('测试备注') } -async function openAdjustment() { - const { wrapper, router } = await mountPage('statements') - await rowClick(wrapper, '调整') - return { wrapper, router, dialog: activeDialog(wrapper) } -} -async function fillAdjustment(dialog: VueWrapper) { - await dialog.get('textarea[placeholder="请输入整体调整原因"]').setValue(' 整体调整 ') - await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true) - dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 88.12) - await dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').setValue(' 行说明 ') -} enableAutoUnmount(afterEach) beforeEach(() => { vi.resetAllMocks() @@ -233,6 +225,30 @@ describe('fee source and statement native dialogs', () => { await click(dialog, '确定') expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] })) }) + it.each([false, true])('opens selected statement generation directly from arap with a cached statement page=%s', async cached => { + setFee({ reviewStatus: 'APPROVED' }) + const { wrapper, router } = await mountPage(cached ? 'statements' : 'arap', true) + const cachedPage = cached ? wrapper.getComponent(FinanceCustomerStatementsView).element : null + if (cached) { + await click(wrapper, '生成对账单') + await click(activeDialog(wrapper), '取消') + await router.push({ name: 'finance-customer-arap' }) + await flushPromises() + } + await wrapper.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true) + await click(wrapper, '生成所选对账单') + + const page = wrapper.getComponent(FinanceCustomerStatementsView) + if (cachedPage) expect(page.element).toBe(cachedPage) + const dialog = activeDialog(wrapper) + expect(dialog.props('title')).toBe('生成客户对账单') + expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('SELECTED') + expect(dialog.findAllComponents({ name: 'ElSelect' })[0]!.props('modelValue')).toBe(String(customer.id)) + expect(dialog.findAllComponents({ name: 'ElDatePicker' }).map(item => item.props('modelValue'))).toEqual(['2026-10-01', '2026-10-01']) + expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY') + expect(router.currentRoute.value.query).toEqual({}) + expect(api.createReconciliationStatement).not.toHaveBeenCalled() + }) it('preserves customer and period validation before creating', async () => { const { dialog } = await openCreate() await click(dialog, '确定') @@ -271,48 +287,14 @@ describe('fee source and statement native dialogs', () => { expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注') expect(dialog.props('modelValue')).toBe(true) }) - it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => { - vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) }) - const { dialog } = await openAdjustment() - expect(dialog.props('width')).toContain('900px') - expect(dialog.props('title')).toContain(statement.billNo) - expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) - expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined() - expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13) - expect(dialog.get('.el-table__body-wrapper').text()).toContain('费用13') - }) - it('saves only checked lines with full IDs and the freshly loaded version', async () => { - const { dialog } = await openAdjustment() - await fillAdjustment(dialog) - await dialog.get('button[aria-label="展开全屏"]').trigger('click') - await click(dialog, '确定') - expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action: 'ADJUST', reason: '整体调整', expectedVersion: 8, adjustmentLines: [{ statementLineId: '9223372036854775011', adjustedAmount: 88.12, reason: '行说明' }] }) - }) - it('requires an overall reason, selection and each selected line reason', async () => { - const { dialog } = await openAdjustment() - await click(dialog, '确定') + it.each(['APPROVED', 'ISSUED', 'PAID'])('does not offer adjustment or void after statement approval: %s', async status => { + vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }]) + const { wrapper } = await mountPage('statements') + const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) + expect(labels).toContain('详情') + expect(labels).not.toContain('调整') + expect(labels).not.toContain('作废') expect(api.actionReconciliationStatement).not.toHaveBeenCalled() - await dialog.get('textarea').setValue('整体调整') - await click(dialog, '确定') - expect(api.actionReconciliationStatement).not.toHaveBeenCalled() - await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true) - await click(dialog, '确定') - expect(api.actionReconciliationStatement).not.toHaveBeenCalled() - }) - it('locks adjustment rows and retains their state with visible failure diagnostics', async () => { - const pending = deferred() - vi.mocked(api.actionReconciliationStatement).mockReturnValue(pending.promise) - const { dialog } = await openAdjustment() - await fillAdjustment(dialog) - await click(dialog, '确定') - expect(dialog.props('showClose')).toBe(false) - expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined() - pending.reject(failure()) - await flushPromises() - expect(dialog.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝操作', requestId: 'finance-dialog-test' }) - expect((dialog.get('.el-table__body-wrapper input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true) - expect((dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').element as HTMLInputElement).value).toBe(' 行说明 ') - expect(dialog.props('modelValue')).toBe(true) }) it.each([ ['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'], diff --git a/src/modules/finance/pages/FinanceCustomerAccountsView.vue b/src/modules/finance/pages/FinanceCustomerAccountsView.vue index a225a281..9a684434 100644 --- a/src/modules/finance/pages/FinanceCustomerAccountsView.vue +++ b/src/modules/finance/pages/FinanceCustomerAccountsView.vue @@ -576,7 +576,7 @@ onBeforeUnmount(clearVoucherPreview) 原消费金额{{ money(refundSource?.entry.amount) }} -
+
diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts index f1702f70..4b2241bc 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.test.ts @@ -1,8 +1,9 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { defineComponent, h, KeepAlive, type VNode } from 'vue' import ElementPlus from 'element-plus' import { createPinia, setActivePinia } from 'pinia' -import { createMemoryHistory, createRouter } from 'vue-router' +import { createMemoryHistory, createRouter, RouterView } from 'vue-router' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAccessPreviewStore } from '@/core/access/accessPreview' @@ -53,7 +54,7 @@ const eligibleFeeSource: FinanceFeeSourceSummary = { sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-15', } -async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements') { +async function mountPage(permissions = ['platform:finance:customer-statement:list'], initialPath = '/finance/customer-statements', cached = false) { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() @@ -63,7 +64,10 @@ async function mountPage(permissions = ['platform:finance:customer-statement:lis { path: '/downloads', name: 'download-center', component: { template: '
下载中心
' } }, ] }) await router.push(initialPath); await router.isReady() - const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) + const cachedPage = defineComponent({ setup: () => () => h(RouterView, {}, { + default: ({ Component }: { Component: VNode }) => h(KeepAlive, {}, { default: () => Component }), + }) }) + const wrapper = mount(cached ? cachedPage : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) await flushPromises() return { wrapper, router } } @@ -121,6 +125,25 @@ describe('FinanceCustomerStatementsView', () => { expect(wrapper.find('[aria-label="支付历史"]').isVisible()).toBe(true) }) + it('shows overseas procurement as the source type in statement detail fee lines', async () => { + vi.mocked(getReconciliationStatement).mockResolvedValue({ + ...detail, + lines: ['开箱费', '提柜-额外费用'].map((feeItemName, index) => ({ + ...detail.lines[0]!, statementLineId: `procurement-line-${index}`, sourceType: 'OVERSEAS_PROCUREMENT_FEE', + sourceNo: 'CG-TEST-001', feeItemName, + })), + }) + const { wrapper } = await mountPage(['platform:finance:customer-statement:list'], '/finance/customer-statements/501') + const table = wrapper.get('[aria-label="客户对账单账单明细"]') + const rows = table.findAll('.el-table__body tbody tr') + expect(rows).toHaveLength(2) + expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual(['海外采购费用', '海外采购费用']) + expect(table.text()).toContain('CG-TEST-001') + expect(table.text()).toContain('开箱费') + expect(table.text()).toContain('提柜-额外费用') + expect(table.text()).not.toMatch(/未知来源类型|OVERSEAS_PROCUREMENT_FEE/) + }) + it('collapses advanced criteria without losing values and resets hidden filters', async () => { const { wrapper } = await mountPage() const vm = wrapper.vm as unknown as { filters: Record; appliedFilters: Record } @@ -160,31 +183,33 @@ describe('FinanceCustomerStatementsView', () => { expect(source).toContain(' { + it.each(['APPROVED', 'ISSUED', 'PAID'])('hides adjustment and void for reviewed statement %s even with write permissions', async status => { + vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status, customerConfirmationStatus: 'PENDING_CONFIRM' }]) const { wrapper } = await mountPage([ - 'platform:finance:customer-statement:list', - 'platform:finance:reconciliation:adjust', - 'platform:finance:reconciliation:void', + 'platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void', ]) - const actionButtons = [...document.querySelectorAll('button')].filter((button) => ['详情', '调整', '作废'].includes(button.textContent?.trim() || '')) - const detailButton = actionButtons.find((button) => button.textContent?.trim() === '详情') - const adjustButton = actionButtons.find((button) => button.textContent?.trim() === '调整') - const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废') - expect(new Set(actionButtons.map((button) => button.textContent?.trim()))).toEqual(new Set(['详情', '调整', '作废'])) - expect(detailButton?.classList).toContain('el-button--small') - expect(detailButton?.classList).toContain('density-action') - expect(detailButton?.getAttribute('data-tone')).toBe('neutral') - expect(adjustButton?.classList).toContain('density-action') - expect(voidButton?.getAttribute('data-tone')).toBe('danger') - - const vm = wrapper.vm as unknown as { actionBusy: boolean; adjustBusy: boolean } - vm.actionBusy = true - vm.adjustBusy = true - await flushPromises() - expect(adjustButton?.hasAttribute('disabled')).toBe(true) - expect(voidButton?.hasAttribute('disabled')).toBe(true) + const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) + expect(labels).toContain('详情') + expect(labels).not.toContain('调整') + expect(labels).not.toContain('作废') }) + it.each(['DRAFT', 'PENDING_REVIEW'])('keeps void before approval with the independent permission: %s', async status => { + vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status }]) + const { wrapper } = await mountPage(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:void']) + const button = wrapper.findAll('.el-table__body-wrapper button').find(item => item.text().trim() === '作废')! + expect(button.attributes('data-tone')).toBe('danger') + const vm = wrapper.vm as unknown as { actionBusy: boolean } + vm.actionBusy = true + await flushPromises() + expect(button.attributes('disabled')).toBeDefined() + }) + + it('does not offer void without permission even before approval', async () => { + vi.mocked(listFinanceStatements).mockResolvedValue([{ ...statement, status: 'DRAFT' }]) + const { wrapper } = await mountPage() + expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).not.toContain('作废') + }) it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => { const { wrapper } = await mountPage() const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise } @@ -224,10 +249,6 @@ describe('FinanceCustomerStatementsView', () => { expect(text).toContain('2026-08-14 03:27') expect(text).not.toContain('2026-08-14T03:27:34Z') - await (wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise }).openAdjustment(internalStatement) - await flushPromises() - expect(document.body.textContent).toContain('调整客户对账单') - expect(document.body.textContent).toContain('运费') }) it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => { @@ -373,6 +394,67 @@ describe('FinanceCustomerStatementsView', () => { expect(router.currentRoute.value).toMatchObject({ name: 'finance-customer-statement-detail', params: { statementId: '502' } }) }) + const selectedCreatePermissions = ['platform:finance:customer-statement:list', 'platform:finance:customer-arap:list', 'platform:finance:reconciliation:create', 'platform:customer:list'] + const selectedCreatePath = '/finance/customer-statements?createMode=SELECTED&customerId=10001&feeSourceIds=42&periodStart=2026-08-15&periodEnd=2026-08-15' + type SourcePage = Awaited> + + it.each(['success', 'failure'] as const)('ignores a late %s after leaving a reactivated statement page', async (outcome) => { + const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true) + const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as { + createOpen: boolean; createSourcesError: unknown; createSourcesLoading: boolean + } + await router.push('/downloads'); await flushPromises() + let resolve!: (value: SourcePage) => void, reject!: (reason: Error) => void + vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise((yes, no) => { resolve = yes; reject = no })) + await router.push(selectedCreatePath); await flushPromises() + expect(listFinanceFeeSources).toHaveBeenCalledTimes(1) + const replace = vi.spyOn(router, 'replace') + await router.push('/downloads'); await flushPromises() + if (outcome === 'success') resolve({ records: [eligibleFeeSource], total: 2, pages: 2 }) + else reject(new Error('旧候选查询失败')) + await flushPromises() + expect(router.currentRoute.value.name).toBe('download-center') + expect(replace).not.toHaveBeenCalled() + expect(pageVm.createOpen).toBe(false) + expect(pageVm.createSourcesError).toBeNull() + expect(pageVm.createSourcesLoading).toBe(false) + expect(listFinanceFeeSources).toHaveBeenCalledTimes(1) + }) + + it('opens the current selection after reactivation without letting an older response reopen it', async () => { + const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true) + const pageVm = wrapper.getComponent(FinanceCustomerStatementsView).vm as unknown as { + createOpen: boolean; createForm: { feeSourceIds: string[] }; createSourceOptions: FinanceFeeSourceSummary[] + } + await router.push('/downloads'); await flushPromises() + let resolve!: (value: SourcePage) => void + vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes })) + await router.push(selectedCreatePath); await flushPromises() + await router.push('/downloads'); await flushPromises() + vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [{ ...eligibleFeeSource, feeSourceId: '43' }], total: 1, pages: 1 }) + await router.push(selectedCreatePath.replace('feeSourceIds=42', 'feeSourceIds=43')); await flushPromises() + expect(pageVm.createOpen).toBe(true) + expect(pageVm.createForm.feeSourceIds).toEqual(['43']) + const replace = vi.spyOn(router, 'replace') + resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises() + expect(replace).not.toHaveBeenCalled() + expect(pageVm.createForm.feeSourceIds).toEqual(['43']) + expect(pageVm.createSourceOptions.map(item => item.feeSourceId)).toEqual(['43']) + }) + + it('does not open or navigate after the statement page is unmounted during candidate loading', async () => { + const { wrapper, router } = await mountPage(selectedCreatePermissions, '/finance/customer-statements', true) + await router.push('/downloads'); await flushPromises() + let resolve!: (value: SourcePage) => void + vi.mocked(listFinanceFeeSources).mockImplementationOnce(() => new Promise(yes => { resolve = yes })) + await router.push(selectedCreatePath); await flushPromises() + const replace = vi.spyOn(router, 'replace') + wrapper.unmount() + resolve({ records: [eligibleFeeSource], total: 1, pages: 1 }); await flushPromises() + expect(replace).not.toHaveBeenCalled() + expect(document.body.textContent).not.toContain('对账单生成设置') + }) + it('keeps the generation dialog open with a customer-friendly message when no billable fees exist', async () => { const permissions = ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:create', 'platform:customer:list'] const { wrapper } = await mountPage(permissions) diff --git a/src/modules/finance/pages/FinanceCustomerStatementsView.vue b/src/modules/finance/pages/FinanceCustomerStatementsView.vue index 7d0cd7f0..5d6cfc57 100644 --- a/src/modules/finance/pages/FinanceCustomerStatementsView.vue +++ b/src/modules/finance/pages/FinanceCustomerStatementsView.vue @@ -2,7 +2,7 @@ import '@/styles/adminWorkbench.css' import '@/styles/adminDensity.css' import '../financeDensity.css' -import { computed, onMounted, ref, watch } from 'vue' +import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { ArrowLeft, Download, Plus, SlidersHorizontal } from '@lucide/vue' @@ -29,15 +29,6 @@ import { actionReconciliationStatement, createReconciliationStatement, getReconc import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status' import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types' -interface AdjustmentRow { - selected: boolean - statementLineId: string - feeItemName: string - originalAmount: number - adjustedAmount: number - reason: string -} - type StatementRow = Omit & { periodStart: string periodEnd: string @@ -85,12 +76,9 @@ const createForm = ref({ remark: '', }) let createSourceLoadSequence = 0 -const adjustOpen = ref(false) -const adjustBusy = ref(false) -const adjustError = ref(null) -const adjustStatement = ref(null) -const adjustReason = ref('') -const adjustLines = ref([]) +let selectedCreateGeneration = 0 +let pageActive = true +let initialLoadCompleted = false const canView = computed(() => access.can('platform:finance:customer-statement:list')) const canListCustomers = computed(() => access.can('platform:customer:list')) @@ -100,7 +88,6 @@ const canCreate = computed(() => access.can('platform:finance:reconciliation:cre const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE')) const canReview = computed(() => access.can('platform:finance:reconciliation:review')) const canIssue = computed(() => access.can('platform:finance:reconciliation:issue')) -const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust')) 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) @@ -407,6 +394,7 @@ function defaultCreateForm() { function openCreate() { if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单') + invalidateCreateLoads() createForm.value = defaultCreateForm() createSourceOptions.value = [] createSourcesError.value = null @@ -440,6 +428,7 @@ async function loadEligibleCreateSources(preferredIds: string[] = createForm.val businessDateFrom: createForm.value.periodStart, businessDateTo: createForm.value.periodEnd, }) + if (sequence !== createSourceLoadSequence) return records.push(...result.records) pages = Math.max(1, result.pages) currentPage += 1 @@ -467,7 +456,9 @@ function queryText(value: unknown) { } async function openSelectedCreateFromRoute() { - if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return + if (!pageActive || route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return + const generation = ++selectedCreateGeneration + const sourcePath = route.fullPath if (!canGenerate.value || !canSelectSources.value) { ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单') return @@ -484,8 +475,12 @@ async function openSelectedCreateFromRoute() { ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用') return } + createError.value = null createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' } await loadEligibleCreateSources(feeSourceIds) + // 带入候选只属于当前激活页面及本次路由,旧响应不能重新打开弹框或覆盖用户导航。 + if (!pageActive || generation !== selectedCreateGeneration || route.fullPath !== sourcePath + || !canGenerate.value || !canSelectSources.value) return createOpen.value = true await router.replace({ name: 'finance-customer-statements' }) } @@ -535,18 +530,6 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci finally { actionBusy.value = false } } -async function openAdjustment(row: FinanceStatementReadModel) { - adjustError.value = null - adjustBusy.value = true - try { - const current = await fetchDetail(row.id, true) - adjustStatement.value = { ...row, version: current.version } - adjustReason.value = '' - adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : []) - adjustOpen.value = true - } catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') } - finally { adjustBusy.value = false } -} function statementActions(row: FinanceStatementReadModel): RowActionItem[] { return [ { key: 'detail', label: '详情', priority: 300, disabled: loading.value }, @@ -554,14 +537,13 @@ function statementActions(row: FinanceStatementReadModel): RowActionItem[] { { key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value }, { key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value }, { key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value }, - { key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value }, { key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value }, - { key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value }, + { key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value }, ] } function handleStatementAction(command: string, row: FinanceStatementReadModel) { + if (!statementActions(row).some(item => item.key === command && item.visible !== false)) return if (command === 'detail') void openDetail(row) - else if (command === 'adjust') void openAdjustment(row) else { const actions: Record = { submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID', @@ -571,35 +553,39 @@ function handleStatementAction(command: string, row: FinanceStatementReadModel) } } -async function submitAdjustment() { - if (adjustBusy.value) return - const statement = adjustStatement.value - const selected = adjustLines.value.filter((line) => line.selected) - if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细') - if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明') - adjustBusy.value = true - adjustError.value = null - try { - await actionReconciliationStatement(statement.id, { - action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version, - adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })), - }) - adjustOpen.value = false - ElMessage.success('对账单调整已保存') - await load(appliedFilters.value) - await openDetail(statement) - } catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value } - finally { adjustBusy.value = false } +function invalidateCreateLoads() { + selectedCreateGeneration += 1 + createSourceLoadSequence += 1 + createSourcesLoading.value = false +} + +function deactivateCreateLoads() { + pageActive = false + invalidateCreateLoads() } onMounted(async () => { - if (canListCustomers.value) await loadCustomers() + await (canListCustomers.value ? loadCustomers() : undefined) + initialLoadCompleted = true await openSelectedCreateFromRoute() }) +onActivated(() => { + pageActive = true + if (initialLoadCompleted) void openSelectedCreateFromRoute() +}) +onDeactivated(deactivateCreateLoads) +onBeforeUnmount(deactivateCreateLoads) +watch(() => route.name, (name) => { + if (name !== 'finance-customer-statements') invalidateCreateLoads() +}, { flush: 'sync' }) watch( () => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd], - ([open, mode]) => { - if (!open || mode !== 'SELECTED') return + ([open, mode], [wasOpen]) => { + if (!open) { + if (wasOpen === true) invalidateCreateLoads() + return + } + if (mode !== 'SELECTED') return void loadEligibleCreateSources() }, ) @@ -744,16 +730,7 @@ watch(detailRouteId, (statementId) => { - - - -

当前对账单

对账单号{{ adjustStatement.billNo || '—' }}客户{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}
-

调整登记

-
-
-
- - + @@ -761,10 +738,8 @@ watch(detailRouteId, (statementId) => { .finance-statements-page { min-width: 0; } :deep(.el-select), :deep(.el-date-editor) { width: 100%; } .selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; } -.statement-create-table, .statement-dialog-facts { table-layout: fixed; } +.statement-create-table { table-layout: fixed; } .statement-create-table td:not([colspan]) { width: 50%; } -.statement-dialog-facts th { width: var(--admin-label-width); } -.statement-adjust-editor { display: grid; gap: var(--admin-space-5); } .statement-detail-page { min-width: 0; width: 100%; margin: 0 auto; overflow: hidden; } .statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); } .statement-outstanding { color: var(--el-color-warning-dark-2); } diff --git a/src/modules/finance/pages/FinanceFormalSettlementPage.vue b/src/modules/finance/pages/FinanceFormalSettlementPage.vue index a482ad5a..cc9f1bda 100644 --- a/src/modules/finance/pages/FinanceFormalSettlementPage.vue +++ b/src/modules/finance/pages/FinanceFormalSettlementPage.vue @@ -90,6 +90,7 @@ const receiptValidationError = ref(null) const receiptError = ref(null) const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' }) const actionBusy = ref(false) +const actionError = ref(null) const exportSubmitting = ref(false) const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create') @@ -459,12 +460,19 @@ async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') { }).then((result) => result.value.trim()).catch(() => null) if (reason === null) return actionBusy.value = true + actionError.value = null try { await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version }) ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开') if (detailMode.value) await loadDetail(row.id) else await load() - } catch (cause) { error.value = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) } + } catch (cause) { + const details = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) + if (props.kind === 'receivable' && action === 'REOPEN' && details.message === '原对账单已生成其他有效应收结算单') { + details.message = '已有新的应收单,无法重开' + } + actionError.value = details + } finally { actionBusy.value = false } } @@ -524,6 +532,7 @@ onMounted(() => { + -

生成设置

+

生成设置

diff --git a/src/modules/finance/pages/FinanceG3G4Operations.test.ts b/src/modules/finance/pages/FinanceG3G4Operations.test.ts index e83b63b3..ee126412 100644 --- a/src/modules/finance/pages/FinanceG3G4Operations.test.ts +++ b/src/modules/finance/pages/FinanceG3G4Operations.test.ts @@ -95,7 +95,7 @@ describe('finance G3/G4 page operations', () => { vi.mocked(createFinanceReceipt).mockReset().mockResolvedValue({ id: '801', customerId: '10001', customerName: '客户A', receivedDate: '2026-08-11', amount: 300, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: ['9001'], allocationCount: 1 }) }) - it('loads frozen G3 detail and submits multiple selected adjustment lines', async () => { + it('keeps issued statement history readable and ignores stale adjustment or void commands', async () => { const pinia = piniaWithPermissions(['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust']) const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView }, @@ -105,26 +105,17 @@ describe('finance G3/G4 page operations', () => { const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } }) await flushPromises() const vm = wrapper.vm as unknown as { - openAdjustment(row: FinanceStatementReadModel): Promise - submitAdjustment(): Promise - adjustReason: string - adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> + openDetail(row: FinanceStatementReadModel): Promise + handleStatementAction(command: string, row: FinanceStatementReadModel): void } - - await vm.openAdjustment(statement) - vm.adjustReason = '账期复核调整' - vm.adjustLines[0]!.selected = true; vm.adjustLines[0]!.adjustedAmount = 90; vm.adjustLines[0]!.reason = '运费更正' - vm.adjustLines[1]!.selected = true; vm.adjustLines[1]!.adjustedAmount = 180; vm.adjustLines[1]!.reason = '操作费更正' - await vm.submitAdjustment() - + vm.handleStatementAction('adjust', statement) + vm.handleStatementAction('void', statement) + await flushPromises() + expect(actionReconciliationStatement).not.toHaveBeenCalled() + await vm.openDetail(statement) + await flushPromises() expect(getReconciliationStatement).toHaveBeenCalledWith('501') - expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { - action: 'ADJUST', reason: '账期复核调整', expectedVersion: 3, - adjustmentLines: [ - { statementLineId: '601', adjustedAmount: 90, reason: '运费更正' }, - { statementLineId: '602', adjustedAmount: 180, reason: '操作费更正' }, - ], - }) + expect(wrapper.text()).toContain(statement.billNo) }, 15_000) it('loads AR linkage, uploads a voucher and allocates a real receipt to its statement', async () => { diff --git a/src/modules/finance/pages/FinanceG3G4Views.test.ts b/src/modules/finance/pages/FinanceG3G4Views.test.ts index cd403677..9f4bc0bf 100644 --- a/src/modules/finance/pages/FinanceG3G4Views.test.ts +++ b/src/modules/finance/pages/FinanceG3G4Views.test.ts @@ -101,16 +101,14 @@ describe('Finance G3/G4 views', () => { expect(actionReconciliationStatement).not.toHaveBeenCalled() }) - it('submits every selected adjustment line with the detail version', async () => { - const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust']) - const vm = wrapper.vm as unknown as { openAdjustment(row: FinanceStatementReadModel): Promise; submitAdjustment(): Promise; adjustReason: string; adjustLines: Array<{ selected: boolean; adjustedAmount: number; reason: string }> } - await vm.openAdjustment(statement) - vm.adjustReason = '月末核对更正' - vm.adjustLines.forEach((line, index) => { line.selected = true; line.adjustedAmount = index === 0 ? 90 : 180; line.reason = `明细${index + 1}更正` }) - await vm.submitAdjustment() - expect(actionReconciliationStatement).toHaveBeenCalledWith('501', { action: 'ADJUST', reason: '月末核对更正', expectedVersion: 3, adjustmentLines: [{ statementLineId: '601', adjustedAmount: 90, reason: '明细1更正' }, { statementLineId: '602', adjustedAmount: 180, reason: '明细2更正' }] }) + it('keeps an issued statement read-only with legacy adjustment permission', async () => { + const wrapper = await mountWithAccess(FinanceCustomerStatementsView, ['platform:finance:customer-statement:list', 'platform:finance:reconciliation:adjust', 'platform:finance:reconciliation:void']) + const labels = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) + expect(labels).toContain('详情') + expect(labels).not.toContain('调整') + expect(labels).not.toContain('作废') + expect(actionReconciliationStatement).not.toHaveBeenCalled() }) - it('creates a payable settlement and registers a voucher-backed payment', async () => { const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list', 'platform:finance:settlement-ap:create', 'platform:finance:payment:create'], { kind: 'payable' }) const vm = wrapper.vm as unknown as { load(model: Record): Promise; openCreate(): Promise; submitCreate(): Promise; openPayment(row: FinancePayableSettlement): void; submitPayment(): Promise; createForm: { customerId: string; supplierId: string; dueDate: string; feeSourceIds: string[] }; paymentFile: File | null; paymentForm: { amount: number; transactionDate: string; method: 'BANK_TRANSFER'; referenceNo: string; remark: string } } diff --git a/src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts b/src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts index aae3ce7c..aff04ef5 100644 --- a/src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts +++ b/src/modules/finance/pages/FinanceReceivableStatisticsView.test.ts @@ -1,8 +1,51 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import ElementPlus from 'element-plus' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { listPlatformTenants } from '@/modules/iam' +import { getReceivableStatistics } from '../api/finance' +import type { ReceivableStatisticsResult } from '../types' +import FinanceReceivableStatisticsView from './FinanceReceivableStatisticsView.vue' + +const can = vi.hoisted(() => vi.fn((_permission: string) => true)) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) +vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) +vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) +vi.mock('../api/finance', () => ({ getReceivableStatistics: vi.fn() })) const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceReceivableStatisticsView.vue'), 'utf8') +const statistics: ReceivableStatisticsResult = { + statisticsMonth: '2026-10', currency: 'CNY', totalReceivable: '2600', totalReceived: '2550', totalOutstanding: '50', + totalCustomers: 2, page: 1, pageSize: 20, + records: [ + { customerId: '10001', customerName: '客户A', currency: 'CNY', totalReceivable: '2400', totalReceived: '2400', totalOutstanding: '0' }, + { customerId: '10002', customerName: '客户 10002', currency: 'CNY', totalReceivable: '200', totalReceived: '150', totalOutstanding: '50' }, + ], +} + +async function mountPage() { + const router = createRouter({ history: createMemoryHistory(), routes: [ + { path: '/finance/receivable-statistics', name: 'finance-receivable-statistics', component: FinanceReceivableStatisticsView }, + { path: '/other', name: 'other-page', component: { template: '
其他页面
' } }, + ] }) + await router.push('/finance/receivable-statistics') + await router.isReady() + const wrapper = mount(FinanceReceivableStatisticsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) + await flushPromises() + return { wrapper, router } +} + +enableAutoUnmount(afterEach) +beforeEach(() => { + can.mockReturnValue(true) + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10002', tenantName: '客户B', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 }) + vi.mocked(getReceivableStatistics).mockResolvedValue(statistics) +}) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) describe('receivable statistics page', () => { it('renders only the approved CNY derived metrics and current-filter actions', () => { @@ -25,4 +68,47 @@ describe('receivable statistics page', () => { expect(page).toContain('当前月份暂无应收数据') expect(page).toContain(':loading="loading"') }) + + it('prints an isolated report with all five columns, the loaded month and server totals', async () => { + const print = vi.spyOn(window, 'print').mockImplementation(() => {}) + const { wrapper } = await mountPage() + const report = document.body.querySelector(':scope > .receivable-statistics-print')! + expect(report).not.toBeNull() + expect(report.querySelector('h1')?.textContent).toBe('应收款统计表') + expect(report.textContent).toContain('统计月份:2026-10') + expect([...report.querySelectorAll('thead th')].map(cell => cell.textContent)).toEqual(['应收单位', '币种', '应收总额(CNY)', '已收合计(CNY)', '余额未收(CNY)']) + expect([...report.querySelectorAll('tbody tr')].map(row => [...row.querySelectorAll('td')].map(cell => cell.textContent))).toEqual([ + ['客户A', 'CNY', '2,400.00', '2,400.00', '0.00'], + ['客户B', 'CNY', '200.00', '150.00', '50.00'], + ]) + expect([...report.querySelectorAll('tfoot td')].map(cell => cell.textContent)).toEqual(['2,600.00', '2,550.00', '50.00']) + expect(report.querySelector('button, input, .finance-metric-grid, .data-table__pagination')).toBeNull() + expect(page).toContain(':global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }') + expect(page).toContain('width: 100%; border-collapse: collapse; table-layout: fixed;') + await wrapper.findAll('button').find(button => button.text() === '打印')!.trigger('click') + expect(print).toHaveBeenCalledOnce() + }) + + it('removes the print report when a cached statistics page is no longer the active route', async () => { + const { router } = await mountPage() + expect(document.querySelector('.receivable-statistics-print')).not.toBeNull() + await router.push('/other') + await flushPromises() + expect(document.querySelector('.receivable-statistics-print')).toBeNull() + await router.push('/finance/receivable-statistics') + await flushPromises() + expect(document.querySelector('.receivable-statistics-print')).not.toBeNull() + }) + + it.each(['loading', 'error', 'no-permission'])('does not print unavailable statistics: %s', async state => { + if (state === 'loading') vi.mocked(getReceivableStatistics).mockReturnValueOnce(new Promise(() => {})) + if (state === 'error') vi.mocked(getReceivableStatistics).mockRejectedValueOnce(new Error('不可用')) + if (state === 'no-permission') can.mockReturnValue(false) + const print = vi.spyOn(window, 'print').mockImplementation(() => {}) + const { wrapper } = await mountPage() + const vm = wrapper.vm as unknown as { printCurrent(): void } + expect(document.querySelector('.receivable-statistics-print')).toBeNull() + vm.printCurrent() + expect(print).not.toHaveBeenCalled() + }) }) diff --git a/src/modules/finance/pages/FinanceReceivableStatisticsView.vue b/src/modules/finance/pages/FinanceReceivableStatisticsView.vue index d4ab211d..3c6f95f6 100644 --- a/src/modules/finance/pages/FinanceReceivableStatisticsView.vue +++ b/src/modules/finance/pages/FinanceReceivableStatisticsView.vue @@ -5,7 +5,7 @@ import '../financeDensity.css' import { computed, onMounted, ref } from 'vue' import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue' import { ElMessage } from 'element-plus' -import { useRouter } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { createPlatformExportTask } from '@/modules/downloads' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' @@ -21,6 +21,7 @@ import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../typ const now = new Date() const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` const access = useAccessPreviewStore() +const route = useRoute() const router = useRouter() const month = ref(defaultMonth) const customerId = ref('') @@ -33,6 +34,7 @@ const error = ref(null) const result = ref(null) const canView = computed(() => access.can('platform:finance:receivable-statistics:list')) +const canPrint = computed(() => route.name === 'finance-receivable-statistics' && canView.value && !loading.value && !error.value && Boolean(result.value)) const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list')) const rows = computed(() => result.value?.records ?? []) const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName]))) @@ -65,7 +67,7 @@ async function loadCustomers() { function search() { page.value = 1; void load() } function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() } -function printCurrent() { window.print() } +function printCurrent() { if (canPrint.value) window.print() } function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` } function displayCustomerName(value: unknown) { const row = value as ReceivableStatisticsRow @@ -116,12 +118,30 @@ onMounted(() => { void loadCustomers(); void load() }) - + + +
+
+

应收款统计表

+

统计月份:{{ result.statisticsMonth }} · 币种:CNY

+
+
CNY
+ + + + + + + +
应收单位币种应收总额(CNY)已收合计(CNY)余额未收(CNY)
{{ displayCustomerName(row) }}{{ row.currency }}{{ money(row.totalReceivable) }}{{ money(row.totalReceived) }}{{ money(row.totalOutstanding) }}
当前月份暂无应收数据
统计汇总{{ money(result.totalReceivable) }}{{ money(result.totalReceived) }}{{ money(result.totalOutstanding) }}
+

数据第 {{ result.page }} 页 · 共 {{ result.totalCustomers }} 家应收单位

+
+ diff --git a/src/modules/finance/pages/FinanceSettlementDialogs.test.ts b/src/modules/finance/pages/FinanceSettlementDialogs.test.ts index a5f64dc4..56a9024d 100644 --- a/src/modules/finance/pages/FinanceSettlementDialogs.test.ts +++ b/src/modules/finance/pages/FinanceSettlementDialogs.test.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createMemoryHistory, createRouter } from 'vue-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { ElMessageBox } from 'element-plus' +import { ElMessage, ElMessageBox } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import { preferenceStorageKey } from '@/shared/utils/uiLayoutPreference' @@ -295,4 +295,46 @@ describe('formal settlement native dialogs', () => { expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm admin-workbench', inputPattern: /\S+/ })) expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version }) }) + it('shows a concise reopen conflict message and retains both the voided and replacement receivables', async () => { + const voided = { ...ar, status: 'VOIDED' } + const replacement = { ...ar, id: '9223372036854775012', settlementNo: 'AR202610200002' } + vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([voided, replacement]) + vi.mocked(api.actionFinanceSettlement).mockRejectedValue(new ApiResponseError( + '原对账单已生成其他有效应收结算单', 'FIN_409', 409, null, 'reopen-test-request', 'reopen-test-trace', + )) + vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试重开', action: 'confirm' as const })) + const message = vi.spyOn(ElMessage, 'error') + const wrapper = await mountPage('receivable') + + for (let attempt = 1; attempt <= 2; attempt += 1) { + await click(wrapper, '重开', '.el-table__body-wrapper button') + expect(message).toHaveBeenCalledTimes(attempt) + expect(message).toHaveBeenLastCalledWith({ message: '已有新的应收单,无法重开', showClose: true, duration: 4500 }) + } + + expect(api.actionFinanceSettlement).toHaveBeenLastCalledWith(ar.id, { action: 'REOPEN', reason: '测试重开', expectedVersion: ar.version }) + expect(errorDetails(wrapper)).toMatchObject({ message: '已有新的应收单,无法重开', code: 'FIN_409', requestId: 'reopen-test-request', traceId: 'reopen-test-trace' }) + const list = wrapper.get('.el-table__body-wrapper') + expect(list.text()).toContain(voided.settlementNo) + expect(list.text()).toContain(replacement.settlementNo) + expect(wrapper.text()).not.toContain('加载失败') + expect(wrapper.text()).not.toContain('reopen-test-request') + expect(wrapper.text()).not.toContain('reopen-test-trace') + }) + it.each([ + ['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'], + ['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'], + ] as const)('retains the %s list and original diagnostics when %s fails', async (kind, action, status) => { + const current = kind === 'receivable' ? ar : ap + if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }]) + else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }]) + vi.mocked(api.actionFinanceSettlement).mockRejectedValue(failure()) + vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试原因', action: 'confirm' as const })) + const wrapper = await mountPage(kind) + await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button') + + expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', code: 'TEST_REJECTED', requestId: 'settlement-test-request' }) + expect(wrapper.get('.el-table__body-wrapper').text()).toContain(current.settlementNo) + expect(wrapper.text()).not.toContain('加载失败') + }) }) diff --git a/src/modules/finance/status.test.ts b/src/modules/finance/status.test.ts index be31aee3..d9ac6ffb 100644 --- a/src/modules/finance/status.test.ts +++ b/src/modules/finance/status.test.ts @@ -52,6 +52,21 @@ describe('finance status presentation', () => { expect(financeOperationActionLabel('NEW_ACTION')).toBe('未知操作') }) + it.each([ + ['OVERSEAS_INBOUND_FEE', '海外入仓费用'], + ['OVERSEAS_PROCUREMENT_FEE', '海外采购费用'], + ['OVERSEAS_TRUCK_FEE', '本土卡车费用'], + ['OVERSEAS_LOOSEBOARD_FEE', '本土散板费用'], + ['OVERSEAS_WAREHOUSE_FEE', '海外仓服务费'], + ['OVERSEAS_CONTAINER_FEE', '海外柜子费用'], + ['OVERSEAS_ORDER_FEE', '尾程订单费用'], + ['OVERSEAS_DELIVERY_FEE', '尾程派送费用'], + ['OVERSEAS_RELABEL_FEE', '换标费用'], + ])('labels the current overseas fee source %s consistently', (code, label) => { + expect(financeSourceTypeLabel(code)).toBe(label) + expect(financeSourceTypeLabel(` ${code.toLowerCase()} `)).toBe(label) + }) + it('uses review-status values for the customer AR/AP review filter', () => { expect(financeArapStatusOptions).toEqual([ { label: '草稿', value: 'DRAFT' }, diff --git a/src/modules/finance/status.ts b/src/modules/finance/status.ts index a65732c0..542e765d 100644 --- a/src/modules/finance/status.ts +++ b/src/modules/finance/status.ts @@ -60,6 +60,11 @@ export function financeStatusTone(status: unknown): FinanceStatusTone { const financeSourceTypeLabels: Record = { INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_FEE: '装柜费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用', + OVERSEAS_INBOUND_FEE: '海外入仓费用', OVERSEAS_PROCUREMENT_FEE: '海外采购费用', + OVERSEAS_TRUCK_FEE: '本土卡车费用', OVERSEAS_LOOSEBOARD_FEE: '本土散板费用', + OVERSEAS_WAREHOUSE_FEE: '海外仓服务费', OVERSEAS_CONTAINER_FEE: '海外柜子费用', + OVERSEAS_ORDER_FEE: '尾程订单费用', OVERSEAS_DELIVERY_FEE: '尾程派送费用', + OVERSEAS_RELABEL_FEE: '换标费用', } const financeBusinessTypeLabels: Record = { RECONCILIATION_CREATED: '生成对账单', diff --git a/src/modules/overseas-business/components/BusinessOrderFees.test.ts b/src/modules/overseas-business/components/BusinessOrderFees.test.ts index 27303cb8..eef3870f 100644 --- a/src/modules/overseas-business/components/BusinessOrderFees.test.ts +++ b/src/modules/overseas-business/components/BusinessOrderFees.test.ts @@ -270,6 +270,20 @@ describe('单据费用操作', () => { expect(dialog.text()).toContain('审核状态') expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false) }) + it.each([['ADJUST', '调整'], ['VOID', '作废']] as const)('does not execute %s after the fee was approved elsewhere', async (action, label) => { + const pending = { ...fee, allowedActions: [action] } + const approved = { ...fee, reviewStatus: 'APPROVED', allowedActions: ['ADJUST', 'VOID'] } + vi.mocked(api.listBusinessOverseasFees).mockResolvedValueOnce({ records: [pending], total: 1, page: 1, size: 20, pages: 1 }) + .mockResolvedValue({ records: [approved], total: 1, page: 1, size: 20, pages: 1 }) + vi.mocked(api.getOverseasFee).mockResolvedValue(approved) + const wrapper = await mountPage() + await click(wrapper, label, '.el-table__body-wrapper button') + expect(api.actOnOverseasFee).not.toHaveBeenCalled() + expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(false) + const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) + expect(buttons).not.toContain('调整') + expect(buttons).not.toContain('作废') + }) it('voids with the freshly loaded version and a required reason', async () => { vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] }) diff --git a/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts b/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts index dc3ae684..40fcf32b 100644 --- a/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts +++ b/src/modules/overseas-business/components/ProcurementWorkflowPanel.test.ts @@ -24,7 +24,7 @@ function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-c } beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) }) describe('simplified procurement operations', () => { - it('allows a rejected purchase to be resubmitted without uploading photos and clears the old reason after success', async () => { + it('allows a rejected purchase to be resubmitted without uploading photos and hides the old reason after success', async () => { const wrapper = render() const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [ { id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' }, @@ -39,8 +39,32 @@ describe('simplified procurement operations', () => { expect(uploadProcurementFile).not.toHaveBeenCalled() expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) }) expect(wrapper.emitted('changed')).toHaveLength(1) - await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5, rejectionReason: null, photosNeedUpdate: false } }) + await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5 } }) expect(wrapper.text()).not.toContain('客户驳回原因') + expect(wrapper.text()).not.toContain('请核对') + await wrapper.setProps({ workflow: { ...rejected, status: 'CONFIRMED', version: 6 } }) + expect(wrapper.text()).not.toContain('客户驳回原因') + expect(wrapper.text()).not.toContain('请核对') + }) + it('hides the previous rejection after receipt photos are updated and shows a new rejection again', async () => { + const wrapper = render() + const rejected = { ...workflow(), rejectionReason: '照片不清晰', photosNeedUpdate: true, receipts: [ + { id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' }, + { id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' }, + ] } + await wrapper.setProps({ workflow: rejected }) + expect(wrapper.text()).toContain('照片不清晰') + + await wrapper.setProps({ workflow: { ...rejected, version: 5, rejectionReason: null, photosNeedUpdate: true } }) + expect(wrapper.text()).not.toContain('客户驳回原因') + expect(wrapper.text()).not.toContain('照片不清晰') + expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(true) + + await wrapper.setProps({ workflow: { ...rejected, version: 6, rejectionReason: null, photosNeedUpdate: false } }) + expect(wrapper.findAll('button').some(button => button.text() === '重新上传照片')).toBe(false) + + await wrapper.setProps({ workflow: { ...rejected, version: 7, rejectionReason: '请补拍完整商品' } }) + expect(wrapper.text()).toContain('请补拍完整商品') }) it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => { const wrapper = render() diff --git a/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue b/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue index afdfc17b..0dfd1e7f 100644 --- a/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue +++ b/src/modules/overseas-business/components/ProcurementWorkflowPanel.vue @@ -101,14 +101,14 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===