386 lines
18 KiB
TypeScript
386 lines
18 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_REFUND', pricingTaxMode: 'TAX_INCLUDED', chargeMode: 'KG',
|
|
conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP',
|
|
actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6,
|
|
baselineChargeableQuantity: 133.6, lowerChargeableQuantity: 120.24, upperChargeableQuantity: 146.96,
|
|
tolerancePercent: 10, 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',
|
|
customerConfirmationStatus: 'PENDING_CONFIRMATION', customerConfirmedBy: null,
|
|
customerConfirmedByName: null, customerConfirmedAt: null,
|
|
}
|
|
|
|
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).toContain('订单报关:退税报关')
|
|
expect(document.body.textContent).toContain('报价税制:包税')
|
|
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 compact title and refresh action on one heading row', async () => {
|
|
const pinia = setupPermissions(['firstleg:fba-pricing:view'])
|
|
const wrapper = mount(InboundFeePanel, {
|
|
props: { customerId: '10001', inboundId: '88', compact: true, displayOnly: true },
|
|
global: { plugins: [pinia, ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
|
|
const heading = wrapper.get('.fee-heading--compact')
|
|
expect(heading.get('#inbound-fee-title').text()).toBe('头程自动初算')
|
|
expect(heading.find('button[aria-label="刷新费用快照"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('keeps the refresh icon visible and spinning while the snapshot reloads', () => {
|
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/fee/InboundFeePanel.vue'), 'utf8')
|
|
expect(source).toContain('class="fee-refresh-button"')
|
|
expect(source).toContain(':aria-busy="loading"')
|
|
expect(source).toContain(`<RefreshCw :size="16" :class="{ 'is-spinning': loading }" />`)
|
|
expect(source).not.toMatch(/aria-label="刷新费用快照"[^>]*:loading=/)
|
|
expect(source).toContain('@keyframes fee-refresh-spin')
|
|
})
|
|
|
|
it('supports a display-only fee snapshot without calculation or transition actions', async () => {
|
|
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null })
|
|
const pinia = setupPermissions([
|
|
'firstleg:fba-pricing:view', 'firstleg:fba-pricing:calculate', 'firstleg:fba-pricing:adjust',
|
|
'firstleg:fba-pricing:confirm', 'firstleg:inbound:send-fee',
|
|
])
|
|
const wrapper = mount(InboundFeePanel, {
|
|
props: { customerId: '10001', inboundId: '88', displayOnly: true },
|
|
global: { plugins: [pinia, ElementPlus] },
|
|
})
|
|
await flushPromises()
|
|
|
|
expect(wrapper.find('button[aria-label="刷新费用快照"]').exists()).toBe(true)
|
|
expect(wrapper.text()).not.toContain('费用试算')
|
|
expect(wrapper.text()).not.toContain('确认费用')
|
|
expect(wrapper.text()).not.toContain('确认推送费用')
|
|
expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-testid="confirm-fee-button"]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-testid="send-fee-button"]').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('allows a published fee adjustment until the customer confirms it', async () => {
|
|
const wrapper = mountPanel()
|
|
await flushPromises()
|
|
expect(wrapper.get('[data-testid="adjust-fee-button"]').text()).toContain('调整已发布费用')
|
|
|
|
document.body.innerHTML = ''
|
|
vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, customerConfirmationStatus: 'CONFIRMED' })
|
|
const confirmed = mountPanel()
|
|
await flushPromises()
|
|
expect(confirmed.find('[data-testid="adjust-fee-button"]').exists()).toBe(false)
|
|
})
|
|
|
|
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: '补充已确认的附加费用', toleranceConfirmed: true },
|
|
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')
|
|
expect(source).toContain('snapshot.value?.lowerChargeableQuantity')
|
|
expect(source).toContain('snapshot.value?.upperChargeableQuantity')
|
|
expect(source).toContain('toleranceConfirmed: true')
|
|
expect(source).toContain("'再次确认计费量调整'")
|
|
expect(source).toContain('nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value')
|
|
})
|
|
|
|
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('当前输入仍保留')
|
|
})
|
|
|
|
})
|