对齐 Parent Group 前端展示与复核

This commit is contained in:
andy
2026-07-12 13:35:01 +08:00
parent 92489af18e
commit bf796946f9
13 changed files with 575 additions and 13 deletions

View File

@@ -88,7 +88,7 @@
<div>
<dt>{{ t('task.taskCardType') }}</dt>
<dd>
{{ formatReservationTaskCard(t, detail.task_card_type) }}
{{ formatReservationTaskCardLabel(t, detail) }}
<small class="th-code">{{ detail.task_card_type }}</small>
</dd>
</div>
@@ -191,6 +191,79 @@
<pre>{{ formatJson(manualReviewRecord) }}</pre>
</section>
<section
v-if="manualReviewEvidence"
class="manual-review-evidence"
>
<header>
<h2>{{ t('task.manualReviewEvidence') }}</h2>
<p>{{ manualReviewEvidence.visibleReason || t('task.manualReviewEvidenceFallback') }}</p>
</header>
<dl>
<div>
<dt>{{ t('task.manualResolution.reasonCode') }}</dt>
<dd class="th-code">
{{ manualReviewEvidence.reasonCode || '-' }}
</dd>
</div>
<div>
<dt>{{ t('task.manualReviewMissingFields') }}</dt>
<dd>{{ formatStringList(manualReviewEvidence.missingFields) }}</dd>
</div>
<div>
<dt>{{ t('task.manualReviewBlockingPoints') }}</dt>
<dd>{{ formatStringList(manualReviewEvidence.blockingPoints) }}</dd>
</div>
<div>
<dt>{{ t('task.manualReviewConflictingPoints') }}</dt>
<dd>{{ formatStringList(manualReviewEvidence.conflictingPoints) }}</dd>
</div>
<div>
<dt>{{ t('task.manualReviewSuggestedActions') }}</dt>
<dd>{{ formatStringList(manualReviewEvidence.suggestedHumanActions) }}</dd>
</div>
<div>
<dt>{{ t('task.manualReviewEvidenceToCheck') }}</dt>
<dd>{{ formatStringList(manualReviewEvidence.evidenceToCheck) }}</dd>
</div>
</dl>
<div class="manual-review-evidence__candidates">
<h3>{{ t('task.parentIdentityCandidates') }}</h3>
<div
v-if="manualReviewEvidence.parentIdentityCandidates.length"
class="candidate-grid"
>
<article
v-for="(candidate, index) in manualReviewEvidence.parentIdentityCandidates"
:key="`${candidate.title}-${candidate.summary}-${index}`"
class="candidate-item"
>
<strong>{{ candidate.title || t('task.parentIdentityCandidate') }}</strong>
<span class="th-code">{{ candidate.summary || '-' }}</span>
<small>{{ candidate.sourceSummary || '-' }}</small>
<dl
v-if="candidate.attributes.length"
class="candidate-item__attributes"
>
<div
v-for="attribute in candidate.attributes"
:key="attribute.key"
>
<dt>{{ attribute.key }}</dt>
<dd>{{ attribute.value }}</dd>
</div>
</dl>
</article>
</div>
<p
v-else
class="manual-review-evidence__empty"
>
{{ t('task.noParentIdentityCandidates') }}
</p>
</div>
</section>
<section
v-if="adapterContractErrors.length"
class="task-ai-diagnostics task-ai-diagnostics--error"
@@ -366,6 +439,7 @@ import {
formatReservationMaybeStableReason,
formatReservationSystemProcessCategory,
formatReservationTaskCard,
formatReservationTaskCardLabel,
formatReservationTaskRouteSummary,
isReservationFallbackManualReviewTask,
isReservationReadonlyDiagnosticTask,
@@ -404,6 +478,7 @@ const isPendingTypeKnownManualReviewDetail = computed(
const isFallbackManualReviewDetail = computed(() => isReservationFallbackManualReviewTask(detail.value))
const sourceMessageOnlyResult = computed(() => detail.value?.source_message_only_result ?? null)
const manualReviewRecord = computed(() => detail.value?.manual_review ?? sourceMessageOnlyResult.value?.manual_review ?? null)
const manualReviewEvidence = computed(() => buildManualReviewEvidence(detail.value, manualReviewRecord.value))
const adapterContractErrors = computed(() => detail.value?.adapter_contract_errors ?? [])
const unhandledIntents = computed(() => detail.value?.unhandled_intents ?? [])
const isFieldRendererReadOnly = computed(
@@ -659,6 +734,123 @@ function replaceOperation(operation: ReservationOperaOperationResult): void {
function formatJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2)
}
interface ManualReviewEvidenceCandidate {
title: string | null
summary: string | null
sourceSummary: string | null
attributes: Array<{
key: string
value: string
}>
}
interface ManualReviewEvidence {
reasonCode: string | null
visibleReason: string | null
missingFields: string[]
blockingPoints: string[]
conflictingPoints: string[]
suggestedHumanActions: string[]
evidenceToCheck: string[]
parentIdentityCandidates: ManualReviewEvidenceCandidate[]
}
function buildManualReviewEvidence(
taskDetail: ReservationTaskDetailResult | null,
manualReview: ReservationRecord | null | undefined,
): ManualReviewEvidence | null {
if (!manualReview || normalizeStableCode(readString(manualReview.reason_code)) !== 'TARGET_OBJECT_UNCLEAR') {
return null
}
return {
reasonCode: readString(manualReview.reason_code),
visibleReason: readString(manualReview.visible_reason),
missingFields: readStringArray(manualReview.missing_fields),
blockingPoints: readStringArray(manualReview.blocking_points),
conflictingPoints: readStringArray(manualReview.conflicting_points),
suggestedHumanActions: readStringArray(manualReview.suggested_human_actions),
evidenceToCheck: readStringArray(manualReview.evidence_to_check),
parentIdentityCandidates: readParentIdentityCandidates(taskDetail, manualReview),
}
}
function readParentIdentityCandidates(
taskDetail: ReservationTaskDetailResult | null,
manualReview: ReservationRecord,
): ManualReviewEvidenceCandidate[] {
const contextCandidates = [
readRecord(taskDetail?.context_used)?.parent_identity_candidates,
readRecord(manualReview.context_used)?.parent_identity_candidates,
manualReview.parent_identity_candidates,
]
const candidateList = contextCandidates.find((item) => Array.isArray(item))
if (!Array.isArray(candidateList)) {
return []
}
return candidateList
.map((item) => readRecord(item))
.filter((item): item is ReservationRecord => item !== null)
.map((item) => {
const attributes = Object.entries(item)
.map(([key, value]) => ({
key,
value: stringifyCandidateValue(value),
}))
.filter((attribute) => attribute.value !== '')
return {
title: readFirstString(item, ['field', 'candidate_field', 'key', 'field_path']),
summary: readFirstString(item, ['value', 'raw_value', 'normalized_value', 'candidate_value']),
sourceSummary: readFirstString(item, ['evidence_source', 'source', 'evidence', 'origin']),
attributes,
}
})
}
function readRecord(value: unknown): ReservationRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as ReservationRecord
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
function readFirstString(record: ReservationRecord, keys: string[]): string | null {
for (const key of keys) {
const value = readString(record[key])
if (value) {
return value
}
}
return null
}
function readStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
}
function stringifyCandidateValue(value: unknown): string {
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
return JSON.stringify(value)
}
function formatStringList(items: string[]): string {
return items.length ? items.join(' / ') : '-'
}
</script>
<style scoped>
@@ -751,6 +943,15 @@ function formatJson(value: unknown): string {
border-color: var(--th-color-danger);
}
.manual-review-evidence {
display: grid;
gap: 14px;
border: 1px solid var(--th-color-warning);
border-radius: var(--th-radius-md);
background: color-mix(in srgb, var(--th-color-warning) 8%, var(--th-color-white));
padding: 16px;
}
.source-only-result {
display: grid;
gap: 12px;
@@ -766,6 +967,10 @@ function formatJson(value: unknown): string {
.task-route-info dl,
.task-ai-diagnostics h2,
.task-ai-diagnostics pre,
.manual-review-evidence h2,
.manual-review-evidence h3,
.manual-review-evidence p,
.manual-review-evidence dl,
.source-only-result h2,
.source-only-result p,
.source-only-result dl {
@@ -775,11 +980,24 @@ function formatJson(value: unknown): string {
.task-evidence h2,
.task-route-info h2,
.task-ai-diagnostics h2,
.manual-review-evidence h2,
.source-only-result h2 {
color: var(--th-color-slate-900);
font-size: 15px;
}
.manual-review-evidence header {
display: grid;
gap: 6px;
}
.manual-review-evidence header p,
.manual-review-evidence__empty {
color: var(--th-color-slate-600);
font-size: 13px;
line-height: 1.6;
}
.source-only-result header {
display: grid;
gap: 6px;
@@ -793,7 +1011,8 @@ function formatJson(value: unknown): string {
}
.task-evidence dl,
.task-route-info dl {
.task-route-info dl,
.manual-review-evidence > dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
@@ -806,7 +1025,8 @@ function formatJson(value: unknown): string {
}
.task-evidence dt,
.task-route-info dt {
.task-route-info dt,
.manual-review-evidence > dl dt {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 700;
@@ -814,6 +1034,7 @@ function formatJson(value: unknown): string {
.task-evidence dd,
.task-route-info dd,
.manual-review-evidence > dl dd,
.source-only-result dd {
margin: 5px 0 0;
color: var(--th-color-slate-900);
@@ -821,6 +1042,65 @@ function formatJson(value: unknown): string {
overflow-wrap: anywhere;
}
.manual-review-evidence__candidates {
display: grid;
gap: 10px;
}
.manual-review-evidence__candidates h3 {
color: var(--th-color-slate-900);
font-size: 13px;
}
.candidate-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.candidate-item {
display: grid;
gap: 4px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 10px;
}
.candidate-item strong {
color: var(--th-color-slate-900);
font-size: 13px;
}
.candidate-item small {
color: var(--th-color-slate-500);
font-size: 12px;
}
.candidate-item__attributes {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
margin: 4px 0 0;
}
.candidate-item__attributes div {
display: grid;
grid-template-columns: minmax(90px, 0.35fr) minmax(0, 1fr);
gap: 8px;
}
.candidate-item__attributes dt,
.candidate-item__attributes dd {
margin: 0;
color: var(--th-color-slate-500);
font-size: 11px;
}
.candidate-item__attributes dd {
color: var(--th-color-slate-700);
}
.source-only-result__raw {
grid-column: 1 / -1;
}
@@ -934,7 +1214,9 @@ function formatJson(value: unknown): string {
}
.task-evidence dl,
.task-route-info dl {
.task-route-info dl,
.manual-review-evidence > dl,
.candidate-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@@ -942,6 +1224,8 @@ function formatJson(value: unknown): string {
@media (max-width: 640px) {
.task-evidence dl,
.task-route-info dl,
.manual-review-evidence > dl,
.candidate-grid,
.source-only-result dl {
grid-template-columns: 1fr;
}

View File

@@ -13,7 +13,7 @@
>
<span class="task-row__index">{{ task.queue_sequence ?? '-' }}</span>
<span class="task-row__main">
<strong>{{ formatReservationTaskCard(t, task.task_type, task.card_name) }}</strong>
<strong>{{ formatReservationTaskCardLabel(t, task) }}</strong>
<small>{{ formatReservationTaskRouteSummary(t, task) }}</small>
<em :class="{ 'task-row__reason--blocked': !task.can_process }">
{{ task.can_process ? t('taskList.canProcess') : t('taskList.cannotProcess') }}
@@ -51,7 +51,7 @@ import ReservationStatusBadge from '@/components/reservation/ReservationStatusBa
import type { ReservationOrderTaskTimelineItem } from '@/types/reservation'
import {
formatReservationReadonlyReason,
formatReservationTaskCard,
formatReservationTaskCardLabel,
formatReservationTaskRouteSummary,
} from '@/utils/reservationDisplay'

View File

@@ -309,6 +309,7 @@ export default {
},
viewOrder: 'View order',
viewConversation: 'View email conversation',
parentGroup: 'Parent Group',
sourceMessageId: 'Source message ID',
sourceSubject: 'Source subject',
sourceSender: 'Sender',
@@ -334,6 +335,16 @@ export default {
agentAssessment: 'AI raw fields',
sourceNotification: 'Source message notification',
manualReviewData: 'Manual review data',
manualReviewEvidence: 'Manual review evidence',
manualReviewEvidenceFallback: 'Confirm the Parent Group identity from the source email and candidates.',
manualReviewMissingFields: 'Missing fields',
manualReviewBlockingPoints: 'Blocking points',
manualReviewConflictingPoints: 'Conflicting points',
manualReviewSuggestedActions: 'Suggested actions',
manualReviewEvidenceToCheck: 'Evidence to check',
parentIdentityCandidates: 'Parent Group candidates',
parentIdentityCandidate: 'Candidate',
noParentIdentityCandidates: 'No Parent Group candidates yet. Backend should expose context_used.parent_identity_candidates.',
adapterContractErrors: 'Adapter contract errors',
unhandledIntents: 'Unhandled intents',
noSourceOnlyResult: 'No source message entry result is available.',
@@ -459,6 +470,7 @@ export default {
NEW_BOOKING: 'New booking',
UPDATE_BOOKING: 'Update booking',
CANCEL_BOOKING: 'Cancel booking',
CANCEL_ALLOTMENT: 'Cancel Parent Group allotment',
VOUCHER: 'Voucher',
ROOMING_LIST: 'Rooming list',
AMEND_GROUP_CODE: 'Amend group code',
@@ -479,6 +491,7 @@ export default {
UPDATE_STAY_DATES: 'Update stay dates',
UPDATE_ROOM_TYPE: 'Update room type',
UPDATE_FIX_CHARGE: 'Update fixed charge',
CANCEL_ALLOTMENT_CONTROL_BLOCK: 'Cancel Parent Group control block',
NEW_GROUP_BLOCK: 'New group block',
NEW_FIT_RESERVATION: 'New FIT reservation',
MANUAL_REVIEW: 'Manual review',

View File

@@ -309,6 +309,7 @@ export default {
},
viewOrder: 'ดูออเดอร์',
viewConversation: 'ดูเธรดอีเมล',
parentGroup: 'Parent Group',
sourceMessageId: 'รหัสข้อความต้นทาง (ID)',
sourceSubject: 'หัวข้อต้นทาง',
sourceSender: 'ผู้ส่ง',
@@ -334,6 +335,16 @@ export default {
agentAssessment: 'ข้อมูลดิบจาก AI',
sourceNotification: 'การแจ้งเตือนข้อความต้นทาง',
manualReviewData: 'ข้อมูลตรวจสอบด้วยคน',
manualReviewEvidence: 'หลักฐานการตรวจสอบด้วยคน',
manualReviewEvidenceFallback: 'ยืนยันตัวตน Parent Group จากอีเมลต้นทางและรายการตัวเลือก',
manualReviewMissingFields: 'ข้อมูลที่ขาด',
manualReviewBlockingPoints: 'จุดที่บล็อก',
manualReviewConflictingPoints: 'จุดที่ขัดแย้ง',
manualReviewSuggestedActions: 'การดำเนินการที่แนะนำ',
manualReviewEvidenceToCheck: 'หลักฐานที่ควรตรวจ',
parentIdentityCandidates: 'ตัวเลือก Parent Group',
parentIdentityCandidate: 'ตัวเลือก',
noParentIdentityCandidates: 'ยังไม่มีตัวเลือก Parent Group ต้องให้หลังบ้านส่ง context_used.parent_identity_candidates',
adapterContractErrors: 'ข้อผิดพลาดสัญญา Adapter',
unhandledIntents: 'เจตนาที่ยังไม่รองรับ',
noSourceOnlyResult: 'ยังไม่มีผลลัพธ์ทางเข้าของข้อความต้นทาง',
@@ -459,6 +470,7 @@ export default {
NEW_BOOKING: 'จองใหม่',
UPDATE_BOOKING: 'แก้ไขการจอง',
CANCEL_BOOKING: 'ยกเลิกการจอง',
CANCEL_ALLOTMENT: 'ยกเลิกโควตา Parent Group',
VOUCHER: 'วอเชอร์',
ROOMING_LIST: 'รายชื่อห้องพัก',
AMEND_GROUP_CODE: 'แก้ไขเลขกรุ๊ป',
@@ -479,6 +491,7 @@ export default {
UPDATE_STAY_DATES: 'แก้ไขวันเข้าพัก',
UPDATE_ROOM_TYPE: 'แก้ไขประเภทห้อง',
UPDATE_FIX_CHARGE: 'แก้ไขค่าบริการคงที่',
CANCEL_ALLOTMENT_CONTROL_BLOCK: 'ยกเลิกบล็อก Parent Group ทั้งก้อน',
NEW_GROUP_BLOCK: 'บล็อกกรุ๊ปใหม่',
NEW_FIT_RESERVATION: 'จองรายบุคคลใหม่',
MANUAL_REVIEW: 'ตรวจสอบด้วยคน',

View File

@@ -309,6 +309,7 @@ export default {
},
viewOrder: '查看订单',
viewConversation: '查看邮件会话',
parentGroup: 'Parent Group',
sourceMessageId: '来源消息编号ID',
sourceSubject: '来源主题',
sourceSender: '发件人',
@@ -334,6 +335,16 @@ export default {
agentAssessment: 'AI 原始字段',
sourceNotification: '来源消息通知',
manualReviewData: '人工复核信息',
manualReviewEvidence: '人工复核证据',
manualReviewEvidenceFallback: '请根据来源邮件和候选信息确认 Parent Group 身份。',
manualReviewMissingFields: '缺失字段',
manualReviewBlockingPoints: '阻塞点',
manualReviewConflictingPoints: '冲突点',
manualReviewSuggestedActions: '建议动作',
manualReviewEvidenceToCheck: '建议核对证据',
parentIdentityCandidates: 'Parent Group 候选',
parentIdentityCandidate: '候选项',
noParentIdentityCandidates: '暂无 Parent Group 候选;需要后端透出 context_used.parent_identity_candidates。',
adapterContractErrors: '适配契约异常',
unhandledIntents: '未处理意图',
noSourceOnlyResult: '暂无来源消息入口结果。',
@@ -459,6 +470,7 @@ export default {
NEW_BOOKING: '新预订',
UPDATE_BOOKING: '修改预订',
CANCEL_BOOKING: '取消预订',
CANCEL_ALLOTMENT: '取消 Parent Group 配额',
VOUCHER: '凭证',
ROOMING_LIST: '房表',
AMEND_GROUP_CODE: '修改团队号',
@@ -479,6 +491,7 @@ export default {
UPDATE_STAY_DATES: '修改入住日期',
UPDATE_ROOM_TYPE: '修改房型',
UPDATE_FIX_CHARGE: '修改固定收费',
CANCEL_ALLOTMENT_CONTROL_BLOCK: '取消 Parent Group 整块配额',
NEW_GROUP_BLOCK: '新团队预留',
NEW_FIT_RESERVATION: '新散客预订',
MANUAL_REVIEW: '人工复核',

View File

@@ -587,6 +587,168 @@ describe('ReservationTaskDetailPanel', () => {
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('resolves type-known manual review on the same task card', async () => {
vi.mocked(service.fetchReservationTaskDetail)
.mockResolvedValueOnce(

View File

@@ -132,6 +132,21 @@ function createSourceMessageOnlyTaskListResult(taskSubtype: 'S10' | 'S99' = 'S10
}
}
function createParentGroupCancelAllotmentTaskListResult(): ReservationTaskListResult {
const result = createTaskListResult('PARENT-2608-BLOCK', 'Parent split confirmation')
result.items[0] = {
...result.items[0]!,
task_type: 'CANCEL_BOOKING',
task_subtype: 'cancel_allotment_control_block',
result_type: 'normal_task',
ai_task_type: 'Cancel Allotment',
route_code: 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL',
system_process_category: 'BUSINESS_TASK',
card_name: 'CANCEL_ALLOTMENT',
}
return result
}
function createOrderDetailResult(displayName = 'GRP-001') {
return {
order: {
@@ -516,6 +531,7 @@ describe('reservation P0 views', () => {
])
expect(taskSubtypeOptions).not.toContain('S000')
expect(taskSubtypeOptions).not.toContain('S999')
expect(taskSubtypeOptions).not.toContain('linked_parent_release_after_child_split')
expect(wrapper.find('select[name="task_type"]').text()).toContain('来源消息只读')
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('纯信息类邮件S10')
expect(wrapper.find('select[name="task_subtype"]').text()).toContain('无法形成业务素材包S99')
@@ -550,6 +566,21 @@ describe('reservation P0 views', () => {
expect(lastTaskRequest).not.toHaveProperty('system_process_category')
})
it('renders R08 parent group cancel allotment as a business card', async () => {
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createParentGroupCancelAllotmentTaskListResult())
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('PARENT-2608-BLOCK')
expect(wrapper.text()).toContain('Parent Group')
expect(wrapper.text()).toContain('取消 Parent Group 配额')
expect(wrapper.text()).toContain('取消 Parent Group 整块配额')
expect(wrapper.text()).toContain('查看订单')
expect(wrapper.text()).not.toContain('适配契约异常')
expect(wrapper.text()).not.toContain('linked_parent_release_after_child_split')
})
it('renders source-message-only task rows without a visible order action', async () => {
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S10'))

View File

@@ -16,6 +16,7 @@ export type ReservationTaskCardType =
| 'NEW_BOOKING'
| 'UPDATE_BOOKING'
| 'CANCEL_BOOKING'
| 'CANCEL_ALLOTMENT'
| 'VOUCHER'
| 'ROOMING_LIST'
| 'AMEND_GROUP_CODE'
@@ -365,6 +366,7 @@ export interface ReservationTaskDetailResult {
review_status?: string | null
review_resolution?: ReservationRecord | null
manual_review?: ReservationRecord | null
context_used?: ReservationRecord | null
availability: ReservationTaskAvailabilityResult
fields: ReservationTaskFieldResult[]
opera_operations: ReservationOperaOperationResult[]

View File

@@ -4,6 +4,7 @@ export interface ReservationTaskRouteLike {
task_type?: string | null
system_task_type?: string | null
task_card_type?: string | null
card_name?: string | null
task_subtype?: string | null
result_type?: string | null
ai_task_type?: string | null
@@ -19,6 +20,27 @@ export function formatReservationTaskCard(
return translateStableCode(t, 'cardType', taskType, 'common.unknownTaskType', fallbackLabel)
}
export function formatReservationTaskCardLabel(
t: ReservationTranslator,
input: ReservationTaskRouteLike,
fallbackLabel?: string | null,
): string {
if (isReservationAdapterContractErrorTask(input)) {
return t('resultType.ADAPTER_CONTRACT_ERROR')
}
if (isReservationUnhandledIntentTask(input)) {
return t('resultType.UNHANDLED_CURRENT_INTENT')
}
if (isReservationParentGroupCancelAllotmentTask(input)) {
return formatReservationTaskCard(t, 'CANCEL_ALLOTMENT')
}
return formatReservationTaskCard(
t,
input.task_card_type ?? input.task_type ?? input.system_task_type,
fallbackLabel ?? input.card_name,
)
}
export function formatReservationTaskTypeSummary(
t: ReservationTranslator,
taskType: string | null | undefined,
@@ -86,6 +108,18 @@ export function isReservationReadonlyDiagnosticTask(input: ReservationTaskRouteL
)
}
export function isReservationParentGroupCancelAllotmentTask(input: ReservationTaskRouteLike | null | undefined): boolean {
const routeCode = normalizeStableCode(input?.route_code)
const taskSubtype = normalizeStableCode(input?.task_subtype)
const taskCardType = normalizeStableCode(input?.task_card_type ?? input?.card_name)
return (
routeCode === 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL' ||
routeCode === 'R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW' ||
taskSubtype === 'CANCEL_ALLOTMENT_CONTROL_BLOCK' ||
taskCardType === 'CANCEL_ALLOTMENT'
)
}
export function isReservationTypeKnownManualReviewTask(input: ReservationTaskRouteLike | null | undefined): boolean {
if (normalizeStableCode(input?.result_type) !== 'MANUAL_REVIEW') {
return false
@@ -121,6 +155,13 @@ export function formatReservationTaskRouteSummary(
if (isReservationUnhandledIntentTask(input)) {
return t('resultType.UNHANDLED_CURRENT_INTENT')
}
if (isReservationParentGroupCancelAllotmentTask(input)) {
return [
t('task.parentGroup'),
formatReservationTaskCard(t, 'CANCEL_ALLOTMENT'),
formatReservationRouteCode(t, 'cancel_allotment_control_block'),
].join(' / ')
}
return formatReservationTaskTypeSummary(
t,
input.task_type ?? input.system_task_type ?? input.task_card_type,
@@ -144,7 +185,7 @@ export function formatReservationRouteCode(t: ReservationTranslator, routeCode:
if (['S10', 'S99', 'S000', 'S999'].includes(normalizedCode)) {
return translateStableCode(t, 'taskSubtype', routeCode, 'common.unknownTaskSubtype')
}
return routeCode || t('common.unknownTaskSubtype')
return translateStableCode(t, 'taskSubtype', routeCode, 'common.unknownTaskSubtype', routeCode)
}
export function formatReservationReadonlyReason(

View File

@@ -130,7 +130,7 @@
:key="task.task_id"
>
<RouterLink :to="`/reservation/tasks/${task.task_id}`">
{{ formatReservationTaskCard(t, task.task_type, task.card_name) }}
{{ formatReservationTaskCardLabel(t, task) }}
</RouterLink>
</li>
</ul>
@@ -154,7 +154,7 @@ import type {
SourceMessageConversationResult,
SourceMessageOriginalMedia,
} from '@/types/reservation'
import { formatReservationTaskCard } from '@/utils/reservationDisplay'
import { formatReservationTaskCardLabel } from '@/utils/reservationDisplay'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const route = useRoute()

View File

@@ -180,7 +180,7 @@
</template>
</td>
<td>
<span>{{ formatReservationTaskCard(t, task.task_type, task.card_name) }}</span>
<span>{{ formatReservationTaskCardLabel(t, task) }}</span>
<small v-if="task.result_type">{{ formatReservationResultType(t, task.result_type) }}</small>
</td>
<td><ReservationStatusBadge :status="task.task_status" /></td>
@@ -275,7 +275,7 @@ import type { ReservationPageResult, ReservationTaskListFilters, ReservationTask
import {
formatReservationReadonlyReason,
formatReservationResultType,
formatReservationTaskCard,
formatReservationTaskCardLabel,
formatReservationTaskRouteSummary,
isReservationReadonlyDiagnosticTask,
} from '@/utils/reservationDisplay'

View File

@@ -146,7 +146,8 @@ POST /api/auth/logout
- 任务列表里旧 `task_type=SOURCE_MESSAGE_ONLY``task_subtype=S000/S999` 或新 `task_subtype=S10/S99` 的记录只展示邮件来源和 SuperAgent 入口结果,不展示处理按钮。
- 任务详情里 `source_message_only_result` 仅对 `SOURCE_MESSAGE_ONLY` 返回,包含 `entry_result_code``entry_result_meaning``entry_result_description``entry_result_source_message_id``result_type``route_code``agent_assessment``notification``manual_review``raw_answer`;普通业务任务该字段为空。
- 任务列表、订单任务时间线和任务详情顶层已透出 `result_type``ai_task_type``route_code``system_process_category`。前端展示任务卡标题和标签时优先用这些稳定 code不要只靠旧 `task_type` 判断。
- P0.1 后Parent split 父事件不再是独立 Parent Cancel Booking 卡;前端应展示为 `Cancel Allotment / cancel_allotment_control_block``linked_parent_release_after_child_split` 只作为关系字段或详情信息,不作为任务 subtype 筛选项。
- P0.1 后Parent split 父事件不再是独立 Parent Cancel Booking 卡;前端应展示为 `Parent Group / Cancel Allotment / cancel_allotment_control_block``route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL` 是普通业务卡,`route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW` 是同卡人工复核业务卡,不应展示成 `adapter_contract_error``linked_parent_release_after_child_split` 只作为关系字段或详情信息,不作为任务 subtype 筛选项。
- `manual_review.reason_code=target_object_unclear` 时,前端需要在任务详情展示 `manual_review.visible_reason``missing_fields``blocking_points``conflicting_points``suggested_human_actions``evidence_to_check`,并展示 `context_used.parent_identity_candidates[]` 辅助确认 Parent Group identity。当前前端已兼容顶层 `context_used.parent_identity_candidates[]``manual_review.context_used.parent_identity_candidates[]`;若后端 DTO 不透出 candidates页面会显示候选空态。
- P0.1 的“40 条路由”表示当前合法 route definition 数量;`route_code` 保持历史稳定且不连续重编号,因此 `R41_FALLBACK_BUSINESS_EVENT_REVIEW``R42_UNHANDLED_CURRENT_INTENT` 仍是合法展示 code。
- `adapter_contract_errors[]``unhandled_intents[]` 只在任务详情返回,表示同一 SuperAgent 入站批次中没有生成业务任务的诊断块;前端只读展示并提供来源邮件入口,不显示保存、确认、执行或重试按钮。

View File

@@ -327,7 +327,7 @@ POST /api/system/reservation/demo-data
| 旧 `S000/S999` 兼容映射 | 任务列表、任务详情 | 已完成第一版 | 旧数据继续可见;前端可按 `S000→S10``S999→S99` 展示统一文案。 |
| 40 条 P0.1 路由元数据 | 任务列表筛选、订单任务时间线、任务详情标题、字段展示 | 已完成第一版 | 后端保存并返回 AI 原始 `result_type/ai_task_type/task_subtype``route_code` 和系统处理分类;前端不要只依赖系统主任务类型判断卡片。 |
| P0.1 稳定 route_code | 任务列表筛选、订单任务时间线、任务详情标题 | 已完成第一版 | `route_code` 保持历史稳定,不因路由总数变 40 而连续重编号;前端仍可能看到 `R41_FALLBACK_BUSINESS_EVENT_REVIEW``R42_UNHANDLED_CURRENT_INTENT`。 |
| Parent split 父事件卡型 | 任务列表、订单任务时间线、任务详情标题 | 已完成第一版 | 0712 P0.1 后Parent split 父事件展示为 `Cancel Allotment / cancel_allotment_control_block``linked_parent_release_after_child_split` 仅作为关系字段,不作为任务 subtype 筛选项。 |
| Parent split 父事件卡型 | 任务列表、订单任务时间线、任务详情标题 | 已完成第一版 | 0712 P0.1 后Parent split 父事件展示为 `Parent Group / Cancel Allotment / cancel_allotment_control_block``route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL` 展示为普通业务卡,`route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW` 展示为同卡人工复核业务卡;`linked_parent_release_after_child_split` 仅作为关系字段,不作为任务 subtype 筛选项。 |
| `unhandled_current_intents[]` 展示块 | 任务详情 | 已完成第一版 | 后端保存并在任务详情 `unhandled_intents[]` 返回未覆盖业务意图,只用于展示和源邮件查看,不自动建业务任务卡。 |
| `adapter_contract_error` | 任务详情、错误提示 | 已完成第一版 | 命中 P1/P2 未闭合或路由冲突时,任务详情 `adapter_contract_errors[]` 返回稳定错误 code 和原始片段,不转成 Fallback。 |
| type-known manual review 同卡解阻 | 任务详情复核 | 已完成第一版 | `manual_review` 不再全部等同 Fallback已知业务卡型返回原业务卡信息、`review_status``review_resolution` 和可编辑 pointer 字段,解阻后进入 `READY`。 |
@@ -340,6 +340,7 @@ POST /api/system/reservation/demo-data
- 任务列表已按 `result_type``route_code``system_process_category` 识别 `source_message_review_notification``adapter_contract_error``unhandled_current_intent` 只读诊断任务S10/S99 和旧 S000/S999 都不展示订单入口。
- 任务详情已展示 `result_type``ai_task_type``task_subtype``route_code``system_process_category``review_status`、来源邮件入口、`source_message_only_result``manual_review``adapter_contract_errors[]``unhandled_intents[]`
- Parent Group P0.1 前端已按 `route_code=R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL/REVIEW` 展示 `Parent Group / Cancel Allotment / cancel_allotment_control_block`review 场景继续走同卡人工复核解阻,不展示为 `adapter_contract_error`
- type-known `result_type=manual_review` 已在原业务任务卡展示复核状态和缺失字段,并调用 `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` 解阻,不再创建第二张人工复核任务卡。
- 第一版解阻 UI 已改为优先使用任务详情 `fields[].field_pointer`,并同时提交 P0 主 `fields[].field_path`;旧扁平 `legacy_field_path` / `legacy_field_values` 仅用于 `field_contract_version=code-v1` 历史任务过渡回显,不作为新前端主动提交路径。
- 前端只读规则已收口S10/S99、适配契约异常、未处理意图、前置任务阻塞和同卡人工复核待解阻状态都不显示保存草稿、确认任务、人工转换或 OPERA 执行 / 重试入口。
@@ -350,6 +351,7 @@ POST /api/system/reservation/demo-data
- `GET /api/reservation/tasks` 是否会在结构化 S10/S99 行中稳定返回 `task_type=SOURCE_MESSAGE_ONLY`,或允许返回 `MESSAGE_NOTIFICATION` 并只依赖 `result_type/route_code/system_process_category`;前端当前两种都兼容。
- `adapter_contract_error` / `unhandled_current_intent` 如果未来也作为独立列表行返回,请保持 `source_message_id` 可用,便于前端继续提供邮件会话入口。
- 同卡人工复核解阻成功后是否一定返回 `opera_operations[]`。当前文档写“两条 OPERA 模拟操作”,前端实现按实际返回刷新,不假设固定数量。
- Parent Group `manual_review.reason_code=target_object_unclear` 场景需要任务详情稳定透出 `context_used.parent_identity_candidates[]`。当前 `ReservationTaskDetailResult` 后端 DTO 仅透出 `manual_review`,前端已兼容顶层 `context_used.parent_identity_candidates[]``manual_review.context_used.parent_identity_candidates[]`,但若后端不透出 candidates页面只能显示空态提示。
前端注意:不要把访问口令写入前端仓库、浏览器环境变量或构建产物;该接口只能由本地联调人员手动调用或由受控测试脚本调用。