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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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