Files
th-hotel-simple/client/src/tests/reservationV4Views.spec.ts

2349 lines
89 KiB
TypeScript

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<typeof import('@/services/reservationService')>()
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<Element>, label: string): DOMWrapper<Element> | 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 = `<p>Sanitized current email opening. ${'Sanitized detail. '.repeat(24)}SANITIZED_HTML_TAIL</p>`
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.fetchSourceMessageConversation).mockResolvedValue(createSourceMessageConversationResult({
sourceMessageId: '30001',
currentBody: '',
sanitizedHtml,
rawHtml: '<script>RAW_HTML_SHOULD_NOT_RENDER</script>',
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('<p>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: '<p>SANITIZED_HTML_SHOULD_NOT_RENDER</p>',
rawHtml: '<p>RAW_HTML_SHOULD_NOT_RENDER</p>',
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: '<div />' } },
{ path: '/reservation/order-tasks/:orderTaskId', component: { template: '<div />' } },
{ path: '/reservation/source-notifications/:notificationId', component: { template: '<div />' } },
{ path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '<div />' } },
{ path: '/reservation/orders/:orderId', component: { template: '<div />' } },
],
})
await router.push(initialPath)
await router.isReady()
return mount(component, {
global: {
plugins: [pinia, i18n, router, PrimeVue],
stubs: {
RouterLink: {
template: '<a><slot /></a>',
},
},
},
})
}
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<ReservationV4TaskCardResult['availability']>
businessCardStatus?: string
businessCardAvailability?: Partial<ReservationV4TaskCardResult['availability']>
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<SourceMessageConversationResult['messages'][number]> & { 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<string, unknown>
proposed_values?: Record<string, unknown>
final_values?: Record<string, unknown>
change_summary?: Array<Record<string, unknown>>
} = {}): 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<Partial<ReservationV4CatalogLookupResult['items'][number]> & { 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> = {},
): 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]> = {},
): 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']> = {},
): 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,
}
}