接入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

@@ -0,0 +1,536 @@
<template>
<div class="room-information">
<div
v-if="!roomInformation"
class="room-information__empty"
>
{{ t('taskV4.roomInformation.noDisplayModel') }}
</div>
<template v-else>
<div class="room-information__chips">
<span>{{ eventType || '-' }}</span>
<span>{{ bookingType || '-' }}</span>
</div>
<section
v-if="showChangeSummary"
class="room-information__panel room-information__panel--changes"
>
<h3>{{ t('taskV4.roomInformation.changeSummary') }}</h3>
<ul>
<li
v-for="(change, index) in visibleChangeSummary"
:key="`${change.field}-${index}`"
>
<span>{{ roomInformationFieldLabel(change.field) }}</span>
<strong>{{ formatRoomInformationValue(change.field, change.before, currentValues) }}</strong>
<em aria-hidden="true"></em>
<strong>{{ formatRoomInformationValue(change.field, change.after, finalValues) }}</strong>
</li>
</ul>
</section>
<section
v-if="showCurrentValues"
class="room-information__panel"
>
<h3>{{ t('taskV4.roomInformation.currentValues') }}</h3>
<dl
v-if="scalarEntries(currentValues).length"
class="room-information__grid"
>
<template
v-for="entry in scalarEntries(currentValues)"
:key="entry.key"
>
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
<dd>{{ entry.value }}</dd>
</template>
</dl>
<p
v-else-if="!roomItems(currentValues).length"
class="room-information__empty"
>
{{ t('taskV4.roomInformation.emptyValues') }}
</p>
<div
v-if="roomItems(currentValues).length"
class="room-information__room-items"
>
<h4>{{ t('taskV4.roomInformation.roomItems') }}</h4>
<table>
<thead>
<tr>
<th>{{ t('taskV4.roomInformation.roomTypeCode') }}</th>
<th>{{ t('taskV4.roomInformation.roomCount') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in roomItems(currentValues)"
:key="index"
>
<td>{{ formatRoomInformationValue('room_type_code', item.room_type_code, item) }}</td>
<td>{{ formatRoomInformationValue('room_count', item.room_count, item) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="room-information__panel">
<h3>{{ t('taskV4.roomInformation.finalValues') }}</h3>
<div class="room-information__derived">
<div
v-if="hasValue(finalValues.nights)"
class="room-information__derived-item"
>
<span>{{ t('taskV4.roomInformation.nights') }}</span>
<strong>{{ formatRoomInformationValue('nights', finalValues.nights, finalValues) }}</strong>
</div>
<label
v-if="showReadonlyBreakfast"
class="room-information__derived-item room-information__derived-item--checkbox"
>
<span>{{ t('taskV4.roomInformation.breakfastIncluded') }}</span>
<input
type="checkbox"
name="/room_information/final_values/breakfast_included"
:checked="finalValues.breakfast_included === true"
disabled
>
</label>
<div
v-if="hasValue(finalValues.group_booking_status) && !hasEditableField('/room_information/final_values/group_booking_status')"
class="room-information__derived-item"
>
<span>{{ t('taskV4.roomInformation.groupBookingStatus') }}</span>
<strong>{{ formatRoomInformationValue('group_booking_status', finalValues.group_booking_status, finalValues) }}</strong>
</div>
</div>
<ReservationV4TaskCardFieldRenderer
v-if="showEditableFields"
:fields="visibleFields"
:model-value="modelValue"
:read-only="readOnly"
:editable-keys="editableKeys"
:validation-errors="validationErrors"
:hotel-id="hotelId"
@update:model-value="emit('update:modelValue', $event)"
/>
<template v-else>
<dl
v-if="scalarEntries(finalValues).length"
class="room-information__grid"
>
<template
v-for="entry in scalarEntries(finalValues)"
:key="entry.key"
>
<dt>{{ roomInformationFieldLabel(entry.key) }}</dt>
<dd>{{ entry.value }}</dd>
</template>
</dl>
<p
v-else-if="!roomItems(finalValues).length"
class="room-information__empty"
>
{{ t('taskV4.roomInformation.emptyValues') }}
</p>
<div
v-if="roomItems(finalValues).length"
class="room-information__room-items"
>
<h4>{{ t('taskV4.roomInformation.roomItems') }}</h4>
<table>
<thead>
<tr>
<th>{{ t('taskV4.roomInformation.roomTypeCode') }}</th>
<th>{{ t('taskV4.roomInformation.roomCount') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(item, index) in roomItems(finalValues)"
:key="index"
>
<td>{{ formatRoomInformationValue('room_type_code', item.room_type_code, item) }}</td>
<td>{{ formatRoomInformationValue('room_count', item.room_count, item) }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import type {
ReservationRecord,
ReservationV4RoomInformationDisplayModel,
ReservationV4RoomInformationValues,
ReservationV4TaskCardResult,
} from '@/types/reservation'
import {
reservationV4FieldKey,
isReservationV4RoomInformationSafeField,
stringifyReservationV4SafeDisplayValue,
} from '@/utils/reservationV4FieldRules'
const props = withDefaults(defineProps<{
card: ReservationV4TaskCardResult
modelValue: ReservationRecord
readOnly: boolean
editableKeys: string[]
validationErrors?: Record<string, string>
hotelId?: string
}>(), {
validationErrors: () => ({}),
hotelId: undefined,
})
const emit = defineEmits<{
'update:modelValue': [value: ReservationRecord]
}>()
const { t } = useI18n()
const roomInformation = computed<ReservationV4RoomInformationDisplayModel | null>(() => {
const payload = props.card.display_payload
const value = isRecord(payload?.room_information) ? payload.room_information : null
return value as ReservationV4RoomInformationDisplayModel | null
})
const eventType = computed(() => roomInformation.value?.event_type ?? props.card.event_type ?? '')
const bookingType = computed(() => roomInformation.value?.booking_type ?? '')
const currentValues = computed(() => normalizeValues(roomInformation.value?.current_values))
const finalValues = computed(() => normalizeValues(roomInformation.value?.final_values))
const changeSummary = computed(() => roomInformation.value?.change_summary ?? [])
const visibleChangeSummary = computed(() => changeSummary.value.filter((change) =>
isVisibleRoomInformationChangeField(change.field),
))
const visibleFields = computed(() => props.card.fields.filter((field) =>
isReservationV4RoomInformationSafeField(field) && isVisibleRoomInformationField(field),
))
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
const showCurrentValues = computed(() =>
['CANCEL_BOOKING'].includes(eventType.value) && hasVisibleValues(currentValues.value),
)
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && visibleFields.value.length > 0)
const showReadonlyBreakfast = computed(() =>
hasValue(finalValues.value.breakfast_included) &&
(bookingType.value === 'GROUP' || !hasEditableField('/room_information/final_values/breakfast_included')),
)
const scalarFieldOrder = [
'group_block_name',
'fit_name',
'arrival_date',
'departure_date',
'nights',
'rate_code',
'breakfast_included',
'group_booking_status',
'block_id',
'confirmation_number',
]
function scalarEntries(values: ReservationV4RoomInformationValues): Array<{ key: string; value: string }> {
return scalarFieldOrder
.filter((key) => key in values && hasValue(values[key]))
.filter((key) => key !== 'group_booking_status_label')
.filter((key) => key !== 'breakfast_included' || !hasEditableField('/room_information/final_values/breakfast_included'))
.map((key) => ({
key,
value: formatRoomInformationValue(key, values[key], values),
}))
}
function roomItems(values: ReservationV4RoomInformationValues): ReservationRecord[] {
return Array.isArray(values.room_items)
? values.room_items.filter((item): item is ReservationRecord => isRecord(item))
: []
}
function hasEditableField(pointer: string): boolean {
return visibleFields.value.some((field) => reservationV4FieldKey(field) === pointer)
}
function isVisibleRoomInformationField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
const key = reservationV4FieldKey(field)
return !(bookingType.value === 'GROUP' && key === '/room_information/final_values/breakfast_included')
}
function isVisibleRoomInformationChangeField(field: unknown): boolean {
const key = typeof field === 'string' ? field.toLowerCase() : ''
return key !== 'adult' &&
key !== 'adults' &&
!key.endsWith('.adult') &&
!key.endsWith('.adults') &&
!key.endsWith('/adult') &&
!key.endsWith('/adults') &&
!key.includes('target_order') &&
!key.includes('locator_value')
}
function hasVisibleValues(values: ReservationV4RoomInformationValues): boolean {
return scalarEntries(values).length > 0 || roomItems(values).length > 0
}
function normalizeValues(value: unknown): ReservationV4RoomInformationValues {
return isRecord(value) ? value : {}
}
function roomInformationFieldLabel(field: unknown): string {
const key = typeof field === 'string' ? field : ''
const labelKey = roomInformationLabelKeys[key]
return labelKey ? t(labelKey) : key || '-'
}
function formatRoomInformationValue(
field: string,
value: unknown,
values: ReservationRecord,
): string {
if (field === 'group_booking_status') {
const statusLabel = values.group_booking_status_label
if (typeof statusLabel === 'string' && statusLabel.trim()) {
return statusLabel
}
}
if (typeof value === 'boolean') {
return value ? t('taskV4.roomInformation.yes') : t('taskV4.roomInformation.no')
}
return stringifyReservationV4SafeDisplayValue(
value,
t('taskV4.field.empty'),
t('taskV4.field.hidden'),
)
}
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim().length > 0
}
if (Array.isArray(value)) {
return value.length > 0
}
return true
}
function isRecord(value: unknown): value is ReservationRecord {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
const roomInformationLabelKeys: Record<string, string> = {
group_block_name: 'taskV4.roomInformation.groupBlockName',
fit_name: 'taskV4.roomInformation.fitName',
arrival_date: 'taskV4.roomInformation.arrivalDate',
departure_date: 'taskV4.roomInformation.departureDate',
nights: 'taskV4.roomInformation.nights',
rate_code: 'taskV4.roomInformation.rateCode',
breakfast_included: 'taskV4.roomInformation.breakfastIncluded',
group_booking_status: 'taskV4.roomInformation.groupBookingStatus',
block_id: 'taskV4.roomInformation.blockId',
confirmation_number: 'taskV4.roomInformation.confirmationNumber',
room_items: 'taskV4.roomInformation.roomItems',
room_type_code: 'taskV4.roomInformation.roomTypeCode',
room_count: 'taskV4.roomInformation.roomCount',
}
</script>
<style scoped>
.room-information {
display: grid;
gap: 16px;
}
.room-information__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.room-information__chips span {
border-radius: 999px;
background: var(--th-color-info-bg);
color: var(--th-color-blue-700);
font-size: 12px;
font-weight: 800;
padding: 4px 9px;
}
.room-information__panel {
display: grid;
gap: 12px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 14px;
}
.room-information__panel h3,
.room-information__room-items h4 {
margin: 0;
color: var(--th-color-slate-900);
font-size: 14px;
font-weight: 900;
}
.room-information__panel--changes {
border-color: color-mix(in srgb, var(--th-color-blue-600) 28%, var(--th-color-slate-200));
background: linear-gradient(180deg, var(--th-color-white), var(--th-color-info-bg));
}
.room-information__panel--changes ul {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
}
.room-information__panel--changes li {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(0, 1fr) auto minmax(0, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
color: var(--th-color-slate-700);
font-size: 13px;
list-style: none;
}
.room-information__panel--changes strong {
color: var(--th-color-slate-900);
overflow-wrap: anywhere;
}
.room-information__panel--changes em {
color: var(--th-color-blue-600);
font-style: normal;
font-weight: 900;
}
.room-information__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
margin: 0;
}
.room-information__grid dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.room-information__grid dd {
margin: 4px 0 0;
color: var(--th-color-slate-900);
font-size: 14px;
font-weight: 800;
overflow-wrap: anywhere;
}
.room-information__derived {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.room-information__derived-item {
display: inline-grid;
gap: 4px;
min-width: 120px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-slate-50);
padding: 9px 11px;
}
.room-information__derived-item span {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.room-information__derived-item strong {
color: var(--th-color-slate-900);
font-size: 14px;
font-weight: 900;
}
.room-information__derived-item--checkbox {
align-items: center;
grid-template-columns: 1fr auto;
}
.room-information__derived-item--checkbox span {
grid-column: 1 / -1;
}
.room-information__derived-item--checkbox input {
width: 18px;
height: 18px;
accent-color: var(--th-color-blue-600);
}
.room-information__room-items {
display: grid;
gap: 8px;
}
.room-information__room-items table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
font-size: 13px;
}
.room-information__room-items th,
.room-information__room-items td {
border-bottom: 1px solid var(--th-color-slate-200);
padding: 9px 10px;
text-align: left;
}
.room-information__room-items th {
background: var(--th-color-slate-50);
color: var(--th-color-slate-500);
font-weight: 900;
}
.room-information__room-items td {
color: var(--th-color-slate-900);
font-weight: 800;
}
.room-information__room-items tr:last-child td {
border-bottom: 0;
}
.room-information__empty {
margin: 0;
color: var(--th-color-slate-500);
font-size: 13px;
font-weight: 700;
}
@media (max-width: 760px) {
.room-information__grid,
.room-information__panel--changes li {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -15,6 +15,7 @@
<select
v-if="isEditable(field) && isSelectField(field)"
class="v4-field__control"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:disabled="selectDisabled(field)"
:value="fieldValue(field)"
@@ -34,15 +35,27 @@
v-else-if="isEditable(field) && isDateField(field)"
class="v4-field__control"
type="date"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isEditable(field) && isCheckboxField(field)"
class="v4-field__control v4-field__control--checkbox"
type="checkbox"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:checked="checkboxValue(field)"
@change="updateCheckboxField(field, $event)"
>
<input
v-else-if="isEditable(field) && isNumberField(field)"
class="v4-field__control"
type="number"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
@@ -51,6 +64,7 @@
<textarea
v-else-if="isEditable(field) && isTextAreaField(field)"
class="v4-field__control v4-field__control--textarea"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
rows="3"
@@ -61,16 +75,26 @@
v-else-if="isEditable(field)"
class="v4-field__control"
type="text"
:name="fieldKey(field)"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isCheckboxField(field)"
class="v4-field__control v4-field__control--checkbox"
type="checkbox"
:name="fieldKey(field)"
:checked="checkboxValue(field)"
disabled
>
<span
v-else
class="v4-field__static"
>
{{ stringifyV4Value(fieldValue(field)) }}
{{ staticFieldValue(field) }}
</span>
<small
@@ -209,13 +233,18 @@ function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return isSelectControl(field) && Boolean(reservationV4LookupKindForOptionsSource(field.options_source))
return isSelectControl(field) &&
(Boolean(reservationV4LookupKindForOptionsSource(field.options_source)) || fixedFieldOptions(field).length > 0)
}
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'DATE'
}
function isCheckboxField(field: ReservationV4TaskCardFieldResult): boolean {
return ['CHECKBOX', 'BOOLEAN'].includes(normalizeControlType(field.control_type))
}
function isNumberField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'NUMBER'
}
@@ -232,7 +261,7 @@ function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
label: lookupItemLabel(item),
item,
}))
: []
: fixedFieldOptions(field)
const exactItem = exactLookupItemForField(field)
if (exactItem && !options.some((option) => option.value === exactItem.code)) {
options.push({
@@ -267,6 +296,17 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
return stringifyV4Value(value)
}
function staticFieldValue(field: ReservationV4TaskCardFieldResult): string {
const value = fieldValue(field)
const matchedOption = fieldOptions(field).find((option) => option.value === value)
return matchedOption?.label ?? stringifyV4Value(value)
}
function checkboxValue(field: ReservationV4TaskCardFieldResult): boolean {
const value = readV4FieldValue(field, props.modelValue)
return value === true || value === 'true' || value === 1 || value === '1'
}
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
delete invalidLookupValues[reservationV4FieldKey(field)]
@@ -276,6 +316,15 @@ function updateField(field: ReservationV4TaskCardFieldResult, event: Event): voi
})
}
function updateCheckboxField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement
delete invalidLookupValues[reservationV4FieldKey(field)]
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.checked,
})
}
function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
@@ -389,6 +438,27 @@ function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
return ['SELECT', 'LOOKUP'].includes(normalizeControlType(field.control_type))
}
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const optionsSource = normalizeControlType(field.options_source)
if (optionsSource !== 'RESERVATION_V4_GROUP_BOOKING_STATUS_FIXED') {
return []
}
return [
{
value: 'TEN',
label: 'TEN-Tentative',
},
{
value: 'DEF',
label: 'DEF-Definite',
},
{
value: 'INQ',
label: 'INQ-Inquiry',
},
]
}
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
if (item.display_name && item.display_name !== item.code) {
return `${item.code} - ${item.display_name}`
@@ -629,6 +699,14 @@ function exactLookupKey(kind: ReservationV4LookupKind, code: string): string {
resize: vertical;
}
.v4-field__control--checkbox {
min-height: 18px;
width: 18px;
accent-color: var(--th-color-blue-600);
box-shadow: none;
padding: 0;
}
.v4-field__control[aria-invalid='true'] {
border-color: var(--th-color-danger);
box-shadow: 0 0 0 3px var(--th-color-danger-bg);

View File

@@ -1,5 +1,8 @@
<template>
<section class="th-section task-card-section">
<section
class="th-section task-card-section"
:data-testid="isRoomInformationCard ? 'room-information-card' : undefined"
>
<div class="task-card-section__header">
<div class="task-card-section__heading">
<h2 class="th-section-title">
@@ -31,7 +34,19 @@
</div>
</div>
<ReservationV4RoomInformationCard
v-if="isRoomInformationCard"
class="task-card-section__fields"
:card="card"
:model-value="modelValue"
:read-only="readOnly"
:editable-keys="editableKeys"
:validation-errors="validationErrors"
:hotel-id="hotelId"
@update:model-value="emit('update:modelValue', $event)"
/>
<ReservationV4TaskCardFieldRenderer
v-else
class="task-card-section__fields"
:fields="card.fields"
:model-value="modelValue"
@@ -43,7 +58,7 @@
/>
<details
v-if="safePayloadRows.length"
v-if="!isRoomInformationCard && safePayloadRows.length"
class="safe-payload"
>
<summary>
@@ -86,6 +101,7 @@
<label>
<span>{{ t('taskV4.confirmedOrderId') }}</span>
<input
name="v4_confirmed_order_id"
:value="reviewForm.confirmed_order_id"
:placeholder="t('taskV4.confirmedOrderIdPlaceholder')"
@input="updateReviewForm('confirmed_order_id', $event)"
@@ -107,7 +123,7 @@
:disabled="!canReview"
@click="emit('resolveReview')"
>
{{ submitting ? t('taskV4.resolvingReview') : t('taskV4.resolveReview') }}
{{ submitting ? t('taskV4.confirmingCard') : t('taskV4.confirmCard') }}
</button>
<RouterLink
v-if="boundOrderId"
@@ -148,6 +164,7 @@ import { RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ReservationStatusBadge from '@/components/reservation/ReservationStatusBadge.vue'
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
@@ -198,6 +215,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
const availabilityReason = computed(() => {
return props.card.availability.readonly_reason_message ??

View File

@@ -585,6 +585,29 @@ export default {
cardConfirmed: 'Card confirmed and detail refreshed.',
reviewResolved: 'Review submitted and detail refreshed.',
validationFailed: 'Fix current-card field errors first.',
roomInformation: {
noDisplayModel: 'Room Information display model is not returned yet.',
emptyValues: 'No fields to display.',
changeSummary: 'Change summary',
currentValues: 'Current values',
proposedValues: 'Proposed values',
finalValues: 'Final values',
groupBlockName: 'Group block name',
fitName: 'FIT name',
arrivalDate: 'Arrival date',
departureDate: 'Departure date',
nights: 'Nights',
rateCode: 'Rate Code',
breakfastIncluded: 'Breakfast included',
groupBookingStatus: 'Group booking status',
blockId: 'Block ID',
confirmationNumber: 'Confirmation no.',
roomItems: 'Room items',
roomTypeCode: 'Room type code',
roomCount: 'Room count',
yes: 'Yes',
no: 'No',
},
lookup: {
loading: 'Loading catalog',
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',

View File

@@ -585,6 +585,29 @@ export default {
cardConfirmed: 'ยืนยันการ์ดแล้วและรีเฟรชรายละเอียดแล้ว',
reviewResolved: 'ส่งผลตรวจสอบแล้วและรีเฟรชรายละเอียดแล้ว',
validationFailed: 'โปรดแก้ไขข้อมูลในการ์ดปัจจุบันก่อน',
roomInformation: {
noDisplayModel: 'ยังไม่ได้รับโมเดลแสดงผล Room Information',
emptyValues: 'ไม่มีฟิลด์ให้แสดง',
changeSummary: 'สรุปการเปลี่ยนแปลง',
currentValues: 'ค่าปัจจุบัน',
proposedValues: 'ค่าที่เสนอ',
finalValues: 'ค่าสุดท้าย',
groupBlockName: 'ชื่อกรุ๊ป',
fitName: 'ชื่อ FIT',
arrivalDate: 'วันเข้าพัก',
departureDate: 'วันออก',
nights: 'จำนวนคืน',
rateCode: 'Rate Code',
breakfastIncluded: 'รวมอาหารเช้า',
groupBookingStatus: 'สถานะกรุ๊ป',
blockId: 'Block ID',
confirmationNumber: 'เลขยืนยัน',
roomItems: 'รายละเอียดห้อง',
roomTypeCode: 'รหัสประเภทห้อง',
roomCount: 'จำนวนห้อง',
yes: 'ใช่',
no: 'ไม่ใช่',
},
lookup: {
loading: 'กำลังโหลดแค็ตตาล็อก',
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',

View File

@@ -585,6 +585,29 @@ export default {
cardConfirmed: '卡片已确认,详情已刷新。',
reviewResolved: '复核已提交,详情已刷新。',
validationFailed: '请先修正当前卡片字段。',
roomInformation: {
noDisplayModel: 'Room Information 展示模型暂未返回。',
emptyValues: '暂无可展示字段。',
changeSummary: '本次变更摘要',
currentValues: '当前值',
proposedValues: '建议值',
finalValues: '最终值',
groupBlockName: '团队名称',
fitName: '散客姓名',
arrivalDate: '入住日期',
departureDate: '离店日期',
nights: '晚数',
rateCode: 'Rate Code',
breakfastIncluded: '含早',
groupBookingStatus: '团队预订状态',
blockId: 'Block ID',
confirmationNumber: '确认号',
roomItems: '房型明细',
roomTypeCode: '房型代码',
roomCount: '房间数',
yes: '是',
no: '否',
},
lookup: {
loading: '目录加载中',
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',

View File

@@ -76,12 +76,12 @@ describe('reservationV4FieldRules', () => {
it('uses field_pointer for review resolution overrides', () => {
const fields = [
createV4Field('/business_fields/after/room_items/0/room_type_code', {
createV4Field('/room_information/final_values/room_items/0/room_type_code', {
value: '',
edit_scope: 'MANUAL_REVIEW_ONLY',
write_target: 'REVIEW_RESOLUTION_FIELD_OVERRIDES',
}),
createV4Field('/business_fields/after/room_items/0/room_type_raw', {
createV4Field('/room_information/final_values/room_items/0/room_type_raw', {
value: 'TWN',
editable: false,
write_target: 'NONE',
@@ -89,11 +89,11 @@ describe('reservationV4FieldRules', () => {
]
expect(buildReservationV4ReviewOverrides(fields, {
'/business_fields/after/room_items/0/room_type_code': 'TWN',
'/business_fields/after/room_items/0/room_type_raw': 'Twin',
'/room_information/final_values/room_items/0/room_type_code': 'TWN',
'/room_information/final_values/room_items/0/room_type_raw': 'Twin',
})).toEqual([
{
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',
},
])
@@ -118,12 +118,63 @@ describe('reservationV4FieldRules', () => {
})).toEqual([])
})
it('allows review overrides only for basic information and business fields', () => {
it('never submits Room Information derived or target locator fields', () => {
const fields = [
createV4Field('/room_information/final_values/group_block_name', {
value: 'GRP-001',
}),
createV4Field('/room_information/final_values/nights', {
value: 3,
}),
createV4Field('/room_information/final_values/adult', {
value: 2,
}),
createV4Field('/room_information/final_values/target_order/locator_value', {
value: 'RAW-GRP',
}),
]
expect(buildReservationV4ConfirmedPayload(fields, {
'/room_information/final_values/group_block_name': 'GRP-002',
'/room_information/final_values/nights': 9,
'/room_information/final_values/adult': 4,
'/room_information/final_values/target_order/locator_value': 'SHOULD_NOT_SEND',
})).toEqual({
room_information: {
final_values: {
group_block_name: 'GRP-002',
},
},
})
const reviewFields = fields.map((field) => ({
...field,
edit_scope: 'MANUAL_REVIEW_ONLY',
write_target: 'REVIEW_RESOLUTION_FIELD_OVERRIDES',
}))
expect(buildReservationV4ReviewOverrides(reviewFields, {
'/room_information/final_values/group_block_name': 'GRP-002',
'/room_information/final_values/nights': 9,
'/room_information/final_values/adult': 4,
'/room_information/final_values/target_order/locator_value': 'SHOULD_NOT_SEND',
})).toEqual([
{
field_pointer: '/room_information/final_values/group_block_name',
value: 'GRP-002',
},
])
})
it('allows review overrides only for basic information, room information and legacy business fields', () => {
const fields = [
createV4Field('/source_message/subject', {
edit_scope: 'MANUAL_REVIEW_ONLY',
write_target: 'REVIEW_RESOLUTION_FIELD_OVERRIDES',
}),
createV4Field('/room_information/final_values/arrival_date', {
edit_scope: 'MANUAL_REVIEW_ONLY',
write_target: 'REVIEW_RESOLUTION_FIELD_OVERRIDES',
}),
createV4Field('/business_fields/after/room_items/0/room_type_code', {
edit_scope: 'MANUAL_REVIEW_ONLY',
write_target: 'REVIEW_RESOLUTION_FIELD_OVERRIDES',
@@ -132,8 +183,13 @@ describe('reservationV4FieldRules', () => {
expect(buildReservationV4ReviewOverrides(fields, {
'/source_message/subject': 'SHOULD_NOT_SEND',
'/room_information/final_values/arrival_date': '2026-07-26',
'/business_fields/after/room_items/0/room_type_code': 'TWN',
})).toEqual([
{
field_pointer: '/room_information/final_values/arrival_date',
value: '2026-07-26',
},
{
field_pointer: '/business_fields/after/room_items/0/room_type_code',
value: 'TWN',

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: {

View File

@@ -363,6 +363,47 @@ export interface ReservationV4TaskCardFieldResult {
control_hint: string | null
}
export interface ReservationV4RoomInformationRoomItem extends ReservationRecord {
room_type_code?: string | null
room_count?: number | string | null
}
export interface ReservationV4RoomInformationValues extends ReservationRecord {
group_block_name?: string | null
fit_name?: string | null
arrival_date?: string | null
departure_date?: string | null
nights?: number | string | null
rate_code?: string | null
breakfast_included?: boolean | null
group_booking_status?: string | null
group_booking_status_label?: string | null
block_id?: string | null
confirmation_number?: string | null
room_items?: ReservationV4RoomInformationRoomItem[] | null
}
export interface ReservationV4RoomInformationChange {
field: string
before: unknown
after: unknown
}
export interface ReservationV4RoomInformationStatusOption {
code: string
label: string
}
export interface ReservationV4RoomInformationDisplayModel extends ReservationRecord {
event_type: string | null
booking_type: string | null
current_values: ReservationV4RoomInformationValues
proposed_values: ReservationV4RoomInformationValues
final_values: ReservationV4RoomInformationValues
change_summary: ReservationV4RoomInformationChange[]
group_booking_status_options: ReservationV4RoomInformationStatusOption[]
}
export interface ReservationV4TaskCardResult {
card_id: string
card_type: ReservationV4CardType

View File

@@ -18,7 +18,8 @@ const reviewWriteTargets = new Set([
'FIELD_OVERRIDES',
])
const readonlyControlTypes = new Set(['READONLY', 'FILE', 'WORKFLOW_STATE', 'STRUCTURED_TABLE'])
const reviewWritablePointerPrefixes = ['/basic_information/', '/business_fields/']
const reviewWritablePointerPrefixes = ['/basic_information/', '/room_information/final_values/', '/business_fields/']
const roomInformationFieldPrefix = '/room_information/final_values/'
const hiddenDisplayValue = Symbol('reservation-v4-hidden-display-value')
export type ReservationV4FieldErrorMap = Record<string, string>
@@ -37,6 +38,11 @@ export function buildReservationV4InitialFieldValues(
}, {})
}
export function isReservationV4RoomInformationSafeField(field: ReservationV4TaskCardFieldResult): boolean {
const pointer = field.field_pointer || fieldPathToPointer(field.field_path)
return pointer.startsWith(roomInformationFieldPrefix) && !isForbiddenRoomInformationPointer(pointer)
}
export function buildReservationV4ConfirmedPayload(
fields: ReservationV4TaskCardFieldResult[],
values: ReservationRecord,
@@ -78,6 +84,9 @@ export function isReservationV4ConfirmWritableField(field: ReservationV4TaskCard
if (!isReservationV4EditableField(field)) {
return false
}
if (isRoomInformationReadOnlyDerivedField(field)) {
return false
}
if (isUnsafeV4Field(field)) {
return false
}
@@ -92,6 +101,9 @@ export function isReservationV4ReviewWritableField(field: ReservationV4TaskCardF
if (!isReservationV4EditableField(field)) {
return false
}
if (isRoomInformationReadOnlyDerivedField(field)) {
return false
}
const pointer = field.field_pointer || fieldPathToPointer(field.field_path)
if (!reviewWritablePointerPrefixes.some((prefix) => pointer.startsWith(prefix))) {
return false
@@ -352,6 +364,25 @@ function isUnsafeV4Field(field: ReservationV4TaskCardFieldResult): boolean {
return isReservationV4UnsafeDisplayKey(field.field_path) || isReservationV4UnsafeDisplayKey(field.field_pointer)
}
function isRoomInformationReadOnlyDerivedField(field: ReservationV4TaskCardFieldResult): boolean {
const pointer = field.field_pointer || fieldPathToPointer(field.field_path)
if (!pointer.startsWith(roomInformationFieldPrefix)) {
return false
}
return isForbiddenRoomInformationPointer(pointer)
}
function isForbiddenRoomInformationPointer(pointer: string): boolean {
const normalizedPointer = pointer.toLowerCase()
return normalizedPointer.endsWith('/nights') ||
normalizedPointer.endsWith('/adult') ||
normalizedPointer.endsWith('/adults') ||
normalizedPointer.endsWith('/block_id') ||
normalizedPointer.endsWith('/confirmation_number') ||
normalizedPointer.includes('/target_order') ||
normalizedPointer.includes('/locator_value')
}
function isUnsafeUrlLikeValue(value: string): boolean {
const trimmedValue = value.trim()
return /^(https?:\/\/|oss:\/\/|s3:\/\/)/i.test(trimmedValue)

View File

@@ -213,6 +213,7 @@ import {
buildReservationV4InitialFieldValues,
buildReservationV4ReviewOverrides,
isReservationV4ConfirmWritableField,
isReservationV4RoomInformationSafeField,
isReservationV4ReviewWritableField,
mapReservationV4BackendDetailsToFields,
reservationV4FieldKey,
@@ -341,7 +342,8 @@ function canReviewCard(card: ReservationV4TaskCardResult): boolean {
}
async function submitConfirm(card: ReservationV4TaskCardResult): Promise<void> {
const writableFields = card.fields.filter(isReservationV4ConfirmWritableField)
const submissionFields = submissionFieldsForCard(card)
const writableFields = submissionFields.filter(isReservationV4ConfirmWritableField)
const localErrors = validateReservationV4Fields(writableFields, cardValues.value[card.card_id] ?? {})
cardFieldErrors.value = {
...cardFieldErrors.value,
@@ -367,7 +369,7 @@ async function submitConfirm(card: ReservationV4TaskCardResult): Promise<void> {
try {
const result = await confirmReservationV4OrderTaskCard(orderTaskId.value, card.card_id, {
version: card.version,
confirmed_payload: buildReservationV4ConfirmedPayload(card.fields, cardValues.value[card.card_id] ?? {}),
confirmed_payload: buildReservationV4ConfirmedPayload(submissionFields, cardValues.value[card.card_id] ?? {}),
})
applyDetail(result)
cardSuccessMessages.value = {
@@ -383,7 +385,17 @@ async function submitConfirm(card: ReservationV4TaskCardResult): Promise<void> {
}
async function submitReview(card: ReservationV4TaskCardResult): Promise<void> {
const writableFields = card.fields.filter(isReservationV4ReviewWritableField)
const form = reviewForms.value[card.card_id] ?? { confirmed_order_id: '', reason: '' }
if (requiresConfirmedOrderId() && !form.confirmed_order_id.trim()) {
cardActionErrors.value = {
...cardActionErrors.value,
[card.card_id]: [t('taskV4.confirmedOrderRequired')],
}
return
}
const submissionFields = submissionFieldsForCard(card)
const writableFields = submissionFields.filter(isReservationV4ReviewWritableField)
const localErrors = validateReservationV4Fields(writableFields, cardValues.value[card.card_id] ?? {})
cardFieldErrors.value = {
...cardFieldErrors.value,
@@ -397,15 +409,6 @@ async function submitReview(card: ReservationV4TaskCardResult): Promise<void> {
return
}
const form = reviewForms.value[card.card_id] ?? { confirmed_order_id: '', reason: '' }
if (requiresConfirmedOrderId() && !form.confirmed_order_id.trim()) {
cardActionErrors.value = {
...cardActionErrors.value,
[card.card_id]: [t('taskV4.confirmedOrderRequired')],
}
return
}
submittingCardId.value = card.card_id
cardActionErrors.value = {
...cardActionErrors.value,
@@ -420,7 +423,7 @@ async function submitReview(card: ReservationV4TaskCardResult): Promise<void> {
version: card.version,
confirmed_order_id: form.confirmed_order_id.trim() || undefined,
reason: form.reason.trim() || undefined,
field_overrides: buildReservationV4ReviewOverrides(card.fields, cardValues.value[card.card_id] ?? {}),
field_overrides: buildReservationV4ReviewOverrides(submissionFields, cardValues.value[card.card_id] ?? {}),
})
applyDetail(result)
cardSuccessMessages.value = {
@@ -444,15 +447,43 @@ function requiresConfirmedOrderId(): boolean {
}
function editableFieldKeys(card: ReservationV4TaskCardResult): string[] {
const fields = submissionFieldsForCard(card)
const writableFields = card.card_status === 'REVIEW_REQUIRED'
? card.fields.filter(isReservationV4ReviewWritableField)
: card.fields.filter(isReservationV4ConfirmWritableField)
? fields.filter(isReservationV4ReviewWritableField)
: fields.filter(isReservationV4ConfirmWritableField)
return writableFields.map(reservationV4FieldKey)
}
function submissionFieldsForCard(card: ReservationV4TaskCardResult): ReservationV4TaskCardResult['fields'] {
return card.card_type === 'ROOM_INFORMATION'
? card.fields.filter((field) =>
isReservationV4RoomInformationSafeField(field) && !isGroupRoomInformationBreakfastField(card, field),
)
: card.fields
}
function isGroupRoomInformationBreakfastField(
card: ReservationV4TaskCardResult,
field: ReservationV4TaskCardResult['fields'][number],
): boolean {
return roomInformationBookingType(card) === 'GROUP' &&
reservationV4FieldKey(field) === '/room_information/final_values/breakfast_included'
}
function roomInformationBookingType(card: ReservationV4TaskCardResult): string {
const payload = card.display_payload
const roomInformation = isRecord(payload?.room_information) ? payload.room_information : null
const bookingType = roomInformation?.booking_type
return typeof bookingType === 'string' ? bookingType.toUpperCase() : ''
}
function isRecord(value: unknown): value is ReservationRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function applyCardActionError(card: ReservationV4TaskCardResult, error: unknown, fallback: string): void {
const formatted = formatApiError(error, fallback)
const mappedErrors = mapReservationV4BackendDetailsToFields(formatted.details, card.fields)
const mappedErrors = mapReservationV4BackendDetailsToFields(formatted.details, submissionFieldsForCard(card))
cardFieldErrors.value = {
...cardFieldErrors.value,
[card.card_id]: mappedErrors.fieldErrors,