适配 M002 V3 字段矩阵前端交互

This commit is contained in:
andy
2026-07-12 09:23:03 +08:00
parent db95aece20
commit 9bd6bc1cd0
11 changed files with 515 additions and 66 deletions

View File

@@ -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, '.')
} }

View File

@@ -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
} }

View File

@@ -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')))
} }

View File

@@ -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',
}, },

View File

@@ -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(

View File

@@ -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 房型代码',
},
})
})
}) })

View File

@@ -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',
}, },
], ],

View File

@@ -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',
}, },
], ],

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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`