import { flushPromises, mount } from '@vue/test-utils' import type { DOMWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import PrimeVue from 'primevue/config' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createI18n } from 'vue-i18n' import { createMemoryHistory, createRouter } from 'vue-router' import taskCardSectionSource from '@/components/reservation/ReservationV4TaskCardSection.vue?raw' import zhCN from '@/i18n/locales/zh-CN' import { useAuthStore } from '@/stores/authStore' import type { ReservationV4CatalogLookupResult, ReservationV4OrderTaskDetailResult, ReservationV4SourceNotificationDetailResult, ReservationV4TaskCardResult, SourceMessageConversationResult, SourceMessageOriginalMedia, } from '@/types/reservation' import ReservationV4OrderTaskDetailView from '@/views/reservation/ReservationV4OrderTaskDetailView.vue' import ReservationV4SourceNotificationDetailView from '@/views/reservation/ReservationV4SourceNotificationDetailView.vue' import { ApiError } from '@/services/httpClient' vi.mock('@/services/reservationService', async (importOriginal) => { const actual = await importOriginal() return { ...actual, ackReservationV4SourceNotification: vi.fn(), confirmReservationV4OrderTaskCard: vi.fn(), fetchReservationV4AccountLookups: vi.fn(), fetchReservationV4OrderTaskDetail: vi.fn(), fetchReservationV4RateCodeLookups: vi.fn(), fetchReservationV4RoomTypeLookups: vi.fn(), fetchReservationV4SourceNotificationDetail: vi.fn(), fetchSourceMessageConversation: vi.fn(), resolveReservationV4OrderTaskCardReview: vi.fn(), } }) const service = await import('@/services/reservationService') function findFieldByLabel(wrapper: DOMWrapper, label: string): DOMWrapper | undefined { return wrapper.findAll('.v4-field').find((field) => field.find('.v4-field__label').text().includes(label)) } describe('reservation V4 pages', () => { beforeEach(() => { sessionStorage.clear() Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: vi.fn().mockImplementation((query: string) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }) vi.mocked(service.ackReservationV4SourceNotification).mockReset() vi.mocked(service.confirmReservationV4OrderTaskCard).mockReset() vi.mocked(service.fetchReservationV4AccountLookups).mockReset() vi.mocked(service.fetchReservationV4OrderTaskDetail).mockReset() vi.mocked(service.fetchReservationV4RateCodeLookups).mockReset() vi.mocked(service.fetchReservationV4RoomTypeLookups).mockReset() vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockReset() vi.mocked(service.fetchSourceMessageConversation).mockReset() vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockReset() vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult()) mockCatalogLookups() }) it('renders reservation handling cards, hides default diagnostics and confirms only selected codes', async () => { const detail = createOrderTaskDetail() vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({ ...detail, basic_information_card: { ...detail.basic_information_card!, card_status: 'CONFIRMED', }, }) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).toContain('订单事项办理') expect(wrapper.text()).toContain('Booking Request') expect(wrapper.text()).toContain('预订基础信息') expect(wrapper.text()).toContain('预订事项') expect(wrapper.text()).toContain('房型与日期') expect(wrapper.text()).not.toContain('V4 订单任务详情') expect(wrapper.text()).not.toContain('Backend contract issue') expect(wrapper.text()).not.toContain('card-basic') expect(wrapper.text()).not.toContain('card-room') expect(wrapper.text()).not.toContain('#9001') expect(wrapper.text()).not.toContain(zhCN.taskV4.orderTaskId) expect(wrapper.text()).not.toContain(zhCN.taskV4.displayPayload) expect(service.fetchReservationV4AccountLookups).toHaveBeenCalledWith({ hotel_id: 'HOTEL-TEST', page_num: 1, page_size: 100, }) expect(service.fetchReservationV4RoomTypeLookups).toHaveBeenCalledWith({ hotel_id: 'HOTEL-TEST', page_num: 1, page_size: 100, }) await wrapper.find('select').setValue('ACC-LIVE') await flushPromises() const basicCard = wrapper.findAll('.task-card-section')[0]! expect(basicCard.find('.v4-field__label em').exists()).toBe(false) expect(basicCard.text()).not.toContain('Market 由 Account Code 派生') expect(basicCard.text()).not.toContain('Source 由 Account Code 派生') expect(basicCard.text()).not.toContain('选择后会带出市场') const marketField = findFieldByLabel(basicCard, '市场代码') const sourceField = findFieldByLabel(basicCard, '来源代码') expect(marketField?.find('input[name="/basic_information/market_code"]').exists()).toBe(true) expect(sourceField?.find('input[name="/basic_information/source_code"]').exists()).toBe(true) expect((marketField?.find('input[name="/basic_information/market_code"]').element as HTMLInputElement).value) .toBe('LEISURE') expect((sourceField?.find('input[name="/basic_information/source_code"]').element as HTMLInputElement).value) .toBe('TRAVEL_AGENT') await marketField?.find('input[name="/basic_information/market_code"]').setValue('MICE') await sourceField?.find('input[name="/basic_information/source_code"]').setValue('DIRECT') await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-basic', { version: 7, confirmed_payload: { basic_information: { account_code: 'ACC-LIVE', market_code: 'MICE', source_code: 'DIRECT', }, }, }) }) it('keeps task card layout classes and places the primary card action in the card footer', async () => { const detail = createOrderTaskDetail() detail.business_cards[0]!.card_type = 'VOUCHER' detail.business_cards[0]!.display_payload = { booking_scenario: 'STANDARD', relevant_message_excerpt: 'Please keep this visible.', } vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.findAll('.task-card-section')).toHaveLength(2) expect(wrapper.find('.task-card-section__header').exists()).toBe(true) expect(wrapper.find('.card-meta-grid').exists()).toBe(false) expect(wrapper.find('details.safe-payload').exists()).toBe(false) expect(wrapper.find('.task-card-section__header .primary-button').exists()).toBe(false) const firstCard = wrapper.find('.task-card-section') const footer = firstCard.find('.task-card-section__footer') expect(footer.exists()).toBe(true) const cardChildren = Array.from(firstCard.element.children) expect(cardChildren[cardChildren.length - 1]).toBe(footer.element) expect(footer.find('.task-card-section__footer-actions .primary-button').text()).toContain(zhCN.taskV4.confirmCard) expect(wrapper.find('.card-actions .primary-button').exists()).toBe(false) }) it('does not render direct attachment URLs from V4 source message payload', async () => { const detail = createOrderTaskDetail({ sourceDisplayPayload: { subject: 'Booking Request', sender_summary: 'guest@example.test', relevant_message_excerpt: 'Please book one twin room.', uploaded_media: [ 'https://oss.example/private/booking.pdf', 'oss://bucket/private-voucher.jpg', { file_name: 'safe-booking.pdf', content_type: 'application/pdf', }, ], }, }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).toContain('safe-booking.pdf') expect(wrapper.text()).toContain(zhCN.conversation.unnamedAttachment) expect(wrapper.text()).not.toContain('https://oss.example') expect(wrapper.text()).not.toContain('oss://bucket') }) it('renders V4 source message display at the bottom with only the current email body collapsed', async () => { const detail = createOrderTaskDetail() detail.source_message_card!.fields = [ createField('/source_message/ai_payload_json', { display_name: 'AI payload', value: 'AI_PAYLOAD_SHOULD_NOT_RENDER', }), createField('/source_message/raw_evidence', { display_name: 'Raw evidence', value: 'RAW_EVIDENCE_SHOULD_NOT_RENDER', }), createField('/source_message/attachment_url', { display_name: 'Attachment URL', value: 'https://oss.example/private/source-field.pdf', }), ] const currentBody = `Current trigger email body opening. ${'Current booking detail. '.repeat(24)}CURRENT_BODY_TAIL` vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({ sourceMessageId: '30001', currentBody, })) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const pageText = wrapper.text() const basicIndex = pageText.indexOf(zhCN.taskV4.basicInformationCard) const businessIndex = pageText.indexOf(zhCN.taskV4.businessCards) expect(basicIndex).toBeGreaterThanOrEqual(0) expect(businessIndex).toBeGreaterThan(basicIndex) const sections = wrapper.findAll('.th-section') expect(sections[sections.length - 1]?.text()).toContain(zhCN.taskV4.sourceMessageCard) expect(sections[sections.length - 1]?.text()).toContain(zhCN.taskV4.sourceMessage.bodyCollapsed) expect(service.fetchSourceMessageConversation).not.toHaveBeenCalled() expect(pageText).not.toContain('Current trigger email body opening.') expect(pageText).not.toContain('OLDER_MESSAGE_BODY') expect(pageText).not.toContain('REPLY_MESSAGE_BODY') expect(pageText).not.toContain('CURRENT_BODY_TAIL') expect(pageText).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER') expect(pageText).not.toContain('RAW_EVIDENCE_SHOULD_NOT_RENDER') expect(pageText).not.toContain('https://oss.example/private/source-field.pdf') const expandButton = wrapper .findAll('button') .find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody)) expect(expandButton).toBeTruthy() await expandButton!.trigger('click') await flushPromises() expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001') expect(wrapper.text()).toContain('Current trigger email body opening.') expect(wrapper.text()).toContain('CURRENT_BODY_TAIL') expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.collapseBody) }) it('falls back to source summary when current source message body cannot be loaded', async () => { const detail = createOrderTaskDetail() vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.fetchSourceMessageConversation).mockRejectedValue(new ApiError('Forbidden', 403, { code: 'SOURCE_MESSAGE_ORIGINAL_READ_FORBIDDEN', message: 'Forbidden', })) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).toContain('Please book one twin room.') expect(wrapper.text()).not.toContain(zhCN.taskV4.sourceMessage.bodyLoadFailed) expect(service.fetchSourceMessageConversation).not.toHaveBeenCalled() const expandButton = wrapper .findAll('button') .find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody)) expect(expandButton).toBeTruthy() await expandButton!.trigger('click') await flushPromises() expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001') expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessage.bodyLoadFailed) expect(wrapper.text()).toContain(zhCN.task.viewConversation) }) it('renders only sanitized source message html and never raw html body', async () => { const detail = createOrderTaskDetail() const sanitizedHtml = `

Sanitized current email opening. ${'Sanitized detail. '.repeat(24)}SANITIZED_HTML_TAIL

` vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({ sourceMessageId: '30001', currentBody: '', sanitizedHtml, rawHtml: '', htmlRenderMode: 'SANITIZED_HTML', })) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).not.toContain('Sanitized current email opening.') expect(wrapper.text()).not.toContain('SANITIZED_HTML_TAIL') expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER') const expandButton = wrapper .findAll('button') .find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody)) expect(expandButton).toBeTruthy() await expandButton!.trigger('click') await flushPromises() expect(wrapper.text()).toContain('Sanitized current email opening.') expect(wrapper.text()).toContain('SANITIZED_HTML_TAIL') expect(wrapper.html()).toContain('

Sanitized current email opening.') expect(wrapper.html()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER') }) it('does not render sanitized html when backend marks the email body as text only', async () => { const detail = createOrderTaskDetail() vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({ sourceMessageId: '30001', currentBody: 'Plain text fallback body.', sanitizedHtml: '

SANITIZED_HTML_SHOULD_NOT_RENDER

', rawHtml: '

RAW_HTML_SHOULD_NOT_RENDER

', htmlRenderMode: 'TEXT_ONLY', })) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).not.toContain('Plain text fallback body.') const expandButton = wrapper .findAll('button') .find((button) => button.text().includes(zhCN.taskV4.sourceMessage.showFullBody)) expect(expandButton).toBeTruthy() await expandButton!.trigger('click') await flushPromises() expect(wrapper.text()).toContain('Plain text fallback body.') expect(wrapper.text()).not.toContain('SANITIZED_HTML_SHOULD_NOT_RENDER') expect(wrapper.text()).not.toContain('RAW_HTML_SHOULD_NOT_RENDER') }) it('renders Payment attachments with image preview and non-image download from current source message media', async () => { const detail = createOrderTaskDetail() usePaymentBusinessCard(detail) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({ attachments: createPaymentConversationMedia(), })) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(service.fetchSourceMessageConversation).toHaveBeenCalledWith('30001') const attachmentPreview = wrapper.find('[data-testid="payment-attachment-preview"]') expect(attachmentPreview.exists()).toBe(true) expect(attachmentPreview.text()).toContain('voucher-image.jpg') expect(attachmentPreview.text()).toContain('voucher-document.pdf') expect(attachmentPreview.text()).toContain(zhCN.taskV4.paymentAttachments.noMatchedMedia) expect(attachmentPreview.text()).not.toContain('https://oss.example') expect(attachmentPreview.findAll('[data-testid="payment-attachment-thumbnail"]')).toHaveLength(1) const thumbnail = attachmentPreview.find('[data-testid="payment-attachment-thumbnail"]') expect(thumbnail.exists()).toBe(true) expect(thumbnail.find('img').attributes('src')).toBe('https://oss.example/private/payment-image.jpg') await thumbnail.trigger('click') await flushPromises() const modal = wrapper.find('[data-testid="payment-attachment-modal"]') expect(modal.exists()).toBe(true) expect(modal.find('img').attributes('src')).toBe('https://oss.example/private/payment-image.jpg') expect(wrapper.text()).not.toContain('https://oss.example/private/payment-image.jpg') const downloadLink = attachmentPreview.find('[data-testid="payment-attachment-download"]') expect(downloadLink.exists()).toBe(true) expect(downloadLink.attributes('href')).toBe('https://oss.example/private/payment-document.pdf') }) it('degrades Payment attachment preview when conversation media cannot be loaded', async () => { const detail = createOrderTaskDetail() usePaymentBusinessCard(detail) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.fetchSourceMessageConversation).mockRejectedValue(new ApiError('Forbidden', 403, { code: 'SOURCE_MESSAGE_ORIGINAL_READ_FORBIDDEN', message: 'Forbidden', })) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).toContain('voucher-image.jpg') expect(wrapper.text()).toContain(zhCN.taskV4.paymentAttachments.loadFailed) expect(wrapper.find('[data-testid="payment-attachment-thumbnail"]').exists()).toBe(false) expect(wrapper.find('[data-testid="payment-attachment-download"]').exists()).toBe(false) }) it('confirms Payment cards with version only and never submits attachment ids or attachment URLs', async () => { const detail = createOrderTaskDetail() usePaymentBusinessCard(detail) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({ attachments: createPaymentConversationMedia(), })) vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({ ...detail, business_cards: [ { ...detail.business_cards[0]!, card_status: 'CONFIRMED', }, ], }) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const paymentCard = wrapper .findAll('.task-card-section') .find((section) => section.text().includes(zhCN.taskV4.paymentCard)) expect(paymentCard).toBeTruthy() await paymentCard!.find('button.primary-button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-payment', { version: 9, }) const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2] expect(JSON.stringify(submittedRequest)).not.toContain('attachment_ids') expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') }) it('renders Rooming List cards as lightweight action items without rows, attachments or raw payloads', async () => { const detail = createOrderTaskDetail() useRoomingListBusinessCard(detail) useBoundOrder(detail) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomingListSection = wrapper .findAll('.task-card-section') .find((section) => section.find('[data-testid="rooming-list-card"]').exists()) expect(roomingListSection).toBeTruthy() const roomingListCard = roomingListSection!.find('[data-testid="rooming-list-card"]') expect(roomingListCard.exists()).toBe(true) expect(roomingListSection!.text()).toContain(zhCN.taskV4.roomingList.title) expect(roomingListCard.text()).toContain(zhCN.taskV4.roomingList.description) expect(roomingListCard.text()).toContain('GRP-001') expect(roomingListSection!.findAll('a').filter((link) => link.text() === zhCN.task.viewOrder)).toHaveLength(1) expect(wrapper.text()).toContain(zhCN.taskV4.sourceMessageCard) const sections = wrapper.findAll('.th-section') expect(sections[sections.length - 1]?.text()).toContain(zhCN.taskV4.sourceMessageCard) expect(roomingListSection!.find('details.safe-payload').exists()).toBe(false) expect(roomingListSection!.text()).not.toContain('ROW_SHOULD_NOT_RENDER') expect(roomingListSection!.text()).not.toContain('attachment_ids') expect(roomingListSection!.text()).not.toContain('https://oss.example/private/rooming-list.xlsx') expect(roomingListSection!.text()).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER') expect(roomingListSection!.text()).not.toContain('PMS_IMPORT_SHOULD_NOT_RENDER') expect(roomingListSection!.text()).not.toContain('OHIP_SHOULD_NOT_RENDER') expect(roomingListSection!.text()).not.toContain('Excel') expect(wrapper.find('[data-testid="payment-attachment-preview"]').exists()).toBe(false) }) it('shows an explanatory unbound order label for Rooming List cards without target order clues', async () => { const detail = createOrderTaskDetail() useRoomingListBusinessCard(detail) detail.order_task.display_order_key = null detail.order_task.order_ref = null detail.order_task.target_locator_type = null detail.order_task.target_locator_value = null vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomingListCard = wrapper.find('[data-testid="rooming-list-card"]') expect(roomingListCard.text()).toContain(zhCN.taskV4.roomingList.unboundOrder) }) it('confirms Rooming List cards with version only and applies the refreshed backend detail', async () => { const detail = createOrderTaskDetail() useRoomingListBusinessCard(detail) const confirmedDetail = createOrderTaskDetail() useRoomingListBusinessCard(confirmedDetail, 'CONFIRMED') vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue(confirmedDetail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomingListSection = wrapper .findAll('.task-card-section') .find((section) => section.find('[data-testid="rooming-list-card"]').exists()) expect(roomingListSection).toBeTruthy() await roomingListSection!.find('button.primary-button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-rooming-list', { version: 12, }) const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2] expect(JSON.stringify(submittedRequest)).not.toContain('confirmed_payload') expect(JSON.stringify(submittedRequest)).not.toContain('rows') expect(JSON.stringify(submittedRequest)).not.toContain('attachment_ids') expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') expect(wrapper.text()).toContain(zhCN.status.CONFIRMED) }) it('resolves Rooming List review cards without submitting row or attachment overrides', async () => { const detail = createOrderTaskDetail() useRoomingListBusinessCard(detail, 'REVIEW_REQUIRED') detail.order_task.order_id = 'order-2001' detail.order_task.target_resolution_status = 'RESOLVED' const resolvedDetail = createOrderTaskDetail() useRoomingListBusinessCard(resolvedDetail, 'PENDING_CONFIRM') vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockResolvedValue(resolvedDetail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomingListSection = wrapper .findAll('.task-card-section') .find((section) => section.find('[data-testid="rooming-list-card"]').exists()) expect(roomingListSection).toBeTruthy() expect(roomingListSection!.find('.review-box').exists()).toBe(true) expect(roomingListSection!.find('.task-card-section__fields').exists()).toBe(false) await roomingListSection!.find('textarea').setValue('handled rooming list') await roomingListSection!.find('button.primary-button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled() expect(service.resolveReservationV4OrderTaskCardReview).toHaveBeenCalledWith('9001', 'card-rooming-list', { version: 12, confirmed_order_id: 'order-2001', reason: 'handled rooming list', field_overrides: [], }) const submittedRequest = vi.mocked(service.resolveReservationV4OrderTaskCardReview).mock.calls[0]?.[2] expect(JSON.stringify(submittedRequest)).not.toContain('rows') expect(JSON.stringify(submittedRequest)).not.toContain('attachment_ids') expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') }) it('renders GENERAL Trace cards with fixed department options and confirms safe trace fields only', async () => { const detail = createOrderTaskDetail() useTraceBusinessCard(detail, 'GENERAL') vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({ ...detail, business_cards: [ { ...detail.business_cards[0]!, card_status: 'CONFIRMED', }, ], }) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const traceSection = wrapper .findAll('.task-card-section') .find((section) => section.find('[data-testid="trace-card"]').exists()) expect(traceSection).toBeTruthy() expect((traceSection!.find('textarea[name="/trace_items/0/text"]').element as HTMLTextAreaElement).value) .toBe('Late arrival note') expect(traceSection!.text()).not.toContain('CONTENT_SHOULD_NOT_RENDER') expect(traceSection!.text()).not.toContain('TARGET_ORDER_SHOULD_NOT_RENDER') expect(traceSection!.text()).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER') expect(traceSection!.text()).not.toContain('https://oss.example/private/trace.pdf') expect(traceSection!.find('details.safe-payload').exists()).toBe(false) const departmentOptions = traceSection! .find('select[name="/trace_items/0/department_code"]') .findAll('option') .map((option) => option.attributes('value')) expect(departmentOptions).toEqual(['', 'FO', 'HSK', 'FO+HSK']) await traceSection!.find('textarea[name="/trace_items/0/text"]').setValue('Arrange late arrival follow-up') await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('HSK') await traceSection!.find('button.primary-button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-trace', { version: 10, confirmed_payload: { trace_items: [ { text: 'Arrange late arrival follow-up', department_code: 'HSK', }, ], }, }) const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2] expect(JSON.stringify(submittedRequest)).not.toContain('content') expect(JSON.stringify(submittedRequest)).not.toContain('target_order') expect(JSON.stringify(submittedRequest)).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER') expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') }) it('renders EXTRA_BED Trace cards with room type lookup, positive count input and confirms safe fields only', async () => { const detail = createOrderTaskDetail() useTraceBusinessCard(detail, 'EXTRA_BED') vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const traceSection = wrapper .findAll('.task-card-section') .find((section) => section.find('[data-testid="trace-card"]').exists()) expect(traceSection).toBeTruthy() expect(traceSection!.text()).toContain('SET EXTRA BED') expect(traceSection!.find('select[name="/trace_items/0/target_room_type_code"]').text()).toContain('RM2') const roomCountInput = traceSection!.find('input[name="/trace_items/0/extra_bed_room_count"]') expect(roomCountInput.attributes('type')).toBe('number') expect(roomCountInput.attributes('min')).toBe('1') expect(roomCountInput.attributes('step')).toBe('1') await traceSection!.find('select[name="/trace_items/0/target_room_type_code"]').setValue('RM2') await roomCountInput.setValue('0') await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('FO+HSK') await traceSection!.find('button.primary-button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled() expect(traceSection!.text()).toContain('Extra Bed Room Count must be a positive integer') await roomCountInput.setValue('2') await traceSection!.find('button.primary-button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-trace', { version: 10, confirmed_payload: { trace_items: [ { target_room_type_code: 'RM2', extra_bed_room_count: 2, department_code: 'FO+HSK', }, ], }, }) const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2] expect(JSON.stringify(submittedRequest)).not.toContain('content') expect(JSON.stringify(submittedRequest)).not.toContain('target_order') expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') }) it('resolves REVIEW_REQUIRED Trace cards with field errors and safe review overrides', async () => { const detail = createOrderTaskDetail() useTraceBusinessCard(detail, 'GENERAL', 'REVIEW_REQUIRED') detail.order_task.order_id = 'order-2001' detail.order_task.target_resolution_status = 'RESOLVED' vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockResolvedValue({ ...detail, business_cards: [ { ...detail.business_cards[0]!, card_status: 'PENDING_CONFIRM', review_status: 'RESOLVED', }, ], }) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const traceSection = wrapper .findAll('.task-card-section') .find((section) => section.find('[data-testid="trace-card"]').exists()) expect(traceSection).toBeTruthy() expect(traceSection!.find('.trace-field__error').text()).toContain('Department is required') await traceSection!.find('textarea[name="/trace_items/0/text"]').setValue('Confirm trace review text') await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('FO') await traceSection!.find('textarea[name="v4_review_reason"]').setValue('confirmed trace note') await traceSection!.find('button.primary-button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled() expect(service.resolveReservationV4OrderTaskCardReview).toHaveBeenCalledWith('9001', 'card-trace', { version: 10, confirmed_order_id: 'order-2001', reason: 'confirmed trace note', field_overrides: [ { field_pointer: '/trace_items/0/text', value: 'Confirm trace review text', }, { field_pointer: '/trace_items/0/department_code', value: 'FO', }, ], }) const submittedRequest = vi.mocked(service.resolveReservationV4OrderTaskCardReview).mock.calls[0]?.[2] expect(JSON.stringify(submittedRequest)).not.toContain('content') expect(JSON.stringify(submittedRequest)).not.toContain('target_order') expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example') }) it('renders New Booking Room Information as a business form and confirms stable final values', async () => { const detail = createOrderTaskDetail({ businessEventType: 'NEW_BOOKING', businessBookingType: 'GROUP', }) detail.business_cards[0]!.fields = [ ...detail.business_cards[0]!.fields, createField('/room_information/final_values/adult', { display_name: 'Adult', value: 2, }), createField('/room_information/final_values/nights', { display_name: 'Nights', value: 3, }), createField('/room_information/final_values/target_order/locator_value', { display_name: 'Target Locator', value: 'RAW-GRP-001', }), createField('/room_information/final_values/breakfast_included', { display_name: 'Breakfast', value: true, control_type: 'CHECKBOX', }), createField('/business_fields/after/room_items/0/room_type_code', { display_name: 'Legacy Room Type', value: 'LEGACY', }), ] const groupStatusField = detail.business_cards[0]!.fields.find((field) => field.field_pointer === '/room_information/final_values/group_booking_status', ) if (groupStatusField) { groupStatusField.validation_errors = ['请选择团队预订状态'] groupStatusField.fixed_options = [ { value: 'TEN', label: 'TEN-Tentative', }, { value: 'DEF', label: 'DEF-Definite', }, { value: 'INQ', label: 'INQ-Inquiry', }, ] } vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({ ...detail, business_cards: [ { ...detail.business_cards[0]!, card_status: 'CONFIRMED', }, ], }) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomCard = wrapper.find('[data-testid="room-information-card"]') expect(roomCard.exists()).toBe(true) expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.finalValues) expect(roomCard.text()).toContain('TEN-Tentative') expect(roomCard.text()).toContain('3') expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.confirmationNumber) expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.blockId) const confirmationField = findFieldByLabel(roomCard, zhCN.taskV4.roomInformation.confirmationNumber) const blockIdField = findFieldByLabel(roomCard, zhCN.taskV4.roomInformation.blockId) expect(confirmationField?.find('.v4-field__static').text()).toBe('-') expect(blockIdField?.find('.v4-field__static').text()).toBe('-') const statusSlot = roomCard.find('.room-information__status-slot') const statusSelect = statusSlot.find('select[name="/room_information/final_values/group_booking_status"]') expect(statusSlot.text()).toContain(zhCN.taskV4.roomInformation.groupBookingStatus) expect(statusSlot.text()).toContain('*') expect(statusSlot.text()).toContain('TEN-Tentative') expect(statusSelect.attributes('aria-invalid')).toBe('true') expect(statusSelect.findAll('option').map((option) => (option.element as HTMLOptionElement).value)) .toEqual(['', 'TEN', 'DEF', 'INQ']) expect(roomCard.find('.room-information__status-error').text()).toContain('请选择团队预订状态') expect(roomCard.find('.room-information__derived').exists()).toBe(false) expect(roomCard.text()).not.toContain('target_order') expect(roomCard.text()).not.toContain('Adult') expect(roomCard.text()).not.toContain('Legacy Room Type') expect(roomCard.text()).not.toContain('Group Block Name') expect(roomCard.text()).not.toContain('Rate Code') expect(roomCard.text()).not.toContain('Group Booking Status') expect(roomCard.find('input[name="/room_information/final_values/adult"]').exists()).toBe(false) expect(roomCard.find('input[name="/room_information/final_values/nights"]').exists()).toBe(false) expect(roomCard.find('input[name="/room_information/final_values/target_order/locator_value"]').exists()).toBe(false) expect(roomCard.find('input[name="/business_fields/after/room_items/0/room_type_code"]').exists()).toBe(false) const breakfastInputs = roomCard.findAll('input[type="checkbox"][name="/room_information/final_values/breakfast_included"]') expect(breakfastInputs).toHaveLength(1) const breakfast = breakfastInputs[0]! expect((breakfast.element as HTMLInputElement).checked).toBe(true) expect((breakfast.element as HTMLInputElement).disabled).toBe(true) expect((roomCard.find('input[name="/room_information/final_values/group_block_name"]').element as HTMLInputElement).value) .toBe('GRP-V4-RI-GROUP-001') expect(roomCard.find('input[name="/room_information/final_values/arrival_date"]').attributes('type')).toBe('text') expect((roomCard.find('input[name="/room_information/final_values/arrival_date"]').element as HTMLInputElement).value) .toBe('2026-07-26') expect(roomCard.find('input[name="/room_information/final_values/departure_date"]').attributes('type')).toBe('text') expect((roomCard.find('input[name="/room_information/final_values/departure_date"]').element as HTMLInputElement).value) .toBe('2026-07-29') const roomInformationText = roomCard.text() expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.departureDate)) .toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.nights)) expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.nights)) .toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.rateCode)) expect(roomInformationText.indexOf(zhCN.taskV4.roomInformation.blockId)) .toBeLessThan(roomInformationText.indexOf(zhCN.taskV4.roomInformation.breakfastIncluded)) await roomCard.find('input[name="/room_information/final_values/group_block_name"]').setValue('前端修正团队名') await roomCard.find('select[name="/room_information/final_values/group_booking_status"]').setValue('DEF') await roomCard.find('button').trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-room', { version: 5, confirmed_payload: { room_information: { final_values: { group_block_name: '前端修正团队名', arrival_date: '2026-07-26', departure_date: '2026-07-29', rate_code: 'GRPA2-850UP', group_booking_status: 'DEF', room_items: [ { room_type_code: 'RM2', room_count: 2, }, ], }, }, }, }) }) it('shows a user-facing empty state when Room Information display data is unavailable', async () => { const detail = createOrderTaskDetail({ businessDisplayPayload: {}, }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomCard = wrapper.find('[data-testid="room-information-card"]') expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.noDisplayModel) expect(roomCard.text()).not.toContain('Room Information') expect(roomCard.text()).not.toContain('display model') expect(roomCard.text()).not.toContain('展示模型') }) it('keeps backend errors for hidden Room Information fields visible as action messages', async () => { const detail = createOrderTaskDetail({ businessEventType: 'NEW_BOOKING', businessBookingType: 'GROUP', }) detail.business_cards[0]!.fields = [ ...detail.business_cards[0]!.fields, createField('/room_information/final_values/nights', { display_name: 'Nights', value: 3, }), ] vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) vi.mocked(service.confirmReservationV4OrderTaskCard).mockRejectedValue(new ApiError('validation failed', 400, { message: '后端校验失败', details: [ 'room_information.final_values.nights: 晚数不能由前端提交。', 'room_information.final_values.group_block_name: 团队名称不能为空。', ], })) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomCard = wrapper.find('[data-testid="room-information-card"]') await roomCard.find('button').trigger('click') await flushPromises() expect(wrapper.text()).toContain('后端校验失败') expect(wrapper.text()).toContain('room_information.final_values.nights: 晚数不能由前端提交。') expect(wrapper.text()).toContain('room_information.final_values.group_block_name: 团队名称不能为空。') }) it('renders readonly Room Information fixed select values with business labels', async () => { const detail = createOrderTaskDetail({ businessEventType: 'NEW_BOOKING', businessBookingType: 'GROUP', }) detail.business_cards[0]!.fields = detail.business_cards[0]!.fields.map((field) => field.field_pointer === '/room_information/final_values/group_booking_status' ? { ...field, editable: false, raw_readonly: true, } : field, ) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomCard = wrapper.find('[data-testid="room-information-card"]') expect(roomCard.text()).toContain('新预订') expect(roomCard.text()).not.toContain('NEW_BOOKING') expect(roomCard.find('select[name="/room_information/final_values/group_booking_status"]').exists()).toBe(false) expect(roomCard.text()).toContain('TEN-Tentative') }) it('renders Update Booking Room Information change summary before final values', async () => { const detail = createOrderTaskDetail({ businessEventType: 'UPDATE_BOOKING', businessBookingType: 'GROUP', businessDisplayPayload: createRoomInformationDisplayPayload({ event_type: 'UPDATE_BOOKING', current_values: { group_block_name: 'GRP-V4-RI-UPDATE-001', arrival_date: '2026-07-26', departure_date: '2026-07-29', nights: 3, rate_code: 'GRPA2-850UP', breakfast_included: true, group_booking_status: 'TEN', group_booking_status_label: 'TEN-Tentative', room_items: [ { room_type_code: 'RM2', room_count: 2, }, ], }, proposed_values: { arrival_date: '2026-07-27', departure_date: '2026-07-31', room_items: [ { room_type_code: 'RM3', room_count: 3, }, ], }, final_values: { group_block_name: 'GRP-V4-RI-UPDATE-001', arrival_date: '2026-07-27', departure_date: '2026-07-31', nights: 4, rate_code: 'GRPA2-850UP', breakfast_included: true, group_booking_status: 'TEN', group_booking_status_label: 'TEN-Tentative', room_items: [ { room_type_code: 'RM3', room_count: 3, }, ], }, change_summary: [ { field: 'arrival_date', before: '2026-07-26', after: '2026-07-27', }, { field: 'nights', before: 3, after: 4, }, { field: 'adult', before: 'ADULT-BEFORE-SHOULD-NOT-SHOW', after: 'ADULT-AFTER-SHOULD-NOT-SHOW', }, { field: 'target_order.locator_value', before: 'RAW-BEFORE-SHOULD-NOT-SHOW', after: 'RAW-AFTER-SHOULD-NOT-SHOW', }, ], }), }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomCard = wrapper.find('[data-testid="room-information-card"]') expect(roomCard.text()).toContain('修改预订') expect(roomCard.text()).not.toContain('UPDATE_BOOKING') expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.changeSummary) expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.currentValues) expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.proposedValues) expect(roomCard.text()).toContain('2026-07-26') expect(roomCard.text()).toContain('2026-07-27') expect(roomCard.text()).toContain('3') expect(roomCard.text()).toContain('4') expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.finalValues) expect(roomCard.text()).not.toContain('target_order') expect(roomCard.text()).not.toContain('ADULT-BEFORE-SHOULD-NOT-SHOW') expect(roomCard.text()).not.toContain('RAW-BEFORE-SHOULD-NOT-SHOW') }) it('renders Cancel Booking Room Information as readonly current and final values', async () => { const detail = createOrderTaskDetail({ businessEventType: 'CANCEL_BOOKING', businessBookingType: 'GROUP', businessFields: [], businessDisplayPayload: createRoomInformationDisplayPayload({ event_type: 'CANCEL_BOOKING', current_values: { group_block_name: 'GRP-V4-RI-CANCEL-001', arrival_date: '2026-08-01', departure_date: '2026-08-05', nights: 4, rate_code: 'GRPA2-850UP', breakfast_included: true, group_booking_status: 'DEF', group_booking_status_label: 'DEF-Definite', room_items: [ { room_type_code: 'SU1', room_count: 1, }, ], }, final_values: { group_block_name: 'GRP-V4-RI-CANCEL-001', arrival_date: '2026-08-01', departure_date: '2026-08-05', nights: 4, rate_code: 'GRPA2-850UP', breakfast_included: true, group_booking_status: 'DEF', group_booking_status_label: 'DEF-Definite', room_items: [ { room_type_code: 'SU1', room_count: 1, }, ], }, }), }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomCard = wrapper.find('[data-testid="room-information-card"]') expect(roomCard.text()).toContain('取消预订') expect(roomCard.text()).not.toContain('CANCEL_BOOKING') expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.currentValues) expect(roomCard.text()).toContain(zhCN.taskV4.roomInformation.finalValues) expect(roomCard.text()).toContain('GRP-V4-RI-CANCEL-001') expect(roomCard.text()).toContain('SU1') expect(roomCard.find('input[name="/room_information/final_values/arrival_date"]').exists()).toBe(false) expect(roomCard.find('select[name="/room_information/final_values/group_booking_status"]').exists()).toBe(false) }) it('submits V4 review resolution with Room Information field_pointer overrides from the confirm card button', async () => { const detail = createOrderTaskDetail({ businessCardStatus: 'REVIEW_REQUIRED', businessCardAvailability: { confirmable: false, reviewable: true, }, }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const resolvedBusinessCard = detail.business_cards[0]! vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockResolvedValue({ ...detail, business_cards: [ { ...resolvedBusinessCard, card_status: 'PENDING_CONFIRM', review_status: 'RESOLVED', }, ], }) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() const roomCard = wrapper.find('[data-testid="room-information-card"]') await roomCard.find('select[name="/room_information/final_values/room_items/0/room_type_code"]').setValue('RM2') await wrapper.find('input[name="v4_confirmed_order_id"]').setValue('order-2001') await wrapper.find('textarea').setValue('confirmed by email evidence') await roomCard.find('button').trigger('click') await flushPromises() expect(service.resolveReservationV4OrderTaskCardReview).toHaveBeenCalledWith('9001', 'card-room', { version: 5, confirmed_order_id: 'order-2001', reason: 'confirmed by email evidence', field_overrides: [ { field_pointer: '/room_information/final_values/room_items/0/room_type_code', value: 'RM2', }, ], }) }) it('shows lookup empty state without catalog technical warnings', async () => { const detail = createOrderTaskDetail() vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({ hotel_id: 'HOTEL-TEST', catalog_type: 'ACCOUNT', catalog_source: 'PMS_SYNC', catalog_version: 'stale-v1', stale: true, items: [], page: { page_num: 1, page_size: 100, total: 0, }, warnings: ['PMS sync is stale'], }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.empty) expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.stale) expect(wrapper.text()).not.toContain('PMS sync is stale') expect(wrapper.text()).not.toContain('PMS_SYNC') expect(wrapper.text()).not.toContain('stale-v1') }) it('does not submit a catalog field value that is missing from the loaded lookup result', async () => { const detail = createOrderTaskDetail({ basicAccountValue: 'UNKNOWN_ACCOUNT', basicAccountRequired: true, }) vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({ hotel_id: 'HOTEL-TEST', catalog_type: 'ACCOUNT', catalog_source: 'SYSTEM_MANAGED', catalog_version: 'catalog-v1', stale: false, items: [], page: { page_num: 1, page_size: 100, total: 0, }, warnings: [], }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() await flushPromises() expect(wrapper.text()).toContain('UNKNOWN_ACCOUNT') expect(wrapper.text()).toContain(zhCN.taskV4.lookup.currentNotInCatalog.replace('{value}', 'UNKNOWN_ACCOUNT')) await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled() expect(wrapper.text()).toContain(zhCN.taskV4.validationFailed) }) it('keeps readonly catalog field values even when active lookup does not contain them', async () => { const detail = createOrderTaskDetail({ basicAccountValue: 'HISTORICAL_ACCOUNT', basicCardStatus: 'CONFIRMED', basicCardAvailability: { editable: false, read_only: true, confirmable: false, }, }) vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue(createCatalogLookupResult('ACCOUNT', [])) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(service.fetchReservationV4AccountLookups).not.toHaveBeenCalled() expect(wrapper.text()).toContain('HISTORICAL_ACCOUNT') expect(wrapper.text()).not.toContain( zhCN.taskV4.lookup.currentNotInCatalog.replace('{value}', 'HISTORICAL_ACCOUNT'), ) }) it('keeps a current catalog value found by exact keyword lookup outside the first page', async () => { const detail = createOrderTaskDetail({ basicAccountValue: 'ACC-101', basicAccountRequired: true, }) vi.mocked(service.fetchReservationV4AccountLookups) .mockResolvedValueOnce(createCatalogLookupResult('ACCOUNT', [ { code: 'ACC-001', display_name: 'Account 001', market_code: 'LEISURE', source_code: 'TRAVEL_AGENT', }, ], 101)) .mockResolvedValueOnce(createCatalogLookupResult('ACCOUNT', [ { code: 'ACC-101', display_name: 'Account 101', market_code: 'MICE', source_code: 'DIRECT', }, ], 1, 20)) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() await flushPromises() expect(service.fetchReservationV4AccountLookups).toHaveBeenNthCalledWith(1, { hotel_id: 'HOTEL-TEST', page_num: 1, page_size: 100, }) expect(service.fetchReservationV4AccountLookups).toHaveBeenNthCalledWith(2, { hotel_id: 'HOTEL-TEST', keyword: 'ACC-101', page_num: 1, page_size: 20, }) await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click') await flushPromises() expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-basic', { version: 7, confirmed_payload: { basic_information: { account_code: 'ACC-101', market_code: 'LEISURE', source_code: 'TRAVEL_AGENT', }, }, }) }) it('hides catalog technical warnings from ordinary V4 business cards', async () => { const detail = createOrderTaskDetail() vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({ hotel_id: 'HOTEL-TEST', catalog_type: 'ACCOUNT', catalog_source: 'PMS_SYNC', catalog_version: 'seed-v1', stale: true, items: [], page: { page_num: 1, page_size: 100, total: 0, }, warnings: ['固定种子初始化,后续接入真实 PMS。'], }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.empty) expect(wrapper.text()).not.toContain('固定种子初始化') expect(wrapper.text()).not.toContain('真实 PMS') expect(wrapper.text()).not.toContain('PMS_SYNC') expect(wrapper.text()).not.toContain('seed-v1') expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.stale) }) it('keeps the per-card primary action right aligned on narrow screens', () => { expect(taskCardSectionSource).toContain('@media (max-width: 980px)') expect(taskCardSectionSource).toContain('.task-card-section__footer-actions') expect(taskCardSectionSource).toContain('padding: 0 20px 20px') expect(taskCardSectionSource).toContain('justify-content: flex-end') expect(taskCardSectionSource).toContain('.task-card-section__footer {\n align-items: flex-end;\n }') }) it('requires confirmed order id before resolving an unresolved V4 review card', async () => { const detail = createOrderTaskDetail({ businessCardStatus: 'REVIEW_REQUIRED', businessCardAvailability: { confirmable: false, reviewable: true, }, }) vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail) const wrapper = await mountWithPlugins( ReservationV4OrderTaskDetailView, '/reservation/order-tasks/9001', ) await flushPromises() await wrapper.find('[data-testid="room-information-card"]').find('button').trigger('click') await flushPromises() expect(service.resolveReservationV4OrderTaskCardReview).not.toHaveBeenCalled() expect(wrapper.text()).toContain(zhCN.taskV4.confirmedOrderRequired) }) it('renders V4 source notification detail and acknowledges it without business actions', async () => { const detail = createSourceNotificationDetail() vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockResolvedValue(detail) vi.mocked(service.ackReservationV4SourceNotification).mockResolvedValue({ ...detail, notification: { ...detail.notification, notification_status: 'ACKED', }, availability: { ...detail.availability, ackable: false, }, }) const wrapper = await mountWithPlugins( ReservationV4SourceNotificationDetailView, '/reservation/source-notifications/7001', ) await flushPromises() expect(wrapper.text()).toContain('来源通知详情') expect(wrapper.text()).toContain('FYI only') expect(wrapper.text()).toContain('该通知不关联订单、业务卡、字段编辑、复核或 OPERA 操作') expect(wrapper.text()).not.toContain('业务任务卡') expect(wrapper.text()).not.toContain('确认卡片') await wrapper.find('textarea').setValue('handled') await wrapper.findAll('button').find((button) => button.text().includes('确认已读'))?.trigger('click') await flushPromises() expect(service.ackReservationV4SourceNotification).toHaveBeenCalledWith('7001', { version: 3, reason: 'handled', }) }) }) async function mountWithPlugins(component: object, initialPath: string) { const pinia = createPinia() setActivePinia(pinia) const authStore = useAuthStore() authStore.applyLoginResult({ access_token: 'test-token', token_type: 'Bearer', expires_at: '2026-07-08T04:00:00Z', user: { id: 'u1', username: 'agent', display_name: '系统管理员', super_admin: false, }, default_hotel_id: 'HOTEL-TEST', hotels: [ { hotel_id: 'HOTEL-TEST', hotel_name: '测试酒店', time_zone: 'Asia/Bangkok', default_hotel: true, }, ], permissions: [ 'RESERVATION_TASK_READ', 'RESERVATION_TASK_CONFIRM', 'RESERVATION_MANUAL_REVIEW_RESOLVE', 'SOURCE_MESSAGE_ORIGINAL_READ', ], menus: [], }) const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': zhCN, }, }) const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: { template: '
' } }, { path: '/reservation/order-tasks/:orderTaskId', component: { template: '
' } }, { path: '/reservation/source-notifications/:notificationId', component: { template: '
' } }, { path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '
' } }, { path: '/reservation/orders/:orderId', component: { template: '
' } }, ], }) await router.push(initialPath) await router.isReady() return mount(component, { global: { plugins: [pinia, i18n, router, PrimeVue], stubs: { RouterLink: { template: '', }, }, }, }) } function mockCatalogLookups() { vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({ hotel_id: 'HOTEL-TEST', catalog_type: 'ACCOUNT', catalog_source: 'SYSTEM_MANAGED', catalog_version: 'catalog-v1', stale: false, items: [ { code: 'ACC-LIVE', display_name: 'Live Account', status: 'ACTIVE', catalog_source: 'SYSTEM_MANAGED', market_code: 'LEISURE', market_name: 'Leisure', source_code: 'TRAVEL_AGENT', source_name: 'Travel Agent', adult_capacity: null, pricing_available: null, }, ], page: { page_num: 1, page_size: 100, total: 1, }, warnings: [], }) vi.mocked(service.fetchReservationV4RoomTypeLookups).mockResolvedValue({ hotel_id: 'HOTEL-TEST', catalog_type: 'ROOM_TYPE', catalog_source: 'SYSTEM_MANAGED', catalog_version: 'catalog-v1', stale: false, items: [ { code: 'RM2', display_name: 'RM2', status: 'ACTIVE', catalog_source: 'SYSTEM_MANAGED', market_code: null, market_name: null, source_code: null, source_name: null, adult_capacity: 2, pricing_available: null, }, ], page: { page_num: 1, page_size: 100, total: 1, }, warnings: [], }) vi.mocked(service.fetchReservationV4RateCodeLookups).mockResolvedValue({ hotel_id: 'HOTEL-TEST', catalog_type: 'RATE_CODE', catalog_source: 'SYSTEM_MANAGED', catalog_version: 'catalog-v1', stale: false, items: [ { code: 'GRPA2-850UP', display_name: 'GRPA2-850UP', status: 'ACTIVE', catalog_source: 'SYSTEM_MANAGED', market_code: null, market_name: null, source_code: null, source_name: null, adult_capacity: null, pricing_available: null, }, ], page: { page_num: 1, page_size: 100, total: 1, }, warnings: [], }) } function createOrderTaskDetail(options: { basicCardStatus?: string basicCardAvailability?: Partial businessCardStatus?: string businessCardAvailability?: Partial businessEventType?: string businessBookingType?: string businessDisplayPayload?: ReservationV4TaskCardResult['display_payload'] businessFields?: ReservationV4TaskCardResult['fields'] sourceDisplayPayload?: ReservationV4TaskCardResult['display_payload'] basicAccountValue?: string basicMarketCodeValue?: string basicSourceCodeValue?: string basicAccountRequired?: boolean } = {}): ReservationV4OrderTaskDetailResult { const sourceCard = createCard('card-source', 'SOURCE_MESSAGE_DISPLAY', 'READONLY', { fields: [], display_payload: options.sourceDisplayPayload ?? { subject: 'Booking Request', sender_summary: 'guest@example.test', relevant_message_excerpt: 'Please book one twin room.', attachments: [ { name: 'booking.pdf', content_type: 'application/pdf', }, ], }, }) const basicCard = createCard('card-basic', 'BASIC_INFORMATION', options.basicCardStatus ?? 'PENDING_CONFIRM', { version: 7, availability: createAvailability(options.basicCardAvailability), fields: [ createField('/basic_information/account_code', { display_name: 'Account', value: options.basicAccountValue ?? '', required: options.basicAccountRequired ?? false, options_source: 'RESERVATION_V4_ACCOUNT_CATALOG', control_type: 'SELECT', }), createField('/basic_information/market_code', { display_name: 'Market Code', value: options.basicMarketCodeValue ?? 'LEISURE', editable: false, raw_readonly: true, control_type: 'READONLY', edit_scope: 'NEVER', write_target: 'NONE', control_hint: 'Market 由 Account Code 派生,前端只读展示。', }), createField('/basic_information/source_code', { display_name: 'Source Code', value: options.basicSourceCodeValue ?? 'TRAVEL_AGENT', editable: false, raw_readonly: true, control_type: 'READONLY', edit_scope: 'NEVER', write_target: 'NONE', control_hint: 'Source 由 Account Code 派生,前端只读展示。', }), createField('/basic_information/read_only_marker', { display_name: 'Read only marker', value: 'VISIBLE', editable: true, raw_readonly: true, }), ], }) const businessEventType = options.businessEventType ?? 'NEW_BOOKING' const businessBookingType = options.businessBookingType ?? 'GROUP' const businessCard = createCard('card-room', 'ROOM_INFORMATION', options.businessCardStatus ?? 'PENDING_CONFIRM', { event_type: businessEventType, version: 5, availability: createAvailability({ confirmable: options.businessCardAvailability?.confirmable ?? true, reviewable: options.businessCardAvailability?.reviewable ?? false, }), display_payload: options.businessDisplayPayload ?? createRoomInformationDisplayPayload({ event_type: businessEventType, booking_type: businessBookingType, }), fields: options.businessFields ?? createRoomInformationFields(options.businessCardStatus === 'REVIEW_REQUIRED'), }) return { order_task: { order_task_id: '9001', hotel_id: 'HOTEL-TEST', source_message_id: '30001', ai_batch_id: 'batch-1', order_ref: 'GRP-001', order_context_index: 0, order_id: null, target_booking_type: 'GROUP', target_locator_type: 'GROUP_CODE', target_locator_value: 'GRP-001', target_resolution_status: 'UNRESOLVED', order_task_status: 'OPEN', display_order_key: 'GRP-001', source_received_at: '2026-07-08T03:00:00Z', version: 11, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:00:00Z', }, source_message_summary: { source_message_id: '30001', hotel_id: 'HOTEL-TEST', external_message_id: 'm1', external_conversation_id: 'thread-1', subject: 'Booking Request', sender_summary: 'guest@example.test', received_at: '2026-07-08T03:00:00Z', source_sent_at: null, conversation_message_count: 2, }, bound_order: null, source_message_card: sourceCard, basic_information_card: basicCard, business_cards: [businessCard], card_counts: { total_count: 3, readonly_count: 1, pending_confirm_count: 2, review_required_count: options.businessCardStatus === 'REVIEW_REQUIRED' ? 1 : 0, confirmed_count: 0, }, adapter_contract_errors: [ { transition_id: 'trans-1', route_code: 'R02_NEW_GROUP_BLOCK_NORMAL', result_type: 'ADAPTER_CONTRACT_ERROR', adapter_error_code: 'ADAPTER_CONTRACT_ERROR', adapter_error_message: 'Backend contract issue', payload_fragment: null, }, ], availability: createAvailability(), } } function createSourceMessageConversationResult(options: { sourceMessageId?: string currentBody?: string sanitizedHtml?: string | null rawHtml?: string | null htmlRenderMode?: SourceMessageConversationResult['messages'][number]['html_render_mode'] attachments?: SourceMessageOriginalMedia[] } = {}): SourceMessageConversationResult { const sourceMessageId = options.sourceMessageId ?? '30001' const currentBody = options.currentBody ?? 'Current trigger email body.' return { conversation: { hotel_id: 'HOTEL-TEST', channel: 'EMAIL', external_conversation_id: 'thread-1', subject: 'Booking Request', message_count: 3, first_received_at: '2026-07-08T02:00:00Z', last_received_at: '2026-07-08T04:00:00Z', }, messages: [ createConversationMessage({ id: '29999', text_body: 'OLDER_MESSAGE_BODY', received_at: '2026-07-08T02:00:00Z', }), createConversationMessage({ id: sourceMessageId, text_body: currentBody, html_body: options.rawHtml ?? null, html_body_sanitized: options.sanitizedHtml ?? null, html_render_mode: options.htmlRenderMode, received_at: '2026-07-08T03:00:00Z', attachments: options.attachments ?? [ { mediaType: 'ATTACHMENT', fileName: 'current-message.pdf', contentType: 'application/pdf', sizeBytes: 2048, externalUrl: 'https://oss.example/private/current-message.pdf', externalMediaId: 'media-current', }, ], }), createConversationMessage({ id: '30002', text_body: 'REPLY_MESSAGE_BODY', received_at: '2026-07-08T04:00:00Z', }), ], } } function createConversationMessage( overrides: Partial & { id: string }, ): SourceMessageConversationResult['messages'][number] { return { id: overrides.id, external_message_id: overrides.external_message_id ?? `external-${overrides.id}`, external_conversation_id: overrides.external_conversation_id ?? 'thread-1', sender_summary: overrides.sender_summary ?? 'guest@example.test', subject: overrides.subject ?? 'Booking Request', received_at: overrides.received_at ?? '2026-07-08T03:00:00Z', source_sent_at: overrides.source_sent_at ?? null, text_body: overrides.text_body ?? null, html_body: overrides.html_body ?? null, html_body_sanitized: overrides.html_body_sanitized ?? null, html_sanitize_required: overrides.html_sanitize_required ?? false, html_render_mode: overrides.html_render_mode ?? (overrides.html_body_sanitized ? 'SANITIZED_HTML' : 'TEXT_ONLY'), inline_images: overrides.inline_images ?? [], attachments: overrides.attachments ?? [], related_orders: overrides.related_orders ?? [], related_tasks: overrides.related_tasks ?? [], } } function usePaymentBusinessCard(detail: ReservationV4OrderTaskDetailResult): void { detail.business_cards = [ createCard('card-payment', 'PAYMENT', 'PENDING_CONFIRM', { version: 9, display_payload: { payment_attachments: [ { attachment_id: 'att-img-1', file_name: 'voucher-image.jpg', content_type: 'image/jpeg', size_bytes: 123456, is_image: true, preview_available: true, download_available: true, external_media_id: 'media-payment-image', }, { attachment_id: 'att-pdf-1', file_name: 'voucher-document.pdf', content_type: 'application/pdf', size_bytes: 2048, is_image: false, preview_available: false, download_available: true, external_media_id: 'media-payment-document', }, { attachment_id: 'att-missing-1', file_name: 'missing-voucher.jpg', content_type: 'image/jpeg', size_bytes: 4096, is_image: true, preview_available: false, download_available: false, external_media_id: 'media-payment-missing', unavailable_reason_code: 'MEDIA_NOT_AVAILABLE', }, ], }, fields: [ createField('/payment/attachment_ids', { display_name: 'attachment_ids', value: ['att-img-1', 'att-pdf-1'], editable: true, raw_readonly: false, edit_scope: 'CONFIRM', write_target: 'CONFIRMED_PAYLOAD_JSON', }), createField('/payment/payment_note', { display_name: 'Payment Note', value: 'Should not submit for Payment confirm.', editable: true, edit_scope: 'CONFIRM', write_target: 'CONFIRMED_PAYLOAD_JSON', }), ], }), ] } function useRoomingListBusinessCard( detail: ReservationV4OrderTaskDetailResult, cardStatus = 'PENDING_CONFIRM', ): void { const isPendingConfirm = cardStatus === 'PENDING_CONFIRM' const isReviewRequired = cardStatus === 'REVIEW_REQUIRED' detail.business_cards = [ createCard('card-rooming-list', 'ROOMING_LIST', cardStatus, { version: 12, availability: createAvailability({ confirmable: isPendingConfirm, reviewable: isReviewRequired, editable: false, read_only: !isPendingConfirm, }), display_payload: { rows: [ { guest_name: 'ROW_SHOULD_NOT_RENDER', room_no: '1001', }, ], attachment_ids: ['rooming-list-att-1'], attachment_url: 'https://oss.example/private/rooming-list.xlsx', ai_payload_json: 'AI_PAYLOAD_SHOULD_NOT_RENDER', pms_import_plan: 'PMS_IMPORT_SHOULD_NOT_RENDER', ohip_payload: 'OHIP_SHOULD_NOT_RENDER', }, fields: [ createField('/rooming_list/rows/0/guest_name', { display_name: 'Rooming list row', value: 'ROW_SHOULD_NOT_RENDER', editable: true, edit_scope: 'CONFIRM', write_target: 'CONFIRMED_PAYLOAD_JSON', }), createField('/rooming_list/attachment_ids', { display_name: 'attachment_ids', value: ['rooming-list-att-1'], editable: true, edit_scope: 'CONFIRM', write_target: 'CONFIRMED_PAYLOAD_JSON', }), ], }), ] detail.card_counts = { ...detail.card_counts, pending_confirm_count: cardStatus === 'PENDING_CONFIRM' ? 2 : 1, confirmed_count: cardStatus === 'CONFIRMED' ? 1 : 0, } } function useBoundOrder(detail: ReservationV4OrderTaskDetailResult): void { detail.bound_order = { order_id: 'order-2001', hotel_id: 'HOTEL-TEST', order_status: 'ACTIVE', temporary_order_no: null, confirmation_number: null, group_code: 'GRP-001', block_code: null, allotment_code: null, display_name: 'GRP-001', created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:00:00Z', } } function useTraceBusinessCard( detail: ReservationV4OrderTaskDetailResult, itemType: 'GENERAL' | 'EXTRA_BED', cardStatus = 'PENDING_CONFIRM', ): void { const isReviewRequired = cardStatus === 'REVIEW_REQUIRED' detail.business_cards = [ createCard('card-trace', 'TRACE_RESERVATION_NOTES', cardStatus, { event_type: 'TRACE_RESERVATION_NOTES', version: 10, availability: createAvailability({ confirmable: cardStatus === 'PENDING_CONFIRM', reviewable: isReviewRequired, editable: true, read_only: false, }), display_payload: { trace_items: [ itemType === 'EXTRA_BED' ? { item_type: 'EXTRA_BED', target_room_type_code: 'RM2', extra_bed_room_count: 1, department_code: 'FO', content: 'CONTENT_SHOULD_NOT_RENDER', } : { item_type: 'GENERAL', text: 'Late arrival note', department_code: 'FO', content: 'CONTENT_SHOULD_NOT_RENDER', }, ], target_order: 'TARGET_ORDER_SHOULD_NOT_RENDER', ai_payload_json: 'AI_PAYLOAD_SHOULD_NOT_RENDER', attachment_url: 'https://oss.example/private/trace.pdf', }, fields: itemType === 'EXTRA_BED' ? [ createField('/trace_items/0/target_room_type_code', { display_name: 'Target Room Type', value: 'RM2', options_source: 'RESERVATION_V4_ROOM_TYPE_CATALOG', control_type: 'SELECT', edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM', write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON', }), createField('/trace_items/0/extra_bed_room_count', { display_name: 'Extra Bed Room Count', value: 1, control_type: 'NUMBER', edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM', write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON', }), createTraceDepartmentField(isReviewRequired), createField('/trace_items/0/content', { display_name: 'Legacy content', value: 'CONTENT_SHOULD_NOT_RENDER', editable: true, edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM', write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON', }), ] : [ createField('/trace_items/0/text', { display_name: 'Trace Text', value: 'Late arrival note', control_type: 'TEXTAREA', edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM', write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON', }), createTraceDepartmentField(isReviewRequired, isReviewRequired ? ['Department is required'] : []), createField('/trace_items/0/content', { display_name: 'Legacy content', value: 'CONTENT_SHOULD_NOT_RENDER', editable: true, edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM', write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON', }), ], }), ] detail.card_counts = { ...detail.card_counts, pending_confirm_count: cardStatus === 'PENDING_CONFIRM' ? 2 : 1, review_required_count: isReviewRequired ? 1 : 0, } } function createTraceDepartmentField( isReviewRequired: boolean, validationErrors: string[] = [], ): ReservationV4TaskCardResult['fields'][number] { return createField('/trace_items/0/department_code', { display_name: 'Department', value: 'FO', control_type: 'SELECT', options_source: 'reservation_v4_trace_department_fixed', fixed_options: [ { value: 'FO', label: 'FO', }, { value: 'HSK', label: 'HSK', }, { value: 'FO+HSK', label: 'FO+HSK', }, ], edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM', write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON', validation_errors: validationErrors, }) } function createPaymentConversationMedia(): SourceMessageOriginalMedia[] { return [ { mediaType: 'ATTACHMENT', fileName: 'voucher-image.jpg', contentType: 'image/jpeg', sizeBytes: 123456, externalUrl: 'https://oss.example/private/payment-image.jpg', externalMediaId: 'media-payment-image', }, { mediaType: 'ATTACHMENT', fileName: 'voucher-document.pdf', contentType: 'application/pdf', sizeBytes: 2048, externalUrl: 'https://oss.example/private/payment-document.pdf', externalMediaId: 'media-payment-document', }, { mediaType: 'ATTACHMENT', fileName: 'missing-voucher.jpg', contentType: 'image/jpeg', sizeBytes: 4096, externalUrl: 'https://oss.example/private/same-file-name-should-not-match.jpg', externalMediaId: 'media-different-same-file-name', }, ] } function createRoomInformationDisplayPayload(overrides: { event_type?: string booking_type?: string current_values?: Record proposed_values?: Record final_values?: Record change_summary?: Array> } = {}): ReservationV4TaskCardResult['display_payload'] { const eventType = overrides.event_type ?? 'NEW_BOOKING' const bookingType = overrides.booking_type ?? 'GROUP' const finalValues = overrides.final_values ?? { group_block_name: 'GRP-V4-RI-GROUP-001', arrival_date: '2026-07-26', departure_date: '2026-07-29', nights: 3, rate_code: 'GRPA2-850UP', breakfast_included: true, group_booking_status: 'TEN', group_booking_status_label: 'TEN-Tentative', room_items: [ { room_type_code: 'RM2', room_count: 2, }, ], } return { event_type: eventType, source_event_index: 1, room_information: { event_type: eventType, booking_type: bookingType, current_values: overrides.current_values ?? {}, proposed_values: overrides.proposed_values ?? finalValues, final_values: finalValues, change_summary: overrides.change_summary ?? [], group_booking_status_options: [ { code: 'TEN', label: 'TEN-Tentative', }, { code: 'DEF', label: 'DEF-Definite', }, { code: 'INQ', label: 'INQ-Inquiry', }, ], }, } } function createRoomInformationFields(reviewMode = false): ReservationV4TaskCardResult['fields'] { const edit_scope = reviewMode ? 'MANUAL_REVIEW_ONLY' : 'CONFIRM' const write_target = reviewMode ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON' return [ createField('/room_information/final_values/group_block_name', { display_name: 'Group Block Name', value: 'GRP-V4-RI-GROUP-001', required: true, editable: !reviewMode, edit_scope, write_target, }), createField('/room_information/final_values/arrival_date', { display_name: '入住日期', value: '2026-07-26', required: true, control_type: 'DATE', editable: !reviewMode, edit_scope, write_target, }), createField('/room_information/final_values/departure_date', { display_name: '离店日期', value: '2026-07-29', required: true, control_type: 'DATE', editable: !reviewMode, edit_scope, write_target, }), createField('/room_information/final_values/rate_code', { display_name: 'Rate Code', value: 'GRPA2-850UP', required: true, control_type: 'SELECT', options_source: 'RESERVATION_V4_RATE_CODE_CATALOG', editable: !reviewMode, edit_scope, write_target, }), createField('/room_information/final_values/room_items/0/room_type_code', { display_name: '房型代码', value: reviewMode ? '' : 'RM2', required: true, control_type: 'SELECT', options_source: 'RESERVATION_V4_ROOM_TYPE_CATALOG', validation_errors: reviewMode ? ['房型代码不在第一版目录中。'] : [], edit_scope, write_target, }), createField('/room_information/final_values/room_items/0/room_count', { display_name: '房间数', value: 2, required: true, control_type: 'NUMBER', editable: !reviewMode, edit_scope, write_target, }), createField('/room_information/final_values/group_booking_status', { display_name: 'Group Booking Status', value: 'TEN', required: true, control_type: 'SELECT', options_source: 'reservation_v4_group_booking_status_fixed', editable: !reviewMode, edit_scope, write_target, }), ] } function createSourceNotificationDetail(): ReservationV4SourceNotificationDetailResult { return { notification: { notification_id: '7001', hotel_id: 'HOTEL-TEST', source_message_id: '30002', ai_batch_id: 'batch-2', ai_transition_id: 'transition-2', route_code: 'S10', notification_status: 'ACK_REQUIRED', source_received_at: '2026-07-08T03:00:00Z', ack_by: null, ack_at: null, version: 3, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:00:00Z', }, conversation_summary: { source_message_id: '30002', hotel_id: 'HOTEL-TEST', external_message_id: 'm2', external_conversation_id: 'thread-2', subject: 'FYI only', sender_summary: 'guest@example.test', received_at: '2026-07-08T03:00:00Z', source_sent_at: null, conversation_message_count: 1, }, source_message_card: createCard('card-source-notification', 'SOURCE_MESSAGE_NOTIFICATION', 'ACK_REQUIRED', { display_payload: { subject: 'FYI only', relevant_message_excerpt: 'Thanks for the update.', }, }), availability: createAvailability({ ackable: true, confirmable: false, editable: false, }), } } function createCatalogLookupResult( catalog_type: ReservationV4CatalogLookupResult['catalog_type'], items: Array & { code: string }>, total = items.length, pageSize = 100, ): ReservationV4CatalogLookupResult { return { hotel_id: 'HOTEL-TEST', catalog_type, catalog_source: 'SYSTEM_MANAGED', catalog_version: 'catalog-v1', stale: false, items: items.map((item) => ({ code: item.code, display_name: item.display_name ?? item.code, status: item.status ?? 'ACTIVE', catalog_source: item.catalog_source ?? 'SYSTEM_MANAGED', market_code: item.market_code ?? null, market_name: item.market_name ?? null, source_code: item.source_code ?? null, source_name: item.source_name ?? null, adult_capacity: item.adult_capacity ?? null, pricing_available: item.pricing_available ?? null, })), page: { page_num: 1, page_size: pageSize, total, }, warnings: [], } } function createCard( card_id: string, card_type: string, card_status: string, overrides: Partial = {}, ): ReservationV4TaskCardResult { return { card_id, card_type, event_type: null, source_event_index: null, card_sort_order: null, card_status, review_status: null, display_payload: overrides.display_payload ?? null, confirmed_payload: null, review_resolution: null, validation_errors: null, fields: overrides.fields ?? [], confirmed_by: null, confirmed_at: null, version: overrides.version ?? 1, created_at: '2026-07-08T03:00:00Z', updated_at: '2026-07-08T03:00:00Z', availability: createAvailability(overrides.availability), } } function createField( field_pointer: string, overrides: Partial = {}, ): ReservationV4TaskCardResult['fields'][number] { return { field_path: field_pointer.replace(/^\//, '').replace(/\//g, '.'), field_pointer, display_name: overrides.display_name ?? field_pointer, value: overrides.value ?? '', editable: overrides.editable ?? true, required: overrides.required ?? false, control_type: overrides.control_type ?? 'TEXT', edit_scope: overrides.edit_scope ?? 'NORMAL_TASK', write_target: overrides.write_target ?? 'CONFIRMED_PAYLOAD_JSON', options_source: overrides.options_source ?? null, fixed_options: overrides.fixed_options ?? null, raw_readonly: overrides.raw_readonly ?? false, validation_errors: overrides.validation_errors ?? [], control_hint: overrides.control_hint ?? null, } } function createAvailability( overrides: Partial = {}, ): ReservationV4TaskCardResult['availability'] { return { blocked: overrides.blocked ?? false, read_only: overrides.read_only ?? false, editable: overrides.editable ?? true, confirmable: overrides.confirmable ?? true, reviewable: overrides.reviewable ?? false, ackable: overrides.ackable ?? false, readonly_reason_code: overrides.readonly_reason_code ?? null, blocked_by_order_task_id: overrides.blocked_by_order_task_id ?? null, blocked_by_card_id: overrides.blocked_by_card_id ?? null, blocked_reason: overrides.blocked_reason ?? null, } }