接入V4任务卡目录查询

This commit is contained in:
andy
2026-07-19 15:35:58 +07:00
parent 8b37232ac9
commit d808732107
16 changed files with 803 additions and 38 deletions

View File

@@ -16,6 +16,7 @@
v-if="isEditable(field) && isSelectField(field)"
class="v4-field__control"
:aria-invalid="fieldError(field) ? 'true' : 'false'"
:disabled="selectDisabled(field)"
:value="fieldValue(field)"
@change="updateField(field, $event)"
>
@@ -76,6 +77,12 @@
v-if="field.control_hint"
class="v4-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint.key"
class="v4-field__hint"
:class="`v4-field__hint--${hint.tone}`"
>{{ hint.message }}</small>
<small
v-for="error in visibleFieldErrors(field)"
:key="error"
@@ -86,15 +93,27 @@
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { ReservationRecord, ReservationV4TaskCardFieldResult } from '@/types/reservation'
import {
fetchReservationV4AccountLookups,
fetchReservationV4RateCodeLookups,
fetchReservationV4RoomTypeLookups,
} from '@/services/reservationService'
import type {
ReservationRecord,
ReservationV4CatalogLookupItem,
ReservationV4CatalogLookupResult,
ReservationV4TaskCardFieldResult,
} from '@/types/reservation'
import {
isReservationV4EditableField,
readV4FieldValue,
reservationV4LookupKindForOptionsSource,
reservationV4FieldKey,
staticReservationV4Options,
stringifyReservationV4SafeDisplayValue,
type ReservationV4LookupKind,
} from '@/utils/reservationV4FieldRules'
const props = defineProps<{
@@ -103,6 +122,7 @@ const props = defineProps<{
readOnly: boolean
validationErrors?: Record<string, string>
editableKeys?: string[]
hotelId?: string
}>()
const emit = defineEmits<{
@@ -110,6 +130,65 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
type FieldOption = {
value: string
label: string
item?: ReservationV4CatalogLookupItem
}
type LookupHintTone = 'normal' | 'warning' | 'error'
type LookupHint = {
key: string
message: string
tone: LookupHintTone
}
type LookupState = {
loading: boolean
error: string
result: ReservationV4CatalogLookupResult | null
}
const invalidLookupValues = reactive<Record<string, string>>({})
const lookupStates = reactive<Record<ReservationV4LookupKind, LookupState>>({
ACCOUNT: createLookupState(),
ROOM_TYPE: createLookupState(),
RATE_CODE: createLookupState(),
})
const lookupRequestVersions: Record<ReservationV4LookupKind, number> = {
ACCOUNT: 0,
ROOM_TYPE: 0,
RATE_CODE: 0,
}
const lookupKinds = computed(() => {
const kinds = props.fields
.filter((field) => isSelectControl(field) && isReservationV4EditableField(field))
.map((field) => reservationV4LookupKindForOptionsSource(field.options_source))
.filter((kind): kind is ReservationV4LookupKind => Boolean(kind))
return [...new Set(kinds)]
})
watch(
[lookupKinds, () => props.hotelId],
() => {
resetLookupStates()
lookupKinds.value.forEach((kind) => {
void loadLookup(kind)
})
},
{ immediate: true },
)
watch(
[
() => props.modelValue,
() => lookupStates.ACCOUNT.result,
() => lookupStates.ROOM_TYPE.result,
() => lookupStates.RATE_CODE.result,
],
() => {
clearInvalidLoadedCatalogValues()
},
{ deep: true },
)
function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
@@ -123,7 +202,7 @@ function isEditable(field: ReservationV4TaskCardFieldResult): boolean {
}
function isSelectField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'SELECT' && fieldOptions(field).length > 0
return isSelectControl(field) && Boolean(reservationV4LookupKindForOptionsSource(field.options_source))
}
function isDateField(field: ReservationV4TaskCardFieldResult): boolean {
@@ -138,8 +217,25 @@ function isTextAreaField(field: ReservationV4TaskCardFieldResult): boolean {
return normalizeControlType(field.control_type) === 'TEXTAREA'
}
function fieldOptions(field: ReservationV4TaskCardFieldResult): Array<{ value: string; label: string }> {
return staticReservationV4Options(field.options_source)
function fieldOptions(field: ReservationV4TaskCardFieldResult): FieldOption[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const options = kind
? (lookupStates[kind].result?.items ?? []).map((item) => ({
value: item.code,
label: lookupItemLabel(item),
item,
}))
: []
return options
}
function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return false
}
const state = lookupStates[kind]
return state.loading || Boolean(state.error)
}
function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
@@ -158,12 +254,92 @@ function fieldValue(field: ReservationV4TaskCardFieldResult): string | number {
function updateField(field: ReservationV4TaskCardFieldResult, event: Event): void {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
delete invalidLookupValues[reservationV4FieldKey(field)]
emit('update:modelValue', {
...props.modelValue,
[reservationV4FieldKey(field)]: target.value,
})
}
function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
if (!kind) {
return []
}
const state = lookupStates[kind]
if (state.loading) {
return [{
key: `${kind}-loading`,
message: t('taskV4.lookup.loading'),
tone: 'normal',
}]
}
if (state.error) {
return [{
key: `${kind}-error`,
message: state.error,
tone: 'error',
}]
}
const result = state.result
if (!result) {
return []
}
const hints: LookupHint[] = []
const selectedItem = selectedLookupItem(field)
if (kind === 'ACCOUNT' && selectedItem && (selectedItem.market_code || selectedItem.source_code)) {
hints.push({
key: `${kind}-derived`,
message: t('taskV4.lookup.accountDerived', {
market: selectedItem.market_code ?? '-',
source: selectedItem.source_code ?? '-',
}),
tone: 'normal',
})
}
if (result.stale) {
hints.push({
key: `${kind}-stale`,
message: t('taskV4.lookup.stale'),
tone: 'warning',
})
}
result.warnings.forEach((warning, index) => {
hints.push({
key: `${kind}-warning-${index}`,
message: t('taskV4.lookup.warning', { warning }),
tone: 'warning',
})
})
if (!result.items.length) {
hints.push({
key: `${kind}-empty`,
message: t('taskV4.lookup.empty'),
tone: 'warning',
})
}
if (result.catalog_source || result.catalog_version) {
hints.push({
key: `${kind}-meta`,
message: t('taskV4.lookup.catalogMeta', {
source: result.catalog_source ?? '-',
version: result.catalog_version ?? '-',
}),
tone: 'normal',
})
}
const invalidValue = invalidLookupValues[reservationV4FieldKey(field)]
if (invalidValue) {
hints.push({
key: `${kind}-invalid-current`,
message: t('taskV4.lookup.currentNotInCatalog', { value: invalidValue }),
tone: 'error',
})
}
return hints
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [
...(field.validation_errors ?? []),
@@ -186,6 +362,122 @@ function stringifyV4Value(value: unknown): string {
function normalizeControlType(value: string | null): string {
return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
}
function isSelectControl(field: ReservationV4TaskCardFieldResult): boolean {
return ['SELECT', 'LOOKUP'].includes(normalizeControlType(field.control_type))
}
function lookupItemLabel(item: ReservationV4CatalogLookupItem): string {
if (item.display_name && item.display_name !== item.code) {
return `${item.code} - ${item.display_name}`
}
return item.code
}
function selectedLookupItem(field: ReservationV4TaskCardFieldResult): ReservationV4CatalogLookupItem | null {
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const value = String(fieldValue(field) || '')
if (!kind || !value) {
return null
}
return lookupStates[kind].result?.items.find((item) => item.code === value) ?? null
}
function createLookupState(): LookupState {
return {
loading: false,
error: '',
result: null,
}
}
function resetLookupStates(): void {
const allKinds: ReservationV4LookupKind[] = ['ACCOUNT', 'ROOM_TYPE', 'RATE_CODE']
allKinds.forEach((kind) => {
lookupRequestVersions[kind] += 1
lookupStates[kind].loading = false
lookupStates[kind].error = ''
lookupStates[kind].result = null
})
Object.keys(invalidLookupValues).forEach((key) => {
delete invalidLookupValues[key]
})
}
async function loadLookup(kind: ReservationV4LookupKind): Promise<void> {
const state = lookupStates[kind]
const requestVersion = ++lookupRequestVersions[kind]
state.loading = true
state.error = ''
try {
const result = await lookupFetcher(kind)({
...(props.hotelId ? { hotel_id: props.hotelId } : {}),
page_num: 1,
page_size: 100,
})
if (requestVersion === lookupRequestVersions[kind]) {
state.result = {
...result,
items: result.items ?? [],
warnings: result.warnings ?? [],
}
}
} catch {
if (requestVersion === lookupRequestVersions[kind]) {
state.error = t('taskV4.lookup.error')
}
} finally {
if (requestVersion === lookupRequestVersions[kind]) {
state.loading = false
}
}
}
function lookupFetcher(kind: ReservationV4LookupKind) {
switch (kind) {
case 'ACCOUNT':
return fetchReservationV4AccountLookups
case 'ROOM_TYPE':
return fetchReservationV4RoomTypeLookups
case 'RATE_CODE':
return fetchReservationV4RateCodeLookups
}
}
function clearInvalidLoadedCatalogValues(): void {
const nextValues = { ...props.modelValue }
let changed = false
props.fields.forEach((field) => {
if (!isSelectField(field) || !isReservationV4EditableField(field)) {
return
}
const kind = reservationV4LookupKindForOptionsSource(field.options_source)
const result = kind ? lookupStates[kind].result : null
if (!kind || !result || !isFullyLoadedCatalog(result)) {
return
}
const key = reservationV4FieldKey(field)
const currentValue = String(readV4FieldValue(field, nextValues) || '')
if (!currentValue) {
return
}
if (result.items.some((item) => item.code === currentValue)) {
delete invalidLookupValues[key]
return
}
invalidLookupValues[key] = currentValue
nextValues[key] = ''
changed = true
})
if (changed) {
emit('update:modelValue', nextValues)
}
}
function isFullyLoadedCatalog(result: ReservationV4CatalogLookupResult): boolean {
return result.items.length >= result.page.total
}
</script>
<style scoped>
@@ -255,6 +547,16 @@ function normalizeControlType(value: string | null): string {
font-size: 12px;
}
.v4-field__hint--warning {
color: var(--th-color-warning);
font-weight: 700;
}
.v4-field__hint--error {
color: var(--th-color-danger);
font-weight: 700;
}
.v4-field__error {
color: var(--th-color-danger);
font-size: 12px;

View File

@@ -406,6 +406,8 @@ export default {
order: {
summary: 'Order summary',
taskQueue: 'Order task queue',
v4TaskTimeline: 'V4 order task timeline',
v4TaskTimelineEmpty: 'No V4 order tasks.',
sourceMessage: 'Source message',
businessKeySource: 'Business key source',
updatedAt: 'Last updated',
@@ -500,6 +502,16 @@ export default {
cardConfirmed: 'Card confirmed and detail refreshed.',
reviewResolved: 'Review submitted and detail refreshed.',
validationFailed: 'Fix current-card field errors first.',
lookup: {
loading: 'Loading catalog',
empty: 'No options in the current catalog. A no-match search does not mean the catalog is uninitialized.',
stale: 'This catalog is a stale snapshot. You can still select from it; backend validation remains final.',
warning: 'Catalog note: {warning}',
error: 'Catalog is temporarily unavailable. Refresh and retry.',
catalogMeta: 'Catalog source: {source}, version: {version}',
accountDerived: 'Market: {market} / Source: {source} will be shown from the catalog; backend confirmation remains final.',
currentNotInCatalog: 'Original value "{value}" is not in the current catalog. Select again.',
},
sourceMessage: {
subject: 'Subject',
sender: 'Sender',

View File

@@ -406,6 +406,8 @@ export default {
order: {
summary: 'สรุปออเดอร์',
taskQueue: 'คิวงานของออเดอร์นี้',
v4TaskTimeline: 'ไทม์ไลน์งานออเดอร์ V4',
v4TaskTimelineEmpty: 'ยังไม่มีงานออเดอร์ V4',
sourceMessage: 'ข้อความต้นทาง',
businessKeySource: 'แหล่งที่มาของเลขธุรกิจ',
updatedAt: 'อัปเดตล่าสุด',
@@ -500,6 +502,16 @@ export default {
cardConfirmed: 'ยืนยันการ์ดแล้วและรีเฟรชรายละเอียดแล้ว',
reviewResolved: 'ส่งผลตรวจสอบแล้วและรีเฟรชรายละเอียดแล้ว',
validationFailed: 'โปรดแก้ไขข้อมูลในการ์ดปัจจุบันก่อน',
lookup: {
loading: 'กำลังโหลดแค็ตตาล็อก',
empty: 'ไม่มีตัวเลือกในแค็ตตาล็อกปัจจุบัน หากค้นหาไม่พบไม่ได้หมายความว่าแค็ตตาล็อกยังไม่เริ่มต้น',
stale: 'แค็ตตาล็อกนี้เป็น snapshot เก่า ยังเลือกได้ แต่การตรวจสอบฝั่งหลังบ้านเป็นผลสุดท้าย',
warning: 'หมายเหตุแค็ตตาล็อก: {warning}',
error: 'แค็ตตาล็อกไม่พร้อมใช้งานชั่วคราว โปรดรีเฟรชแล้วลองใหม่',
catalogMeta: 'แหล่งที่มาแค็ตตาล็อก: {source}, เวอร์ชัน: {version}',
accountDerived: 'จะแสดง Market: {market} / Source: {source} จากแค็ตตาล็อก โดยผลยืนยันจากหลังบ้านเป็นผลสุดท้าย',
currentNotInCatalog: 'ค่าเดิม "{value}" ไม่อยู่ในแค็ตตาล็อกปัจจุบัน โปรดเลือกใหม่',
},
sourceMessage: {
subject: 'หัวข้อ',
sender: 'ผู้ส่ง',

View File

@@ -406,6 +406,8 @@ export default {
order: {
summary: '订单摘要',
taskQueue: '同订单任务队列',
v4TaskTimeline: 'V4 订单任务时间线',
v4TaskTimelineEmpty: '暂无 V4 订单任务。',
sourceMessage: '来源消息',
businessKeySource: '业务号来源',
updatedAt: '最近更新',
@@ -500,6 +502,16 @@ export default {
cardConfirmed: '卡片已确认,详情已刷新。',
reviewResolved: '复核已提交,详情已刷新。',
validationFailed: '请先修正当前卡片字段。',
lookup: {
loading: '目录加载中',
empty: '当前目录没有可选项;如果是搜索无结果,不代表目录未初始化。',
stale: '当前目录为过期快照,仍可选择,最终以后端校验为准。',
warning: '目录提示:{warning}',
error: '目录暂不可用,请刷新后重试。',
catalogMeta: '目录来源:{source},版本:{version}',
accountDerived: '选择后将参考目录展示 Market{market} / Source{source},最终以后端确认结果为准。',
currentNotInCatalog: '原值“{value}”不在当前目录中,请重新选择。',
},
sourceMessage: {
subject: '主题',
sender: '发件人',

View File

@@ -26,6 +26,8 @@ import type {
ReservationTaskPayloadMutationRequest,
ReservationTaskPayloadMutationResult,
ReservationV4CardConfirmRequest,
ReservationV4CatalogLookupFilters,
ReservationV4CatalogLookupResult,
ReservationV4OrderTaskDetailResult,
ReservationV4OrderTaskListFilters,
ReservationV4OrderTaskListResult,
@@ -67,6 +69,30 @@ export async function fetchReservationV4OrderTasks(
)
}
export async function fetchReservationV4AccountLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/accounts', withReservationHotel(filters)),
)
}
export async function fetchReservationV4RoomTypeLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/room-types', withReservationHotel(filters)),
)
}
export async function fetchReservationV4RateCodeLookups(
filters: ReservationV4CatalogLookupFilters = {},
): Promise<ReservationV4CatalogLookupResult> {
return getJson<ReservationV4CatalogLookupResult>(
withQuery('/api/reservation/lookups/rate-codes', withReservationHotel(filters)),
)
}
export async function fetchReservationOrderDetail(orderId: string): Promise<ReservationOrderDetailResult> {
return getJson<ReservationOrderDetailResult>(
withQuery(`/api/reservation/orders/${encodeURIComponent(orderId)}`, {

View File

@@ -8,8 +8,11 @@ import {
fetchReservationOrders,
fetchReservationTaskDetail,
fetchReservationTaskList,
fetchReservationV4AccountLookups,
fetchReservationV4OrderTaskDetail,
fetchReservationV4OrderTasks,
fetchReservationV4RateCodeLookups,
fetchReservationV4RoomTypeLookups,
fetchReservationV4SourceNotificationDetail,
fetchReservationV4WorkbenchItems,
fetchSourceMessageConversation,
@@ -188,6 +191,85 @@ describe('reservationService real API mode', () => {
)
})
it('fetches V4 catalog lookups with selected hotel and query params', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockJsonResponse({
hotel_id: 'HOTEL-BKK',
catalog_type: 'ACCOUNT',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'v1',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
warnings: [],
}))
.mockResolvedValueOnce(mockJsonResponse({
hotel_id: 'HOTEL-BKK',
catalog_type: 'ROOM_TYPE',
catalog_source: 'PMS_SYNC',
catalog_version: 'v2',
stale: true,
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
warnings: ['PMS sync stale'],
}))
.mockResolvedValueOnce(mockJsonResponse({
hotel_id: 'HOTEL-BKK',
catalog_type: 'RATE_CODE',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'v3',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 20,
total: 0,
},
warnings: [],
}))
setReservationHotelIdProvider(() => 'HOTEL-BKK')
await fetchReservationV4AccountLookups({
keyword: 'QBD',
page_num: 1,
page_size: 20,
})
await fetchReservationV4RoomTypeLookups({
keyword: 'RM',
page_num: 1,
page_size: 20,
})
await fetchReservationV4RateCodeLookups({
keyword: 'BAR',
page_num: 1,
page_size: 20,
})
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/reservation/lookups/accounts?hotel_id=HOTEL-BKK&keyword=QBD&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/reservation/lookups/room-types?hotel_id=HOTEL-BKK&keyword=RM&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/api/reservation/lookups/rate-codes?hotel_id=HOTEL-BKK&keyword=BAR&page_num=1&page_size=20',
expect.objectContaining({ method: 'GET' }),
)
})
it('fetches V4 order task and source notification details by id', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(mockJsonResponse({ order_task: { order_task_id: '9001' } }))
@@ -482,6 +564,7 @@ describe('reservationService real API mode', () => {
updated_at: '2026-07-08T03:10:00Z',
},
tasks: [],
v4_order_tasks: [],
warnings: [],
}),
)

View File

@@ -7,6 +7,7 @@ import {
buildReservationV4ReviewOverrides,
isReservationV4ConfirmWritableField,
isReservationV4ReviewWritableField,
reservationV4LookupKindForOptionsSource,
stringifyReservationV4SafeDisplayValue,
validateReservationV4Fields,
} from '@/utils/reservationV4FieldRules'
@@ -188,4 +189,11 @@ describe('reservationV4FieldRules', () => {
'/basic_information/account_code': 'Account is required',
})
})
it('maps V4 options_source to lookup kinds without returning fixed catalog options', () => {
expect(reservationV4LookupKindForOptionsSource('reservation_v4_account_catalog')).toBe('ACCOUNT')
expect(reservationV4LookupKindForOptionsSource('RESERVATION_V4_ROOM_TYPE_CATALOG')).toBe('ROOM_TYPE')
expect(reservationV4LookupKindForOptionsSource('reservation_v4_rate_code_catalog')).toBe('RATE_CODE')
expect(reservationV4LookupKindForOptionsSource('static_enum')).toBeNull()
})
})

View File

@@ -20,7 +20,10 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
...actual,
ackReservationV4SourceNotification: vi.fn(),
confirmReservationV4OrderTaskCard: vi.fn(),
fetchReservationV4AccountLookups: vi.fn(),
fetchReservationV4OrderTaskDetail: vi.fn(),
fetchReservationV4RateCodeLookups: vi.fn(),
fetchReservationV4RoomTypeLookups: vi.fn(),
fetchReservationV4SourceNotificationDetail: vi.fn(),
resolveReservationV4OrderTaskCardReview: vi.fn(),
}
@@ -33,12 +36,16 @@ describe('reservation V4 pages', () => {
sessionStorage.clear()
vi.mocked(service.ackReservationV4SourceNotification).mockReset()
vi.mocked(service.confirmReservationV4OrderTaskCard).mockReset()
vi.mocked(service.fetchReservationV4AccountLookups).mockReset()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockReset()
vi.mocked(service.fetchReservationV4RateCodeLookups).mockReset()
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockReset()
vi.mocked(service.fetchReservationV4SourceNotificationDetail).mockReset()
vi.mocked(service.resolveReservationV4OrderTaskCardReview).mockReset()
mockCatalogLookups()
})
it('renders V4 order task cards and confirms only editable basic information fields', async () => {
it('renders V4 order task cards, loads lookup options and confirms only selected codes', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
vi.mocked(service.confirmReservationV4OrderTaskCard).mockResolvedValue({
@@ -60,8 +67,21 @@ describe('reservation V4 pages', () => {
expect(wrapper.text()).toContain('基础信息卡')
expect(wrapper.text()).toContain('业务任务卡')
expect(wrapper.text()).toContain('Backend contract issue')
expect(service.fetchReservationV4AccountLookups).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
page_num: 1,
page_size: 100,
})
expect(service.fetchReservationV4RoomTypeLookups).toHaveBeenCalledWith({
hotel_id: 'HOTEL-TEST',
page_num: 1,
page_size: 100,
})
await wrapper.find('select').setValue('HANATOUR')
await wrapper.find('select').setValue('ACC-LIVE')
await flushPromises()
expect(wrapper.text()).toContain('LEISURE')
expect(wrapper.text()).toContain('TRAVEL_AGENT')
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises()
@@ -69,7 +89,7 @@ describe('reservation V4 pages', () => {
version: 7,
confirmed_payload: {
basic_information: {
account_code: 'HANATOUR',
account_code: 'ACC-LIVE',
},
},
})
@@ -153,6 +173,74 @@ describe('reservation V4 pages', () => {
})
})
it('shows non-blocking lookup empty, stale and warning states', async () => {
const detail = createOrderTaskDetail()
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ACCOUNT',
catalog_source: 'PMS_SYNC',
catalog_version: 'stale-v1',
stale: true,
items: [],
page: {
page_num: 1,
page_size: 100,
total: 0,
},
warnings: ['PMS sync is stale'],
})
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.stale)
expect(wrapper.text()).toContain('PMS sync is stale')
expect(wrapper.text()).toContain('PMS_SYNC')
expect(wrapper.text()).toContain('stale-v1')
})
it('does not submit a catalog field value that is missing from the loaded lookup result', async () => {
const detail = createOrderTaskDetail({
basicAccountValue: 'UNKNOWN_ACCOUNT',
basicAccountRequired: true,
})
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ACCOUNT',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 100,
total: 0,
},
warnings: [],
})
vi.mocked(service.fetchReservationV4OrderTaskDetail).mockResolvedValue(detail)
const wrapper = await mountWithPlugins(
ReservationV4OrderTaskDetailView,
'/reservation/order-tasks/9001',
)
await flushPromises()
expect(wrapper.text()).toContain('UNKNOWN_ACCOUNT')
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.currentNotInCatalog.replace('{value}', 'UNKNOWN_ACCOUNT'))
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises()
expect(service.confirmReservationV4OrderTaskCard).not.toHaveBeenCalled()
expect(wrapper.text()).toContain(zhCN.taskV4.validationFailed)
})
it('requires confirmed order id before resolving an unresolved V4 review card', async () => {
const detail = createOrderTaskDetail({
businessCardStatus: 'REVIEW_REQUIRED',
@@ -277,10 +365,83 @@ async function mountWithPlugins(component: object, initialPath: string) {
})
}
function mockCatalogLookups() {
vi.mocked(service.fetchReservationV4AccountLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ACCOUNT',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [
{
code: 'ACC-LIVE',
display_name: 'Live Account',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
market_code: 'LEISURE',
market_name: 'Leisure',
source_code: 'TRAVEL_AGENT',
source_name: 'Travel Agent',
adult_capacity: null,
pricing_available: null,
},
],
page: {
page_num: 1,
page_size: 100,
total: 1,
},
warnings: [],
})
vi.mocked(service.fetchReservationV4RoomTypeLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'ROOM_TYPE',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [
{
code: 'TWN',
display_name: 'Twin',
status: 'ACTIVE',
catalog_source: 'SYSTEM_MANAGED',
market_code: null,
market_name: null,
source_code: null,
source_name: null,
adult_capacity: 2,
pricing_available: null,
},
],
page: {
page_num: 1,
page_size: 100,
total: 1,
},
warnings: [],
})
vi.mocked(service.fetchReservationV4RateCodeLookups).mockResolvedValue({
hotel_id: 'HOTEL-TEST',
catalog_type: 'RATE_CODE',
catalog_source: 'SYSTEM_MANAGED',
catalog_version: 'catalog-v1',
stale: false,
items: [],
page: {
page_num: 1,
page_size: 100,
total: 0,
},
warnings: [],
})
}
function createOrderTaskDetail(options: {
businessCardStatus?: string
businessCardAvailability?: Partial<ReservationV4TaskCardResult['availability']>
sourceDisplayPayload?: ReservationV4TaskCardResult['display_payload']
basicAccountValue?: string
basicAccountRequired?: boolean
} = {}): ReservationV4OrderTaskDetailResult {
const sourceCard = createCard('card-source', 'SOURCE_MESSAGE_DISPLAY', 'READONLY', {
fields: [],
@@ -301,7 +462,8 @@ function createOrderTaskDetail(options: {
fields: [
createField('/basic_information/account_code', {
display_name: 'Account',
value: '',
value: options.basicAccountValue ?? '',
required: options.basicAccountRequired ?? false,
options_source: 'RESERVATION_V4_ACCOUNT_CATALOG',
control_type: 'SELECT',
}),

View File

@@ -270,6 +270,35 @@ function createOrderDetailResult(displayName = 'GRP-001') {
created_at: '2026-07-08T04:00:00Z',
},
],
v4_order_tasks: [
{
order_task_id: '9001',
order_ref: 'order-ref-1',
order_task_status: 'OPEN',
card_counts: {
total_count: 3,
readonly_count: 1,
pending_confirm_count: 1,
review_required_count: 1,
confirmed_count: 0,
},
source_message_summary: {
source_message_id: '30003',
hotel_id: 'HOTEL-TEST',
external_message_id: 'm-v4-1',
external_conversation_id: 'thread-v4-1',
subject: 'V4 Booking Package',
sender_summary: 'v4@example.test',
received_at: '2026-07-08T05:00:00Z',
source_sent_at: null,
conversation_message_count: 1,
},
source_received_at: '2026-07-08T05:00:00Z',
created_at: '2026-07-08T05:00:00Z',
updated_at: '2026-07-08T05:10:00Z',
latest_activity_at: '2026-07-08T05:10:00Z',
},
],
warnings: [],
}
}
@@ -461,6 +490,9 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('前序任务未完成')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).toContain(zhCN.order.v4TaskTimeline)
expect(wrapper.text()).toContain('order-ref-1')
expect(wrapper.text()).toContain('V4 Booking Package')
expect(wrapper.text()).not.toContain('PREVIOUS_TASK_NOT_FINISHED')
expect(wrapper.text()).not.toContain('来源邮件摘要字段待后端补充')
})

View File

@@ -184,6 +184,39 @@ export interface ReservationV4OrderTaskListFilters {
page_size?: number
}
export type ReservationV4CatalogType = 'ACCOUNT' | 'ROOM_TYPE' | 'RATE_CODE' | string
export interface ReservationV4CatalogLookupFilters {
hotel_id?: string
keyword?: string
page_num?: number
page_size?: number
}
export interface ReservationV4CatalogLookupItem {
code: string
display_name: string
status: string | null
catalog_source: string | null
market_code: string | null
market_name: string | null
source_code: string | null
source_name: string | null
adult_capacity: number | null
pricing_available: boolean | null
}
export interface ReservationV4CatalogLookupResult {
hotel_id: string
catalog_type: ReservationV4CatalogType
catalog_source: string | null
catalog_version: string | null
stale: boolean
items: ReservationV4CatalogLookupItem[]
page: ReservationPageResult
warnings: string[]
}
export interface ReservationV4SourceMessageSummary {
source_message_id: string
hotel_id: string
@@ -406,9 +439,22 @@ export interface ReservationOrderTaskTimelineItem {
created_at: string | null
}
export interface ReservationV4OrderTaskTimelineItem {
order_task_id: string
order_ref: string | null
order_task_status: ReservationV4OrderTaskStatus | string
card_counts: ReservationV4CardCounts
source_message_summary: ReservationV4SourceMessageSummary | null
source_received_at: string | null
created_at: string | null
updated_at: string | null
latest_activity_at: string | null
}
export interface ReservationOrderDetailResult {
order: ReservationOrderSummaryResult
tasks: ReservationOrderTaskTimelineItem[]
v4_order_tasks: ReservationV4OrderTaskTimelineItem[]
warnings: string[]
}

View File

@@ -22,6 +22,7 @@ const reviewWritablePointerPrefixes = ['/basic_information/', '/business_fields/
const hiddenDisplayValue = Symbol('reservation-v4-hidden-display-value')
export type ReservationV4FieldErrorMap = Record<string, string>
export type ReservationV4LookupKind = 'ACCOUNT' | 'ROOM_TYPE' | 'RATE_CODE'
export function reservationV4FieldKey(field: ReservationV4TaskCardFieldResult): string {
return field.field_pointer || field.field_path
@@ -163,34 +164,19 @@ export function mapReservationV4BackendDetailsToFields(
}
}
export function staticReservationV4Options(optionsSource: string | null): Array<{ value: string; label: string }> {
export function reservationV4LookupKindForOptionsSource(optionsSource: string | null): ReservationV4LookupKind | null {
switch (normalizeV4RuleCode(optionsSource)) {
case 'RESERVATION_V4_ACCOUNT_CATALOG':
case 'ACCOUNT_CATALOG':
return [
{ value: 'HANATOUR', label: 'HANATOUR' },
{ value: 'LIAN_TAI', label: 'LIAN TAI' },
{ value: 'QBD_TRAVEL', label: 'QBD Travel' },
]
return 'ACCOUNT'
case 'RESERVATION_V4_ROOM_TYPE_CATALOG':
case 'ROOM_TYPE_CATALOG':
return [
{ value: 'TWN', label: 'TWN' },
{ value: 'DBL', label: 'DBL' },
{ value: 'KING', label: 'KING' },
{ value: 'SGL', label: 'SGL' },
{ value: 'TRP', label: 'TRP' },
]
return 'ROOM_TYPE'
case 'RESERVATION_V4_RATE_CODE_CATALOG':
case 'RATE_CODE_CATALOG':
return [
{ value: 'BAR', label: 'BAR' },
{ value: 'RACK', label: 'RACK' },
{ value: 'GROUP', label: 'GROUP' },
{ value: 'FIT', label: 'FIT' },
]
return 'RATE_CODE'
default:
return []
return null
}
}

View File

@@ -68,6 +68,41 @@
</div>
</section>
<section class="th-section side-section">
<div class="th-section-header">
<h2 class="th-section-title">
{{ t('order.v4TaskTimeline') }}
</h2>
</div>
<div class="side-section__body">
<p
v-if="!v4OrderTasks.length"
class="side-empty"
>
{{ t('order.v4TaskTimelineEmpty') }}
</p>
<template v-else>
<RouterLink
v-for="item in v4OrderTasks"
:key="item.order_task_id"
class="v4-timeline-item"
:to="`/reservation/order-tasks/${item.order_task_id}`"
>
<span>
<strong>{{ v4OrderReference(item) }}</strong>
<small>{{ item.source_message_summary?.subject ?? '-' }}</small>
</span>
<ReservationStatusBadge :status="item.order_task_status" />
<small>
{{ t('taskList.cardCountsTotal', { total: item.card_counts.total_count }) }}
·
{{ formatReservationDateTime(item.latest_activity_at ?? item.updated_at ?? item.created_at) }}
</small>
</RouterLink>
</template>
</div>
</section>
<section class="th-section source-gap">
<h2>{{ t('task.evidence') }}</h2>
<template v-if="activeTask">
@@ -134,7 +169,7 @@ import ReservationTaskDetailPanel from '@/components/reservation/ReservationTask
import ReservationTaskQueue from '@/components/reservation/ReservationTaskQueue.vue'
import { fetchReservationOrderDetail } from '@/services/reservationService'
import { useAuthStore } from '@/stores/authStore'
import type { ReservationOrderDetailResult } from '@/types/reservation'
import type { ReservationOrderDetailResult, ReservationV4OrderTaskTimelineItem } from '@/types/reservation'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const route = useRoute()
@@ -147,6 +182,7 @@ const activeTaskId = ref('')
const orderId = computed(() => String(route.params.orderId ?? ''))
const activeTask = computed(() => detail.value?.tasks.find((task) => task.task_id === activeTaskId.value) ?? null)
const v4OrderTasks = computed(() => detail.value?.v4_order_tasks ?? [])
watch(
orderId,
@@ -181,6 +217,10 @@ async function loadOrder(nextOrderId: string): Promise<void> {
loading.value = false
}
}
function v4OrderReference(item: ReservationV4OrderTaskTimelineItem): string {
return item.order_ref?.trim() || item.source_message_summary?.subject?.trim() || `#${item.order_task_id}`
}
</script>
<style scoped>
@@ -295,6 +335,48 @@ async function loadOrder(nextOrderId: string): Promise<void> {
line-height: 1.6;
}
.side-empty {
margin: 0;
color: var(--th-color-slate-500);
font-size: 13px;
line-height: 1.6;
}
.v4-timeline-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: inherit;
padding: 10px;
text-decoration: none;
}
.v4-timeline-item > span {
display: grid;
gap: 3px;
min-width: 0;
}
.v4-timeline-item strong {
color: var(--th-color-slate-900);
font-size: 13px;
overflow-wrap: anywhere;
}
.v4-timeline-item small {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
overflow-wrap: anywhere;
}
.v4-timeline-item > small {
grid-column: 1 / -1;
}
.source-gap dl {
display: grid;
gap: 10px;

View File

@@ -522,6 +522,7 @@ const TaskCardSection = defineComponent({
submittingCardId.value === props.card.card_id,
editableKeys: editableFieldKeys(props.card),
validationErrors: cardFieldErrors.value[props.card.card_id] ?? {},
hotelId: detail.value?.order_task.hotel_id,
'onUpdate:modelValue': (values: ReservationRecord) => setCardValues(props.card, values),
}),
safePayloadRows(props.card.display_payload).length