import ElementPlus, { ElDialog } 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 { ApiResponseError } from '@/shared/api/http' import OverseasFeeEditor from './OverseasFeeEditor.vue' import AppDialog from '@/shared/components/AppDialog.vue' import * as api from '../api/overseasFees' import type { OverseasFee } from '../api/overseasFees' import BusinessOrderFees from './BusinessOrderFees.vue' import { adjustStorageRentFee } from '../api/storageRent' vi.mock('../api/storageRent', () => ({ adjustStorageRentFee: vi.fn() })) 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(), listBusinessOverseasFees: 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, reviewerId: null, reviewedAt: null, createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'], } const business = { businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: fee.orderId } function deferred() { let resolve!: (value: T) => void, reject!: (reason: unknown) => void const promise = new Promise((yes, no) => { resolve = yes; reject = no }) return { promise, resolve, reject } } async function mountPage() { const wrapper = mount(BusinessOrderFees, { props: { business }, 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.listBusinessOverseasFees).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.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => { vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) await flushPromises() expect(wrapper.isVisible()).toBe(false) vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, direction }], total: 1, page: 1, size: 20, pages: 1 }) await wrapper.vm.refresh(); await flushPromises() expect(wrapper.isVisible()).toBe(true) expect(wrapper.get('h3').text()).toBe(direction === 'RECEIVABLE' ? '应收明细' : '应付明细') expect(wrapper.text()).toContain('操作费') expect(wrapper.findAll('button').some(button => button.text().trim() === '新增费用')).toBe(false) vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) await wrapper.vm.refresh(); await flushPromises() expect(wrapper.isVisible()).toBe(false) }) it('queries the exact document and creates without a second order selector or catalog default amount', async () => { const wrapper = await mountPage() expect(api.listBusinessOverseasFees).toHaveBeenCalledWith([business], { page: 1, size: 20 }, 'RECEIVABLE') await click(wrapper, '新增费用') expect(api.listBusinessFeeOptions).not.toHaveBeenCalled() const dialog = activeDialog(wrapper) expect(dialog.text()).not.toContain('业务类型') expect(dialog.text()).not.toContain('业务订单') expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false) const selects = dialog.findAllComponents({ name: 'ElSelect' }) selects[1]!.vm.$emit('update:modelValue', 'CNY') dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.34) selects[0]!.vm.$emit('update:modelValue', 'HANDLING') selects[0]!.vm.$emit('change', 'HANDLING') await flushPromises() expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(12.34) expect(dialog.text()).toContain('USD 10.00 · 待审核') await click(dialog, '保存') expect(api.createOverseasFee).not.toHaveBeenCalled() dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '补收本次额外操作费用') await flushPromises() await click(dialog, '保存') expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ ...business, feeRemark: '补收本次额外操作费用', amount: 12.34, currency: 'CNY', businessDate: new Date().toLocaleDateString('sv-SE') }), expect.any(String)) expect(wrapper.emitted('changed')).toHaveLength(1) }) it('checks refreshed eligibility and rejects a fee belonging to another order', async () => { const wrapper = await mountPage() vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, allowedActions: [] }) await click(wrapper, '审核', '.el-table__body-wrapper button') expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false) vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, businessId: 'different-order' }) await click(wrapper, '审核', '.el-table__body-wrapper button') expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false) expect(api.actOnOverseasFee).not.toHaveBeenCalled() }) it('keeps supplier payables separate and reviews them for settlement without customer billing hints', async () => { const payable: OverseasFee = { ...fee, businessType: 'LASTLEG_ORDER', direction: 'PAYABLE', supplierId: 'supplier-1', supplierName: '测试供应商', settlementMode: 'ON_ACCOUNT', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED' } vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [payable], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(payable) const wrapper = mount(BusinessOrderFees, { props: { business: { ...business, businessType: 'LASTLEG_ORDER' }, direction: 'PAYABLE' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) await flushPromises() expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([expect.objectContaining({ businessType: 'LASTLEG_ORDER' })], { page: 1, size: 20 }, 'PAYABLE') expect(wrapper.text()).toContain('测试供应商'); expect(wrapper.text()).toContain('推送结算') expect(wrapper.findAll('button').map(button => button.text())).not.toContain('新增费用') await click(wrapper, '审核', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) expect(dialog.text()).not.toContain('请核对原报价') await click(dialog, '审核通过') expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' })) }) it('cannot review the opposite direction returned by a refreshed fee', async () => { const wrapper = await mountPage() vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, direction: 'PAYABLE' }) 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('keeps read-only documents read-only and closes an editor when the order changes', async () => { const wrapper = await mountPage() await click(wrapper, '新增费用') await wrapper.setProps({ business: { ...business, businessId: 'other' } }) await flushPromises() expect(wrapper.getComponent(OverseasFeeEditor).props('modelValue')).toBe(false) await wrapper.setProps({ readonly: true }) const visibleButtons = wrapper.findAll('button').filter(button => button.isVisible()).map(button => button.text()) expect(visibleButtons).not.toContain('新增费用') expect(visibleButtons).not.toContain('审核') expect(visibleButtons).not.toContain('驳回') expect(visibleButtons).not.toContain('取消费用') }) it('does not create against an order or customer changed after the editor was opened', async () => { const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, business, items: [{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } }) await wrapper.setProps({ modelValue: true }); await flushPromises() const dialog = activeDialog(wrapper) dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'HANDLING') dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10) await wrapper.setProps({ business: { ...business, customerId: 'different-customer' } }) await flushPromises(); await click(dialog, '保存') expect(api.createOverseasFee).not.toHaveBeenCalled() }) it('blocks automatic storage rent from manual addition', async () => { vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }]) const wrapper = await mountPage() await click(wrapper, '新增费用') const dialog = activeDialog(wrapper) const select = dialog.findAllComponents({ name: 'ElSelect' })[0]! expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(0) select.vm.$emit('update:modelValue', 'FEE_TYPE_077') dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10) await flushPromises(); await click(dialog, '保存') expect(api.createOverseasFee).not.toHaveBeenCalled() }) it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => { const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' } const relatedFee = { ...fee, ...related, allowedActions: ['ADJUST'] } vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [relatedFee], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(relatedFee) const wrapper = await mountPage() await wrapper.setProps({ relatedBusinesses: [related, { ...business, customerId: 'other-customer' }] }) await flushPromises() expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([business, related], { page: 1, size: 20 }, 'RECEIVABLE') await click(wrapper, '调整', '.el-table__body-wrapper button') expect(wrapper.getComponent(OverseasFeeEditor).props('business')).toEqual(related) const dialog = activeDialog(wrapper) dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15) await flushPromises(); await click(dialog, '保存') expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'ADJUST', expect.objectContaining({ expectedVersion: fee.sourceVersion, fee: expect.objectContaining({ businessId: related.businessId, businessType: 'RETURN_ORDER', amount: 15 }), })) }) it('shows a voided freeze-failure fixture as closed instead of pending review', async () => { const closed = { ...fee, sourceStatus: 'VOIDED', reviewStatus: 'PENDING', walletStatus: 'FREEZE_FAILED', walletErrorMessage: '可用余额和授信额度不足', allowedActions: [] } vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [closed], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(closed) const wrapper = await mountPage() expect(wrapper.text()).toContain('已作废') expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).toEqual(['详情']) await click(wrapper, '详情', '.el-table__body-wrapper button') expect(activeDialog(wrapper).text()).toContain('冻结失败') expect(activeDialog(wrapper).text()).toContain('此费用已作废,无需继续审核') expect(activeDialog(wrapper).text()).not.toContain('待审核') }) it('retries a failed debit only when the refreshed server action is allowed', async () => { const failed = { ...fee, walletStatus: 'SETTLE_FAILED', allowedActions: ['RETRY_SETTLE'] } vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(failed) const wrapper = await mountPage() await click(wrapper, '重试扣费', '.el-table__body-wrapper button') await click(activeDialog(wrapper), '重试扣费') expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_SETTLE', { idempotencyKey: expect.any(String) }) expect(wrapper.emitted('changed')).toHaveLength(1) }) it('requires a rejection reason and submits the review contract', async () => { const wrapper = await mountPage() vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, sourceVersion: 5 }) await click(wrapper, '审核', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) const reject = dialog.findAll('button').find(button => button.text().trim() === '驳回') expect(reject?.attributes('disabled')).toBeDefined() expect(api.actOnOverseasFee).not.toHaveBeenCalled() await dialog.get('textarea').setValue(' 金额不符 ') await click(dialog, '驳回') expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '金额不符', expectedVersion: 5, idempotencyKey: expect.any(String) }) }) it('keeps rejected fees read-only while allowing failed frozen funds to be released', async () => { vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] }], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] }) const wrapper = await mountPage() const visibleButtons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim()) expect(visibleButtons).toContain('详情') expect(visibleButtons).not.toContain('审核') expect(visibleButtons).not.toContain('驳回') expect(visibleButtons).not.toContain('提交审核') expect(visibleButtons).toContain('重试释放') await click(wrapper, '重试释放', '.el-table__body-wrapper button') await click(activeDialog(wrapper), '重试释放') expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_RELEASE', expect.objectContaining({ idempotencyKey: expect.any(String) })) await click(wrapper, '详情', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) expect(dialog.text()).toContain('审核状态') expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false) }) 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'] }) const wrapper = await mountPage() await click(wrapper, '作废', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) await click(dialog, '作废') expect(api.actOnOverseasFee).not.toHaveBeenCalled() await dialog.get('textarea').setValue(' 重复费用 ') await click(dialog, '作废') expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'VOID', { expectedVersion: 5, reason: '重复费用', idempotencyKey: expect.any(String) }) }) it('locks in-flight approvals and preserves diagnostics and idempotency when retried', async () => { const pending = deferred() vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(pending.promise) const wrapper = await mountPage() await click(wrapper, '审核', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) await click(dialog, '审核通过') expect(wrapper.findAllComponents(ElDialog).find(component => component.props('modelValue'))!.props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) expect(dialog.get('textarea').attributes('disabled')).toBeDefined() pending.reject(new ApiResponseError('余额服务暂不可用', 'FUNDING_ERROR', 409, null, 'test-request-1')) await flushPromises() expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(true) expect(dialog.text()).toContain('余额服务暂不可用') expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('') const firstKey = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey await click(dialog, '审核通过') expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(firstKey) }) }) describe('仓租费用审核与调价', () => { 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: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'LASTLEG_ORDER', sourceBusinessNo: 'WC-SOURCE-1', batchNo: 'B-SOURCE-1' }, { lotId: '2', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'RETURN_ORDER', sourceBusinessNo: 'TH-SOURCE-2', batchNo: 'B-SOURCE-2' }, ] } } async function setupRent() { vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 }) vi.mocked(api.getOverseasFee).mockResolvedValue(rent) return mountPage() } it('审核显示实际出入库与原计算额,批准以前不请求扣款', async () => { const wrapper = await setupRent() await click(wrapper, '审核', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) expect(dialog.text()).toContain('入库时间'); expect(dialog.text()).toContain('出库时间') expect(dialog.text()).toContain('原计算金额'); expect(dialog.text()).toContain('实扣金额') expect(dialog.text()).toContain('来源单号'); expect(dialog.text()).toContain('头程尾程订单') expect(dialog.text()).toContain('WC-SOURCE-1'); expect(dialog.text()).toContain('TH-SOURCE-2') expect(dialog.text()).toContain('退货单'); expect(dialog.text()).toContain('B-SOURCE-2') expect(dialog.text()).toContain('2026-10-01'); expect(api.actOnOverseasFee).not.toHaveBeenCalled() await click(dialog, '审核通过') expect(api.actOnOverseasFee).toHaveBeenCalledWith(rent.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' })) }) it('调价说明必填,失败保留金额说明且同参数重试复用请求编号', async () => { const wrapper = await setupRent() vi.mocked(adjustStorageRentFee).mockRejectedValueOnce(new Error('暂时不可用')).mockResolvedValue(rent) await click(wrapper, '调整仓租', '.el-table__body-wrapper button') const dialog = activeDialog(wrapper) expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(10) expect(dialog.findAll('button').find(button => button.text() === '保存调整')!.attributes('disabled')).toBeDefined() dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12) dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核对调整') await flushPromises(); await click(dialog, '保存调整') expect(dialog.text()).toContain('仓租操作失败') await click(dialog, '保存调整') const calls = vi.mocked(adjustStorageRentFee).mock.calls expect(calls).toHaveLength(2); expect(calls[0]).toEqual(calls[1]) expect(calls[0]).toEqual([rent.sourceFeeId, expect.objectContaining({ amount: 12, reason: '人工核对调整', expectedVersion: 3 })]) }) it('只读单据在仓租弹框已打开时同样禁止修改', async () => { const wrapper = await setupRent(); await click(wrapper, '调整仓租', '.el-table__body-wrapper button') await wrapper.setProps({ readonly: true }); await flushPromises() expect(activeDialog(wrapper).findAll('button').some(button => button.text() === '保存调整')).toBe(false) expect(adjustStorageRentFee).not.toHaveBeenCalled() }) })