import { flushPromises, mount } 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 ReservationOrderDetailView from '@/views/reservation/ReservationOrderDetailView.vue' import ReservationOrderListView from '@/views/reservation/ReservationOrderListView.vue' import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue' import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue' import { useAuthStore } from '@/stores/authStore' import type { ReservationOrderDetailResult, ReservationOrderListResult, ReservationV4OrderTaskListResult, ReservationV4WorkbenchListResult, SourceMessageConversationResult, } from '@/types/reservation' vi.mock('@/services/reservationService', async (importOriginal) => { const actual = await importOriginal() return { ...actual, fetchReservationOrderDetail: vi.fn(), fetchReservationOrders: vi.fn(), fetchReservationTaskList: vi.fn(), fetchReservationV4OrderTasks: vi.fn(), fetchReservationV4WorkbenchItems: vi.fn(), fetchSourceMessageConversation: vi.fn(), } }) const service = await import('@/services/reservationService') function createOrderListResult( displayOrderKey = 'GRP-001', page: { page_num: number; page_size: number; total: number } = { page_num: 1, page_size: 20, total: 1, }, orderOverrides: Partial = {}, ): ReservationOrderListResult { return { items: [ { order_id: displayOrderKey === 'GRP-002' ? '20002' : '20001', hotel_id: 'HOTEL-TEST', order_status: 'ACTIVE', display_order_key: displayOrderKey, temporary_order_no: null, confirmation_number: null, group_code: displayOrderKey, display_name: displayOrderKey, open_task_count: 2, open_work_item_count: 2, next_processable_task_id: '10002', created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:10:00Z', ...orderOverrides, }, ], page, } } function createV4WorkbenchResult( displayOrderKey = 'GRP-001', sourceSubject = 'Booking Request', page: { page_num: number; page_size: number; total: number } = { page_num: 1, page_size: 20, total: 1, }, ): ReservationV4WorkbenchListResult { return { items: [ { item_type: 'ORDER_TASK', target_id: displayOrderKey === 'GRP-NEW' ? '9002' : '9001', hotel_id: 'HOTEL-TEST', source_message_id: '30001', source_message_summary: { source_message_id: '30001', hotel_id: 'HOTEL-TEST', external_message_id: 'msg-30001', external_conversation_id: 'thread-30001', subject: sourceSubject, sender_summary: 'guest@example.test', received_at: '2026-07-08T03:00:00Z', source_sent_at: null, conversation_message_count: 2, }, source_received_at: '2026-07-08T03:00:00Z', display_order_key: displayOrderKey, card_counts: { total_count: 3, readonly_count: 1, pending_confirm_count: 1, review_required_count: 1, confirmed_count: 0, }, next_action_card_id: '9101', notification_status: null, order_task_status: 'OPEN', display_status: 'OPEN', readonly_reason_code: null, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:10:00Z', }, ], page, } } function createV4SourceNotificationWorkbenchResult(routeCode: 'S10' | 'S99' = 'S10'): ReservationV4WorkbenchListResult { return { items: [ { item_type: 'SOURCE_NOTIFICATION', target_id: routeCode === 'S10' ? '7001' : '7002', hotel_id: 'HOTEL-TEST', source_message_id: routeCode === 'S10' ? '39990' : '39999', source_message_summary: { source_message_id: routeCode === 'S10' ? '39990' : '39999', hotel_id: 'HOTEL-TEST', external_message_id: `msg-${routeCode}`, external_conversation_id: 'thread-source-only', subject: routeCode === 'S10' ? 'FYI only' : 'Unclear email', sender_summary: 'guest@example.test', received_at: '2026-07-08T03:00:00Z', source_sent_at: null, conversation_message_count: 1, }, source_received_at: '2026-07-08T03:00:00Z', display_order_key: null, card_counts: null, next_action_card_id: null, notification_status: 'ACK_REQUIRED', order_task_status: null, display_status: 'ACK_REQUIRED', readonly_reason_code: null, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:10:00Z', }, ], page: { page_num: 1, page_size: 20, total: 1, }, } } function createV4OrderTaskListResult( displayOrderKey = 'GRP-002', sourceSubject = 'Booking Update', page: { page_num: number; page_size: number; total: number } = { page_num: 1, page_size: 20, total: 1, }, ): ReservationV4OrderTaskListResult { return { items: [ { order_task: { order_task_id: displayOrderKey === 'GRP-NEW' ? '9002' : '9001', hotel_id: 'HOTEL-TEST', source_message_id: '30002', ai_batch_id: '8001', order_ref: 'order-1', order_context_index: 1, order_id: '20001', target_booking_type: 'GROUP', target_locator_type: 'GROUP_CODE', target_locator_value: displayOrderKey, target_resolution_status: 'RESOLVED', order_task_status: 'OPEN', display_order_key: displayOrderKey, source_received_at: '2026-07-08T04:00:00Z', version: 0, created_at: '2026-07-08T04:00:00Z', updated_at: '2026-07-08T04:10:00Z', }, source_message_summary: { source_message_id: '30002', hotel_id: 'HOTEL-TEST', external_message_id: 'msg-30002', external_conversation_id: 'thread-30002', subject: sourceSubject, sender_summary: 'agent@example.test', received_at: '2026-07-08T04:00:00Z', source_sent_at: null, conversation_message_count: 3, }, card_counts: { total_count: 2, readonly_count: 1, pending_confirm_count: 1, review_required_count: 0, confirmed_count: 0, }, next_action_card_id: '9102', display_status: 'OPEN', availability: { blocked: false, read_only: false, editable: true, confirmable: true, reviewable: false, ackable: false, readonly_reason_code: null, blocked_by_order_task_id: null, blocked_by_card_id: null, blocked_reason: null, }, }, ], page, } } function createOrderDetailResult(displayName = 'GRP-001'): ReservationOrderDetailResult { return { order: { order_id: displayName === 'GRP-002' ? '20002' : '20001', hotel_id: 'HOTEL-TEST', order_status: 'ACTIVE', temporary_order_no: null, confirmation_number: null, group_code: displayName, block_code: null, allotment_code: null, display_name: displayName, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:10:00Z', }, tasks: [ { task_id: '10001', task_type: 'NEW_BOOKING', task_subtype: 'NEW_BOOKING', task_status: 'PENDING_CONFIRM', card_name: 'New Booking', queue_sequence: 1, queue_participation: true, can_process: true, readonly_reason_code: null, 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: null, conversation_message_count: 2, created_at: '2026-07-08T03:00:00Z', }, { task_id: '10002', task_type: 'UPDATE_BOOKING', task_subtype: 'RATE_CHANGE', task_status: 'BLOCKED', card_name: 'Rate Change', queue_sequence: 2, queue_participation: true, can_process: false, readonly_reason_code: 'PREVIOUS_TASK_NOT_FINISHED', source_message_id: '30002', source_subject: 'Booking Update', source_sender_summary: 'agent@example.test', source_received_at: '2026-07-08T04:00:00Z', external_conversation_id: null, conversation_message_count: 3, created_at: '2026-07-08T04:00:00Z', }, ], v4_order_tasks: [ { order_task_id: '9001', order_ref: 'order-ref-1', order_task_status: 'OPEN', card_counts: { total_count: 3, readonly_count: 1, pending_confirm_count: 1, review_required_count: 1, confirmed_count: 0, }, cards: [ { card_id: '9100', card_type: 'SOURCE_MESSAGE_DISPLAY', event_type: null, source_event_index: 0, card_sort_order: 0, card_status: 'READONLY', review_status: null, confirmed_by: null, confirmed_at: null, latest_activity_at: '2026-07-08T05:05:00Z', ai_payload_json: 'unsafe ai payload must stay hidden', display_payload_json: 'unsafe display payload must stay hidden', confirmed_payload_json: 'unsafe confirmed payload must stay hidden', text_body: 'unsafe mail body must stay hidden', attachment_url: 'https://oss.example.test/private.xlsx', }, { card_id: '9101', card_type: 'BASIC_INFORMATION', event_type: 'NEW_BOOKING', source_event_index: 1, card_sort_order: 10, card_status: 'CONFIRMED', review_status: null, confirmed_by: 'frontend-query-admin', confirmed_at: '2026-07-08T11:10:00Z', latest_activity_at: '2026-07-08T11:10:00Z', }, { card_id: '9104', card_type: 'PAYMENT', event_type: 'PAYMENT', source_event_index: 3, card_sort_order: 60, card_status: 'REVIEW_REQUIRED', review_status: 'PENDING', confirmed_by: null, confirmed_at: null, latest_activity_at: '2026-07-08T11:20:00Z', }, ], source_message_summary: { source_message_id: '30003', hotel_id: 'HOTEL-TEST', external_message_id: 'm-v4-1', external_conversation_id: 'thread-v4-1', subject: 'V4 Booking Package', sender_summary: 'v4@example.test', received_at: '2026-07-08T05:00:00Z', source_sent_at: null, conversation_message_count: 1, }, source_received_at: '2026-07-08T05:00:00Z', created_at: '2026-07-08T05:00:00Z', updated_at: '2026-07-08T05:10:00Z', latest_activity_at: '2026-07-08T05:10:00Z', }, ], order_overview: { account_code: 'QBD_TRAVEL', account_name: 'Q.B.D. TRAVEL GROUP CO., LTD', market_code: 'LEISURE', source_code: 'TRAVEL_AGENT', arrival_date: '2026-07-26', departure_date: '2026-07-29', rate_code: 'BAR', room_items: [ { room_type_code: 'RM1', room_count: 2, }, { room_type_code: 'RM2', room_count: 1, }, ], trace_card_status: 'CONFIRMED', rooming_list_card_status: 'PENDING_CONFIRM', payment_card_status: 'REVIEW_REQUIRED', latest_confirmed_at: '2026-07-08T11:20:00Z', ai_payload_json: 'unsafe overview payload must stay hidden', }, next_v4_action: { order_task_id: '9001', card_id: '9104', action_type: 'REVIEW', action_status: 'REVIEW_REQUIRED', open_order_task_count: 1, }, related_source_messages: [ { source_message_id: '30003', hotel_id: 'HOTEL-TEST', external_message_id: 'm-v4-1', external_conversation_id: 'thread-v4-1', subject: 'V4 Booking Package', sender_summary: 'v4@example.test', received_at: '2026-07-08T05:00:00Z', source_sent_at: null, conversation_message_count: 1, html_body: 'unsafe related mail body', attachment_url: 'https://oss.example.test/related.xlsx', }, ], warnings: [], } as unknown as ReservationOrderDetailResult } function createSourceMessageConversationResult( overrides: Partial = {}, ): SourceMessageConversationResult { const result = createSourceMessageConversationResultBase() return { ...result, ...overrides, } } function createSourceMessageConversationResultBase(): SourceMessageConversationResult { return { conversation: { hotel_id: 'HOTEL-TEST', channel: 'EMAIL', external_conversation_id: 'thread-30001', subject: 'Booking Request', message_count: 2, first_received_at: '2026-07-08T02:00:00Z', last_received_at: '2026-07-08T03:00:00Z', }, messages: [ { id: '30001', external_message_id: 'msg-30001', external_conversation_id: 'thread-30001', sender_summary: 'guest@example.test', subject: 'Booking Request', received_at: '2026-07-08T02:00:00Z', source_sent_at: '2026-07-08T01:58:00Z', text_body: '完整邮件正文', html_body: '原始邮件正文', html_body_sanitized: '清洗后邮件正文', html_sanitize_required: true, html_render_mode: 'SANITIZED_HTML', inline_images: [], attachments: [ { mediaType: 'ATTACHMENT', fileName: 'rooming-list.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', sizeBytes: 1024, externalUrl: 'https://example.test/rooming-list.xlsx', externalMediaId: 'media-1', }, ], related_orders: [ { order_id: '20001', display_order_key: 'GRP-001', order_status: 'ACTIVE', }, ], related_tasks: [ { task_id: '10001', order_id: '20001', task_type: 'NEW_BOOKING', task_subtype: 'NEW_BOOKING', task_status: 'PENDING_CONFIRM', card_name: 'New Booking', }, ], }, ], } } function createAttachment(overrides: Record = {}) { return { mediaType: 'ATTACHMENT', fileName: 'rooming-list.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', sizeBytes: 1024, externalUrl: 'https://example.test/rooming-list.xlsx', externalMediaId: 'media-1', ...overrides, } } function createDeferred() { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve reject = promiseReject }) return { promise, resolve, reject } } function createAuthPayload() { 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, }, { hotel_id: 'HOTEL-BKK', hotel_name: '曼谷酒店', time_zone: 'Asia/Bangkok', default_hotel: false, }, ], permissions: ['HOTEL_SWITCH'], menus: [], } } async function mountWithPlugins(component: object, initialPath = '/', stubs: Record = {}) { const pinia = createPinia() setActivePinia(pinia) const authStore = useAuthStore() authStore.applyLoginResult(createAuthPayload()) const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zhCN, }, }) const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: { template: '
' } }, { path: '/reservation/orders/:orderId', component: { template: '
' } }, { path: '/reservation/tasks/:taskId', component: { template: '
' } }, { path: '/reservation/order-tasks/:orderTaskId', component: { template: '
' } }, { path: '/reservation/source-notifications/:notificationId', component: { template: '
' } }, { path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '
' } }, ], }) await router.push(initialPath) await router.isReady() return mount(component, { global: { plugins: [pinia, i18n, router], stubs: { RouterLink: { props: ['to'], template: '', }, ...stubs, }, }, }) } describe('reservation P0 views', () => { beforeEach(() => { sessionStorage.clear() localStorage.clear() vi.mocked(service.fetchReservationOrderDetail).mockReset() vi.mocked(service.fetchReservationOrders).mockReset() vi.mocked(service.fetchReservationTaskList).mockReset() vi.mocked(service.fetchReservationV4OrderTasks).mockReset() vi.mocked(service.fetchReservationV4WorkbenchItems).mockReset() vi.mocked(service.fetchSourceMessageConversation).mockReset() }) it('renders the user-facing order overview, next action, related source emails and processing records', async () => { vi.mocked(service.fetchReservationOrderDetail).mockResolvedValue(createOrderDetailResult()) const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001') await vi.dynamicImportSettled() expect(wrapper.text()).toContain('订单信息总览') expect(wrapper.text()).toContain('QBD_TRAVEL') expect(wrapper.text()).toContain('Q.B.D. TRAVEL GROUP CO., LTD') expect(wrapper.text()).toContain('2026-07-26') expect(wrapper.text()).toContain('2026-07-29') expect(wrapper.text()).toContain('RM1 × 2') expect(wrapper.text()).toContain('下一步处理') expect(wrapper.find('a[href="/reservation/order-tasks/9001"]').text()).toContain('进入办理页') expect(wrapper.find('.primary-link[href="/reservation/order-tasks/9001"]').exists()).toBe(true) expect(wrapper.text()).toContain('关联来源邮件') expect(wrapper.text()).toContain('v4@example.test') expect(wrapper.text()).toContain('查看邮件会话') expect(wrapper.find('a[href="/reservation/source-messages/30003/conversation"]').exists()).toBe(true) expect(wrapper.text()).toContain('处理记录') expect(wrapper.text()).not.toContain('order-ref-1') expect(wrapper.text()).toContain('V4 Booking Package') expect(wrapper.find('.v4-timeline-item__main[href="/reservation/order-tasks/9001"]').exists()).toBe(true) expect(wrapper.text()).toContain('事项状态摘要') expect(wrapper.text()).toContain('预订基础信息') expect(wrapper.text()).toContain('frontend-query-admin') expect(wrapper.text()).toContain('待复核') expect(wrapper.text()).not.toContain('V4 当前确认快照') expect(wrapper.text()).not.toContain('任务卡编号') expect(wrapper.text()).not.toContain('card-basic') expect(wrapper.text()).not.toContain('unsafe ai payload must stay hidden') expect(wrapper.text()).not.toContain('unsafe display payload must stay hidden') expect(wrapper.text()).not.toContain('unsafe confirmed payload must stay hidden') expect(wrapper.text()).not.toContain('unsafe mail body must stay hidden') expect(wrapper.text()).not.toContain('https://oss.example.test') expect(wrapper.findComponent({ name: 'ReservationTaskDetailPanel' }).exists()).toBe(false) expect(wrapper.text()).not.toContain('来源邮件摘要字段待后端补充') }) it('shows empty states when an order has no V4 overview, next action, source messages or task timeline', async () => { const result = createOrderDetailResult() result.order_overview = null result.next_v4_action = { order_task_id: null, card_id: null, action_type: 'NONE', action_status: null, open_order_task_count: 0, } result.related_source_messages = [] result.v4_order_tasks = [] vi.mocked(service.fetchReservationOrderDetail).mockResolvedValue(result) const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001') await vi.dynamicImportSettled() expect(wrapper.text()).toContain('暂无已确认的订单信息') expect(wrapper.text()).toContain('暂无下一步处理事项') expect(wrapper.text()).toContain('暂无关联来源邮件') expect(wrapper.text()).toContain(zhCN.order.v4TaskTimelineEmpty) expect(wrapper.find('a[href^="/reservation/order-tasks/"]').exists()).toBe(false) }) it('renders order list items returned by the backend', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult()) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() const rowText = wrapper.find('tbody').text() expect(wrapper.text()).toContain('GRP-001') expect(rowText).not.toContain('20001') expect(rowText).not.toContain('有效') expect(wrapper.text()).not.toContain('未关闭任务') expect(wrapper.text()).toContain('继续处理') expect(wrapper.text()).not.toContain('接口待接入') }) it('shows the backend unified open work item count in the order list', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', { page_num: 1, page_size: 20, total: 1, }, { open_task_count: 99, v4_open_order_task_count: 88, open_work_item_count: 3, })) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() const headers = wrapper.findAll('thead th').map((header) => header.text()) const cells = wrapper.findAll('tbody td').map((cell) => cell.text()) expect(headers).toContain('待处理') expect(cells[1]).toBe('3') expect(cells[1]).not.toContain('99') expect(cells[1]).not.toContain('88') }) it('routes continue action to the V4 order task when the backend returns next_v4_order_task_id', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', { page_num: 1, page_size: 20, total: 1, }, { next_v4_order_task_id: '9001', next_v4_action_card_id: '9101', next_v4_action_type: 'CONFIRM', next_v4_action_status: 'PENDING_CONFIRM', v4_open_order_task_count: 1, })) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() expect(wrapper.find('a[href="/reservation/orders/20001"]').text()).toContain('查看详情') expect(wrapper.find('a[href="/reservation/order-tasks/9001"]').text()).toContain('继续处理') expect(wrapper.find('a[href="/reservation/tasks/10002"]').exists()).toBe(false) expect(wrapper.text()).toContain('待确认') }) it('shows review state for V4 review entrypoints in the order list', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', { page_num: 1, page_size: 20, total: 1, }, { next_v4_order_task_id: '9002', next_v4_action_card_id: '9102', next_v4_action_type: 'REVIEW', next_v4_action_status: 'REVIEW_REQUIRED', v4_open_order_task_count: 1, })) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() expect(wrapper.find('a[href="/reservation/order-tasks/9002"]').text()).toContain('继续处理') expect(wrapper.text()).toContain('待复核') }) it('falls back to the legacy task detail route when there is no V4 order task entrypoint', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult()) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() expect(wrapper.find('a[href="/reservation/orders/20001"]').text()).toContain('查看详情') expect(wrapper.find('a[href="/reservation/tasks/10002"]').text()).toContain('继续处理') expect(wrapper.find('a[href="/reservation/order-tasks/9001"]').exists()).toBe(false) }) it('shows no open task state when neither V4 nor legacy entrypoint exists', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult('GRP-001', { page_num: 1, page_size: 20, total: 1, }, { open_task_count: 0, open_work_item_count: 0, next_processable_task_id: null, next_v4_order_task_id: null, next_v4_action_card_id: null, next_v4_action_type: 'NONE', next_v4_action_status: null, v4_open_order_task_count: 0, })) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() expect(wrapper.find('a[href="/reservation/orders/20001"]').text()).toContain('查看详情') expect(wrapper.find('a[href^="/reservation/tasks/"]').exists()).toBe(false) expect(wrapper.find('a[href^="/reservation/order-tasks/"]').exists()).toBe(false) expect(wrapper.text()).toContain('无待处理任务') }) it('filters order list with order status and business-key fields', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderListResult()) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() await wrapper.find('select[name="order_status"]').setValue('LOGIC_DELETED') await vi.dynamicImportSettled() await wrapper.find('input[name="group_code"]').setValue('GRP-009') await vi.dynamicImportSettled() await wrapper.find('input[name="confirmation_number"]').setValue('CNF-009') await vi.dynamicImportSettled() expect(service.fetchReservationOrders).toHaveBeenLastCalledWith( expect.objectContaining({ order_status: 'LOGIC_DELETED', group_code: 'GRP-009', confirmation_number: 'CNF-009', page_num: 1, }), ) }) it('paginates order list requests with backend page metadata', async () => { vi.mocked(service.fetchReservationOrders) .mockResolvedValueOnce(createOrderListResult('GRP-001', { page_num: 1, page_size: 20, total: 41 })) .mockResolvedValueOnce(createOrderListResult('GRP-002', { page_num: 2, page_size: 20, total: 41 })) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() expect(wrapper.text()).toContain('第 1 / 3 页') await wrapper.findAll('button').find((button) => button.text().includes('下一页'))?.trigger('click') await vi.dynamicImportSettled() expect(service.fetchReservationOrders).toHaveBeenLastCalledWith( expect.objectContaining({ page_num: 2, page_size: 20, }), ) expect(wrapper.text()).toContain('GRP-002') expect(wrapper.text()).toContain('第 2 / 3 页') }) it('renders backend-normalized order pagination metadata', async () => { vi.mocked(service.fetchReservationOrders).mockResolvedValue( createOrderListResult('GRP-001', { page_num: 3, page_size: 10, total: 31 }), ) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() expect(wrapper.text()).toContain('第 3 / 4 页') }) it('reloads the order list when the selected hotel changes', async () => { vi.mocked(service.fetchReservationOrders) .mockResolvedValueOnce(createOrderListResult('GRP-001')) .mockResolvedValueOnce(createOrderListResult('GRP-002')) const wrapper = await mountWithPlugins(ReservationOrderListView) await vi.dynamicImportSettled() const authStore = useAuthStore() authStore.setSelectedHotelId('HOTEL-BKK') await vi.dynamicImportSettled() expect(service.fetchReservationOrders).toHaveBeenCalledTimes(2) expect(service.fetchReservationOrders).toHaveBeenLastCalledWith( expect.objectContaining({ page_num: 1, }), ) expect(wrapper.text()).toContain('GRP-002') }) it('renders V4 workbench items returned by the backend', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems).mockResolvedValue(createV4WorkbenchResult('GRP-001', 'Booking Request')) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() expect(wrapper.text()).toContain('GRP-001') expect(wrapper.text()).toContain('Booking Request') expect(wrapper.text()).toContain('预订事项') expect(wrapper.text()).toContain('待确认 1') expect(wrapper.text()).toContain('待复核 1') expect(wrapper.text()).toContain('查看邮件会话') expect(wrapper.text()).not.toContain('#9001') expect(wrapper.text()).not.toContain('接口待补') expect(service.fetchReservationV4WorkbenchItems).toHaveBeenCalledWith( expect.objectContaining({ page_num: 1, page_size: 20, }), ) expect(service.fetchReservationTaskList).not.toHaveBeenCalled() }) it('renders task source received time in the source email column', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems).mockResolvedValue(createV4WorkbenchResult('GRP-001', 'Booking Request')) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() expect(wrapper.text()).toContain('来源时间') expect(wrapper.text()).toContain('2026-07-08 10:00:00') expect(wrapper.text()).toContain('guest@example.test') }) it('renders V4 S10/S99 source notification rows without a visible order action', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems).mockResolvedValue(createV4SourceNotificationWorkbenchResult('S10')) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() expect(wrapper.text()).toContain('FYI only') expect(wrapper.text()).toContain('来源通知') expect(wrapper.text()).toContain('待确认已读') expect(wrapper.text()).toContain('查看通知') expect(wrapper.text()).toContain('查看邮件会话') expect(wrapper.text()).toContain('该邮件不关联订单,请查看邮件内容。') expect(wrapper.text()).not.toContain('S10/S99') expect(wrapper.text()).not.toContain('查看订单') }) it('filters the reservation workbench with user-facing filter choices only', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems) .mockResolvedValueOnce(createV4WorkbenchResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 })) .mockResolvedValueOnce(createV4SourceNotificationWorkbenchResult('S10')) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() const itemTypeOptions = wrapper.find('select[name="business_status"]').findAll('option') .map((option) => option.attributes('value')) expect(itemTypeOptions).toEqual(['', 'PENDING_CONFIRM', 'REVIEW_REQUIRED', 'COMPLETED', 'SOURCE_NOTIFICATION']) expect(wrapper.find('select[name="business_status"]').text()).toContain('来源通知') expect(wrapper.find('select[name="business_status"]').text()).not.toContain('ORDER_TASK') expect(wrapper.find('select[name="business_status"]').text()).not.toContain('SOURCE_NOTIFICATION') expect(wrapper.find('select[name="item_type"]').exists()).toBe(false) expect(wrapper.find('select[name="order_task_status"]').exists()).toBe(false) expect(wrapper.find('select[name="card_status"]').exists()).toBe(false) await wrapper.find('select[name="business_status"]').setValue('SOURCE_NOTIFICATION') await vi.dynamicImportSettled() expect(service.fetchReservationV4WorkbenchItems).toHaveBeenLastCalledWith( expect.objectContaining({ item_type: 'SOURCE_NOTIFICATION', page_num: 1, }), ) const lastTaskRequest = vi.mocked(service.fetchReservationV4WorkbenchItems).mock.calls[ vi.mocked(service.fetchReservationV4WorkbenchItems).mock.calls.length - 1 ]?.[0] ?? {} expect(Object.keys(lastTaskRequest).sort()).toEqual(['item_type', 'keyword', 'page_num', 'page_size']) expect(lastTaskRequest).not.toHaveProperty('task_type') expect(lastTaskRequest).not.toHaveProperty('task_subtype') expect(lastTaskRequest).not.toHaveProperty('result_type') expect(lastTaskRequest).not.toHaveProperty('route_code') }) it('shows a filter-specific empty state for completed reservation items', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems) .mockResolvedValueOnce(createV4WorkbenchResult('GRP-001', 'Booking Request')) vi.mocked(service.fetchReservationV4OrderTasks) .mockResolvedValueOnce({ items: [], page: { page_num: 1, page_size: 20, total: 0, }, }) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() await wrapper.find('select[name="business_status"]').setValue('COMPLETED') await vi.dynamicImportSettled() expect(wrapper.text()).toContain('暂无已完成的预订事项') expect(wrapper.text()).not.toContain('暂无需要处理的预订事项') }) it('filters and paginates reservation work items with supported backend fields', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems) .mockResolvedValueOnce(createV4WorkbenchResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 })) vi.mocked(service.fetchReservationV4OrderTasks) .mockResolvedValueOnce(createV4OrderTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 })) .mockResolvedValueOnce(createV4OrderTaskListResult('GRP-002', 'Booking Update', { page_num: 2, page_size: 20, total: 45 })) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() await wrapper.find('select[name="business_status"]').setValue('REVIEW_REQUIRED') await vi.dynamicImportSettled() const taskListCalls = vi.mocked(service.fetchReservationV4OrderTasks).mock.calls const filteredTaskRequest = taskListCalls[taskListCalls.length - 1]?.[0] ?? {} expect(filteredTaskRequest).toMatchObject({ card_status: 'REVIEW_REQUIRED', page_num: 1, }) await wrapper.findAll('button').find((button) => button.text().includes('下一页'))?.trigger('click') await vi.dynamicImportSettled() expect(service.fetchReservationV4OrderTasks).toHaveBeenLastCalledWith( expect.objectContaining({ card_status: 'REVIEW_REQUIRED', page_num: 2, page_size: 20, }), ) expect(wrapper.text()).toContain('GRP-002') expect(wrapper.text()).toContain('第 2 / 3 页') }) it('renders backend-normalized task pagination metadata', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems).mockResolvedValue( createV4WorkbenchResult('GRP-001', 'Booking Request', { page_num: 2, page_size: 10, total: 21 }), ) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() expect(wrapper.text()).toContain('第 2 / 3 页') }) it('reloads the task list when the selected hotel changes', async () => { vi.mocked(service.fetchReservationV4WorkbenchItems) .mockResolvedValueOnce(createV4WorkbenchResult('GRP-001', 'Booking Request')) .mockResolvedValueOnce(createV4WorkbenchResult('GRP-002', 'Booking Update')) const wrapper = await mountWithPlugins(ReservationTaskListView) await vi.dynamicImportSettled() const authStore = useAuthStore() authStore.setSelectedHotelId('HOTEL-BKK') await vi.dynamicImportSettled() expect(service.fetchReservationV4WorkbenchItems).toHaveBeenCalledTimes(2) expect(service.fetchReservationV4WorkbenchItems).toHaveBeenLastCalledWith( expect.objectContaining({ page_num: 1, }), ) expect(wrapper.text()).toContain('Booking Update') }) it('reloads order detail when the selected hotel changes', async () => { vi.mocked(service.fetchReservationOrderDetail) .mockResolvedValueOnce(createOrderDetailResult('GRP-001')) .mockResolvedValueOnce(createOrderDetailResult('GRP-002')) const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001', { ReservationTaskDetailPanel: { template: '
', }, }) await vi.dynamicImportSettled() const authStore = useAuthStore() authStore.setSelectedHotelId('HOTEL-BKK') await vi.dynamicImportSettled() expect(service.fetchReservationOrderDetail).toHaveBeenCalledTimes(2) expect(service.fetchReservationOrderDetail).toHaveBeenLastCalledWith('20001') expect(wrapper.text()).toContain('GRP-002') }) it('keeps the latest task list response when filters change quickly', async () => { const firstRequest = createDeferred>>() const secondRequest = createDeferred>>() vi.mocked(service.fetchReservationV4WorkbenchItems) .mockReturnValueOnce(firstRequest.promise) .mockReturnValueOnce(secondRequest.promise) const wrapper = await mountWithPlugins(ReservationTaskListView) await wrapper.find('input[type="search"]').setValue('fresh') expect(service.fetchReservationV4WorkbenchItems).toHaveBeenCalledTimes(2) secondRequest.resolve(createV4WorkbenchResult('GRP-NEW', 'New Booking Request')) await flushPromises() expect(wrapper.text()).toContain('GRP-NEW') firstRequest.resolve(createV4WorkbenchResult('GRP-OLD', 'Old Booking Request')) await flushPromises() expect(wrapper.text()).toContain('GRP-NEW') expect(wrapper.text()).not.toContain('GRP-OLD') }) it('renders full source message conversation returned by the backend', async () => { vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult()) const wrapper = await mountWithPlugins( ReservationSourceMessageConversationView, '/reservation/source-messages/30001/conversation', ) await vi.dynamicImportSettled() expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001') expect(wrapper.text()).toContain('Booking Request') expect(wrapper.text()).toContain('清洗后邮件正文') expect(wrapper.text()).toContain('rooming-list.xlsx') expect(wrapper.find('.message-body').html()).toContain('清洗后邮件正文') expect(wrapper.find('.message-body').html()).not.toContain('