实现V4 Trace事项卡前端展示

This commit is contained in:
andy
2026-07-21 18:15:19 +07:00
parent 38661257d1
commit f3b6adda17
10 changed files with 931 additions and 7 deletions

View File

@@ -439,6 +439,13 @@ function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
}
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const backendOptions = (field.fixed_options ?? []).map((option) => ({
value: option.value,
label: option.label || option.value,
}))
if (backendOptions.length) {
return backendOptions
}
const optionsSource = normalizeControlType(field.options_source)
if (optionsSource !== 'RESERVATION_V4_GROUP_BOOKING_STATUS_FIXED') {
return []

View File

@@ -58,6 +58,16 @@
:card="card"
:source-message-id="sourceMessageId"
/>
<ReservationV4TraceCard
v-else-if="isTraceCard"
: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"
@@ -72,7 +82,7 @@
</template>
<details
v-if="!isRoomInformationCard && !isRoomingListCard && safePayloadRows.length"
v-if="!isRoomInformationCard && !isRoomingListCard && !isTraceCard && safePayloadRows.length"
class="safe-payload"
>
<summary>
@@ -124,6 +134,7 @@
<label>
<span>{{ t('taskV4.reviewReason') }}</span>
<textarea
name="v4_review_reason"
:value="reviewForm.reason"
:placeholder="t('taskV4.reviewReasonPlaceholder')"
rows="3"
@@ -182,6 +193,7 @@ import ReservationV4PaymentAttachmentPreview from '@/components/reservation/Rese
import ReservationV4RoomInformationCard from '@/components/reservation/ReservationV4RoomInformationCard.vue'
import ReservationV4RoomingListCard from '@/components/reservation/ReservationV4RoomingListCard.vue'
import ReservationV4TaskCardFieldRenderer from '@/components/reservation/ReservationV4TaskCardFieldRenderer.vue'
import ReservationV4TraceCard from '@/components/reservation/ReservationV4TraceCard.vue'
import type { ReservationRecord, ReservationV4TaskCardResult } from '@/types/reservation'
import { formatReservationReadonlyReason } from '@/utils/reservationDisplay'
import {
@@ -242,6 +254,7 @@ const { t } = useI18n()
const isRoomInformationCard = computed(() => props.card.card_type === 'ROOM_INFORMATION')
const isPaymentCard = computed(() => props.card.card_type === 'PAYMENT')
const isRoomingListCard = computed(() => props.card.card_type === 'ROOMING_LIST')
const isTraceCard = computed(() => props.card.card_type === 'TRACE_RESERVATION_NOTES')
const visibleFields = computed(() =>
isPaymentCard.value
? props.card.fields.filter((field) => !isPaymentAttachmentIdField(field))

View File

@@ -0,0 +1,543 @@
<template>
<section
class="trace-card"
data-testid="trace-card"
>
<article
v-for="item in traceItems"
:key="item.index"
class="trace-item"
>
<header class="trace-item__header">
<span>{{ t('taskV4.trace.itemLabel', { index: item.index + 1 }) }}</span>
<strong>{{ item.type === 'EXTRA_BED' ? t('taskV4.trace.extraBedTitle') : t('taskV4.trace.generalTitle') }}</strong>
</header>
<div
v-if="item.type === 'EXTRA_BED'"
class="trace-item__action"
>
<span>{{ t('taskV4.trace.action') }}</span>
<strong>SET EXTRA BED</strong>
</div>
<div class="trace-item__fields">
<label
v-for="field in fieldsForItem(item)"
:key="fieldKey(field)"
class="trace-field"
:class="{ 'trace-field--readonly': !isEditable(field) }"
>
<span class="trace-field__label">
{{ field.display_name }}
<sup v-if="field.required">*</sup>
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
</span>
<select
v-if="isEditable(field) && isSelectField(field)"
class="trace-field__control"
:name="fieldKey(field)"
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
:disabled="selectDisabled(field)"
:value="fieldValue(field)"
@change="updateField(field, $event)"
>
<option value="">{{ t('taskV4.field.selectPlaceholder') }}</option>
<option
v-for="option in fieldOptions(field)"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<textarea
v-else-if="isEditable(field) && isTextField(field)"
class="trace-field__control trace-field__control--textarea"
:name="fieldKey(field)"
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
:value="fieldValue(field)"
rows="3"
@input="updateField(field, $event)"
/>
<input
v-else-if="isEditable(field) && isPositiveIntegerField(field)"
class="trace-field__control"
type="number"
min="1"
step="1"
inputmode="numeric"
:name="fieldKey(field)"
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<input
v-else-if="isEditable(field)"
class="trace-field__control"
type="text"
:name="fieldKey(field)"
:aria-invalid="visibleFieldErrors(field).length ? 'true' : 'false'"
:value="fieldValue(field)"
@input="updateField(field, $event)"
>
<span
v-else
class="trace-field__static"
>
{{ staticFieldValue(field) }}
</span>
<small
v-if="field.control_hint"
class="trace-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint"
class="trace-field__hint"
>{{ hint }}</small>
<small
v-for="error in visibleFieldErrors(field)"
:key="error"
class="trace-field__error"
>{{ error }}</small>
</label>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { fetchReservationV4RoomTypeLookups } from '@/services/reservationService'
import type {
ReservationRecord,
ReservationV4CatalogLookupItem,
ReservationV4CatalogLookupResult,
ReservationV4TaskCardFieldResult,
ReservationV4TaskCardResult,
} from '@/types/reservation'
import {
isReservationV4EditableField,
isReservationV4TraceSafeField,
fieldPathToPointer,
readV4FieldValue,
reservationV4FieldKey,
reservationV4LookupKindForOptionsSource,
stringifyReservationV4SafeDisplayValue,
} from '@/utils/reservationV4FieldRules'
type TraceItemType = 'GENERAL' | 'EXTRA_BED'
type TraceItem = {
index: number
type: TraceItemType
}
type FieldOption = {
value: string
label: string
}
const props = defineProps<{
card: ReservationV4TaskCardResult
modelValue: ReservationRecord
readOnly: boolean
editableKeys: string[]
validationErrors?: Record<string, string>
hotelId?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: ReservationRecord]
}>()
const { t } = useI18n()
const roomTypeLookup = reactive<{
loading: boolean
error: string
result: ReservationV4CatalogLookupResult | null
}>({
loading: false,
error: '',
result: null,
})
let roomTypeLookupRequestVersion = 0
const traceFields = computed(() => props.card.fields.filter(isReservationV4TraceSafeField))
const hasRoomTypeLookupField = computed(() =>
traceFields.value.some((field) => reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE'),
)
const traceItems = computed<TraceItem[]>(() => {
const itemIndexes = [...new Set(traceFields.value.map(fieldIndex).filter((index) => index >= 0))]
return itemIndexes.sort((left, right) => left - right).map((index) => ({
index,
type: traceItemType(index),
}))
})
watch(
[hasRoomTypeLookupField, () => props.hotelId],
() => {
if (hasRoomTypeLookupField.value) {
void loadRoomTypeLookup()
} else {
roomTypeLookupRequestVersion += 1
roomTypeLookup.loading = false
roomTypeLookup.error = ''
roomTypeLookup.result = null
}
},
{ immediate: true },
)
function fieldsForItem(item: TraceItem): ReservationV4TaskCardFieldResult[] {
const preferredOrder = item.type === 'EXTRA_BED'
? ['target_room_type_code', 'extra_bed_room_count', 'department_code']
: ['text', 'department_code']
return preferredOrder
.map((suffix) => traceFields.value.find((field) => fieldPointer(field) === `/trace_items/${item.index}/${suffix}`))
.filter((field): field is ReservationV4TaskCardFieldResult => Boolean(field))
}
function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
const key = fieldKey(field)
return !props.readOnly &&
props.editableKeys.includes(key) &&
isReservationV4EditableField(field)
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return fixedFieldOptions(field).length > 0 ||
isDepartmentField(field) ||
reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE'
}
function isTextField(field: ReservationV4TaskCardFieldResult): boolean {
return fieldPointer(field).endsWith('/text')
}
function isPositiveIntegerField(field: ReservationV4TaskCardFieldResult): boolean {
return fieldPointer(field).endsWith('/extra_bed_room_count')
}
function isDepartmentField(field: ReservationV4TaskCardFieldResult): boolean {
return fieldPointer(field).endsWith('/department_code')
}
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
}
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
const value = readV4FieldValue(field, props.modelValue)
if (typeof value === 'number') {
return value
}
if (typeof value === 'string') {
return value
}
return stringifyValue(value)
}
function staticFieldValue(field: ReservationV4TaskCardFieldResult): string {
const value = String(fieldValue(field))
return fieldOptions(field).find((option) => option.value === value)?.label ?? stringifyValue(value)
}
function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
if (reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE') {
return roomTypeOptions(field)
}
const options = fixedFieldOptions(field)
if (!options.length && isDepartmentField(field)) {
const currentValue = String(readV4FieldValue(field, props.modelValue) || '')
return currentValue
? [{
value: currentValue,
label: currentValue,
}]
: []
}
return options
}
function fixedFieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
return (field.fixed_options ?? []).map((option) => ({
value: option.value,
label: option.label || option.value,
}))
}
function roomTypeOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const options = (roomTypeLookup.result?.items ?? []).map((item) => ({
value: item.code,
label: lookupItemLabel(item),
}))
const currentValue = String(readV4FieldValue(field, props.modelValue) || '')
if (currentValue && !options.some((option) => option.value === currentValue)) {
options.push({
value: currentValue,
label: currentValue,
})
}
return options
}
function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
if (isDepartmentField(field)) {
return fixedFieldOptions(field).length === 0
}
return reservationV4LookupKindForOptionsSource(field.options_source) === 'ROOM_TYPE' &&
(roomTypeLookup.loading || Boolean(roomTypeLookup.error))
}
function lookupHints(field: ReservationV4TaskCardFieldResult): string[] {
if (reservationV4LookupKindForOptionsSource(field.options_source) !== 'ROOM_TYPE') {
return []
}
if (roomTypeLookup.loading) {
return [t('taskV4.lookup.loading')]
}
if (roomTypeLookup.error) {
return [roomTypeLookup.error]
}
return []
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [
...(field.validation_errors ?? []),
props.validationErrors?.[fieldKey(field)] ?? '',
].filter(Boolean)
}
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
emit('update:modelValue', {
...props.modelValue,
[fieldKey(field)]: inputValue(field, target.value),
})
}
function inputValue(field: ReservationV4TaskCardFieldResult, value: string): string | number {
if (!isPositiveIntegerField(field)) {
return value
}
if (value.trim() === '') {
return ''
}
const numericValue = Number(value)
return Number.isFinite(numericValue) ? numericValue : value
}
async function loadRoomTypeLookup(): Promise<void> {
const requestVersion = ++roomTypeLookupRequestVersion
roomTypeLookup.loading = true
roomTypeLookup.error = ''
try {
const result = await fetchReservationV4RoomTypeLookups({
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
page_num: 1,
page_size: 100,
})
if (requestVersion === roomTypeLookupRequestVersion) {
roomTypeLookup.result = {
...result,
items: result.items ?? [],
warnings: result.warnings ?? [],
}
}
} catch {
if (requestVersion === roomTypeLookupRequestVersion) {
roomTypeLookup.error = t('taskV4.lookup.error')
}
} finally {
if (requestVersion === roomTypeLookupRequestVersion) {
roomTypeLookup.loading = false
}
}
}
function traceItemType(index: number): TraceItemType {
const payloadItem = payloadTraceItem(index)
if (readString(payloadItem?.item_type).toUpperCase() === 'EXTRA_BED') {
return 'EXTRA_BED'
}
return traceFields.value.some((field) =>
fieldPointer(field) === `/trace_items/${index}/target_room_type_code` ||
fieldPointer(field) === `/trace_items/${index}/extra_bed_room_count`,
)
? 'EXTRA_BED'
: 'GENERAL'
}
function payloadTraceItem(index: number): ReservationRecord | null {
const traceItemsValue = props.card.display_payload?.trace_items
if (!Array.isArray(traceItemsValue)) {
return null
}
const item = traceItemsValue[index]
return isRecord(item) ? item : null
}
function fieldIndex(field: ReservationV4TaskCardFieldResult): number {
const match = fieldPointer(field).match(/^\/trace_items\/(\d+)\//)
return match ? Number(match[1]) : -1
}
function fieldPointer(field: ReservationV4TaskCardFieldResult): string {
return field.field_pointer || fieldPathToPointer(field.field_path)
}
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
if (item.display_name && item.display_name !== item.code) {
return `${item.code} - ${item.display_name}`
}
return item.code
}
function stringifyValue(value: unknown): string {
return stringifyReservationV4SafeDisplayValue(
value,
t('taskV4.field.empty'),
t('taskV4.field.hidden'),
)
}
function readString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function isRecord(value: unknown): value is ReservationRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
</script>
<style scoped>
.trace-card {
display: grid;
gap: 14px;
margin: 0 20px 18px;
}
.trace-item {
display: grid;
gap: 14px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-slate-50);
padding: 14px;
}
.trace-item__header,
.trace-item__action {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.trace-item__header span,
.trace-item__action span {
color: var(--th-color-blue-600);
font-size: 12px;
font-weight: 900;
}
.trace-item__header strong,
.trace-item__action strong {
color: var(--th-color-slate-900);
font-size: 14px;
overflow-wrap: anywhere;
}
.trace-item__action {
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 10px 12px;
}
.trace-item__fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.trace-field {
display: grid;
gap: 7px;
}
.trace-field__label {
display: flex;
align-items: center;
gap: 6px;
color: var(--th-color-slate-700);
font-size: 12px;
font-weight: 800;
}
.trace-field__label sup {
color: var(--th-color-danger);
}
.trace-field__label em {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-500);
font-size: 11px;
font-style: normal;
padding: 2px 6px;
}
.trace-field__control,
.trace-field__static {
min-height: 38px;
width: 100%;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: var(--th-color-slate-900);
font: inherit;
padding: 9px 10px;
}
.trace-field__control--textarea {
resize: vertical;
}
.trace-field__control[aria-invalid='true'] {
border-color: var(--th-color-danger);
box-shadow: 0 0 0 3px var(--th-color-danger-bg);
}
.trace-field__static {
display: block;
background: var(--th-color-white);
overflow-wrap: anywhere;
}
.trace-field__hint {
color: var(--th-color-slate-500);
font-size: 12px;
}
.trace-field__error {
color: var(--th-color-danger);
font-size: 12px;
font-weight: 800;
}
@media (max-width: 760px) {
.trace-item__fields {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -630,6 +630,13 @@ export default {
description: 'The current source message contains a Rooming List item that needs manual handling.',
confirmHint: 'This first version only confirms that the item has been handled manually. Guest details and attachment content are not displayed or processed here.',
},
trace: {
title: 'Trace notes',
itemLabel: 'Item {index}',
generalTitle: 'General note',
extraBedTitle: 'Extra bed note',
action: 'Fixed action',
},
lookup: {
loading: 'Loading catalog',
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',

View File

@@ -630,6 +630,13 @@ export default {
description: 'ข้อความต้นทางปัจจุบันมีรายการ Rooming List ที่ต้องให้เจ้าหน้าที่จัดการ',
confirmHint: 'เวอร์ชันแรกนี้ใช้ยืนยันว่ารายการได้รับการจัดการด้วยคนแล้วเท่านั้น ไม่แสดงหรือประมวลผลรายชื่อแขกและเนื้อหาไฟล์แนบในหน้านี้',
},
trace: {
title: 'หมายเหตุ Trace',
itemLabel: 'รายการ {index}',
generalTitle: 'หมายเหตุทั่วไป',
extraBedTitle: 'รายการเตียงเสริม',
action: 'การดำเนินการคงที่',
},
lookup: {
loading: 'กำลังโหลดแค็ตตาล็อก',
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',

View File

@@ -630,6 +630,13 @@ export default {
description: '当前来源消息包含需要人工处理的 Rooming List 事项,请在酒店内部流程处理后确认该卡片。',
confirmHint: '第一版仅确认该事项已由人工处理,不展示或处理名单明细和附件内容。',
},
trace: {
title: 'Trace / 追踪备注事项',
itemLabel: '事项 {index}',
generalTitle: '普通事项',
extraBedTitle: '加床事项',
action: '固定动作',
},
lookup: {
loading: '目录加载中',
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',

View File

@@ -104,7 +104,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]!.card_type = 'VOUCHER'
detail.business_cards[0]!.display_payload = {
booking_scenario: 'STANDARD',
relevant_message_excerpt: 'Please keep this visible.',
@@ -514,6 +514,178 @@ describe('reservation V4 pages', () => {
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
})
it('renders GENERAL Trace cards with fixed department options and confirms safe trace fields only', async () => {
const detail = createOrderTaskDetail()
useTraceBusinessCard(detail, 'GENERAL')
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
...detail,
business_cards: [
{
...detail.business_cards[0]!,
card_status: 'CONFIRMED',
},
],
})
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
const traceSection = wrapper
.findAll('.task-card-section')
.find((section) => section.find('[data-testid="trace-card"]').exists())
expect(traceSection).toBeTruthy()
expect((traceSection!.find('textarea[name="/trace_items/0/text"]').element as HTMLTextAreaElement).value)
.toBe('Late arrival note')
expect(traceSection!.text()).not.toContain('CONTENT_SHOULD_NOT_RENDER')
expect(traceSection!.text()).not.toContain('TARGET_ORDER_SHOULD_NOT_RENDER')
expect(traceSection!.text()).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER')
expect(traceSection!.text()).not.toContain('https://oss.example/private/trace.pdf')
expect(traceSection!.find('details.safe-payload').exists()).toBe(false)
const departmentOptions = traceSection!
.find('select[name="/trace_items/0/department_code"]')
.findAll('option')
.map((option) => option.attributes('value'))
expect(departmentOptions).toEqual(['', 'FO', 'HSK', 'FO+HSK'])
await traceSection!.find('textarea[name="/trace_items/0/text"]').setValue('Arrange late arrival follow-up')
await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('HSK')
await traceSection!.find('button.primary-button').trigger('click')
await flushPromises()
expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-trace', {
version: 10,
confirmed_payload: {
trace_items: [
{
text: 'Arrange late arrival follow-up',
department_code: 'HSK',
},
],
},
})
const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2]
expect(JSON.stringify(submittedRequest)).not.toContain('content')
expect(JSON.stringify(submittedRequest)).not.toContain('target_order')
expect(JSON.stringify(submittedRequest)).not.toContain('AI_PAYLOAD_SHOULD_NOT_RENDER')
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
})
it('renders EXTRA_BED Trace cards with room type lookup, positive count input and confirms safe fields only', async () => {
const detail = createOrderTaskDetail()
useTraceBusinessCard(detail, 'EXTRA_BED')
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue(detail)
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
const traceSection = wrapper
.findAll('.task-card-section')
.find((section) => section.find('[data-testid="trace-card"]').exists())
expect(traceSection).toBeTruthy()
expect(traceSection!.text()).toContain('SET EXTRA BED')
expect(traceSection!.find('select[name="/trace_items/0/target_room_type_code"]').text()).toContain('TWN - Twin')
const roomCountInput = traceSection!.find('input[name="/trace_items/0/extra_bed_room_count"]')
expect(roomCountInput.attributes('type')).toBe('number')
expect(roomCountInput.attributes('min')).toBe('1')
expect(roomCountInput.attributes('step')).toBe('1')
await traceSection!.find('select[name="/trace_items/0/target_room_type_code"]').setValue('TWN')
await roomCountInput.setValue('0')
await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('FO+HSK')
await traceSection!.find('button.primary-button').trigger('click')
await flushPromises()
expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled()
expect(traceSection!.text()).toContain('Extra Bed Room Count must be a positive integer')
await roomCountInput.setValue('2')
await traceSection!.find('button.primary-button').trigger('click')
await flushPromises()
expect(service.confirmReservationV4OrderTaskCard).toHaveBeenCalledWith('9001', 'card-trace', {
version: 10,
confirmed_payload: {
trace_items: [
{
target_room_type_code: 'TWN',
extra_bed_room_count: 2,
department_code: 'FO+HSK',
},
],
},
})
const submittedRequest = vi.mocked(service.confirmReservationV4OrderTaskCard).mock.calls[0]?.[2]
expect(JSON.stringify(submittedRequest)).not.toContain('content')
expect(JSON.stringify(submittedRequest)).not.toContain('target_order')
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
})
it('resolves REVIEW_REQUIRED Trace cards with field errors and safe review overrides', async () => {
const detail = createOrderTaskDetail()
useTraceBusinessCard(detail, 'GENERAL', 'REVIEW_REQUIRED')
detail.order_task.order_id = 'order-2001'
detail.order_task.target_resolution_status = 'RESOLVED'
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockResolvedValue({
...detail,
business_cards: [
{
...detail.business_cards[0]!,
card_status: 'PENDING_CONFIRM',
review_status: 'RESOLVED',
},
],
})
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
const traceSection = wrapper
.findAll('.task-card-section')
.find((section) => section.find('[data-testid="trace-card"]').exists())
expect(traceSection).toBeTruthy()
expect(traceSection!.find('.trace-field__error').text()).toContain('Department is required')
await traceSection!.find('textarea[name="/trace_items/0/text"]').setValue('Confirm trace review text')
await traceSection!.find('select[name="/trace_items/0/department_code"]').setValue('FO')
await traceSection!.find('textarea[name="v4_review_reason"]').setValue('confirmed trace note')
await traceSection!.find('button.primary-button').trigger('click')
await flushPromises()
expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled()
expect(service.resolveReservationV4OrderTaskCardReview).toHaveBeenCalledWith('9001', 'card-trace', {
version: 10,
confirmed_order_id: 'order-2001',
reason: 'confirmed trace note',
field_overrides: [
{
field_pointer: '/trace_items/0/text',
value: 'Confirm trace review text',
},
{
field_pointer: '/trace_items/0/department_code',
value: 'FO',
},
],
})
const submittedRequest = vi.mocked(service.resolveReservationV4OrderTaskCardReview).mock.calls[0]?.[2]
expect(JSON.stringify(submittedRequest)).not.toContain('content')
expect(JSON.stringify(submittedRequest)).not.toContain('target_order')
expect(JSON.stringify(submittedRequest)).not.toContain('https://oss.example')
})
it('renders New Booking Room Information as a business form and confirms stable final values', async () => {
const detail = createOrderTaskDetail({
businessEventType: 'NEW_BOOKING',
@@ -1557,6 +1729,124 @@ function useBoundOrder(detail: ReservationV4OrderTaskDetailResult): void {
}
}
function useTraceBusinessCard(
detail: ReservationV4OrderTaskDetailResult,
itemType: 'GENERAL' | 'EXTRA_BED',
cardStatus = 'PENDING_CONFIRM',
): void {
const isReviewRequired = cardStatus === 'REVIEW_REQUIRED'
detail.business_cards = [
createCard('card-trace', 'TRACE_RESERVATION_NOTES', cardStatus, {
event_type: 'TRACE_RESERVATION_NOTES',
version: 10,
availability: createAvailability({
confirmable: cardStatus === 'PENDING_CONFIRM',
reviewable: isReviewRequired,
editable: true,
read_only: false,
}),
display_payload: {
trace_items: [
itemType === 'EXTRA_BED'
? {
item_type: 'EXTRA_BED',
target_room_type_code: 'TWN',
extra_bed_room_count: 1,
department_code: 'FO',
content: 'CONTENT_SHOULD_NOT_RENDER',
}
: {
item_type: 'GENERAL',
text: 'Late arrival note',
department_code: 'FO',
content: 'CONTENT_SHOULD_NOT_RENDER',
},
],
target_order: 'TARGET_ORDER_SHOULD_NOT_RENDER',
ai_payload_json: 'AI_PAYLOAD_SHOULD_NOT_RENDER',
attachment_url: 'https://oss.example/private/trace.pdf',
},
fields: itemType === 'EXTRA_BED'
? [
createField('/trace_items/0/target_room_type_code', {
display_name: 'Target Room Type',
value: 'TWN',
options_source: 'RESERVATION_V4_ROOM_TYPE_CATALOG',
control_type: 'SELECT',
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
}),
createField('/trace_items/0/extra_bed_room_count', {
display_name: 'Extra Bed Room Count',
value: 1,
control_type: 'NUMBER',
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
}),
createTraceDepartmentField(isReviewRequired),
createField('/trace_items/0/content', {
display_name: 'Legacy content',
value: 'CONTENT_SHOULD_NOT_RENDER',
editable: true,
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
}),
]
: [
createField('/trace_items/0/text', {
display_name: 'Trace Text',
value: 'Late arrival note',
control_type: 'TEXTAREA',
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
}),
createTraceDepartmentField(isReviewRequired, isReviewRequired ? ['Department is required'] : []),
createField('/trace_items/0/content', {
display_name: 'Legacy content',
value: 'CONTENT_SHOULD_NOT_RENDER',
editable: true,
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
}),
],
}),
]
detail.card_counts = {
...detail.card_counts,
pending_confirm_count: cardStatus === 'PENDING_CONFIRM' ? 2 : 1,
review_required_count: isReviewRequired ? 1 : 0,
}
}
function createTraceDepartmentField(
isReviewRequired: boolean,
validationErrors: string[] = [],
): ReservationV4TaskCardResult['fields'][number] {
return createField('/trace_items/0/department_code', {
display_name: 'Department',
value: 'FO',
control_type: 'SELECT',
options_source: 'reservation_v4_trace_department_fixed',
fixed_options: [
{
value: 'FO',
label: 'FO',
},
{
value: 'HSK',
label: 'HSK',
},
{
value: 'FO+HSK',
label: 'FO+HSK',
},
],
edit_scope: isReviewRequired ? 'REVIEW' : 'CONFIRM',
write_target: isReviewRequired ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
validation_errors: validationErrors,
})
}
function createPaymentConversationMedia(): SourceMessageOriginalMedia[] {
return [
{
@@ -1830,6 +2120,7 @@ function createField(
edit_scope: overrides.edit_scope ?? 'NORMAL_TASK',
write_target: overrides.write_target ?? 'CONFIRMED_PAYLOAD_JSON',
options_source: overrides.options_source ?? null,
fixed_options: overrides.fixed_options ?? null,
raw_readonly: overrides.raw_readonly ?? false,
validation_errors: overrides.validation_errors ?? [],
control_hint: overrides.control_hint ?? null,

View File

@@ -358,11 +358,17 @@ export interface ReservationV4TaskCardFieldResult {
edit_scope: string | null
write_target: string | null
options_source: string | null
fixed_options?: ReservationV4TaskCardFieldOptionResult[] | null
raw_readonly: boolean | null
validation_errors: string[]
control_hint: string | null
}
export interface ReservationV4TaskCardFieldOptionResult {
value: string
label: string | null
}
export interface ReservationV4RoomInformationRoomItem extends ReservationRecord {
room_type_code?: string | null
room_count?: number | string | null

View File

@@ -18,8 +18,14 @@ const reviewWriteTargets = new Set([
'FIELD_OVERRIDES',
])
const readonlyControlTypes = new Set(['READONLY', 'FILE', 'WORKFLOW_STATE', 'STRUCTURED_TABLE'])
const reviewWritablePointerPrefixes = ['/basic_information/', '/room_information/final_values/', '/business_fields/']
const reviewWritablePointerPrefixes = [
'/basic_information/',
'/room_information/final_values/',
'/business_fields/',
'/trace_items/',
]
const roomInformationFieldPrefix = '/room_information/final_values/'
const traceFieldPattern = /^\/trace_items\/\d+\/(?:text|department_code|target_room_type_code|extra_bed_room_count)$/
const hiddenDisplayValue = Symbol('reservation-v4-hidden-display-value')
export type ReservationV4FieldErrorMap = Record<string, string>
@@ -43,6 +49,11 @@ export function isReservationV4RoomInformationSafeField(field: ReservationV4Task
return pointer.startsWith(roomInformationFieldPrefix) && !isForbiddenRoomInformationPointer(pointer)
}
export function isReservationV4TraceSafeField(field: ReservationV4TaskCardFieldResult): boolean {
const pointer = field.field_pointer || fieldPathToPointer(field.field_path)
return traceFieldPattern.test(pointer)
}
export function buildReservationV4ConfirmedPayload(
fields: ReservationV4TaskCardFieldResult[],
values: ReservationRecord,
@@ -73,8 +84,13 @@ export function validateReservationV4Fields(
): ReservationV4FieldErrorMap {
return fields.filter(isReservationV4EditableField).reduce<ReservationV4FieldErrorMap>((errors, field) => {
const key = reservationV4FieldKey(field)
if (field.required && isEmptyV4Value(readV4FieldValue(field, values))) {
const value = readV4FieldValue(field, values)
if (field.required && isEmptyV4Value(value)) {
errors[key] = `${field.display_name} is required`
return errors
}
if (isTracePositiveIntegerField(field) && !isEmptyV4Value(value) && !isPositiveIntegerValue(value)) {
errors[key] = `${field.display_name} must be a positive integer`
}
return errors
}, {})
@@ -383,6 +399,21 @@ function isForbiddenRoomInformationPointer(pointer: string): boolean {
normalizedPointer.includes('/locator_value')
}
function isTracePositiveIntegerField(field: ReservationV4TaskCardFieldResult): boolean {
const pointer = field.field_pointer || fieldPathToPointer(field.field_path)
return /^\/trace_items\/\d+\/extra_bed_room_count$/.test(pointer)
}
function isPositiveIntegerValue(value: unknown): boolean {
if (typeof value === 'number') {
return Number.isInteger(value) && value > 0
}
if (typeof value !== 'string') {
return false
}
return /^[1-9]\d*$/.test(value.trim())
}
function isUnsafeUrlLikeValue(value: string): boolean {
const trimmedValue = value.trim()
return /^(https?:\/\/|oss:\/\/|s3:\/\/)/i.test(trimmedValue)

View File

@@ -224,6 +224,7 @@ import {
isReservationV4ConfirmWritableField,
isReservationV4RoomInformationSafeField,
isReservationV4ReviewWritableField,
isReservationV4TraceSafeField,
mapReservationV4BackendDetailsToFields,
reservationV4FieldKey,
validateReservationV4Fields,
@@ -468,11 +469,15 @@ function submissionFieldsForCard(card: ReservationV4TaskCardResult): Reservation
if (isVersionOnlyConfirmCard(card)) {
return []
}
return card.card_type === 'ROOM_INFORMATION'
? card.fields.filter((field) =>
if (card.card_type === 'ROOM_INFORMATION') {
return card.fields.filter((field) =>
isReservationV4RoomInformationSafeField(field) && !isGroupRoomInformationBreakfastField(card, field),
)
: card.fields
}
if (isTraceCard(card)) {
return card.fields.filter(isReservationV4TraceSafeField)
}
return card.fields
}
function buildConfirmRequest(
@@ -498,6 +503,10 @@ function isRoomingListCard(card: ReservationV4TaskCardResult): boolean {
return card.card_type === 'ROOMING_LIST'
}
function isTraceCard(card: ReservationV4TaskCardResult): boolean {
return card.card_type === 'TRACE_RESERVATION_NOTES'
}
function isVersionOnlyConfirmCard(card: ReservationV4TaskCardResult): boolean {
return isPaymentCard(card) || isRoomingListCard(card)
}
@@ -538,6 +547,9 @@ function cardTitle(card: ReservationV4TaskCardResult): string {
if (isRoomingListCard(card)) {
return t('taskV4.roomingList.title')
}
if (isTraceCard(card)) {
return t('taskV4.trace.title')
}
return formatReservationTaskCard(t, card.card_type)
}