import ElementPlus from 'element-plus' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { ApiResponseError } from '@/shared/api/http' import { adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail, listInboundFeeDetails, listInboundFeeItemOptions, listInboundFeePartnerOptions, reviewInboundFeeDetail, updateInboundFeeDetail, voidInboundFeeDetail, } from '@/modules/unified-orders/api/inboundFeeDetails' import InboundFeeDetailManager from './InboundFeeDetailManager.vue' import type { InboundFeeDetail } from '@/modules/unified-orders/api/inboundFeeDetails' vi.mock('@/modules/unified-orders/api/inboundFeeDetails', () => ({ listInboundFeeDetails: vi.fn(), listInboundFeeItemOptions: vi.fn(), listInboundFeePartnerOptions: vi.fn(), createInboundFeeDetail: vi.fn(), updateInboundFeeDetail: vi.fn(), deleteInboundFeeDetail: vi.fn(), reviewInboundFeeDetail: vi.fn(), adjustInboundFeeDetail: vi.fn(), voidInboundFeeDetail: vi.fn(), })) const pending: InboundFeeDetail = { id: '11', inboundId: '88', feeItemId: '31', sourceType: 'MANUAL', sourceFeeVersion: 1, inboundCode: 'RK-88', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', customerName: '测试客户', feeTypeNameSnapshot: '操作费', unitPrice: 10, currency: 'CNY', amount: 100, remark: 'marker', reviewStatus: 'PENDING', reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', settlementIncluded: true, } const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' } const source = readFileSync(join(process.cwd(), 'src/modules/unified-orders/components/InboundFeeDetailManager.vue'), 'utf8') enableAutoUnmount(afterEach) function mountManager(permissions: string[]) { const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() return mount(InboundFeeDetailManager, { attachTo: document.body, props: { accountId: '100', inboundId: '88' }, global: { plugins: [pinia, ElementPlus] } }) } describe('InboundFeeDetailManager', () => { beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = '' vi.mocked(listInboundFeeDetails).mockResolvedValue([pending, approved]) vi.mocked(listInboundFeeItemOptions).mockResolvedValue([{ id: '31', feeTypeName: '操作费', warehouseReceivable: true, warehousePayable: true }]) vi.mocked(listInboundFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '测试往来单位' }]) vi.mocked(createInboundFeeDetail).mockResolvedValue(pending) vi.mocked(updateInboundFeeDetail).mockResolvedValue(pending) vi.mocked(adjustInboundFeeDetail).mockResolvedValue({ ...approved, reviewStatus: 'PENDING' }) vi.mocked(reviewInboundFeeDetail).mockResolvedValue(null) vi.mocked(deleteInboundFeeDetail).mockResolvedValue(null) vi.mocked(voidInboundFeeDetail).mockResolvedValue(null) }) it('renders actions from backend status and exact permissions', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:delete', 'firstleg:inbound:fee-detail:review']) await flushPromises() expect(listInboundFeeDetails).toHaveBeenCalledWith('100', '88') const first = wrapper.findAll('.el-table__row')[0]!.text() const second = wrapper.findAll('.el-table__row')[1]!.text() const secondButtons = wrapper.findAll('.el-table__row')[1]!.findAll('button').map((button) => button.text()) expect(first).toContain('编辑'); expect(first).toContain('审核'); expect(first).toContain('驳回') expect(second).toContain('调整'); expect(second).toContain('作废'); expect(secondButtons).not.toContain('审核') expect(wrapper.find('th').text()).toContain('操作') }) it('does not request or expose fee rows without view permission', async () => { mountManager([]); await flushPromises() expect(listInboundFeeDetails).not.toHaveBeenCalled() expect(document.body.textContent).toContain('没有查看费用明细的权限') }) it('approves a pending fee then refreshes rows and emits changed', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review']) await flushPromises() wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click') await flushPromises() expect(reviewInboundFeeDetail).toHaveBeenCalledWith('100', '88', '11', 'approve') expect(listInboundFeeDetails).toHaveBeenCalledTimes(2) expect(wrapper.emitted('changed')).toHaveLength(1) }) it('preserves request and trace IDs when a write fails', async () => { vi.mocked(reviewInboundFeeDetail).mockRejectedValue(new ApiResponseError('状态冲突', 'A0409', 409, null, 'request-fee', 'trace-fee')) const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:review']) await flushPromises() wrapper.findAll('button').find((button) => button.text().includes('审核'))?.trigger('click') await flushPromises() expect(document.body.textContent).toContain('request-fee') expect(document.body.textContent).toContain('trace-fee') }) it('keeps currency fixed to CNY in the form and write payload', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) await flushPromises() await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click') await flushPromises() const currency = document.querySelector('input[aria-label="货币 CNY"]') expect(currency?.readOnly).toBe(true) expect(currency?.value).toBe('CNY') }) it('uses only the Element Plus table scroller without an outer horizontal scrollbar', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view']) await flushPromises() expect(wrapper.find('.fee-table-shell').exists()).toBe(true) expect(wrapper.find('.detail-table-wrap').exists()).toBe(false) }) it('loads RECEIVABLE fee items without requesting counterparties', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) await flushPromises() await wrapper.findAll('button').find((button) => button.text().includes('新增费用'))?.trigger('click') await flushPromises() expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1) expect(listInboundFeePartnerOptions).not.toHaveBeenCalled() expect(document.body.textContent).toContain('缺少往来单位查看权限') expect(document.body.textContent).toContain('应收费用不受影响') }) it('does not expose the editor without fee-item view permission', async () => { const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit']) await flushPromises() expect(wrapper.findAll('button').some((button) => button.text().includes('新增费用'))).toBe(false) expect(listInboundFeeItemOptions).not.toHaveBeenCalled() }) it('keeps fee items usable when the independent counterparty request fails', async () => { vi.mocked(listInboundFeeDetails).mockResolvedValue([{ ...pending, direction: 'PAYABLE', partnerId: '41', partnerName: '测试往来单位' }]) vi.mocked(listInboundFeePartnerOptions).mockRejectedValue(new ApiResponseError('往来单位不可用', 'A0403', 403, null, 'request-partner', 'trace-partner')) const wrapper = mountManager([ 'firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list', ]) await flushPromises() await wrapper.findAll('button').find((button) => button.text().includes('编辑'))?.trigger('click') await flushPromises() expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1) expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1) expect(document.body.textContent).toContain('当前账号无权访问该数据') expect(document.body.textContent).toContain('A0403') expect(document.body.textContent).toContain('request-partner') expect(document.body.textContent).toContain('trace-partner') expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false) }) it('refreshes fee rows and the order detail after every successful write', () => { expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')") expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/) expect(source).toMatch(/deleteInboundFeeDetail[\s\S]*await finishAction\('费用明细已删除'\)/) expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*await finishAction\('费用审核通过'\)/) expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*rejectReason[\s\S]*await finishAction\('费用已驳回'\)/) expect(source).toMatch(/voidInboundFeeDetail[\s\S]*await finishAction\('费用明细已作废'\)/) }) })