接入V4房型信息展示模型

This commit is contained in:
andy
2026-07-21 09:04:13 +07:00
parent 1d593f05fd
commit 8d181ee8ad
21 changed files with 1390 additions and 77 deletions

View File

@@ -14,6 +14,7 @@ import type {
} 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')>()
@@ -98,6 +99,7 @@ describe('reservation V4 pages', () => {
it('keeps V4 task card layout classes for styled card metadata and payload summary', async () => {
const detail = createOrderTaskDetail()
detail.business_cards[0]!.card_type = 'TRACE_RESERVATION_NOTES'
detail.business_cards[0]!.display_payload = {
booking_scenario: 'STANDARD',
relevant_message_excerpt: 'Please keep this visible.',
@@ -148,7 +150,318 @@ describe('reservation V4 pages', () => {
expect(wrapper.text()).not.toContain('oss://bucket')
})
it('submits V4 review resolution with field_pointer overrides and confirmed order id', async () => {
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',
}),
]
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()).not.toContain('target_order')
expect(roomCard.text()).not.toContain('Adult')
expect(roomCard.text()).not.toContain('Legacy Room Type')
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"]').element as HTMLInputElement).value)
.toBe('2026-07-26')
expect((roomCard.find('input[name="/room_information/final_values/departure_date"]').element as HTMLInputElement).value)
.toBe('2026-07-29')
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: 'GROUP',
group_booking_status: 'DEF',
room_items: [
{
room_type_code: 'TWN',
room_count: 2,
},
],
},
},
},
})
})
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.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: 'GROUP',
breakfast_included: true,
group_booking_status: 'TEN',
group_booking_status_label: 'TEN-Tentative',
room_items: [
{
room_type_code: 'TWN',
room_count: 2,
},
],
},
proposed_values: {
arrival_date: '2026-07-27',
departure_date: '2026-07-31',
room_items: [
{
room_type_code: 'DBL',
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: 'GROUP',
breakfast_included: true,
group_booking_status: 'TEN',
group_booking_status_label: 'TEN-Tentative',
room_items: [
{
room_type_code: 'DBL',
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(zhCN.taskV4.roomInformation.changeSummary)
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: 'GROUP',
breakfast_included: true,
group_booking_status: 'DEF',
group_booking_status_label: 'DEF-Definite',
room_items: [
{
room_type_code: 'KING',
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: 'GROUP',
breakfast_included: true,
group_booking_status: 'DEF',
group_booking_status_label: 'DEF-Definite',
room_items: [
{
room_type_code: 'KING',
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(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('KING')
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: {
@@ -175,12 +488,11 @@ describe('reservation V4 pages', () => {
)
await flushPromises()
const selects = wrapper.findAll('select')
await selects[1]!.setValue('TWN')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('order-2001')
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('TWN')
await wrapper.find('input[name="v4_confirmed_order_id"]').setValue('order-2001')
await wrapper.find('textarea').setValue('confirmed by email evidence')
await wrapper.findAll('button').find((button) => button.text().includes('提交复核'))?.trigger('click')
await roomCard.find('button').trigger('click')
await flushPromises()
expect(service.resolveReservationV4OrderTaskCardReview).toHaveBeenCalledWith('9001', 'card-room', {
@@ -189,7 +501,7 @@ describe('reservation V4 pages', () => {
reason: 'confirmed by email evidence',
field_overrides: [
{
field_pointer: '/business_fields/after/room_items/0/room_type_code',
field_pointer: '/room_information/final_values/room_items/0/room_type_code',
value: 'TWN',
},
],
@@ -365,7 +677,7 @@ describe('reservation V4 pages', () => {
)
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('提交复核'))?.trigger('click')
await wrapper.find('[data-testid="room-information-card"]').find('button').trigger('click')
await flushPromises()
expect(service.resolveReservationV4OrderTaskCardReview).not.toHaveBeenCalled()
@@ -534,11 +846,24 @@ function mockCatalogLookups() {
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [],
items: [
{
code: 'GROUP',
display_name: 'Group Rate',
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: 0,
total: 1,
},
warnings: [],
})
@@ -549,6 +874,10 @@ function createOrderTaskDetail(options: {
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
basicAccountRequired?: boolean
@@ -586,24 +915,20 @@ function createOrderTaskDetail(options: {
}),
],
})
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,
}),
fields: [
createField('/business_fields/after/room_items/0/room_type_code', {
display_name: 'Room type',
value: '',
options_source: 'RESERVATION_V4_ROOM_TYPE_CATALOG',
control_type: 'SELECT',
edit_scope: options.businessCardStatus === 'REVIEW_REQUIRED' ? 'MANUAL_REVIEW_ONLY' : 'NORMAL_TASK',
write_target: options.businessCardStatus === 'REVIEW_REQUIRED'
? 'REVIEW_RESOLUTION_FIELD_OVERRIDES'
: 'CONFIRMED_PAYLOAD_JSON',
}),
],
display_payload: options.businessDisplayPayload ?? createRoomInformationDisplayPayload({
event_type: businessEventType,
booking_type: businessBookingType,
}),
fields: options.businessFields ?? createRoomInformationFields(options.businessCardStatus === 'REVIEW_REQUIRED'),
})
return {
order_task: {
@@ -661,6 +986,132 @@ function createOrderTaskDetail(options: {
}
}
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: 'GROUP',
breakfast_included: true,
group_booking_status: 'TEN',
group_booking_status_label: 'TEN-Tentative',
room_items: [
{
room_type_code: 'TWN',
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: 'GROUP',
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 ? '' : 'TWN',
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: {