Files
Wyndham-RSVN/client/src/tests/reservationTaskDetailPanel.spec.ts
2026-07-20 12:05:54 +07:00

1265 lines
44 KiB
TypeScript

import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import zhCN from '@/i18n/locales/zh-CN'
import ReservationTaskDetailPanel from '@/components/reservation/ReservationTaskDetailPanel.vue'
import { useAuthStore } from '@/stores/authStore'
import type {
ReservationManualReviewConversionResult,
ReservationManualReviewResolutionResult,
ReservationOrderListItem,
ReservationOrderListResult,
ReservationTaskAuditLogResult,
ReservationTaskDetailResult,
ReservationTaskFieldResult,
ReservationTaskPayloadMutationResult,
} from '@/types/reservation'
vi.mock('@/services/reservationService', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/reservationService')>()
return {
...actual,
fetchReservationTaskDetail: vi.fn(),
fetchReservationTaskAudits: vi.fn(),
fetchReservationOrders: vi.fn(),
convertReservationManualReviewTask: vi.fn(),
resolveReservationManualReviewTask: vi.fn(),
saveReservationTaskDraft: vi.fn(),
confirmReservationTask: vi.fn(),
}
})
const service = await import('@/services/reservationService')
function createTaskDetail(overrides: Partial<ReservationTaskDetailResult> = {}): ReservationTaskDetailResult {
const detail: ReservationTaskDetailResult = {
task_id: '10001',
order_id: '20001',
source_message_id: '30001',
source_subject: 'Booking Request',
source_sender_summary: 'guest@example.test',
source_received_at: '2026-07-08T03:00:00Z',
external_conversation_id: 'thread-30001',
conversation_message_count: 2,
system_task_type: 'NEW_BOOKING',
task_card_type: 'NEW_BOOKING',
task_status: 'PENDING_CONFIRM',
field_contract_version: '20260708-3.0',
draft_payload: null,
confirmed_payload: null,
availability: {
blocked: false,
read_only: false,
editable: true,
confirmable: true,
executable: false,
blocked_by_task_id: null,
blocked_reason: null,
},
fields: [],
opera_operations: [],
}
return {
...detail,
...overrides,
}
}
function createSourceMessageOnlyTaskDetail(
overrides: Partial<ReservationTaskDetailResult> = {},
): ReservationTaskDetailResult {
return createTaskDetail({
result_type: 'source_message_review_notification',
ai_task_type: 'Message Notification',
task_subtype: 'S10',
route_code: 'S10',
system_process_category: 'SOURCE_MESSAGE_NOTIFICATION',
system_task_type: 'MESSAGE_NOTIFICATION',
task_card_type: 'MESSAGE_NOTIFICATION',
task_status: 'COMPLETED',
fields: [],
opera_operations: [],
availability: {
blocked: false,
read_only: true,
editable: false,
confirmable: false,
executable: false,
blocked_by_task_id: null,
blocked_reason: null,
},
source_message_only_result: {
entry_result_code: 'S10',
entry_result_meaning: 'PURE_INFORMATION',
entry_result_description: '纯信息类邮件',
entry_result_source_message_id: '30001',
result_type: 'source_message_review_notification',
route_code: 'S10',
agent_assessment: {
intent: 'informational',
},
notification: {
current_message_summary: '仅同步信息,不需要订单动作。',
},
manual_review: null,
raw_answer: 'S10,30001',
},
...overrides,
})
}
function createMutationResult(taskStatus = 'PENDING_CONFIRM'): ReservationTaskPayloadMutationResult {
return {
task_id: '10001',
order_id: '20001',
task_status: taskStatus,
draft_payload: {},
confirmed_payload: taskStatus === 'READY' ? {} : null,
opera_operations: [],
}
}
function createManualReviewConversionResult(
overrides: Partial<ReservationManualReviewConversionResult> = {},
): ReservationManualReviewConversionResult {
return {
task_id: '10001',
order_id: '20001',
original_order_id: '20001',
system_task_type: 'NEW_BOOKING',
task_card_type: 'NEW_BOOKING',
task_status: 'PENDING_CONFIRM',
original_order_logic_deleted: false,
...overrides,
}
}
function createManualReviewResolutionResult(
overrides: Partial<ReservationManualReviewResolutionResult> = {},
): ReservationManualReviewResolutionResult {
return {
task_id: '10001',
order_id: '20001',
task_status: 'READY',
review_status: 'RESOLVED',
review_resolution: {
confirmed_order_id: '20001',
field_overrides: [
{
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,
}
}
function createOrderCandidate(overrides: Partial<ReservationOrderListItem> = {}): ReservationOrderListItem {
return {
order_id: '20002',
hotel_id: 'HOTEL-TEST',
order_status: 'ACTIVE',
display_order_key: 'GRP-001',
temporary_order_no: null,
confirmation_number: 'CNF-001',
group_code: 'GRP-001',
display_name: 'GRP-001',
open_task_count: 1,
open_work_item_count: 1,
next_processable_task_id: null,
created_at: '2026-07-08T03:00:00Z',
updated_at: '2026-07-08T03:10:00Z',
...overrides,
}
}
function createOrderCandidateResult(items: ReservationOrderListItem[]): ReservationOrderListResult {
return {
items,
page: {
page_num: 1,
page_size: 8,
total: items.length,
},
}
}
function createAudit(auditId: string, action: string): ReservationTaskAuditLogResult {
return {
audit_id: auditId,
order_id: '20001',
task_id: '10001',
operation_id: null,
actor_type: 'USER',
actor_id: 'andy',
action,
reason: null,
before_snapshot: null,
after_snapshot: null,
occurred_at: '2026-07-08T03:00:00Z',
}
}
function createOperaOperation(): ReservationTaskDetailResult['opera_operations'][number] {
return {
operation_id: 'op-1',
task_id: '10001',
operation_sequence: 1,
operation_code: 'CREATE_RESERVATION',
operation_name: 'Create reservation',
operation_status: 'PENDING',
attempt_count: 0,
last_error_message: null,
attempts: [],
}
}
function createRequiredField(overrides: Partial<ReservationTaskFieldResult> = {}): ReservationTaskFieldResult {
return {
row_number: 1,
card_name: 'New Booking',
display_area: '客人信息',
field_path: 'guest.name',
display_name: '客人姓名',
visible: 'Y',
editable: 'Y',
input_editable: 'Y',
select_editable: 'N',
date_picker: 'N',
number_input: 'N',
file_display: 'N',
table_editable: 'N',
enum_options: null,
required_rule: 'Y',
display_condition: null,
validation_rule: null,
write_path: null,
opera_write_participation: 'N',
opera_parameter_mapping: null,
notes: null,
value: '',
...overrides,
}
}
function createAuthPayload(permissions: string[]) {
return {
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-09T12:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
],
permissions,
menus: [],
}
}
const defaultTaskPermissions = [
'RESERVATION_TASK_READ',
'RESERVATION_TASK_EDIT',
'RESERVATION_TASK_CONFIRM',
'RESERVATION_OPERA_SIM_EXECUTE',
'RESERVATION_AUDIT_READ',
]
async function mountPanel(permissions = defaultTaskPermissions) {
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
messages: {
'zh-CN': zhCN,
},
})
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/reservation/tasks/:taskId', component: { template: '<div />' } },
{ path: '/reservation/orders/:orderId', component: { template: '<div />' } },
{ path: '/reservation/source-messages/:sourceMessageId/conversation', component: { template: '<div />' } },
],
})
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().applyLoginResult(createAuthPayload(permissions))
await router.push('/reservation/tasks/10001')
await router.isReady()
const wrapper = mount(ReservationTaskDetailPanel, {
props: {
taskId: '10001',
},
global: {
plugins: [i18n, router, pinia],
stubs: {
RouterLink: {
template: '<a><slot /></a>',
},
},
},
})
await flushPromises()
return wrapper
}
function findButton(wrapper: VueWrapper, label: string) {
const button = wrapper.findAll('button').find((item) => item.text().includes(label))
if (!button) {
throw new Error(`Button ${label} was not found.`)
}
return button
}
describe('ReservationTaskDetailPanel', () => {
beforeEach(() => {
vi.mocked(service.fetchReservationTaskDetail).mockReset()
vi.mocked(service.fetchReservationTaskAudits).mockReset()
vi.mocked(service.fetchReservationOrders).mockReset()
vi.mocked(service.convertReservationManualReviewTask).mockReset()
vi.mocked(service.resolveReservationManualReviewTask).mockReset()
vi.mocked(service.saveReservationTaskDraft).mockReset()
vi.mocked(service.confirmReservationTask).mockReset()
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(createTaskDetail())
vi.mocked(service.fetchReservationTaskAudits).mockResolvedValue({
task_id: '10001',
items: [],
})
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderCandidateResult([]))
vi.mocked(service.convertReservationManualReviewTask).mockResolvedValue(createManualReviewConversionResult())
vi.mocked(service.resolveReservationManualReviewTask).mockResolvedValue(createManualReviewResolutionResult())
sessionStorage.clear()
})
it('shows draft save errors instead of leaving an unhandled rejection', async () => {
vi.mocked(service.saveReservationTaskDraft).mockRejectedValue(new Error('Draft save failed'))
const wrapper = await mountPanel()
await findButton(wrapper, '保存草稿').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Draft save failed')
expect(findButton(wrapper, '保存草稿').attributes('disabled')).toBeUndefined()
})
it('shows confirm errors instead of leaving an unhandled rejection', async () => {
vi.mocked(service.confirmReservationTask).mockRejectedValue(new Error('Confirm failed'))
const wrapper = await mountPanel()
await findButton(wrapper, '确认').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Confirm failed')
expect(findButton(wrapper, '确认').attributes('disabled')).toBeUndefined()
})
it('blocks confirmation when frontend field validation fails', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
fields: [createRequiredField()],
}),
)
const wrapper = await mountPanel()
await findButton(wrapper, '确认').trigger('click')
await flushPromises()
expect(service.confirmReservationTask).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请先修正字段')
expect(wrapper.text()).toContain('客人姓名为必填项')
})
it('keeps remaining validation errors and clears the summary after fields are fixed', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
fields: [
createRequiredField(),
createRequiredField({
field_path: 'room.type',
display_name: '房型',
}),
],
}),
)
const wrapper = await mountPanel()
await findButton(wrapper, '确认').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('请先修正字段')
expect(wrapper.text()).toContain('客人姓名为必填项')
expect(wrapper.text()).toContain('房型为必填项')
const textareas = wrapper.findAll('textarea')
await textareas[0]!.setValue('王建国')
await flushPromises()
expect(wrapper.text()).toContain('请先修正字段')
expect(wrapper.text()).not.toContain('客人姓名为必填项')
expect(wrapper.text()).toContain('房型为必填项')
await textareas[1]!.setValue('豪华 Q1A')
await flushPromises()
expect(wrapper.text()).not.toContain('请先修正字段')
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 () => {
vi.mocked(service.fetchReservationTaskAudits)
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-1', 'TASK_LOADED')],
})
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-2', 'DRAFT_SAVED')],
})
vi.mocked(service.saveReservationTaskDraft).mockResolvedValue(createMutationResult())
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('任务读取')
await findButton(wrapper, '保存草稿').trigger('click')
await flushPromises()
expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('草稿保存')
expect(wrapper.text()).not.toContain('任务读取')
expect(wrapper.text()).not.toContain('DRAFT_SAVED')
expect(wrapper.text()).not.toContain('TASK_LOADED')
})
it('saves only editable active fields to match backend task field validation', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
fields: [
createRequiredField({
field_path: 'visible_reason',
display_name: '生成原因',
editable: 'N',
input_editable: 'N',
required_rule: 'Y',
value: 'AI extracted this task.',
}),
createRequiredField({
field_path: 'case_keys.group_code',
display_name: 'Group Code',
display_condition: '对象为Group Block或Allotment时展示',
value: 'GRP-001',
}),
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',
}),
],
}),
)
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-001',
'extracted_fields.booking_object_type': 'Group Block',
},
},
)
})
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({
external_conversation_id: null,
}),
)
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('接口待补')
})
it('renders source email preview with excerpt, attachments and full conversation entry', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
fields: [
createRequiredField({
field_path: 'relevant_message_excerpt',
display_name: '邮件片段',
value: 'LLT260509FA213 11-13/05/2026 2N 14 TWN +1 DBL',
editable: 'N',
input_editable: 'N',
required_rule: null,
control_type: 'evidence_text',
edit_scope: 'never',
write_target: 'none',
raw_readonly: true,
} as Partial<ReservationTaskFieldResult>),
createRequiredField({
field_path: 'source_message.attachments',
display_name: '来源附件',
value: [
{
id: 'att-1',
name: 'WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx',
content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size_bytes: 2048,
},
],
editable: 'N',
input_editable: 'N',
file_display: 'Y',
required_rule: null,
control_type: 'file',
edit_scope: 'never',
write_target: 'none',
raw_readonly: true,
} as Partial<ReservationTaskFieldResult>),
],
}),
)
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('来源邮件预览')
expect(wrapper.text()).toContain('Booking Request')
expect(wrapper.text()).toContain('guest@example.test')
expect(wrapper.text()).toContain('2026-07-08 10:00:00')
expect(wrapper.text()).toContain('LLT260509FA213 11-13/05/2026 2N 14 TWN +1 DBL')
expect(wrapper.text()).toContain('WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx')
expect(wrapper.text()).toContain('查看完整邮件会话')
expect(wrapper.text()).not.toContain('{"id":"att-1"')
})
it('does not surface source email excerpt or attachments from unexposed backend context', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
fields: [],
context_used: {
relevant_message_excerpt: 'Hidden context excerpt',
attachments: [
{
name: 'hidden-context-attachment.pdf',
},
],
},
manual_review: {
attachments: [
{
name: 'hidden-review-attachment.pdf',
},
],
},
}),
)
const wrapper = await mountPanel()
const sourceEmailPreview = wrapper.find('.task-source-email')
expect(sourceEmailPreview.text()).toContain('暂无邮件片段')
expect(sourceEmailPreview.text()).not.toContain('Hidden context excerpt')
expect(sourceEmailPreview.text()).not.toContain('hidden-context-attachment.pdf')
expect(sourceEmailPreview.text()).not.toContain('hidden-review-attachment.pdf')
expect(sourceEmailPreview.text()).not.toContain('相关附件')
})
it('renders source-message-only entry result without editable or OPERA actions', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(createSourceMessageOnlyTaskDetail())
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('来源消息只读')
expect(wrapper.text()).toContain('来源消息入口结果')
expect(wrapper.text()).toContain('该任务仅用于展示来源消息入口结果')
expect(wrapper.text()).toContain('S10')
expect(wrapper.text()).toContain('PURE_INFORMATION')
expect(wrapper.text()).toContain('纯信息类邮件')
expect(wrapper.text()).toContain('S10,30001')
expect(wrapper.text()).toContain('来源消息通知')
expect(wrapper.text()).toContain('查看邮件会话')
expect(wrapper.text()).not.toContain('查看订单')
expect(wrapper.text()).not.toContain('保存草稿')
expect(wrapper.text()).not.toContain('确认任务')
expect(wrapper.text()).not.toContain('人工转换')
expect(wrapper.text()).not.toContain('OPERA 模拟操作')
expect(wrapper.text()).not.toContain('确认任务后生成 OPERA 模拟操作')
expect(wrapper.find('button[title="执行"]').exists()).toBe(false)
expect(wrapper.find('button[title="重试"]').exists()).toBe(false)
})
it('renders R08 parent group manual review with identity candidates on the same task card', async () => {
vi.mocked(service.fetchReservationTaskDetail)
.mockResolvedValueOnce(
createTaskDetail({
result_type: 'manual_review',
ai_task_type: 'Cancel Allotment',
task_subtype: 'cancel_allotment_control_block',
route_code: 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW',
system_process_category: 'BUSINESS_TASK',
system_task_type: 'CANCEL_BOOKING',
task_card_type: 'CANCEL_ALLOTMENT',
task_status: 'BLOCKED',
review_status: 'PENDING',
manual_review: {
reason_code: 'target_object_unclear',
visible_reason: 'Parent Group 的 group_code 与 block_code 原始候选冲突,请人工确认目标。',
missing_fields: ['/case_keys/group_code', '/case_keys/block_code'],
blocking_points: ['Parent Group identity cannot be safely normalized.'],
conflicting_points: ['PARENT-2608-CONFLICT-A', 'PARENT-2608-CONFLICT-B'],
suggested_human_actions: ['confirm_parent_group_identity'],
evidence_to_check: ['parent_identity_candidates'],
},
context_used: {
parent_identity_candidates: [
{
field: 'group_code',
value: 'PARENT-2608-CONFLICT-A',
evidence_source: 'subject',
},
{
field: 'block_code',
value: 'PARENT-2608-CONFLICT-B',
evidence_source: 'attachment',
normalized_value: 'PARENT-2608-CONFLICT-B',
},
{
candidate_field: 'parent_group_code',
raw_value: 'PARENT-2608-CONFLICT-C',
origin: 'email_body',
},
],
},
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: 'case_keys.group_code',
field_pointer: '/case_keys/group_code',
display_name: 'Parent Group 团队号',
value: null,
}),
createRequiredField({
field_path: 'case_keys.block_code',
field_pointer: '/case_keys/block_code',
display_name: 'Parent Group Block Code',
value: null,
}),
],
}),
)
.mockResolvedValueOnce(
createTaskDetail({
result_type: 'manual_review',
ai_task_type: 'Cancel Allotment',
task_subtype: 'cancel_allotment_control_block',
route_code: 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW',
system_process_category: 'BUSINESS_TASK',
system_task_type: 'CANCEL_BOOKING',
task_card_type: 'CANCEL_ALLOTMENT',
task_status: 'READY',
review_status: 'RESOLVED',
review_resolution: {
confirmed_order_id: '20001',
field_overrides: [
{
field_pointer: '/case_keys/group_code',
field_path: 'case_keys.group_code',
value: 'PARENT-2608-CONFLICT-A',
},
{
field_pointer: '/case_keys/block_code',
field_path: 'case_keys.block_code',
value: 'PARENT-2608-CONFLICT-A',
},
],
},
availability: {
blocked: false,
read_only: false,
editable: false,
confirmable: false,
executable: true,
blocked_by_task_id: null,
blocked_reason: null,
},
opera_operations: [createOperaOperation()],
}),
)
vi.mocked(service.fetchReservationTaskAudits)
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-1', 'TASK_LOADED')],
})
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-2', 'MANUAL_REVIEW_RESOLVED')],
})
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('Parent Group')
expect(wrapper.text()).toContain('取消 Parent Group 配额')
expect(wrapper.text()).toContain('取消 Parent Group 整块配额')
expect(wrapper.text()).toContain('R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW')
expect(wrapper.text()).toContain('人工复核证据')
expect(wrapper.text()).toContain('target_object_unclear')
expect(wrapper.text()).toContain('Parent Group 的 group_code 与 block_code 原始候选冲突')
expect(wrapper.text()).toContain('PARENT-2608-CONFLICT-A')
expect(wrapper.text()).toContain('PARENT-2608-CONFLICT-B')
expect(wrapper.text()).toContain('parent_group_code')
expect(wrapper.text()).toContain('PARENT-2608-CONFLICT-C')
expect(wrapper.text()).toContain('subject')
expect(wrapper.text()).toContain('attachment')
expect(wrapper.text()).toContain('email_body')
expect(wrapper.text()).toContain('normalized_value')
expect(wrapper.text()).not.toContain('适配契约异常')
expect(wrapper.text()).not.toContain('人工转换')
expect(wrapper.text()).not.toContain('保存草稿')
expect(wrapper.text()).not.toContain('确认任务')
await wrapper.find('input[name="manual_resolution_field_override_0"]').setValue('PARENT-2608-CONFLICT-A')
await wrapper.find('input[name="manual_resolution_field_override_1"]').setValue('PARENT-2608-CONFLICT-A')
await wrapper.find('textarea[name="manual_resolution_reason"]').setValue('确认 Parent Group identity')
await findButton(wrapper, '提交复核结果').trigger('click')
await flushPromises()
expect(service.resolveReservationManualReviewTask).toHaveBeenCalledWith('10001', {
confirmed_order_id: '20001',
reason: '确认 Parent Group identity',
field_overrides: [
{
field_pointer: '/case_keys/group_code',
field_path: 'case_keys.group_code',
value: 'PARENT-2608-CONFLICT-A',
},
{
field_pointer: '/case_keys/block_code',
field_path: 'case_keys.block_code',
value: 'PARENT-2608-CONFLICT-A',
},
],
})
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(
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'],
review_instruction: '请补充 PMS 房型代码。',
},
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 房型代码',
editable: 'N',
input_editable: 'N',
value: null,
}),
createRequiredField({
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: '房量',
control_type: 'number',
edit_scope: 'normal_and_manual_review',
write_target: 'draft_payload_and_review_resolution',
raw_readonly: false,
value: null,
}),
],
}),
)
.mockResolvedValueOnce(
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: 'READY',
review_status: 'RESOLVED',
review_resolution: {
confirmed_order_id: '20001',
field_overrides: [
{
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',
},
],
},
availability: {
blocked: false,
read_only: false,
editable: false,
confirmable: false,
executable: true,
blocked_by_task_id: null,
blocked_reason: null,
},
opera_operations: [createOperaOperation()],
}),
)
vi.mocked(service.fetchReservationTaskAudits)
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-1', 'TASK_LOADED')],
})
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-2', 'MANUAL_REVIEW_RESOLVED')],
})
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('路由与处理信息')
expect(wrapper.text()).toContain('系统任务类型')
expect(wrapper.text()).toContain('任务卡类型')
expect(wrapper.text()).toContain('NEW_BOOKING_MANUAL_REVIEW')
expect(wrapper.text()).toContain('人工复核解阻')
expect(wrapper.text()).toContain('/extracted_fields/room_items/0/pms_room_type_code')
expect(wrapper.text()).toContain('PMS 房型代码')
expect(wrapper.text()).toContain('/extracted_fields/room_items/0/room_quantity')
expect(wrapper.text()).toContain('房量')
expect(wrapper.text()).not.toContain('人工转换')
expect(wrapper.text()).not.toContain('保存草稿')
expect(wrapper.text()).not.toContain('确认任务')
expect(wrapper.text()).not.toContain('OPERA 模拟操作')
await wrapper.find('input[name="manual_resolution_field_override_0"]').setValue('RM3')
await wrapper.find('input[name="manual_resolution_field_override_1"]').setValue('2')
await wrapper.find('textarea[name="manual_resolution_reason"]').setValue('确认 PMS 房型代码')
await findButton(wrapper, '提交复核结果').trigger('click')
await flushPromises()
expect(service.resolveReservationManualReviewTask).toHaveBeenCalledWith('10001', {
confirmed_order_id: '20001',
reason: '确认 PMS 房型代码',
field_overrides: [
{
field_pointer: '/extracted_fields/room_items/0/pms_room_type_code',
field_path: 'extracted_fields.room_items.0.pms_room_type_code',
value: 'RM3',
},
{
field_pointer: '/extracted_fields/room_items/0/room_quantity',
field_path: 'extracted_fields.room_items.0.room_quantity',
value: '2',
},
],
})
expect(service.fetchReservationTaskDetail).toHaveBeenCalledTimes(2)
expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('人工复核已解阻')
expect(wrapper.text()).toContain('人工复核解阻')
expect(wrapper.text()).toContain('OPERA 模拟操作')
expect(wrapper.text()).toContain('CREATE_RESERVATION')
expect(wrapper.find('button[title="执行"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('MANUAL_REVIEW_RESOLVED')
})
it('converts a manual review task to a new booking without a target order', async () => {
vi.mocked(service.fetchReservationTaskDetail)
.mockResolvedValueOnce(
createTaskDetail({
system_task_type: 'MANUAL_REVIEW',
task_card_type: 'FALLBACK_REVIEW',
}),
)
.mockResolvedValueOnce(
createTaskDetail({
system_task_type: 'NEW_BOOKING',
task_card_type: 'NEW_BOOKING',
}),
)
vi.mocked(service.fetchReservationTaskAudits)
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-1', 'TASK_LOADED')],
})
.mockResolvedValueOnce({
task_id: '10001',
items: [createAudit('audit-2', 'MANUAL_REVIEW_CONVERT')],
})
const wrapper = await mountPanel()
expect(wrapper.text()).toContain('人工转换')
expect(wrapper.text()).not.toContain('未知任务类型')
await wrapper.find('select[name="manual_target_task_type"]').setValue('NEW_BOOKING')
await wrapper.find('textarea[name="manual_conversion_reason"]').setValue('识别为新预订')
await findButton(wrapper, '转换任务').trigger('click')
await flushPromises()
expect(service.convertReservationManualReviewTask).toHaveBeenCalledWith('10001', {
target_task_type: 'NEW_BOOKING',
reason: '识别为新预订',
})
expect(service.fetchReservationTaskDetail).toHaveBeenCalledTimes(2)
expect(service.fetchReservationTaskAudits).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('人工转换已完成')
expect(wrapper.text()).toContain('人工转换')
expect(wrapper.text()).not.toContain('MANUAL_REVIEW_CONVERT')
})
it('requires a non-deleted target order when converting manual review to update booking', async () => {
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
system_task_type: 'MANUAL_REVIEW',
task_card_type: 'FALLBACK_REVIEW',
}),
)
vi.mocked(service.fetchReservationOrders).mockResolvedValue(
createOrderCandidateResult([
createOrderCandidate({
order_id: '20002',
display_order_key: 'GRP-ACTIVE',
group_code: 'GRP-ACTIVE',
confirmation_number: 'CNF-ACTIVE',
order_status: 'ACTIVE',
}),
createOrderCandidate({
order_id: '20003',
display_order_key: 'GRP-DELETED',
group_code: 'GRP-DELETED',
confirmation_number: 'CNF-DELETED',
order_status: 'LOGIC_DELETED',
}),
]),
)
vi.mocked(service.convertReservationManualReviewTask).mockResolvedValue(
createManualReviewConversionResult({
order_id: '20002',
original_order_id: '20001',
system_task_type: 'UPDATE_BOOKING',
task_card_type: 'UPDATE_BOOKING',
}),
)
const wrapper = await mountPanel()
await wrapper.find('select[name="manual_target_task_type"]').setValue('UPDATE_BOOKING')
await findButton(wrapper, '转换任务').trigger('click')
await flushPromises()
expect(service.convertReservationManualReviewTask).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择目标订单')
await wrapper.find('input[name="manual_target_order_keyword"]').setValue('GRP')
await findButton(wrapper, '搜索订单').trigger('click')
await flushPromises()
expect(service.fetchReservationOrders).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'GRP',
page_num: 1,
page_size: 8,
}),
)
expect(wrapper.text()).toContain('GRP-ACTIVE')
expect(wrapper.text()).toContain('GRP-DELETED')
expect(wrapper.find('input[value="20003"]').attributes('disabled')).toBeDefined()
await wrapper.find('input[value="20002"]').setValue()
await findButton(wrapper, '转换任务').trigger('click')
await flushPromises()
expect(service.convertReservationManualReviewTask).toHaveBeenCalledWith('10001', {
target_task_type: 'UPDATE_BOOKING',
target_order_id: '20002',
})
})
it('hides task processing buttons and audit timeline without permissions', async () => {
vi.mocked(service.fetchReservationTaskAudits).mockResolvedValue({
task_id: '10001',
items: [createAudit('audit-1', 'TASK_LOADED')],
})
vi.mocked(service.fetchReservationTaskDetail).mockResolvedValue(
createTaskDetail({
availability: {
blocked: false,
read_only: false,
editable: true,
confirmable: true,
executable: true,
blocked_by_task_id: null,
blocked_reason: null,
},
opera_operations: [
{
operation_id: 'op-1',
task_id: '10001',
operation_sequence: 1,
operation_code: 'CREATE_RESERVATION',
operation_name: 'Create reservation',
operation_status: 'PENDING',
attempt_count: 0,
last_error_message: null,
attempts: [],
},
],
}),
)
const wrapper = await mountPanel(['RESERVATION_TASK_READ'])
expect(wrapper.text()).not.toContain('保存草稿')
expect(wrapper.text()).not.toContain('确认任务')
expect(wrapper.text()).not.toContain('审计时间线')
expect(wrapper.find('button[title="执行"]').exists()).toBe(false)
expect(wrapper.find('button[title="重试"]').exists()).toBe(false)
})
})