修复任务字段过滤与控件可写规则
This commit is contained in:
@@ -77,6 +77,22 @@
|
||||
{{ t('task.manualResolution.noSupportedMissingFields') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="unsupportedMissingFields.length"
|
||||
class="manual-resolution__unsupported"
|
||||
>
|
||||
<strong>{{ t('task.manualResolution.unopenedFieldsTitle') }}</strong>
|
||||
<ul>
|
||||
<li
|
||||
v-for="reference in unsupportedMissingFields"
|
||||
:key="reference"
|
||||
>
|
||||
<span class="th-code">{{ reference }}</span>
|
||||
<small>{{ t('task.manualResolution.fieldBackendUnopened') }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>{{ t('task.manualResolution.reason') }}</span>
|
||||
<textarea
|
||||
@@ -124,7 +140,10 @@ import type {
|
||||
ReservationTaskFieldResult,
|
||||
} from '@/types/reservation'
|
||||
import { normalizeStableCode } from '@/utils/reservationDisplay'
|
||||
import { readReservationFieldValue } from '@/utils/reservationFieldRules'
|
||||
import {
|
||||
isReservationManualReviewFieldWritable,
|
||||
readReservationFieldValue,
|
||||
} from '@/utils/reservationFieldRules'
|
||||
|
||||
interface ManualResolutionFieldItem {
|
||||
key: string
|
||||
@@ -170,8 +189,11 @@ const supportedMissingFields = computed(() =>
|
||||
.map((reference) => resolveMissingField(reference))
|
||||
.filter((item): item is ManualResolutionFieldItem => item !== null),
|
||||
)
|
||||
const unsupportedMissingFields = computed(() =>
|
||||
extractMissingFields(props.manualReview).filter((reference) => resolveMissingField(reference) === null),
|
||||
)
|
||||
const canSubmit = computed(() => {
|
||||
if (!supportedMissingFields.value.length || !confirmedOrderId.value.trim()) {
|
||||
if (!supportedMissingFields.value.length || unsupportedMissingFields.value.length || !confirmedOrderId.value.trim()) {
|
||||
return false
|
||||
}
|
||||
return supportedMissingFields.value.every((item) => String(fieldOverrideInputs.value[item.key] ?? '').trim())
|
||||
@@ -230,7 +252,7 @@ function resolveMissingField(reference: string): ManualResolutionFieldItem | nul
|
||||
item.field_path === referenceFieldPath ||
|
||||
item.legacy_field_path === referenceFieldPath,
|
||||
)
|
||||
if (field) {
|
||||
if (field && isReservationManualReviewFieldWritable(field)) {
|
||||
return {
|
||||
key: field.field_pointer ?? field.field_path,
|
||||
pointer: field.field_pointer ?? pointer,
|
||||
@@ -350,6 +372,31 @@ function formatJson(value: unknown): string {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.manual-resolution__unsupported {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border: 1px dashed var(--th-color-warning);
|
||||
border-radius: var(--th-radius-md);
|
||||
background: color-mix(in srgb, var(--th-color-warning) 12%, var(--th-color-white));
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.manual-resolution__unsupported ul {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.manual-resolution__unsupported li {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.manual-resolution__unsupported small {
|
||||
color: var(--th-color-slate-500);
|
||||
}
|
||||
|
||||
.manual-resolution footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -358,6 +358,8 @@ export default {
|
||||
confirmedOrderId: 'Confirmed order ID',
|
||||
fieldValuePlaceholder: 'Enter the confirmed value',
|
||||
noSupportedMissingFields: 'The current missing fields are not supported for frontend resolution yet. Confirm field mapping with backend.',
|
||||
unopenedFieldsTitle: 'Fields not opened for editing',
|
||||
fieldBackendUnopened: 'The backend has not opened this field for editing.',
|
||||
fieldRequired: 'Fill the missing field values and confirmed order ID',
|
||||
reason: 'Resolution note',
|
||||
reasonPlaceholder: 'Optional note for the manual decision',
|
||||
|
||||
@@ -358,6 +358,8 @@ export default {
|
||||
confirmedOrderId: 'รหัสออเดอร์ที่ยืนยัน',
|
||||
fieldValuePlaceholder: 'กรอกค่าที่ยืนยันแล้ว',
|
||||
noSupportedMissingFields: 'ข้อมูลที่ขาดปัจจุบันยังไม่รองรับการปลดบล็อกจากหน้าเว็บ โปรดยืนยัน mapping กับหลังบ้าน',
|
||||
unopenedFieldsTitle: 'ข้อมูลที่ยังไม่เปิดให้แก้ไข',
|
||||
fieldBackendUnopened: 'หลังบ้านยังไม่ได้เปิดให้แก้ไขข้อมูลนี้',
|
||||
fieldRequired: 'โปรดกรอกข้อมูลที่ขาดและรหัสออเดอร์ที่ยืนยัน',
|
||||
reason: 'หมายเหตุการจัดการ',
|
||||
reasonPlaceholder: 'ไม่บังคับ ระบุเหตุผลการตัดสินใจ',
|
||||
|
||||
@@ -358,6 +358,8 @@ export default {
|
||||
confirmedOrderId: '确认订单编号',
|
||||
fieldValuePlaceholder: '填写确认后的值',
|
||||
noSupportedMissingFields: '当前缺失字段暂不支持前端解阻,请联系后端确认字段映射。',
|
||||
unopenedFieldsTitle: '未开放编辑字段',
|
||||
fieldBackendUnopened: '当前字段后端未开放编辑',
|
||||
fieldRequired: '请填写缺失字段和确认订单编号',
|
||||
reason: '处理说明',
|
||||
reasonPlaceholder: '可选,说明人工判断依据',
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
buildInitialFieldValues,
|
||||
buildEditableFieldValues,
|
||||
groupReservationFields,
|
||||
isReservationFieldEditable,
|
||||
isReservationManualReviewFieldWritable,
|
||||
isSelectField,
|
||||
validateReservationFieldValues,
|
||||
} from '@/utils/reservationFieldRules'
|
||||
|
||||
@@ -90,7 +93,7 @@ describe('validateReservationFieldValues', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('groups only fields that match the current display condition', () => {
|
||||
it('groups all backend-returned fields without rebuilding display-condition logic', () => {
|
||||
const groups = groupReservationFields(
|
||||
[
|
||||
createField({
|
||||
@@ -111,10 +114,13 @@ describe('validateReservationFieldValues', () => {
|
||||
},
|
||||
)
|
||||
|
||||
expect(groups.flatMap((group) => group.fields.map((field) => field.field_path))).toEqual(['case_keys.group_code'])
|
||||
expect(groups.flatMap((group) => group.fields.map((field) => field.field_path))).toEqual([
|
||||
'case_keys.group_code',
|
||||
'case_keys.confirmation_number',
|
||||
])
|
||||
})
|
||||
|
||||
it('builds a backend-safe editable payload without readonly or inactive fields', () => {
|
||||
it('builds a backend-safe editable payload without readonly or omitted fields', () => {
|
||||
const payload = buildEditableFieldValues(
|
||||
[
|
||||
createField({
|
||||
@@ -129,16 +135,11 @@ describe('validateReservationFieldValues', () => {
|
||||
display_name: 'Group Code',
|
||||
display_condition: '对象为Group Block或Allotment时展示',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'case_keys.confirmation_number',
|
||||
display_name: 'Confirmation No.',
|
||||
display_condition: '对象为FIT Reservation时展示',
|
||||
}),
|
||||
],
|
||||
{
|
||||
visible_reason: 'AI extracted this task.',
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
'case_keys.confirmation_number': '',
|
||||
'case_keys.confirmation_number': 'CNF-NOT-RETURNED',
|
||||
'extracted_fields.booking_object_type': 'Group Block',
|
||||
},
|
||||
false,
|
||||
@@ -149,6 +150,271 @@ describe('validateReservationFieldValues', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('treats detail.fields as the authoritative backend-filtered field set', () => {
|
||||
const fields = [
|
||||
createField({
|
||||
field_path: 'extracted_fields.booking_object_type',
|
||||
display_name: '订单对象类型',
|
||||
select_editable: 'Y',
|
||||
input_editable: 'N',
|
||||
enum_options: 'Group Block, FIT Reservation',
|
||||
value: 'Group Block',
|
||||
}),
|
||||
createField({
|
||||
field_path: 'case_keys.group_code',
|
||||
display_name: 'Group Code',
|
||||
value: 'GRP-001',
|
||||
}),
|
||||
]
|
||||
const values = buildInitialFieldValues(fields, null, '20260711-p0')
|
||||
const groups = groupReservationFields(fields, '基础信息', {
|
||||
...values,
|
||||
'case_keys.confirmation_number': 'CNF-SHOULD-NOT-RENDER',
|
||||
attachments: [
|
||||
{
|
||||
name: 'hidden-attachment.pdf',
|
||||
},
|
||||
],
|
||||
})
|
||||
const payload = buildEditableFieldValues(
|
||||
fields,
|
||||
{
|
||||
...values,
|
||||
'case_keys.confirmation_number': 'CNF-SHOULD-NOT-SUBMIT',
|
||||
attachments: [
|
||||
{
|
||||
name: 'hidden-attachment.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
expect(groups.flatMap((group) => group.fields.map((field) => field.field_path))).toEqual([
|
||||
'extracted_fields.booking_object_type',
|
||||
'case_keys.group_code',
|
||||
])
|
||||
expect(payload).toEqual({
|
||||
'extracted_fields.booking_object_type': 'Group Block',
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
})
|
||||
expect(payload).not.toHaveProperty('case_keys.confirmation_number')
|
||||
expect(payload).not.toHaveProperty('attachments')
|
||||
})
|
||||
|
||||
it('uses field control metadata before legacy editable flags when building payloads', () => {
|
||||
const payload = buildEditableFieldValues(
|
||||
[
|
||||
createField({
|
||||
field_path: 'case_keys.group_code',
|
||||
display_name: 'Group Code',
|
||||
value: 'GRP-001',
|
||||
control_type: 'text',
|
||||
edit_scope: 'normal_and_manual_review',
|
||||
write_target: 'field_values',
|
||||
raw_readonly: false,
|
||||
} as Partial<ReservationTaskFieldResult>),
|
||||
createField({
|
||||
field_path: 'source_message.attachments',
|
||||
display_name: '来源附件',
|
||||
value: [{ name: 'rooming-list.pdf' }],
|
||||
editable: 'Y',
|
||||
input_editable: 'Y',
|
||||
control_type: 'file',
|
||||
edit_scope: 'never',
|
||||
write_target: 'none',
|
||||
raw_readonly: true,
|
||||
} as Partial<ReservationTaskFieldResult>),
|
||||
createField({
|
||||
field_path: 'route_code',
|
||||
display_name: '路由代码',
|
||||
value: 'R01_NEW_BOOKING_NORMAL',
|
||||
editable: 'Y',
|
||||
input_editable: 'Y',
|
||||
control_type: 'workflow_state',
|
||||
edit_scope: 'system_only',
|
||||
write_target: 'none',
|
||||
raw_readonly: false,
|
||||
} as Partial<ReservationTaskFieldResult>),
|
||||
],
|
||||
{
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
'source_message.attachments': [{ name: 'rooming-list.pdf' }],
|
||||
route_code: 'R01_NEW_BOOKING_NORMAL',
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
expect(payload).toEqual({
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
})
|
||||
})
|
||||
|
||||
it('submits dual draft and review fields in normal editable payloads', () => {
|
||||
const payload = buildEditableFieldValues(
|
||||
[
|
||||
createField({
|
||||
field_path: 'case_keys.group_code',
|
||||
display_name: 'Group Code',
|
||||
value: 'GRP-001',
|
||||
control_type: 'text',
|
||||
edit_scope: 'normal_and_manual_review',
|
||||
write_target: 'draft_payload_and_review_resolution',
|
||||
raw_readonly: false,
|
||||
} as Partial<ReservationTaskFieldResult>),
|
||||
],
|
||||
{
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
expect(payload).toEqual({
|
||||
'case_keys.group_code': 'GRP-001',
|
||||
})
|
||||
})
|
||||
|
||||
it('allows manual review overrides only for fields opened by backend metadata', () => {
|
||||
expect(
|
||||
isReservationManualReviewFieldWritable(
|
||||
createField({
|
||||
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||
control_type: 'select',
|
||||
edit_scope: 'manual_review_only',
|
||||
write_target: 'review_resolution.field_overrides',
|
||||
raw_readonly: false,
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isReservationManualReviewFieldWritable(
|
||||
createField({
|
||||
field_path: 'case_keys.group_code',
|
||||
field_pointer: '/case_keys/group_code',
|
||||
control_type: 'text',
|
||||
edit_scope: 'normal_and_manual_review',
|
||||
write_target: 'draft_payload_and_review_resolution',
|
||||
raw_readonly: false,
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isReservationManualReviewFieldWritable(
|
||||
createField({
|
||||
field_path: 'route_code',
|
||||
control_type: 'workflow_state',
|
||||
edit_scope: 'system_only',
|
||||
write_target: 'none',
|
||||
raw_readonly: false,
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isReservationManualReviewFieldWritable(
|
||||
createField({
|
||||
field_path: 'source_message.attachments',
|
||||
control_type: 'file',
|
||||
edit_scope: 'never',
|
||||
write_target: 'none',
|
||||
raw_readonly: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render review-resolution-only fields as normal editable controls', () => {
|
||||
expect(
|
||||
isReservationFieldEditable(
|
||||
createField({
|
||||
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||
control_type: 'select',
|
||||
edit_scope: 'manual_review_only',
|
||||
write_target: 'review_resolution.field_overrides',
|
||||
enum_options: 'RM1, RM2',
|
||||
raw_readonly: false,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isReservationManualReviewFieldWritable(
|
||||
createField({
|
||||
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
|
||||
control_type: 'select',
|
||||
edit_scope: 'manual_review_only',
|
||||
write_target: 'review_resolution.field_overrides',
|
||||
enum_options: 'RM1, RM2',
|
||||
raw_readonly: false,
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat catalog-backed select metadata as a static enum select', () => {
|
||||
const catalogField = createField({
|
||||
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
|
||||
control_type: 'select',
|
||||
edit_scope: 'manual_review_only',
|
||||
write_target: 'review_resolution.field_overrides',
|
||||
options_source: 'active_pms_room_type_catalog',
|
||||
enum_options: 'Q1A, DLX',
|
||||
raw_readonly: false,
|
||||
})
|
||||
const staticField = createField({
|
||||
field_path: 'extracted_fields.payment_method',
|
||||
control_type: 'select',
|
||||
edit_scope: 'normal_task',
|
||||
write_target: 'draft_payload.field_values',
|
||||
options_source: 'static_enum',
|
||||
enum_options: 'PREPAID, HOTEL_COLLECT',
|
||||
raw_readonly: false,
|
||||
})
|
||||
|
||||
expect(isSelectField(catalogField)).toBe(false)
|
||||
expect(isReservationManualReviewFieldWritable(catalogField)).toBe(true)
|
||||
expect(isSelectField(staticField)).toBe(true)
|
||||
})
|
||||
|
||||
it('honors edit scope white lists for normal payload and manual review writes', () => {
|
||||
const manualOnlyPayloadField = createField({
|
||||
field_path: 'case_keys.confirmation_number',
|
||||
control_type: 'text',
|
||||
edit_scope: 'manual_review_only',
|
||||
write_target: 'field_values',
|
||||
raw_readonly: false,
|
||||
})
|
||||
const workflowDualTargetField = createField({
|
||||
field_path: 'review_status',
|
||||
control_type: 'text',
|
||||
edit_scope: 'workflow_only',
|
||||
write_target: 'draft_payload_and_review_resolution',
|
||||
raw_readonly: false,
|
||||
})
|
||||
const normalOnlyReviewTargetField = createField({
|
||||
field_path: 'case_keys.group_code',
|
||||
control_type: 'text',
|
||||
edit_scope: 'normal_task',
|
||||
write_target: 'review_resolution.field_overrides',
|
||||
raw_readonly: false,
|
||||
})
|
||||
|
||||
expect(isReservationFieldEditable(manualOnlyPayloadField, false)).toBe(false)
|
||||
expect(isReservationFieldEditable(workflowDualTargetField, false)).toBe(false)
|
||||
expect(isReservationManualReviewFieldWritable(manualOnlyPayloadField)).toBe(false)
|
||||
expect(isReservationManualReviewFieldWritable(workflowDualTargetField)).toBe(false)
|
||||
expect(isReservationManualReviewFieldWritable(normalOnlyReviewTargetField)).toBe(false)
|
||||
expect(buildEditableFieldValues(
|
||||
[manualOnlyPayloadField, workflowDualTargetField],
|
||||
{
|
||||
'case_keys.confirmation_number': 'CNF-001',
|
||||
review_status: 'PENDING',
|
||||
},
|
||||
false,
|
||||
)).toEqual({})
|
||||
})
|
||||
|
||||
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',
|
||||
|
||||
@@ -516,12 +516,6 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
display_condition: '对象为Group Block或Allotment时展示',
|
||||
value: 'GRP-001',
|
||||
}),
|
||||
createRequiredField({
|
||||
field_path: 'case_keys.confirmation_number',
|
||||
display_name: 'Confirmation No.',
|
||||
display_condition: '对象为FIT Reservation时展示',
|
||||
value: null,
|
||||
}),
|
||||
createRequiredField({
|
||||
field_path: 'extracted_fields.booking_object_type',
|
||||
display_name: '订单对象类型',
|
||||
@@ -550,6 +544,100 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a CP9 group block detail without assuming FIT or attachment fields exist', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
field_contract_version: '20260711-p0',
|
||||
ai_task_type: 'New Booking',
|
||||
task_subtype: 'new_group_block',
|
||||
fields: [
|
||||
createRequiredField({
|
||||
field_path: 'extracted_fields.booking_object_type',
|
||||
display_name: '订单对象类型',
|
||||
select_editable: 'Y',
|
||||
input_editable: 'N',
|
||||
enum_options: 'Group Block, FIT Reservation',
|
||||
value: 'Group Block',
|
||||
}),
|
||||
createRequiredField({
|
||||
field_path: 'case_keys.group_code',
|
||||
field_pointer: '/case_keys/group_code',
|
||||
display_name: 'Group Code',
|
||||
value: 'GRP-CP9',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult())
|
||||
|
||||
const wrapper = await mountPanel()
|
||||
await findButton(wrapper, '保存草稿').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Group Code')
|
||||
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('GRP-CP9')
|
||||
expect(wrapper.text()).not.toContain('Confirmation No.')
|
||||
expect(wrapper.text()).not.toContain('相关附件')
|
||||
expect(service.saveReservationTaskDraft).toHaveBeenCalledWith('10001', {
|
||||
field_values: {
|
||||
'extracted_fields.booking_object_type': 'Group Block',
|
||||
'case_keys.group_code': 'GRP-CP9',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('saves drafts only with editable fields opened by backend control metadata', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
fields: [
|
||||
createRequiredField({
|
||||
field_path: 'case_keys.group_code',
|
||||
field_pointer: '/case_keys/group_code',
|
||||
display_name: 'Group Code',
|
||||
value: 'GRP-OPEN',
|
||||
control_type: 'text',
|
||||
edit_scope: 'normal_and_manual_review',
|
||||
write_target: 'field_values',
|
||||
raw_readonly: false,
|
||||
} as Partial<ReservationTaskFieldResult>),
|
||||
createRequiredField({
|
||||
field_path: 'route_code',
|
||||
display_name: '路由代码',
|
||||
value: 'R01_NEW_BOOKING_NORMAL',
|
||||
editable: 'Y',
|
||||
input_editable: 'Y',
|
||||
control_type: 'workflow_state',
|
||||
edit_scope: 'system_only',
|
||||
write_target: 'none',
|
||||
raw_readonly: false,
|
||||
} as Partial<ReservationTaskFieldResult>),
|
||||
createRequiredField({
|
||||
field_path: 'source_message.attachments',
|
||||
display_name: '来源附件',
|
||||
value: [{ name: 'rooming-list.pdf' }],
|
||||
editable: 'Y',
|
||||
input_editable: 'Y',
|
||||
control_type: 'file',
|
||||
edit_scope: 'never',
|
||||
write_target: 'none',
|
||||
raw_readonly: true,
|
||||
} as Partial<ReservationTaskFieldResult>),
|
||||
],
|
||||
}),
|
||||
)
|
||||
vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult())
|
||||
|
||||
const wrapper = await mountPanel()
|
||||
await findButton(wrapper, '保存草稿').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(service.saveReservationTaskDraft).toHaveBeenCalledWith('10001', {
|
||||
field_values: {
|
||||
'case_keys.group_code': 'GRP-OPEN',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the source conversation by source message id without external conversation id', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
@@ -749,6 +837,54 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
expect(wrapper.text()).toContain('人工复核已解阻')
|
||||
})
|
||||
|
||||
it('shows missing manual review fields as backend unopened when detail.fields omits them', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
|
||||
createTaskDetail({
|
||||
result_type: 'manual_review',
|
||||
ai_task_type: 'NEW_BOOKING',
|
||||
task_subtype: 'NEW_BOOKING',
|
||||
route_code: 'NEW_BOOKING_MANUAL_REVIEW',
|
||||
system_process_category: 'BUSINESS_TASK',
|
||||
task_status: 'BLOCKED',
|
||||
review_status: 'PENDING',
|
||||
manual_review: {
|
||||
reason_code: 'missing_room_type_code',
|
||||
missing_fields: [
|
||||
'/extracted_fields/room_items/0/pms_room_type_code',
|
||||
'/case_keys/confirmation_number',
|
||||
],
|
||||
review_instruction: '请补充缺失字段。',
|
||||
},
|
||||
availability: {
|
||||
blocked: true,
|
||||
read_only: true,
|
||||
editable: false,
|
||||
confirmable: false,
|
||||
executable: false,
|
||||
blocked_by_task_id: null,
|
||||
blocked_reason: 'MANUAL_REVIEW_REQUIRED',
|
||||
},
|
||||
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,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(wrapper.text()).toContain('PMS 房型代码')
|
||||
expect(wrapper.text()).toContain('/case_keys/confirmation_number')
|
||||
expect(wrapper.text()).toContain('当前字段后端未开放编辑')
|
||||
expect(wrapper.findAll('input[name^="manual_resolution_field_override_"]')).toHaveLength(1)
|
||||
expect(findButton(wrapper, '提交复核结果').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('resolves type-known manual review on the same task card', async () => {
|
||||
vi.mocked(service.fetchReservationTaskDetail)
|
||||
.mockResolvedValueOnce(
|
||||
|
||||
@@ -332,6 +332,12 @@ export interface ReservationTaskFieldResult {
|
||||
number_input: string | null
|
||||
file_display: string | null
|
||||
table_editable: string | null
|
||||
control_type?: string | null
|
||||
edit_scope?: string | null
|
||||
write_target?: string | null
|
||||
options_source?: string | null
|
||||
raw_readonly?: boolean | null
|
||||
control_hint?: string | null
|
||||
enum_options: string | null
|
||||
required_rule: string | null
|
||||
display_condition: string | null
|
||||
|
||||
@@ -2,6 +2,23 @@ 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 normalPayloadEditScopes = new Set(['NORMAL_TASK', 'NORMAL_AND_MANUAL_REVIEW'])
|
||||
const manualReviewEditScopes = new Set(['MANUAL_REVIEW_ONLY', 'NORMAL_AND_MANUAL_REVIEW'])
|
||||
const editableControlTypes = new Set(['TEXT', 'TEXTAREA', 'INPUT', 'SELECT', 'DATE', 'NUMBER'])
|
||||
const readonlyControlTypes = new Set(['READONLY', 'FILE', 'WORKFLOW_STATE', 'STRUCTURED_TABLE'])
|
||||
const staticOptionSources = new Set(['STATIC_ENUM'])
|
||||
const payloadWriteTargets = new Set([
|
||||
'FIELD_VALUES',
|
||||
'DRAFT_PAYLOAD.FIELD_VALUES',
|
||||
'CONFIRMED_PAYLOAD.FIELD_VALUES',
|
||||
'TASK_FIELD_VALUES',
|
||||
'DRAFT_PAYLOAD_AND_REVIEW_RESOLUTION',
|
||||
])
|
||||
const manualReviewWriteTargets = new Set([
|
||||
'REVIEW_RESOLUTION.FIELD_OVERRIDES',
|
||||
'FIELD_OVERRIDES',
|
||||
'DRAFT_PAYLOAD_AND_REVIEW_RESOLUTION',
|
||||
])
|
||||
const roomItemFieldPathAliases: Record<string, string[]> = {
|
||||
'extracted_fields.room_quantity': ['extracted_fields.room_items.0.room_quantity'],
|
||||
'extracted_fields.room_type': [
|
||||
@@ -58,68 +75,8 @@ export function isReservationFieldVisible(field: ReservationTaskFieldResult): bo
|
||||
}
|
||||
|
||||
export function isReservationFieldActive(field: ReservationTaskFieldResult, values: ReservationRecord = {}): boolean {
|
||||
if (!isReservationFieldVisible(field)) {
|
||||
return false
|
||||
}
|
||||
const condition = field.display_condition?.trim()
|
||||
if (!condition || condition === '-' || condition.includes('始终展示')) {
|
||||
return true
|
||||
}
|
||||
const currentValue = valueForConditionPath(values, field, field.field_path)
|
||||
if (condition.includes('有值时展示')) {
|
||||
return !isEmptyReservationValue(currentValue)
|
||||
}
|
||||
if (condition.includes('有附件')) {
|
||||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'attachments'))
|
||||
}
|
||||
if (condition.includes('对象为FIT Reservation')) {
|
||||
return isFitTarget(values, field)
|
||||
}
|
||||
if (condition.includes('对象为Group Block或Allotment')) {
|
||||
return isGroupOrAllotmentTarget(values, field)
|
||||
}
|
||||
if (condition.includes('涉及房型')) {
|
||||
return (
|
||||
matrixContains(field.task_type, 'New Booking') ||
|
||||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.room_type')) ||
|
||||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.pms_room_type_code'))
|
||||
)
|
||||
}
|
||||
if (condition.includes('涉及Rate Code') || condition.includes('涉及价格/Rate Code')) {
|
||||
return (
|
||||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.rate_code_result')) ||
|
||||
hasNonEmptyValueWithPrefix(values, 'extracted_fields.rate_code_result.') ||
|
||||
matrixContains(field.task_subtype, 'manual_rate_code_or_settlement_price')
|
||||
)
|
||||
}
|
||||
if (condition.includes('需要价格确认')) {
|
||||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.rate_code_result.settlement_price'))
|
||||
}
|
||||
if (condition.includes('有复合单价') || condition.includes('fix_charge_required=true')) {
|
||||
return (
|
||||
isBooleanTrue(valueForConditionPath(values, field, 'extracted_fields.fix_charge_required')) ||
|
||||
!isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.fix_charge_items[]'))
|
||||
)
|
||||
}
|
||||
if (condition.includes('allocation_split_from_parent=true')) {
|
||||
return isBooleanTrue(valueForConditionPath(values, field, 'extracted_fields.allocation_split_from_parent'))
|
||||
}
|
||||
if (condition.includes('来自QBD/LianTai表格')) {
|
||||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.table_evidence'))
|
||||
}
|
||||
if (condition.includes('有Group目标')) {
|
||||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'case_keys.group_code')) || isGroupOrAllotmentTarget(values, field)
|
||||
}
|
||||
if (condition.includes('有FIT目标') || condition.includes('FIT取消')) {
|
||||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'case_keys.confirmation_number')) || isFitTarget(values, field)
|
||||
}
|
||||
if (condition.includes('有before/after修改')) {
|
||||
return !isEmptyReservationValue(valueForConditionPath(values, field, 'extracted_fields.before_after'))
|
||||
}
|
||||
if (condition.includes('update_fix_charge')) {
|
||||
return matrixContains(field.task_subtype, 'update_fix_charge') || isBooleanTrue(valueForConditionPath(values, field, 'extracted_fields.fix_charge_required'))
|
||||
}
|
||||
return true
|
||||
void values
|
||||
return isReservationFieldVisible(field)
|
||||
}
|
||||
|
||||
export function isReservationFieldRequired(field: ReservationTaskFieldResult): boolean {
|
||||
@@ -130,6 +87,9 @@ export function isReservationFieldEditable(field: ReservationTaskFieldResult, re
|
||||
if (readOnly) {
|
||||
return false
|
||||
}
|
||||
if (hasControlMetadata(field)) {
|
||||
return isReservationFieldEditableByControlMetadata(field)
|
||||
}
|
||||
return (
|
||||
isRuleTruthy(field.editable) ||
|
||||
isRuleTruthy(field.input_editable) ||
|
||||
@@ -141,23 +101,28 @@ export function isReservationFieldEditable(field: ReservationTaskFieldResult, re
|
||||
}
|
||||
|
||||
export function isSelectField(field: ReservationTaskFieldResult): boolean {
|
||||
return isRuleTruthy(field.select_editable) && parseEnumOptions(field.enum_options).length > 0
|
||||
return (
|
||||
normalizeControlValue(field.control_type) === 'SELECT' ||
|
||||
isRuleTruthy(field.select_editable)
|
||||
) &&
|
||||
isStaticOptionsSource(field) &&
|
||||
parseEnumOptions(field.enum_options).length > 0
|
||||
}
|
||||
|
||||
export function isDateField(field: ReservationTaskFieldResult): boolean {
|
||||
return isRuleTruthy(field.date_picker)
|
||||
return normalizeControlValue(field.control_type) === 'DATE' || isRuleTruthy(field.date_picker)
|
||||
}
|
||||
|
||||
export function isNumberField(field: ReservationTaskFieldResult): boolean {
|
||||
return isRuleTruthy(field.number_input)
|
||||
return normalizeControlValue(field.control_type) === 'NUMBER' || isRuleTruthy(field.number_input)
|
||||
}
|
||||
|
||||
export function isFileField(field: ReservationTaskFieldResult): boolean {
|
||||
return isRuleTruthy(field.file_display)
|
||||
return normalizeControlValue(field.control_type) === 'FILE' || isRuleTruthy(field.file_display)
|
||||
}
|
||||
|
||||
export function isTableField(field: ReservationTaskFieldResult): boolean {
|
||||
return isRuleTruthy(field.table_editable)
|
||||
return normalizeControlValue(field.control_type) === 'STRUCTURED_TABLE' || isRuleTruthy(field.table_editable)
|
||||
}
|
||||
|
||||
export function parseEnumOptions(enumOptions: string | null): EnumOption[] {
|
||||
@@ -201,13 +166,28 @@ export function buildEditableFieldValues(
|
||||
}
|
||||
return fields
|
||||
.filter((field) => isReservationFieldActive(field, values))
|
||||
.filter((field) => isReservationFieldEditable(field, readOnly))
|
||||
.filter((field) => isReservationFieldPayloadWritable(field, readOnly))
|
||||
.reduce<ReservationRecord>((payload, field) => {
|
||||
payload[field.field_path] = readReservationFieldValue(field, values)
|
||||
return payload
|
||||
}, {})
|
||||
}
|
||||
|
||||
export function isReservationManualReviewFieldWritable(field: ReservationTaskFieldResult): boolean {
|
||||
if (!hasControlMetadata(field)) {
|
||||
return true
|
||||
}
|
||||
if (isRawReadonly(field.raw_readonly)) {
|
||||
return false
|
||||
}
|
||||
const editScope = normalizeControlValue(field.edit_scope)
|
||||
if (editScope && !manualReviewEditScopes.has(editScope)) {
|
||||
return false
|
||||
}
|
||||
const writeTarget = normalizeControlValue(field.write_target)
|
||||
return !writeTarget || manualReviewWriteTargets.has(writeTarget)
|
||||
}
|
||||
|
||||
export function validateReservationFieldValues(
|
||||
fields: ReservationTaskFieldResult[],
|
||||
values: ReservationRecord,
|
||||
@@ -241,47 +221,6 @@ export function validateReservationFieldValues(
|
||||
}, {})
|
||||
}
|
||||
|
||||
function valueForConditionPath(
|
||||
values: ReservationRecord,
|
||||
currentField: ReservationTaskFieldResult,
|
||||
fieldPath: string,
|
||||
): unknown {
|
||||
const value = readValueByFieldPath(values, fieldPath)
|
||||
if (value !== undefined) {
|
||||
return value
|
||||
}
|
||||
if (fieldPath === currentField.field_path) {
|
||||
return currentField.value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function hasNonEmptyValueWithPrefix(values: ReservationRecord, prefix: string): boolean {
|
||||
return Object.entries(values).some(([key, value]) => key.startsWith(prefix) && !isEmptyReservationValue(value))
|
||||
}
|
||||
|
||||
function isFitTarget(values: ReservationRecord, field: ReservationTaskFieldResult): boolean {
|
||||
const target = stringifyConditionValue(valueForConditionPath(values, field, 'extracted_fields.booking_object_type'))
|
||||
return target.includes('FIT') || target.includes('RESERVATION')
|
||||
}
|
||||
|
||||
function isGroupOrAllotmentTarget(values: ReservationRecord, field: ReservationTaskFieldResult): boolean {
|
||||
const target = stringifyConditionValue(valueForConditionPath(values, field, 'extracted_fields.booking_object_type'))
|
||||
return target.includes('GROUP') || target.includes('ALLOTMENT') || !isEmptyReservationValue(valueForConditionPath(values, field, 'case_keys.group_code'))
|
||||
}
|
||||
|
||||
function stringifyConditionValue(value: unknown): string {
|
||||
return value === null || value === undefined ? '' : String(value).trim().toUpperCase()
|
||||
}
|
||||
|
||||
function matrixContains(value: string | null | undefined, keyword: string): boolean {
|
||||
return Boolean(value?.toLowerCase().includes(keyword.toLowerCase()))
|
||||
}
|
||||
|
||||
function isBooleanTrue(value: unknown): boolean {
|
||||
return value === true || String(value).trim().toLowerCase() === 'true' || String(value).trim() === '是'
|
||||
}
|
||||
|
||||
function createValidationError(
|
||||
field: ReservationTaskFieldResult,
|
||||
code: ReservationFieldValidationErrorCode,
|
||||
@@ -369,12 +308,70 @@ export function readReservationFieldValue(
|
||||
])
|
||||
}
|
||||
|
||||
function readValueByFieldPath(values: ReservationRecord, fieldPath: string): unknown {
|
||||
const flatValue = firstValueByPaths(values, fieldPathCandidates(fieldPath))
|
||||
if (flatValue !== undefined) {
|
||||
return flatValue
|
||||
function isReservationFieldPayloadWritable(field: ReservationTaskFieldResult, readOnly: boolean): boolean {
|
||||
if (!isReservationFieldEditable(field, readOnly)) {
|
||||
return false
|
||||
}
|
||||
return readNestedPath(values, fieldPath)
|
||||
if (!hasControlMetadata(field)) {
|
||||
return true
|
||||
}
|
||||
const writeTarget = normalizeControlValue(field.write_target)
|
||||
return !writeTarget || payloadWriteTargets.has(writeTarget)
|
||||
}
|
||||
|
||||
function isReservationFieldEditableByControlMetadata(field: ReservationTaskFieldResult): boolean {
|
||||
if (isRawReadonly(field.raw_readonly)) {
|
||||
return false
|
||||
}
|
||||
const editScope = normalizeControlValue(field.edit_scope)
|
||||
if (editScope && !normalPayloadEditScopes.has(editScope)) {
|
||||
return false
|
||||
}
|
||||
const writeTarget = normalizeControlValue(field.write_target)
|
||||
if (writeTarget === 'NONE') {
|
||||
return false
|
||||
}
|
||||
if (writeTarget && !payloadWriteTargets.has(writeTarget)) {
|
||||
return false
|
||||
}
|
||||
const controlType = normalizeControlValue(field.control_type)
|
||||
if (readonlyControlTypes.has(controlType)) {
|
||||
return false
|
||||
}
|
||||
if (controlType) {
|
||||
return editableControlTypes.has(controlType)
|
||||
}
|
||||
return (
|
||||
isRuleTruthy(field.editable) ||
|
||||
isRuleTruthy(field.input_editable) ||
|
||||
isRuleTruthy(field.select_editable) ||
|
||||
isRuleTruthy(field.date_picker) ||
|
||||
isRuleTruthy(field.number_input)
|
||||
)
|
||||
}
|
||||
|
||||
function isStaticOptionsSource(field: ReservationTaskFieldResult): boolean {
|
||||
const optionsSource = normalizeControlValue(field.options_source)
|
||||
return !optionsSource || staticOptionSources.has(optionsSource)
|
||||
}
|
||||
|
||||
function hasControlMetadata(field: ReservationTaskFieldResult): boolean {
|
||||
return Boolean(
|
||||
field.control_type ||
|
||||
field.edit_scope ||
|
||||
field.write_target ||
|
||||
field.options_source ||
|
||||
field.raw_readonly !== undefined && field.raw_readonly !== null ||
|
||||
field.control_hint,
|
||||
)
|
||||
}
|
||||
|
||||
function isRawReadonly(value: boolean | string | null | undefined): boolean {
|
||||
return value === true || String(value).trim().toUpperCase() === 'TRUE'
|
||||
}
|
||||
|
||||
function normalizeControlValue(value: string | null | undefined): string {
|
||||
return value?.trim().replace(/[\s-]+/g, '_').toUpperCase() ?? ''
|
||||
}
|
||||
|
||||
function fieldPathCandidates(fieldPath: string): string[] {
|
||||
|
||||
@@ -231,6 +231,9 @@ Content-Type: application/json
|
||||
|
||||
### 5.7 任务详情字段元数据接入注意
|
||||
|
||||
- M002 V3 CP9 后,`GET /api/reservation/tasks/{taskId}` 的 `fields[]` 是后端已经按 `visible`、`result_type`、`task_type`、`task_subtype` 和 `display_condition` 过滤后的当前任务生效字段集合。前端必须直接以 `fields[]` 为权威列表渲染、校验和提交,不再按完整字段矩阵自行补齐后端未返回的字段。
|
||||
- 后端未返回的字段应视为“当前任务不展示 / 不可提交”,不是接口错误。例如 `new_group_block` 不再返回 FIT 专属 `case_keys.confirmation_number`,无附件时也可能不返回 `attachments` 字段;前端不得为了旧矩阵完整性临时合成这些字段。
|
||||
- 保存草稿、最终确认和同卡人工复核解阻只允许从当前 `fields[]` 中选择字段提交。`manual_review.missing_fields[]` 如果指向的字段没有出现在当前 `fields[]`,前端只展示“当前字段后端未开放编辑”,不要自行构造 `field_pointer` 或 `field_path`。
|
||||
- `fields[]` 第一版服务于任务详情动态展示,字段来源与白名单规则后续以 `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` 和同目录路由说明为准;当前代码中仍有 20260708 白名单兼容口径。
|
||||
- `result_type`、`task_type`、`task_subtype`、`default_value_source` 已透出给前端,用于和最新前端白名单对齐。
|
||||
- 后端校验、最终确认写入、OPERA 映射和展示条件仍以 `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 为完整规则来源。
|
||||
|
||||
Reference in New Issue
Block a user