This commit is contained in:
2026-08-19 09:14:35 +08:00
parent c188482c24
commit 80cfca5056
36 changed files with 854 additions and 1044 deletions
@@ -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()
+62 -2
View File
@@ -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>
+22 -1
View File
@@ -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-/)
})
})
+28 -2
View File
@@ -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)}`
}