import ElementPlus from 'element-plus' import { readFileSync } from 'node:fs' import { resolve } 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 { ElMessageBox } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import { adjustPublishedInboundFee, confirmInboundFee, getLatestInboundFee, sendInboundFee, } from './api' import type { InboundFeeSnapshot } from './types' import InboundFeePanel from './InboundFeePanel.vue' vi.mock('./api', async () => { const actual = await vi.importActual('./api') return { ...actual, getLatestInboundFee: vi.fn(), adjustPublishedInboundFee: vi.fn(), confirmInboundFee: vi.fn(), sendInboundFee: vi.fn(), } }) const snapshot: InboundFeeSnapshot = { feeId: '9007199254740993', feeNo: 'FBAF-88-2', feeVersion: 2, feeStatus: 'SENT', inboundId: '88', inboundCode: 'RK202608040088', chargeRuleId: '101', ruleCode: 'RULE-FBA-KG', chargeRuleVersion: 3, carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5, customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓', businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司', destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG', conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP', actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6, chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100, totalAmount: 1235.6, currency: 'CNY', rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null, calculatedAt: '2026-08-04T10:00:00', confirmedAt: '2026-08-04T10:01:00', sentAt: '2026-08-04T10:02:00', nextProcessStatus: 'WAIT_SPLIT', } enableAutoUnmount(afterEach) function setupPermissions(permissions: string[]) { const pinia = createPinia() setActivePinia(pinia) const auth = useAuthStore() auth.token = 'token' auth.permissions = permissions useAccessPreviewStore().initializeAfterLogin() return pinia } function mountPanel(permissions = [ 'firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust', 'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee', ]) { const pinia = setupPermissions(permissions) return mount(InboundFeePanel, { attachTo: document.body, props: { customerId: '10001', inboundId: '88' }, global: { plugins: [pinia, ElementPlus] }, }) } describe('InboundFeePanel', () => { beforeEach(() => { vi.clearAllMocks() document.body.innerHTML = '' vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot) vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' }) vi.mocked(confirmInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CONFIRMED', confirmedAt: '2026-08-04T10:01:00', sentAt: null }) vi.mocked(sendInboundFee).mockResolvedValue(snapshot) }) it('shows the immutable calculation basis, versions, amount, and timestamps', async () => { mountPanel() await flushPromises() expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88') expect(document.body.textContent).not.toContain('租户') expect(document.body.textContent).toContain('头程自动费用 · CNY') expect(document.body.textContent).toContain('¥1,235.60') expect(document.body.textContent).toContain('基础运费小计 ¥1,135.60') expect(document.body.textContent).toContain('附加费 ¥100.00') expect(document.body.textContent).toContain('133.6 KG') expect(document.body.textContent).toContain('按重量计费') expect(document.body.textContent).toContain('FBA 海运') expect(document.body.textContent).toContain('¥8.50 / KG') expect(document.body.textContent).toContain('RULE-FBA-KG · V3') expect(document.body.textContent).toContain('RATE-LAX · V5') expect(document.body.textContent).toContain('时间记录') expect(document.body.textContent).toContain('发布') expect(document.body.textContent).not.toContain('重新试算') expect(document.body.textContent).not.toContain('确认费用') expect(document.body.textContent).not.toContain('发送费用') }) it('follows the admin detail interaction pattern without list-only scaffolding', async () => { const wrapper = mountPanel() await flushPromises() expect(wrapper.get('button[aria-label="刷新费用快照"]').classes()).toContain('is-circle') expect(wrapper.get('.version-status .el-tag').text()).toBe('已发布') expect(wrapper.find('.query-toolbar').exists()).toBe(false) expect(wrapper.find('.data-table').exists()).toBe(false) expect(wrapper.find('.surface-panel').exists()).toBe(false) }) it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => { const pinia = setupPermissions(['firstleg:fba-pricing:view']) mount(InboundFeePanel, { props: { tenantId: 'legacy-10001', inboundId: '88' }, global: { plugins: [pinia, ElementPlus] }, }) await flushPromises() expect(getLatestInboundFee).toHaveBeenCalledWith('legacy-10001', '88') expect(document.body.textContent).not.toContain('租户') }) it('keeps the prototype-aligned detail order and renders long values with pending timestamps', async () => { const longRuleCode = `RULE-${'LONG-'.repeat(20)}` const longChannelCode = `CHANNEL-${'LONG-'.repeat(20)}` vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, ruleCode: longRuleCode, channelCode: longChannelCode, confirmedAt: null, sentAt: null, }) const wrapper = mountPanel() await flushPromises() const content = wrapper.find('.fee-content').text() expect(wrapper.findAll('.fee-summary > div > span').map((node) => node.text())).toEqual([ '头程自动费用 · CNY', '最终计费量', '单价', '附加费', '快照版本', ]) expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([ '实际重量', '实际体积', '体积重量', '取大后的计费量', ]) expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([ '客户计费规则', '渠道价格', ]) expect(content).toContain(longRuleCode) expect(content).toContain(longChannelCode) const pendingTimelineItems = wrapper.findAll('.fee-timeline li.pending') expect(pendingTimelineItems).toHaveLength(2) expect(pendingTimelineItems.map((item) => item.find('strong').text())).toEqual(['—', '—']) }) it('does not request or expose fees without the view permission', async () => { mountPanel([]) await flushPromises() expect(getLatestInboundFee).not.toHaveBeenCalled() expect(document.body.textContent).toContain('没有查看入库费用的权限') }) it.each(['', ' ', 'null', 'undefined'])('does not request a fee for invalid inboundId %j', async (inboundId) => { const pinia = setupPermissions(['firstleg:fba-pricing:view']) mount(InboundFeePanel, { props: { customerId: '10001', inboundId }, global: { plugins: [pinia, ElementPlus] }, }) await flushPromises() expect(getLatestInboundFee).not.toHaveBeenCalled() }) it('treats A0404 as a neutral not-yet-generated state', async () => { vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404')) mountPanel(['firstleg:fba-pricing:view']) await flushPromises() expect(document.body.textContent).toContain('尚未生成费用快照') expect(document.body.textContent).toContain('测量审核完成后') expect(document.querySelector('[role="alert"]')).toBeNull() }) it('shows adjustment only for authorized calculated inbound fees', async () => { mountPanel(['firstleg:fba-pricing:view']) await flushPromises() expect(document.querySelector('[data-testid="adjust-fee-button"]')).toBeNull() document.body.innerHTML = '' vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' }) mountPanel() await flushPromises() expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull() }) it('allows adjustment for a calculated fee waiting for split', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' }) const wrapper = mountPanel() await flushPromises() expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true) }) it('confirms and then sends a calculated fee through the implemented backend transitions', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) const wrapper = mountPanel() await flushPromises() await wrapper.get('[data-testid="confirm-fee-button"]').trigger('click') await flushPromises() expect(confirmInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-confirm-/)) expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(true) await wrapper.get('[data-testid="send-fee-button"]').trigger('click') await flushPromises() expect(sendInboundFee).toHaveBeenCalledWith('10001', '88', expect.stringMatching(/^fee-send-/)) expect(wrapper.emitted('changed')).toHaveLength(2) expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false) }) it('does not expose fee transitions without their independent permissions', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) const wrapper = mountPanel(['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) await flushPromises() expect(wrapper.find('[data-testid="confirm-fee-button"]').exists()).toBe(false) expect(wrapper.find('[data-testid="send-fee-button"]').exists()).toBe(false) }) it('submits a reasoned adjustment and emits the refreshed snapshot', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' }) const wrapper = mountPanel() await flushPromises() ;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click() await flushPromises() const numbers = [...document.querySelectorAll('.adjustment-form input')] numbers[0]!.value = '140' numbers[0]!.dispatchEvent(new Event('input', { bubbles: true })) numbers[0]!.dispatchEvent(new Event('change', { bubbles: true })) numbers[1]!.value = '8.2' numbers[1]!.dispatchEvent(new Event('input', { bubbles: true })) numbers[1]!.dispatchEvent(new Event('change', { bubbles: true })) numbers[2]!.value = '80' numbers[2]!.dispatchEvent(new Event('input', { bubbles: true })) numbers[2]!.dispatchEvent(new Event('change', { bubbles: true })) const reason = document.querySelector('.adjustment-form textarea')! reason.value = '补充已确认的附加费用' reason.dispatchEvent(new Event('input', { bubbles: true })) reason.dispatchEvent(new Event('blur', { bubbles: true })) const confirm = [...document.querySelectorAll('button')] .find((button) => button.textContent?.includes('确认调整并生成新版本')) confirm?.click() await flushPromises() expect(adjustPublishedInboundFee).toHaveBeenCalledWith( '10001', '88', { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用' }, expect.stringMatching(/^fee-adjust-/), ) expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 }) }) it('keeps adjustment units on one line in a three-column desktop layout', () => { const source = readFileSync(resolve('src/modules/inbounds/fee/InboundFeePanel.vue'), 'utf8') expect(source).toContain('width="min(760px, calc(100vw - 28px))"') expect(source).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))') expect(source).toContain('class="adjustment-unit"') expect(source).toContain('white-space: nowrap') }) it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED' }) vi.mocked(adjustPublishedInboundFee).mockRejectedValue( new ApiResponseError('费用已被其他操作员修改,请刷新后重试', 'A0409', 409), ) mountPanel() await flushPromises() ;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click() await flushPromises() const numbers = [...document.querySelectorAll('.adjustment-form input')] numbers[0]!.value = '140' numbers[0]!.dispatchEvent(new Event('input', { bubbles: true })) numbers[1]!.value = '8.2' numbers[1]!.dispatchEvent(new Event('input', { bubbles: true })) numbers[2]!.value = '80' numbers[2]!.dispatchEvent(new Event('input', { bubbles: true })) const reason = document.querySelector('.adjustment-form textarea')! reason.value = '并发场景价格修正' reason.dispatchEvent(new Event('input', { bubbles: true })) reason.dispatchEvent(new Event('blur', { bubbles: true })) ;[...document.querySelectorAll('button')] .find((button) => button.textContent?.includes('确认调整并生成新版本'))?.click() await flushPromises() expect(getLatestInboundFee).toHaveBeenCalledTimes(2) expect(document.body.textContent).toContain('系统已刷新最新快照') expect(document.body.textContent).toContain('当前输入仍保留') }) })