优化
This commit is contained in:
@@ -4,12 +4,15 @@ 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'
|
||||
@@ -20,6 +23,8 @@ vi.mock('./api', async () => {
|
||||
...actual,
|
||||
getLatestInboundFee: vi.fn(),
|
||||
adjustPublishedInboundFee: vi.fn(),
|
||||
confirmInboundFee: vi.fn(),
|
||||
sendInboundFee: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -52,7 +57,12 @@ function setupPermissions(permissions: string[]) {
|
||||
return pinia
|
||||
}
|
||||
|
||||
function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) {
|
||||
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,
|
||||
@@ -67,6 +77,8 @@ describe('InboundFeePanel', () => {
|
||||
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 () => {
|
||||
@@ -88,6 +100,7 @@ describe('InboundFeePanel', () => {
|
||||
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('发送费用')
|
||||
})
|
||||
|
||||
@@ -201,6 +214,33 @@ describe('InboundFeePanel', () => {
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Check, CircleDollarSign, PencilLine, RefreshCw, Send } from '@lucide/vue'
|
||||
import { firstLegBusinessTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
} from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
confirmInboundFee,
|
||||
createFeeActionIdempotencyKey,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
sendInboundFee,
|
||||
} from './api'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
@@ -34,6 +37,7 @@ const emit = defineEmits<{
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const adjusting = ref(false)
|
||||
const feeAction = ref<'confirm' | 'send' | null>(null)
|
||||
const notFound = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const adjustmentError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -65,6 +69,8 @@ const adjustmentRules: FormRules = {
|
||||
|
||||
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
|
||||
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
|
||||
const canConfirm = computed(() => access.can('firstleg:fba-pricing:confirm'))
|
||||
const canSend = computed(() => access.can('firstleg:inbound:send-fee'))
|
||||
const customerContextId = computed(() => props.customerId || props.tenantId || '')
|
||||
const normalizedInboundId = computed(() => {
|
||||
const identifier = String(props.inboundId ?? '').trim()
|
||||
@@ -171,6 +177,44 @@ function openAdjustment() {
|
||||
adjustOpen.value = true
|
||||
}
|
||||
|
||||
async function transitionFee(action: 'confirm' | 'send') {
|
||||
if (!snapshot.value || feeAction.value) return
|
||||
const confirming = action === 'confirm'
|
||||
const expectedStatus = confirming ? 'CALCULATED' : 'CONFIRMED'
|
||||
if (snapshot.value.feeStatus !== expectedStatus) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
confirming
|
||||
? '确认后费用金额与计费依据将进入已确认状态,下一步需要发送费用。是否继续?'
|
||||
: '发送后费用将正式发布,并进入客户应收应付费用来源。是否继续?',
|
||||
confirming ? '确认头程费用' : '发送头程费用',
|
||||
{
|
||||
type: confirming ? 'warning' : 'success',
|
||||
confirmButtonText: confirming ? '确认费用' : '确认发送',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
feeAction.value = action
|
||||
adjustmentError.value = null
|
||||
try {
|
||||
const key = createFeeActionIdempotencyKey(action)
|
||||
const result = confirming
|
||||
? await confirmInboundFee(customerContextId.value, normalizedInboundId.value, key)
|
||||
: await sendInboundFee(customerContextId.value, normalizedInboundId.value, key)
|
||||
snapshot.value = result
|
||||
emit('changed', result)
|
||||
ElMessage.success(confirming ? '费用已确认,请继续发送费用' : '费用已发送并发布')
|
||||
} catch (error) {
|
||||
adjustmentError.value = getApiErrorDetails(error, confirming ? '费用确认失败,请刷新后重试' : '费用发送失败,请刷新后重试')
|
||||
await loadFee()
|
||||
} finally {
|
||||
feeAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
|
||||
return {
|
||||
expectedFeeVersion: adjustmentForm.expectedFeeVersion,
|
||||
@@ -261,6 +305,22 @@ defineExpose({ reload: loadFee })
|
||||
type="primary"
|
||||
@click="openAdjustment"
|
||||
><PencilLine :size="16" />调整初算费用</el-button>
|
||||
<el-button
|
||||
v-if="canConfirm && snapshot?.feeStatus === 'CALCULATED'"
|
||||
data-testid="confirm-fee-button"
|
||||
type="primary"
|
||||
:loading="feeAction === 'confirm'"
|
||||
:disabled="Boolean(feeAction)"
|
||||
@click="transitionFee('confirm')"
|
||||
><Check :size="16" />确认费用</el-button>
|
||||
<el-button
|
||||
v-if="canSend && snapshot?.feeStatus === 'CONFIRMED'"
|
||||
data-testid="send-fee-button"
|
||||
type="primary"
|
||||
:loading="feeAction === 'send'"
|
||||
:disabled="Boolean(feeAction)"
|
||||
@click="transitionFee('send')"
|
||||
><Send :size="16" />发送费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
confirmInboundFee,
|
||||
createFeeActionIdempotencyKey,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
sendInboundFee,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn(), put: vi.fn() } }
|
||||
return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }
|
||||
})
|
||||
|
||||
const snapshot = {
|
||||
@@ -50,6 +53,22 @@ describe('inbound fee API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('confirms and sends a calculated fee with independent idempotency keys', async () => {
|
||||
vi.mocked(http.post)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: { ...snapshot, feeStatus: 'CONFIRMED' } })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: snapshot })
|
||||
|
||||
await expect(confirmInboundFee('10001', '88', 'fee-confirm-1')).resolves.toMatchObject({ feeStatus: 'CONFIRMED' })
|
||||
await expect(sendInboundFee('10001', '88', 'fee-send-1')).resolves.toMatchObject({ feeStatus: 'SENT' })
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/88/fees/confirm', undefined, {
|
||||
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-confirm-1' },
|
||||
})
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/88/fees/send', undefined, {
|
||||
params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-send-1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves backend conflict metadata for stale-version recovery', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({
|
||||
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
|
||||
@@ -73,5 +92,7 @@ describe('inbound fee API', () => {
|
||||
expect(first).toMatch(/^fee-adjust-/)
|
||||
expect(first.length).toBeLessThanOrEqual(128)
|
||||
expect(second).not.toBe(first)
|
||||
expect(createFeeActionIdempotencyKey('confirm')).toMatch(/^fee-confirm-/)
|
||||
expect(createFeeActionIdempotencyKey('send')).toMatch(/^fee-send-/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,28 @@ export async function getLatestInboundFee(tenantId: string, inboundId: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
async function transitionInboundFee(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
action: 'confirm' | 'send',
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await http.post<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/${action}`,
|
||||
undefined,
|
||||
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function confirmInboundFee(tenantId: string, inboundId: string, idempotencyKey: string) {
|
||||
return transitionInboundFee(tenantId, inboundId, 'confirm', idempotencyKey)
|
||||
}
|
||||
|
||||
export function sendInboundFee(tenantId: string, inboundId: string, idempotencyKey: string) {
|
||||
return transitionInboundFee(tenantId, inboundId, 'send', idempotencyKey)
|
||||
}
|
||||
|
||||
export async function adjustPublishedInboundFee(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
@@ -30,7 +52,11 @@ export async function adjustPublishedInboundFee(
|
||||
}
|
||||
|
||||
export function createFeeAdjustmentIdempotencyKey() {
|
||||
return createFeeActionIdempotencyKey('adjust')
|
||||
}
|
||||
|
||||
export function createFeeActionIdempotencyKey(action: 'adjust' | 'confirm' | 'send') {
|
||||
const uuid = globalThis.crypto?.randomUUID?.()
|
||||
if (uuid) return `fee-adjust-${uuid}`
|
||||
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
if (uuid) return `fee-${action}-${uuid}`
|
||||
return `fee-${action}-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user