import ElementPlus from 'element-plus' import { createPinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import QueryToolbar from '@/shared/components/QueryToolbar.vue' import { ApiResponseError } from '@/shared/api/http' import AppDialog from '@/shared/components/AppDialog.vue' import * as api from '../api/overseasFees' import type { OverseasFee } from '../api/overseasFees' import OverseasFeesView from './OverseasFeesView.vue' const can = vi.hoisted(() => vi.fn((_permission: string) => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('../api/overseasFees', () => ({ actOnOverseasFee: vi.fn(), getOverseasFee: vi.fn(), listOverseasFeeCustomers: vi.fn(), listOverseasFeeItems: vi.fn(), listOverseasFeeRetries: vi.fn(), listOverseasFees: vi.fn(), listBusinessFeeOptions: vi.fn(), createOverseasFee: vi.fn(), })) const fee: OverseasFee = { direction: 'RECEIVABLE', id: '9223372036854775001', sourceFeeId: '9223372036854775001', sourceVersion: 3, customerId: '9223372036854775002', customerName: '测试客户', sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER', businessId: '9223372036854775003', businessNo: 'WT202610220001', orderId: null, warehouseId: '9223372036854775004', feeItemCode: 'HANDLING', feeItemName: '操作费', businessDate: '2026-10-22', currency: 'CNY', amount: 70, originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null, unitPrice: null, periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'UNPUBLISHED', collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewedAt: null, createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'], } async function mountPage() { const wrapper = mount(OverseasFeesView, { attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) await flushPromises() return wrapper } async function click(wrapper: VueWrapper, label: string, selector = 'button') { const button = wrapper.findAll(selector).find(node => node.text().trim() === label) expect(button, label).toBeDefined() await button!.trigger('click'); await flushPromises() } function activeDialog(wrapper: VueWrapper) { const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('modelValue')) expect(dialog).toBeDefined() return dialog!.getComponent({ name: 'ElDialogContent' }) } enableAutoUnmount(afterEach) beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true) vi.mocked(api.listOverseasFeeRetries).mockResolvedValue([]) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.mocked(api.listOverseasFeeCustomers).mockResolvedValue([{ customerId: fee.customerId, customerName: fee.customerName }]) vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }]) vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [fee], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(fee) vi.mocked(api.actOnOverseasFee).mockResolvedValue(fee) vi.mocked(api.createOverseasFee).mockResolvedValue(fee) vi.mocked(api.listBusinessFeeOptions).mockResolvedValue([{ businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: null }]) }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) describe('海外费用汇总与失败恢复', () => { it('推送请求提交后继续刷新,真实成功时列表自动显示已完成', async () => { const pushing = { ...fee, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED', walletStatus: 'NOT_REQUIRED', deliveryStatus: 'PENDING' as const, allowedActions: [] } const wrapper = await mountPage() try { // 挂载后再使用假时钟,避免影响Element Plus自身挂载任务。 vi.useFakeTimers() vi.mocked(api.listOverseasFees).mockResolvedValueOnce({ records: [pushing], total: 1, page: 1, size: 20, pages: 1 }) .mockResolvedValue({ records: [{ ...pushing, deliveryStatus: 'PUBLISHED' }], total: 1, page: 1, size: 20, pages: 1 }) wrapper.getComponent(QueryToolbar).vm.$emit('search', {}) await flushPromises() expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已完成') await vi.advanceTimersByTimeAsync(2000); await flushPromises() expect(wrapper.get('.el-table__body-wrapper').text()).toContain('已完成') const calls = vi.mocked(api.listOverseasFees).mock.calls.length await vi.advanceTimersByTimeAsync(4000) expect(api.listOverseasFees).toHaveBeenCalledTimes(calls) } finally { wrapper.unmount(); vi.useRealTimers() } }) it('shows relabel delivery as 换标 in table and detail and filters by that business type', async () => { const relabel = { ...fee, businessType: 'LASTLEG_ORDER', displayBusinessType: 'RELABEL_ORDER', businessNo: 'TB202610220001', feeItemName: '派送费', originalCurrency: 'CNY', originalAmount: 66.70, amount: 66.70 } vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [relabel], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(relabel) const wrapper = await mountPage() const table = wrapper.get('.el-table__body-wrapper') expect(table.text()).toContain('换标'); expect(table.text()).toContain('派送费') expect(table.text()).toContain('66.70'); expect(table.text()).not.toContain('尾程订单') wrapper.getComponent(QueryToolbar).vm.$emit('search', { businessType: 'RELABEL_ORDER' }) await flushPromises() expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ businessType: 'RELABEL_ORDER', page: 1 })) await click(wrapper, '详情', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) expect(dialog.text()).toContain('换标'); expect(dialog.text()).toContain('派送费') expect(dialog.text()).not.toContain('尾程订单') }) it('keeps approval and amount changes on the business document', async () => { const allActions = { ...fee, allowedActions: ['APPROVE', 'REJECT', 'CANCEL', 'ADJUST', 'VOID', 'SUBMIT'] } vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [allActions], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(allActions) const wrapper = await mountPage() wrapper.getComponent(QueryToolbar).vm.$emit('search', { customerId: fee.customerId, businessNo: ' WT2026 ', reviewStatus: 'PENDING' }) await flushPromises() expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: fee.customerId, businessNo: 'WT2026', reviewStatus: 'PENDING', page: 1 })) await click(wrapper, '详情', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) expect(dialog.text()).toContain(fee.businessNo) expect(dialog.text()).toContain('余额/授信') const buttons = wrapper.findAll('button').map(button => button.text().trim()) for (const label of ['新增费用', '调整', '审核通过', '驳回', '取消费用', '作废', '提交审核', '重试锁定']) expect(buttons).not.toContain(label) expect(api.createOverseasFee).not.toHaveBeenCalled() expect(api.actOnOverseasFee).not.toHaveBeenCalled() }) it.each(['RETRY_FREEZE', 'RETRY_SETTLE', 'RETRY_RELEASE', 'RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'])('retries %s only after refreshing eligibility and preserving the original identity', async action => { const failed = { ...fee, reviewStatus: 'APPROVED', walletStatus: 'SETTLE_FAILED', deliveryEventId: 'original-event', allowedActions: [action] } vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(failed) const labels: Record = { RETRY_FREEZE: '重试锁定', RETRY_SETTLE: '重试扣费', RETRY_RELEASE: '重试释放', RETRY_PUBLISH: '重试推送', RETRY_FUNDING_DELIVERY: '重试资金指令' } const wrapper = await mountPage() await click(wrapper, labels[action]!, '.el-table__body-wrapper button') expect(api.getOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId) await click(activeDialog(wrapper), labels[action]!) expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, { idempotencyKey: expect.any(String), ...(['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? { expectedVersion: fee.sourceVersion, eventId: 'original-event' } : {}), }) expect(api.listOverseasFees).toHaveBeenCalledTimes(2) }) it('does not retry a fee that has already succeeded or belongs to another customer', async () => { const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] } vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 }) const wrapper = await mountPage() for (const refreshed of [{ ...failed, allowedActions: [] }, { ...failed, customerId: 'other' }]) { vi.mocked(api.getOverseasFee).mockResolvedValueOnce(refreshed) await click(wrapper, '重试扣费', '.el-table__body-wrapper button') expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('modelValue'))).toBe(false) } expect(api.actOnOverseasFee).not.toHaveBeenCalled() }) it('blocks concurrent clicks and reuses the same request key after an uncertain failure', async () => { const failed = { ...fee, allowedActions: ['RETRY_SETTLE'] } vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(failed) let reject!: (reason: unknown) => void vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(new Promise((_, no) => { reject = no })) const wrapper = await mountPage() await click(wrapper, '重试扣费', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) await click(dialog, '重试扣费'); await click(dialog, '重试扣费') expect(api.actOnOverseasFee).toHaveBeenCalledTimes(1) reject(new ApiResponseError('连接中断', 'UNAVAILABLE', 503)); await flushPromises() expect(dialog.text()).toContain('连接中断') const key = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey await click(dialog, '重试扣费') expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(key) }) it('hides retries without edit permission and keeps rejected fees read-only', async () => { vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['RETRY_SETTLE'] }, { ...fee, sourceFeeId: 'rejected', reviewStatus: 'REJECTED', allowedActions: ['RETRY_SETTLE'] }], total: 2, page: 1, size: 20, pages: 1 }) can.mockReturnValue(false) const wrapper = await mountPage() expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text())).not.toContain('重试扣费') }) it.each(['RETRY_RELEASE', 'RETRY_FUNDING_DELIVERY'])('recovers rejected frozen funds through the server-authorized %s action', async action => { const rejected = { ...fee, reviewStatus: 'REJECTED', walletStatus: 'RELEASE_FAILED', deliveryEventId: 'release-event', allowedActions: [action, 'RETRY_SETTLE', 'RETRY_FREEZE'] } vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rejected], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(rejected) const wrapper = await mountPage() const buttons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) expect(buttons).not.toContain('重试扣费'); expect(buttons).not.toContain('重试锁定') expect(wrapper.get('.el-table__body-wrapper').text()).toContain('处理失败') const label = action === 'RETRY_RELEASE' ? '重试释放' : '重试资金指令' await click(wrapper, label, '.el-table__body-wrapper button') await click(activeDialog(wrapper), label) expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, action, expect.objectContaining({ idempotencyKey: expect.any(String), ...(action === 'RETRY_FUNDING_DELIVERY' ? { expectedVersion: fee.sourceVersion, eventId: 'release-event' } : {}), })) }) it('keeps automatic rent available as a summary filter', async () => { vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }]) const wrapper = await mountPage() expect(wrapper.getComponent(QueryToolbar).props('fields').find(field => field.key === 'feeItemCode')?.options).toContainEqual({ value: 'FEE_TYPE_077', label: '仓储费' }) }) it('shows only final results and queries failure filtering on the server for either direction', async () => { const wrapper = await mountPage() const fields = wrapper.getComponent(QueryToolbar).props('fields') expect(fields.find(field => field.key === 'processingStatus')?.options?.map(option => option.label)).toEqual(['已完成', '处理失败']) expect(fields.some(field => field.key === 'walletStatus')).toBe(false) wrapper.getComponent(QueryToolbar).vm.$emit('search', { direction: 'PAYABLE', processingStatus: 'FAILED' }) await flushPromises() expect(api.listOverseasFees).toHaveBeenLastCalledWith(expect.objectContaining({ direction: 'PAYABLE', processingStatus: 'FAILED', page: 1 })) expect(wrapper.get('.el-table__body-wrapper').text()).toContain('—') expect(wrapper.get('.el-table__body-wrapper').text()).not.toContain('已锁定') }) }) it('海外费用管理只查询仓租依据,审核和调价留在对应单据详情', async () => { const rent: OverseasFee = { ...fee, feeItemCode: 'FEE_TYPE_077', feeItemName: '仓储费', allowedActions: ['APPROVE', 'REJECT', 'ADJUST_RENT'], storageRent: { outboundNo: 'OUT-1', outboundAt: '2026-10-22T10:00:00Z', calculatedAmount: 10, chargedAmount: null, lines: [{ lotId: '1', inboundAt: '2026-10-01T10:00:00Z', quantity: 4, days: 21, calculatedAmount: 10 }] } } vi.mocked(api.listOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(rent) const wrapper = await mountPage() expect(wrapper.findAll('[role="tab"]').map(item => item.text())).toEqual(['费用明细', '仓租库存']) const actions = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) expect(actions).not.toContain('调整仓租') expect(actions).not.toContain('审核仓租') await click(wrapper, '详情', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) expect(dialog.text()).toContain('2026-10-01'); expect(dialog.text()).toContain('2026-10-22') expect(dialog.text()).toContain('实扣金额') expect(dialog.text()).toContain('OUT-1') expect(api.actOnOverseasFee).not.toHaveBeenCalled() })