Files
admin-web/src/modules/overseas-business/pages/OverseasFeesView.test.ts
T
2026-10-26 13:43:08 +08:00

216 lines
15 KiB
TypeScript

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<string, string> = { 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()
})