适配 M002 V3 前端任务流与同卡复核
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
fetchReservationTaskDetail,
|
||||
fetchReservationTaskList,
|
||||
fetchSourceMessageConversation,
|
||||
resolveReservationManualReviewTask,
|
||||
} from '@/services/reservationService'
|
||||
import { setReservationHotelIdProvider } from '@/config/reservationConfig'
|
||||
|
||||
@@ -233,6 +234,63 @@ describe('reservationService real API mode', () => {
|
||||
expect(result.original_order_logic_deleted).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves a type-known manual review task on the original card', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
task_id: '10001',
|
||||
order_id: '20001',
|
||||
task_status: 'READY',
|
||||
review_status: 'RESOLVED',
|
||||
review_resolution: {
|
||||
confirmed_order_id: '20001',
|
||||
field_overrides: [
|
||||
{
|
||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
||||
value: 'RM3',
|
||||
},
|
||||
],
|
||||
},
|
||||
confirmed_payload: {
|
||||
'extracted_fields.pms_room_type_code': 'RM3',
|
||||
},
|
||||
opera_operations: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await resolveReservationManualReviewTask('10001', {
|
||||
confirmed_order_id: '20001',
|
||||
reason: '确认 PMS 房型代码',
|
||||
field_overrides: [
|
||||
{
|
||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
||||
value: 'RM3',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/reservation/tasks/10001/manual-review-resolutions',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
confirmed_order_id: '20001',
|
||||
reason: '确认 PMS 房型代码',
|
||||
field_overrides: [
|
||||
{
|
||||
field_pointer: '/extracted_fields/pms_room_type_code',
|
||||
value: 'RM3',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.task_status).toBe('READY')
|
||||
expect(result.review_status).toBe('RESOLVED')
|
||||
})
|
||||
|
||||
it('fetches order detail from the backend by default', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
|
||||
@@ -9,6 +9,7 @@ import ReservationTaskDetailPanel from '@/components/reservation/ReservationTask
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type {
|
||||
ReservationManualReviewConversionResult,
|
||||
ReservationManualReviewResolutionResult,
|
||||
ReservationOrderListItem,
|
||||
ReservationOrderListResult,
|
||||
ReservationTaskAuditLogResult,
|
||||
@@ -25,6 +26,7 @@ vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||
fetchReservationTaskAudits: vi.fn(),
|
||||
fetchReservationOrders: vi.fn(),
|
||||
convertReservationManualReviewTask: vi.fn(),
|
||||
resolveReservationManualReviewTask: vi.fn(),
|
||||
saveReservationTaskDraft: vi.fn(),
|
||||
confirmReservationTask: vi.fn(),
|
||||
}
|
||||
@@ -70,8 +72,13 @@ function createSourceMessageOnlyTaskDetail(
|
||||
overrides: Partial<ReservationTaskDetailResult> = {},
|
||||
): ReservationTaskDetailResult {
|
||||
return createTaskDetail({
|
||||
system_task_type: 'SOURCE_MESSAGE_ONLY',
|
||||
task_card_type: 'SOURCE_MESSAGE_ONLY',
|
||||
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: [],
|
||||
@@ -85,11 +92,20 @@ function createSourceMessageOnlyTaskDetail(
|
||||
blocked_reason: null,
|
||||
},
|
||||
source_message_only_result: {
|
||||
entry_result_code: 'S000',
|
||||
entry_result_code: 'S10',
|
||||
entry_result_meaning: 'PURE_INFORMATION',
|
||||
entry_result_description: '纯信息类邮件',
|
||||
entry_result_source_message_id: '30001',
|
||||
raw_answer: 'S000,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,
|
||||
})
|
||||
@@ -121,6 +137,31 @@ function createManualReviewConversionResult(
|
||||
}
|
||||
}
|
||||
|
||||
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/pms_room_type_code',
|
||||
value: 'RM3',
|
||||
},
|
||||
],
|
||||
},
|
||||
confirmed_payload: {
|
||||
'extracted_fields.pms_room_type_code': 'RM3',
|
||||
},
|
||||
opera_operations: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createOrderCandidate(overrides: Partial<ReservationOrderListItem> = {}): ReservationOrderListItem {
|
||||
return {
|
||||
order_id: '20002',
|
||||
@@ -166,6 +207,20 @@ function createAudit(auditId: string, action: string): ReservationTaskAuditLogRe
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -280,6 +335,7 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
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())
|
||||
@@ -289,6 +345,7 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
})
|
||||
vi.mocked(service.fetchReservationOrders).mockResolvedValue(createOrderCandidateResult([]))
|
||||
vi.mocked(service.convertReservationManualReviewTask).mockResolvedValue(createManualReviewConversionResult())
|
||||
vi.mocked(service.resolveReservationManualReviewTask).mockResolvedValue(createManualReviewResolutionResult())
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
@@ -465,10 +522,11 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
expect(wrapper.text()).toContain('来源消息只读')
|
||||
expect(wrapper.text()).toContain('来源消息入口结果')
|
||||
expect(wrapper.text()).toContain('该任务仅用于展示来源消息入口结果')
|
||||
expect(wrapper.text()).toContain('S000')
|
||||
expect(wrapper.text()).toContain('S10')
|
||||
expect(wrapper.text()).toContain('PURE_INFORMATION')
|
||||
expect(wrapper.text()).toContain('纯信息类邮件')
|
||||
expect(wrapper.text()).toContain('S000,30001')
|
||||
expect(wrapper.text()).toContain('S10,30001')
|
||||
expect(wrapper.text()).toContain('来源消息通知')
|
||||
expect(wrapper.text()).toContain('查看邮件会话')
|
||||
expect(wrapper.text()).not.toContain('查看订单')
|
||||
expect(wrapper.text()).not.toContain('保存草稿')
|
||||
@@ -480,6 +538,121 @@ describe('ReservationTaskDetailPanel', () => {
|
||||
expect(wrapper.find('button[title="重试"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
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/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.pms_room_type_code',
|
||||
display_name: 'PMS 房型代码',
|
||||
editable: 'N',
|
||||
input_editable: 'N',
|
||||
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/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/pms_room_type_code')
|
||||
expect(wrapper.text()).toContain('PMS 房型代码')
|
||||
expect(wrapper.text()).not.toContain('人工转换')
|
||||
expect(wrapper.text()).not.toContain('保存草稿')
|
||||
expect(wrapper.text()).not.toContain('确认任务')
|
||||
expect(wrapper.text()).not.toContain('OPERA 模拟操作')
|
||||
|
||||
await wrapper.find('input[name="manual_resolution_field_override_0"]').setValue('RM3')
|
||||
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/pms_room_type_code',
|
||||
value: 'RM3',
|
||||
},
|
||||
],
|
||||
})
|
||||
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(
|
||||
|
||||
@@ -10,7 +10,7 @@ import ReservationOrderListView from '@/views/reservation/ReservationOrderListVi
|
||||
import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue'
|
||||
import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { SourceMessageConversationResult } from '@/types/reservation'
|
||||
import type { ReservationTaskListResult, SourceMessageConversationResult } from '@/types/reservation'
|
||||
|
||||
vi.mock('@/services/reservationService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/services/reservationService')>()
|
||||
@@ -62,7 +62,7 @@ function createTaskListResult(
|
||||
page_size: 20,
|
||||
total: 1,
|
||||
},
|
||||
) {
|
||||
): ReservationTaskListResult {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
@@ -93,25 +93,29 @@ function createTaskListResult(
|
||||
}
|
||||
}
|
||||
|
||||
function createSourceMessageOnlyTaskListResult(taskSubtype: 'S000' | 'S999' = 'S000') {
|
||||
function createSourceMessageOnlyTaskListResult(taskSubtype: 'S10' | 'S99' | 'S000' | 'S999' = 'S10') {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
task_id: taskSubtype === 'S000' ? '19990' : '19999',
|
||||
order_id: taskSubtype === 'S000' ? '29990' : '29999',
|
||||
task_id: taskSubtype === 'S10' || taskSubtype === 'S000' ? '19990' : '19999',
|
||||
order_id: taskSubtype === 'S10' || taskSubtype === 'S000' ? '29990' : '29999',
|
||||
hotel_id: 'HOTEL-TEST',
|
||||
display_order_key: null,
|
||||
temporary_order_no: null,
|
||||
task_type: 'SOURCE_MESSAGE_ONLY',
|
||||
task_type: 'MESSAGE_NOTIFICATION',
|
||||
task_subtype: taskSubtype,
|
||||
result_type: 'source_message_review_notification',
|
||||
ai_task_type: 'Message Notification',
|
||||
route_code: taskSubtype,
|
||||
system_process_category: 'SOURCE_MESSAGE_NOTIFICATION',
|
||||
task_status: 'COMPLETED',
|
||||
card_name: 'SOURCE_MESSAGE_ONLY',
|
||||
card_name: 'Message Notification',
|
||||
queue_sequence: null,
|
||||
queue_participation: false,
|
||||
can_process: false,
|
||||
readonly_reason_code: 'QUEUE_EXCLUDED',
|
||||
source_message_id: taskSubtype === 'S000' ? '39990' : '39999',
|
||||
source_subject: taskSubtype === 'S000' ? 'FYI only' : 'Unclear email',
|
||||
source_message_id: taskSubtype === 'S10' || taskSubtype === 'S000' ? '39990' : '39999',
|
||||
source_subject: taskSubtype === 'S10' || taskSubtype === 'S000' ? 'FYI only' : 'Unclear email',
|
||||
source_sender_summary: 'guest@example.test',
|
||||
source_received_at: '2026-07-08T03:00:00Z',
|
||||
external_conversation_id: 'thread-source-only',
|
||||
@@ -475,39 +479,42 @@ describe('reservation P0 views', () => {
|
||||
expect(wrapper.text()).not.toContain('NEW_BOOKING / NEW_BOOKING')
|
||||
})
|
||||
|
||||
it('filters source-message-only tasks with S000 and S999 subtypes', async () => {
|
||||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S000'))
|
||||
it('filters source-message-only tasks with S10 and S99 subtypes', async () => {
|
||||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S10'))
|
||||
|
||||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(wrapper.find('select[name="task_type"]').text()).toContain('来源消息只读')
|
||||
expect(wrapper.find('select[name="task_type"]').text()).toContain('历史 Message Notification')
|
||||
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('纯信息类邮件(S10)')
|
||||
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('无法形成业务素材包(S99)')
|
||||
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('纯信息类邮件')
|
||||
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('无法形成业务素材包')
|
||||
|
||||
await wrapper.find('select[name="task_type"]').setValue('SOURCE_MESSAGE_ONLY')
|
||||
await vi.dynamicImportSettled()
|
||||
await wrapper.find('select[name="task_subtype"]').setValue('S999')
|
||||
await wrapper.find('select[name="task_subtype"]').setValue('S99')
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
task_type: 'SOURCE_MESSAGE_ONLY',
|
||||
task_subtype: 'S999',
|
||||
task_subtype: 'S99',
|
||||
page_num: 1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders source-message-only task rows without a visible order action', async () => {
|
||||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S000'))
|
||||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S10'))
|
||||
|
||||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(wrapper.text()).toContain('来源消息只读')
|
||||
expect(wrapper.text()).toContain('纯信息类邮件')
|
||||
expect(wrapper.text()).toContain('纯信息类邮件(S10)')
|
||||
expect(wrapper.text()).toContain('来源消息通知')
|
||||
expect(wrapper.text()).toContain('FYI only')
|
||||
expect(wrapper.text()).toContain('查看任务')
|
||||
expect(wrapper.text()).toContain('查看邮件会话')
|
||||
@@ -515,6 +522,31 @@ describe('reservation P0 views', () => {
|
||||
expect(wrapper.text()).not.toContain('29990')
|
||||
})
|
||||
|
||||
it('does not treat a business task as source-message-only only because subtype is S10', async () => {
|
||||
const result = createTaskListResult('GRP-S10-BUSINESS', 'Business booking request')
|
||||
result.items[0] = {
|
||||
...result.items[0]!,
|
||||
task_type: 'NEW_BOOKING',
|
||||
task_subtype: 'S10',
|
||||
result_type: 'normal_task',
|
||||
ai_task_type: 'NEW_BOOKING',
|
||||
route_code: 'S10',
|
||||
system_process_category: 'BUSINESS_TASK',
|
||||
card_name: 'New Booking',
|
||||
}
|
||||
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(result)
|
||||
|
||||
const wrapper = await mountWithPlugins(ReservationTaskListView)
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const rowText = wrapper.find('tbody').text()
|
||||
expect(rowText).toContain('GRP-S10-BUSINESS')
|
||||
expect(rowText).toContain('20001')
|
||||
expect(rowText).toContain('查看订单')
|
||||
expect(rowText).not.toContain('来源消息只读')
|
||||
expect(rowText).not.toContain('不关联可见订单')
|
||||
})
|
||||
|
||||
it('filters and paginates task list with supported backend fields', async () => {
|
||||
vi.mocked(service.fetchReservationTaskList)
|
||||
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request', { page_num: 1, page_size: 20, total: 45 }))
|
||||
|
||||
Reference in New Issue
Block a user