适配 M002 V3 字段矩阵前端交互
This commit is contained in:
@@ -55,14 +55,14 @@
|
|||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
v-for="(item, index) in supportedMissingFields"
|
v-for="(item, index) in supportedMissingFields"
|
||||||
:key="item.pointer"
|
:key="item.key"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
<small class="th-code">{{ item.pointer }}</small>
|
<small class="th-code">{{ item.pointer ?? item.fieldPath }}</small>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
v-model="fieldOverrideInputs[item.pointer]"
|
v-model="fieldOverrideInputs[item.key]"
|
||||||
:name="`manual_resolution_field_override_${index}`"
|
:name="`manual_resolution_field_override_${index}`"
|
||||||
type="text"
|
type="text"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@@ -124,11 +124,15 @@ import type {
|
|||||||
ReservationTaskFieldResult,
|
ReservationTaskFieldResult,
|
||||||
} from '@/types/reservation'
|
} from '@/types/reservation'
|
||||||
import { normalizeStableCode } from '@/utils/reservationDisplay'
|
import { normalizeStableCode } from '@/utils/reservationDisplay'
|
||||||
|
import { readReservationFieldValue } from '@/utils/reservationFieldRules'
|
||||||
|
|
||||||
const supportedFieldPointers = [
|
interface ManualResolutionFieldItem {
|
||||||
'/extracted_fields/pms_room_type_code',
|
key: string
|
||||||
'/extracted_fields/room_items/0/pms_room_type_code',
|
pointer?: string
|
||||||
] as const
|
fieldPath: string
|
||||||
|
label: string
|
||||||
|
field?: ReservationTaskFieldResult
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -163,17 +167,14 @@ const isResolved = computed(() => normalizeStableCode(props.reviewStatus) === 'R
|
|||||||
const reviewStatusLabel = computed(() => props.reviewStatus || t('task.manualResolution.pendingStatus'))
|
const reviewStatusLabel = computed(() => props.reviewStatus || t('task.manualResolution.pendingStatus'))
|
||||||
const supportedMissingFields = computed(() =>
|
const supportedMissingFields = computed(() =>
|
||||||
extractMissingFields(props.manualReview)
|
extractMissingFields(props.manualReview)
|
||||||
.filter((pointer) => supportedFieldPointers.includes(pointer as (typeof supportedFieldPointers)[number]))
|
.map((reference) => resolveMissingField(reference))
|
||||||
.map((pointer) => ({
|
.filter((item): item is ManualResolutionFieldItem => item !== null),
|
||||||
pointer,
|
|
||||||
label: findFieldLabel(pointer),
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
if (!supportedMissingFields.value.length || !confirmedOrderId.value.trim()) {
|
if (!supportedMissingFields.value.length || !confirmedOrderId.value.trim()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return supportedMissingFields.value.every((item) => String(fieldOverrideInputs.value[item.pointer] ?? '').trim())
|
return supportedMissingFields.value.every((item) => String(fieldOverrideInputs.value[item.key] ?? '').trim())
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -188,7 +189,7 @@ watch(
|
|||||||
(items) => {
|
(items) => {
|
||||||
const nextInputs: Record<string, string> = {}
|
const nextInputs: Record<string, string> = {}
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
nextInputs[item.pointer] = fieldOverrideInputs.value[item.pointer] || readCurrentFieldValue(item.pointer)
|
nextInputs[item.key] = fieldOverrideInputs.value[item.key] || readCurrentFieldValue(item)
|
||||||
})
|
})
|
||||||
fieldOverrideInputs.value = nextInputs
|
fieldOverrideInputs.value = nextInputs
|
||||||
},
|
},
|
||||||
@@ -206,8 +207,9 @@ function submitResolution(): void {
|
|||||||
confirmed_order_id: confirmedOrderId.value.trim(),
|
confirmed_order_id: confirmedOrderId.value.trim(),
|
||||||
reason: reason.value.trim() || undefined,
|
reason: reason.value.trim() || undefined,
|
||||||
field_overrides: supportedMissingFields.value.map((item) => ({
|
field_overrides: supportedMissingFields.value.map((item) => ({
|
||||||
field_pointer: item.pointer,
|
...(item.pointer ? { field_pointer: item.pointer } : {}),
|
||||||
value: String(fieldOverrideInputs.value[item.pointer] ?? '').trim(),
|
field_path: item.fieldPath,
|
||||||
|
value: String(fieldOverrideInputs.value[item.key] ?? '').trim(),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -217,24 +219,37 @@ function extractMissingFields(manualReview: ReservationRecord | null | undefined
|
|||||||
if (!Array.isArray(missingFields)) {
|
if (!Array.isArray(missingFields)) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return missingFields.filter((item): item is string => typeof item === 'string' && item.startsWith('/'))
|
return missingFields.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFieldLabel(pointer: string): string {
|
function resolveMissingField(reference: string): ManualResolutionFieldItem | null {
|
||||||
const fieldPath = fieldPointerToFieldPath(pointer)
|
const pointer = reference.startsWith('/') ? reference : undefined
|
||||||
return props.fields.find((field) => field.field_path === fieldPath)?.display_name ?? fieldPath
|
const referenceFieldPath = pointer ? fieldPointerToFieldPath(pointer) : reference
|
||||||
|
const field = props.fields.find((item) =>
|
||||||
|
item.field_pointer === reference ||
|
||||||
|
item.field_path === referenceFieldPath ||
|
||||||
|
item.legacy_field_path === referenceFieldPath,
|
||||||
|
)
|
||||||
|
if (field) {
|
||||||
|
return {
|
||||||
|
key: field.field_pointer ?? field.field_path,
|
||||||
|
pointer: field.field_pointer ?? pointer,
|
||||||
|
fieldPath: field.field_path,
|
||||||
|
label: field.display_name,
|
||||||
|
field,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCurrentFieldValue(pointer: string): string {
|
function readCurrentFieldValue(item: ManualResolutionFieldItem): string {
|
||||||
const fieldPath = fieldPointerToFieldPath(pointer)
|
const value = item.field
|
||||||
const value = props.fieldValues[fieldPath] ?? props.fields.find((field) => field.field_path === fieldPath)?.value
|
? readReservationFieldValue(item.field, props.fieldValues)
|
||||||
|
: props.fieldValues[item.fieldPath]
|
||||||
return value === undefined || value === null ? '' : String(value)
|
return value === undefined || value === null ? '' : String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldPointerToFieldPath(pointer: string): string {
|
function fieldPointerToFieldPath(pointer: string): string {
|
||||||
if (pointer === '/extracted_fields/room_items/0/pms_room_type_code') {
|
|
||||||
return 'extracted_fields.pms_room_type_code'
|
|
||||||
}
|
|
||||||
return pointer.replace(/^\//, '').replace(/\//g, '.')
|
return pointer.replace(/^\//, '').replace(/\//g, '.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -594,7 +594,11 @@ async function mutateTaskPayload(action: 'save' | 'confirm'): Promise<void> {
|
|||||||
function applyTaskData(nextDetail: ReservationTaskDetailResult, nextAudits: ReservationTaskAuditLogResult[]): void {
|
function applyTaskData(nextDetail: ReservationTaskDetailResult, nextAudits: ReservationTaskAuditLogResult[]): void {
|
||||||
detail.value = nextDetail
|
detail.value = nextDetail
|
||||||
audits.value = nextAudits
|
audits.value = nextAudits
|
||||||
fieldValues.value = buildInitialFieldValues(nextDetail.fields, nextDetail.draft_payload ?? nextDetail.confirmed_payload)
|
fieldValues.value = buildInitialFieldValues(
|
||||||
|
nextDetail.fields,
|
||||||
|
nextDetail.draft_payload ?? nextDetail.confirmed_payload,
|
||||||
|
nextDetail.field_contract_version,
|
||||||
|
)
|
||||||
validationErrors.value = {}
|
validationErrors.value = {}
|
||||||
validationSummaryActive.value = false
|
validationSummaryActive.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ import {
|
|||||||
isReservationFieldRequired,
|
isReservationFieldRequired,
|
||||||
isSelectField,
|
isSelectField,
|
||||||
isTableField,
|
isTableField,
|
||||||
|
readReservationFieldValue,
|
||||||
parseEnumOptions,
|
parseEnumOptions,
|
||||||
stringifyReservationValue,
|
stringifyReservationValue,
|
||||||
} from '@/utils/reservationFieldRules'
|
} from '@/utils/reservationFieldRules'
|
||||||
@@ -135,7 +136,7 @@ const { t } = useI18n()
|
|||||||
const fieldGroups = computed(() => groupReservationFields(props.fields, t('task.fieldAreaFallback'), props.modelValue))
|
const fieldGroups = computed(() => groupReservationFields(props.fields, t('task.fieldAreaFallback'), props.modelValue))
|
||||||
|
|
||||||
function fieldValue(field: ReservationTaskFieldResult): string | number | readonly string[] {
|
function fieldValue(field: ReservationTaskFieldResult): string | number | readonly string[] {
|
||||||
const value = props.modelValue[field.field_path] ?? field.value ?? ''
|
const value = readReservationFieldValue(field, props.modelValue)
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map((item) => stringifyReservationValue(item, t('task.emptyValue')))
|
return value.map((item) => stringifyReservationValue(item, t('task.emptyValue')))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,16 +34,17 @@ const v3MessageEventFixture: ReservationRecord = {
|
|||||||
history_message_count: 1,
|
history_message_count: 1,
|
||||||
},
|
},
|
||||||
extracted_fields: {
|
extracted_fields: {
|
||||||
pms_room_type_code: null,
|
|
||||||
room_items: [
|
room_items: [
|
||||||
{
|
{
|
||||||
|
room_quantity: 1,
|
||||||
|
room_type_raw: 'Deluxe Q1A',
|
||||||
pms_room_type_code: null,
|
pms_room_type_code: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
manual_review: {
|
manual_review: {
|
||||||
reason_code: 'missing_room_type_code',
|
reason_code: 'missing_room_type_code',
|
||||||
missing_fields: ['/extracted_fields/pms_room_type_code'],
|
missing_fields: ['/extracted_fields/room_items/0/pms_room_type_code'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +578,7 @@ export const reservationTaskDetails: ReservationTaskDetailResult[] = [
|
|||||||
manual_review: {
|
manual_review: {
|
||||||
reason_code: 'missing_room_type_code',
|
reason_code: 'missing_room_type_code',
|
||||||
review_instruction: '请确认 PMS 房型代码后解阻原任务卡。',
|
review_instruction: '请确认 PMS 房型代码后解阻原任务卡。',
|
||||||
missing_fields: ['/extracted_fields/pms_room_type_code'],
|
missing_fields: ['/extracted_fields/room_items/0/pms_room_type_code'],
|
||||||
source_message: {
|
source_message: {
|
||||||
...v3SourceMessageFixture,
|
...v3SourceMessageFixture,
|
||||||
source_message_id: '900085',
|
source_message_id: '900085',
|
||||||
@@ -599,7 +600,7 @@ export const reservationTaskDetails: ReservationTaskDetailResult[] = [
|
|||||||
system_task_type: 'NEW_BOOKING',
|
system_task_type: 'NEW_BOOKING',
|
||||||
task_card_type: 'NEW_BOOKING',
|
task_card_type: 'NEW_BOOKING',
|
||||||
task_status: 'BLOCKED',
|
task_status: 'BLOCKED',
|
||||||
field_contract_version: 'reservation-task-card-v3',
|
field_contract_version: '20260711-p0',
|
||||||
draft_payload: null,
|
draft_payload: null,
|
||||||
confirmed_payload: null,
|
confirmed_payload: null,
|
||||||
availability: {
|
availability: {
|
||||||
@@ -619,12 +620,14 @@ export const reservationTaskDetails: ReservationTaskDetailResult[] = [
|
|||||||
task_type: 'NEW_BOOKING',
|
task_type: 'NEW_BOOKING',
|
||||||
task_subtype: 'NEW_BOOKING',
|
task_subtype: 'NEW_BOOKING',
|
||||||
display_area: '预订信息',
|
display_area: '预订信息',
|
||||||
field_path: 'extracted_fields.pms_room_type_code',
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
display_name: 'PMS 房型代码',
|
display_name: 'PMS 房型代码',
|
||||||
default_value_source: 'manual_review',
|
default_value_source: 'manual_review',
|
||||||
visible: 'Y',
|
visible: 'Y',
|
||||||
editable: 'N',
|
editable: 'Y',
|
||||||
input_editable: 'N',
|
input_editable: 'Y',
|
||||||
select_editable: 'N',
|
select_editable: 'N',
|
||||||
date_picker: 'N',
|
date_picker: 'N',
|
||||||
number_input: 'N',
|
number_input: 'N',
|
||||||
@@ -634,7 +637,7 @@ export const reservationTaskDetails: ReservationTaskDetailResult[] = [
|
|||||||
required_rule: 'Y',
|
required_rule: 'Y',
|
||||||
display_condition: null,
|
display_condition: null,
|
||||||
validation_rule: null,
|
validation_rule: null,
|
||||||
write_path: 'extracted_fields.pms_room_type_code',
|
write_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
opera_write_participation: 'Y',
|
opera_write_participation: 'Y',
|
||||||
opera_parameter_mapping: 'roomTypeCode',
|
opera_parameter_mapping: 'roomTypeCode',
|
||||||
notes: 'V3 同卡人工复核缺失字段。',
|
notes: 'V3 同卡人工复核缺失字段。',
|
||||||
@@ -766,7 +769,7 @@ export const reservationTaskAudits: ReservationTaskAuditListResult[] = [
|
|||||||
after_snapshot: {
|
after_snapshot: {
|
||||||
result_type: 'manual_review',
|
result_type: 'manual_review',
|
||||||
review_status: 'PENDING',
|
review_status: 'PENDING',
|
||||||
missing_fields: ['/extracted_fields/pms_room_type_code'],
|
missing_fields: ['/extracted_fields/room_items/0/pms_room_type_code'],
|
||||||
},
|
},
|
||||||
occurred_at: '2026-07-08T08:15:00+08:00',
|
occurred_at: '2026-07-08T08:15:00+08:00',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ReservationOrderDetailResult,
|
ReservationOrderDetailResult,
|
||||||
ReservationOrderListFilters,
|
ReservationOrderListFilters,
|
||||||
ReservationOrderListResult,
|
ReservationOrderListResult,
|
||||||
|
ReservationRecord,
|
||||||
ReservationTaskAuditListResult,
|
ReservationTaskAuditListResult,
|
||||||
ReservationTaskDetailResult,
|
ReservationTaskDetailResult,
|
||||||
ReservationTaskListFilters,
|
ReservationTaskListFilters,
|
||||||
@@ -201,8 +202,8 @@ function fixtureManualReviewResolution(
|
|||||||
request: ReservationManualReviewResolutionRequest,
|
request: ReservationManualReviewResolutionRequest,
|
||||||
): Promise<ReservationManualReviewResolutionResult> {
|
): Promise<ReservationManualReviewResolutionResult> {
|
||||||
return fixtureTaskDetail(taskId).then((detail) => {
|
return fixtureTaskDetail(taskId).then((detail) => {
|
||||||
const confirmedPayload = Object.fromEntries(
|
const fieldValues = Object.fromEntries(
|
||||||
request.field_overrides.map((item) => [fieldPointerToFixturePath(item.field_pointer), item.value]),
|
request.field_overrides.map((item) => [fieldOverrideToFixturePath(item), item.value]),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
task_id: detail.task_id,
|
task_id: detail.task_id,
|
||||||
@@ -215,19 +216,86 @@ function fixtureManualReviewResolution(
|
|||||||
field_overrides: request.field_overrides,
|
field_overrides: request.field_overrides,
|
||||||
},
|
},
|
||||||
confirmed_payload: {
|
confirmed_payload: {
|
||||||
...(detail.confirmed_payload ?? {}),
|
field_values: fieldValues,
|
||||||
...confirmedPayload,
|
legacy_field_values: legacyFieldValues(fieldValues),
|
||||||
|
effective_payload: effectivePayload(fieldValues),
|
||||||
},
|
},
|
||||||
opera_operations: detail.opera_operations,
|
opera_operations: detail.opera_operations,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldPointerToFixturePath(fieldPointer: string): string {
|
function fieldOverrideToFixturePath(
|
||||||
if (fieldPointer === '/extracted_fields/room_items/0/pms_room_type_code') {
|
fieldOverride: ReservationManualReviewResolutionRequest['field_overrides'][number],
|
||||||
return 'extracted_fields.pms_room_type_code'
|
): string {
|
||||||
|
if (fieldOverride.field_path) {
|
||||||
|
return canonicalFixtureFieldPath(fieldOverride.field_path)
|
||||||
}
|
}
|
||||||
return fieldPointer.replace(/^\//, '').replace(/\//g, '.')
|
if (fieldOverride.field_pointer) {
|
||||||
|
return canonicalFixtureFieldPath(fieldOverride.field_pointer.replace(/^\//, '').replace(/\//g, '.'))
|
||||||
|
}
|
||||||
|
throw new Error('Manual review field override requires field_pointer or field_path')
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalFixtureFieldPath(fieldPath: string): string {
|
||||||
|
if (fieldPath === 'extracted_fields.room_quantity') {
|
||||||
|
return 'extracted_fields.room_items.0.room_quantity'
|
||||||
|
}
|
||||||
|
if (fieldPath === 'extracted_fields.room_type') {
|
||||||
|
return 'extracted_fields.room_items.0.room_type_raw'
|
||||||
|
}
|
||||||
|
if (fieldPath === 'extracted_fields.pms_room_type_code') {
|
||||||
|
return 'extracted_fields.room_items.0.pms_room_type_code'
|
||||||
|
}
|
||||||
|
return fieldPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyFieldValues(fieldValues: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return Object.entries(fieldValues).reduce<Record<string, unknown>>((legacyValues, [fieldPath, value]) => {
|
||||||
|
if (fieldPath === 'extracted_fields.room_items.0.room_quantity') {
|
||||||
|
legacyValues['extracted_fields.room_quantity'] = value
|
||||||
|
}
|
||||||
|
if (fieldPath === 'extracted_fields.room_items.0.room_type_raw') {
|
||||||
|
legacyValues['extracted_fields.room_type'] = value
|
||||||
|
}
|
||||||
|
if (fieldPath === 'extracted_fields.room_items.0.pms_room_type_code') {
|
||||||
|
legacyValues['extracted_fields.pms_room_type_code'] = value
|
||||||
|
}
|
||||||
|
return legacyValues
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectivePayload(fieldValues: Record<string, unknown>): ReservationRecord {
|
||||||
|
return Object.entries(fieldValues).reduce<ReservationRecord>((payload, [fieldPath, value]) => {
|
||||||
|
writeNestedFieldValue(payload, fieldPath, value)
|
||||||
|
return payload
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeNestedFieldValue(payload: ReservationRecord, fieldPath: string, value: unknown): void {
|
||||||
|
const segments = fieldPath.split('.')
|
||||||
|
let current: ReservationRecord | unknown[] = payload
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
const last = index === segments.length - 1
|
||||||
|
const nextSegment = segments[index + 1]
|
||||||
|
if (last) {
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
current[Number(segment)] = value
|
||||||
|
} else {
|
||||||
|
current[segment] = value
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextIsArray = nextSegment !== undefined && /^\d+$/.test(nextSegment)
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
const arrayIndex = Number(segment)
|
||||||
|
current[arrayIndex] ??= nextIsArray ? [] : {}
|
||||||
|
current = current[arrayIndex] as ReservationRecord | unknown[]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current[segment] ??= nextIsArray ? [] : {}
|
||||||
|
current = current[segment] as ReservationRecord | unknown[]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fixtureOperaOperation(
|
async function fixtureOperaOperation(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import type { ReservationTaskFieldResult } from '@/types/reservation'
|
import type { ReservationTaskFieldResult } from '@/types/reservation'
|
||||||
import {
|
import {
|
||||||
|
buildInitialFieldValues,
|
||||||
buildEditableFieldValues,
|
buildEditableFieldValues,
|
||||||
groupReservationFields,
|
groupReservationFields,
|
||||||
validateReservationFieldValues,
|
validateReservationFieldValues,
|
||||||
@@ -147,4 +148,156 @@ describe('validateReservationFieldValues', () => {
|
|||||||
'case_keys.group_code': 'GRP-001',
|
'case_keys.group_code': 'GRP-001',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hydrates P0 room_items fields from field_values and submits main field_path keys', () => {
|
||||||
|
const roomTypeField = createField({
|
||||||
|
field_path: 'extracted_fields.room_items.0.room_type_raw',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/room_type_raw',
|
||||||
|
legacy_field_path: 'extracted_fields.room_type',
|
||||||
|
display_name: '房型原文',
|
||||||
|
})
|
||||||
|
const pmsCodeField = createField({
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
|
display_name: 'PMS 房型代码',
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = buildInitialFieldValues(
|
||||||
|
[roomTypeField, pmsCodeField],
|
||||||
|
{
|
||||||
|
field_values: {
|
||||||
|
'extracted_fields.room_items.0.room_type_raw': 'Deluxe Q1A',
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'Q1A',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'20260711-p0',
|
||||||
|
)
|
||||||
|
const payload = buildEditableFieldValues([roomTypeField, pmsCodeField], values, false)
|
||||||
|
|
||||||
|
expect(values).toMatchObject({
|
||||||
|
'extracted_fields.room_items.0.room_type_raw': 'Deluxe Q1A',
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'Q1A',
|
||||||
|
})
|
||||||
|
expect(payload).toEqual({
|
||||||
|
'extracted_fields.room_items.0.room_type_raw': 'Deluxe Q1A',
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'Q1A',
|
||||||
|
})
|
||||||
|
expect(payload).not.toHaveProperty('extracted_fields.room_type')
|
||||||
|
expect(payload).not.toHaveProperty('extracted_fields.pms_room_type_code')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates P0 fields from nested field_values as a defensive fallback', () => {
|
||||||
|
const values = buildInitialFieldValues(
|
||||||
|
[
|
||||||
|
createField({
|
||||||
|
field_path: 'extracted_fields.room_items.0.room_quantity',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/room_quantity',
|
||||||
|
legacy_field_path: 'extracted_fields.room_quantity',
|
||||||
|
display_name: '房间数量',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
field_values: {
|
||||||
|
extracted_fields: {
|
||||||
|
room_items: [
|
||||||
|
{
|
||||||
|
room_quantity: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'20260711-p0',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(values).toEqual({
|
||||||
|
'extracted_fields.room_items.0.room_quantity': 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates code-v1 historical fields from legacy_field_values into P0 main keys', () => {
|
||||||
|
const values = buildInitialFieldValues(
|
||||||
|
[
|
||||||
|
createField({
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
|
display_name: 'PMS 房型代码',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
field_values: {
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'NEW',
|
||||||
|
},
|
||||||
|
legacy_field_values: {
|
||||||
|
'extracted_fields.pms_room_type_code': 'LEGACY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'code-v1',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(values).toEqual({
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'LEGACY',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hydrates code-v1 historical fields from legacy effective_payload when legacy_field_values is absent', () => {
|
||||||
|
const values = buildInitialFieldValues(
|
||||||
|
[
|
||||||
|
createField({
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
|
display_name: 'PMS 房型代码',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
effective_payload: {
|
||||||
|
extracted_fields: {
|
||||||
|
pms_room_type_code: 'LEGACY-NESTED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'code-v1',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(values).toEqual({
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'LEGACY-NESTED',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create frontend required errors for readonly evidence fields missing in V3 payloads', () => {
|
||||||
|
const errors = validateReservationFieldValues(
|
||||||
|
[
|
||||||
|
createField({
|
||||||
|
field_path: 'visible_reason',
|
||||||
|
display_name: '生成原因',
|
||||||
|
editable: 'N',
|
||||||
|
input_editable: 'N',
|
||||||
|
required_rule: 'Y',
|
||||||
|
value: null,
|
||||||
|
}),
|
||||||
|
createField({
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
display_name: 'PMS 房型代码',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
|
required_rule: 'Y',
|
||||||
|
value: '',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
relevant_message_excerpt: 'Please book a Deluxe Q1A room.',
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(errors).toEqual({
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': {
|
||||||
|
code: 'required',
|
||||||
|
field_name: 'PMS 房型代码',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -245,13 +245,20 @@ describe('reservationService real API mode', () => {
|
|||||||
confirmed_order_id: '20001',
|
confirmed_order_id: '20001',
|
||||||
field_overrides: [
|
field_overrides: [
|
||||||
{
|
{
|
||||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
value: 'RM3',
|
value: 'RM3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
confirmed_payload: {
|
confirmed_payload: {
|
||||||
'extracted_fields.pms_room_type_code': 'RM3',
|
field_values: {
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'RM3',
|
||||||
|
},
|
||||||
|
legacy_field_values: {
|
||||||
|
'extracted_fields.pms_room_type_code': 'RM3',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
opera_operations: [],
|
opera_operations: [],
|
||||||
}),
|
}),
|
||||||
@@ -262,7 +269,8 @@ describe('reservationService real API mode', () => {
|
|||||||
reason: '确认 PMS 房型代码',
|
reason: '确认 PMS 房型代码',
|
||||||
field_overrides: [
|
field_overrides: [
|
||||||
{
|
{
|
||||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
value: 'RM3',
|
value: 'RM3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -280,7 +288,8 @@ describe('reservationService real API mode', () => {
|
|||||||
reason: '确认 PMS 房型代码',
|
reason: '确认 PMS 房型代码',
|
||||||
field_overrides: [
|
field_overrides: [
|
||||||
{
|
{
|
||||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
value: 'RM3',
|
value: 'RM3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -149,13 +149,20 @@ function createManualReviewResolutionResult(
|
|||||||
confirmed_order_id: '20001',
|
confirmed_order_id: '20001',
|
||||||
field_overrides: [
|
field_overrides: [
|
||||||
{
|
{
|
||||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
value: 'RM3',
|
value: 'RM3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
confirmed_payload: {
|
confirmed_payload: {
|
||||||
'extracted_fields.pms_room_type_code': 'RM3',
|
field_values: {
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'RM3',
|
||||||
|
},
|
||||||
|
legacy_field_values: {
|
||||||
|
'extracted_fields.pms_room_type_code': 'RM3',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
opera_operations: [],
|
opera_operations: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -424,6 +431,48 @@ describe('ReservationTaskDetailPanel', () => {
|
|||||||
expect(wrapper.text()).not.toContain('房型为必填项')
|
expect(wrapper.text()).not.toContain('房型为必填项')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('hydrates code-v1 legacy payload values but saves future drafts with P0 field paths', async () => {
|
||||||
|
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||||
|
createTaskDetail({
|
||||||
|
field_contract_version: 'code-v1',
|
||||||
|
draft_payload: {
|
||||||
|
field_values: {
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'P0-SHOULD-NOT-WIN',
|
||||||
|
},
|
||||||
|
legacy_field_values: {
|
||||||
|
'extracted_fields.pms_room_type_code': 'LEGACY-RM',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
createRequiredField({
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
|
display_name: 'PMS 房型代码',
|
||||||
|
value: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult())
|
||||||
|
|
||||||
|
const wrapper = await mountPanel()
|
||||||
|
const pmsInput = wrapper.find('textarea')
|
||||||
|
|
||||||
|
expect((pmsInput.element as HTMLTextAreaElement).value).toBe('LEGACY-RM')
|
||||||
|
expect(wrapper.text()).not.toContain('P0-SHOULD-NOT-WIN')
|
||||||
|
|
||||||
|
await pmsInput.setValue('RM4')
|
||||||
|
await findButton(wrapper, '保存草稿').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(service.saveReservationTaskDraft).toHaveBeenCalledWith('10001', {
|
||||||
|
field_values: {
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': 'RM4',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('refreshes audit records after saving a draft', async () => {
|
it('refreshes audit records after saving a draft', async () => {
|
||||||
vi.mocked(service.fetchReservationTaskAudits)
|
vi.mocked(service.fetchReservationTaskAudits)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@@ -551,7 +600,7 @@ describe('ReservationTaskDetailPanel', () => {
|
|||||||
review_status: 'PENDING',
|
review_status: 'PENDING',
|
||||||
manual_review: {
|
manual_review: {
|
||||||
reason_code: 'missing_room_type_code',
|
reason_code: 'missing_room_type_code',
|
||||||
missing_fields: ['/extracted_fields/pms_room_type_code'],
|
missing_fields: ['/extracted_fields/room_items/0/pms_room_type_code'],
|
||||||
review_instruction: '请补充 PMS 房型代码。',
|
review_instruction: '请补充 PMS 房型代码。',
|
||||||
},
|
},
|
||||||
availability: {
|
availability: {
|
||||||
@@ -565,7 +614,9 @@ describe('ReservationTaskDetailPanel', () => {
|
|||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
createRequiredField({
|
createRequiredField({
|
||||||
field_path: 'extracted_fields.pms_room_type_code',
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
display_name: 'PMS 房型代码',
|
display_name: 'PMS 房型代码',
|
||||||
editable: 'N',
|
editable: 'N',
|
||||||
input_editable: 'N',
|
input_editable: 'N',
|
||||||
@@ -587,7 +638,9 @@ describe('ReservationTaskDetailPanel', () => {
|
|||||||
confirmed_order_id: '20001',
|
confirmed_order_id: '20001',
|
||||||
field_overrides: [
|
field_overrides: [
|
||||||
{
|
{
|
||||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
|
legacy_field_path: 'extracted_fields.pms_room_type_code',
|
||||||
value: 'RM3',
|
value: 'RM3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -621,7 +674,7 @@ describe('ReservationTaskDetailPanel', () => {
|
|||||||
expect(wrapper.text()).toContain('任务卡类型')
|
expect(wrapper.text()).toContain('任务卡类型')
|
||||||
expect(wrapper.text()).toContain('NEW_BOOKING_MANUAL_REVIEW')
|
expect(wrapper.text()).toContain('NEW_BOOKING_MANUAL_REVIEW')
|
||||||
expect(wrapper.text()).toContain('人工复核解阻')
|
expect(wrapper.text()).toContain('人工复核解阻')
|
||||||
expect(wrapper.text()).toContain('/extracted_fields/pms_room_type_code')
|
expect(wrapper.text()).toContain('/extracted_fields/room_items/0/pms_room_type_code')
|
||||||
expect(wrapper.text()).toContain('PMS 房型代码')
|
expect(wrapper.text()).toContain('PMS 房型代码')
|
||||||
expect(wrapper.text()).not.toContain('人工转换')
|
expect(wrapper.text()).not.toContain('人工转换')
|
||||||
expect(wrapper.text()).not.toContain('保存草稿')
|
expect(wrapper.text()).not.toContain('保存草稿')
|
||||||
@@ -638,7 +691,8 @@ describe('ReservationTaskDetailPanel', () => {
|
|||||||
reason: '确认 PMS 房型代码',
|
reason: '确认 PMS 房型代码',
|
||||||
field_overrides: [
|
field_overrides: [
|
||||||
{
|
{
|
||||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||||
|
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||||
value: 'RM3',
|
value: 'RM3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -296,6 +296,8 @@ export interface ReservationTaskFieldResult {
|
|||||||
task_subtype?: string | null
|
task_subtype?: string | null
|
||||||
display_area: string | null
|
display_area: string | null
|
||||||
field_path: string
|
field_path: string
|
||||||
|
field_pointer?: string | null
|
||||||
|
legacy_field_path?: string | null
|
||||||
display_name: string
|
display_name: string
|
||||||
default_value_source?: string | null
|
default_value_source?: string | null
|
||||||
visible: string | null
|
visible: string | null
|
||||||
@@ -406,8 +408,13 @@ export interface ReservationManualReviewConversionResult {
|
|||||||
original_order_logic_deleted: boolean
|
original_order_logic_deleted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationManualReviewResolutionFieldOverrideRequest {
|
export type ReservationManualReviewResolutionFieldOverrideRequest = {
|
||||||
field_pointer: string
|
field_pointer: string
|
||||||
|
field_path?: string
|
||||||
|
value: unknown
|
||||||
|
} | {
|
||||||
|
field_pointer?: string
|
||||||
|
field_path: string
|
||||||
value: unknown
|
value: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import type { ReservationRecord, ReservationTaskFieldResult } from '@/types/rese
|
|||||||
|
|
||||||
const truthyRuleValues = new Set(['Y', 'YES', 'TRUE', '1', '是', '可编辑', 'ENABLED'])
|
const truthyRuleValues = new Set(['Y', 'YES', 'TRUE', '1', '是', '可编辑', 'ENABLED'])
|
||||||
const falsyRuleValues = new Set(['N', 'NO', 'FALSE', '0', '否', '不可编辑', 'DISABLED', '隐藏'])
|
const falsyRuleValues = new Set(['N', 'NO', 'FALSE', '0', '否', '不可编辑', 'DISABLED', '隐藏'])
|
||||||
|
const roomItemFieldPathAliases: Record<string, string[]> = {
|
||||||
|
'extracted_fields.room_quantity': ['extracted_fields.room_items.0.room_quantity'],
|
||||||
|
'extracted_fields.room_type': [
|
||||||
|
'extracted_fields.room_items.0.room_type_raw',
|
||||||
|
'extracted_fields.room_items.0.room_type_normalized',
|
||||||
|
],
|
||||||
|
'extracted_fields.pms_room_type_code': ['extracted_fields.room_items.0.pms_room_type_code'],
|
||||||
|
'extracted_fields.room_items.0.room_quantity': ['extracted_fields.room_quantity'],
|
||||||
|
'extracted_fields.room_items.0.room_type_raw': [
|
||||||
|
'extracted_fields.room_type',
|
||||||
|
'extracted_fields.room_items.0.room_type_normalized',
|
||||||
|
],
|
||||||
|
'extracted_fields.room_items.0.pms_room_type_code': ['extracted_fields.pms_room_type_code'],
|
||||||
|
}
|
||||||
|
|
||||||
export interface FieldAreaGroup {
|
export interface FieldAreaGroup {
|
||||||
area: string
|
area: string
|
||||||
@@ -166,9 +180,13 @@ export function parseEnumOptions(enumOptions: string | null): EnumOption[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInitialFieldValues(fields: ReservationTaskFieldResult[], payload: ReservationRecord | null): ReservationRecord {
|
export function buildInitialFieldValues(
|
||||||
|
fields: ReservationTaskFieldResult[],
|
||||||
|
payload: ReservationRecord | null,
|
||||||
|
fieldContractVersion?: string | null,
|
||||||
|
): ReservationRecord {
|
||||||
return fields.reduce<ReservationRecord>((values, field) => {
|
return fields.reduce<ReservationRecord>((values, field) => {
|
||||||
values[field.field_path] = payload?.[field.field_path] ?? field.value ?? ''
|
values[field.field_path] = readReservationFieldValue(field, payload ?? {}, fieldContractVersion)
|
||||||
return values
|
return values
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
@@ -185,7 +203,7 @@ export function buildEditableFieldValues(
|
|||||||
.filter((field) => isReservationFieldActive(field, values))
|
.filter((field) => isReservationFieldActive(field, values))
|
||||||
.filter((field) => isReservationFieldEditable(field, readOnly))
|
.filter((field) => isReservationFieldEditable(field, readOnly))
|
||||||
.reduce<ReservationRecord>((payload, field) => {
|
.reduce<ReservationRecord>((payload, field) => {
|
||||||
payload[field.field_path] = field.field_path in values ? values[field.field_path] : field.value ?? ''
|
payload[field.field_path] = readReservationFieldValue(field, values)
|
||||||
return payload
|
return payload
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
@@ -195,13 +213,14 @@ export function validateReservationFieldValues(
|
|||||||
values: ReservationRecord,
|
values: ReservationRecord,
|
||||||
): ReservationFieldValidationErrors {
|
): ReservationFieldValidationErrors {
|
||||||
return fields.filter((field) => isReservationFieldActive(field, values)).reduce<ReservationFieldValidationErrors>((errors, field) => {
|
return fields.filter((field) => isReservationFieldActive(field, values)).reduce<ReservationFieldValidationErrors>((errors, field) => {
|
||||||
const value = values[field.field_path] ?? field.value ?? ''
|
const value = readReservationFieldValue(field, values)
|
||||||
if (isReservationFieldRequired(field) && isEmptyReservationValue(value)) {
|
const shouldValidateInput = isReservationFieldEditable(field, false)
|
||||||
|
if (shouldValidateInput && isReservationFieldRequired(field) && isEmptyReservationValue(value)) {
|
||||||
errors[field.field_path] = createValidationError(field, 'required')
|
errors[field.field_path] = createValidationError(field, 'required')
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEmptyReservationValue(value)) {
|
if (!shouldValidateInput || isEmptyReservationValue(value)) {
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +246,9 @@ function valueForConditionPath(
|
|||||||
currentField: ReservationTaskFieldResult,
|
currentField: ReservationTaskFieldResult,
|
||||||
fieldPath: string,
|
fieldPath: string,
|
||||||
): unknown {
|
): unknown {
|
||||||
if (fieldPath in values) {
|
const value = readValueByFieldPath(values, fieldPath)
|
||||||
return values[fieldPath]
|
if (value !== undefined) {
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
if (fieldPath === currentField.field_path) {
|
if (fieldPath === currentField.field_path) {
|
||||||
return currentField.value
|
return currentField.value
|
||||||
@@ -306,6 +326,121 @@ function isIsoLocalDate(value: string): boolean {
|
|||||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readReservationFieldValue(
|
||||||
|
field: ReservationTaskFieldResult,
|
||||||
|
values: ReservationRecord,
|
||||||
|
fieldContractVersion?: string | null,
|
||||||
|
): unknown {
|
||||||
|
const fieldValues = recordProperty(values, 'field_values') ?? values
|
||||||
|
const legacyValues = recordProperty(values, 'legacy_field_values')
|
||||||
|
const effectivePayload = recordProperty(values, 'effective_payload')
|
||||||
|
const legacyFieldPath = field.legacy_field_path ?? undefined
|
||||||
|
const codeV1 = normalizeContractVersion(fieldContractVersion) === 'CODE_V1'
|
||||||
|
const mainCandidates = fieldPathCandidates(field.field_path)
|
||||||
|
const legacyCandidates = legacyFieldPath ? fieldPathCandidates(legacyFieldPath) : []
|
||||||
|
|
||||||
|
if (codeV1) {
|
||||||
|
return firstDefined([
|
||||||
|
() => firstValueByPaths(legacyValues, legacyCandidates),
|
||||||
|
() => firstValueByPaths(fieldValues, legacyCandidates),
|
||||||
|
() => firstNestedValueByPaths(fieldValues, legacyCandidates),
|
||||||
|
() => firstValueByPaths(values, legacyCandidates),
|
||||||
|
() => firstNestedValueByPaths(effectivePayload, legacyCandidates),
|
||||||
|
() => firstValueByPaths(fieldValues, mainCandidates),
|
||||||
|
() => firstNestedValueByPaths(fieldValues, mainCandidates),
|
||||||
|
() => firstValueByPaths(values, mainCandidates),
|
||||||
|
() => firstNestedValueByPaths(effectivePayload, mainCandidates),
|
||||||
|
() => field.value,
|
||||||
|
() => '',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstDefined([
|
||||||
|
() => firstValueByPaths(fieldValues, mainCandidates),
|
||||||
|
() => firstNestedValueByPaths(fieldValues, mainCandidates),
|
||||||
|
() => firstValueByPaths(values, mainCandidates),
|
||||||
|
() => firstNestedValueByPaths(effectivePayload, mainCandidates),
|
||||||
|
() => firstValueByPaths(legacyValues, legacyCandidates),
|
||||||
|
() => firstValueByPaths(fieldValues, legacyCandidates),
|
||||||
|
() => firstNestedValueByPaths(fieldValues, legacyCandidates),
|
||||||
|
() => firstValueByPaths(values, legacyCandidates),
|
||||||
|
() => field.value,
|
||||||
|
() => '',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function readValueByFieldPath(values: ReservationRecord, fieldPath: string): unknown {
|
||||||
|
const flatValue = firstValueByPaths(values, fieldPathCandidates(fieldPath))
|
||||||
|
if (flatValue !== undefined) {
|
||||||
|
return flatValue
|
||||||
|
}
|
||||||
|
return readNestedPath(values, fieldPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldPathCandidates(fieldPath: string): string[] {
|
||||||
|
return [fieldPath, ...(roomItemFieldPathAliases[fieldPath] ?? [])]
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstValueByPaths(values: ReservationRecord | undefined, fieldPaths: string[]): unknown {
|
||||||
|
if (!values) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
for (const fieldPath of fieldPaths) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(values, fieldPath)) {
|
||||||
|
return values[fieldPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNestedValueByPaths(values: ReservationRecord | undefined, fieldPaths: string[]): unknown {
|
||||||
|
if (!values) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
for (const fieldPath of fieldPaths) {
|
||||||
|
const value = readNestedPath(values, fieldPath)
|
||||||
|
if (value !== undefined) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNestedPath(values: ReservationRecord, fieldPath: string): unknown {
|
||||||
|
return fieldPath.split('.').reduce<unknown>((current, segment) => {
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
const index = Number(segment)
|
||||||
|
return Number.isInteger(index) ? current[index] : undefined
|
||||||
|
}
|
||||||
|
if (typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, segment)) {
|
||||||
|
return (current as ReservationRecord)[segment]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstDefined(candidates: Array<() => unknown>): unknown {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const value = candidate()
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordProperty(values: ReservationRecord, key: string): ReservationRecord | undefined {
|
||||||
|
const value = values[key]
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as ReservationRecord : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeContractVersion(version: string | null | undefined): string {
|
||||||
|
return version?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
function isRuleTruthy(value: string | null): boolean {
|
function isRuleTruthy(value: string | null): boolean {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ POST /api/system/reservation/demo-data
|
|||||||
- 任务列表已按 `result_type`、`route_code`、`system_process_category` 识别 `source_message_review_notification`、`adapter_contract_error`、`unhandled_current_intent` 只读诊断任务;S10/S99 和旧 S000/S999 都不展示订单入口。
|
- 任务列表已按 `result_type`、`route_code`、`system_process_category` 识别 `source_message_review_notification`、`adapter_contract_error`、`unhandled_current_intent` 只读诊断任务;S10/S99 和旧 S000/S999 都不展示订单入口。
|
||||||
- 任务详情已展示 `result_type`、`ai_task_type`、`task_subtype`、`route_code`、`system_process_category`、`review_status`、来源邮件入口、`source_message_only_result`、`manual_review`、`adapter_contract_errors[]` 和 `unhandled_intents[]`。
|
- 任务详情已展示 `result_type`、`ai_task_type`、`task_subtype`、`route_code`、`system_process_category`、`review_status`、来源邮件入口、`source_message_only_result`、`manual_review`、`adapter_contract_errors[]` 和 `unhandled_intents[]`。
|
||||||
- type-known `result_type=manual_review` 已在原业务任务卡展示复核状态和缺失字段,并调用 `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` 解阻,不再创建第二张人工复核任务卡。
|
- type-known `result_type=manual_review` 已在原业务任务卡展示复核状态和缺失字段,并调用 `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` 解阻,不再创建第二张人工复核任务卡。
|
||||||
- 第一版解阻 UI 可提交 `/extracted_fields/pms_room_type_code` 和 `/extracted_fields/room_items/0/pms_room_type_code` 两类 pointer;后端也支持提交 P0 主 `field_path` 或旧扁平 `field_path`,提交成功后刷新任务详情和审计流水。
|
- 第一版解阻 UI 已改为优先使用任务详情 `fields[].field_pointer`,并同时提交 P0 主 `fields[].field_path`;旧扁平 `legacy_field_path` / `legacy_field_values` 仅用于 `field_contract_version=code-v1` 历史任务过渡回显,不作为新前端主动提交路径。
|
||||||
- 前端只读规则已收口:S10/S99、适配契约异常、未处理意图、前置任务阻塞和同卡人工复核待解阻状态都不显示保存草稿、确认任务、人工转换或 OPERA 执行 / 重试入口。
|
- 前端只读规则已收口:S10/S99、适配契约异常、未处理意图、前置任务阻塞和同卡人工复核待解阻状态都不显示保存草稿、确认任务、人工转换或 OPERA 执行 / 重试入口。
|
||||||
- 前端 fixture 已补 V3 最小结构样例:`source_message` 完整对象、`message_events[]` 的 `event_role`、`current_or_history`、`source_event_index`、四字段 `case_keys`、`relevant_message_excerpt`、`attachments`、`file_references`、`context_used`、`extracted_fields`、`manual_review`。
|
- 前端 fixture 已补 V3 最小结构样例:`source_message` 完整对象、`message_events[]` 的 `event_role`、`current_or_history`、`source_event_index`、四字段 `case_keys`、`relevant_message_excerpt`、`attachments`、`file_references`、`context_used`、`extracted_fields`、`manual_review`。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user