feat(booking): add safe retries and operational metrics

This commit is contained in:
鲨鱼辣椒 committed 2026-08-08 16:25:17 +08:00
1 parent c826b6e574
commit 7330ac853b
26 files changed
+749 -31

No files matched your search

+4
View File
@@ -680,6 +680,10 @@ export default {
refreshing: 'Refreshing…',
source: 'Source message',
revision: 'Revision',
retryCount: 'Retry count',
retry: 'Retry processing',
retrying: 'Retrying…',
retrySuccess: 'The run was returned to the processing queue and will refresh automatically.',
disposition: 'Disposition',
status: 'Current status',
timeline: 'Processing progress',
+4
View File
@@ -680,6 +680,10 @@ export default {
refreshing: 'กำลังรีเฟรช…',
source: 'อีเมลต้นทาง',
revision: 'ฉบับแก้ไข',
retryCount: 'จำนวนครั้งที่ลองใหม่',
retry: 'ประมวลผลอีกครั้ง',
retrying: 'กำลังประมวลผลอีกครั้ง…',
retrySuccess: 'ส่งกลับเข้าคิวประมวลผลแล้ว และสถานะจะรีเฟรชอัตโนมัติ',
disposition: 'ผลการจัดการ',
status: 'สถานะปัจจุบัน',
timeline: 'ความคืบหน้าการประมวลผล',
+4
View File
@@ -679,6 +679,10 @@ export default {
refreshing: '刷新中…',
source: '来源邮件',
revision: '修订',
retryCount: '重试次数',
retry: '重新处理',
retrying: '正在重新处理…',
retrySuccess: '已重新进入处理队列,将自动刷新状态。',
disposition: '处理结果',
status: '当前状态',
timeline: '处理进度',
@@ -63,6 +63,13 @@ export async function fetchBookingProcessingRun(runId: string): Promise<BookingP
)
}
export async function retryBookingProcessingRun(runId: string): Promise<BookingProcessingRunView> {
return sendJson<BookingProcessingRunView>(
`/api/reservation/booking-processing-runs/${encodeURIComponent(runId)}/retry`,
'POST',
)
}
export async function confirmBookingProcessingRun(
runId: string,
request: BookingConfirmationRequest,
@@ -13,6 +13,7 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
...actual,
fetchBookingProcessingRun: vi.fn(),
confirmBookingProcessingRun: vi.fn(),
retryBookingProcessingRun: vi.fn(),
}
})
@@ -22,6 +23,7 @@ describe('ReservationBookingProcessingRunView', () => {
beforeEach(() => {
vi.mocked(service.fetchBookingProcessingRun).mockReset()
vi.mocked(service.confirmBookingProcessingRun).mockReset()
vi.mocked(service.retryBookingProcessingRun).mockReset()
})
it('shows the safe confirmation projection and freezes corrected values without a PMS action', async () => {
@@ -64,6 +66,38 @@ describe('ReservationBookingProcessingRunView', () => {
expect(wrapper.text()).toContain('参数已冻结')
expect(wrapper.text()).toContain('已确认')
})
it('retries only a failed processing run and resumes safe polling', async () => {
const failed = {
...awaitingRun(),
status: 'FAILED' as const,
result_disposition: 'RISK_NOTIFICATION' as const,
retry_count: 0,
error_code: 'BOOKING_ORCHESTRATION_FAILED',
error_summary: 'Booking processing failed before confirmation.',
confirmation_projection: null,
}
const retried = {
...failed,
status: 'CONTEXT_READY' as const,
retry_count: 1,
error_code: null,
error_summary: null,
}
vi.mocked(service.fetchBookingProcessingRun).mockResolvedValue(failed)
vi.mocked(service.retryBookingProcessingRun).mockResolvedValue(retried)
const wrapper = await mountView()
await flushPromises()
const retryButton = wrapper.findAll('button').find((button) => button.text().includes('重新处理'))
expect(retryButton).toBeDefined()
await retryButton!.trigger('click')
await flushPromises()
expect(service.retryBookingProcessingRun).toHaveBeenCalledWith('booking-001')
expect(wrapper.text()).toContain('上下文已组装')
expect(wrapper.text()).toContain('已重新进入处理队列')
})
})
async function mountView() {
@@ -64,6 +64,10 @@
<dt>{{ t('bookingProcessingRun.revision') }}</dt>
<dd>{{ run.revision_number }}</dd>
</div>
<div>
<dt>{{ t('bookingProcessingRun.retryCount') }}</dt>
<dd>{{ run.retry_count }}</dd>
</div>
<div>
<dt>{{ t('bookingProcessingRun.disposition') }}</dt>
<dd>{{ run.result_disposition ?? '—' }}</dd>
@@ -105,6 +109,15 @@
>
{{ run.error_code ?? 'BOOKING_PROCESSING_ERROR' }} · {{ run.error_summary }}
</p>
<button
v-if="canRetry"
type="button"
class="secondary-button"
:disabled="retrying"
@click="retryRun"
>
{{ retrying ? t('bookingProcessingRun.retrying') : t('bookingProcessingRun.retry') }}
</button>
</section>
<section
@@ -258,6 +271,7 @@ import { ApiError } from '@/services/httpClient'
import {
confirmBookingProcessingRun,
fetchBookingProcessingRun,
retryBookingProcessingRun,
} from '@/services/reservationService'
import type {
BookingConfirmationField,
@@ -282,6 +296,7 @@ const route = useRoute()
const run = ref<BookingProcessingRunView | null>(null)
const loading = ref(false)
const confirming = ref(false)
const retrying = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const draftValues = ref<Record<string, Record<string, string>>>({})
@@ -299,6 +314,7 @@ const projectionIssues = computed(() => projection.value?.issues ?? [])
const evidenceCount = computed(() => projection.value?.evidence_refs.length ?? 0)
const canSubmit = computed(() => run.value?.status === 'AWAITING_CONFIRMATION'
&& confirmationItems.value.length > 0)
const canRetry = computed(() => run.value?.status === 'FAILED')
onMounted(() => {
void loadRun(true)
@@ -401,6 +417,26 @@ async function confirmRun(): Promise<void> {
}
}
async function retryRun(): Promise<void> {
if (!run.value || !canRetry.value || retrying.value) {
return
}
retrying.value = true
errorMessage.value = ''
successMessage.value = ''
try {
const nextRun = await retryBookingProcessingRun(run.value.processing_run_id)
run.value = nextRun
hydrate(nextRun)
schedulePolling(nextRun)
successMessage.value = t('bookingProcessingRun.retrySuccess')
} catch (error) {
errorMessage.value = apiErrorMessage(error)
} finally {
retrying.value = false
}
}
function itemFields(item: BookingConfirmationProjection['confirmation_items'][number]): BookingConfirmationField[] {
const byCode = new Map<string, BookingConfirmationField>()
for (const field of item.display_fields) {