适配 M002 V3 字段矩阵前端交互
This commit is contained in:
@@ -55,14 +55,14 @@
|
||||
>
|
||||
<label
|
||||
v-for="(item, index) in supportedMissingFields"
|
||||
:key="item.pointer"
|
||||
:key="item.key"
|
||||
>
|
||||
<span>
|
||||
{{ item.label }}
|
||||
<small class="th-code">{{ item.pointer }}</small>
|
||||
<small class="th-code">{{ item.pointer ?? item.fieldPath }}</small>
|
||||
</span>
|
||||
<input
|
||||
v-model="fieldOverrideInputs[item.pointer]"
|
||||
v-model="fieldOverrideInputs[item.key]"
|
||||
:name="`manual_resolution_field_override_${index}`"
|
||||
type="text"
|
||||
:disabled="busy"
|
||||
@@ -124,11 +124,15 @@ import type {
|
||||
ReservationTaskFieldResult,
|
||||
} from '@/types/reservation'
|
||||
import { normalizeStableCode } from '@/utils/reservationDisplay'
|
||||
import { readReservationFieldValue } from '@/utils/reservationFieldRules'
|
||||
|
||||
const supportedFieldPointers = [
|
||||
'/extracted_fields/pms_room_type_code',
|
||||
'/extracted_fields/room_items/0/pms_room_type_code',
|
||||
] as const
|
||||
interface ManualResolutionFieldItem {
|
||||
key: string
|
||||
pointer?: string
|
||||
fieldPath: string
|
||||
label: string
|
||||
field?: ReservationTaskFieldResult
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -163,17 +167,14 @@ const isResolved = computed(() => normalizeStableCode(props.reviewStatus) === 'R
|
||||
const reviewStatusLabel = computed(() => props.reviewStatus || t('task.manualResolution.pendingStatus'))
|
||||
const supportedMissingFields = computed(() =>
|
||||
extractMissingFields(props.manualReview)
|
||||
.filter((pointer) => supportedFieldPointers.includes(pointer as (typeof supportedFieldPointers)[number]))
|
||||
.map((pointer) => ({
|
||||
pointer,
|
||||
label: findFieldLabel(pointer),
|
||||
})),
|
||||
.map((reference) => resolveMissingField(reference))
|
||||
.filter((item): item is ManualResolutionFieldItem => item !== null),
|
||||
)
|
||||
const canSubmit = computed(() => {
|
||||
if (!supportedMissingFields.value.length || !confirmedOrderId.value.trim()) {
|
||||
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(
|
||||
@@ -188,7 +189,7 @@ watch(
|
||||
(items) => {
|
||||
const nextInputs: Record<string, string> = {}
|
||||
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
|
||||
},
|
||||
@@ -206,8 +207,9 @@ function submitResolution(): void {
|
||||
confirmed_order_id: confirmedOrderId.value.trim(),
|
||||
reason: reason.value.trim() || undefined,
|
||||
field_overrides: supportedMissingFields.value.map((item) => ({
|
||||
field_pointer: item.pointer,
|
||||
value: String(fieldOverrideInputs.value[item.pointer] ?? '').trim(),
|
||||
...(item.pointer ? { field_pointer: item.pointer } : {}),
|
||||
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)) {
|
||||
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 {
|
||||
const fieldPath = fieldPointerToFieldPath(pointer)
|
||||
return props.fields.find((field) => field.field_path === fieldPath)?.display_name ?? fieldPath
|
||||
function resolveMissingField(reference: string): ManualResolutionFieldItem | null {
|
||||
const pointer = reference.startsWith('/') ? reference : undefined
|
||||
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 {
|
||||
const fieldPath = fieldPointerToFieldPath(pointer)
|
||||
const value = props.fieldValues[fieldPath] ?? props.fields.find((field) => field.field_path === fieldPath)?.value
|
||||
function readCurrentFieldValue(item: ManualResolutionFieldItem): string {
|
||||
const value = item.field
|
||||
? readReservationFieldValue(item.field, props.fieldValues)
|
||||
: props.fieldValues[item.fieldPath]
|
||||
return value === undefined || value === null ? '' : String(value)
|
||||
}
|
||||
|
||||
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, '.')
|
||||
}
|
||||
|
||||
|
||||
@@ -594,7 +594,11 @@ async function mutateTaskPayload(action: 'save' | 'confirm'): Promise<void> {
|
||||
function applyTaskData(nextDetail: ReservationTaskDetailResult, nextAudits: ReservationTaskAuditLogResult[]): void {
|
||||
detail.value = nextDetail
|
||||
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 = {}
|
||||
validationSummaryActive.value = false
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ import {
|
||||
isReservationFieldRequired,
|
||||
isSelectField,
|
||||
isTableField,
|
||||
readReservationFieldValue,
|
||||
parseEnumOptions,
|
||||
stringifyReservationValue,
|
||||
} from '@/utils/reservationFieldRules'
|
||||
@@ -135,7 +136,7 @@ const { t } = useI18n()
|
||||
const fieldGroups = computed(() => groupReservationFields(props.fields, t('task.fieldAreaFallback'), props.modelValue))
|
||||
|
||||
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)) {
|
||||
return value.map((item) => stringifyReservationValue(item, t('task.emptyValue')))
|
||||
}
|
||||
|
||||
@@ -34,16 +34,17 @@ const v3MessageEventFixture: ReservationRecord = {
|
||||
history_message_count: 1,
|
||||
},
|
||||
extracted_fields: {
|
||||
pms_room_type_code: null,
|
||||
room_items: [
|
||||
{
|
||||
room_quantity: 1,
|
||||
room_type_raw: 'Deluxe Q1A',
|
||||
pms_room_type_code: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
manual_review: {
|
||||
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: {
|
||||
reason_code: 'missing_room_type_code',
|
||||
review_instruction: '请确认 PMS 房型代码后解阻原任务卡。',
|
||||
missing_fields: ['/extracted_fields/pms_room_type_code'],
|
||||
missing_fields: ['/extracted_fields/room_items/0/pms_room_type_code'],
|
||||
source_message: {
|
||||
...v3SourceMessageFixture,
|
||||
source_message_id: '900085',
|
||||
@@ -599,7 +600,7 @@ export const reservationTaskDetails: ReservationTaskDetailResult[] = [
|
||||
system_task_type: 'NEW_BOOKING',
|
||||
task_card_type: 'NEW_BOOKING',
|
||||
task_status: 'BLOCKED',
|
||||
field_contract_version: 'reservation-task-card-v3',
|
||||
field_contract_version: '20260711-p0',
|
||||
draft_payload: null,
|
||||
confirmed_payload: null,
|
||||
availability: {
|
||||
@@ -619,12 +620,14 @@ export const reservationTaskDetails: ReservationTaskDetailResult[] = [
|
||||
task_type: 'NEW_BOOKING',
|
||||
task_subtype: 'NEW_BOOKING',
|
||||
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 房型代码',
|
||||
default_value_source: 'manual_review',
|
||||
visible: 'Y',
|
||||
editable: 'N',
|
||||
input_editable: 'N',
|
||||
editable: 'Y',
|
||||
input_editable: 'Y',
|
||||
select_editable: 'N',
|
||||
date_picker: 'N',
|
||||
number_input: 'N',
|
||||
@@ -634,7 +637,7 @@ export const reservationTaskDetails: ReservationTaskDetailResult[] = [
|
||||
required_rule: 'Y',
|
||||
display_condition: 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_parameter_mapping: 'roomTypeCode',
|
||||
notes: 'V3 同卡人工复核缺失字段。',
|
||||
@@ -766,7 +769,7 @@ export const reservationTaskAudits: ReservationTaskAuditListResult[] = [
|
||||
after_snapshot: {
|
||||
result_type: 'manual_review',
|
||||
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',
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ReservationOrderDetailResult,
|
||||
ReservationOrderListFilters,
|
||||
ReservationOrderListResult,
|
||||
ReservationRecord,
|
||||
ReservationTaskAuditListResult,
|
||||
ReservationTaskDetailResult,
|
||||
ReservationTaskListFilters,
|
||||
@@ -201,8 +202,8 @@ function fixtureManualReviewResolution(
|
||||
request: ReservationManualReviewResolutionRequest,
|
||||
): Promise<ReservationManualReviewResolutionResult> {
|
||||
return fixtureTaskDetail(taskId).then((detail) => {
|
||||
const confirmedPayload = Object.fromEntries(
|
||||
request.field_overrides.map((item) => [fieldPointerToFixturePath(item.field_pointer), item.value]),
|
||||
const fieldValues = Object.fromEntries(
|
||||
request.field_overrides.map((item) => [fieldOverrideToFixturePath(item), item.value]),
|
||||
)
|
||||
return {
|
||||
task_id: detail.task_id,
|
||||
@@ -215,19 +216,86 @@ function fixtureManualReviewResolution(
|
||||
field_overrides: request.field_overrides,
|
||||
},
|
||||
confirmed_payload: {
|
||||
...(detail.confirmed_payload ?? {}),
|
||||
...confirmedPayload,
|
||||
field_values: fieldValues,
|
||||
legacy_field_values: legacyFieldValues(fieldValues),
|
||||
effective_payload: effectivePayload(fieldValues),
|
||||
},
|
||||
opera_operations: detail.opera_operations,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function fieldPointerToFixturePath(fieldPointer: string): string {
|
||||
if (fieldPointer === '/extracted_fields/room_items/0/pms_room_type_code') {
|
||||
return 'extracted_fields.pms_room_type_code'
|
||||
function fieldOverrideToFixturePath(
|
||||
fieldOverride: ReservationManualReviewResolutionRequest['field_overrides'][number],
|
||||
): 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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ReservationTaskFieldResult } from '@/types/reservation'
|
||||
import {
|
||||
buildInitialFieldValues,
|
||||
buildEditableFieldValues,
|
||||
groupReservationFields,
|
||||
validateReservationFieldValues,
|
||||
@@ -147,4 +148,156 @@ describe('validateReservationFieldValues', () => {
|
||||
'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,14 +245,21 @@ describe('reservationService real API mode', () => {
|
||||
confirmed_order_id: '20001',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
confirmed_payload: {
|
||||
field_values: {
|
||||
'extracted_fields.room_items.0.pms_room_type_code': 'RM3',
|
||||
},
|
||||
legacy_field_values: {
|
||||
'extracted_fields.pms_room_type_code': 'RM3',
|
||||
},
|
||||
},
|
||||
opera_operations: [],
|
||||
}),
|
||||
)
|
||||
@@ -262,7 +269,8 @@ describe('reservationService real API mode', () => {
|
||||
reason: '确认 PMS 房型代码',
|
||||
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',
|
||||
},
|
||||
],
|
||||
@@ -280,7 +288,8 @@ describe('reservationService real API mode', () => {
|
||||
reason: '确认 PMS 房型代码',
|
||||
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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -149,14 +149,21 @@ function createManualReviewResolutionResult(
|
||||
confirmed_order_id: '20001',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
confirmed_payload: {
|
||||
field_values: {
|
||||
'extracted_fields.room_items.0.pms_room_type_code': 'RM3',
|
||||
},
|
||||
legacy_field_values: {
|
||||
'extracted_fields.pms_room_type_code': 'RM3',
|
||||
},
|
||||
},
|
||||
opera_operations: [],
|
||||
...overrides,
|
||||
}
|
||||
@@ -424,6 +431,48 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
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 () => {
|
||||
vi.mocked(service.fetchReservationTaskAudits)
|
||||
.mockResolvedValueOnce({
|
||||
@@ -551,7 +600,7 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
review_status: 'PENDING',
|
||||
manual_review: {
|
||||
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 房型代码。',
|
||||
},
|
||||
availability: {
|
||||
@@ -565,7 +614,9 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
},
|
||||
fields: [
|
||||
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 房型代码',
|
||||
editable: 'N',
|
||||
input_editable: 'N',
|
||||
@@ -587,7 +638,9 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
confirmed_order_id: '20001',
|
||||
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',
|
||||
},
|
||||
],
|
||||
@@ -621,7 +674,7 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
expect(wrapper.text()).toContain('任务卡类型')
|
||||
expect(wrapper.text()).toContain('NEW_BOOKING_MANUAL_REVIEW')
|
||||
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()).not.toContain('人工转换')
|
||||
expect(wrapper.text()).not.toContain('保存草稿')
|
||||
@@ -638,7 +691,8 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
reason: '确认 PMS 房型代码',
|
||||
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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -296,6 +296,8 @@ export interface ReservationTaskFieldResult {
|
||||
task_subtype?: string | null
|
||||
display_area: string | null
|
||||
field_path: string
|
||||
field_pointer?: string | null
|
||||
legacy_field_path?: string | null
|
||||
display_name: string
|
||||
default_value_source?: string | null
|
||||
visible: string | null
|
||||
@@ -406,8 +408,13 @@ export interface ReservationManualReviewConversionResult {
|
||||
original_order_logic_deleted: boolean
|
||||
}
|
||||
|
||||
export interface ReservationManualReviewResolutionFieldOverrideRequest {
|
||||
export type ReservationManualReviewResolutionFieldOverrideRequest = {
|
||||
field_pointer: string
|
||||
field_path?: string
|
||||
value: unknown
|
||||
} | {
|
||||
field_pointer?: string
|
||||
field_path: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@ import type { ReservationRecord, ReservationTaskFieldResult } from '@/types/rese
|
||||
|
||||
const truthyRuleValues = new Set(['Y', 'YES', 'TRUE', '1', '是', '可编辑', 'ENABLED'])
|
||||
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 {
|
||||
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) => {
|
||||
values[field.field_path] = payload?.[field.field_path] ?? field.value ?? ''
|
||||
values[field.field_path] = readReservationFieldValue(field, payload ?? {}, fieldContractVersion)
|
||||
return values
|
||||
}, {})
|
||||
}
|
||||
@@ -185,7 +203,7 @@ export function buildEditableFieldValues(
|
||||
.filter((field) => isReservationFieldActive(field, values))
|
||||
.filter((field) => isReservationFieldEditable(field, readOnly))
|
||||
.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
|
||||
}, {})
|
||||
}
|
||||
@@ -195,13 +213,14 @@ export function validateReservationFieldValues(
|
||||
values: ReservationRecord,
|
||||
): ReservationFieldValidationErrors {
|
||||
return fields.filter((field) => isReservationFieldActive(field, values)).reduce<ReservationFieldValidationErrors>((errors, field) => {
|
||||
const value = values[field.field_path] ?? field.value ?? ''
|
||||
if (isReservationFieldRequired(field) && isEmptyReservationValue(value)) {
|
||||
const value = readReservationFieldValue(field, values)
|
||||
const shouldValidateInput = isReservationFieldEditable(field, false)
|
||||
if (shouldValidateInput && isReservationFieldRequired(field) && isEmptyReservationValue(value)) {
|
||||
errors[field.field_path] = createValidationError(field, 'required')
|
||||
return errors
|
||||
}
|
||||
|
||||
if (isEmptyReservationValue(value)) {
|
||||
if (!shouldValidateInput || isEmptyReservationValue(value)) {
|
||||
return errors
|
||||
}
|
||||
|
||||
@@ -227,8 +246,9 @@ function valueForConditionPath(
|
||||
currentField: ReservationTaskFieldResult,
|
||||
fieldPath: string,
|
||||
): unknown {
|
||||
if (fieldPath in values) {
|
||||
return values[fieldPath]
|
||||
const value = readValueByFieldPath(values, fieldPath)
|
||||
if (value !== undefined) {
|
||||
return value
|
||||
}
|
||||
if (fieldPath === currentField.field_path) {
|
||||
return currentField.value
|
||||
@@ -306,6 +326,121 @@ function isIsoLocalDate(value: string): boolean {
|
||||
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 {
|
||||
if (!value) {
|
||||
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`、`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` 解阻,不再创建第二张人工复核任务卡。
|
||||
- 第一版解阻 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 执行 / 重试入口。
|
||||
- 前端 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