import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import { createMemoryHistory, createRouter } from 'vue-router' import zhCN from '@/i18n/locales/zh-CN' import ReservationTaskDetailPanel from '@/components/reservation/ReservationTaskDetailPanel.vue' import { useAuthStore } from '@/stores/authStore' import type { ReservationManualReviewConversionResult, ReservationManualReviewResolutionResult, ReservationOrderListItem, ReservationOrderListResult, ReservationTaskAuditLogResult, ReservationTaskDetailResult, ReservationTaskFieldResult, ReservationTaskPayloadMutationResult, } from '@/types/reservation' vi.mock('@/services/reservationService', async (importOriginal) => { const actual = await importOriginal() return { ...actual, fetchReservationTaskDetail: vi.fn(), fetchReservationTaskAudits: vi.fn(), fetchReservationOrders: vi.fn(), convertReservationManualReviewTask: vi.fn(), resolveReservationManualReviewTask: vi.fn(), saveReservationTaskDraft: vi.fn(), confirmReservationTask: vi.fn(), } }) const service = await import('@/services/reservationService') function createTaskDetail(overrides: Partial = {}): ReservationTaskDetailResult { const detail: ReservationTaskDetailResult = { task_id: '10001', order_id: '20001', source_message_id: '30001', source_subject: 'Booking Request', source_sender_summary: 'guest@example.test', source_received_at: '2026-07-08T03:00:00Z', external_conversation_id: 'thread-30001', conversation_message_count: 2, system_task_type: 'NEW_BOOKING', task_card_type: 'NEW_BOOKING', task_status: 'PENDING_CONFIRM', field_contract_version: '20260708-3.0', draft_payload: null, confirmed_payload: null, availability: { blocked: false, read_only: false, editable: true, confirmable: true, executable: false, blocked_by_task_id: null, blocked_reason: null, }, fields: [], opera_operations: [], } return { ...detail, ...overrides, } } function createSourceMessageOnlyTaskDetail( overrides: Partial = {}, ): ReservationTaskDetailResult { return createTaskDetail({ result_type: 'source_message_review_notification', ai_task_type: 'Message Notification', task_subtype: 'S10', route_code: 'S10', system_process_category: 'SOURCE_MESSAGE_NOTIFICATION', system_task_type: 'MESSAGE_NOTIFICATION', task_card_type: 'MESSAGE_NOTIFICATION', task_status: 'COMPLETED', fields: [], opera_operations: [], availability: { blocked: false, read_only: true, editable: false, confirmable: false, executable: false, blocked_by_task_id: null, blocked_reason: null, }, source_message_only_result: { entry_result_code: 'S10', entry_result_meaning: 'PURE_INFORMATION', entry_result_description: '纯信息类邮件', entry_result_source_message_id: '30001', result_type: 'source_message_review_notification', route_code: 'S10', agent_assessment: { intent: 'informational', }, notification: { current_message_summary: '仅同步信息,不需要订单动作。', }, manual_review: null, raw_answer: 'S10,30001', }, ...overrides, }) } function createMutationResult(taskStatus = 'PENDING_CONFIRM'): ReservationTaskPayloadMutationResult { return { task_id: '10001', order_id: '20001', task_status: taskStatus, draft_payload: {}, confirmed_payload: taskStatus === 'READY' ? {} : null, opera_operations: [], } } function createManualReviewConversionResult( overrides: Partial = {}, ): ReservationManualReviewConversionResult { return { task_id: '10001', order_id: '20001', original_order_id: '20001', system_task_type: 'NEW_BOOKING', task_card_type: 'NEW_BOOKING', task_status: 'PENDING_CONFIRM', original_order_logic_deleted: false, ...overrides, } } function createManualReviewResolutionResult( overrides: Partial = {}, ): ReservationManualReviewResolutionResult { return { task_id: '10001', order_id: '20001', task_status: 'READY', review_status: 'RESOLVED', review_resolution: { confirmed_order_id: '20001', field_overrides: [ { field_pointer: '/extracted_fields/room_items/0/pms_room_type_code', field_path: 'extracted_fields.room_items.0.pms_room_type_code', legacy_field_path: 'extracted_fields.pms_room_type_code', value: 'RM3', }, ], }, confirmed_payload: { field_values: { 'extracted_fields.room_items.0.pms_room_type_code': 'RM3', }, legacy_field_values: { 'extracted_fields.pms_room_type_code': 'RM3', }, }, opera_operations: [], ...overrides, } } function createOrderCandidate(overrides: Partial = {}): ReservationOrderListItem { return { order_id: '20002', hotel_id: 'HOTEL-TEST', order_status: 'ACTIVE', display_order_key: 'GRP-001', temporary_order_no: null, confirmation_number: 'CNF-001', group_code: 'GRP-001', display_name: 'GRP-001', open_task_count: 1, open_work_item_count: 1, next_processable_task_id: null, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:10:00Z', ...overrides, } } function createOrderCandidateResult(items: ReservationOrderListItem[]): ReservationOrderListResult { return { items, page: { page_num: 1, page_size: 8, total: items.length, }, } } function createAudit(auditId: string, action: string): ReservationTaskAuditLogResult { return { audit_id: auditId, order_id: '20001', task_id: '10001', operation_id: null, actor_type: 'USER', actor_id: 'andy', action, reason: null, before_snapshot: null, after_snapshot: null, occurred_at: '2026-07-08T03:00:00Z', } } function createOperaOperation(): ReservationTaskDetailResult['opera_operations'][number] { return { operation_id: 'op-1', task_id: '10001', operation_sequence: 1, operation_code: 'CREATE_RESERVATION', operation_name: 'Create reservation', operation_status: 'PENDING', attempt_count: 0, last_error_message: null, attempts: [], } } function createRequiredField(overrides: Partial = {}): ReservationTaskFieldResult { return { row_number: 1, card_name: 'New Booking', display_area: '客人信息', field_path: 'guest.name', display_name: '客人姓名', visible: 'Y', editable: 'Y', input_editable: 'Y', select_editable: 'N', date_picker: 'N', number_input: 'N', file_display: 'N', table_editable: 'N', enum_options: null, required_rule: 'Y', display_condition: null, validation_rule: null, write_path: null, opera_write_participation: 'N', opera_parameter_mapping: null, notes: null, value: '', ...overrides, } } function createAuthPayload(permissions: string[]) { return { access_token: 'session-token', token_type: 'Bearer', expires_at: '2026-07-09T12:00:00Z', user: { id: '1900000000000000001', username: 'admin', display_name: '系统管理员', super_admin: true, }, default_hotel_id: 'HOTEL-TEST', hotels: [ { hotel_id: 'HOTEL-TEST', hotel_name: '测试酒店', time_zone: 'Asia/Bangkok', default_hotel: true, }, ], permissions, menus: [], } } const defaultTaskPermissions = [ 'RESERVATION_TASK_READ', 'RESERVATION_TASK_EDIT', 'RESERVATION_TASK_CONFIRM', 'RESERVATION_OPERA_SIM_EXECUTE', 'RESERVATION_AUDIT_READ', ] async function mountPanel(permissions = defaultTaskPermissions) { const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zhCN, }, }) const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/reservation/tasks/:taskId', component: { template: '
' } }, { path: '/reservation/orders/:orderId', component: { template: '
' } }, { path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '
' } }, ], }) const pinia = createPinia() setActivePinia(pinia) useAuthStore().applyLoginResult(createAuthPayload(permissions)) await router.push('/reservation/tasks/10001') await router.isReady() const wrapper = mount(ReservationTaskDetailPanel, { props: { taskId: '10001', }, global: { plugins: [i18n, router, pinia], stubs: { RouterLink: { template: '', }, }, }, }) await flushPromises() return wrapper } function findButton(wrapper: VueWrapper, label: string) { const button = wrapper.findAll('button').find((item) => item.text().includes(label)) if (!button) { throw new Error(`Button ${label} was not found.`) } return button } describe('ReservationTaskDetailPanel', () => { beforeEach(() => { vi.mocked(service.fetchReservationTaskDetail).mockReset() vi.mocked(service.fetchReservationTaskAudits).mockReset() vi.mocked(service.fetchReservationOrders).mockReset() vi.mocked(service.convertReservationManualReviewTask).mockReset() vi.mocked(service.resolveReservationManualReviewTask).mockReset() vi.mocked(service.saveReservationTaskDraft).mockReset() vi.mocked(service.confirmReservationTask).mockReset() vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(createTaskDetail()) vi.mocked(service.fetchReservationTaskAudits).mockResolvedValue({ task_id: '10001', items: [], }) vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderCandidateResult([])) vi.mocked(service.convertReservationManualReviewTask).mockResolvedValue(createManualReviewConversionResult()) vi.mocked(service.resolveReservationManualReviewTask).mockResolvedValue(createManualReviewResolutionResult()) sessionStorage.clear() }) it('shows draft save errors instead of leaving an unhandled rejection', async () => { vi.mocked(service.saveReservationTaskDraft).mockRejectedValue(new Error('Draft save failed')) const wrapper = await mountPanel() await findButton(wrapper, '保存草稿').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Draft save failed') expect(findButton(wrapper, '保存草稿').attributes('disabled')).toBeUndefined() }) it('shows confirm errors instead of leaving an unhandled rejection', async () => { vi.mocked(service.confirmReservationTask).mockRejectedValue(new Error('Confirm failed')) const wrapper = await mountPanel() await findButton(wrapper, '确认').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Confirm failed') expect(findButton(wrapper, '确认').attributes('disabled')).toBeUndefined() }) it('blocks confirmation when frontend field validation fails', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ fields: [createRequiredField()], }), ) const wrapper = await mountPanel() await findButton(wrapper, '确认').trigger('click') await flushPromises() expect(service.confirmReservationTask).not.toHaveBeenCalled() expect(wrapper.text()).toContain('请先修正字段') expect(wrapper.text()).toContain('客人姓名为必填项') }) it('keeps remaining validation errors and clears the summary after fields are fixed', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ fields: [ createRequiredField(), createRequiredField({ field_path: 'room.type', display_name: '房型', }), ], }), ) const wrapper = await mountPanel() await findButton(wrapper, '确认').trigger('click') await flushPromises() expect(wrapper.text()).toContain('请先修正字段') expect(wrapper.text()).toContain('客人姓名为必填项') expect(wrapper.text()).toContain('房型为必填项') const textareas = wrapper.findAll('textarea') await textareas[0]!.setValue('王建国') await flushPromises() expect(wrapper.text()).toContain('请先修正字段') expect(wrapper.text()).not.toContain('客人姓名为必填项') expect(wrapper.text()).toContain('房型为必填项') await textareas[1]!.setValue('豪华 Q1A') await flushPromises() expect(wrapper.text()).not.toContain('请先修正字段') expect(wrapper.text()).not.toContain('客人姓名为必填项') expect(wrapper.text()).not.toContain('房型为必填项') }) it('hydrates code-v1 legacy payload values but saves future drafts with P0 field paths', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ field_contract_version: 'code-v1', draft_payload: { field_values: { 'extracted_fields.room_items.0.pms_room_type_code': 'P0-SHOULD-NOT-WIN', }, legacy_field_values: { 'extracted_fields.pms_room_type_code': 'LEGACY-RM', }, }, fields: [ createRequiredField({ field_path: 'extracted_fields.room_items.0.pms_room_type_code', field_pointer: '/extracted_fields/room_items/0/pms_room_type_code', legacy_field_path: 'extracted_fields.pms_room_type_code', display_name: 'PMS 房型代码', value: null, }), ], }), ) vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult()) const wrapper = await mountPanel() const pmsInput = wrapper.find('textarea') expect((pmsInput.element as HTMLTextAreaElement).value).toBe('LEGACY-RM') expect(wrapper.text()).not.toContain('P0-SHOULD-NOT-WIN') await pmsInput.setValue('RM4') await findButton(wrapper, '保存草稿').trigger('click') await flushPromises() expect(service.saveReservationTaskDraft).toHaveBeenCalledWith('10001', { field_values: { 'extracted_fields.room_items.0.pms_room_type_code': 'RM4', }, }) }) it('refreshes audit records after saving a draft', async () => { vi.mocked(service.fetchReservationTaskAudits) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-1', 'TASK_LOADED')], }) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-2', 'DRAFT_SAVED')], }) vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult()) const wrapper = await mountPanel() expect(wrapper.text()).toContain('任务读取') await findButton(wrapper, '保存草稿').trigger('click') await flushPromises() expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2) expect(wrapper.text()).toContain('草稿保存') expect(wrapper.text()).not.toContain('任务读取') expect(wrapper.text()).not.toContain('DRAFT_SAVED') expect(wrapper.text()).not.toContain('TASK_LOADED') }) it('saves only editable active fields to match backend task field validation', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ fields: [ createRequiredField({ field_path: 'visible_reason', display_name: '生成原因', editable: 'N', input_editable: 'N', required_rule: 'Y', value: 'AI extracted this task.', }), createRequiredField({ field_path: 'case_keys.group_code', display_name: 'Group Code', display_condition: '对象为Group Block或Allotment时展示', value: 'GRP-001', }), createRequiredField({ field_path: 'extracted_fields.booking_object_type', display_name: '订单对象类型', select_editable: 'Y', input_editable: 'N', enum_options: 'Group Block, FIT Reservation', value: 'Group Block', }), ], }), ) vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult()) const wrapper = await mountPanel() await findButton(wrapper, '保存草稿').trigger('click') await flushPromises() expect(service.saveReservationTaskDraft).toHaveBeenCalledWith( '10001', { field_values: { 'case_keys.group_code': 'GRP-001', 'extracted_fields.booking_object_type': 'Group Block', }, }, ) }) it('renders a CP9 group block detail without assuming FIT or attachment fields exist', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ field_contract_version: '20260711-p0', ai_task_type: 'New Booking', task_subtype: 'new_group_block', fields: [ createRequiredField({ field_path: 'extracted_fields.booking_object_type', display_name: '订单对象类型', select_editable: 'Y', input_editable: 'N', enum_options: 'Group Block, FIT Reservation', value: 'Group Block', }), createRequiredField({ field_path: 'case_keys.group_code', field_pointer: '/case_keys/group_code', display_name: 'Group Code', value: 'GRP-CP9', }), ], }), ) vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult()) const wrapper = await mountPanel() await findButton(wrapper, '保存草稿').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Group Code') expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('GRP-CP9') expect(wrapper.text()).not.toContain('Confirmation No.') expect(wrapper.text()).not.toContain('相关附件') expect(service.saveReservationTaskDraft).toHaveBeenCalledWith('10001', { field_values: { 'extracted_fields.booking_object_type': 'Group Block', 'case_keys.group_code': 'GRP-CP9', }, }) }) it('saves drafts only with editable fields opened by backend control metadata', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ fields: [ createRequiredField({ field_path: 'case_keys.group_code', field_pointer: '/case_keys/group_code', display_name: 'Group Code', value: 'GRP-OPEN', control_type: 'text', edit_scope: 'normal_and_manual_review', write_target: 'field_values', raw_readonly: false, } as Partial), createRequiredField({ field_path: 'route_code', display_name: '路由代码', value: 'R01_NEW_BOOKING_NORMAL', editable: 'Y', input_editable: 'Y', control_type: 'workflow_state', edit_scope: 'system_only', write_target: 'none', raw_readonly: false, } as Partial), createRequiredField({ field_path: 'source_message.attachments', display_name: '来源附件', value: [{ name: 'rooming-list.pdf' }], editable: 'Y', input_editable: 'Y', control_type: 'file', edit_scope: 'never', write_target: 'none', raw_readonly: true, } as Partial), ], }), ) vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult()) const wrapper = await mountPanel() await findButton(wrapper, '保存草稿').trigger('click') await flushPromises() expect(service.saveReservationTaskDraft).toHaveBeenCalledWith('10001', { field_values: { 'case_keys.group_code': 'GRP-OPEN', }, }) }) it('opens the source conversation by source message id without external conversation id', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ external_conversation_id: null, }), ) const wrapper = await mountPanel() expect(wrapper.text()).toContain('查看邮件会话') expect(wrapper.text()).not.toContain('接口待补') }) it('renders source email preview with excerpt, attachments and full conversation entry', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ fields: [ createRequiredField({ field_path: 'relevant_message_excerpt', display_name: '邮件片段', value: 'LLT260509FA213 11-13/05/2026 2N 14 TWN +1 DBL', editable: 'N', input_editable: 'N', required_rule: null, control_type: 'evidence_text', edit_scope: 'never', write_target: 'none', raw_readonly: true, } as Partial), createRequiredField({ field_path: 'source_message.attachments', display_name: '来源附件', value: [ { id: 'att-1', name: 'WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx', content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size_bytes: 2048, }, ], editable: 'N', input_editable: 'N', file_display: 'Y', required_rule: null, control_type: 'file', edit_scope: 'never', write_target: 'none', raw_readonly: true, } as Partial), ], }), ) const wrapper = await mountPanel() expect(wrapper.text()).toContain('来源邮件预览') expect(wrapper.text()).toContain('Booking Request') expect(wrapper.text()).toContain('guest@example.test') expect(wrapper.text()).toContain('2026-07-08 10:00:00') expect(wrapper.text()).toContain('LLT260509FA213 11-13/05/2026 2N 14 TWN +1 DBL') expect(wrapper.text()).toContain('WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx') expect(wrapper.text()).toContain('查看完整邮件会话') expect(wrapper.text()).not.toContain('{"id":"att-1"') }) it('does not surface source email excerpt or attachments from unexposed backend context', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ fields: [], context_used: { relevant_message_excerpt: 'Hidden context excerpt', attachments: [ { name: 'hidden-context-attachment.pdf', }, ], }, manual_review: { attachments: [ { name: 'hidden-review-attachment.pdf', }, ], }, }), ) const wrapper = await mountPanel() const sourceEmailPreview = wrapper.find('.task-source-email') expect(sourceEmailPreview.text()).toContain('暂无邮件片段') expect(sourceEmailPreview.text()).not.toContain('Hidden context excerpt') expect(sourceEmailPreview.text()).not.toContain('hidden-context-attachment.pdf') expect(sourceEmailPreview.text()).not.toContain('hidden-review-attachment.pdf') expect(sourceEmailPreview.text()).not.toContain('相关附件') }) it('renders source-message-only entry result without editable or OPERA actions', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(createSourceMessageOnlyTaskDetail()) const wrapper = await mountPanel() expect(wrapper.text()).toContain('来源消息只读') expect(wrapper.text()).toContain('来源消息入口结果') expect(wrapper.text()).toContain('该任务仅用于展示来源消息入口结果') expect(wrapper.text()).toContain('S10') expect(wrapper.text()).toContain('PURE_INFORMATION') expect(wrapper.text()).toContain('纯信息类邮件') expect(wrapper.text()).toContain('S10,30001') expect(wrapper.text()).toContain('来源消息通知') expect(wrapper.text()).toContain('查看邮件会话') expect(wrapper.text()).not.toContain('查看订单') expect(wrapper.text()).not.toContain('保存草稿') expect(wrapper.text()).not.toContain('确认任务') expect(wrapper.text()).not.toContain('人工转换') expect(wrapper.text()).not.toContain('OPERA 模拟操作') expect(wrapper.text()).not.toContain('确认任务后生成 OPERA 模拟操作') expect(wrapper.find('button[title="执行"]').exists()).toBe(false) expect(wrapper.find('button[title="重试"]').exists()).toBe(false) }) it('renders R08 parent group manual review with identity candidates on the same task card', async () => { vi.mocked(service.fetchReservationTaskDetail) .mockResolvedValueOnce( createTaskDetail({ result_type: 'manual_review', ai_task_type: 'Cancel Allotment', task_subtype: 'cancel_allotment_control_block', route_code: 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW', system_process_category: 'BUSINESS_TASK', system_task_type: 'CANCEL_BOOKING', task_card_type: 'CANCEL_ALLOTMENT', task_status: 'BLOCKED', review_status: 'PENDING', manual_review: { reason_code: 'target_object_unclear', visible_reason: 'Parent Group 的 group_code 与 block_code 原始候选冲突,请人工确认目标。', missing_fields: ['/case_keys/group_code', '/case_keys/block_code'], blocking_points: ['Parent Group identity cannot be safely normalized.'], conflicting_points: ['PARENT-2608-CONFLICT-A', 'PARENT-2608-CONFLICT-B'], suggested_human_actions: ['confirm_parent_group_identity'], evidence_to_check: ['parent_identity_candidates'], }, context_used: { parent_identity_candidates: [ { field: 'group_code', value: 'PARENT-2608-CONFLICT-A', evidence_source: 'subject', }, { field: 'block_code', value: 'PARENT-2608-CONFLICT-B', evidence_source: 'attachment', normalized_value: 'PARENT-2608-CONFLICT-B', }, { candidate_field: 'parent_group_code', raw_value: 'PARENT-2608-CONFLICT-C', origin: 'email_body', }, ], }, availability: { blocked: true, read_only: true, editable: false, confirmable: false, executable: false, blocked_by_task_id: null, blocked_reason: 'MANUAL_REVIEW_REQUIRED', }, fields: [ createRequiredField({ field_path: 'case_keys.group_code', field_pointer: '/case_keys/group_code', display_name: 'Parent Group 团队号', value: null, }), createRequiredField({ field_path: 'case_keys.block_code', field_pointer: '/case_keys/block_code', display_name: 'Parent Group Block Code', value: null, }), ], }), ) .mockResolvedValueOnce( createTaskDetail({ result_type: 'manual_review', ai_task_type: 'Cancel Allotment', task_subtype: 'cancel_allotment_control_block', route_code: 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW', system_process_category: 'BUSINESS_TASK', system_task_type: 'CANCEL_BOOKING', task_card_type: 'CANCEL_ALLOTMENT', task_status: 'READY', review_status: 'RESOLVED', review_resolution: { confirmed_order_id: '20001', field_overrides: [ { field_pointer: '/case_keys/group_code', field_path: 'case_keys.group_code', value: 'PARENT-2608-CONFLICT-A', }, { field_pointer: '/case_keys/block_code', field_path: 'case_keys.block_code', value: 'PARENT-2608-CONFLICT-A', }, ], }, availability: { blocked: false, read_only: false, editable: false, confirmable: false, executable: true, blocked_by_task_id: null, blocked_reason: null, }, opera_operations: [createOperaOperation()], }), ) vi.mocked(service.fetchReservationTaskAudits) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-1', 'TASK_LOADED')], }) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-2', 'MANUAL_REVIEW_RESOLVED')], }) const wrapper = await mountPanel() expect(wrapper.text()).toContain('Parent Group') expect(wrapper.text()).toContain('取消 Parent Group 配额') expect(wrapper.text()).toContain('取消 Parent Group 整块配额') expect(wrapper.text()).toContain('R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW') expect(wrapper.text()).toContain('人工复核证据') expect(wrapper.text()).toContain('target_object_unclear') expect(wrapper.text()).toContain('Parent Group 的 group_code 与 block_code 原始候选冲突') expect(wrapper.text()).toContain('PARENT-2608-CONFLICT-A') expect(wrapper.text()).toContain('PARENT-2608-CONFLICT-B') expect(wrapper.text()).toContain('parent_group_code') expect(wrapper.text()).toContain('PARENT-2608-CONFLICT-C') expect(wrapper.text()).toContain('subject') expect(wrapper.text()).toContain('attachment') expect(wrapper.text()).toContain('email_body') expect(wrapper.text()).toContain('normalized_value') expect(wrapper.text()).not.toContain('适配契约异常') expect(wrapper.text()).not.toContain('人工转换') expect(wrapper.text()).not.toContain('保存草稿') expect(wrapper.text()).not.toContain('确认任务') await wrapper.find('input[name="manual_resolution_field_override_0"]').setValue('PARENT-2608-CONFLICT-A') await wrapper.find('input[name="manual_resolution_field_override_1"]').setValue('PARENT-2608-CONFLICT-A') await wrapper.find('textarea[name="manual_resolution_reason"]').setValue('确认 Parent Group identity') await findButton(wrapper, '提交复核结果').trigger('click') await flushPromises() expect(service.resolveReservationManualReviewTask).toHaveBeenCalledWith('10001', { confirmed_order_id: '20001', reason: '确认 Parent Group identity', field_overrides: [ { field_pointer: '/case_keys/group_code', field_path: 'case_keys.group_code', value: 'PARENT-2608-CONFLICT-A', }, { field_pointer: '/case_keys/block_code', field_path: 'case_keys.block_code', value: 'PARENT-2608-CONFLICT-A', }, ], }) expect(wrapper.text()).toContain('人工复核已解阻') }) it('shows missing manual review fields as backend unopened when detail.fields omits them', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ result_type: 'manual_review', ai_task_type: 'NEW_BOOKING', task_subtype: 'NEW_BOOKING', route_code: 'NEW_BOOKING_MANUAL_REVIEW', system_process_category: 'BUSINESS_TASK', task_status: 'BLOCKED', review_status: 'PENDING', manual_review: { reason_code: 'missing_room_type_code', missing_fields: [ '/extracted_fields/room_items/0/pms_room_type_code', '/case_keys/confirmation_number', ], review_instruction: '请补充缺失字段。', }, availability: { blocked: true, read_only: true, editable: false, confirmable: false, executable: false, blocked_by_task_id: null, blocked_reason: 'MANUAL_REVIEW_REQUIRED', }, fields: [ createRequiredField({ field_path: 'extracted_fields.room_items.0.pms_room_type_code', field_pointer: '/extracted_fields/room_items/0/pms_room_type_code', legacy_field_path: 'extracted_fields.pms_room_type_code', display_name: 'PMS 房型代码', value: null, }), ], }), ) const wrapper = await mountPanel() expect(wrapper.text()).toContain('PMS 房型代码') expect(wrapper.text()).toContain('/case_keys/confirmation_number') expect(wrapper.text()).toContain('当前字段后端未开放编辑') expect(wrapper.findAll('input[name^="manual_resolution_field_override_"]')).toHaveLength(1) expect(findButton(wrapper, '提交复核结果').attributes('disabled')).toBeDefined() }) it('resolves type-known manual review on the same task card', async () => { vi.mocked(service.fetchReservationTaskDetail) .mockResolvedValueOnce( createTaskDetail({ result_type: 'manual_review', ai_task_type: 'NEW_BOOKING', task_subtype: 'NEW_BOOKING', route_code: 'NEW_BOOKING_MANUAL_REVIEW', system_process_category: 'BUSINESS_TASK', task_status: 'BLOCKED', review_status: 'PENDING', manual_review: { reason_code: 'missing_room_type_code', missing_fields: ['/extracted_fields/room_items/0/pms_room_type_code'], review_instruction: '请补充 PMS 房型代码。', }, availability: { blocked: true, read_only: true, editable: false, confirmable: false, executable: false, blocked_by_task_id: null, blocked_reason: 'MANUAL_REVIEW_REQUIRED', }, fields: [ createRequiredField({ field_path: 'extracted_fields.room_items.0.pms_room_type_code', field_pointer: '/extracted_fields/room_items/0/pms_room_type_code', legacy_field_path: 'extracted_fields.pms_room_type_code', display_name: 'PMS 房型代码', editable: 'N', input_editable: 'N', value: null, }), createRequiredField({ field_path: 'extracted_fields.room_items.0.room_quantity', field_pointer: '/extracted_fields/room_items/0/room_quantity', legacy_field_path: 'extracted_fields.room_quantity', display_name: '房量', control_type: 'number', edit_scope: 'normal_and_manual_review', write_target: 'draft_payload_and_review_resolution', raw_readonly: false, value: null, }), ], }), ) .mockResolvedValueOnce( createTaskDetail({ result_type: 'manual_review', ai_task_type: 'NEW_BOOKING', task_subtype: 'NEW_BOOKING', route_code: 'NEW_BOOKING_MANUAL_REVIEW', system_process_category: 'BUSINESS_TASK', task_status: 'READY', review_status: 'RESOLVED', review_resolution: { confirmed_order_id: '20001', field_overrides: [ { field_pointer: '/extracted_fields/room_items/0/pms_room_type_code', field_path: 'extracted_fields.room_items.0.pms_room_type_code', legacy_field_path: 'extracted_fields.pms_room_type_code', value: 'RM3', }, ], }, availability: { blocked: false, read_only: false, editable: false, confirmable: false, executable: true, blocked_by_task_id: null, blocked_reason: null, }, opera_operations: [createOperaOperation()], }), ) vi.mocked(service.fetchReservationTaskAudits) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-1', 'TASK_LOADED')], }) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-2', 'MANUAL_REVIEW_RESOLVED')], }) const wrapper = await mountPanel() expect(wrapper.text()).toContain('路由与处理信息') expect(wrapper.text()).toContain('系统任务类型') expect(wrapper.text()).toContain('任务卡类型') expect(wrapper.text()).toContain('NEW_BOOKING_MANUAL_REVIEW') expect(wrapper.text()).toContain('人工复核解阻') expect(wrapper.text()).toContain('/extracted_fields/room_items/0/pms_room_type_code') expect(wrapper.text()).toContain('PMS 房型代码') expect(wrapper.text()).toContain('/extracted_fields/room_items/0/room_quantity') expect(wrapper.text()).toContain('房量') expect(wrapper.text()).not.toContain('人工转换') expect(wrapper.text()).not.toContain('保存草稿') expect(wrapper.text()).not.toContain('确认任务') expect(wrapper.text()).not.toContain('OPERA 模拟操作') await wrapper.find('input[name="manual_resolution_field_override_0"]').setValue('RM3') await wrapper.find('input[name="manual_resolution_field_override_1"]').setValue('2') await wrapper.find('textarea[name="manual_resolution_reason"]').setValue('确认 PMS 房型代码') await findButton(wrapper, '提交复核结果').trigger('click') await flushPromises() expect(service.resolveReservationManualReviewTask).toHaveBeenCalledWith('10001', { confirmed_order_id: '20001', reason: '确认 PMS 房型代码', field_overrides: [ { field_pointer: '/extracted_fields/room_items/0/pms_room_type_code', field_path: 'extracted_fields.room_items.0.pms_room_type_code', value: 'RM3', }, { field_pointer: '/extracted_fields/room_items/0/room_quantity', field_path: 'extracted_fields.room_items.0.room_quantity', value: '2', }, ], }) expect(service.fetchReservationTaskDetail).toHaveBeenCalledTimes(2) expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2) expect(wrapper.text()).toContain('人工复核已解阻') expect(wrapper.text()).toContain('人工复核解阻') expect(wrapper.text()).toContain('OPERA 模拟操作') expect(wrapper.text()).toContain('CREATE_RESERVATION') expect(wrapper.find('button[title="执行"]').exists()).toBe(true) expect(wrapper.text()).not.toContain('MANUAL_REVIEW_RESOLVED') }) it('converts a manual review task to a new booking without a target order', async () => { vi.mocked(service.fetchReservationTaskDetail) .mockResolvedValueOnce( createTaskDetail({ system_task_type: 'MANUAL_REVIEW', task_card_type: 'FALLBACK_REVIEW', }), ) .mockResolvedValueOnce( createTaskDetail({ system_task_type: 'NEW_BOOKING', task_card_type: 'NEW_BOOKING', }), ) vi.mocked(service.fetchReservationTaskAudits) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-1', 'TASK_LOADED')], }) .mockResolvedValueOnce({ task_id: '10001', items: [createAudit('audit-2', 'MANUAL_REVIEW_CONVERT')], }) const wrapper = await mountPanel() expect(wrapper.text()).toContain('人工转换') expect(wrapper.text()).not.toContain('未知任务类型') await wrapper.find('select[name="manual_target_task_type"]').setValue('NEW_BOOKING') await wrapper.find('textarea[name="manual_conversion_reason"]').setValue('识别为新预订') await findButton(wrapper, '转换任务').trigger('click') await flushPromises() expect(service.convertReservationManualReviewTask).toHaveBeenCalledWith('10001', { target_task_type: 'NEW_BOOKING', reason: '识别为新预订', }) expect(service.fetchReservationTaskDetail).toHaveBeenCalledTimes(2) expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2) expect(wrapper.text()).toContain('人工转换已完成') expect(wrapper.text()).toContain('人工转换') expect(wrapper.text()).not.toContain('MANUAL_REVIEW_CONVERT') }) it('requires a non-deleted target order when converting manual review to update booking', async () => { vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ system_task_type: 'MANUAL_REVIEW', task_card_type: 'FALLBACK_REVIEW', }), ) vi.mocked(service.fetchReservationOrders).mockResolvedValue( createOrderCandidateResult([ createOrderCandidate({ order_id: '20002', display_order_key: 'GRP-ACTIVE', group_code: 'GRP-ACTIVE', confirmation_number: 'CNF-ACTIVE', order_status: 'ACTIVE', }), createOrderCandidate({ order_id: '20003', display_order_key: 'GRP-DELETED', group_code: 'GRP-DELETED', confirmation_number: 'CNF-DELETED', order_status: 'LOGIC_DELETED', }), ]), ) vi.mocked(service.convertReservationManualReviewTask).mockResolvedValue( createManualReviewConversionResult({ order_id: '20002', original_order_id: '20001', system_task_type: 'UPDATE_BOOKING', task_card_type: 'UPDATE_BOOKING', }), ) const wrapper = await mountPanel() await wrapper.find('select[name="manual_target_task_type"]').setValue('UPDATE_BOOKING') await findButton(wrapper, '转换任务').trigger('click') await flushPromises() expect(service.convertReservationManualReviewTask).not.toHaveBeenCalled() expect(wrapper.text()).toContain('请选择目标订单') await wrapper.find('input[name="manual_target_order_keyword"]').setValue('GRP') await findButton(wrapper, '搜索订单').trigger('click') await flushPromises() expect(service.fetchReservationOrders).toHaveBeenCalledWith( expect.objectContaining({ keyword: 'GRP', page_num: 1, page_size: 8, }), ) expect(wrapper.text()).toContain('GRP-ACTIVE') expect(wrapper.text()).toContain('GRP-DELETED') expect(wrapper.find('input[value="20003"]').attributes('disabled')).toBeDefined() await wrapper.find('input[value="20002"]').setValue() await findButton(wrapper, '转换任务').trigger('click') await flushPromises() expect(service.convertReservationManualReviewTask).toHaveBeenCalledWith('10001', { target_task_type: 'UPDATE_BOOKING', target_order_id: '20002', }) }) it('hides task processing buttons and audit timeline without permissions', async () => { vi.mocked(service.fetchReservationTaskAudits).mockResolvedValue({ task_id: '10001', items: [createAudit('audit-1', 'TASK_LOADED')], }) vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue( createTaskDetail({ availability: { blocked: false, read_only: false, editable: true, confirmable: true, executable: true, blocked_by_task_id: null, blocked_reason: null, }, opera_operations: [ { operation_id: 'op-1', task_id: '10001', operation_sequence: 1, operation_code: 'CREATE_RESERVATION', operation_name: 'Create reservation', operation_status: 'PENDING', attempt_count: 0, last_error_message: null, attempts: [], }, ], }), ) const wrapper = await mountPanel(['RESERVATION_TASK_READ']) expect(wrapper.text()).not.toContain('保存草稿') expect(wrapper.text()).not.toContain('确认任务') expect(wrapper.text()).not.toContain('审计时间线') expect(wrapper.find('button[title="执行"]').exists()).toBe(false) expect(wrapper.find('button[title="重试"]').exists()).toBe(false) }) })