接入V4房型信息展示模型
This commit is contained in:
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
|
||||
|
||||
@@ -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: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user