Files
admin-web/src/modules/inbounds/fee/InboundFeePanel.test.ts
T
2026-09-01 21:09:39 +08:00

319 lines
14 KiB
TypeScript

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<typeof import('./api')>('./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<HTMLInputElement>('.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<HTMLTextAreaElement>('.adjustment-form textarea')!
reason.value = '补充已确认的附加费用'
reason.dispatchEvent(new Event('input', { bubbles: true }))
reason.dispatchEvent(new Event('blur', { bubbles: true }))
const confirm = [...document.querySelectorAll<HTMLButtonElement>('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<HTMLInputElement>('.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<HTMLTextAreaElement>('.adjustment-form textarea')!
reason.value = '并发场景价格修正'
reason.dispatchEvent(new Event('input', { bubbles: true }))
reason.dispatchEvent(new Event('blur', { bubbles: true }))
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes('确认调整并生成新版本'))?.click()
await flushPromises()
expect(getLatestInboundFee).toHaveBeenCalledTimes(2)
expect(document.body.textContent).toContain('系统已刷新最新快照')
expect(document.body.textContent).toContain('当前输入仍保留')
})
})