调整预订事项字段展示与基础信息编辑

This commit is contained in:
andy
2026-07-22 10:34:26 +07:00
parent 08c8b0165b
commit 891a4aa6cf
10 changed files with 212 additions and 65 deletions

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ server/target/
var/ var/
*.log *.log
.superpowers/

File diff suppressed because one or more lines are too long

View File

@@ -7,9 +7,8 @@
:class="{ 'v4-field--readonly': !isEditable(field) }" :class="{ 'v4-field--readonly': !isEditable(field) }"
> >
<span class="v4-field__label"> <span class="v4-field__label">
{{ field.display_name }} {{ fieldDisplayName(field) }}
<sup v-if="field.required">*</sup> <sup v-if="field.required">*</sup>
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
</span> </span>
<select <select
@@ -99,11 +98,7 @@
</span> </span>
<small <small
v-if="field.control_hint" v-for="hint in visibleLookupHints(field)"
class="v4-field__hint"
>{{ field.control_hint }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint.key" :key="hint.key"
class="v4-field__hint" class="v4-field__hint"
:class="`v4-field__hint--${hint.tone}`" :class="`v4-field__hint--${hint.tone}`"
@@ -227,6 +222,11 @@ function fieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field) return reservationV4FieldKey(field)
} }
function fieldDisplayName(field: ReservationV4TaskCardFieldResult): string {
const labelKey = fieldLabelKey(field)
return labelKey ? t(labelKey) : field.display_name
}
function dateFieldInputId(field: ReservationV4TaskCardFieldResult): string { function dateFieldInputId(field: ReservationV4TaskCardFieldResult): string {
return `v4-date-${fieldKey(field).replace(/[^a-zA-Z0-9_-]/g, '-')}` return `v4-date-${fieldKey(field).replace(/[^a-zA-Z0-9_-]/g, '-')}`
} }
@@ -402,6 +402,10 @@ function lookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
return hints return hints
} }
function visibleLookupHints(field: ReservationV4TaskCardFieldResult): LookupHint[] {
return lookupHints(field).filter((hint) => hint.tone === 'error')
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] { function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
return [ return [
...(field.validation_errors ?? []), ...(field.validation_errors ?? []),
@@ -645,6 +649,62 @@ function isExactLookupLoading(field: ReservationV4TaskCardFieldResult): boolean
function exactLookupKey(kind: ReservationV4LookupKind, code: string): string { function exactLookupKey(kind: ReservationV4LookupKind, code: string): string {
return `${props.hotelId ?? ''}:${kind}:${code}` return `${props.hotelId ?? ''}:${kind}:${code}`
} }
function fieldLabelKey(field: ReservationV4TaskCardFieldResult): string | null {
const key = normalizeFieldKey(field)
if (key === '/basic_information/account_code') {
return 'taskV4.fieldLabels.basicInformation.accountCode'
}
if (key === '/basic_information/market_code') {
return 'taskV4.fieldLabels.basicInformation.marketCode'
}
if (key === '/basic_information/source_code') {
return 'taskV4.fieldLabels.basicInformation.sourceCode'
}
if (key === '/room_information/final_values/group_block_name') {
return 'taskV4.roomInformation.groupBlockName'
}
if (key === '/room_information/final_values/fit_name') {
return 'taskV4.roomInformation.fitName'
}
if (key === '/room_information/final_values/arrival_date') {
return 'taskV4.roomInformation.arrivalDate'
}
if (key === '/room_information/final_values/departure_date') {
return 'taskV4.roomInformation.departureDate'
}
if (key === '/room_information/final_values/nights') {
return 'taskV4.roomInformation.nights'
}
if (key === '/room_information/final_values/rate_code') {
return 'taskV4.roomInformation.rateCode'
}
if (key === '/room_information/final_values/breakfast_included') {
return 'taskV4.roomInformation.breakfastIncluded'
}
if (key === '/room_information/final_values/group_booking_status') {
return 'taskV4.roomInformation.groupBookingStatus'
}
if (key === '/room_information/final_values/block_id') {
return 'taskV4.roomInformation.blockId'
}
if (key === '/room_information/final_values/confirmation_number') {
return 'taskV4.roomInformation.confirmationNumber'
}
if (/^\/room_information\/final_values\/room_items\/\d+\/room_type_code$/.test(key)) {
return 'taskV4.roomInformation.roomTypeCode'
}
if (/^\/room_information\/final_values\/room_items\/\d+\/room_count$/.test(key)) {
return 'taskV4.roomInformation.roomCount'
}
return null
}
function normalizeFieldKey(field: ReservationV4TaskCardFieldResult): string {
return reservationV4FieldKey(field)
.replace(/\./g, '/')
.replace(/^([^/])/, '/$1')
}
</script> </script>
<style scoped> <style scoped>
@@ -672,15 +732,6 @@ function exactLookupKey(kind: ReservationV4LookupKind, code: string): string {
color: var(--th-color-danger); color: var(--th-color-danger);
} }
.v4-field__label em {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-500);
font-size: 11px;
font-style: normal;
padding: 2px 6px;
}
.v4-field__control, .v4-field__control,
.v4-field__static { .v4-field__static {
min-height: 38px; min-height: 38px;

View File

@@ -31,7 +31,6 @@
<span class="trace-field__label"> <span class="trace-field__label">
{{ field.display_name }} {{ field.display_name }}
<sup v-if="field.required">*</sup> <sup v-if="field.required">*</sup>
<em v-if="!isEditable(field)">{{ t('taskV4.field.readonly') }}</em>
</span> </span>
<select <select
@@ -94,14 +93,9 @@
</span> </span>
<small <small
v-if="field.control_hint" v-if="lookupError(field)"
class="trace-field__hint" class="trace-field__hint trace-field__hint--error"
>{{ field.control_hint }}</small> >{{ lookupError(field) }}</small>
<small
v-for="hint in lookupHints(field)"
:key="hint"
class="trace-field__hint"
>{{ hint }}</small>
<small <small
v-for="error in visibleFieldErrors(field)" v-for="error in visibleFieldErrors(field)"
:key="error" :key="error"
@@ -298,17 +292,14 @@ function selectDisabled(field: ReservationV4TaskCardFieldResult): boolean {
(roomTypeLookup.loading || Boolean(roomTypeLookup.error)) (roomTypeLookup.loading || Boolean(roomTypeLookup.error))
} }
function lookupHints(field: ReservationV4TaskCardFieldResult): string[] { function lookupError(field: ReservationV4TaskCardFieldResult): string {
if (reservationV4LookupKindForOptionsSource(field.options_source) !== 'ROOM_TYPE') { if (reservationV4LookupKindForOptionsSource(field.options_source) !== 'ROOM_TYPE') {
return [] return ''
}
if (roomTypeLookup.loading) {
return [t('taskV4.lookup.loading')]
} }
if (roomTypeLookup.error) { if (roomTypeLookup.error) {
return [roomTypeLookup.error] return roomTypeLookup.error
} }
return [] return ''
} }
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] { function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
@@ -488,15 +479,6 @@ function isRecord(value: unknown): value is ReservationRecord {
color: var(--th-color-danger); color: var(--th-color-danger);
} }
.trace-field__label em {
border-radius: 999px;
background: var(--th-color-slate-100);
color: var(--th-color-slate-500);
font-size: 11px;
font-style: normal;
padding: 2px 6px;
}
.trace-field__control, .trace-field__control,
.trace-field__static { .trace-field__static {
min-height: 38px; min-height: 38px;
@@ -529,6 +511,11 @@ function isRecord(value: unknown): value is ReservationRecord {
font-size: 12px; font-size: 12px;
} }
.trace-field__hint--error {
color: var(--th-color-danger);
font-weight: 800;
}
.trace-field__error { .trace-field__error {
color: var(--th-color-danger); color: var(--th-color-danger);
font-size: 12px; font-size: 12px;

View File

@@ -608,6 +608,13 @@ export default {
cardConfirmed: 'Card confirmed and detail refreshed.', cardConfirmed: 'Card confirmed and detail refreshed.',
reviewResolved: 'Review submitted and detail refreshed.', reviewResolved: 'Review submitted and detail refreshed.',
validationFailed: 'Fix current-card field errors first.', validationFailed: 'Fix current-card field errors first.',
fieldLabels: {
basicInformation: {
accountCode: 'Account Code',
marketCode: 'Market Code',
sourceCode: 'Source Code',
},
},
roomInformation: { roomInformation: {
noDisplayModel: 'Room and date information is temporarily unavailable. Refresh later or contact an administrator.', noDisplayModel: 'Room and date information is temporarily unavailable. Refresh later or contact an administrator.',
eventType: { eventType: {

View File

@@ -608,6 +608,13 @@ export default {
cardConfirmed: 'ยืนยันการ์ดแล้วและรีเฟรชรายละเอียดแล้ว', cardConfirmed: 'ยืนยันการ์ดแล้วและรีเฟรชรายละเอียดแล้ว',
reviewResolved: 'ส่งผลตรวจสอบแล้วและรีเฟรชรายละเอียดแล้ว', reviewResolved: 'ส่งผลตรวจสอบแล้วและรีเฟรชรายละเอียดแล้ว',
validationFailed: 'โปรดแก้ไขข้อมูลในการ์ดปัจจุบันก่อน', validationFailed: 'โปรดแก้ไขข้อมูลในการ์ดปัจจุบันก่อน',
fieldLabels: {
basicInformation: {
accountCode: 'รหัสบัญชี',
marketCode: 'รหัสตลาด',
sourceCode: 'รหัสแหล่งที่มา',
},
},
roomInformation: { roomInformation: {
noDisplayModel: 'ข้อมูลห้องและวันที่ยังไม่พร้อมใช้งาน โปรดลองรีเฟรชภายหลังหรือติดต่อผู้ดูแลระบบ', noDisplayModel: 'ข้อมูลห้องและวันที่ยังไม่พร้อมใช้งาน โปรดลองรีเฟรชภายหลังหรือติดต่อผู้ดูแลระบบ',
eventType: { eventType: {
@@ -626,10 +633,10 @@ export default {
arrivalDate: 'วันเข้าพัก', arrivalDate: 'วันเข้าพัก',
departureDate: 'วันออก', departureDate: 'วันออก',
nights: 'จำนวนคืน', nights: 'จำนวนคืน',
rateCode: 'Rate Code', rateCode: 'รหัสราคา',
breakfastIncluded: 'รวมอาหารเช้า', breakfastIncluded: 'รวมอาหารเช้า',
groupBookingStatus: 'สถานะกรุ๊ป', groupBookingStatus: 'สถานะกรุ๊ป',
blockId: 'Block ID', blockId: 'รหัสบล็อก',
confirmationNumber: 'เลขยืนยัน', confirmationNumber: 'เลขยืนยัน',
roomItems: 'รายละเอียดห้อง', roomItems: 'รายละเอียดห้อง',
roomTypeCode: 'รหัสประเภทห้อง', roomTypeCode: 'รหัสประเภทห้อง',

View File

@@ -608,6 +608,13 @@ export default {
cardConfirmed: '卡片已确认,详情已刷新。', cardConfirmed: '卡片已确认,详情已刷新。',
reviewResolved: '复核已提交,详情已刷新。', reviewResolved: '复核已提交,详情已刷新。',
validationFailed: '请先修正当前卡片字段。', validationFailed: '请先修正当前卡片字段。',
fieldLabels: {
basicInformation: {
accountCode: '客户代码',
marketCode: '市场代码',
sourceCode: '来源代码',
},
},
roomInformation: { roomInformation: {
noDisplayModel: '房型与日期信息暂不可用,请稍后刷新或联系管理员。', noDisplayModel: '房型与日期信息暂不可用,请稍后刷新或联系管理员。',
eventType: { eventType: {
@@ -626,10 +633,10 @@ export default {
arrivalDate: '入住日期', arrivalDate: '入住日期',
departureDate: '离店日期', departureDate: '离店日期',
nights: '晚数', nights: '晚数',
rateCode: 'Rate Code', rateCode: '价格代码',
breakfastIncluded: '含早', breakfastIncluded: '含早',
groupBookingStatus: '团队预订状态', groupBookingStatus: '团队预订状态',
blockId: 'Block ID', blockId: '团队预留编号',
confirmationNumber: '确认号', confirmationNumber: '确认号',
roomItems: '房型明细', roomItems: '房型明细',
roomTypeCode: '房型代码', roomTypeCode: '房型代码',

View File

@@ -115,8 +115,21 @@ describe('reservation V4 pages', () => {
await wrapper.find('select').setValue('ACC-LIVE') await wrapper.find('select').setValue('ACC-LIVE')
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('LEISURE') const basicCard = wrapper.findAll('.task-card-section')[0]!
expect(wrapper.text()).toContain('TRAVEL_AGENT') expect(basicCard.find('.v4-field__label em').exists()).toBe(false)
expect(basicCard.text()).not.toContain('Market 由 Account Code 派生')
expect(basicCard.text()).not.toContain('Source 由 Account Code 派生')
expect(basicCard.text()).not.toContain('选择后会带出市场')
const marketField = findFieldByLabel(basicCard, '市场代码')
const sourceField = findFieldByLabel(basicCard, '来源代码')
expect(marketField?.find('input[name="/basic_information/market_code"]').exists()).toBe(true)
expect(sourceField?.find('input[name="/basic_information/source_code"]').exists()).toBe(true)
expect((marketField?.find('input[name="/basic_information/market_code"]').element as HTMLInputElement).value)
.toBe('LEISURE')
expect((sourceField?.find('input[name="/basic_information/source_code"]').element as HTMLInputElement).value)
.toBe('TRAVEL_AGENT')
await marketField?.find('input[name="/basic_information/market_code"]').setValue('MICE')
await sourceField?.find('input[name="/basic_information/source_code"]').setValue('DIRECT')
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises() await flushPromises()
@@ -125,6 +138,8 @@ describe('reservation V4 pages', () => {
confirmed_payload: { confirmed_payload: {
basic_information: { basic_information: {
account_code: 'ACC-LIVE', account_code: 'ACC-LIVE',
market_code: 'MICE',
source_code: 'DIRECT',
}, },
}, },
}) })
@@ -830,6 +845,9 @@ describe('reservation V4 pages', () => {
expect(roomCard.text()).not.toContain('target_order') expect(roomCard.text()).not.toContain('target_order')
expect(roomCard.text()).not.toContain('Adult') expect(roomCard.text()).not.toContain('Adult')
expect(roomCard.text()).not.toContain('Legacy Room Type') expect(roomCard.text()).not.toContain('Legacy Room Type')
expect(roomCard.text()).not.toContain('Group Block Name')
expect(roomCard.text()).not.toContain('Rate Code')
expect(roomCard.text()).not.toContain('Group Booking Status')
expect(roomCard.find('input[name="/room_information/final_values/adult"]').exists()).toBe(false) expect(roomCard.find('input[name="/room_information/final_values/adult"]').exists()).toBe(false)
expect(roomCard.find('input[name="/room_information/final_values/nights"]').exists()).toBe(false) expect(roomCard.find('input[name="/room_information/final_values/nights"]').exists()).toBe(false)
expect(roomCard.find('input[name="/room_information/final_values/target_order/locator_value"]').exists()).toBe(false) expect(roomCard.find('input[name="/room_information/final_values/target_order/locator_value"]').exists()).toBe(false)
@@ -1194,7 +1212,7 @@ describe('reservation V4 pages', () => {
) )
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.empty) expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.stale) expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.stale)
expect(wrapper.text()).not.toContain('PMS sync is stale') expect(wrapper.text()).not.toContain('PMS sync is stale')
expect(wrapper.text()).not.toContain('PMS_SYNC') expect(wrapper.text()).not.toContain('PMS_SYNC')
@@ -1307,9 +1325,6 @@ describe('reservation V4 pages', () => {
page_num: 1, page_num: 1,
page_size: 20, page_size: 20,
}) })
expect(wrapper.text()).toContain('MICE')
expect(wrapper.text()).toContain('DIRECT')
await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click') await wrapper.findAll('button').find((button) => button.text().includes('确认卡片'))?.trigger('click')
await flushPromises() await flushPromises()
@@ -1318,6 +1333,8 @@ describe('reservation V4 pages', () => {
confirmed_payload: { confirmed_payload: {
basic_information: { basic_information: {
account_code: 'ACC-101', account_code: 'ACC-101',
market_code: 'LEISURE',
source_code: 'TRAVEL_AGENT',
}, },
}, },
}) })
@@ -1347,7 +1364,7 @@ describe('reservation V4 pages', () => {
) )
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain(zhCN.taskV4.lookup.empty) expect(wrapper.text()).not.toContain(zhCN.taskV4.lookup.empty)
expect(wrapper.text()).not.toContain('固定种子初始化') expect(wrapper.text()).not.toContain('固定种子初始化')
expect(wrapper.text()).not.toContain('真实 PMS') expect(wrapper.text()).not.toContain('真实 PMS')
expect(wrapper.text()).not.toContain('PMS_SYNC') expect(wrapper.text()).not.toContain('PMS_SYNC')
@@ -1582,6 +1599,8 @@ function createOrderTaskDetail(options: {
businessFields?: ReservationV4TaskCardResult['fields'] businessFields?: ReservationV4TaskCardResult['fields']
sourceDisplayPayload?: ReservationV4TaskCardResult['display_payload'] sourceDisplayPayload?: ReservationV4TaskCardResult['display_payload']
basicAccountValue?: string basicAccountValue?: string
basicMarketCodeValue?: string
basicSourceCodeValue?: string
basicAccountRequired?: boolean basicAccountRequired?: boolean
} = {}): ReservationV4OrderTaskDetailResult { } = {}): ReservationV4OrderTaskDetailResult {
const sourceCard = createCard('card-source', 'SOURCE_MESSAGE_DISPLAY', 'READONLY', { const sourceCard = createCard('card-source', 'SOURCE_MESSAGE_DISPLAY', 'READONLY', {
@@ -1609,6 +1628,26 @@ function createOrderTaskDetail(options: {
options_source: 'RESERVATION_V4_ACCOUNT_CATALOG', options_source: 'RESERVATION_V4_ACCOUNT_CATALOG',
control_type: 'SELECT', control_type: 'SELECT',
}), }),
createField('/basic_information/market_code', {
display_name: 'Market Code',
value: options.basicMarketCodeValue ?? 'LEISURE',
editable: false,
raw_readonly: true,
control_type: 'READONLY',
edit_scope: 'NEVER',
write_target: 'NONE',
control_hint: 'Market 由 Account Code 派生,前端只读展示。',
}),
createField('/basic_information/source_code', {
display_name: 'Source Code',
value: options.basicSourceCodeValue ?? 'TRAVEL_AGENT',
editable: false,
raw_readonly: true,
control_type: 'READONLY',
edit_scope: 'NEVER',
write_target: 'NONE',
control_hint: 'Source 由 Account Code 派生,前端只读展示。',
}),
createField('/basic_information/read_only_marker', { createField('/basic_information/read_only_marker', {
display_name: 'Read only marker', display_name: 'Read only marker',
value: 'VISIBLE', value: 'VISIBLE',

View File

@@ -283,11 +283,12 @@ async function loadDetail(): Promise<void> {
} }
function applyDetail(result: ReservationV4OrderTaskDetailResult): void { function applyDetail(result: ReservationV4OrderTaskDetailResult): void {
detail.value = result const normalizedResult = normalizeReservationV4Detail(result)
detail.value = normalizedResult
const cards = [ const cards = [
result.source_message_card, normalizedResult.source_message_card,
result.basic_information_card, normalizedResult.basic_information_card,
...result.business_cards, ...normalizedResult.business_cards,
].filter((card): card is ReservationV4TaskCardResult => Boolean(card)) ].filter((card): card is ReservationV4TaskCardResult => Boolean(card))
cardValues.value = cards.reduce<Record<string, ReservationRecord>>((values, card) => { cardValues.value = cards.reduce<Record<string, ReservationRecord>>((values, card) => {
values[card.card_id] = buildReservationV4InitialFieldValues(card.fields) values[card.card_id] = buildReservationV4InitialFieldValues(card.fields)
@@ -298,13 +299,60 @@ function applyDetail(result: ReservationV4OrderTaskDetailResult): void {
cardSuccessMessages.value = {} cardSuccessMessages.value = {}
reviewForms.value = cards.reduce<Record<string, { confirmed_order_id: string; reason: string }>>((forms, card) => { reviewForms.value = cards.reduce<Record<string, { confirmed_order_id: string; reason: string }>>((forms, card) => {
forms[card.card_id] = { forms[card.card_id] = {
confirmed_order_id: result.order_task.order_id ?? '', confirmed_order_id: normalizedResult.order_task.order_id ?? '',
reason: '', reason: '',
} }
return forms return forms
}, {}) }, {})
} }
function normalizeReservationV4Detail(result: ReservationV4OrderTaskDetailResult): ReservationV4OrderTaskDetailResult {
return {
...result,
basic_information_card: result.basic_information_card
? normalizeBasicInformationCard(result.basic_information_card)
: result.basic_information_card,
}
}
function normalizeBasicInformationCard(card: ReservationV4TaskCardResult): ReservationV4TaskCardResult {
return {
...card,
fields: card.fields.map((field) =>
isEditableBasicMarketSourceField(field)
? normalizeEditableBasicMarketSourceField(card, field)
: field,
),
}
}
function normalizeEditableBasicMarketSourceField(
card: ReservationV4TaskCardResult,
field: ReservationV4TaskCardResult['fields'][number],
): ReservationV4TaskCardResult['fields'][number] {
const reviewMode = card.card_status === 'REVIEW_REQUIRED'
return {
...field,
editable: true,
raw_readonly: false,
control_type: 'TEXT',
edit_scope: reviewMode ? 'REVIEW' : 'CONFIRM',
write_target: reviewMode ? 'REVIEW_RESOLUTION_FIELD_OVERRIDES' : 'CONFIRMED_PAYLOAD_JSON',
control_hint: null,
}
}
function isEditableBasicMarketSourceField(field: ReservationV4TaskCardResult['fields'][number]): boolean {
const key = normalizeV4FieldKey(field)
return key === '/basic_information/market_code' || key === '/basic_information/source_code'
}
function normalizeV4FieldKey(field: ReservationV4TaskCardResult['fields'][number]): string {
return reservationV4FieldKey(field)
.replace(/\./g, '/')
.replace(/^([^/])/, '/$1')
}
function setCardValues(card: ReservationV4TaskCardResult, values: ReservationRecord): void { function setCardValues(card: ReservationV4TaskCardResult, values: ReservationRecord): void {
cardValues.value = { cardValues.value = {
...cardValues.value, ...cardValues.value,

View File

@@ -60,7 +60,7 @@
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id``items[]``items[]` 用于展示 V4 卡片确认、复核解阻、订单归属确认轨迹和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 摘要只包含脱敏后的审计摘要不包含邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret。 | | `GET /api/reservation/order-tasks/{orderTaskId}/audits` | 查询 V4 订单任务审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task_id``items[]``items[]` 用于展示 V4 卡片确认、复核解阻、订单归属确认轨迹和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 摘要只包含脱敏后的审计摘要不包含邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret。 |
| `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 |
| `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 S10/S99 来源通知审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按来源通知实际酒店校验访问权;返回 `notification_id``items[]``items[]` 第一版用于展示来源通知 ack 记录,只包含脱敏后的审计摘要。 | | `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 S10/S99 来源通知审计流水 | 必须带 Bearer token需要 `RESERVATION_AUDIT_READ`,后端按来源通知实际酒店校验访问权;返回 `notification_id``items[]``items[]` 第一版用于展示来源通知 ack 记录,只包含脱敏后的审计摘要。 |
| `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;返回统一 wrapper`hotel_id``catalog_type=ACCOUNT``catalog_source``catalog_version``stale``items[]``page``warnings[]``keyword` 匹配目录 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时 `items=[]` / `page.total=0`,但只要酒店未过滤目录存在,`catalog_source/catalog_version` 仍保持真实目录元数据,不代表目录未初始化。前端在 `options_source=reservation_v4_account_catalog` 时调用只提交 `items[].code`Market / Source 以后端确认派生结果为准。 | | `GET /api/reservation/lookups/accounts` | 查询 V4 Account 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;返回统一 wrapper`hotel_id``catalog_type=ACCOUNT``catalog_source``catalog_version``stale``items[]``page``warnings[]``keyword` 匹配目录 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时 `items=[]` / `page.total=0`,但只要酒店未过滤目录存在,`catalog_source/catalog_version` 仍保持真实目录元数据,不代表目录未初始化。前端在 `options_source=reservation_v4_account_catalog` 时调用Account 字段只提交 `items[].code`Market / Source 可作为 Basic Information 字段默认值展示并允许用户覆盖。 |
| `GET /api/reservation/lookups/room-types` | 查询 V4 Room Type 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;第一版只返回当前酒店 `ACTIVE` 房型目录,不接日期过滤,不代表 PMS 全量房型。M002-V4-owner-rate-catalog-data-alignment 后固定初始化目录已收敛为 `RM2``RM3``RM4``SU1``SU2``SU3` 六个 code。`keyword` 匹配房型 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `options_source=reservation_v4_room_type_catalog` 时调用。 | | `GET /api/reservation/lookups/room-types` | 查询 V4 Room Type 目录 | 必须带 Bearer token需要 `RESERVATION_TASK_READ`,支持 `hotel_id``keyword``page_num``page_size`;第一版只返回当前酒店 `ACTIVE` 房型目录,不接日期过滤,不代表 PMS 全量房型。M002-V4-owner-rate-catalog-data-alignment 后固定初始化目录已收敛为 `RM2``RM3``RM4``SU1``SU2``SU3` 六个 code。`keyword` 匹配房型 code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。`keyword` 无匹配时按空选项处理,不要当作目录不可用。前端在 `options_source=reservation_v4_room_type_catalog` 时调用。 |
| `GET /api/reservation/lookups/rate-codes` | 查询 V4 Rate Code 目录 | 当前 CP11 实现支持 `hotel_id``keyword``page_num``page_size`,返回当前酒店 `ACTIVE` Rate CodeM002-V4-owner-rate-catalog-data-alignment 后固定初始化目录已收敛为 OWNER RATE `RATECODE (2)` 的 40 个规范化酒店级 code。2026-07-21 结论是第一阶段暂不建立 Account 与 Rate Code 的适用关系Rate Code lookup 继续按酒店级目录返回,不要求 `account_code` / `booking_type``pricing_available=false` 表示后端未接真实价格,不要据此展示价格。`keyword` 匹配 Rate Code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。前端在 `options_source=reservation_v4_rate_code_catalog` 时调用。 | | `GET /api/reservation/lookups/rate-codes` | 查询 V4 Rate Code 目录 | 当前 CP11 实现支持 `hotel_id``keyword``page_num``page_size`,返回当前酒店 `ACTIVE` Rate CodeM002-V4-owner-rate-catalog-data-alignment 后固定初始化目录已收敛为 OWNER RATE `RATECODE (2)` 的 40 个规范化酒店级 code。2026-07-21 结论是第一阶段暂不建立 Account 与 Rate Code 的适用关系Rate Code lookup 继续按酒店级目录返回,不要求 `account_code` / `booking_type``pricing_available=false` 表示后端未接真实价格,不要据此展示价格。`keyword` 匹配 Rate Code 时后端按稳定 code 大写归一化处理,前端可传小写;匹配显示名仍按数据库比较规则。前端在 `options_source=reservation_v4_rate_code_catalog` 时调用。 |
| `GET /api/admin/reservation/catalogs/accounts` | 管理后台 Account 目录列表 | 必须带 Bearer token需要 `RESERVATION_CATALOG_MANAGE` 和目标酒店访问权;支持 `hotel_id``keyword``status=ACTIVE/DISABLED``page_num``page_size``keyword` 搜索 code 时大小写不敏感,显示名仍按数据库比较规则;返回 `items[] + page`,包含 `id``account_code``account_name``market_code``source_code``status``catalog_source``external_account_id``catalog_version``metadata_json``version``created_at``updated_at`。 | | `GET /api/admin/reservation/catalogs/accounts` | 管理后台 Account 目录列表 | 必须带 Bearer token需要 `RESERVATION_CATALOG_MANAGE` 和目标酒店访问权;支持 `hotel_id``keyword``status=ACTIVE/DISABLED``page_num``page_size``keyword` 搜索 code 时大小写不敏感,显示名仍按数据库比较规则;返回 `items[] + page`,包含 `id``account_code``account_name``market_code``source_code``status``catalog_source``external_account_id``catalog_version``metadata_json``version``created_at``updated_at`。 |
@@ -111,7 +111,7 @@
| `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 新增邮件会话详情接口,并补齐 `html_body_sanitized` / `html_render_mode`。 | 当前唯一推荐路径是这个接口;前端渲染邮件 HTML 时优先使用 `html_body_sanitized`;不要调用历史讨论过的 `/api/source-message-conversations/{externalConversationId}`。 |
| `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留、安全 HTML 字段和入口通知识别。 | 只用于调试页面;请求为 multipart/form-data必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。测试机 V4 smoke 默认使用实时 AgentBus V4 subject历史 Debug V2/V3 profile 只能由后端显式配置,不应作为 V4 smoke 入口。 | | `POST /api/system/debug/eml-superagent-runs` | 新增 Debug EML 上传到 SuperAgent 调试接口,并补齐独立 Debug 外部消息 ID、原始 Message-ID 保留、安全 HTML 字段和入口通知识别。 | 只用于调试页面;请求为 multipart/form-data必须传 `X-TH-Hotel-Debug-Upload-Key`,但该 key 不能写进前端源码、构建产物、URL、localStorage 或错误上报SuperAgent 返回旧 S000/S999 或新 S10/S99 入口通知时都不应被前端视为 JSON 解析失败。测试机 V4 smoke 默认使用实时 AgentBus V4 subject历史 Debug V2/V3 profile 只能由后端显式配置,不应作为 V4 smoke 入口。 |
| `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口,并在 CP6 打开卡片确认 / 来源通知 ack availability。 | 这是 V4 新模型前端主入口;前端应按每张卡或通知返回的 `availability.confirmable``availability.ackable``readonly_reason_code` 控制按钮。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 | | `GET /api/reservation/workbench-items` / `/api/reservation/order-tasks/**` / `/api/reservation/source-notifications/{notificationId}` | 新增 M002 V4 CP5 查询接口,并在 CP6 打开卡片确认 / 来源通知 ack availability。 | 这是 V4 新模型前端主入口;前端应按每张卡或通知返回的 `availability.confirmable``availability.ackable``readonly_reason_code` 控制按钮。工作台条目已返回 `created_at` / `updated_at` 作为排序兜底和调试字段;前端不要继续从旧 `/api/reservation/tasks/**` 推断 V4 多卡详情。 |
| `GET /api/reservation/lookups/accounts` / `/room-types` / `/rate-codes` | 新增 M002 V4 CP11 数据库目录 lookup。 | 前端从任务详情 `fields[].options_source` 选择调用哪个 lookup只提交返回项的 `code`,不要提交显示名、派生 Market / Source、目录完整对象或前端自造 code。2026-07-21 结论是 Rate Code 第一阶段仍按酒店级目录 lookup不做 Account 过滤;未来如新增适用关系,再另行扩展联动参数。`warnings[]` 非空时可做非阻塞提示。 | | `GET /api/reservation/lookups/accounts` / `/room-types` / `/rate-codes` | 新增 M002 V4 CP11 数据库目录 lookup。 | 前端从任务详情 `fields[].options_source` 选择调用哪个 lookuplookup 字段只提交返回项的 `code`,不要提交显示名、目录完整对象或前端自造 code。Basic Information 中 `market_code` / `source_code` 可由 Account 目录默认带出,但前端按可编辑字段允许人工覆盖,并随 `basic_information.market_code` / `basic_information.source_code` 提交,最终以后端确认校验和落库为准。2026-07-21 结论是 Rate Code 第一阶段仍按酒店级目录 lookup不做 Account 过滤;未来如新增适用关系,再另行扩展联动参数。`warnings[]` 非空时默认不在普通业务页展示,如需保留应进入技术折叠区或调试模式。 |
| `GET/POST/PUT /api/admin/reservation/catalogs/...` | 新增 M002 V4 目录管理后台 CP1 后端接口。 | 仅供系统设置 / 管理后台页面使用,必须带 `RESERVATION_CATALOG_MANAGE`;支持 Account、Room Type、Rate Code 列表、新增、启用 / 停用;停用后普通 lookup 不再返回该目录项。 | | `GET/POST/PUT /api/admin/reservation/catalogs/...` | 新增 M002 V4 目录管理后台 CP1 后端接口。 | 仅供系统设置 / 管理后台页面使用,必须带 `RESERVATION_CATALOG_MANAGE`;支持 Account、Room Type、Rate Code 列表、新增、启用 / 停用;停用后普通 lookup 不再返回该目录项。 |
V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。`order_task_status``card_status` 是稳定枚举查询参数,前端不要传中文文案或自造状态码。 V4 CP5 分页注意:`page_num` 从 1 开始,后端第一版安全上限为 100`page_size` 最大 100。超出上限时后端按上限处理并在 `page.page_num` / `page.page_size` 中返回实际使用值。`order_task_status``card_status` 是稳定枚举查询参数,前端不要传中文文案或自造状态码。
@@ -553,9 +553,9 @@ RESERVATION_ROOMING_LIST_GENERATE
- CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order``order_ref``manual_review`、校验诊断字段以及前端额外注入字段不会写入内部确认快照。 - CP8 / Room Information 展示模型后,确认接口按 `fields[]` 白名单收口:前端可以只提交用户修改过的可编辑字段,不建议整包回传 `display_payload`。后端会从当前卡展示快照生成确认快照,并只合并可写叶子字段;来源邮件、路由、`target_order``order_ref``manual_review`、校验诊断字段以及前端额外注入字段不会写入内部确认快照。
- 业务卡目录校验会递归检查稳定模型或历史兼容结构。例如 Room Information 新结构的房型位于 `/room_information/final_values/room_items/0/room_type_code`,错误详情会使用 `room_information.final_values.room_items.0.room_type_code`;历史兼容 `UPDATE_BOOKING` 的房型可能仍使用 `business_fields.after.room_items.0.room_type_code`。前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]` - 业务卡目录校验会递归检查稳定模型或历史兼容结构。例如 Room Information 新结构的房型位于 `/room_information/final_values/room_items/0/room_type_code`,错误详情会使用 `room_information.final_values.room_items.0.room_type_code`;历史兼容 `UPDATE_BOOKING` 的房型可能仍使用 `business_fields.after.room_items.0.room_type_code`。前端展示错误时优先用 `fields[].validation_errors`,接口 400 时可直接展示 `details[]`
- `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/room_information/final_values/room_items/0/room_type_code","value":"RM2"}]}``confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*``room_information.final_values.*` 或历史兼容 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order``order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段也不能替换整个对象 / 数组。 - `review-resolution` 请求示例:`{"version":0,"reason":"确认房型映射","confirmed_order_id":"123456","field_overrides":[{"field_pointer":"/room_information/final_values/room_items/0/room_type_code","value":"RM2"}]}``confirmed_order_id` 在订单任务归属未解决时必填;如果订单任务已经绑定订单且 `target_resolution_status=RESOLVED`,只能不传或传当前同一个订单 ID不能借该接口切换到其它订单。`field_pointer` 必须来自当前卡 `fields[]` 中可编辑的 `basic_information.*``room_information.final_values.*` 或历史兼容 `business_fields.*` 叶子字段;复核态允许编辑当前卡业务白名单内字段,不再限定只能改空值、`missing_fields[]` 或目录错误字段。前端不要提交来源邮件、路由、`target_order``order_ref`、缺失字段清单、`manual_review`、raw evidence、校验诊断字段也不能替换整个对象 / 数组。
- V4 `fields[]` 第一版字段说明Basic Information 固定返回 `/basic_information/account_code``/basic_information/market_code``/basic_information/source_code`;其中 Account `control_type=select``options_source=reservation_v4_account_catalog`Market / Source 为只读派生字段。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code``REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer即使该叶子字段原始值缺失、详情页显示 `value=null`,前端仍可按原样提交该 pointer。查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略。测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,确认部署后让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,日志会输出两侧 pointer 白名单、validation error pointers、`display_payload_has_room_information_final_values` 和拒绝原因。前端不要自行补未返回字段。 - V4 `fields[]` 第一版字段说明Basic Information 固定返回 `/basic_information/account_code``/basic_information/market_code``/basic_information/source_code`;其中 Account `control_type=select``options_source=reservation_v4_account_catalog`Market / Source 前端按可编辑文本字段展示,默认值可来自 Account 目录派生或后端当前 value用户可覆盖确认 / 复核时随 `basic_information.market_code` / `basic_information.source_code` 提交,后端最终校验和落库为准。Room Information 字段统一返回 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date``/room_information/final_values/room_items/0/room_type_code``REVIEW_REQUIRED` 状态下只要字段仍在当前卡业务白名单内且未被前置阻塞,就会返回 `editable=true` 并允许 `review-resolution` 提交同一个 pointer即使该叶子字段原始值缺失、详情页显示 `value=null`,前端仍可按原样提交该 pointer。查询侧 editable 计算和命令侧 pointer 校验共用同一套 Room Information 字段策略。测试机如仍出现 `V4_REVIEW_POINTER_NOT_ALLOWED`,确认部署后让后端日志检索 `review_pointer_policy=m002_v4_review_pointer_runtime_fix_v1`,日志会输出两侧 pointer 白名单、validation error pointers、`display_payload_has_room_information_final_values` 和拒绝原因。前端不要自行补未返回字段。
- V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时只提交稳定 `code`,不要提交显示名、派生 Market / Source 或目录完整对象;后端确认前仍会重新校验目录。2026-07-21 结论是 Rate Code 第一阶段继续按当前酒店级目录查询,不依赖 Account + `booking_type` 过滤,也不硬编码 OWNER RATE Excel 中的 Account 映射;未来如新增适用关系,再由后端接口提供联动能力。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]` - V4 CP11 已开放独立目录 lookup API。前端应使用 `GET /api/reservation/lookups/accounts``GET /api/reservation/lookups/room-types``GET /api/reservation/lookups/rate-codes` 渲染 Account / Room Type / Rate Code 选项;用户提交确认或复核时 lookup 字段只提交稳定 `code`,不要提交显示名或目录完整对象Basic Information 的 Market / Source 作为用户可覆盖业务字段提交。后端确认前仍会重新校验目录。2026-07-21 结论是 Rate Code 第一阶段继续按当前酒店级目录查询,不依赖 Account + `booking_type` 过滤,也不硬编码 OWNER RATE Excel 中的 Account 映射;未来如新增适用关系,再由后端接口提供联动能力。`keyword` 查不到只表示当前筛选无结果,不能仅凭 `items=[]` 判断目录未初始化,应结合 `catalog_source``catalog_version``warnings[]`
- M002 V4 CP12 前端已接入上述三个 lookup APIV4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项,空 `items[]``stale=true` `warnings[]` 作为非阻塞提示展示Account 选择后只展示目录返回的 `market_code` / `source_code` 辅助确认,确认 / 复核请求仍只提交用户选择的 code - M002 V4 CP12 前端已接入上述三个 lookup APIV4 多卡详情页会按当前卡 `fields[].options_source` 拉取目录选项;普通业务页默认不展示空目录`stale=true` `warnings[]` 等技术提示接口失败和当前值不在目录中仍展示错误Account 选择后的 Market / Source 默认值可用于人工确认,最终用户可在 Basic Information 中手工覆盖并提交
- V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft``confirm``manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。 - V4 新模型确认口径是不保存后端草稿、卡片最终确认后锁定、技术异常不进入用户可处理卡、当前不生成 OPERA 模拟操作。Basic Information 必须先确认;其它业务卡第一版不强制逐张顺序确认。现有 V3 `draft``confirm``manual-review-resolutions` 和 OPERA 模拟接口仍只代表旧链路能力,不能直接等同 V4 多卡最终接口。
- V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。 - V4 S10/S99 已采用来源通知模型入库:新 V4 `route_code=S10/S99` 不再挂隐藏技术订单,也不再创建旧 `SOURCE_MESSAGE_ONLY` 任务;对应工作台 / 来源通知详情查询接口和 ack 写接口已开放。旧 `SOURCE_MESSAGE_ONLY` 只读任务仅代表 V3 S10/S99 和旧 S000/S999 兼容数据。
- M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT``adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。 - M002 V3 的结构化 `S10/S99` 入站、40 条 P0.1 路由枚举 / 稳定配置、`UNHANDLED_CURRENT_INTENT``adapter_contract_error` transition 最小落库、任务列表 / 订单时间线 / 任务详情 V3 路由字段和只读诊断块透出、type-known manual review 同卡解阻第一版、typed infrastructure error、P0 fixtures 回归基线和 Parent Group / Cancel Allotment 路由修订均已完成。