2349 lines
89 KiB
TypeScript
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,
|
|
}
|
|
}
|