From d91cd717df5e96230e7d55b88ff021cb68a8a1a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?=
Date: Sat, 8 Aug 2026 16:02:09 +0800
Subject: [PATCH] feat(reservation): solidify booking email v0.1 intake
---
.../components/local/LocalEditableField.vue | 149 +
.../components/local/LocalPrototypeHeader.vue | 58 +
.../ReservationV4RecognitionEvidence.vue | 222 ++
.../ReservationV4RoomInformationCard.vue | 15 +-
.../ReservationV4SourceMessageCard.vue | 14 +
.../ReservationV4TaskCardFieldRenderer.vue | 15 +-
.../ReservationV4TaskCardSection.vue | 7 +-
.../src/composables/useLocalPrototypeTheme.ts | 26 +
client/src/data/localTaskPrototype.ts | 951 ++++++
client/src/i18n/locales/en-US.ts | 209 ++
client/src/i18n/locales/th-TH.ts | 209 ++
client/src/i18n/locales/zh-CN.ts | 209 ++
client/src/layouts/ReservationAppShell.vue | 21 +-
client/src/router/index.ts | 68 +-
client/src/services/httpClient.ts | 7 +
client/src/services/reservationService.ts | 35 +-
.../src/tests/localTaskPrototypeViews.spec.ts | 351 +++
.../reservationBookingEmailIntakeView.spec.ts | 125 +
...eservationBookingProcessingRunView.spec.ts | 163 ++
client/src/tests/reservationRouter.spec.ts | 60 +-
client/src/tests/reservationService.spec.ts | 33 +
client/src/tests/reservationV4Views.spec.ts | 144 +
client/src/types/bookingContracts.ts | 201 ++
client/src/types/reservation.ts | 24 +
.../views/local/LocalOperationsDemoView.vue | 349 +++
.../src/views/local/LocalTaskDetailView.vue | 1245 ++++++++
client/src/views/local/localPrototype.css | 2578 +++++++++++++++++
.../ReservationBookingEmailIntakeView.vue | 1166 ++++++++
.../ReservationBookingProcessingRunView.vue | 991 +++++++
.../reservation/ReservationTaskListView.vue | 36 +
.../ReservationV4OrderTaskDetailView.vue | 22 +-
.../backend-to-frontend-notes.md | 18 +-
.../frontend-to-backend-api-requests.md | 6 +-
.../integrations/superagent-api-contract.md | 2 +-
.../M002-v4-agent-callback-field-contract.md | 4 +-
...002-v4-order-task-card-domain-model-cp2.md | 25 +-
...-v4-requirement-spec-template-alignment.md | 18 +-
...M012-booking-email-confirmation-e2e-v01.md | 384 +++
.../security-access-control-boundary.md | 7 +-
.../DebugEmlSuperAgentRunServiceImpl.java | 11 +-
.../ReservationBookingEmailIntakeResult.java | 56 +
...vationBookingEmailIntakeWarningResult.java | 14 +
...servationBookingEmailIntakeController.java | 47 +
.../ReservationTaskControllerAdvice.java | 2 +
...ybatisReservationAiWorkflowRepository.java | 3 +-
.../ReservationBookingEmailIntakeService.java | 15 +
.../ReservationAiTaskIntakeServiceImpl.java | 13 +
.../impl/ReservationBookingCatalog.java | 274 ++
...ervationBookingEmailIntakeServiceImpl.java | 676 +++++
.../impl/ReservationBookingEmailParser.java | 1102 +++++++
.../impl/ReservationV4CommandServiceImpl.java | 309 +-
.../impl/ReservationV4QueryServiceImpl.java | 209 +-
...servationV4RoomInformationFieldPolicy.java | 32 +
.../ReservationV4TaskIntakeServiceImpl.java | 72 +
.../catalog/booking-catalog-v20260808.json | 98 +
...ationBookingEmailIntakeControllerTest.java | 297 ++
...nBookingEmailRealSampleAcceptanceTest.java | 142 +
.../ReservationV4CommandControllerTest.java | 221 +-
.../ReservationV4QueryControllerTest.java | 10 +-
.../SuperAgentTaskResultControllerTest.java | 175 ++
.../ReservationBookingEmailParserTest.java | 393 +++
61 files changed, 13961 insertions(+), 377 deletions(-)
create mode 100644 client/src/components/local/LocalEditableField.vue
create mode 100644 client/src/components/local/LocalPrototypeHeader.vue
create mode 100644 client/src/components/reservation/ReservationV4RecognitionEvidence.vue
create mode 100644 client/src/composables/useLocalPrototypeTheme.ts
create mode 100644 client/src/data/localTaskPrototype.ts
create mode 100644 client/src/tests/localTaskPrototypeViews.spec.ts
create mode 100644 client/src/tests/reservationBookingEmailIntakeView.spec.ts
create mode 100644 client/src/tests/reservationBookingProcessingRunView.spec.ts
create mode 100644 client/src/types/bookingContracts.ts
create mode 100644 client/src/views/local/LocalOperationsDemoView.vue
create mode 100644 client/src/views/local/LocalTaskDetailView.vue
create mode 100644 client/src/views/local/localPrototype.css
create mode 100644 client/src/views/reservation/ReservationBookingEmailIntakeView.vue
create mode 100644 client/src/views/reservation/ReservationBookingProcessingRunView.vue
create mode 100644 docs/project/requirements/M012-booking-email-confirmation-e2e-v01.md
create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeResult.java
create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeWarningResult.java
create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingEmailIntakeController.java
create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationBookingEmailIntakeService.java
create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingCatalog.java
create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailIntakeServiceImpl.java
create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailParser.java
create mode 100644 server/src/main/resources/reservation-booking/catalog/booking-catalog-v20260808.json
create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingEmailIntakeControllerTest.java
create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingEmailRealSampleAcceptanceTest.java
create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailParserTest.java
diff --git a/client/src/components/local/LocalEditableField.vue b/client/src/components/local/LocalEditableField.vue
new file mode 100644
index 0000000..e196cb0
--- /dev/null
+++ b/client/src/components/local/LocalEditableField.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
diff --git a/client/src/components/local/LocalPrototypeHeader.vue b/client/src/components/local/LocalPrototypeHeader.vue
new file mode 100644
index 0000000..a3806fe
--- /dev/null
+++ b/client/src/components/local/LocalPrototypeHeader.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
diff --git a/client/src/components/reservation/ReservationV4RecognitionEvidence.vue b/client/src/components/reservation/ReservationV4RecognitionEvidence.vue
new file mode 100644
index 0000000..a0d660e
--- /dev/null
+++ b/client/src/components/reservation/ReservationV4RecognitionEvidence.vue
@@ -0,0 +1,222 @@
+
+
+
+
{{ t('taskV4.recognition.channel') }}
+ {{ channelProfile }}
+
+
+
{{ t('taskV4.recognition.sheet') }}
+ {{ sheetName }}
+
+
+
{{ t('taskV4.recognition.rows') }}
+ {{ rowReference }}
+
+
+
{{ t('taskV4.recognition.version') }}
+ {{ [catalogVersion, parserVersion].filter(Boolean).join(' · ') }}
+
+
+
+
+
{{ t('taskV4.recognition.sourceRooms') }}
+
{{ sourceRoomLabels.join(' · ') }}
+
+
+
+
{{ t('taskV4.recognition.warnings') }}
+
+
+
+
+
+
+
+
diff --git a/client/src/components/reservation/ReservationV4RoomInformationCard.vue b/client/src/components/reservation/ReservationV4RoomInformationCard.vue
index 0ecdd7d..7e3cdf7 100644
--- a/client/src/components/reservation/ReservationV4RoomInformationCard.vue
+++ b/client/src/components/reservation/ReservationV4RoomInformationCard.vue
@@ -286,6 +286,7 @@ const roomInformation = computed roomInformation.value?.event_type ?? props.card.event_type ?? '')
const bookingType = computed(() => roomInformation.value?.booking_type ?? '')
+const currentOnlyEventTypes = ['TRACE_RESERVATION_NOTES', 'ROOMING_LIST', 'PAYMENT']
const eventTypeLabel = computed(() => {
const normalizedEventType = eventType.value.trim().toUpperCase()
if (normalizedEventType === 'NEW_BOOKING') {
@@ -297,6 +298,9 @@ const eventTypeLabel = computed(() => {
if (normalizedEventType === 'CANCEL_BOOKING') {
return t('taskV4.roomInformation.eventType.CANCEL_BOOKING')
}
+ if (currentOnlyEventTypes.includes(normalizedEventType)) {
+ return t('taskV4.roomInformation.eventType.CURRENT_ORDER')
+ }
return t('taskV4.roomInformation.eventType.unknown')
})
const bookingTypeLabel = computed(() => {
@@ -399,7 +403,7 @@ const finalScalarEntries = computed(() => scalarEntries(finalValues.value, {
}))
const showChangeSummary = computed(() => eventType.value === 'UPDATE_BOOKING' && visibleChangeSummary.value.length > 0)
const showCurrentValues = computed(() =>
- ['UPDATE_BOOKING', 'CANCEL_BOOKING'].includes(eventType.value) &&
+ ['UPDATE_BOOKING', 'CANCEL_BOOKING', ...currentOnlyEventTypes].includes(eventType.value) &&
hasVisibleValues(currentValues.value, { hideEditableBreakfast: false }),
)
const showProposedValues = computed(() =>
@@ -409,6 +413,9 @@ const showProposedValues = computed(() =>
const showEditableFields = computed(() => eventType.value !== 'CANCEL_BOOKING' && finalDisplayFields.value.length > 0)
const scalarFieldOrder = [
+ 'names_text',
+ 'group_code',
+ 'group_name',
'group_block_name',
'fit_name',
'arrival_date',
@@ -420,6 +427,9 @@ const scalarFieldOrder = [
'breakfast_included',
]
const displayFieldOrder = [
+ 'names_text',
+ 'group_code',
+ 'group_name',
'group_block_name',
'fit_name',
'arrival_date',
@@ -642,6 +652,9 @@ function isRecord(value: unknown): value is ReservationRecord {
}
const roomInformationLabelKeys: Record = {
+ names_text: 'taskV4.roomInformation.namesText',
+ group_code: 'taskV4.roomInformation.groupCode',
+ group_name: 'taskV4.roomInformation.groupName',
group_block_name: 'taskV4.roomInformation.groupBlockName',
fit_name: 'taskV4.roomInformation.fitName',
arrival_date: 'taskV4.roomInformation.arrivalDate',
diff --git a/client/src/components/reservation/ReservationV4SourceMessageCard.vue b/client/src/components/reservation/ReservationV4SourceMessageCard.vue
index 5c7b41f..36266db 100644
--- a/client/src/components/reservation/ReservationV4SourceMessageCard.vue
+++ b/client/src/components/reservation/ReservationV4SourceMessageCard.vue
@@ -463,6 +463,7 @@ function compactBodyText(value: string): string {
.attachment-list li {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 8px;
min-width: 0;
border: 1px solid var(--th-color-slate-200);
@@ -473,8 +474,21 @@ function compactBodyText(value: string): string {
padding: 8px 10px;
}
+.attachment-list li i {
+ flex: 0 0 auto;
+}
+
+.attachment-list li span {
+ flex: 1 1 180px;
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
.attachment-list small {
+ flex: 1 1 220px;
+ min-width: 0;
color: var(--th-color-slate-500);
+ overflow-wrap: anywhere;
}
.v4-field-renderer {
diff --git a/client/src/components/reservation/ReservationV4TaskCardFieldRenderer.vue b/client/src/components/reservation/ReservationV4TaskCardFieldRenderer.vue
index 8d63219..2db8ae9 100644
--- a/client/src/components/reservation/ReservationV4TaskCardFieldRenderer.vue
+++ b/client/src/components/reservation/ReservationV4TaskCardFieldRenderer.vue
@@ -407,8 +407,12 @@ function visibleLookupHints(field: ReservationV4TaskCardFieldResult): LookupHint
}
function visibleFieldErrors(field: ReservationV4TaskCardFieldResult): string[] {
+ const currentValue = readV4FieldValue(field, props.modelValue)
+ const backendErrors = Object.is(currentValue, field.value)
+ ? (field.validation_errors ?? [])
+ : []
return [
- ...(field.validation_errors ?? []),
+ ...backendErrors,
fieldError(field),
].filter(Boolean)
}
@@ -664,6 +668,15 @@ function fieldLabelKey(field: ReservationV4TaskCardFieldResult): string | null {
if (key === '/room_information/final_values/group_block_name') {
return 'taskV4.roomInformation.groupBlockName'
}
+ if (key === '/room_information/final_values/names_text') {
+ return 'taskV4.roomInformation.namesText'
+ }
+ if (key === '/room_information/final_values/group_code') {
+ return 'taskV4.roomInformation.groupCode'
+ }
+ if (key === '/room_information/final_values/group_name') {
+ return 'taskV4.roomInformation.groupName'
+ }
if (key === '/room_information/final_values/fit_name') {
return 'taskV4.roomInformation.fitName'
}
diff --git a/client/src/components/reservation/ReservationV4TaskCardSection.vue b/client/src/components/reservation/ReservationV4TaskCardSection.vue
index d35f177..e0ebf98 100644
--- a/client/src/components/reservation/ReservationV4TaskCardSection.vue
+++ b/client/src/components/reservation/ReservationV4TaskCardSection.vue
@@ -26,6 +26,8 @@
+
+
-
+
+ {{ t('bookingEmailIntake.openAction') }}
+
@@ -445,6 +452,30 @@ function createPage(pageNum: number, pageSize: number, total: number): Reservati
margin: 0;
}
+.page-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.intake-link {
+ min-height: 38px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--th-radius-sm);
+ background: var(--th-color-blue-600);
+ color: var(--th-color-white);
+ font-size: 13px;
+ font-weight: 800;
+ padding: 8px 14px;
+}
+
+.intake-link:hover {
+ background: var(--th-color-blue-700);
+}
+
.page-heading h1 {
color: var(--th-color-slate-900);
font-size: 28px;
@@ -625,6 +656,11 @@ td small {
}
@media (max-width: 900px) {
+ .page-heading {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
.filter-grid {
grid-template-columns: 1fr;
}
diff --git a/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue b/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue
index 6fffa13..9e61e05 100644
--- a/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue
+++ b/client/src/views/reservation/ReservationV4OrderTaskDetailView.vue
@@ -108,6 +108,7 @@
:action-errors="cardActionErrors[detail.basic_information_card.card_id] ?? []"
:success-message="cardSuccessMessages[detail.basic_information_card.card_id] ?? ''"
:review-form="reviewForms[detail.basic_information_card.card_id]"
+ :show-confirmed-order-field="requiresConfirmedOrderId()"
:submitting="submittingCardId === detail.basic_information_card.card_id"
:can-confirm="canConfirmCard(detail.basic_information_card)"
:can-review="canReviewCard(detail.basic_information_card)"
@@ -153,6 +154,7 @@
:action-errors="cardActionErrors[businessCard.card_id] ?? []"
:success-message="cardSuccessMessages[businessCard.card_id] ?? ''"
:review-form="reviewForms[businessCard.card_id]"
+ :show-confirmed-order-field="requiresConfirmedOrderId()"
:submitting="submittingCardId === businessCard.card_id"
:can-confirm="canConfirmCard(businessCard)"
:can-review="canReviewCard(businessCard)"
@@ -337,25 +339,23 @@ function normalizeBasicInformationCard(card: ReservationV4TaskCardResult): Reser
return {
...card,
fields: card.fields.map((field) =>
- isEditableBasicMarketSourceField(field)
- ? normalizeEditableBasicMarketSourceField(card, field)
+ card.card_status !== 'REVIEW_REQUIRED' && isEditableBasicMarketSourceField(field)
+ ? normalizeEditableBasicMarketSourceField(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',
+ edit_scope: 'CONFIRM',
+ write_target: 'CONFIRMED_PAYLOAD_JSON',
control_hint: null,
}
}
@@ -516,7 +516,15 @@ function requiresConfirmedOrderId(): boolean {
if (!orderTask) {
return false
}
- return !orderTask.order_id || orderTask.target_resolution_status !== 'RESOLVED'
+ const ownershipUnresolved = !orderTask.order_id || orderTask.target_resolution_status !== 'RESOLVED'
+ return ownershipUnresolved && !isNewBookingTask()
+}
+
+function isNewBookingTask(): boolean {
+ const cards = detail.value
+ ? [detail.value.basic_information_card, ...detail.value.business_cards]
+ : []
+ return cards.some((card) => card?.event_type === 'NEW_BOOKING')
}
function editableFieldKeys(card: ReservationV4TaskCardResult): string[] {
diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md
index f279ad4..13c8bc6 100644
--- a/docs/project/frontend-backend/backend-to-frontend-notes.md
+++ b/docs/project/frontend-backend/backend-to-frontend-notes.md
@@ -18,6 +18,15 @@
- 前端接口新增或字段变更时,后端需同步更新 `docs/project/security-access-control-boundary.md`,前端也应按该文档区分普通业务、系统管理、Debug 和第三方接口。
- 前端页面不得把 Debug、Demo、Replay、Probe 等系统调试接口当成普通用户能力;这类入口需要环境开关和专门权限。
+### 2.1 M012 普通员工 Booking 邮件导入
+
+- 路由 `/reservation/email-intake` 是普通业务页面,不是 Debug 页面;从预订事项队列进入。
+- 上传调用 `POST /api/reservation/booking-email-intakes`,使用 `multipart/form-data` 的 `file` 和可选 `hotel_id`;仅接受 `.eml`,默认最大 25 MiB。
+- 响应只包含 SourceMessage ID、状态、duplicate、Catalog/Parser 版本、warning、订单任务 ID 和来源通知 ID;前端不得期待正文、附件字节或外链。
+- `TASKS_CREATED` 后按 `order_task_id` 进入 `/reservation/order-tasks/{id}`;`NOTIFICATION_CREATED` 后按 `notification_id` 进入来源通知详情;`DUPLICATE_REPLAY` 仍展示已有结果链接。
+- 识别证据读取任务卡 `display_payload.recognition` 的安全字段;不要解析原始 Excel 行、AI payload 或价格来补业务字段。
+- `REVIEW_REQUIRED` 只提交当前 `fields[].editable=true` 且用户实际修改的 pointer;不要把目录派生但当前不可编辑的 Market/Source 自动附带。确认后必须重新读取任务详情,以后端 `confirmed_payload` 为准。
+
## 3. 字段来源注意事项
- `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md` 是 0711 P0 前端 / Adapter 路由说明,覆盖 S10/S99、type-known manual review 和 fail-closed 口径;其中 Parent split / 42 路由口径已被 0712 P0.1 覆盖。
@@ -57,7 +66,7 @@
| `GET /api/reservation/workbench-items` | 查询 V4 工作台统一列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;返回 V4 业务订单任务和 S10/S99 来源通知混排摘要;支持 `hotel_id`、`item_type`、`keyword`、`page_num`、`page_size`;默认按 `source_received_at` 倒序,同一来源时间下按 `updated_at`、`created_at`、数字 `target_id` 倒序;列表不返回邮件正文、附件 URL、`ai_payload_json` 或来源通知原始 payload。 |
| `GET /api/reservation/order-tasks` | 查询 V4 业务订单任务列表 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`;只返回 V4 业务订单任务,不包含 S10/S99 来源通知;支持 `hotel_id`、`order_id`、`order_task_status`、`card_status`、`keyword`、`page_num`、`page_size`;`order_task_status` 非 `OPEN` / `COMPLETED` 返回 400,`card_status` 非 V4 卡状态返回 400;`card_status` 只筛业务 / 可处理卡,固定来源邮件展示卡不参与筛选。 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | 查询 V4 订单任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按订单任务实际酒店校验访问权;返回 `order_task`、`source_message_summary`、`source_message_card`、`basic_information_card`、`business_cards[]`、`card_counts`、`adapter_contract_errors[]` 和 `availability`;来源摘要按酒店过滤,邮件正文和附件仍走 SourceMessage 会话接口。V4 任务详情页展示顺序固定为 Basic Information、业务卡、SourceMessage Display;来源邮件卡位于页面最下方,正文限定为当前触发该 order task 的 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件。Payment 卡已返回 `display_payload.payment_attachments[]` 安全摘要用于展示凭证附件,字段只包含 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`、可选 `external_media_id` / `unavailable_reason_code`;后端只按当前 SourceMessage 包级附件 ID / 同酒店媒体 `external_media_id` 匹配,不按文件名或内部媒体 row ID 匹配;本接口不得返回附件 URL,图片缩略图 / 大图和非图片下载 URL 仍通过 SourceMessage 会话权限链路取得。CP8 起每张 V4 任务卡返回 `fields[]`,前端应以该字段白名单渲染可编辑控件;`write_target` 只返回 `confirmed_payload`、`review_resolution.field_overrides`、`none` 等前端安全语义,不暴露内部列名。Room Information 卡已新增 `display_payload.room_information` 稳定展示模型,前端优先读取 `current_values` / `proposed_values` / `final_values` / `change_summary[]`,不要再从 Agent raw payload、`business_fields` 或 `target_order` 自行推导业务展示;Basic Information 以及普通业务卡的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`,普通业务卡也会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。 |
-| `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 卡片确认、复核解阻和订单归属确认轨迹,只包含脱敏后的审计摘要,不包含邮件正文、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}/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` 时调用;Account 字段只提交 `items[].code`,Market / Source 可作为 Basic Information 字段默认值展示并允许用户覆盖。 |
@@ -72,7 +81,7 @@
前端已在系统设置下新增 `/system/reservation-catalogs` 消费上述目录管理接口。页面入口要求 `RESERVATION_CATALOG_MANAGE`,列表过滤直接传 `hotel_id`、`keyword`、`status`、`page_num`、`page_size`;Account 新增第一版固定提交 `market_code=LEISURE`、`source_code=TRAVEL_AGENT`;状态重复提交按成功提示处理,不额外弹失败。
-| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;Room Information 卡优先提交 `confirmed_payload.room_information.final_values`,后端写入稳定确认快照并重新派生 Nights / Breakfast / Group Booking Status 文案,不写 Agent `target_order`、Adult、邮件正文、附件 URL 或前端注入字段;确认 Rooming List 卡时前端只提交 `version` 即可,若同订单为 Group 且存在可更新 Room Information 确认快照,后端会自动把 Group Booking Status 置为 `DEF` 并写审计,刷新详情时 `display_payload` / `confirmed_payload` 均显示 DEF;没有可更新投影时确认仍成功且不创建不完整 Room Information;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code,Rate Code 第一阶段暂不校验 Account 适用关系;Room Information 新结构错误路径形如 `room_information.final_values.room_items.0.room_type_code`,历史兼容结构可能返回如 `business_fields.after.room_items.0.room_type_code`,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、内部写入确认快照 / 确认人 / 确认时间并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
+| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;前端只提交当前卡 `fields[]` 中可编辑字段,后端以展示快照为基准合并,未开放字段会被忽略;Room Information 卡优先提交 `confirmed_payload.room_information.final_values`,后端写入稳定确认快照并重新派生 Nights / Breakfast / Group Booking Status 文案,不写 Agent `target_order`、Adult、邮件正文、附件 URL 或前端注入字段;确认 Rooming List 卡时前端只提交 `version`,只更新本卡确认状态,不修改同订单 Room Information 或 Group Booking Status;确认前会按当前酒店数据库目录校验 Account / Room Type / Rate Code,Rate Code 第一阶段暂不校验 Account 适用关系;Room Information 新结构错误路径形如 `room_information.final_values.room_items.0.room_type_code`,历史兼容结构可能返回如 `business_fields.after.room_items.0.room_type_code`,失败返回 `V4_FIELD_VALIDATION_FAILED`;确认后卡片 `CONFIRMED`、内部写入确认快照 / 确认人 / 确认时间并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | V4 复核解阻并确认卡片 | 必须带 Bearer token,需要 `RESERVATION_MANUAL_REVIEW_RESOLVE`,仅用于 `card_status=REVIEW_REQUIRED`;请求 JSON 带 `version`,可选 `field_overrides[]` 和 `reason`;订单任务归属未解决时 `confirmed_order_id` 必填,且必须是当前酒店下真实可见订单;`field_overrides[]` 只允许当前卡 `fields[]` 白名单内可编辑业务字段,问题字段可按 `fields[].validation_errors` 红字提示;Room Information 复核优先使用 `/room_information/final_values/...` pointer,不允许指向 `nights`、`target_order`、Adult、Block ID、Confirmation Number 等只读 / 派生字段;成功后卡片 `CONFIRMED`、`review_status=RESOLVED`,写 `review_resolution_json/confirmed_payload_json/confirmed_at/confirmed_by` 并返回刷新后的订单任务详情。 |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;仅允许 `route_code=S10/S99`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;重复 ack 返回当前已确认状态且不新增审计;该动作不创建订单、不参与订单阻塞。 |
| `GET /api/reservation/order-tasks/{orderTaskId}/audits` / `GET /api/reservation/source-notifications/{notificationId}/audits` | 查询 V4 业务审计展示数据 | 必须带 Bearer token,需要 `RESERVATION_AUDIT_READ`;前端可在 V4 订单任务详情和 S10/S99 来源通知详情的“审计时间线”中调用。响应沿用旧审计行结构:`audit_id`、`actor_type`、`actor_id`、`action`、`reason`、`before_snapshot`、`after_snapshot`、`occurred_at`。快照已由后端脱敏,前端仍不要把未知 URL-like 字符串当附件或正文直渲。 |
@@ -545,8 +554,9 @@ RESERVATION_ROOMING_LIST_GENERATE
- M002 V4 CP5 已完成查询接口:普通 V4 业务包可通过 `/api/reservation/workbench-items`、`/api/reservation/order-tasks`、`/api/reservation/order-tasks/{orderTaskId}` 查看;V4 S10/S99 来源通知可通过 `/api/reservation/source-notifications/{notificationId}` 查看。M002 V4 CP6 已开放普通卡片确认和 S10/S99 ack 写接口;M002 V4 CP7 已开放 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` 复核解阻接口;M002 V4 CP8 已开放 V4 卡片 `fields[]` 白名单和目录校验;M002 V4 CP11 已把固定种子迁移到数据库目录,并开放 Account / Room Type / Rate Code lookup API;目录管理后台 CP1 已开放 Account / Room Type / Rate Code 后端列表、新增、启用 / 停用接口;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口。
- V4 任务卡的 `display_payload_json` 只保留后端白名单展示字段;`ai_payload_json` 才包含完整 SuperAgent 原始 event。后续 V4 查询接口不得把 `ai_payload_json`、附件 URL 或 raw evidence 直接给普通页面渲染;前端对来源消息卡附件字段仍做 URL-like 文本兜底脱敏。
- Room Information 卡已由后端返回业务展示模型,前端已按该模型完成第一版业务化展示,不再自行从 Agent raw payload、`business_fields` 或 `target_order` 计算。读取路径是业务卡 `display_payload.room_information`:`NEW_BOOKING` 展示最终值,Group 的最终订单投影字段 `group_block_name` 可编辑,默认来自 Agent `target_order.locator_value` 且 `locator_type=GROUP_CODE`;Fit 的最终订单投影字段 `fit_name` 可编辑,默认来自 `guest_name ?? target_order.locator_value`;Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,用户编辑只影响本系统最终订单投影和确认快照。`UPDATE_BOOKING` 顶部展示本地当前值到 Agent 修改后值的 `change_summary[]`,日期变化时连带展示 Nights 差异,字段区展示合并后的最终值;`CANCEL_BOOKING` 从本地订单投影只读展示 current / final 模型,不显示编辑控件。Nights 由后端按酒店本地日期派生,前端只展示不计算;Adult 不显示。
-- Room Information 卡 Breakfast 前端显示为“含早”勾选框:Group 固定勾选且只读;Fit 由后端按最终 Rate Code 中 `RB` / `RO` 派生,无法派生时作为必填勾选项。Group Booking Status 仅 Group 显示,稳定 code 为 `TEN` / `DEF` / `INQ`,展示文案为 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`;New Group 默认 `TEN`,`NEW_BOOKING` / `UPDATE_BOOKING` 确认前可改选,`CANCEL_BOOKING` 只读。
-- Rooming List 卡确认存在跨卡联动:同订单为 Group 时,确认 `ROOMING_LIST` 后后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN` 或 `INQ`;Fit 不显示也不变更该状态。该自动变更由后端写 `V4_ROOMING_LIST_AUTO_DEF` 审计,并在后续任务详情刷新时让 Room Information 的 `display_payload.room_information.final_values` 和 `confirmed_payload.room_information.final_values` 保持 DEF 口径一致;当前订单详情 `order_overview` 不返回 Group Booking Status 字段,仍只展示既有确认快照字段;如果没有可更新 Room Information 投影,确认仍成功,后端只写安全审计提示。
+- BR00/M012 双区块增量:只有 Trace、Rooming List 或 Payment 的 Order Task 也会在对应原生业务卡之外返回一张 `card_type=ROOM_INFORMATION` 的 companion Room;其 `event_type` 保留原辅助类型,前端统一显示为“当前预订信息”,展示 `current_values` / `final_values`,不展示 `proposed_values` 或 `change_summary`。同组已有 New / Update / Cancel 生命周期 Room 时后端不会重复补卡;前端不得按辅助卡数量自行复制 Room。
+- Room Information 卡 Breakfast 前端显示为“含早”勾选框:Group 固定勾选且只读;Fit 由后端按最终 Rate Code 中 `RB` / `RO` 派生,无法派生时作为必填勾选项。Group Booking Status 仅 Group 显示,稳定 code 为 `TEN` / `DEF` / `INQ`,展示文案为 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`;New Group 默认 `TEN`,`NEW_BOOKING` / `UPDATE_BOOKING` 及三类 auxiliary companion Room 确认前可改选,`CANCEL_BOOKING` 只读。辅助 Room 的 Rate Code 沿用当前后端策略保持只读。
+- Rooming List 卡确认没有跨卡联动:确认 `ROOMING_LIST` 只更新该卡和订单任务的确认状态,不修改同订单 Room Information、Group Booking Status、订单快照或其他业务卡。Fit 与 Group 均按同一安全边界处理;当前订单详情 `order_overview` 继续只展示既有确认快照字段。
- Rooming List 卡第一版是轻量事项确认卡:前端展示卡片标题、状态、目标订单信息和“确认卡片”按钮即可;不要做名单 rows、附件预览、Excel 生成或 PMS 导入入口。确认仅表示该 Rooming List 事项已人工处理。
- Payment 卡已由后端在 `display_payload.payment_attachments[]` 返回安全摘要,字段只包含附件 ID、文件名、类型、大小、是否图片、是否可预览 / 下载等,不包含外链。第一版 `attachment_ids[]` 是 Agent 返回的只读业务事实,前端只展示并确认卡片,不允许用户增删、替换或重新选择附件集合,也不把 `attachment_ids[]`、`externalUrl` 或完整附件对象提交回确认接口。
- V4 复核态卡片仍是原业务卡,不新建单独复核任务卡;页面状态显示“需要复核”,问题字段用 `fields[].validation_errors` 红字提示,主按钮文案统一为“确认卡片”。前端内部必须根据 `card_status=REVIEW_REQUIRED` 调用 `review-resolution`,不要调用普通 `confirm`。
diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md
index f688292..1a05e44 100644
--- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md
+++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md
@@ -8,6 +8,7 @@
| 优先级 | 接口 | 页面 / 场景 | 状态 |
| --- | --- | --- | --- |
+| Done | Booking EML 导入 `POST /api/reservation/booking-email-intakes` | 普通员工预订邮件导入页 | M012 V0.1 已实现;真实 EML 可创建 V4 任务/来源通知并返回可点击 ID;重复投递幂等 |
| P0 | 订单列表接口 `GET /api/reservation/orders` | 订单列表页、首页工作台 | 已完成第一版 |
| P0 | 任务列表接口 `GET /api/reservation/tasks` | 任务列表菜单、订单详情任务入口 | 已完成第一版,已补来源邮件会话字段和 `order_status` 筛选 |
| P0 | 订单详情接口 `GET /api/reservation/orders/{orderId}` | 订单详情页 | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段和 V4 `order_overview` / `next_v4_action` / `related_source_messages[]` / `v4_order_tasks[].cards[]`,前端订单详情总览页已接入 |
@@ -31,10 +32,11 @@
| 接口 / 能力 | 当前后端状态 | 前端是否可直接接入 | 仍需后端处理 |
| --- | --- | --- | --- |
+| `POST /api/reservation/booking-email-intakes` | M012 V0.1 已完成;Bearer + `RESERVATION_TASK_EDIT` + 酒店访问权;仅接受 `.eml`,默认 25 MiB;固定渠道确定性解析后写入现有 V4 主线 | 可以;页面 `/reservation/email-intake` 已接入 | 手工导入当前只返回附件元数据/识别证据,不提供原始附件下载;Parser 无事实时生成 S10/S99 通知,不在本请求内自动调用外部 Booking Agent。统一 AgentBus fallback 编排后置。 |
| `GET /api/reservation/tasks` | 已完成第一版,已补来源邮件会话字段和所属订单状态筛选 | 可以 | `order_status` 按任务所属订单状态过滤;不传时保持当前全部任务列表行为。 |
| `GET /api/reservation/orders/{orderId}` | 已完成第一版,已补旧 `tasks[]` 来源邮件会话字段、V4 总览和 V4 `v4_order_tasks[]` 时间线,前端订单详情总览页已接入 | 可以 | 暂无;`include_tasks=false` 时旧 `tasks[]`、V4 `v4_order_tasks[]` 和 `related_source_messages[]` 都返回空数组,`order_overview` 为空快照,`next_v4_action.action_type=NONE`。 |
| `GET /api/reservation/tasks/{taskId}` | 已完成第一版,已补任务顶层来源邮件字段和 `fields[]` 3.0 元数据 | 可以 | 当前 Controller 不接收 `hotel_id`;如后续多酒店隔离需要前端显式传酒店上下文,请后端补可选入参或确认按 taskId 全局唯一即可。 |
-| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 后端展示模型已补齐且前端业务化展示已接入;Rooming List 确认自动 DEF 后端联动已完成;复核态字段白名单第一版已随 `fields[]` 返回;Payment 附件安全摘要已补齐;Trace 后端稳定字段契约已收口 | 可以;Room Information 前端业务化展示已完成,Rooming List 轻量确认可继续进入前端业务化展示,Payment 预览可基于后端安全摘要继续联动,Trace 专属卡可按 `fields[]` 接入 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡普通事项内容字段统一为 `trace_items[].text`,不要使用或提交 `content`;`GENERAL` 可编辑 `/trace_items/{index}/text`、`/trace_items/{index}/department_code`,`EXTRA_BED` 可编辑 `/trace_items/{index}/target_room_type_code`、`/trace_items/{index}/extra_bed_room_count`、`/trace_items/{index}/department_code`;`department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入,对应字段返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]`;`target_room_type_code` 使用 Room Type lookup 且只校验当前酒店目录存在,`extra_bed_room_count` 为正整数。Room Information 已由后端返回 `display_payload.room_information`:New 展示最终值,Update 展示 `current_values`、`proposed_values`、`final_values` 和 `change_summary[]`,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,默认值可来自 Agent `target_order`,但 Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,也不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer;`write_target=confirmed_payload` 是前端请求体语义,不是后端表字段名。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`,并以 `fields[].editable` 渲染当前卡白名单字段,不只渲染 missing/error 字段。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后,如同订单为 Group,后端会自动把已确认 Room Information 快照中的 Group Booking Status 置为 `DEF`,后续刷新任务详情的 `display_payload` 和 `confirmed_payload` 都会显示 DEF,不需要前端自行提交或计算该状态,并可通过订单任务审计看到 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡已返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 |
+| `GET /api/reservation/order-tasks/{orderTaskId}` | 已完成第一版;Room Information 后端展示模型已补齐且前端业务化展示已接入;Rooming List 确认无跨卡副作用;复核态字段白名单第一版已随 `fields[]` 返回;Payment 附件安全摘要已补齐;Trace 后端稳定字段契约已收口 | 可以;Room Information 前端业务化展示已完成,Rooming List 轻量确认可继续进入前端业务化展示,Payment 预览可基于后端安全摘要继续联动,Trace 专属卡可按 `fields[]` 接入 | V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Trace 卡普通事项内容字段统一为 `trace_items[].text`,不要使用或提交 `content`;`GENERAL` 可编辑 `/trace_items/{index}/text`、`/trace_items/{index}/department_code`,`EXTRA_BED` 可编辑 `/trace_items/{index}/target_room_type_code`、`/trace_items/{index}/extra_bed_room_count`、`/trace_items/{index}/department_code`;`department_code` 第一版固定为 `FO` / `HSK` / `FO+HSK` 三个下拉值,不调用 Department lookup,不开放自由输入,对应字段返回 `options_source=reservation_v4_trace_department_fixed` 和 `fixed_options[]`;`target_room_type_code` 使用 Room Type lookup 且只校验当前酒店目录存在,`extra_bed_room_count` 为正整数。Room Information 已由后端返回 `display_payload.room_information`:New 展示最终值,Update 展示 `current_values`、`proposed_values`、`final_values` 和 `change_summary[]`,Cancel 展示本地订单投影只读;Nights 后端按酒店本地日期派生,Breakfast 前端为含早勾选框,Group Booking Status 显示 `TEN-Tentative` / `DEF-Definite` / `INQ-Inquiry`;New Booking 最终订单投影字段 `group_block_name` / `fit_name` 可编辑,默认值可来自 Agent `target_order`,但 Agent 原始 `target_order` 不在普通 `display_payload` / `confirmed_payload` 中暴露,也不被用户编辑回写。`fields[]` 中 Room Information 字段统一使用 `/room_information/final_values/...`,确认 payload 和复核 `field_overrides[]` 均优先使用这些 pointer;`write_target=confirmed_payload` 是前端请求体语义,不是后端表字段名。`REVIEW_REQUIRED` 仍是原业务卡复核态,问题字段红字提示,按钮统一显示“确认卡片”,前端内部调用 `review-resolution`,并以 `fields[].editable` 渲染当前卡白名单字段,不只渲染 missing/error 字段。Rooming List 卡第一版只做事项确认,前端展示标题、状态、目标订单信息和“确认卡片”按钮,不做名单 rows、附件预览、Excel 生成或 PMS 导入;确认 `ROOMING_LIST` 后仅更新当前事项卡和订单任务派生状态,不修改同订单 Room Information、Group Booking Status 或其他业务卡;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。本接口仍不直接返回邮件正文或附件 URL。来源邮件卡正文限定为当前触发该 V4 order task 的那封 SourceMessage 正文,前端用 `source_message_summary.source_message_id` 调用 `GET /api/source-messages/{sourceMessageId}/conversation` 后定位当前邮件,默认长度折叠并可展开;缺少 `SOURCE_MESSAGE_ORIGINAL_READ` 或会话接口失败时降级展示安全摘要。Payment 卡已返回 `payment_attachments[]` 安全摘要,供前端展示图片缩略图 / 非图片文件列表;`attachment_ids[]` 第一版只读,不支持前端增删、替换或重新选择附件集合;实际大图预览和下载 URL 仍走 SourceMessage conversation。 |
| `PUT /api/reservation/tasks/{taskId}/draft` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 |
| `POST /api/reservation/tasks/{taskId}/confirm` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如写操作需要酒店上下文幂等 / 权限校验,请后端补可选入参或请求体字段。 |
| `GET /api/reservation/tasks/{taskId}/audits` | 已完成 | 可以 | 当前 Controller 不接收 `hotel_id`;如审计查询需要酒店上下文隔离,请后端补可选入参。 |
@@ -1288,4 +1290,4 @@ POST /api/reservation/tasks/{taskId}/order-binding
- V4 真实目录与 Lookup API 第一版已在后端 CP11 落地,前端 CP12 已接入 `GET /api/reservation/lookups/accounts`、`GET /api/reservation/lookups/room-types`、`GET /api/reservation/lookups/rate-codes` 用于 V4 字段选择控件。前端按 `options_source` 选择接口,空列表 / stale / warnings 只做非阻塞提示,确认和复核仍只提交 code;2026-07-21 结论是 Rate Code 第一阶段暂不按 Account + `booking_type` 过滤,继续使用酒店级 `RATE_CODE` 目录,前端不要硬编码 OWNER RATE Excel 中的 Account 映射;真实 PMS 同步、目录管理后台扩展和 SuperAgent 目录机器接口仍后置。
- V4 Payment 附件预览下一阶段已确认:后端需补 `payment_attachments[]` 安全摘要;前端图片缩略图 + 点击大图预览,非图片文件列表 + 下载;预览和下载仍走 SourceMessage conversation 原文权限链路。`attachment_ids[]` 第一版作为 Agent 返回的只读业务事实,前端只展示并确认卡片,不做附件集合编辑。
- V4 复核态交互已确认:`REVIEW_REQUIRED` 不新建独立复核任务卡,仍在原业务卡内编辑当前卡 `fields[]` 白名单业务字段;问题字段红字提示;主按钮文案统一为“确认卡片”,但前端内部调用 `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution`。
-- V4 Room Information smoke 修复已完成:`REVIEW_REQUIRED` 下当前卡白名单业务字段会按 `fields[].editable=true` 暴露给前端,`/room_information/final_values/...` pointer 可用于复核提交;Basic Information 和普通业务卡的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`,普通业务卡也会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;Rooming List 自动 DEF 后刷新任务详情的 Room Information `display_payload` / `confirmed_payload` 应显示 DEF,审计接口返回 `V4_ROOMING_LIST_AUTO_DEF`;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。
+- V4 Room Information smoke 修复已完成:`REVIEW_REQUIRED` 下当前卡白名单业务字段会按 `fields[].editable=true` 暴露给前端,`/room_information/final_values/...` pointer 可用于复核提交;Basic Information 和普通业务卡的 `display_payload` / `confirmed_payload` 不返回 Agent `target_order`,普通业务卡也会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;Rooming List 确认不修改 Room Information `display_payload` / `confirmed_payload`、Group Booking Status 或其他卡片;当前订单详情 `order_overview` 不返回 Group Booking Status 字段。
diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md
index f52c04a..716de91 100644
--- a/docs/project/integrations/superagent-api-contract.md
+++ b/docs/project/integrations/superagent-api-contract.md
@@ -602,7 +602,7 @@ V4 字段说明:
- 普通业务包按 `source_message + order_ref` 创建 V4 订单任务,并创建 `SOURCE_MESSAGE_DISPLAY`、`BASIC_INFORMATION` 和业务事件卡。
- Basic Information 必须先确认;业务卡逐卡确认或复核解阻,确认后永久锁定;V4 第一版不提供前端草稿。
- `ROOM_INFORMATION` 卡只由 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING` 触发;New / Update / Cancel 的最终值、差异、Nights、Breakfast、Group Booking Status 和本地订单投影展示由本系统后端展示模型提供,不扩大 SuperAgent 输入契约。
-- `ROOMING_LIST` 卡第一版只做事项确认;用户点击“确认卡片”表示已人工处理,不代表名单已解析、Excel 已生成或 PMS 已导入。确认后的 Group Booking Status 自动置 `DEF` 是本系统后端联动,不要求 SuperAgent 在 `ROOMING_LIST` event 中额外输出字段。
+- `ROOMING_LIST` 卡第一版只做事项确认;用户点击“确认卡片”表示已人工处理,不代表名单已解析、Excel 已生成或 PMS 已导入。确认不修改 Group Booking Status 或同订单其他卡片,不要求 SuperAgent 在 `ROOMING_LIST` event 中额外输出字段。
- `route_code=S10/S99` 创建 V4 来源通知,工作台可见,订单列表和订单详情不可见;来源通知只能 ack,不创建订单、不阻塞订单。
- V4 包级结构错误如果仍能通过 `source_message.source_message_id` 定位 SourceMessage,会返回成功接收并写入 `adapter_contract_error` transition;不创建订单任务、任务卡或来源通知。`source_message_id` 缺失或找不到 SourceMessage 时仍返回明确错误。
- `PAYMENT.attachment_ids[]` 引用不存在的附件、`UPDATE_BOOKING` 携带不允许字段、目录代码无法匹配当前酒店数据库目录,以及其他 V4 event 契约错误,只写 `adapter_contract_error` transition,不创建用户可处理业务任务。
diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md
index ac9c858..a5c3906 100644
--- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md
+++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md
@@ -16,7 +16,7 @@
本契约用于后续 M002 V4 主流程设计、后端领域建模、前端页面模型、Adapter / MCP Schema 对齐和 SuperAgent 联调。当前后端已按本文完成 V4 入站解析和多卡模型基线:能识别 V4 包、校验关键契约、保存 AI transition / 任务卡原始 payload,并把可映射的六类 event 写入 V4 订单任务和任务卡模型。
-V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP14 和停止旧任务双写 checkpoint,表结构、Entity、Mapper、Repository、SuperAgent V4 入站写入、V4 查询、普通卡片确认、S10/S99 ack、V4 复核解阻、数据库目录、Account / Room Type / Rate Code Lookup API、目录管理后台 CP1 后端接口、订单列表 V4 继续处理入口字段,以及 V4 普通业务不再创建旧 `workflow_reservation_task` 已实现。Room Information 卡 New / Update / Cancel 展示模型、Nights / Breakfast / Group Booking Status 派生,以及 Rooming List 确认后 Group 自动置 `DEF` 的后端联动已实现;这些均不扩大 SuperAgent 输入字段。2026-07-21 OWNER RATE `RATECODE (2)` 只读整理已确认:Room Type 第一阶段只维护 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3` 六个稳定 code,不建 Account -> Room Type 关系;Rate Code 第一阶段暂不建立 Account 适用关系,Q.B.D / LIAN TAI 的 40 个规范化 Rate Code 作为酒店级目录候选;真实 PMS 同步仍后置,方案见 `M002-v4-real-catalog-lookup-api-design.md`。
+V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP14 和停止旧任务双写 checkpoint,表结构、Entity、Mapper、Repository、SuperAgent V4 入站写入、V4 查询、普通卡片确认、S10/S99 ack、V4 复核解阻、数据库目录、Account / Room Type / Rate Code Lookup API、目录管理后台 CP1 后端接口、订单列表 V4 继续处理入口字段,以及 V4 普通业务不再创建旧 `workflow_reservation_task` 已实现。Room Information 卡 New / Update / Cancel 展示模型、Nights / Breakfast / Group Booking Status 派生,以及 Rooming List 确认无跨卡副作用的安全边界已完成;这些均不扩大 SuperAgent 输入字段。2026-07-21 OWNER RATE `RATECODE (2)` 只读整理已确认:Room Type 第一阶段只维护 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3` 六个稳定 code,不建 Account -> Room Type 关系;Rate Code 第一阶段暂不建立 Account 适用关系,Q.B.D / LIAN TAI 的 40 个规范化 Rate Code 作为酒店级目录候选;真实 PMS 同步仍后置,方案见 `M002-v4-real-catalog-lookup-api-design.md`。
2026-07-22 后,MCP `th_hotel_submit_task_results` 已与本文 V4 字段契约对齐并收口为 V4-only:只接受 `route_code + source_message + order_contexts[] + message_events[]`,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED`。
@@ -725,7 +725,7 @@ AI 回调包
- 普通业务包要求 `route_code=null`,并按 `message_events[]` 数组顺序处理。
- 第一版识别六类 `event_type`:`NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT`。
- `ROOM_INFORMATION` 卡只由 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING` 触发;Nights、Breakfast、Group Booking Status、Block ID、Confirmation Number 和本地当前值展示均由本系统后端展示模型派生或查询,不扩大 SuperAgent 输出字段。
-- `ROOMING_LIST` 第一版只表达 Rooming List 事项需要人工处理,确认卡片即表示人工已处理;不承载名单解析、附件预览、Excel 生成或 PMS 导入语义。确认后的 Group Booking Status 自动置 `DEF` 是本系统后端联动,不要求 SuperAgent 增加字段。
+- `ROOMING_LIST` 第一版只表达 Rooming List 事项需要人工处理,确认卡片即表示人工已处理;不承载名单解析、附件预览、Excel 生成或 PMS 导入语义。确认不修改 Group Booking Status 或同订单其他卡片,也不要求 SuperAgent 增加字段。
- 能映射到现有稳定任务卡的 event 会创建业务任务,并在 `ai_payload_json` 中保存 `v4_source_message`、`v4_order_context`、`v4_message_event`、`route_code`、系统处理分类和 `field_contract_version=20260718-v4`。
- V4 入站校验和路由已拆分为独立 Validator / Router,主业务 service 只负责编排、幂等和落库。
- V4 包级契约错误在 SourceMessage 可定位时只写 `adapter_contract_error` transition,不创建订单、任务或用户可处理卡。
diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md
index 37150ae..48b553b 100644
--- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md
+++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md
@@ -6,7 +6,7 @@
| --- | --- |
| 文档版本 | 0.9 |
| 日期 | 2026-07-21 |
-| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14、V4 业务审计查询、停止 V4 普通业务双写旧任务、Room Information 后端展示模型和 Payment 附件安全摘要后端第一版已实现 |
+| 状态 | CP2 设计已确认;CP3-CP8、CP11、CP13、CP14、V4 业务审计查询、停止 V4 普通业务双写旧任务、Room Information 后端展示模型、BR00 辅助订单事件 companion Room 和 Payment 附件安全摘要后端第一版已实现 |
| 适用范围 | M002 V4 入站后的订单任务、多卡、状态、查询和写操作设计 |
| 不适用范围 | 真实 PMS / OPERA / OHIP、前端页面视觉稿、生产历史数据迁移 |
@@ -16,7 +16,7 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路
本文是 CP2 设计文档,用于把 2026-07-18 V4 字段契约落成后续可开发的数据模型和接口草案。
-截至 CP14、V4 业务审计查询、停止旧任务双写、Room Information 后端展示模型、Rooming List 确认自动 DEF 后端联动和 Payment 附件安全摘要后端第一版,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包只创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡,不再创建旧 `workflow_reservation_task`;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1、订单列表 V4 继续处理入口字段、Room Information New / Update / Cancel 第一版业务展示模型、Rooming List 确认触发 Group Booking Status 自动置 `DEF`,以及 Payment 卡 `payment_attachments[]` 安全摘要。2026-07-21 OWNER RATE `RATECODE (2)` 只读整理已确认:Room Type 第一阶段只维护 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3` 六个稳定 code,不建 Account -> Room Type 关系;Rate Code 第一阶段暂不建立 Account 适用关系,Q.B.D / LIAN TAI 的 40 个规范化 Rate Code 作为酒店级目录候选。真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。
+截至 CP14、V4 业务审计查询、停止旧任务双写、Room Information 后端展示模型、Rooming List 确认无跨卡副作用和 Payment 附件安全摘要后端第一版,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包只创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡,不再创建旧 `workflow_reservation_task`;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1、订单列表 V4 继续处理入口字段、Room Information New / Update / Cancel 第一版业务展示模型、Rooming List 确认不修改 Group Booking Status,以及 Payment 卡 `payment_attachments[]` 安全摘要。2026-07-21 OWNER RATE `RATECODE (2)` 只读整理已确认:Room Type 第一阶段只维护 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3` 六个稳定 code,不建 Account -> Room Type 关系;Rate Code 第一阶段暂不建立 Account 适用关系,Q.B.D / LIAN TAI 的 40 个规范化 Rate Code 作为酒店级目录候选。真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。
2026-07-22 后,MCP `th_hotel_submit_task_results` 也已与本文模型对齐:MCP submit 只接受 M002 V4 包级结构,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不会绕回旧 `workflow_reservation_task` 模型。
@@ -59,7 +59,8 @@ order_contexts[]
message_events[]
-> 每个 event 按 order_ref 挂到对应订单任务
- -> 每个 event 创建 1 张业务任务卡
+ -> 每个 event 创建 1 张原生业务任务卡
+ -> 仅含 Trace / Rooming List / Payment 时,订单任务额外创建 1 张共享 companion Room
target_order
-> 用于订单任务绑定本地订单投影
@@ -91,7 +92,7 @@ V4 package / event contract error
| `SOURCE_MESSAGE_DISPLAY` | `source_message` | 否 | 否 | 普通业务包内固定展示当前邮件和附件入口 |
| `SOURCE_MESSAGE_NOTIFICATION` | `route_code=S10` | 是,仅确认已读 | 否 | 纯通知邮件,只需要用户确认已读 / 已处理 |
| `BASIC_INFORMATION` | `order_contexts[].basic_information` | 是 | 是 | 订单级 Account / Market / Source 卡,第一版 Agent 只给 `account_code` |
-| `ROOM_INFORMATION` | `NEW_BOOKING` / `UPDATE_BOOKING` / `CANCEL_BOOKING` | 是 | 是 | 房型信息卡,卡内按 New / Update / Cancel 展示最终值、差异和系统派生字段 |
+| `ROOM_INFORMATION` | `NEW_BOOKING` / `UPDATE_BOOKING` / `CANCEL_BOOKING`;或仅含辅助订单事件时的 companion Room | 是 | 是 | 房型信息卡;生命周期事件展示最终值/差异,Trace / Rooming List / Payment companion Room 展示同订单 current-only 上下文 |
| `TRACE_RESERVATION_NOTES` | `TRACE_RESERVATION_NOTES` | 是 | 是 | Trace 卡,卡内可有普通备注和加床备注多条事项 |
| `ROOMING_LIST` | `ROOMING_LIST` | 是 | 否 | 第一版只做事项确认;不在 Agent 回调里保存名单 rows,不生成 Excel,不导入 PMS |
| `PAYMENT` | `PAYMENT` | 是 | 第一版主要确认附件关联 | 付款凭证卡;业务事实仍是 `attachment_ids[]` 引用包级附件,展示层可返回匹配后的附件安全摘要 |
@@ -102,6 +103,7 @@ V4 package / event contract error
- 来源邮件展示卡不是 event,普通业务包内只读,不参与订单执行阻塞。
- S10/S99 采用来源通知模型:任务列表 / 工作台可见,点击进入纯通知详情页;只显示邮件展示卡和确认按钮,不创建订单、不进订单列表、不参与订单阻塞,也不支持编辑、复核、OPERA 或人工终止。
- 同一 `order_ref` 下如果有多个相同 `event_type`,第一版按 event 数组项分别建卡;页面排序按固定卡片顺序,再按 `source_event_index` 排序。
+- 同一订单任务已有 `NEW_BOOKING`、`UPDATE_BOOKING` 或 `CANCEL_BOOKING` 的 Room 时,不再为 Trace / Rooming List / Payment 复制 Room;若订单任务只含这三类辅助事件,则按接收顺序用首个事件生成一张共享 companion Room,并保留原始 `event_type`、transition 与 `source_event_index`。
## 6. 状态设计
@@ -633,7 +635,8 @@ V4 任务详情页的 `SOURCE_MESSAGE_DISPLAY` 固定展示在 Basic Information
Room Information 卡展示模型:
-- `ROOM_INFORMATION` 卡只由 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING` 三类 event 触发;`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT` 不触发房型信息卡。
+- `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING` 各自生成生命周期 Room;仅含 `TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT` 的订单任务额外生成一张共享 companion Room,因此六类可定位订单事项都有 Basic + Room 双区块。
+- auxiliary companion Room 保留原辅助 `event_type`,但不把辅助 payload 解释成房间变更:`current_values` 与 `final_values` 取同订单已确认投影,`proposed_values={}`、`change_summary=[]`。Trace / Rooming List / Payment 原生卡、确认动作和副作用保持独立。
- SuperAgent 仍只输出字段契约中的业务字段。Nights、Breakfast、Group Booking Status、Block ID、Confirmation Number 和 Adult 不由 SuperAgent 输出;其中 Adult 第一版不在卡内展示。
- 后端已在 `GET /api/reservation/order-tasks/{orderTaskId}` 的 Room Information 业务卡 `display_payload.room_information` 中补稳定展示模型,结构为 `event_type`、`booking_type`、`current_values`、`proposed_values`、`final_values`、`change_summary[]`、`group_booking_status_options[]`。前端按该展示模型渲染业务 UI,不再从 Agent raw payload / `target_order` 自行推导;如果存量或调试数据里已经持久化为稳定 `room_information.final_values` 模型,后端会按该稳定模型归一化展示和复核,不再回退到 Agent raw 推导。`fields[]` 继续作为确认 / 复核的可编辑字段白名单。`fields[].write_target` 对前端只表达请求体目标,例如 `confirmed_payload` 或 `review_resolution.field_overrides`,不暴露后端内部列名。
- `fields[]` 的 Room Information 主路径统一为 `/room_information/final_values/...`,例如 `/room_information/final_values/arrival_date`、`/room_information/final_values/room_items/0/room_type_code`。确认接口收到该结构时,后端会从展示模型派生 `confirmed_payload_json.room_information.final_values`,并重新计算 `nights`、`breakfast_included` 和 `group_booking_status_label`;只读字段、Agent `target_order`、Adult 和前端注入字段不会写入确认快照。`REVIEW_REQUIRED` 状态下,当前卡白名单内业务字段可以返回 `editable=true` 并允许同一 pointer 走 `review-resolution`,不再限定只能修空值、`missing_fields[]` 或目录错误字段。
@@ -642,8 +645,8 @@ Room Information 卡展示模型:
- `CANCEL_BOOKING`:不使用 Agent 输出当前订单快照;后端从本地订单投影读取当前值并只读展示,用户只确认整单取消。Cancel 卡不允许编辑 Group Booking Status、Breakfast、日期、Rate Code 或房型房量。
- `nights` 由后端按酒店本地业务日期计算:`departure_date - arrival_date`,不涉及时区和 UTC;日期缺失、非法或离店早于入住时,`nights` 为空。第一版确认校验要求日期必填,后续如需更严格营业日规则另开 checkpoint。
- `breakfast_included` 是卡片展示和确认使用的布尔字段。Group 固定含早,前端显示勾选且只读;Fit 按 Rate Code 派生,Rate Code 包含 `RB` 时含早,包含 `RO` 时不含早;如果 Rate Code 无法派生,前端显示必填勾选框,由用户确认是否含早。
-- Group Booking Status 仅 Group 显示,稳定 code 为 `TEN`、`DEF`、`INQ`,前端显示 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`。New Group 默认 `TEN`;`booking_scenario=STANDARD | PROPOSAL` 仅保留为 Agent 场景参考,不映射 Group Booking Status。`NEW_BOOKING` / `UPDATE_BOOKING` 确认前可手动改选,`CANCEL_BOOKING` 只读。
-- `ROOMING_LIST` 卡确认时,如果同订单为 Group,后端已把 Group Booking Status 自动置为 `DEF`,即使此前为 `TEN` 或 `INQ`;该自动变更写入 `V4_ROOMING_LIST_AUTO_DEF` 业务审计,并且刷新任务详情时 Room Information 的 `display_payload.room_information.final_values`、`confirmed_payload.room_information.final_values` 都以后端 DEF 后的确认快照为准。当前订单详情 `order_overview` 不返回 Group Booking Status 字段,仍只展示既有确认快照字段。Fit 不显示也不变更 Group Booking Status。
+- Group Booking Status 仅 Group 显示,稳定 code 为 `TEN`、`DEF`、`INQ`,前端显示 `TEN-Tentative`、`DEF-Definite`、`INQ-Inquiry`。New Group 默认 `TEN`;`booking_scenario=STANDARD | PROPOSAL` 仅保留为 Agent 场景参考,不映射 Group Booking Status。`NEW_BOOKING` / `UPDATE_BOOKING` 与三类 auxiliary companion Room 确认前可手动改选,`CANCEL_BOOKING` 只读。
+- `ROOMING_LIST` 卡确认只更新当前事项卡和订单任务派生状态;无论 Group 或 Fit,均不修改同订单 Room Information、Group Booking Status、订单快照或其他业务卡。当前订单详情 `order_overview` 继续只展示既有确认快照字段。
- `target_order.locator_value` 不作为前端可编辑字段,也不在普通任务详情的 Basic Information 或普通业务卡 `display_payload` / `confirmed_payload` 中返回;订单归属错误时通过 V4 复核选择正确订单或创建正确订单投影,不直接改写 Agent 原始 `target_order.locator_value`。但 New Booking 创建 / 确认的最终订单投影字段允许编辑:Group 显示并允许编辑 `group_block_name`,默认值来自 Agent `target_order.locator_value` 且 `locator_type=GROUP_CODE`;Fit 显示并允许编辑 `fit_name`,默认值来自 `guest_name ?? target_order.locator_value`。用户修改这些字段只影响本系统最终订单投影和确认快照,不回写 Agent 原始定位字段。普通业务卡还会移除邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段。
- Block ID 和 Confirmation Number 第一版只读;存在本地投影或未来 PMS 结果时展示,否则为空。Block ID 仅 Group 显示,Confirmation Number 仅 Fit 显示。
@@ -655,8 +658,9 @@ V4 任务详情页第一版字段白名单:
| `ROOM_INFORMATION` / `NEW_BOOKING` | `group_block_name` 或 `fit_name`、`arrival_date`、`departure_date`、`rate_code`、`room_items[].room_type_code`、`room_items[].room_count`、Group 的 `group_booking_status`、无法从 Fit Rate Code 派生时的 `breakfast_included` | `nights`、Group 固定 `breakfast_included=true`、Fit 可由 Rate Code 派生的 `breakfast_included`、Adult、Block ID、Confirmation Number、Agent 原始 `target_order` |
| `ROOM_INFORMATION` / `UPDATE_BOOKING` | 修改后的 `arrival_date`、`departure_date`、`room_items[].room_type_code`、`room_items[].room_count`、Group 的 `group_booking_status`、无法从 Fit Rate Code 派生时的 `breakfast_included` | 当前值、本次变化摘要、`nights` 差异、Rate Code、Adult、Block ID、Confirmation Number、Agent 原始 `target_order` |
| `ROOM_INFORMATION` / `CANCEL_BOOKING` | 无;用户只确认取消事项 | 本地订单投影当前值、`nights`、`breakfast_included`、Group Booking Status、Rate Code、房型房量、Block ID、Confirmation Number |
+| `ROOM_INFORMATION` / `TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT` | `names_text`、Group 的 `group_code` / `group_name`、`arrival_date`、`departure_date`、`room_items[].room_type_code`、`room_items[].room_count`、Group 的 `group_booking_status`、无法从 Fit Rate Code 派生时的 `breakfast_included` | 同订单 current-only 上下文、`proposed_values={}`、`change_summary=[]`、`nights`、Rate Code、Adult、Block ID、Confirmation Number、Agent 原始 `target_order`;Rate Code 沿用现有仅 New 可写策略 |
| `TRACE_RESERVATION_NOTES` | GENERAL:`trace_items[].text`、`trace_items[].department_code`;EXTRA_BED:`trace_items[].target_room_type_code`、`trace_items[].extra_bed_room_count`、`trace_items[].department_code` | `department_code` 第一版只允许 `FO`、`HSK`、`FO+HSK`,字段返回 `fixed_options[]` 三个固定选项,不允许自由文本;`target_room_type_code` 校验当前酒店 Room Type 目录;`extra_bed_room_count` 必须为正整数;不返回或确认 `content`、`target_order`、邮件正文、附件 URL、raw evidence 或 AI 原始 payload |
-| `ROOMING_LIST` | 无;用户只确认 Rooming List 事项 | 来源邮件正文和附件摘要通过底部 `SOURCE_MESSAGE_DISPLAY` 查看;确认后 Group 自动置为 `DEF` |
+| `ROOMING_LIST` | 无;用户只确认 Rooming List 事项 | 来源邮件正文和附件摘要通过底部 `SOURCE_MESSAGE_DISPLAY` 查看;确认不改变其他业务事实 |
| `PAYMENT` | 无;用户只确认附件关联事项 | `attachment_ids[]`、`payment_attachments[]` 安全摘要、图片缩略图、文件名、类型、大小、预览 / 下载可用性 |
| `SOURCE_MESSAGE_DISPLAY` | 无 | 当前触发该 V4 order task 的 SourceMessage 正文,只读且默认长度折叠,可展开全文 |
@@ -665,7 +669,7 @@ Rooming List 卡事项确认规则:
- Rooming List 卡第一版只做事项确认,不做名单解析、附件预览、Excel 生成或 PMS 导入。
- `ROOMING_LIST` event 不输出 `rows[]`、逐人名单、同住分组、18 列、Excel 或 PMS 导入参数,也不要求单独输出 `attachment_ids[]`。
- 页面应展示卡片标题、状态、目标订单信息和“确认卡片”按钮;如需查看来源内容,仍通过本订单任务底部的 `SOURCE_MESSAGE_DISPLAY` 查看当前触发 SourceMessage 正文和附件摘要。
-- 用户点击“确认卡片”表示已人工处理该 Rooming List 事项;该确认会更新 V4 卡片状态和订单任务派生状态;如果同订单为 Group 且存在可更新的已确认 Room Information 快照,后端同时覆盖该快照里的 `group_booking_status=DEF` 和 `group_booking_status_label=DEF-Definite`,不改变 Agent 原始 payload,并写入可通过 V4 订单任务审计接口查询的 `V4_ROOMING_LIST_AUTO_DEF` 摘要。没有可更新投影时确认仍成功,只记录安全审计提示,不临时创建不完整 Room Information。该动作不代表 M010 Rooming List Excel 已生成,也不代表 PMS / OPERA / OHIP 已执行。
+- 用户点击“确认卡片”表示已人工处理该 Rooming List 事项;该确认只更新当前 V4 卡片状态和订单任务派生状态,不改变 Agent 原始 payload、Room Information 确认快照、Group Booking Status 或其他订单事实。该动作不代表 M010 Rooming List Excel 已生成,也不代表 PMS / OPERA / OHIP 已执行。
- 独立 Rooming List Excel 生成能力仍属于 M010 `/reservation/rooming-lists/new` 工具页面,第一版不嵌入 V4 Rooming List 卡。
Payment 卡附件展示规则:
@@ -941,8 +945,9 @@ AI 原始 payload、邮件正文、附件 URL 和技术 trace 不应直接进入
| M002-V4-CP14.6 | Payment 附件预览 | 已完成前后端第一版:Payment 卡返回付款凭证附件安全摘要,不返回 URL;前端通过 SourceMessage 原文权限链路做图片缩略图 / 大图预览和非图片下载 |
| M002-V4-CP14.7 | Rooming List 事项确认卡 | 已完成前端轻量展示:Rooming List 卡第一版只展示事项和确认按钮,不解析名单、不预览附件、不生成 Excel、不导入 PMS |
| M002-V4-CP14.8 | Room Information 展示模型 | 已完成前后端第一版:后端返回 `display_payload.room_information` 稳定展示模型;前端按 New / Update / Cancel 业务表单展示最终值、差异、Nights、Breakfast、Group Booking Status 和本地订单投影;Adult 不显示 |
-| M002-V4-CP14.9 | Rooming List 确认自动 DEF | 已完成后端第一版:确认 `ROOMING_LIST` 卡时,Group 同订单存在可更新 Room Information 确认快照则自动置 `DEF` 并写审计;Fit 不变更;无投影不造脏数据 |
+| M002-V4-CP14.9 | Rooming List 确认无跨卡副作用 | 2026-08-08 已纠偏:确认 `ROOMING_LIST` 只更新本卡和订单任务派生状态;Group/Fit 均不改 Group Booking Status、Room Information 确认快照或其他业务卡。 |
| M002-V4-CP14.10 | 复核态卡片字段白名单和统一确认交互 | 已完成前端第一版:`REVIEW_REQUIRED` 保持原业务卡内编辑,问题字段红字提示,前端按钮显示“确认卡片”但调用 `review-resolution`;后端 `fields[]` 返回当前卡业务字段白名单,复核写入不再只限空值或目录错误字段 |
+| M002-V4-CP14.11 | BR00 辅助订单事件双区块 | 已完成前后端:独立 Trace / Rooming List / Payment 订单任务各补一张共享 companion Room;同组已有生命周期 Room 时去重;详情与确认使用 current-only 模型,原生辅助卡行为不变 |
| M002-V4-CP15 | V4 业务审计查询 | 已完成:`GET /api/reservation/order-tasks/{orderTaskId}/audits` 和 `GET /api/reservation/source-notifications/{notificationId}/audits` 返回卡片确认、复核解阻和来源通知 ack 的脱敏审计流水 |
| M002-V4-CP15.1 | 订单详情 V4 化后端补齐 | 已完成:`GET /api/reservation/orders/{orderId}` 返回 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`,支撑订单总览页 |
| M002-V4-CP16 | PMS / OPERA / OHIP 目录同步 | 同步 Adapter、同步 run、最后成功快照、失败重试和同步状态管理入口 |
diff --git a/docs/project/requirements/M002-v4-requirement-spec-template-alignment.md b/docs/project/requirements/M002-v4-requirement-spec-template-alignment.md
index d477383..8b09dc0 100644
--- a/docs/project/requirements/M002-v4-requirement-spec-template-alignment.md
+++ b/docs/project/requirements/M002-v4-requirement-spec-template-alignment.md
@@ -75,7 +75,9 @@ Reservation V4 还必须满足 `docs/project/ai-nses-project-overlay.md` 的项
### 7.1 Room Information
-- 只由 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING` 触发;`TRACE_RESERVATION_NOTES`、`ROOMING_LIST`、`PAYMENT` 不触发房型信息卡。
+- 2026-08-08 起以 BR00/M012 为准:New、Update、Cancel、Trace、Rooming List、Payment 六类订单事件都必须让所属 Order Task 具备 Room Information;此前“辅助三类不触发 Room”的口径已废止。
+- New、Update、Cancel 继续直接生成生命周期 Room;只有 Trace、Rooming List、Payment 的 Order Task 生成一张共享 companion Room。同组已有生命周期 Room 时不重复生成,多个辅助 event 也不重复生成。
+- companion Room 保留首个辅助 event 的类型和来源序号,但只显示订单 current/final 投影,`proposed_values={}`、`change_summary=[]`,不得把辅助业务字段解释为房间变更。
- 后端提供 `display_payload.room_information` 稳定展示模型,前端不从 Agent raw payload、`business_fields` 或 `target_order` 自行推导。
- `room_items[]` 支持多个房型行。每个 `room_items[].room_type_code` 必须是单个当前酒店 Room Type 目录 code;`RM2/RM3` 这类组合值必须拆成多行,不作为合法单 code。
- `NEW_BOOKING` 展示最终值;`UPDATE_BOOKING` 展示当前值、建议值、最终值和 `change_summary[]`;`CANCEL_BOOKING` 从本地订单投影只读展示。
@@ -101,9 +103,7 @@ Reservation V4 还必须满足 `docs/project/ai-nses-project-overlay.md` 的项
- Rooming List 卡第一版只做事项确认。
- 不做名单解析、附件预览、Excel 生成、PMS / OPERA / OHIP 导入。
- 用户点击“确认卡片”表示已人工处理该 Rooming List 事项。
-- 如果同订单为 Group 且存在可更新的已确认 Room Information 快照,确认 Rooming List 后后端自动把 Group Booking Status 置为 `DEF`,并写 `V4_ROOMING_LIST_AUTO_DEF` 审计。
-- 如果此前 Group Booking Status 是 `TEN` 或 `INQ`,确认 Rooming List 后也强制覆盖为 `DEF`。
-- Fit 不显示也不变更 Group Booking Status。
+- 确认 Rooming List 只确认该事项本身,不修改 Group 或 Fit 的 Group Booking Status、Room Information 确认快照或其他业务卡。
- 独立 Rooming List Excel 生成仍属于 M010 `/reservation/rooming-lists/new`,不嵌入 V4 Rooming List 卡。
### 7.4 Trace
@@ -166,10 +166,11 @@ Reservation V4 还必须满足 `docs/project/ai-nses-project-overlay.md` 的项
| --- | --- | --- | --- | --- | --- |
| Room Information 展示模型 | Done | Done | Passed | `M002-v4-order-task-card-domain-model-cp2.md`、前后端协作文档 | Implemented |
| Room Information 多 `room_items[]` 展示 | Done | Done | Passed | 本文、V4 领域模型、Lookup 文档 | Implemented |
+| Trace / Rooming List / Payment 的 Basic + companion Room | Done | Done | Passed | M012 4.5、本文 7.1、V4 领域模型 | Implemented |
| Nights 后端派生 | Done | Done | Passed | V4 领域模型、Lookup 文档 | Implemented |
| Breakfast 派生:Group 固定含早,Fit 按 RB / RO | Done | Done | Partially Covered | V4 领域模型、Lookup 文档 | Implemented |
| Adult 不展示 | Done | Done | Passed | V4 领域模型、前后端协作文档 | Implemented |
-| Group Booking Status:TEN / DEF / INQ 和 Rooming List 自动 DEF | Done | Done | Passed | V4 领域模型、安全边界、审计文档 | Implemented |
+| Group Booking Status:TEN / DEF / INQ 与 Rooming List 无跨卡副作用 | Done | Done | Passed | V4 领域模型、安全边界、审计文档 | Implemented |
| Payment 附件安全摘要和前端预览 | Done | Done | Passed | V4 领域模型、安全边界、前后端协作文档 | Implemented |
| Payment `attachment_ids[]` 只读、确认只提交 `version` | Done | Done | Passed | V4 领域模型、前后端协作文档 | Implemented |
| Rooming List 轻量事项确认卡 | Done | Done | Passed | V4 领域模型、Agent 字段契约、前后端协作文档 | Implemented |
@@ -205,13 +206,14 @@ Reservation V4 还必须满足 `docs/project/ai-nses-project-overlay.md` 的项
- Given Room Information 存在多个 `room_items[]`,When 打开 V4 任务详情,Then API 和页面都应展示多行房型,不把组合 code 当成单个合法房型。
- Given Payment 卡引用图片和非图片附件,When 打开 V4 任务详情,Then 任务详情 API 只返回附件安全摘要,前端通过 SourceMessage 原文权限链路展示图片预览和非图片下载。
- Given 用户触发 Payment 图片预览或非图片下载,When 浏览器渲染预览或下载入口,Then DOM `src/href` 可以临时使用 conversation 接口返回的受权限附件 URL,但页面可见文本、V4 task detail API、确认 payload、日志和本地存储仍不得暴露该 URL。
-- Given Rooming List 卡被确认且同订单 Group 有可更新 Room Information 快照,When 刷新详情,Then Group Booking Status 显示 `DEF-Definite` 并可查到自动 DEF 审计。
+- Given Rooming List 卡被确认,When 刷新详情,Then 仅该卡变为确认态;同订单 Group Booking Status、Room Information 快照和其他业务卡保持原值。
- Given Trace 卡确认或复核成功,When 刷新详情,Then `fields[].value` 显示已确认值,旧 validation errors 清空。
+- Given 一个 Order Task 只包含 Trace、Rooming List 或 Payment,When V4 intake 完成并查询详情,Then 返回一张 Basic、一张 current-only companion Room 和对应专属卡;同组已有 New/Update/Cancel 时 Room 不重复。
## 11. 测试范围
-- 单元测试:本文不新增代码测试;后续代码变更仍按对应前后端模块测试要求执行。
-- 集成测试:本文不改变接口;已有 smoke 已覆盖主要 V4 卡片链路。
+- 单元测试:Room current-only 分类、辅助事件字段白名单与 lifecycle Room 去重按对应服务测试覆盖。
+- 集成测试:新增独立 Trace、Rooming List、Payment 的建卡数量、卡片顺序、Room 展示模型和敏感字段回归。
- 手工验证:本次文档 checkpoint 使用 `git diff --check` 验证 Markdown 格式。
- 后续 smoke:单卡可操作态数据集已回填;后续如需要可继续用 fresh runId 造演示数据,避免复用旧 SourceMessage 时间线造成阻塞误判。
diff --git a/docs/project/requirements/M012-booking-email-confirmation-e2e-v01.md b/docs/project/requirements/M012-booking-email-confirmation-e2e-v01.md
new file mode 100644
index 0000000..5be3299
--- /dev/null
+++ b/docs/project/requirements/M012-booking-email-confirmation-e2e-v01.md
@@ -0,0 +1,384 @@
+# M012 酒店预订邮件识别到人工确认端到端 V0.1
+
+| 项目 | 内容 |
+| --- | --- |
+| 文档状态 | V0.1 已实现并验收;2026-08-08 补充 BR00 订单卡双区块后端收口 |
+| 业务基线 | `BR00-BASELINE-1`,高于本仓库旧样例、旧 prompt 和旧字段语义 |
+| 适用范围 | 员工上传真实 `.eml` 或 AgentBus 投递邮件后,到任务卡关键参数被用户确认为止 |
+| 非适用范围 | 确认后的 Opera / PMS API 调用、客户回复、Invoice、独立 Manual RateCode、Rooming List 名单解析 |
+| Catalog 版本 | `booking-catalog-v20260808` |
+| Parser 版本 | `booking-email-parser-v0.1` |
+
+本次“已实现并验收”指三封指定真实邮件覆盖的固定渠道纵向切片:普通员工手工导入、LIANTAI/QBD workbook 确定性解析、V4 建卡、字段复核与人工确认。第 4 节仍完整保存 BR00 业务语义,但未被这三封样本覆盖的 Allotment、独立 Rooming List、独立 Payment、图片语义和统一 Booking Agent fallback,不因本次切片通过而宣称已完成;其现状和边界见第 15 节。
+
+## 1. 背景与架构位置
+
+AgentBus 同时承担邮件消息入口和 Agent 平台。本项目不能把“附件解析”孤立成一次文件读取:邮件正文、当前回复、历史往来、附件版本和本次附件中的更新行共同决定业务动作。
+
+本期沿用七层架构,并把邮件入口耦合进去:
+
+1. **信息系统接入与编排**:接收 AgentBus 邮件或员工上传的 `.eml`,建立 SourceMessage、附件摘要、幂等键和处理批次。
+2. **材料预处理**:拆分 current 与 quoted history,识别附件版本,对固定渠道 Excel 只选择本次候选业务行,并保留 sheet/行号/底色等证据。
+3. **确定性 Parser + Catalog**:用版本化业务目录把渠道字段、动作别名、房型写法和数值编码归一为标准事实;不能可靠解析的字段保持未解决,不猜测。
+4. **当前态与邮件上下文**:按会话读取历史 SourceMessage 和既有业务任务,向业务识别提供“当前邮件事实 + 必要历史事实”,历史内容不重复触发动作。
+5. **业务识别**:程序先处理固定渠道可确定部分;失败或存在语义歧义时,把隐私最小化的材料交给 Booking Agent。输出业务事件、关联事件和通知,不直接调用 PMS。
+6. **确定性校验与人工确认**:同一 Catalog 校验程序或 Agent 输出,生成可编辑任务卡、缺失字段、告警和证据;即使全部字段符合目录,也必须由用户确认。
+7. **执行适配器**:确认后调用 PMS/Opera,明确不在 M012 V0.1 范围。
+
+Catalog 不单独构成业务层。它是第 3 层的版本化确定性知识,同时被 Parser、第 5 层 Agent reference 和第 6 层校验器消费。三者必须记录同一个 Catalog 版本,避免同一来源词在不同环节被翻译成不同业务代码。
+
+## 2. 目标
+
+- 提供普通员工可用、受现有权限保护的 `.eml` 导入 API 和可点击页面,不依赖 Debug Key。
+- 保存一封实际邮件一次;相同实际邮件重复投递命中幂等,分别发送的邮件分别处理。
+- 对固定渠道 Excel 按“业务范围内整行均有非白色底色”选择候选行,而不是任一单元格有颜色。
+- 将候选行确定性解析成统一事实,生成 New / Update / Cancel 及 Trace、Rooming List、Payment 或 General/Risk 通知。
+- 复用 SourceMessage 与 Reservation V4 任务卡、目录 lookup、人工复核、确认和审计主线。
+- 在 UI 展示邮件证据、解析证据、告警、关键字段和确认阻断;用户可修正允许字段并逐卡确认。
+- 用三封 2026-08-08 指定真实邮件做只读验收输入,并用去隐私合成 fixture 建立自动化回归。
+- 提供 PostgreSQL 项目专属 Schema `th_hotel_booking` 的版本化迁移与验证脚本;远程执行前必须只读确认无同名冲突及权限边界。
+
+## 3. 非目标
+
+- 不在本期执行 PMS/Opera 操作,也不伪造执行成功。
+- 不自动回复邮件、发送通知或修改 AgentBus 外部状态。
+- 不以 Tour Code 代替 Group Code,也不根据样例猜 Account / Market / Source / Rate Code 映射。
+- 不把价格作为前端结构化确认字段;价格只参加未来 Rate Code 映射并留在来源证据中。
+- 不解析 Rooming List 旅客名单、不导入旅客信息、不做名单差异比对。
+- 不将真实邮件、真实附件、旅客信息、邮箱地址或数据库密码提交到 Git。
+
+## 4. 统一业务语义
+
+### 4.1 生命周期与任务
+
+订单生命周期事件:
+
+- `NEW_BOOKING`
+- `UPDATE_BOOKING`
+- `CANCEL_BOOKING`
+
+同订单可附带的独立任务卡:
+
+- `TRACE_RESERVATION_NOTES`
+- `ROOMING_LIST`
+- `PAYMENT`
+
+来源邮件级通知:
+
+- `S10` General:整封当前邮件没有任何支持的业务任务时最多一条。
+- `S99` Risk:当前邮件存在无法分类或高风险歧义时最多一条;已识别的清晰任务仍继续创建。
+
+已知类型但缺字段时保留原类型卡并进入 `REVIEW_REQUIRED`,不得降格成 Risk。
+
+### 4.2 名称和订单字段
+
+- `Tour Code` 是 `Name` 的一个可能来源值。
+- `Name of Group`、`Group Name` 在本期统一为 `Group Name = Block Name`。
+- `Group Code` 是独立字段,不能由 Tour Code 推导。
+- 一个清晰预订分组生成一个订单任务;多个 Name、多个房型、多个日期段可以属于同一订单,不按 Name 拆卡。
+- New Booking 的实际总房数 `< 5` 为 `FIT`,`>= 5` 为 `GROUP`。
+- 所有订单相关任务均有 Basic Information 与 Room Information。
+- Basic Information 只允许由已确认发件人映射生成;映射未配置时 Account/Market/Source 留空并阻断确认。
+
+### 4.3 New Booking 确认条件
+
+通用必填:
+
+- Booking Type
+- 一个或多个 Name
+- Arrival Date、Departure Date;Nights 由日期派生
+- 至少一个 Room Type + Quantity
+- 一个订单级 Rate Code
+
+Group 额外必填:
+
+- Group Code
+- Group Name / Block Name
+
+Rate Code 根据公司、房型、价格、早餐信息映射,但当前映射仍待配置。零候选或多候选都保留 New Booking,让用户选择并阻断确认;系统不得自动选择。
+
+### 4.4 Update、Cancel 与关联任务
+
+- `AMEND`、`AMD`、`AMEND TO` 等归一为 Update;旧 Group Code → 新 Group Code 仍视为同一订单链。
+- 每封实际 Update 邮件、每个订单生成一张新的 Update 卡,不按历史内容做业务去重。
+- Cancel 只在未来 PMS 执行成功后结束订单;本期只确认 Cancel 动作参数。
+- Allotment:N 个实际团生成 N 张 New 卡,并生成一个共享来源扣减/取消动作;来源动作失败不阻断实际团。
+- Extra Bed 是 Trace,不是 Room Type;同一 Group Code、同一当前邮件的多个 Trace item 合并一张卡。
+- Trace 的 Department 必须由用户选择 FO、HSK 或 FO+HSK 才能确认。
+- Rooming List 与 Payment 的原生事项卡仍是通知型卡,不解析名单、不核验付款;其 companion Room 只补订单上下文,不改变该业务边界。
+
+### 4.5 订单任务双区块与 companion Room
+
+- 每个可定位订单的 Order Task 固定包含一张 `BASIC_INFORMATION`;New、Update、Cancel、Trace、Rooming List、Payment 都必须同时具备 `ROOM_INFORMATION`,General/Risk 来源通知不适用。
+- New、Update、Cancel 继续由生命周期 event 直接生成 Room Information。若同一 `source_message + order_ref` 已有任一生命周期 event,不再为同组 Trace、Rooming List、Payment 重复生成 Room。
+- 若一个 Order Task 只有 Trace、Rooming List、Payment,则以后端接收顺序中的首个关联 event 生成一张共享 companion Room,并保留该 event 的类型、transition 和 `source_event_index` 以便追溯;不能伪装成新的 `UPDATE_BOOKING` event。
+- companion Room 只展示同订单已确认完成态或前置 New 计划完成态:`current_values` 与 `final_values` 使用该投影,`proposed_values` 为空,`change_summary` 为空;辅助 event 自身字段不得被当成房间变更。
+- 同一 Order Task 的多张业务卡仍分别确认;Basic Information 保持前置门禁。Trace、Rooming List、Payment 专属卡、附件安全边界和确认副作用不因 companion Room 改变。
+- 本增量只改变新建订单任务的 intake 行为;已持久化且已有卡片的历史 V4 Order Task 受现有幂等门禁保护,不在重放时隐式补卡。若存量也要补齐,必须另做可审计、可回滚的受控回填。
+
+## 5. 邮件与上下文边界
+
+### 5.1 current/history
+
+- 当前邮件的正文、当前附件和明确的当前指令可以触发业务。
+- quoted history 只用于解释当前语义、继承订单身份和检测重复,不可再次触发旧动作。
+- 没有附件时不经过 Excel 预处理,直接进入上下文组装和业务识别;并非跳过 SourceMessage 接入。
+- 当前正文仅有标题/签名、但附有明确更新附件时,附件是本次动作核心证据。
+
+### 5.2 附件版本
+
+- 当前邮件明确写 `REV.n`、`use this file` 等版本指令时,优先当前附件。
+- 当前版本没有严格候选行或只有部分底色时,可以和同会话前版本做差异,差异只作为复核证据,不直接成为自动执行事实。
+- 同一事实再次出现时仍展示重复/陈旧告警,由用户判断,不静默吞掉新邮件。
+
+## 6. 固定渠道预处理
+
+### 6.1 共同底色口径
+
+- 只认单元格底色,不认字体颜色、批注、筛选或条件格式推测。
+- 白色、默认色、无填充不算。
+- 只检查渠道 Catalog 定义的业务列范围;该范围内每个业务列都必须有非白色底色,才是 `STRICT_CURRENT_CANDIDATE`。
+- 一条渠道业务记录可以由 anchor row 与后续 continuation rows 组成;Tour Code/酒店明细在 anchor,当前动作在后续行时,以该 logical record block 的业务列底色并集判断完整性,并同时记录 anchor/action row。不得要求所有字段出现在同一物理行。
+- 只有部分业务列有底色时记为 `PARTIAL_FILL_REVIEW_EVIDENCE`,不自动生成事实。
+- 表头颜色不触发业务行。
+
+### 6.2 渠道 Profile
+
+| Profile | 表头 | 业务列 | 动作列 | 主要来源列 |
+| --- | ---: | --- | --- | --- |
+| `LIANTAI_FIT` | 3 | A:F | F | Tour Code=B;酒店明细=D/E |
+| `QBD_MONTHLY` | 3 | B:H | H | Tour Code=C;酒店明细=E |
+| `LIANTAI_UPDATE` | 2 | A:I | G | Tour Code=A;酒店明细=D;状态=H;备注=I |
+
+动作别名必须由 Catalog 版本管理,至少包含:
+
+- New:`NEW BOOKING`
+- Update:`UPDATE`、`AMEND`、`AMD BOOKING`、`AMEND TO`
+- Cancel:`CANCEL`、`CXL`、`ยกเลิก`
+
+不得为了“程序识别”无限穷举整句。Parser 先分离动作核心 token、动作日期和自由文本,再用有限别名字典归一;不能可靠归一时降级 Agent 或人工复核。
+
+### 6.3 当前日期与异常年份
+
+- 候选行的动作 marker 优先按日/月与邮件接收或发送日期匹配。
+- FIT 文件若因 Excel 自动填充出现 2026、2027……2041 的异常连续年份,日/月匹配的行仍保留为候选,并加 `ACTION_DATE_YEAR_ANOMALY`。
+- 异常年份不能作为真实业务年;入住/离店年份以邮件业务日期和 sheet 月份解析,并在不唯一时留待复核。
+- QBD 中严格底色但动作日期早于当前邮件日期的行保留证据并加 `STALE_STRICT_CANDIDATE`,不得静默当成本次唯一事实。
+
+## 7. Parser 与 Agent 分工
+
+### 7.1 确定性 Parser 输出
+
+Parser 输出事实,不输出 PMS 命令:
+
+```json
+{
+ "catalog_version": "booking-catalog-v20260808",
+ "parser_version": "booking-email-parser-v0.1",
+ "source": {
+ "attachment_name": "safe-name.xlsx",
+ "profile": "LIANTAI_UPDATE",
+ "sheet": "safe-sheet",
+ "row_number": 57,
+ "selection_status": "STRICT_CURRENT_CANDIDATE"
+ },
+ "action": "NEW_BOOKING",
+ "names": ["TOUR-CODE-VALUE"],
+ "group_code": null,
+ "group_name": null,
+ "arrival_date": "2026-08-12",
+ "departure_date": "2026-08-15",
+ "room_items": [
+ {
+ "source_label": "U-TWN8.5",
+ "room_type_code": "RM3",
+ "room_count": 12
+ }
+ ],
+ "rate_code": null,
+ "warnings": ["RATE_CODE_UNRESOLVED"]
+}
+```
+
+固定渠道优先走程序:
+
+1. Profile、严格候选行、动作和字段均可确定时,由 Parser 生成标准事实。
+2. 某个字段失败时保留已确定字段,标注具体 warning;不丢弃整行。
+3. 需要自然语言语义、图片、复杂历史关系或未知格式时,将最小必要材料交给 Booking Agent。
+4. Agent 输出仍必须经过相同 Catalog 版本的确定性校验和人工确认。
+
+### 7.2 初始房型映射
+
+当前可确定映射:
+
+- `U-DBL`、`Sup DBL` → `RM2`
+- `U-TWN`、`Sup TWN` → `RM3`
+- `U-TRP`、`Sup TRP`、`TRP` → `RM2`
+- `DBL SUITE` → `SU1`
+- 明确 `TWN SUITE` → `SU2`
+- `FAM 6+4` → `RM4`
+- Family 3/4 → `SU3`
+
+`ST:1卧双标 TWN` 等未被当前目录唯一覆盖的变体必须留空并复核。即使不同原始房型映射到同一 PMS code,也必须分别保留 raw label 与数量,不能按 PMS code 合并。
+
+## 8. 后端接口
+
+### 8.1 普通员工 EML 导入
+
+`POST /api/reservation/booking-email-intakes`
+
+- `multipart/form-data`
+- `file`:必填,只接受 `.eml`
+- `hotel_id`:可选,仍以当前登录用户酒店上下文校验
+- 权限:`RESERVATION_TASK_EDIT`
+- 文件大小遵循 Spring multipart 和本模块安全上限
+- 不接收 Debug Key,不直接接受外部附件 URL
+
+成功返回 HTTP 201:
+
+```json
+{
+ "source_message_id": "123",
+ "duplicate": false,
+ "status": "TASKS_CREATED",
+ "catalog_version": "booking-catalog-v20260808",
+ "parser_version": "booking-email-parser-v0.1",
+ "order_task_ids": ["501"],
+ "source_notification_ids": [],
+ "warnings": [
+ {
+ "code": "RATE_CODE_UNRESOLVED",
+ "message": "Rate Code 需要人工选择"
+ }
+ ]
+}
+```
+
+重复投递返回同一 SourceMessage 和已有任务/通知,不重复创建。
+
+### 8.2 内部 V4 契约增量
+
+- `names[]` 是 Agent/Parser 的标准数组;V0.1 前端投影为换行分隔 `names_text` 便于编辑。
+- `group_code` 与 `group_name` 分别保存;兼容读取旧 `group_block_name` / `fit_name`,新入口不再错误默认。
+- `booking_type`、`group_code`、`group_name`、`names_text`、日期、房型数量、Rate Code 进入 Room Information 的最终值。
+- `nights` 为派生只读字段。
+- `block_id` 不展示。
+- Confirmation Number 仅当当前邮件明确提及时展示。
+- `recognition` 安全块保留 profile、sheet、anchor row、action row、版本、选择状态和 warning code,不包含完整原始行、附件 URL 或旅客隐私。
+- 新订单在确认前允许 `order_id` / target binding 未解决;人工复核不得强制用户输入已存在本地订单 ID。Update/Cancel 等既有订单动作仍要求可靠归属。
+- V4 intake 必须按 4.5 的规则保证每个订单任务具有 Room Information;这是同一 Order Task 的展示/确认上下文补齐,不要求 Parser 或 Agent 在 Trace、Rooming List、Payment event 中复制完整房间字段。
+- `GET /api/reservation/order-tasks/{orderTaskId}` 对 companion Room 返回与生命周期 Room 相同的安全 `display_payload.room_information` 和 `fields[]` 结构;辅助 event 采用 current-only 模型,不返回 Agent `target_order`、邮件正文、附件 URL 或原始 evidence。
+
+## 9. 前端验收
+
+新增路由:`/reservation/email-intake`,权限 `RESERVATION_TASK_EDIT`。
+
+页面必须是真实可操作页面并连接后端:
+
+- 点击或拖放选择一封 `.eml`;显示文件名、大小和移除/重新选择。
+- 导入按钮有 idle、uploading、success、partial warning、duplicate 和 error 状态。
+- 成功后展示 SourceMessage ID、解析版本、任务数、通知数和 warning。
+- 每个创建结果可点击进入现有订单任务详情或来源通知详情;可返回任务队列。
+- 任务列表提供“导入邮件”入口。
+- 任务详情展示识别证据和 warning;Basic Information 必须先确认,随后业务卡可编辑/确认。
+- New Booking 未绑定既有订单时隐藏或明确标注“无需现有订单 ID”,不得用不可理解的强制输入阻塞用户。
+- 具备键盘焦点、可见 label、错误关联、响应式布局及 loading/empty/error 状态。
+
+## 10. 安全、隐私与审计
+
+- 原始 EML 与附件内容仅在受控 SourceMessage 原文能力或临时解析内使用;普通任务 API 只返回安全摘要。
+- 日志、错误、前端结果和 migration 不输出原始正文、真实邮箱、完整原始行、OSS 签名 URL、数据库密码或其他 Secret。
+- 真实样本不得复制到 `server/src/test/resources`;测试 fixture 必须合成并去隐私。
+- 用户对 Basic、Room、Trace 等卡的复核/确认继续写现有审计日志。
+- 业务操作记录保留要求为 3 个月;V0.1 先保存 `retention_until`/可清理时间语义,清理调度不在本期强制范围。
+
+## 11. PostgreSQL 项目专属 Schema
+
+- Schema 固定为 `th_hotel_booking`。
+- migration 必须显式 `CREATE SCHEMA IF NOT EXISTS th_hotel_booking` 并限定 `search_path` 或使用全限定名。
+- 不修改 `public`,不复用旧脚本中的 `booking` schema,不删除或改名其他项目对象。
+- 远程执行顺序:只读读取 `current_database/current_user` → 列出同名 schema/对象 → 检查 CREATE/USAGE 权限 → 创建本 schema → 只在本 schema 运行 migration → 对比其他 schema 对象计数/更新时间。
+- 密码只通过进程环境或交互输入,绝不写入仓库、planning 文件、命令回显或交付文本。
+- 当前 Spring 应用仍以 MySQL/H2 为运行基线;PostgreSQL migration 是项目专属目标模型,数据库方言切换不在本期暗中完成。
+
+## 12. 测试与完成定义
+
+### 12.1 自动化
+
+- EML:Message-ID、Conversation-ID、current/history、无附件和多个附件。
+- Excel:三种 Profile、整行业务列有非白底色、部分底色、白色、表头颜色、异常年份、陈旧严格行、多个日期段。
+- Parser:动作别名、房型/价格/数量、New 房量阈值、Update、Cancel、Extra Bed Trace、未解决字段。
+- 业务:一单多 Name/房型/日期段不误拆;三封样本中的生命周期/linked Trace;无确定事实时单封通知;重复投递幂等。Allotment、独立 Rooming List/Payment 与图片语义不属于本次固定渠道切片的完成证据。
+- API:权限、文件类型/大小、201、重复结果、错误安全、任务创建。
+- V4:新订单不要求既有 order ID;Basic 先确认;关键字段阻断;确认审计。
+- V4 双区块:独立 Trace、Rooming List、Payment 各自创建 Basic + companion Room + 专属卡;同组已有 New/Update/Cancel 时不重复 Room;详情返回 current-only Room 模型。
+- 前端:上传成功/失败/重复、结果链接、字段渲染、warning、复核与确认请求。
+
+### 12.2 三封真实邮件验收
+
+真实样本只从用户指定绝对路径读取:
+
+1. FIT LIANTAI:动作日/月候选可被选中;异常连续年份产生 warning,不被当成 2041 年业务日期。
+2. QBD REV.2:当前严格行与陈旧严格行被区分;同一 Tour Code 的多个日期段和房型保持一个订单任务。
+3. Update Booking ครั้งที่2:严格底色行生成 9 New/1 Update;Honeymoon 和 Extra Bed 分别生成同订单 Trace;未确认映射保持待复核。
+
+### 12.3 完成定义
+
+只有以下条件同时满足才可宣布 V0.1 完成:
+
+- 普通员工可在可点击页面上传三封真实邮件。
+- 后端真实创建 SourceMessage、订单任务/通知和任务卡,重复上传不重复建任务。
+- 页面可查看证据、warning、关键参数,并能完成允许范围内的修正与确认。
+- 没有执行 PMS/Opera。
+- 后端和前端自动化通过;本地全栈 smoke 通过。
+- PostgreSQL migration 通过本地语法/作用域验证;若远程测试库凭据可安全注入且只读检查无冲突,则项目 schema 已创建并证明未触碰其他 schema。
+- 自主推断规则、应用代码位置、未解决业务映射和测试证据已记录。
+
+## 13. 与 M011 的关系
+
+M011 仍描述“给 SuperAgent 的通用 Excel 高亮证据增强”,其 V1 规则是任一业务单元格有底色即可抽取。M012 在固定渠道、本期业务入口中采用更严格且已获业务确认的“业务范围整行均有非白底色”规则,并直接形成标准事实候选。
+
+两者不应静默混用:
+
+- M011 通用 extraction 继续供 Debug/AgentBus 证据预览。
+- M012 deterministic parser 使用独立的版本化 Channel Profile 和严格行选择器。
+- 后续若把 M012 规则推广到 M011,需单独 Change Request 和回归验证。
+
+## 14. 待配置而非待猜测
+
+- 发件人/渠道 → Account Code / Account Name / Market Code / Source Code 映射。
+- 公司 + 房型 + 价格 + 早餐 → 唯一 Rate Code 映射。
+- `TWN SUITE` 的正式房型代码。
+- Allotment 来源扣减动作在未来 PMS 适配器中的命令结构。
+- AgentBus 生产投递的认证/重试配置和 Booking Agent profile 版本。
+
+这些项不阻止识别和建卡,但相关卡必须保持 `REVIEW_REQUIRED` 或明确 warning,不能用样本值代填。
+
+## 15. 实施与验收结果
+
+2026-08-08 已完成以下纵向闭环:
+
+- 普通员工页面 `/reservation/email-intake` 可选择或拖放 `.eml`,调用真实 `POST /api/reservation/booking-email-intakes`,显示解析结果、warning,并跳转到真实 V4 订单任务或来源通知。
+- 三封外部真实 EML 的 API 验收结果依次为 20、1、10 张订单任务;业务构成为 19 Update + 1 Cancel、1 Update、9 New + 1 Update,并在第三封中生成 3 张 linked Trace;重复导入不重复建任务。
+- 浏览器全栈 smoke 已从任务队列进入导入页,上传第三封真实邮件,打开首张任务,选择 Account 并成功确认 Basic Information;桌面和 390px 宽度无横向溢出,fresh session 无 console warning/error。
+- 后端全量测试 441 项通过,0 failure、0 error、1 skipped;真实样本 opt-in 验收 1 项通过。
+- 前端 29 个测试文件共 255 项通过,TypeScript typecheck 和生产 build 通过;lint 0 error,本轮文件无新增 warning。
+- 测试库已仅创建 `th_hotel_booking`:9 张表、8 个 identity sequence、30 个索引/约束索引;迁移前后其他项目 schema 对象计数不变,无跨 schema 外键;回滚型 smoke 后无残留测试行。
+- 本期没有调用 PMS/Opera、发送外部邮件或修改 AgentBus 外部状态。
+
+V0.1 的已知运行边界:
+
+- 普通员工手工导入路径在请求内解析附件,SourceMessage 和任务保存附件元数据与安全识别证据,但不长期保存可下载的原始附件字节;如需原件归档/下载,应接入既有 AgentBus/OSS 原文链路后另做 checkpoint。
+- 固定渠道确定性 Parser 无法形成事实时,当前手工导入路径生成 S10/S99 来源通知供人工处理;不会在该请求内自动调用外部 Booking Agent。既有 AgentBus → SuperAgent 路径继续独立存在,生产启用和统一 fallback 编排需后续配置与联调。
+- Account/Market/Source、Rate Code 和未唯一覆盖的房型映射仍按本章第 14 节由用户确认,不以真实样本值自动猜测。
+- 确认后的 PMS/Opera 执行仍明确不在 V0.1。
+
+## 16. 2026-08-08 BR00 双区块增量追踪
+
+| 需求项 | 后端状态 | 前端状态 | 测试状态 | 文档位置 | 当前状态 |
+| --- | --- | --- | --- | --- | --- |
+| 独立 Trace 补 Basic + companion Room | Done | Done | Passed | 本文 4.5、8.2、12.1 | Implemented |
+| 独立 Rooming List 补 Basic + companion Room | Done | Done | Passed | 本文 4.5、8.2、12.1 | Implemented |
+| 独立 Payment 补 Basic + companion Room | Done | Done | Passed | 本文 4.5、8.2、12.1 | Implemented |
+| 同组生命周期 Room 去重 | Done | N/A | Passed | 本文 4.5 | Implemented |
+| General/Risk、权限、酒店隔离与敏感数据边界不变 | N/A | N/A | Passed(既有回归) | 本文 4.5、10 | Verified |
diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md
index 4f35cf2..08743cd 100644
--- a/docs/project/security-access-control-boundary.md
+++ b/docs/project/security-access-control-boundary.md
@@ -43,6 +43,7 @@
| 接口 / 能力 | 分类 | 当前管控 | 目标管控 | 审计要求 |
| --- | --- | --- | --- | --- |
+| `POST /api/reservation/booking-email-intakes` | `FRONTEND_USER` | M012 V0.1 已实现;强制 Bearer 登录 + `RESERVATION_TASK_EDIT`,`hotel_id` 由当前用户酒店上下文解析并校验;只接受 `.eml`,默认上限 25 MiB;返回 SourceMessage、任务/通知 ID 和安全 warning | 保持登录、权限和酒店隔离;不得把上传原文、附件字节、完整行、真实邮箱、外链或 AI 原始 payload放入响应/普通日志;相同实际邮件幂等返回已有任务 | SourceMessage 捕获、V4 建卡和后续卡片确认沿用现有审计;导入响应本身不返回原文,上传失败只返回受控错误摘要 |
| `GET /api/reservation/tasks` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_TASK_READ`;支持可选 `hotel_id` 并校验酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 只读查询默认不写业务审计 |
| `GET /api/reservation/orders` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;支持可选 `hotel_id` 并校验酒店访问权;已返回 V4 继续处理入口和统一 open count 安全字段 | 保持登录 + `RESERVATION_ORDER_READ` + 酒店访问权;V4 入口字段只返回 order task / card ID、动作类型、状态和数量摘要;`open_work_item_count` 仅返回当前订单待处理数量摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload |
| `GET /api/reservation/orders/{orderId}` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_ORDER_READ`;按订单实际所属酒店校验访问权;已返回旧 `tasks[]`、V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[]` 安全摘要时间线 | 保持登录 + `RESERVATION_ORDER_READ` + 订单所属酒店访问权;V4 时间线读取按订单酒店过滤;`order_overview` 只能从已确认 V4 卡片派生,`next_v4_action` 只返回下一步处理 ID / 动作 / 状态 / 数量摘要,`related_source_messages[]` 只返回来源邮件安全摘要 | 只读查询默认不写业务审计;不得在 `order_overview` 或 `v4_order_tasks[].cards[]` 返回未确认 AI 建议、邮件正文、附件 URL、AI 原始 payload、display payload、confirmed payload 或来源通知原始 payload |
@@ -50,7 +51,7 @@
| `GET /api/reservation/workbench-items` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;统一返回 V4 业务订单任务和 S10/S99 来源通知摘要 | 只读查询默认不写业务审计;不得返回邮件正文、附件 URL、AI 原始 payload 或来源通知原始 payload;同来源时间下使用 `updated_at` / `created_at` / 数字 ID 稳定排序 |
| `GET /api/reservation/order-tasks` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 V4 业务订单任务,不返回 S10/S99 来源通知 | 只读查询默认不写业务审计;不得返回 AI 原始 payload;`card_status` 只匹配业务 / 可处理卡,固定来源邮件展示卡不参与筛选 |
| `GET /api/reservation/order-tasks/{orderTaskId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 订单任务所属酒店访问权;V4 任务详情页展示顺序为 Basic Information、业务卡、SourceMessage Display;Basic Information 不返回 Agent `target_order`;Room Information 展示模型只返回当前酒店本地订单投影、Agent 白名单字段和系统派生值;Trace 卡只返回 `trace_items[].text`、`department_code`、`target_room_type_code`、`extra_bed_room_count` 等白名单字段,不返回 `content`;普通业务卡 `display_payload` / `confirmed_payload` 会移除 Agent `target_order`、邮件 HTML、raw evidence、附件原始 URL 和 PMS 原始响应等敏感字段;`fields[].write_target` 只返回前端安全语义,不暴露内部列名;Payment 卡可返回付款凭证附件安全摘要;Payment 第一版 `attachment_ids[]` 只读展示,不支持前端增删或替换附件集合 | 只读查询默认不写业务审计;本接口不得直接返回邮件正文、HTML 或附件 URL,来源邮件卡正文和 Payment 图片预览 / 非图片下载必须通过 `GET /api/source-messages/{id}/conversation` 的 SourceMessage 原文权限链路读取;Basic Information、Room Information 展示模型、Trace 卡和普通业务卡不得返回 PMS 原始响应、价格明细、AI 原始 payload、Agent 原始 `target_order`、`content`、raw evidence 或跨酒店订单值;Payment 安全摘要只能包含 `attachment_id`、`file_name`、`content_type`、`size_bytes`、`is_image`、`preview_available`、`download_available`、可选 `external_media_id` / `unavailable_reason_code`;`external_media_id` 只用于前端匹配会话媒体对象,不是 URL;不得返回 `ai_payload_json`;同批次 `adapter_contract_errors[]` 只返回白名单诊断字段;前端普通业务卡如遇 URL-like 附件字符串必须二次脱敏 |
-| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;返回卡片确认、复核解阻和 `V4_ROOMING_LIST_AUTO_DEF` 自动 DEF 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
+| `GET /api/reservation/order-tasks/{orderTaskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + V4 订单任务所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 订单任务所属酒店访问权;返回卡片确认、复核解阻和订单归属确认的脱敏审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
| `GET /api/reservation/source-notifications/{notificationId}` | `FRONTEND_USER` | 已实现 M002 V4 CP5;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 来源通知所属酒店访问权 | 只读查询默认不写业务审计;邮件正文和附件读取仍走 SourceMessage 原文权限;不得返回来源通知原始 payload 或附件 URL;前端普通通知卡如遇 URL-like 附件字符串必须二次脱敏 |
| `GET /api/reservation/source-notifications/{notificationId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 来源通知所属酒店访问权;仅返回 S10/S99 来源通知 ack 审计摘要 | 查询审计不再写审计;返回快照必须脱敏,不返回原始邮件正文、HTML、附件 URL、AI 原始 payload、token 或 secret |
| `GET /api/reservation/lookups/accounts` | `FRONTEND_USER` | 已实现 M002 V4 CP11;强制 Bearer 登录 + `RESERVATION_TASK_READ` + 酒店访问权 | 保持登录 + `RESERVATION_TASK_READ` + 酒店访问权;只返回 Account code、显示名、派生 Market / Source 和目录安全元数据 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、Secret 或外部同步错误详情 |
@@ -58,7 +59,7 @@
| `GET /api/reservation/lookups/rate-codes` | `FRONTEND_USER` | 已实现 M002 V4 CP11;M002-V4-owner-rate-catalog-data-alignment 后固定初始化目录收敛为 OWNER RATE 40 个规范化 Rate Code;当前实现仍是酒店级目录 | 2026-07-21 结论是第一阶段继续保持登录 + `RESERVATION_TASK_READ` + 酒店访问权,并按当前酒店 `ACTIVE` Rate Code 目录返回;暂不强制 `account_code` + `booking_type=GROUP/FIT` 过滤,不做真实价格计算 | 只读查询默认不写业务审计;不得返回 PMS 原始响应、价格明细、Secret、跨酒店 Rate Plan;未来如新增 Account 适用关系,也不得泄露其他 Account 的 Rate Code 适用关系 |
| `PUT /api/reservation/tasks/{taskId}/draft` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_EDIT` + 酒店访问权 | 写草稿审计可按业务需要记录 |
| `POST /api/reservation/tasks/{taskId}/confirm` | `FRONTEND_USER` | 第一版未全量强制登录;actor 仍待迁移 | 登录 + `RESERVATION_TASK_CONFIRM` + 酒店访问权 | 必须写业务审计 |
-| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6/CP8/CP11;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 + 当前酒店数据库目录校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload;Rooming List 卡确认只表示事项已人工处理,不新增名单解析、附件预览、Excel 生成或 PMS 导入权限;Payment 第一版确认只提交 `version` 和必要审计说明,不提交 `attachment_ids[]`、附件 URL 或完整附件对象;同订单为 Group 时确认 Rooming List 已自动把可更新 Room Information 确认快照中的 Group Booking Status 置为 `DEF`,没有可更新投影时不创建不完整事实;Basic Account、Room Type、Rate Code 目录错误返回 `V4_FIELD_VALIDATION_FAILED`;Rate Code 第一阶段只校验当前酒店 `ACTIVE` 目录存在,暂不校验 Account 适用关系 | 必须写业务审计,actor 使用当前登录用户;Rooming List 触发的 Group Booking Status 自动变更或跳过都必须记录 `V4_ROOMING_LIST_AUTO_DEF` 安全摘要 |
+| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | `FRONTEND_USER` | 已实现 M002 V4 CP6/CP8/CP11;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 + 当前酒店数据库目录校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload;Rooming List 卡确认只表示事项已人工处理,不新增名单解析、附件预览、Excel 生成或 PMS 导入权限,也不修改同订单 Room Information、Group Booking Status 或其他业务卡;Payment 第一版确认只提交 `version` 和必要审计说明,不提交 `attachment_ids[]`、附件 URL 或完整附件对象;Basic Account、Room Type、Rate Code 目录错误返回 `V4_FIELD_VALIDATION_FAILED`;Rate Code 第一阶段只校验当前酒店 `ACTIVE` 目录存在,暂不校验 Account 适用关系 | 必须写业务审计,actor 使用当前登录用户;Rooming List 只记录本卡确认审计 |
| `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | 已实现 M002 V4 CP7/CP8/CP11;强制 Bearer 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 + 当前酒店数据库目录校验 | 保持;仅用于 V4 `REVIEW_REQUIRED` 卡,不开放普通任务任意切换订单;字段指针只允许当前卡 `fields[]` 白名单内可编辑业务字段,不允许提交来源邮件、路由、Agent 原始定位、诊断、`manual_review`、附件 URL 或 AI 原始 payload;订单归属未解决时必须提交当前酒店下真实可见订单 ID,已 `RESOLVED` 的订单任务不得换绑不同订单 | 必须写业务审计,记录复核字段指针、复核说明和订单归属确认摘要;不返回或写入 AI 原始 payload |
| `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 `route_code=S10/S99` 的 V4 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞;重复 ack 幂等返回当前状态且不新增审计 | 首次确认必须写业务审计,记录已读 / 已处理确认,actor 使用当前登录用户 |
| `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计和原因 |
@@ -204,7 +205,7 @@
| 审计类型 | 当前载体 | 必须记录的动作 |
| --- | --- | --- |
| 管理审计 | `platform_admin_audit_log` | 用户、角色、权限、菜单、酒店的写操作 |
-| 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、V4 来源通知 ack、Rooming List 触发 Group Booking Status 自动 DEF、OPERA 执行 / 重试 |
+| 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、V4 来源通知 ack、OPERA 执行 / 重试 |
| 邮件原文读取审计 | `platform_source_message_original_access_audit` | 读取邮件正文、HTML、附件外链、Payment 图片预览和非图片下载 |
| SuperAgent 入站追踪 | `workflow_reservation_ai_batch`、`workflow_reservation_ai_transition` | task-results / MCP 提交、路由、adapter error |
| SuperAgent MCP 入站诊断 | `platform_superagent_mcp_call_diagnostic` | MCP 原始工具参数、submit adapter 后 payload、空 mapping diagnostics 和安全错误摘要;不保存完整查询 tool 响应;V4-only 后旧 V3 事件索引映射已废弃 |
diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java
index a224b42..b439f0d 100644
--- a/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java
+++ b/server/src/main/java/cn/nianxx/thhotel/platform/debug/service/impl/DebugEmlSuperAgentRunServiceImpl.java
@@ -51,6 +51,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
+import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
@@ -519,7 +520,11 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
SCHEMA_VERSION,
uploadedMedia.stream().map(UploadedMedia::captureMedia).toList()));
List attachmentExtractions =
- extractBookingExcelAttachments(runId, parsed.mediaItems(), createdAt, warnings);
+ extractBookingExcelAttachments(
+ runId,
+ parsed.mediaItems(),
+ parsed.sentAt() == null ? createdAt.atOffset(ZoneOffset.UTC).toInstant() : parsed.sentAt(),
+ warnings);
Map superAgentPayload = payloadWithAttachmentExtractions(payload, attachmentExtractions);
String superAgentPayloadJson = objectMapper.writeValueAsString(superAgentPayload);
updateSourceCapturedRun(
@@ -731,7 +736,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
private List extractBookingExcelAttachments(
Long runId,
List mediaItems,
- LocalDateTime createdAt,
+ Instant baseInstant,
List warnings) {
if (!properties.isIncludeBookingExcelExtractions() || mediaItems == null || mediaItems.isEmpty()) {
return List.of();
@@ -756,7 +761,7 @@ public class DebugEmlSuperAgentRunServiceImpl implements DebugEmlSuperAgentRunSe
mediaItem.contentType(),
sizeBytes,
content,
- createdAt.atOffset(ZoneOffset.UTC).toInstant(),
+ baseInstant,
DEFAULT_HOTEL_ZONE,
null,
null));
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeResult.java
new file mode 100644
index 0000000..3f70098
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeResult.java
@@ -0,0 +1,56 @@
+package cn.nianxx.thhotel.workflows.reservation.common.result;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+
+/**
+ * 普通员工导入 Booking EML 后的安全结果。任务 ID 可直接用于前端跳转,不返回原始附件内容。
+ */
+public record ReservationBookingEmailIntakeResult(
+ @JsonProperty("source_message_id")
+ String sourceMessageId,
+ Boolean duplicate,
+ String status,
+ @JsonProperty("catalog_version")
+ String catalogVersion,
+ @JsonProperty("parser_version")
+ String parserVersion,
+ @JsonProperty("order_task_ids")
+ List orderTaskIds,
+ @JsonProperty("source_notification_ids")
+ List sourceNotificationIds,
+ List warnings,
+ @JsonProperty("processing_run_id")
+ String processingRunId,
+ @JsonProperty("processing_status")
+ String processingStatus,
+ @JsonProperty("result_disposition")
+ String resultDisposition,
+ Boolean asynchronous
+) {
+
+ /** 保留 M012 V0.1 调用方的构造方式;contracts-v1 运行信息在新主线启用后才返回。 */
+ public ReservationBookingEmailIntakeResult(
+ String sourceMessageId,
+ Boolean duplicate,
+ String status,
+ String catalogVersion,
+ String parserVersion,
+ List orderTaskIds,
+ List sourceNotificationIds,
+ List warnings) {
+ this(
+ sourceMessageId,
+ duplicate,
+ status,
+ catalogVersion,
+ parserVersion,
+ orderTaskIds,
+ sourceNotificationIds,
+ warnings,
+ null,
+ null,
+ null,
+ false);
+ }
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeWarningResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeWarningResult.java
new file mode 100644
index 0000000..0b552fa
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationBookingEmailIntakeWarningResult.java
@@ -0,0 +1,14 @@
+package cn.nianxx.thhotel.workflows.reservation.common.result;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Booking 邮件导入的安全告警。只返回稳定代码和面向用户的摘要,不回显邮件正文或原始 Excel 行。
+ */
+public record ReservationBookingEmailIntakeWarningResult(
+ String code,
+ String message,
+ @JsonProperty("order_ref")
+ String orderRef
+) {
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingEmailIntakeController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingEmailIntakeController.java
new file mode 100644
index 0000000..8fdfa85
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingEmailIntakeController.java
@@ -0,0 +1,47 @@
+package cn.nianxx.thhotel.workflows.reservation.control;
+
+import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
+import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
+import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationBookingEmailIntakeResult;
+import cn.nianxx.thhotel.workflows.reservation.service.ReservationBookingEmailIntakeService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 普通员工 Booking 邮件导入入口。该接口只创建待确认任务,不执行 PMS/Opera。
+ */
+@RestController
+@RequestMapping("/api/reservation/booking-email-intakes")
+public class ReservationBookingEmailIntakeController {
+
+ private final ReservationBookingEmailIntakeService intakeService;
+ private final FrontendAuthorizationService authorizationService;
+
+ public ReservationBookingEmailIntakeController(
+ ReservationBookingEmailIntakeService intakeService,
+ FrontendAuthorizationService authorizationService) {
+ this.intakeService = intakeService;
+ this.authorizationService = authorizationService;
+ }
+
+ /**
+ * 上传一封 EML,确定性解析固定渠道附件并返回可点击任务 ID。
+ */
+ @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity intake(
+ @RequestParam("file") MultipartFile file,
+ @RequestParam(name = "hotel_id", required = false) String hotelId) {
+ authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_EDIT.name());
+ ReservationBookingEmailIntakeResult result = intakeService.intake(file, hotelId);
+ return ResponseEntity.status(Boolean.TRUE.equals(result.asynchronous())
+ ? HttpStatus.ACCEPTED
+ : HttpStatus.CREATED)
+ .body(result);
+ }
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java
index 7ea84bc..a29ea7f 100644
--- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java
@@ -19,6 +19,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
ReservationV4CatalogLookupController.class,
ReservationV4QueryController.class,
ReservationV4CommandController.class,
+ ReservationBookingEmailIntakeController.class,
+ ReservationBookingProcessingRunController.class,
ReservationDemoDataController.class,
ReservationInvoiceGenerationController.class,
ReservationRoomingListGenerationController.class
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java
index 91ded10..39efce3 100644
--- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java
@@ -61,8 +61,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
private static final List V4_ORDER_TASK_AUDIT_ACTIONS = List.of(
"V4_CARD_CONFIRM",
- "V4_CARD_REVIEW_RESOLVE",
- "V4_ROOMING_LIST_AUTO_DEF");
+ "V4_CARD_REVIEW_RESOLVE");
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
private final ReservationAiBatchMapper batchMapper;
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationBookingEmailIntakeService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationBookingEmailIntakeService.java
new file mode 100644
index 0000000..9dbc545
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationBookingEmailIntakeService.java
@@ -0,0 +1,15 @@
+package cn.nianxx.thhotel.workflows.reservation.service;
+
+import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationBookingEmailIntakeResult;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * Booking 邮件人工导入服务。负责从 EML 建立 SourceMessage 并创建待确认的 V4 任务。
+ */
+public interface ReservationBookingEmailIntakeService {
+
+ /**
+ * 导入一封真实 EML。相同实际邮件重复上传时返回已有 SourceMessage 和任务,不重复创建。
+ */
+ ReservationBookingEmailIntakeResult intake(MultipartFile file, String requestedHotelId);
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java
index 7e88132..c943ba6 100644
--- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java
@@ -1295,6 +1295,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
payload.put("task_subtype", route.taskSubtype());
payload.set("target_order", v4TargetOrderDisplayPayload(event.path("target_order")));
payload.set("business_fields", v4BusinessFieldsDisplayPayload(event));
+ copyJsonFieldIfPresent(payload, event, "recognition");
return payload;
}
@@ -1317,6 +1318,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
copyJsonFieldIfPresent(fields, event, "order_ref");
copyJsonFieldIfPresent(fields, event, "event_type");
copyJsonFieldIfPresent(fields, event, "manual_review");
+ copyJsonFieldIfPresent(fields, event, "names");
+ copyJsonFieldIfPresent(fields, event, "group_code");
+ copyJsonFieldIfPresent(fields, event, "group_name");
+ copyJsonFieldIfPresent(fields, event, "confirmation_number");
switch (nullToEmpty(textAt(event, "event_type"))) {
case "NEW_BOOKING" -> copyV4NewBookingDisplayFields(fields, event);
case "UPDATE_BOOKING" -> copyV4UpdateBookingDisplayFields(fields, event);
@@ -1341,6 +1346,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
copyJsonFieldIfPresent(fields, event, "rate_code");
copyJsonFieldIfPresent(fields, event, "booking_scenario");
copyJsonFieldIfPresent(fields, event, "guest_name");
+ copyJsonFieldIfPresent(fields, event, "names");
+ copyJsonFieldIfPresent(fields, event, "group_code");
+ copyJsonFieldIfPresent(fields, event, "group_name");
+ copyJsonFieldIfPresent(fields, event, "confirmation_number");
fields.set("room_items", v4RoomItemsDisplayPayload(event.path("room_items")));
}
@@ -1353,6 +1362,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
copyJsonFieldIfPresent(afterDisplay, after, "guest_name");
copyJsonFieldIfPresent(afterDisplay, after, "arrival_date");
copyJsonFieldIfPresent(afterDisplay, after, "departure_date");
+ copyJsonFieldIfPresent(afterDisplay, after, "names");
+ copyJsonFieldIfPresent(afterDisplay, after, "group_code");
+ copyJsonFieldIfPresent(afterDisplay, after, "group_name");
+ copyJsonFieldIfPresent(afterDisplay, after, "confirmation_number");
if (after.has("room_items")) {
JsonNode roomItems = after.path("room_items");
afterDisplay.set("room_items", roomItems.isNull() ? objectMapper.nullNode() : v4RoomItemsDisplayPayload(roomItems));
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingCatalog.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingCatalog.java
new file mode 100644
index 0000000..1de6cd8
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingCatalog.java
@@ -0,0 +1,274 @@
+package cn.nianxx.thhotel.workflows.reservation.service.impl;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+
+/**
+ * M012 Parser、业务识别和结果校验共用的版本化 Booking Catalog。
+ */
+@Component
+public class ReservationBookingCatalog {
+
+ private static final String RESOURCE_PATH =
+ "reservation-booking/catalog/booking-catalog-v20260808.json";
+
+ private final String catalogVersion;
+ private final String parserVersion;
+ private final List channelProfiles;
+ private final Map> actionAliases;
+ private final Map roomTypeByAlias;
+ private final Set unresolvedRoomTypeAliases;
+ private final int fitMaxRoomCount;
+
+ public ReservationBookingCatalog(ObjectMapper objectMapper) {
+ JsonNode root = readCatalog(objectMapper);
+ this.catalogVersion = requiredText(root, "catalog_version");
+ this.parserVersion = requiredText(root, "parser_version");
+ this.channelProfiles = readProfiles(root.path("channel_profiles"));
+ this.actionAliases = readAliases(root.path("action_aliases"));
+ this.roomTypeByAlias = readRoomAliases(root.path("room_type_aliases"));
+ this.unresolvedRoomTypeAliases = readStringSet(root.path("unresolved_room_type_aliases"));
+ this.fitMaxRoomCount = root.path("booking_type_threshold").path("fit_max_room_count").asInt(4);
+ }
+
+ public String catalogVersion() {
+ return catalogVersion;
+ }
+
+ public String parserVersion() {
+ return parserVersion;
+ }
+
+ public List channelProfiles() {
+ return channelProfiles;
+ }
+
+ /**
+ * 动作句先按 Cancel、Update、New 优先级归一,避免 “NEW ... AMEND TO ...” 被误判为 New。
+ */
+ public String normalizeAction(String source) {
+ String normalized = normalizePhrase(source);
+ if (normalized == null) {
+ return null;
+ }
+ for (String action : List.of("CANCEL_BOOKING", "UPDATE_BOOKING", "NEW_BOOKING")) {
+ for (String alias : actionAliases.getOrDefault(action, List.of())) {
+ if (normalized.contains(normalizePhrase(alias))) {
+ return action;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 将渠道房型写法翻译为系统目录代码;未知或业务尚未确认的写法必须返回 null。
+ */
+ public String normalizeRoomType(String sourceLabel) {
+ String normalized = normalizeRoomAlias(sourceLabel);
+ if (normalized == null) {
+ return null;
+ }
+ for (String unresolved : unresolvedRoomTypeAliases) {
+ if (normalized.contains(unresolved)) {
+ return null;
+ }
+ }
+ List> aliasesBySpecificity = roomTypeByAlias.entrySet().stream()
+ .sorted((left, right) -> Integer.compare(right.getKey().length(), left.getKey().length()))
+ .toList();
+ for (Map.Entry entry : aliasesBySpecificity) {
+ if (normalized.contains(entry.getKey())) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+
+ public boolean isExplicitlyUnresolvedRoomType(String sourceLabel) {
+ String normalized = normalizeRoomAlias(sourceLabel);
+ return normalized != null && unresolvedRoomTypeAliases.stream().anyMatch(normalized::contains);
+ }
+
+ public String bookingType(Integer totalRoomCount) {
+ if (totalRoomCount == null || totalRoomCount <= 0) {
+ return null;
+ }
+ return totalRoomCount <= fitMaxRoomCount ? "FIT" : "GROUP";
+ }
+
+ private JsonNode readCatalog(ObjectMapper objectMapper) {
+ ClassPathResource resource = new ClassPathResource(RESOURCE_PATH);
+ try (InputStream input = resource.getInputStream()) {
+ return objectMapper.readTree(input);
+ } catch (IOException exception) {
+ throw new IllegalStateException("Booking Catalog 无法加载。", exception);
+ }
+ }
+
+ private List readProfiles(JsonNode profilesNode) {
+ if (!profilesNode.isArray() || profilesNode.isEmpty()) {
+ throw new IllegalStateException("Booking Catalog 缺少 channel_profiles。 ");
+ }
+ List profiles = new ArrayList<>();
+ for (JsonNode profile : profilesNode) {
+ profiles.add(new ChannelProfile(
+ requiredText(profile, "code"),
+ readStrings(profile.path("file_name_signals")),
+ readStrings(profile.path("sheet_name_patterns")),
+ readStrings(profile.path("header_signature")),
+ readHeaderCellSignatures(profile.path("header_cell_signature")),
+ profile.path("header_row").asInt(),
+ columnIndex(requiredText(profile, "business_column_from")),
+ columnIndex(requiredText(profile, "business_column_to")),
+ columnIndex(requiredText(profile, "tour_code_column")),
+ columnIndex(requiredText(profile, "hotel_detail_column")),
+ optionalColumnIndex(profile, "secondary_detail_column"),
+ columnIndex(requiredText(profile, "action_column")),
+ optionalColumnIndex(profile, "status_column"),
+ optionalColumnIndex(profile, "remark_column")));
+ }
+ return List.copyOf(profiles);
+ }
+
+ /**
+ * 固定渠道除了通用表头词外,还必须给出关键表头所在的确切列。
+ * 这能区分同样包含 Tour Code / 酒店 / 备注、但列结构不同的 QBD 与联泰样表。
+ */
+ private List readHeaderCellSignatures(JsonNode node) {
+ List signatures = new ArrayList<>();
+ for (String value : readStrings(node)) {
+ int delimiter = value.indexOf(':');
+ if (delimiter <= 0 || delimiter == value.length() - 1) {
+ throw new IllegalStateException("Booking Catalog header_cell_signature 格式必须为列号:表头词。");
+ }
+ signatures.add(new HeaderCellSignature(
+ columnIndex(value.substring(0, delimiter)),
+ value.substring(delimiter + 1).trim()));
+ }
+ return List.copyOf(signatures);
+ }
+
+ private Map> readAliases(JsonNode aliasesNode) {
+ Map> aliases = new LinkedHashMap<>();
+ aliasesNode.properties().forEach(entry -> aliases.put(entry.getKey(), readStrings(entry.getValue())));
+ return Map.copyOf(aliases);
+ }
+
+ private Map readRoomAliases(JsonNode roomAliasesNode) {
+ Map aliases = new LinkedHashMap<>();
+ roomAliasesNode.properties().forEach(entry -> {
+ for (String alias : readStrings(entry.getValue())) {
+ aliases.put(normalizeRoomAlias(alias), entry.getKey());
+ }
+ });
+ return Map.copyOf(aliases);
+ }
+
+ private Set readStringSet(JsonNode node) {
+ Set values = new LinkedHashSet<>();
+ for (String value : readStrings(node)) {
+ values.add(normalizeRoomAlias(value));
+ }
+ return Set.copyOf(values);
+ }
+
+ private List readStrings(JsonNode node) {
+ List values = new ArrayList<>();
+ if (node != null && node.isArray()) {
+ for (JsonNode value : node) {
+ if (value.isTextual() && !value.asText().isBlank()) {
+ values.add(value.asText().trim());
+ }
+ }
+ }
+ return List.copyOf(values);
+ }
+
+ private String requiredText(JsonNode node, String fieldName) {
+ String value = node == null ? null : node.path(fieldName).asText(null);
+ if (value == null || value.isBlank()) {
+ throw new IllegalStateException("Booking Catalog 字段缺失:" + fieldName);
+ }
+ return value.trim();
+ }
+
+ private Integer optionalColumnIndex(JsonNode node, String fieldName) {
+ String value = node.path(fieldName).asText(null);
+ return value == null || value.isBlank() ? null : columnIndex(value);
+ }
+
+ private int columnIndex(String column) {
+ int result = 0;
+ for (char character : column.trim().toUpperCase(Locale.ROOT).toCharArray()) {
+ if (character < 'A' || character > 'Z') {
+ throw new IllegalStateException("Booking Catalog 列号无效:" + column);
+ }
+ result = result * 26 + (character - 'A' + 1);
+ }
+ return result - 1;
+ }
+
+ private String normalizePhrase(String value) {
+ if (value == null) {
+ return null;
+ }
+ String normalized = value.toUpperCase(Locale.ROOT)
+ .replace('\u00A0', ' ')
+ .replaceAll("\\s+", " ")
+ .trim();
+ return normalized.isEmpty() ? null : normalized;
+ }
+
+ private String normalizeRoomAlias(String value) {
+ String normalized = normalizePhrase(value);
+ if (normalized == null) {
+ return null;
+ }
+ return normalized
+ .replaceAll("[\\s_\\-]+", "")
+ .trim();
+ }
+
+ /**
+ * 固定渠道表格结构。所有列均为 Apache POI 的 0 基下标。
+ */
+ public record ChannelProfile(
+ String code,
+ List fileNameSignals,
+ List sheetNamePatterns,
+ List headerSignature,
+ List headerCellSignatures,
+ int headerRow,
+ int businessColumnFrom,
+ int businessColumnTo,
+ int tourCodeColumn,
+ int hotelDetailColumn,
+ Integer secondaryDetailColumn,
+ int actionColumn,
+ Integer statusColumn,
+ Integer remarkColumn
+ ) {
+ }
+
+ /** 一个必须出现在指定列、指定表头区域内的固定渠道签名。 */
+ public record HeaderCellSignature(int columnIndex, String signature) {
+
+ public HeaderCellSignature {
+ if (columnIndex < 0 || signature == null || signature.isBlank()) {
+ throw new IllegalArgumentException("固定渠道表头列签名无效。");
+ }
+ }
+ }
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailIntakeServiceImpl.java
new file mode 100644
index 0000000..be3badb
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailIntakeServiceImpl.java
@@ -0,0 +1,676 @@
+package cn.nianxx.thhotel.workflows.reservation.service.impl;
+
+import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
+import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem;
+import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage;
+import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
+import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
+import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
+import cn.nianxx.thhotel.platform.message.service.EmlMessageParseService;
+import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
+import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageAttachmentInput;
+import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageInput;
+import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingOrchestrationResult;
+import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingMessageOrchestrator;
+import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
+import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts;
+import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
+import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationBookingEmailIntakeResult;
+import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationBookingEmailIntakeWarningResult;
+import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
+import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
+import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
+import cn.nianxx.thhotel.workflows.reservation.service.ReservationBookingEmailIntakeService;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 普通员工 Booking EML 导入实现。固定渠道先走 deterministic Parser,再进入现有 V4 校验和任务主线。
+ */
+@Service
+public class ReservationBookingEmailIntakeServiceImpl implements ReservationBookingEmailIntakeService {
+
+ private static final String SOURCE_PROVIDER = "MANUAL_EML";
+ private static final String SOURCE_CHANNEL = "EMAIL";
+ private static final String SOURCE_SCHEMA_VERSION = "booking-email-source-v0.1";
+ private static final String INTAKE_CLIENT_ID = "booking-email-deterministic-parser";
+ private static final Set RISK_WARNING_CODES = Set.of(
+ "BOOKING_EXCEL_PARSE_FAILED",
+ "CHANNEL_PROFILE_AMBIGUOUS",
+ "CHANNEL_PROFILE_SHEET_INVALID",
+ "CHANNEL_PROFILE_UNKNOWN",
+ "NO_CURRENT_STRICT_ROWS",
+ "PARTIAL_FILL_REVIEW_EVIDENCE");
+ private static final List BUSINESS_SIGNAL_KEYWORDS = List.of(
+ "BOOKING",
+ "AMEND",
+ "UPDATE",
+ "CANCEL",
+ "CXL",
+ "ROOMING",
+ "PAYMENT",
+ "VOUCHER",
+ "TRACE",
+ "ALLOTMENT",
+ "TOUR CODE",
+ "ยกเลิก",
+ "จอง",
+ "ห้อง");
+
+ private final EmlMessageParseService emlMessageParseService;
+ private final ReservationBookingEmailParser parser;
+ private final ReservationBookingCatalog catalog;
+ private final SourceMessageCaptureService sourceMessageCaptureService;
+ private final SourceMessageInboxRepository sourceMessageInboxRepository;
+ private final ReservationAiTaskIntakeService aiTaskIntakeService;
+ private final ReservationV4WorkflowRepository workflowRepository;
+ private final ReservationV4SourceNotificationRepository notificationRepository;
+ private final HotelContextService hotelContextService;
+ private final ObjectMapper objectMapper;
+ private final ObjectProvider bookingMessageOrchestratorProvider;
+ private final long maxFileSizeBytes;
+
+ public ReservationBookingEmailIntakeServiceImpl(
+ EmlMessageParseService emlMessageParseService,
+ ReservationBookingEmailParser parser,
+ ReservationBookingCatalog catalog,
+ SourceMessageCaptureService sourceMessageCaptureService,
+ SourceMessageInboxRepository sourceMessageInboxRepository,
+ ReservationAiTaskIntakeService aiTaskIntakeService,
+ ReservationV4WorkflowRepository workflowRepository,
+ ReservationV4SourceNotificationRepository notificationRepository,
+ HotelContextService hotelContextService,
+ ObjectMapper objectMapper,
+ ObjectProvider bookingMessageOrchestratorProvider,
+ @Value("${reservation.booking-email-intake.max-file-size-bytes:26214400}") long maxFileSizeBytes) {
+ this.emlMessageParseService = emlMessageParseService;
+ this.parser = parser;
+ this.catalog = catalog;
+ this.sourceMessageCaptureService = sourceMessageCaptureService;
+ this.sourceMessageInboxRepository = sourceMessageInboxRepository;
+ this.aiTaskIntakeService = aiTaskIntakeService;
+ this.workflowRepository = workflowRepository;
+ this.notificationRepository = notificationRepository;
+ this.hotelContextService = hotelContextService;
+ this.objectMapper = objectMapper;
+ this.bookingMessageOrchestratorProvider = bookingMessageOrchestratorProvider;
+ this.maxFileSizeBytes = Math.max(1L, maxFileSizeBytes);
+ }
+
+ @Override
+ public ReservationBookingEmailIntakeResult intake(MultipartFile file, String requestedHotelId) {
+ byte[] emlBytes = validatedEmlBytes(file);
+ String hotelId = hotelContextService.resolveCurrentHotelId(requestedHotelId);
+ ParsedEmlMessage parsed = emlMessageParseService.parse(emlBytes, safeFileName(file.getOriginalFilename()));
+ String contentSha256 = sha256(emlBytes);
+ String externalMessageId = externalMessageId(parsed.messageId(), contentSha256);
+ String externalConversationId = externalConversationId(parsed.conversationId(), externalMessageId);
+ BookingMessageOrchestrator bookingMessageOrchestrator = bookingMessageOrchestratorProvider.getIfAvailable();
+ if (bookingMessageOrchestrator != null) {
+ return contractsV1Result(
+ bookingMessageOrchestrator.process(bookingMessageInput(
+ hotelId,
+ parsed,
+ contentSha256,
+ externalMessageId,
+ externalConversationId)),
+ contentSha256);
+ }
+ ObjectNode sourcePayload = sourcePayload(parsed, externalMessageId, externalConversationId);
+ SourceMessageCaptureResult captureResult = sourceMessageCaptureService.capture(new CaptureSourceMessageCommand(
+ hotelId,
+ SOURCE_PROVIDER,
+ SOURCE_CHANNEL,
+ externalMessageId,
+ externalConversationId,
+ "manual-eml-" + contentSha256.substring(0, 16),
+ "reservation-email-intake",
+ Instant.now(),
+ parsed.sentAt(),
+ parsed.sender(),
+ parsed.subject(),
+ parsed.textBody(),
+ parsed.htmlBody(),
+ json(sourcePayload),
+ SOURCE_SCHEMA_VERSION,
+ List.of()));
+
+ Long sourceMessageId = captureResult.inboxId();
+ List existingTasks =
+ workflowRepository.findOrderTasksBySourceMessageId(hotelId, sourceMessageId);
+ List existingNotifications =
+ notificationRepository.findSourceNotificationsBySourceMessageId(hotelId, sourceMessageId);
+ boolean duplicate = !captureResult.created();
+ if (duplicate && (!existingTasks.isEmpty() || !existingNotifications.isEmpty())) {
+ List duplicateWarnings = new ArrayList<>();
+ duplicateWarnings.add(new ReservationBookingEmailIntakeWarningResult(
+ "DUPLICATE_DELIVERY",
+ "该实际邮件已处理,本次返回已有任务。",
+ null));
+ if (captureResult.duplicatePayloadChanged()) {
+ duplicateWarnings.add(new ReservationBookingEmailIntakeWarningResult(
+ "DUPLICATE_PAYLOAD_CHANGED",
+ "相同 Message-ID 的载荷与首次记录不同,系统保留首次来源事实。",
+ null));
+ }
+ return result(
+ sourceMessageId,
+ true,
+ "DUPLICATE_REPLAY",
+ existingTasks,
+ existingNotifications,
+ duplicateWarnings);
+ }
+
+ ReservationBookingEmailParser.ParseOutcome parseOutcome = parser.parse(parsed);
+ ObjectNode v4Root;
+ if (parseOutcome.facts().isEmpty()) {
+ v4Root = notificationRoot(
+ parsed,
+ externalMessageId,
+ externalConversationId,
+ sourcePayload,
+ notificationRoute(parsed, parseOutcome));
+ } else {
+ v4Root = businessRoot(parsed, externalMessageId, externalConversationId, sourcePayload, parseOutcome);
+ }
+ aiTaskIntakeService.accept(
+ json(v4Root),
+ INTAKE_CLIENT_ID,
+ "booking-email-intake-" + sourceMessageId,
+ hotelId);
+
+ List tasks = workflowRepository.findOrderTasksBySourceMessageId(hotelId, sourceMessageId);
+ List notifications =
+ notificationRepository.findSourceNotificationsBySourceMessageId(hotelId, sourceMessageId);
+ if (tasks.isEmpty() && notifications.isEmpty()) {
+ throw new ReservationTaskWorkflowException(
+ HttpStatus.UNPROCESSABLE_ENTITY,
+ "BOOKING_EMAIL_TASK_CREATION_FAILED",
+ "邮件已保存,但识别结果未能形成可处理任务。请联系系统管理员查看契约诊断。");
+ }
+ String status = tasks.isEmpty() ? "NOTIFICATION_CREATED" : "TASKS_CREATED";
+ List warnings = warningResults(parseOutcome.warnings());
+ if (duplicate) {
+ warnings = appendWarning(warnings, new ReservationBookingEmailIntakeWarningResult(
+ "SOURCE_MESSAGE_REPLAYED",
+ "来源邮件此前已保存,本次补齐了尚未创建的任务。",
+ null));
+ }
+ return result(sourceMessageId, duplicate, status, tasks, notifications, warnings);
+ }
+
+ private byte[] validatedEmlBytes(MultipartFile file) {
+ if (file == null || file.isEmpty()) {
+ throw new ReservationTaskWorkflowException(
+ HttpStatus.BAD_REQUEST,
+ "EML_FILE_REQUIRED",
+ "请选择一封 .eml 邮件文件。 ");
+ }
+ String fileName = safeFileName(file.getOriginalFilename());
+ if (!fileName.toLowerCase(Locale.ROOT).endsWith(".eml")) {
+ throw new ReservationTaskWorkflowException(
+ HttpStatus.BAD_REQUEST,
+ "INVALID_FILE_TYPE",
+ "只支持上传 .eml 邮件文件。 ");
+ }
+ if (file.getSize() > maxFileSizeBytes) {
+ throw new ReservationTaskWorkflowException(
+ HttpStatus.PAYLOAD_TOO_LARGE,
+ "EML_FILE_TOO_LARGE",
+ "上传的邮件文件超过系统大小限制。 ");
+ }
+ try {
+ byte[] bytes = file.getBytes();
+ if (bytes.length == 0) {
+ throw new ReservationTaskWorkflowException(
+ HttpStatus.BAD_REQUEST,
+ "EML_FILE_REQUIRED",
+ "请选择一封非空 .eml 邮件文件。 ");
+ }
+ return bytes;
+ } catch (IOException exception) {
+ throw new ReservationTaskWorkflowException(
+ HttpStatus.BAD_REQUEST,
+ "EML_FILE_READ_FAILED",
+ "上传的邮件文件无法读取。 ");
+ }
+ }
+
+ private ObjectNode sourcePayload(
+ ParsedEmlMessage parsed,
+ String externalMessageId,
+ String externalConversationId) {
+ ObjectNode root = objectMapper.createObjectNode();
+ root.put("schema_version", SOURCE_SCHEMA_VERSION);
+ ObjectNode source = root.putObject("source");
+ source.put("external_message_id", externalMessageId);
+ source.put("external_conversation_id", externalConversationId);
+ source.put("provider", SOURCE_PROVIDER);
+ source.put("channel", SOURCE_CHANNEL);
+ root.put("subject", parsed.subject());
+ root.put("sender", parsed.sender());
+ putInstant(root, "sent_at", parsed.sentAt());
+ root.put("text_body", parsed.textBody());
+ root.put("html_body", parsed.htmlBody());
+ ArrayNode attachments = root.putArray("attachments");
+ int index = 0;
+ for (ParsedEmlMediaItem media : parsed.mediaItems()) {
+ if (!"ATTACHMENT".equals(media.mediaType())) {
+ continue;
+ }
+ index++;
+ ObjectNode attachment = attachments.addObject();
+ attachment.put("id", "att-" + index);
+ attachment.put("name", safeFileName(media.fileName()));
+ attachment.put("content_type", media.contentType());
+ attachment.put("size", media.sizeBytes());
+ attachment.put("sha256", sha256(media.bytes()));
+ }
+ return root;
+ }
+
+ private ObjectNode businessRoot(
+ ParsedEmlMessage parsed,
+ String externalMessageId,
+ String externalConversationId,
+ ObjectNode sourcePayload,
+ ReservationBookingEmailParser.ParseOutcome parseOutcome) {
+ ObjectNode root = baseV4Root(parsed, externalMessageId, externalConversationId, sourcePayload);
+ root.putNull("route_code");
+ root.put("catalog_version", catalog.catalogVersion());
+ root.put("parser_version", catalog.parserVersion());
+ ArrayNode contexts = root.putArray("order_contexts");
+ ArrayNode events = root.putArray("message_events");
+ for (ReservationBookingEmailParser.BookingFact fact : parseOutcome.facts()) {
+ ObjectNode context = contexts.addObject();
+ context.put("order_ref", fact.orderRef());
+ ObjectNode basic = context.putObject("basic_information");
+ basic.putNull("account_code");
+ basic.put("manual_review", true);
+ context.set("recognition", recognition(fact.recognition()));
+
+ ObjectNode event = events.addObject();
+ event.put("order_ref", fact.orderRef());
+ event.put("event_type", fact.action());
+ ObjectNode targetOrder = event.putObject("target_order");
+ targetOrder.put("booking_type", fact.bookingType());
+ targetOrder.putNull("locator_type");
+ targetOrder.putNull("locator_value");
+ event.set("names", textArray(fact.names()));
+ putNullableText(event, "group_code", fact.groupCode());
+ putNullableText(event, "group_name", fact.groupName());
+ event.set("recognition", recognition(fact.recognition()));
+ if ("NEW_BOOKING".equals(fact.action())) {
+ putNullableDate(event, "arrival_date", fact.arrivalDate());
+ putNullableDate(event, "departure_date", fact.departureDate());
+ event.putNull("rate_code");
+ event.put("booking_scenario", "STANDARD");
+ event.set("room_items", roomItems(fact.roomItems()));
+ } else if ("UPDATE_BOOKING".equals(fact.action())) {
+ ObjectNode after = event.putObject("after");
+ putNullableDate(after, "arrival_date", fact.arrivalDate());
+ putNullableDate(after, "departure_date", fact.departureDate());
+ after.set("names", textArray(fact.names()));
+ putNullableText(after, "group_code", fact.groupCode());
+ putNullableText(after, "group_name", fact.groupName());
+ after.set("room_items", roomItems(fact.roomItems()));
+ }
+ event.put("manual_review", true);
+
+ if (!fact.traceItems().isEmpty()) {
+ ObjectNode traceEvent = events.addObject();
+ traceEvent.put("order_ref", fact.orderRef());
+ traceEvent.put("event_type", "TRACE_RESERVATION_NOTES");
+ traceEvent.set("target_order", targetOrder.deepCopy());
+ traceEvent.set("trace_items", traceItems(fact.traceItems()));
+ traceEvent.set("recognition", recognition(fact.recognition()));
+ traceEvent.put("manual_review", true);
+ }
+ }
+ root.set("extraction_warnings", warnings(parseOutcome.warnings()));
+ return root;
+ }
+
+ private ObjectNode notificationRoot(
+ ParsedEmlMessage parsed,
+ String externalMessageId,
+ String externalConversationId,
+ ObjectNode sourcePayload,
+ String routeCode) {
+ ObjectNode root = baseV4Root(parsed, externalMessageId, externalConversationId, sourcePayload);
+ root.put("route_code", routeCode);
+ root.put("catalog_version", catalog.catalogVersion());
+ root.put("parser_version", catalog.parserVersion());
+ root.putArray("order_contexts");
+ root.putArray("message_events");
+ return root;
+ }
+
+ private ObjectNode baseV4Root(
+ ParsedEmlMessage parsed,
+ String externalMessageId,
+ String externalConversationId,
+ ObjectNode sourcePayload) {
+ ObjectNode root = objectMapper.createObjectNode();
+ ObjectNode source = root.putObject("source_message");
+ source.put("source_message_id", externalMessageId);
+ source.put("conversation_id", externalConversationId);
+ source.put("subject", parsed.subject());
+ source.put("sender", parsed.sender());
+ putInstant(source, "sent_at", parsed.sentAt());
+ String body = hasText(parsed.textBody()) ? parsed.textBody() : parsed.htmlBody();
+ source.put("body", body == null ? "" : body);
+ source.put("body_content_type", hasText(parsed.textBody()) ? "text/plain" : "text/html");
+ ArrayNode attachments = source.putArray("attachments");
+ for (var iterator = sourcePayload.path("attachments").elements(); iterator.hasNext();) {
+ var attachmentSummary = iterator.next();
+ ObjectNode attachment = attachments.addObject();
+ String id = attachmentSummary.path("id").asText();
+ attachment.put("id", id);
+ attachment.put("name", attachmentSummary.path("name").asText("attachment"));
+ attachment.put("content_type", attachmentSummary.path("content_type").asText("application/octet-stream"));
+ attachment.put("url", "urn:booking-email-attachment:" + attachmentSummary.path("sha256").asText(id));
+ if (attachmentSummary.path("size").isNumber()) {
+ attachment.put("size", attachmentSummary.path("size").asLong());
+ }
+ }
+ return root;
+ }
+
+ private ArrayNode roomItems(List rooms) {
+ ArrayNode result = objectMapper.createArrayNode();
+ for (ReservationBookingEmailParser.RoomFact room : rooms) {
+ ObjectNode item = result.addObject();
+ putNullableText(item, "room_type_code", room.roomTypeCode());
+ item.put("room_count", room.roomCount());
+ }
+ return result;
+ }
+
+ private ArrayNode traceItems(List traces) {
+ ArrayNode result = objectMapper.createArrayNode();
+ for (ReservationBookingEmailParser.TraceFact trace : traces) {
+ ObjectNode item = result.addObject();
+ item.put("item_type", trace.itemType());
+ if ("GENERAL".equals(trace.itemType())) {
+ putNullableText(item, "text", trace.text());
+ } else {
+ putNullableText(item, "target_room_type_code", trace.targetRoomTypeCode());
+ item.put("extra_bed_room_count", trace.extraBedRoomCount());
+ }
+ putNullableText(item, "department_code", trace.departmentCode());
+ }
+ return result;
+ }
+
+ private ObjectNode recognition(ReservationBookingEmailParser.RecognitionEvidence evidence) {
+ ObjectNode recognition = objectMapper.createObjectNode();
+ recognition.put("attachment_name", evidence.attachmentName());
+ recognition.put("channel_profile", evidence.profile());
+ recognition.put("sheet_name", evidence.sheet());
+ recognition.put("row_number", evidence.rowNumber());
+ recognition.put("action_row_number", evidence.actionRowNumber());
+ recognition.put("selection_status", evidence.selectionStatus());
+ recognition.put("catalog_version", evidence.catalogVersion());
+ recognition.put("parser_version", evidence.parserVersion());
+ recognition.set("source_room_labels", textArray(evidence.sourceRoomLabels()));
+ recognition.set("warning_codes", textArray(evidence.warnings()));
+ return recognition;
+ }
+
+ private ArrayNode warnings(List warnings) {
+ ArrayNode result = objectMapper.createArrayNode();
+ for (ReservationBookingEmailParser.ParseWarning warning : warnings) {
+ ObjectNode item = result.addObject();
+ item.put("code", warning.code());
+ item.put("message", warning.message());
+ putNullableText(item, "order_ref", warning.orderRef());
+ }
+ return result;
+ }
+
+ private ArrayNode textArray(List values) {
+ ArrayNode array = objectMapper.createArrayNode();
+ if (values != null) {
+ values.stream().filter(this::hasText).forEach(array::add);
+ }
+ return array;
+ }
+
+ private String notificationRoute(
+ ParsedEmlMessage parsed,
+ ReservationBookingEmailParser.ParseOutcome parseOutcome) {
+ if (hasAmbiguousParserMaterial(parseOutcome)
+ || hasAnyAttachment(parsed)
+ || containsBusinessSignal(parsed)) {
+ return "S99";
+ }
+ // 只有在整封邮件没有支持任务、没有附件且没有业务信号时才创建 General。
+ return "S10";
+ }
+
+ private boolean hasAmbiguousParserMaterial(ReservationBookingEmailParser.ParseOutcome parseOutcome) {
+ return parseOutcome.warnings().stream()
+ .map(ReservationBookingEmailParser.ParseWarning::code)
+ .anyMatch(RISK_WARNING_CODES::contains);
+ }
+
+ private boolean hasAnyAttachment(ParsedEmlMessage parsed) {
+ return parsed.mediaItems() != null && parsed.mediaItems().stream()
+ .anyMatch(media -> "ATTACHMENT".equals(media.mediaType()));
+ }
+
+ private boolean containsBusinessSignal(ParsedEmlMessage parsed) {
+ String source = String.join("\n",
+ Objects.toString(parsed.subject(), ""),
+ Objects.toString(parsed.textBody(), ""),
+ Objects.toString(parsed.htmlBody(), ""))
+ .toUpperCase(Locale.ROOT);
+ return BUSINESS_SIGNAL_KEYWORDS.stream()
+ .map(keyword -> keyword.toUpperCase(Locale.ROOT))
+ .anyMatch(source::contains);
+ }
+
+ private ReservationBookingEmailIntakeResult result(
+ Long sourceMessageId,
+ boolean duplicate,
+ String status,
+ List tasks,
+ List notifications,
+ List warnings) {
+ return new ReservationBookingEmailIntakeResult(
+ sourceMessageId.toString(),
+ duplicate,
+ status,
+ catalog.catalogVersion(),
+ catalog.parserVersion(),
+ tasks.stream().map(task -> task.id().toString()).toList(),
+ notifications.stream().map(notification -> notification.id().toString()).toList(),
+ warnings == null ? List.of() : List.copyOf(warnings));
+ }
+
+ /**
+ * PostgreSQL contracts-v1 已启用时,手工 EML 直接进入统一编排器,不再向旧 MySQL V4 写入来源或任务。
+ */
+ private BookingMessageInput bookingMessageInput(
+ String hotelId,
+ ParsedEmlMessage parsed,
+ String contentSha256,
+ String externalMessageId,
+ String externalConversationId) {
+ List attachments = new ArrayList<>();
+ int index = 0;
+ for (ParsedEmlMediaItem media : parsed.mediaItems()) {
+ if (!"ATTACHMENT".equals(media.mediaType())) {
+ continue;
+ }
+ index++;
+ byte[] bytes = media.bytes();
+ attachments.add(new BookingMessageAttachmentInput(
+ "manual-att-" + index,
+ safeFileName(media.fileName()),
+ hasText(media.contentType()) ? media.contentType() : "application/octet-stream",
+ Math.max(0L, media.sizeBytes()),
+ sha256(bytes),
+ bytes,
+ true));
+ }
+ String currentBody = hasText(parsed.textBody()) ? parsed.textBody() : parsed.htmlBody();
+ return new BookingMessageInput(
+ hotelId,
+ BookingContracts.MessageOrigin.MANUAL_EML,
+ "manual-eml-" + contentSha256.substring(0, 32),
+ externalMessageId,
+ externalConversationId,
+ null,
+ "manual-eml:" + contentSha256,
+ parsed.sentAt() == null ? Instant.now() : parsed.sentAt(),
+ parsed.subject(),
+ currentBody,
+ null,
+ attachments,
+ parsed.sender());
+ }
+
+ private ReservationBookingEmailIntakeResult contractsV1Result(
+ BookingOrchestrationResult orchestrationResult,
+ String contentSha256) {
+ List warnings = orchestrationResult.warningCodes().stream()
+ .map(code -> new ReservationBookingEmailIntakeWarningResult(
+ code,
+ "该邮件需要在 Booking 确认链路中处理或复核。",
+ null))
+ .toList();
+ return new ReservationBookingEmailIntakeResult(
+ "manual-eml-" + contentSha256.substring(0, 32),
+ orchestrationResult.duplicate(),
+ orchestrationResult.processingStatus().name(),
+ catalog.catalogVersion(),
+ catalog.parserVersion(),
+ List.of(),
+ List.of(),
+ warnings,
+ orchestrationResult.processingRunId(),
+ orchestrationResult.processingStatus().name(),
+ orchestrationResult.resultDisposition() == null
+ ? null
+ : orchestrationResult.resultDisposition().name(),
+ orchestrationResult.asynchronous());
+ }
+
+ private List warningResults(
+ List parserWarnings) {
+ Map warnings = new LinkedHashMap<>();
+ for (ReservationBookingEmailParser.ParseWarning warning : parserWarnings) {
+ ReservationBookingEmailIntakeWarningResult result = new ReservationBookingEmailIntakeWarningResult(
+ warning.code(), warning.message(), warning.orderRef());
+ warnings.putIfAbsent(warning.code() + "|" + Objects.toString(warning.orderRef(), ""), result);
+ }
+ return List.copyOf(warnings.values());
+ }
+
+ private List appendWarning(
+ List warnings,
+ ReservationBookingEmailIntakeWarningResult extra) {
+ List result = new ArrayList<>(warnings);
+ result.add(extra);
+ return List.copyOf(result);
+ }
+
+ private String externalMessageId(String originalMessageId, String contentSha256) {
+ if (hasText(originalMessageId)) {
+ String normalized = originalMessageId.trim();
+ if (normalized.length() <= 220) {
+ return "eml:" + normalized;
+ }
+ return "eml-message-id-sha256:" + sha256(normalized.getBytes(StandardCharsets.UTF_8));
+ }
+ return "eml-content-sha256:" + contentSha256;
+ }
+
+ private String externalConversationId(String originalConversationId, String fallback) {
+ if (!hasText(originalConversationId)) {
+ return fallback;
+ }
+ String normalized = originalConversationId.trim();
+ return normalized.length() <= 256 ? normalized : "eml-thread-sha256:" + sha256(normalized.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String safeFileName(String fileName) {
+ if (!hasText(fileName)) {
+ return "booking-email.eml";
+ }
+ String normalized = fileName.replace('\\', '/');
+ int slash = normalized.lastIndexOf('/');
+ String baseName = slash >= 0 ? normalized.substring(slash + 1) : normalized;
+ return baseName.length() <= 240 ? baseName : baseName.substring(baseName.length() - 240);
+ }
+
+ private void putInstant(ObjectNode node, String fieldName, Instant value) {
+ if (value == null) {
+ node.putNull(fieldName);
+ } else {
+ node.put(fieldName, value.atOffset(ZoneOffset.UTC).toString());
+ }
+ }
+
+ private void putNullableDate(ObjectNode node, String fieldName, java.time.LocalDate value) {
+ if (value == null) {
+ node.putNull(fieldName);
+ } else {
+ node.put(fieldName, value.toString());
+ }
+ }
+
+ private void putNullableText(ObjectNode node, String fieldName, String value) {
+ if (hasText(value)) {
+ node.put(fieldName, value.trim());
+ } else {
+ node.putNull(fieldName);
+ }
+ }
+
+ private String json(ObjectNode node) {
+ try {
+ return objectMapper.writeValueAsString(node);
+ } catch (JsonProcessingException exception) {
+ throw new IllegalStateException("Booking 邮件内部 JSON 无法序列化。", exception);
+ }
+ }
+
+ private String sha256(byte[] bytes) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(bytes == null ? new byte[0] : bytes));
+ } catch (NoSuchAlgorithmException exception) {
+ throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception);
+ }
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.isBlank();
+ }
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailParser.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailParser.java
new file mode 100644
index 0000000..3b7d94f
--- /dev/null
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationBookingEmailParser.java
@@ -0,0 +1,1102 @@
+package cn.nianxx.thhotel.workflows.reservation.service.impl;
+
+import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem;
+import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage;
+import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageMediaType;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.Month;
+import java.time.YearMonth;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.poi.hssf.util.HSSFColor;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.Color;
+import org.apache.poi.ss.usermodel.DataFormatter;
+import org.apache.poi.ss.usermodel.FillPatternType;
+import org.apache.poi.ss.usermodel.FormulaEvaluator;
+import org.apache.poi.ss.usermodel.IndexedColors;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.apache.poi.xssf.usermodel.XSSFColor;
+import org.springframework.stereotype.Service;
+
+/**
+ * M012 固定渠道确定性 Parser。只输出标准事实候选和安全证据,不创建任务或调用 PMS。
+ */
+@Service
+public class ReservationBookingEmailParser {
+
+ private static final ZoneId HOTEL_ZONE = ZoneId.of("Asia/Bangkok");
+ private static final int MAX_ROWS_PER_SHEET = 25_000;
+ private static final Pattern ACTION_DATE_PATTERN = Pattern.compile(
+ "(? facts = new ArrayList<>();
+ List warnings = new ArrayList<>();
+ int excelAttachmentCount = 0;
+ int attachmentIndex = 0;
+ if (message == null || message.mediaItems() == null) {
+ return new ParseOutcome(List.of(), List.of(
+ warning("NO_BOOKING_ATTACHMENT", "当前邮件没有可处理的 Excel 附件。", null)), 0);
+ }
+ for (ParsedEmlMediaItem media : message.mediaItems()) {
+ attachmentIndex++;
+ if (!isExcelAttachment(media)) {
+ continue;
+ }
+ excelAttachmentCount++;
+ parseAttachment(media, attachmentIndex, mailDate, facts, warnings);
+ }
+ facts.sort(Comparator
+ .comparing((BookingFact fact) -> fact.recognition().attachmentIndex())
+ .thenComparing(fact -> fact.recognition().sheetIndex())
+ .thenComparing(fact -> fact.recognition().rowNumber()));
+ if (excelAttachmentCount == 0) {
+ warnings.add(warning("NO_BOOKING_ATTACHMENT", "当前邮件没有可处理的 Excel 附件。", null));
+ } else if (facts.isEmpty()) {
+ warnings.add(warning("NO_CURRENT_STRICT_ROWS", "未找到可自动处理的本次整行底色记录。", null));
+ }
+ return new ParseOutcome(List.copyOf(facts), deduplicateWarnings(warnings), excelAttachmentCount);
+ }
+
+ private void parseAttachment(
+ ParsedEmlMediaItem media,
+ int attachmentIndex,
+ LocalDate mailDate,
+ List facts,
+ List warnings) {
+ try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(media.bytes()))) {
+ DataFormatter formatter = new DataFormatter(Locale.ROOT);
+ FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
+ ProfileDetection detection = detectProfile(media.fileName(), workbook, formatter, evaluator);
+ if (!detection.resolved()) {
+ warnings.add(warning(detection.warningCode(), detection.warningMessage(), null));
+ return;
+ }
+ ReservationBookingCatalog.ChannelProfile profile = detection.profile();
+ for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) {
+ Sheet sheet = workbook.getSheetAt(sheetIndex);
+ if (!matchesBusinessSheetName(profile, sheet)) {
+ continue;
+ }
+ parseSheet(
+ media.fileName(),
+ attachmentIndex,
+ profile,
+ sheet,
+ sheetIndex,
+ formatter,
+ evaluator,
+ mailDate,
+ facts,
+ warnings);
+ }
+ } catch (IOException | RuntimeException exception) {
+ warnings.add(warning("BOOKING_EXCEL_PARSE_FAILED", "Excel 附件无法安全解析,需转人工或 Agent。", null));
+ }
+ }
+
+ private void parseSheet(
+ String attachmentName,
+ int attachmentIndex,
+ ReservationBookingCatalog.ChannelProfile profile,
+ Sheet sheet,
+ int sheetIndex,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator,
+ LocalDate mailDate,
+ List facts,
+ List warnings) {
+ int firstDataRowIndex = Math.max(0, profile.headerRow());
+ int lastRowIndex = Math.min(sheet.getLastRowNum(), firstDataRowIndex + MAX_ROWS_PER_SHEET);
+ List strictCandidates = new ArrayList<>();
+ int partialFillRows = 0;
+ List rowBlocks = rowBlocks(
+ sheet,
+ firstDataRowIndex,
+ lastRowIndex,
+ profile,
+ formatter,
+ evaluator);
+ for (RowBlock block : rowBlocks) {
+ int coloredCells = coloredBusinessColumnCoverage(block.rows(), profile);
+ if (hasStrictColoredBusinessCoverage(block, profile)) {
+ List fragments = actionFragments(block.rows(), profile, formatter, evaluator);
+ String actionText = fragments.stream()
+ .map(ActionFragment::text)
+ .filter(text -> text != null && !text.isBlank())
+ .reduce((left, right) -> left + "\n" + right)
+ .orElse("");
+ ActionSelection selection = selectAction(fragments, mailDate);
+ strictCandidates.add(new RowCandidate(
+ block.anchor(),
+ actionText,
+ actionDates(actionText),
+ selection == null ? null : selection.action(),
+ selection == null ? null : selection.evidenceText(),
+ selection == null ? block.anchor().getRowNum() + 1 : selection.rowNumber()));
+ } else if (coloredCells > 0 || block.invalidContinuation()) {
+ partialFillRows++;
+ }
+ }
+ if (partialFillRows > 0) {
+ warnings.add(warning(
+ "PARTIAL_FILL_REVIEW_EVIDENCE",
+ "发现 " + partialFillRows + " 行仅部分业务列有底色,已作为复核证据而未自动建卡。",
+ null));
+ }
+ if (strictCandidates.isEmpty()) {
+ return;
+ }
+
+ List currentCandidates = strictCandidates.stream()
+ .filter(candidate -> actionMatchesMailDate(candidate.actionDates(), mailDate))
+ .toList();
+ if (currentCandidates.isEmpty() && strictCandidates.stream().allMatch(candidate -> candidate.actionDates().isEmpty())) {
+ currentCandidates = List.copyOf(strictCandidates);
+ warnings.add(warning(
+ "ACTION_DATE_UNRESOLVED",
+ "严格底色行没有可解析动作日期,已保留候选并要求人工确认。",
+ null));
+ } else if (currentCandidates.size() < strictCandidates.size()) {
+ warnings.add(warning(
+ "STALE_STRICT_CANDIDATE",
+ "同一附件存在非本次动作日期的整行底色记录,未自动重复建卡。",
+ null));
+ }
+
+ YearMonth sheetMonth = sheetMonth(sheet.getSheetName(), mailDate);
+ for (RowCandidate candidate : currentCandidates) {
+ BookingFact fact = parseRow(
+ attachmentName,
+ attachmentIndex,
+ profile,
+ sheet,
+ sheetIndex,
+ candidate,
+ formatter,
+ evaluator,
+ sheetMonth,
+ mailDate);
+ if (fact == null) {
+ warnings.add(warning(
+ "ACTION_UNRESOLVED",
+ "严格底色行的动作语义无法归一,需转 Agent 或人工确认。",
+ orderRef(profile.code(), sheetIndex, candidate.row().getRowNum() + 1)));
+ } else {
+ facts.add(fact);
+ for (String code : fact.recognition().warnings()) {
+ warnings.add(warning(code, warningMessage(code), fact.orderRef()));
+ }
+ }
+ }
+ }
+
+ private BookingFact parseRow(
+ String attachmentName,
+ int attachmentIndex,
+ ReservationBookingCatalog.ChannelProfile profile,
+ Sheet sheet,
+ int sheetIndex,
+ RowCandidate candidate,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator,
+ YearMonth sheetMonth,
+ LocalDate mailDate) {
+ String action = candidate.action();
+ if (action == null) {
+ return null;
+ }
+ Row row = candidate.row();
+ String tourCodeText = cellText(row.getCell(profile.tourCodeColumn()), formatter, evaluator);
+ String detail = cellText(row.getCell(profile.hotelDetailColumn()), formatter, evaluator);
+ String secondaryDetail = profile.secondaryDetailColumn() == null
+ ? null
+ : cellText(row.getCell(profile.secondaryDetailColumn()), formatter, evaluator);
+ // 业务已明确:Name of Group / Group Name / Tour Code / Group Code 都表示同一团号。
+ // 固定渠道的 Tour Code 列不能再被错误当作客人姓名;无法得到单一团号时保留未知并转复核。
+ String groupCode = groupCode(tourCodeText);
+ List names = List.of();
+ String actionDetail = effectiveActionDetail(action, candidate.actionEvidenceText());
+ StayRange stayRange = stayRange(actionDetail, sheetMonth);
+ if (stayRange == null) {
+ stayRange = stayRange(detail, sheetMonth);
+ }
+ List rooms = roomFacts(actionDetail);
+ if (rooms.stream().noneMatch(room -> !room.extraBed())) {
+ rooms = roomFacts(detail);
+ }
+ List reservationRooms = rooms.stream().filter(room -> !room.extraBed()).toList();
+ List traces = new ArrayList<>(extraBedTraces(rooms, reservationRooms));
+ traces.addAll(honeymoonTraces(detail));
+ int totalRoomCount = reservationRooms.stream().mapToInt(RoomFact::roomCount).sum();
+ String bookingType = reservationRooms.isEmpty() ? null : catalog.bookingType(totalRoomCount);
+ String orderRef = orderRef(profile.code(), sheetIndex, row.getRowNum() + 1);
+ LinkedHashSet factWarnings = new LinkedHashSet<>();
+ if (stayRange == null) {
+ factWarnings.add("STAY_DATES_UNRESOLVED");
+ }
+ if (reservationRooms.isEmpty()) {
+ factWarnings.add("ROOM_ITEMS_UNRESOLVED");
+ }
+ if (reservationRooms.stream().anyMatch(room -> room.roomTypeCode() == null)) {
+ factWarnings.add("ROOM_TYPE_UNRESOLVED");
+ }
+ if ("NEW_BOOKING".equals(action)) {
+ factWarnings.add("RATE_CODE_UNRESOLVED");
+ if (groupCode == null) {
+ factWarnings.add("GROUP_CODE_UNRESOLVED");
+ }
+ } else {
+ factWarnings.add("NEW_BOOKING_CHAIN_REQUIRED");
+ }
+ if (candidate.actionDates().stream().anyMatch(date ->
+ date.day() == mailDate.getDayOfMonth()
+ && date.month() == mailDate.getMonthValue()
+ && date.year() != null
+ && !Objects.equals(date.year(), mailDate.getYear()))) {
+ factWarnings.add("ACTION_DATE_YEAR_ANOMALY");
+ }
+ if (secondaryDetail != null && !secondaryDetail.isBlank()) {
+ // Secondary detail remains in SourceMessage evidence. It is intentionally not parsed again to avoid double counting.
+ }
+ RecognitionEvidence recognition = new RecognitionEvidence(
+ safeAttachmentName(attachmentName),
+ attachmentIndex,
+ profile.code(),
+ sheet.getSheetName(),
+ sheetIndex,
+ row.getRowNum() + 1,
+ candidate.actionRowNumber(),
+ "STRICT_CURRENT_CANDIDATE",
+ catalog.catalogVersion(),
+ catalog.parserVersion(),
+ rooms.stream().map(RoomFact::sourceLabel).distinct().toList(),
+ List.copyOf(factWarnings));
+ return new BookingFact(
+ orderRef,
+ action,
+ bookingType,
+ names,
+ groupCode,
+ null,
+ stayRange == null ? null : stayRange.arrivalDate(),
+ stayRange == null ? null : stayRange.departureDate(),
+ reservationRooms,
+ traces,
+ recognition);
+ }
+
+ private ProfileDetection detectProfile(
+ String fileName,
+ Workbook workbook,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator) {
+ String normalizedFileName = normalize(fileName);
+ List candidates = new ArrayList<>();
+ boolean invalidSheet = false;
+ for (ReservationBookingCatalog.ChannelProfile profile : catalog.channelProfiles()) {
+ int fileNameSignals = 0;
+ for (String signal : profile.fileNameSignals()) {
+ if (normalizedFileName.contains(normalize(signal))) {
+ fileNameSignals++;
+ }
+ }
+ int matchingSheetCount = 0;
+ boolean headerSignatureMatched = false;
+ for (int index = 0; index < workbook.getNumberOfSheets(); index++) {
+ Sheet sheet = workbook.getSheetAt(index);
+ if (!matchesBusinessSheetName(profile, sheet)) {
+ continue;
+ }
+ matchingSheetCount++;
+ headerSignatureMatched |= headerSignatureMatches(profile, sheet, formatter, evaluator);
+ }
+ if (matchingSheetCount == 0) {
+ invalidSheet |= fileNameSignals > 0;
+ continue;
+ }
+ // 固定渠道确定性解析必须同时有文件信号、合法业务 Sheet 和列级表头签名。
+ // 仅凭文件名或仅凭宽泛表头都不能进入 Parser,避免相似样表被错列解析。
+ if (fileNameSignals == 0 || !headerSignatureMatched) {
+ invalidSheet = true;
+ continue;
+ }
+ int score = fileNameSignals * 10 + (headerSignatureMatched ? 5 : 0) + matchingSheetCount;
+ candidates.add(new ProfileCandidate(profile, score));
+ }
+ if (candidates.isEmpty()) {
+ return invalidSheet
+ ? ProfileDetection.unresolved(
+ "CHANNEL_PROFILE_SHEET_INVALID",
+ "Excel 的文件名、业务 Sheet 或表头签名不能形成固定渠道确定性 Profile。")
+ : ProfileDetection.unresolved(
+ "CHANNEL_PROFILE_UNKNOWN",
+ "Excel 附件不符合当前固定渠道 Profile。");
+ }
+ int highestScore = candidates.stream().mapToInt(ProfileCandidate::score).max().orElse(0);
+ List best = candidates.stream()
+ .filter(candidate -> candidate.score() == highestScore)
+ .toList();
+ if (best.size() != 1) {
+ return ProfileDetection.unresolved(
+ "CHANNEL_PROFILE_AMBIGUOUS",
+ "Excel 同时匹配多个固定渠道 Profile,已停止确定性解析。");
+ }
+ return ProfileDetection.resolved(best.get(0).profile());
+ }
+
+ private boolean matchesBusinessSheetName(ReservationBookingCatalog.ChannelProfile profile, Sheet sheet) {
+ if (sheet == null || profile.sheetNamePatterns().isEmpty()) {
+ return false;
+ }
+ String name = sheet.getSheetName() == null ? "" : sheet.getSheetName();
+ return profile.sheetNamePatterns().stream()
+ .anyMatch(pattern -> Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE)
+ .matcher(name)
+ .matches());
+ }
+
+ private boolean headerSignatureMatches(
+ ReservationBookingCatalog.ChannelProfile profile,
+ Sheet sheet,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator) {
+ if (profile.headerSignature().isEmpty() || profile.headerCellSignatures().isEmpty()) {
+ return false;
+ }
+ Set headers = new LinkedHashSet<>();
+ int lastHeaderRow = Math.min(Math.max(0, profile.headerRow()), sheet.getLastRowNum());
+ for (int rowIndex = 0; rowIndex <= lastHeaderRow; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) {
+ continue;
+ }
+ for (int column = profile.businessColumnFrom(); column <= profile.businessColumnTo(); column++) {
+ String header = normalize(cellText(row.getCell(column), formatter, evaluator));
+ if (!header.isBlank()) {
+ headers.add(header);
+ }
+ }
+ }
+ boolean genericSignatureMatched = profile.headerSignature().stream()
+ .map(this::normalize)
+ .allMatch(signature -> headers.stream().anyMatch(header -> header.contains(signature)));
+ if (!genericSignatureMatched) {
+ return false;
+ }
+ return profile.headerCellSignatures().stream().allMatch(signature -> {
+ for (int rowIndex = 0; rowIndex <= lastHeaderRow; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) {
+ continue;
+ }
+ String header = normalize(cellText(row.getCell(signature.columnIndex()), formatter, evaluator));
+ if (header.contains(normalize(signature.signature()))) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+
+ private boolean isExcelAttachment(ParsedEmlMediaItem media) {
+ if (media == null || !SourceMessageMediaType.ATTACHMENT.code().equals(media.mediaType())) {
+ return false;
+ }
+ String name = normalize(media.fileName());
+ return name.endsWith(".XLSX") || name.endsWith(".XLS");
+ }
+
+ /**
+ * 固定渠道表格中的一条业务记录可以跨多条物理行:主行保存 Tour Code/酒店明细,后续行保存
+ * 当前 UPDATE/CANCEL。这里先按主行建立逻辑记录块,避免把后续动作行误判为“缺团号的样式残留”。
+ */
+ private List rowBlocks(
+ Sheet sheet,
+ int firstRowIndex,
+ int lastRowIndex,
+ ReservationBookingCatalog.ChannelProfile profile,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator) {
+ List blocks = new ArrayList<>();
+ Row anchor = null;
+ List rows = new ArrayList<>();
+ boolean invalidContinuation = false;
+ for (int rowIndex = firstRowIndex; rowIndex <= lastRowIndex; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ boolean anchorRow = row != null
+ && !cellText(row.getCell(profile.tourCodeColumn()), formatter, evaluator).isBlank()
+ && !cellText(row.getCell(profile.hotelDetailColumn()), formatter, evaluator).isBlank();
+ if (anchorRow) {
+ if (anchor != null) {
+ blocks.add(new RowBlock(anchor, List.copyOf(rows), invalidContinuation));
+ }
+ anchor = row;
+ rows = new ArrayList<>();
+ rows.add(row);
+ invalidContinuation = false;
+ continue;
+ }
+ if (anchor == null) {
+ continue;
+ }
+ if (rowIndex - anchor.getRowNum() > MAX_LOGICAL_RECORD_ROWS) {
+ blocks.add(new RowBlock(anchor, List.copyOf(rows), invalidContinuation));
+ anchor = null;
+ rows = new ArrayList<>();
+ invalidContinuation = false;
+ continue;
+ }
+ if (row != null && hasAnyNonWhiteBusinessBackground(row, profile)) {
+ if (isLegalContinuationRow(row, profile, formatter, evaluator)) {
+ rows.add(row);
+ } else {
+ invalidContinuation = true;
+ }
+ }
+ }
+ if (anchor != null) {
+ blocks.add(new RowBlock(anchor, List.copyOf(rows), invalidContinuation));
+ }
+ return List.copyOf(blocks);
+ }
+
+ private boolean isLegalContinuationRow(
+ Row row,
+ ReservationBookingCatalog.ChannelProfile profile,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator) {
+ Set continuationColumns = continuationColumns(profile);
+ for (int column = profile.businessColumnFrom(); column <= profile.businessColumnTo(); column++) {
+ if (continuationColumns.contains(column)) {
+ continue;
+ }
+ if (!cellText(row.getCell(column), formatter, evaluator).isBlank()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private List actionFragments(
+ List rows,
+ ReservationBookingCatalog.ChannelProfile profile,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator) {
+ LinkedHashSet columns = new LinkedHashSet<>();
+ columns.add(profile.actionColumn());
+ if (profile.statusColumn() != null) {
+ columns.add(profile.statusColumn());
+ }
+ if (profile.remarkColumn() != null) {
+ columns.add(profile.remarkColumn());
+ }
+ List fragments = new ArrayList<>();
+ for (Row row : rows) {
+ for (Integer column : columns) {
+ String text = cellText(row.getCell(column), formatter, evaluator);
+ if (!text.isBlank()) {
+ fragments.add(new ActionFragment(row.getRowNum() + 1, text));
+ }
+ }
+ }
+ return List.copyOf(fragments);
+ }
+
+ /**
+ * 动作按当前日期和原始顺序选择。这样同一记录中的旧 NEW 与本次 AMD/CXL 不会被关键词优先级
+ * 误判;同一行同一日期同时出现 NEW+CXL 时,仍由 Catalog 的 Cancel 优先级裁决。
+ */
+ private ActionSelection selectAction(List fragments, LocalDate mailDate) {
+ ActionSelection latestRecognized = null;
+ ActionSelection currentDated = null;
+ for (ActionFragment fragment : fragments) {
+ String[] lines = fragment.text().split("\\R");
+ for (int lineIndex = 0; lineIndex < lines.length; lineIndex++) {
+ String line = lines[lineIndex];
+ String action = catalog.normalizeAction(line);
+ if (action == null) {
+ continue;
+ }
+ ActionSelection selection = new ActionSelection(action, fragment.text(), fragment.rowNumber());
+ latestRecognized = selection;
+ if (actionMatchesMailDate(actionDates(line), mailDate)) {
+ currentDated = selection;
+ continue;
+ }
+ String previous = lineIndex == 0 ? "" : lines[lineIndex - 1];
+ String next = lineIndex + 1 >= lines.length ? "" : lines[lineIndex + 1];
+ if (actionMatchesMailDate(actionDates(previous + " " + line + " " + next), mailDate)) {
+ currentDated = selection;
+ }
+ }
+ }
+ return currentDated == null ? latestRecognized : currentDated;
+ }
+
+ private String effectiveActionDetail(String action, String actionEvidenceText) {
+ if (!"UPDATE_BOOKING".equals(action) || actionEvidenceText == null || actionEvidenceText.isBlank()) {
+ return actionEvidenceText;
+ }
+ Matcher matcher = AMEND_TO_PATTERN.matcher(actionEvidenceText);
+ int afterStart = -1;
+ while (matcher.find()) {
+ afterStart = matcher.end();
+ }
+ return afterStart < 0 ? actionEvidenceText : actionEvidenceText.substring(afterStart).trim();
+ }
+
+ private int coloredBusinessColumnCoverage(
+ List rows,
+ ReservationBookingCatalog.ChannelProfile profile) {
+ Set coloredColumns = new LinkedHashSet<>();
+ for (Row row : rows) {
+ if (row == null) {
+ continue;
+ }
+ for (int column = profile.businessColumnFrom(); column <= profile.businessColumnTo(); column++) {
+ if (hasNonWhiteBackground(row.getCell(column))) {
+ coloredColumns.add(column);
+ }
+ }
+ }
+ return coloredColumns.size();
+ }
+
+ /**
+ * 一条可自动处理记录必须由 anchor 行给足身份与明细列的底色;后续行只能为动作、状态或备注列
+ * 提供连续的底色。禁止通过任意多行拼凑 Tour Code、酒店明细等决定性事实。
+ */
+ private boolean hasStrictColoredBusinessCoverage(
+ RowBlock block,
+ ReservationBookingCatalog.ChannelProfile profile) {
+ if (block.invalidContinuation()) {
+ return false;
+ }
+ Set continuationColumns = continuationColumns(profile);
+ for (int column = profile.businessColumnFrom(); column <= profile.businessColumnTo(); column++) {
+ if (continuationColumns.contains(column)) {
+ int continuationColumn = column;
+ boolean colored = block.rows().stream()
+ .anyMatch(row -> hasNonWhiteBackground(row.getCell(continuationColumn)));
+ if (!colored) {
+ return false;
+ }
+ continue;
+ }
+ if (!hasNonWhiteBackground(block.anchor().getCell(column))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private Set continuationColumns(ReservationBookingCatalog.ChannelProfile profile) {
+ Set columns = new LinkedHashSet<>();
+ columns.add(profile.actionColumn());
+ if (profile.statusColumn() != null) {
+ columns.add(profile.statusColumn());
+ }
+ if (profile.remarkColumn() != null) {
+ columns.add(profile.remarkColumn());
+ }
+ return columns;
+ }
+
+ private boolean hasAnyNonWhiteBusinessBackground(
+ Row row,
+ ReservationBookingCatalog.ChannelProfile profile) {
+ for (int column = profile.businessColumnFrom(); column <= profile.businessColumnTo(); column++) {
+ if (hasNonWhiteBackground(row.getCell(column))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean hasNonWhiteBackground(Cell cell) {
+ if (cell == null) {
+ return false;
+ }
+ CellStyle style = cell.getCellStyle();
+ if (style == null || !FillPatternType.SOLID_FOREGROUND.equals(style.getFillPattern())) {
+ return false;
+ }
+ short indexed = style.getFillForegroundColor();
+ if (indexed == IndexedColors.AUTOMATIC.getIndex()
+ || indexed == IndexedColors.WHITE.getIndex()) {
+ return false;
+ }
+ Color color = style.getFillForegroundColorColor();
+ String colorText = colorText(color);
+ if (colorText == null) {
+ return indexed > 0 && indexed != IndexedColors.AUTOMATIC.getIndex();
+ }
+ String normalized = colorText.replace(":", "").toUpperCase(Locale.ROOT);
+ return !("FFFFFF".equals(normalized)
+ || "FFFFFFFF".equals(normalized)
+ || "00000000".equals(normalized));
+ }
+
+ private String colorText(Color color) {
+ if (color instanceof XSSFColor xssfColor) {
+ String argb = xssfColor.getARGBHex();
+ if (argb != null && !argb.isBlank()) {
+ return argb;
+ }
+ byte[] rgb = xssfColor.getRGB();
+ if (rgb != null) {
+ StringBuilder builder = new StringBuilder();
+ for (byte value : rgb) {
+ builder.append(String.format(Locale.ROOT, "%02X", value & 0xFF));
+ }
+ return builder.toString();
+ }
+ }
+ if (color instanceof HSSFColor hssfColor) {
+ return hssfColor.getHexString();
+ }
+ return null;
+ }
+
+ private boolean isCompletelyBlank(
+ Row row,
+ ReservationBookingCatalog.ChannelProfile profile,
+ DataFormatter formatter,
+ FormulaEvaluator evaluator) {
+ for (int column = profile.businessColumnFrom(); column <= profile.businessColumnTo(); column++) {
+ if (!cellText(row.getCell(column), formatter, evaluator).isBlank()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String cellText(Cell cell, DataFormatter formatter, FormulaEvaluator evaluator) {
+ if (cell == null) {
+ return "";
+ }
+ try {
+ return formatter.formatCellValue(cell, evaluator).trim();
+ } catch (RuntimeException exception) {
+ return formatter.formatCellValue(cell).trim();
+ }
+ }
+
+ private List actionDates(String actionText) {
+ List dates = new ArrayList<>();
+ Matcher matcher = ACTION_DATE_PATTERN.matcher(actionText == null ? "" : actionText);
+ while (matcher.find()) {
+ Integer year = matcher.group(3) == null ? null : normalizedYear(Integer.parseInt(matcher.group(3)));
+ dates.add(new ActionDate(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), year));
+ }
+ return List.copyOf(dates);
+ }
+
+ private Integer normalizedYear(int year) {
+ return year < 100 ? 2000 + year : year;
+ }
+
+ private boolean actionMatchesMailDate(List dates, LocalDate mailDate) {
+ return dates.stream().anyMatch(date ->
+ date.day() == mailDate.getDayOfMonth() && date.month() == mailDate.getMonthValue());
+ }
+
+ private String groupCode(String sourceText) {
+ if (sourceText == null || sourceText.isBlank()) {
+ return null;
+ }
+ LinkedHashSet codes = new LinkedHashSet<>();
+ for (String token : sourceText.split("[/\\n\\r\\t]+")) {
+ String normalized = token.trim();
+ if (!normalized.isEmpty() && !"-".equals(normalized)) {
+ codes.add(normalized);
+ }
+ }
+ return codes.size() == 1 ? codes.iterator().next() : null;
+ }
+
+ private StayRange stayRange(String detail, YearMonth sheetMonth) {
+ if (detail == null || detail.isBlank() || sheetMonth == null) {
+ return null;
+ }
+ Matcher full = FULL_DATE_RANGE_PATTERN.matcher(detail);
+ if (full.find()) {
+ int arrivalMonth = Integer.parseInt(full.group(1));
+ int arrivalDay = Integer.parseInt(full.group(2));
+ int departureMonth = Integer.parseInt(full.group(3));
+ int departureDay = Integer.parseInt(full.group(4));
+ try {
+ return new StayRange(
+ LocalDate.of(sheetMonth.getYear(), arrivalMonth, arrivalDay),
+ LocalDate.of(sheetMonth.getYear(), departureMonth, departureDay));
+ } catch (RuntimeException exception) {
+ return null;
+ }
+ }
+ Matcher leading = LEADING_DAY_RANGE_PATTERN.matcher(detail);
+ if (!leading.find()) {
+ return null;
+ }
+ int arrivalDay = Integer.parseInt(leading.group(1));
+ int departureDay = Integer.parseInt(leading.group(2));
+ try {
+ LocalDate arrival = sheetMonth.atDay(arrivalDay);
+ LocalDate departure = sheetMonth.atDay(departureDay);
+ if (departure.isBefore(arrival)) {
+ departure = sheetMonth.plusMonths(1).atDay(departureDay);
+ }
+ return new StayRange(arrival, departure);
+ } catch (RuntimeException exception) {
+ return null;
+ }
+ }
+
+ private List roomFacts(String detail) {
+ if (detail == null || detail.isBlank()) {
+ return List.of();
+ }
+ List extracted = new ArrayList<>();
+ Set seen = new LinkedHashSet<>();
+ Matcher bracketMatcher = BRACKET_ROOM_PATTERN.matcher(detail);
+ while (bracketMatcher.find()) {
+ addRoomFact(extracted, seen, bracketMatcher.group(1), integer(bracketMatcher.group(2)));
+ }
+ if (extracted.stream().noneMatch(room -> !room.extraBed())) {
+ Matcher plainMatcher = PLAIN_ROOM_PATTERN.matcher(detail);
+ while (plainMatcher.find()) {
+ addRoomFact(extracted, seen, plainMatcher.group(2), integer(plainMatcher.group(1)));
+ }
+ }
+ return aggregateRooms(extracted);
+ }
+
+ private void addRoomFact(List rooms, Set seen, String sourceLabel, int count) {
+ if (count <= 0 || sourceLabel == null || sourceLabel.isBlank()) {
+ return;
+ }
+ String normalizedLabel = sourceLabel.trim().replaceAll("\\s+", " ");
+ boolean extraBed = isExtraBed(normalizedLabel);
+ String roomTypeCode = extraBed ? null : catalog.normalizeRoomType(normalizedLabel);
+ String stableKey = normalizedLabel.toUpperCase(Locale.ROOT) + "|" + count;
+ if (!seen.add(stableKey)) {
+ return;
+ }
+ rooms.add(new RoomFact(
+ normalizedLabel,
+ roomTypeCode,
+ count,
+ sourcePrice(normalizedLabel),
+ extraBed));
+ }
+
+ private List aggregateRooms(List rooms) {
+ Map aggregated = new LinkedHashMap<>();
+ for (RoomFact room : rooms) {
+ String key = (room.extraBed() ? "EXTRA_BED|" : "ROOM|")
+ + normalize(room.sourceLabel())
+ + "|"
+ + Objects.toString(room.roomTypeCode(), "UNKNOWN");
+ RoomFact existing = aggregated.get(key);
+ if (existing == null) {
+ aggregated.put(key, room);
+ } else {
+ aggregated.put(key, new RoomFact(
+ existing.sourceLabel(),
+ existing.roomTypeCode(),
+ existing.roomCount() + room.roomCount(),
+ existing.sourcePrice(),
+ existing.extraBed()));
+ }
+ }
+ return List.copyOf(aggregated.values());
+ }
+
+ private List extraBedTraces(List rooms, List reservationRooms) {
+ Set mappedRoomTypes = reservationRooms.stream()
+ .map(RoomFact::roomTypeCode)
+ .filter(Objects::nonNull)
+ .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+ boolean allReservationRoomsMapped = reservationRooms.stream().allMatch(room -> room.roomTypeCode() != null);
+ String targetRoomTypeCode = allReservationRoomsMapped && mappedRoomTypes.size() == 1
+ ? mappedRoomTypes.iterator().next()
+ : null;
+ return rooms.stream()
+ .filter(RoomFact::extraBed)
+ .map(room -> new TraceFact(
+ "EXTRA_BED",
+ null,
+ targetRoomTypeCode,
+ room.roomCount(),
+ null))
+ .toList();
+ }
+
+ private List honeymoonTraces(String detail) {
+ if (detail == null || detail.isBlank()) {
+ return List.of();
+ }
+ Matcher matcher = HONEYMOON_PATTERN.matcher(detail);
+ if (!matcher.find()) {
+ return List.of();
+ }
+ int start = Math.max(0, matcher.start() - 36);
+ int end = Math.min(detail.length(), matcher.end() + 48);
+ String text = detail.substring(start, end).replaceAll("\\s+", " ").trim();
+ return List.of(new TraceFact("GENERAL", text, null, 0, null));
+ }
+
+ private boolean isExtraBed(String label) {
+ String normalized = normalize(label);
+ return normalized.contains("เตียงเสริม") || normalized.contains("EXTRA BED") || normalized.contains("加床");
+ }
+
+ private Integer sourcePrice(String label) {
+ Matcher matcher = ROOM_PRICE_SUFFIX_PATTERN.matcher(label == null ? "" : label);
+ if (!matcher.find()) {
+ return null;
+ }
+ try {
+ BigDecimal code = new BigDecimal(matcher.group(1));
+ return code.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).intValueExact();
+ } catch (RuntimeException exception) {
+ return null;
+ }
+ }
+
+ private int integer(String value) {
+ try {
+ return Integer.parseInt(value);
+ } catch (RuntimeException exception) {
+ return 0;
+ }
+ }
+
+ private YearMonth sheetMonth(String sheetName, LocalDate mailDate) {
+ String source = sheetName == null ? "" : sheetName;
+ Matcher monthYear = SHEET_MONTH_YEAR_PATTERN.matcher(source);
+ if (monthYear.find()) {
+ return YearMonth.of(Integer.parseInt(monthYear.group(2)), Integer.parseInt(monthYear.group(1)));
+ }
+ Matcher yearMonth = SHEET_YEAR_MONTH_PATTERN.matcher(source);
+ if (yearMonth.find()) {
+ return YearMonth.of(Integer.parseInt(yearMonth.group(1)), Integer.parseInt(yearMonth.group(2)));
+ }
+ String normalized = normalize(source);
+ for (Month month : Month.values()) {
+ if (normalized.contains(month.name())) {
+ return YearMonth.of(mailDate.getYear(), month);
+ }
+ }
+ Matcher chineseMonth = Pattern.compile("(\\d{1,2})月").matcher(source);
+ if (chineseMonth.find()) {
+ return YearMonth.of(mailDate.getYear(), Integer.parseInt(chineseMonth.group(1)));
+ }
+ return YearMonth.from(mailDate);
+ }
+
+ private LocalDate businessDate(Instant sentAt) {
+ return (sentAt == null ? Instant.now() : sentAt).atZone(HOTEL_ZONE).toLocalDate();
+ }
+
+ private String orderRef(String profileCode, int sheetIndex, int rowNumber) {
+ return "MAIL-" + profileCode + "-S" + (sheetIndex + 1) + "-R" + rowNumber;
+ }
+
+ private String safeAttachmentName(String fileName) {
+ if (fileName == null || fileName.isBlank()) {
+ return "unnamed.xlsx";
+ }
+ String normalized = fileName.replace('\\', '/');
+ int slash = normalized.lastIndexOf('/');
+ String baseName = slash >= 0 ? normalized.substring(slash + 1) : normalized;
+ return baseName.length() <= 180 ? baseName : baseName.substring(baseName.length() - 180);
+ }
+
+ private String normalize(String value) {
+ return value == null
+ ? ""
+ : value.toUpperCase(Locale.ROOT).replace('\u00A0', ' ').replaceAll("\\s+", " ").trim();
+ }
+
+ private ParseWarning warning(String code, String message, String orderRef) {
+ return new ParseWarning(code, message, orderRef);
+ }
+
+ private List deduplicateWarnings(List warnings) {
+ Map distinct = new LinkedHashMap<>();
+ for (ParseWarning warning : warnings) {
+ distinct.putIfAbsent(warning.code() + "|" + Objects.toString(warning.orderRef(), ""), warning);
+ }
+ return List.copyOf(distinct.values());
+ }
+
+ private String warningMessage(String code) {
+ return switch (code) {
+ case "NAMES_UNRESOLVED" -> "Name 未能从当前行可靠提取。";
+ case "STAY_DATES_UNRESOLVED" -> "入住或离店日期需要人工确认。";
+ case "ROOM_ITEMS_UNRESOLVED" -> "房型和数量需要人工确认。";
+ case "ROOM_TYPE_UNRESOLVED" -> "存在未映射到业务目录的房型。";
+ case "RATE_CODE_UNRESOLVED" -> "Rate Code 映射未配置,需要用户选择。";
+ case "GROUP_IDENTIFIERS_UNRESOLVED", "GROUP_CODE_UNRESOLVED" -> "当前行没有可确认的 Group Code。";
+ case "NEW_BOOKING_CHAIN_REQUIRED" -> "Update/Cancel 需要关联已确认的 New Booking 链。";
+ case "ACTION_DATE_YEAR_ANOMALY" -> "动作日期年份疑似 Excel 自动填充异常,未作为业务年份使用。";
+ default -> "该记录需要人工复核。";
+ };
+ }
+
+ private record RowBlock(Row anchor, List rows, boolean invalidContinuation) {
+ }
+
+ private record ProfileCandidate(ReservationBookingCatalog.ChannelProfile profile, int score) {
+ }
+
+ private record ProfileDetection(
+ ReservationBookingCatalog.ChannelProfile profile,
+ String warningCode,
+ String warningMessage) {
+
+ private static ProfileDetection resolved(ReservationBookingCatalog.ChannelProfile profile) {
+ return new ProfileDetection(profile, null, null);
+ }
+
+ private static ProfileDetection unresolved(String warningCode, String warningMessage) {
+ return new ProfileDetection(null, warningCode, warningMessage);
+ }
+
+ private boolean resolved() {
+ return profile != null;
+ }
+ }
+
+ private record ActionFragment(int rowNumber, String text) {
+ }
+
+ private record ActionSelection(String action, String evidenceText, int rowNumber) {
+ }
+
+ private record RowCandidate(
+ Row row,
+ String actionText,
+ List actionDates,
+ String action,
+ String actionEvidenceText,
+ int actionRowNumber) {
+ }
+
+ private record ActionDate(int day, int month, Integer year) {
+ }
+
+ private record StayRange(LocalDate arrivalDate, LocalDate departureDate) {
+ }
+
+ public record ParseOutcome(
+ List facts,
+ List warnings,
+ int excelAttachmentCount
+ ) {
+ }
+
+ public record BookingFact(
+ String orderRef,
+ String action,
+ String bookingType,
+ List names,
+ String groupCode,
+ String groupName,
+ LocalDate arrivalDate,
+ LocalDate departureDate,
+ List roomItems,
+ List traceItems,
+ RecognitionEvidence recognition
+ ) {
+ }
+
+ public record RoomFact(
+ String sourceLabel,
+ String roomTypeCode,
+ int roomCount,
+ Integer sourcePrice,
+ boolean extraBed
+ ) {
+ }
+
+ public record TraceFact(
+ String itemType,
+ String text,
+ String targetRoomTypeCode,
+ int extraBedRoomCount,
+ String departmentCode
+ ) {
+ }
+
+ public record RecognitionEvidence(
+ String attachmentName,
+ int attachmentIndex,
+ String profile,
+ String sheet,
+ int sheetIndex,
+ int rowNumber,
+ int actionRowNumber,
+ String selectionStatus,
+ String catalogVersion,
+ String parserVersion,
+ List sourceRoomLabels,
+ List warnings
+ ) {
+ }
+
+ public record ParseWarning(String code, String message, String orderRef) {
+ }
+}
diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java
index 893cff5..13bf458 100644
--- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java
+++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4CommandServiceImpl.java
@@ -64,15 +64,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private static final String ACTION_V4_CARD_CONFIRM = "V4_CARD_CONFIRM";
private static final String ACTION_V4_CARD_REVIEW_RESOLVE = "V4_CARD_REVIEW_RESOLVE";
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
- private static final String ACTION_V4_ROOMING_LIST_AUTO_DEF = "V4_ROOMING_LIST_AUTO_DEF";
private static final String EVENT_NEW_BOOKING = "NEW_BOOKING";
private static final String EVENT_UPDATE_BOOKING = "UPDATE_BOOKING";
private static final String EVENT_CANCEL_BOOKING = "CANCEL_BOOKING";
+ private static final String EVENT_TRACE_RESERVATION_NOTES = "TRACE_RESERVATION_NOTES";
+ private static final String EVENT_ROOMING_LIST = "ROOMING_LIST";
+ private static final String EVENT_PAYMENT = "PAYMENT";
private static final String BOOKING_TYPE_GROUP = "GROUP";
private static final String BOOKING_TYPE_FIT = "FIT";
private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE";
private static final String GROUP_BOOKING_STATUS_TEN = "TEN";
- private static final String GROUP_BOOKING_STATUS_DEF = "DEF";
private static final Map GROUP_BOOKING_STATUS_LABELS = Map.of(
"TEN", "TEN-Tentative",
"DEF", "DEF-Definite",
@@ -179,7 +180,6 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
refreshOrderTaskStatus(orderTask, now);
writeCardConfirmAudit(orderTask, card, actorId, confirmedPayloadJson, now);
- applyRoomingListGroupDefIfNeeded(orderTask, card, actorId, now);
return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id());
}
@@ -255,7 +255,6 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
refreshOrderTaskStatus(latestOrderTask, now);
writeCardReviewResolveAudit(orderTask, card, actorId, normalizedOverrides, confirmedOrderId, request, now);
- applyRoomingListGroupDefIfNeeded(latestOrderTask, card, actorId, now);
return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id());
}
@@ -932,14 +931,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
validateRoomInformationDates(finalValues, details);
validateRequiredRoomItems(finalValues.path("room_items"), details);
if (EVENT_NEW_BOOKING.equals(eventType)) {
+ validateNewBookingIdentity(finalValues, details);
validateRequiredText(finalValues, "rate_code", "room_information.final_values.rate_code", "Rate Code", details);
}
if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) {
- validateRequiredText(finalValues, "group_block_name", "room_information.final_values.group_block_name",
- "Group Block Name", details);
+ validateRequiredText(finalValues, "group_code", "room_information.final_values.group_code",
+ "Group Code", details);
}
if (BOOKING_TYPE_FIT.equals(bookingType)) {
- validateRequiredText(finalValues, "fit_name", "room_information.final_values.fit_name", "Fit Name", details);
if (breakfastIncludedFromRateCode(textAt(finalValues, "rate_code")) == null
&& isMissingOrNull(finalValues.path("breakfast_included"))) {
details.add("room_information.final_values.breakfast_included: 含早不能为空。");
@@ -965,6 +964,17 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
+ /**
+ * Tour Code / Group Name / Name of Group / Group Code 均是团号来源别名。
+ * 因而新预订以明确 Group Code 即可定位;没有团号时才要求真实的客人姓名,不能把团号伪装成 Name。
+ */
+ private void validateNewBookingIdentity(JsonNode finalValues, List details) {
+ if (hasText(textAt(finalValues, "group_code")) || hasText(textAt(finalValues, "names_text"))) {
+ return;
+ }
+ details.add("room_information.final_values.group_code: Group Code 或真实 Name(s) 至少需要一项。");
+ }
+
private void validateRequiredText(
JsonNode node,
String fieldName,
@@ -1083,7 +1093,20 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
return EVENT_NEW_BOOKING.equals(card.eventType())
|| EVENT_UPDATE_BOOKING.equals(card.eventType())
- || EVENT_CANCEL_BOOKING.equals(card.eventType());
+ || EVENT_CANCEL_BOOKING.equals(card.eventType())
+ || EVENT_TRACE_RESERVATION_NOTES.equals(card.eventType())
+ || EVENT_ROOMING_LIST.equals(card.eventType())
+ || EVENT_PAYMENT.equals(card.eventType());
+ }
+
+ /**
+ * Cancel 与三类辅助订单事件没有本次 Room proposed 变更,只确认已有订单投影。
+ */
+ private boolean isCurrentOnlyRoomInformationEvent(String eventType) {
+ return EVENT_CANCEL_BOOKING.equals(eventType)
+ || EVENT_TRACE_RESERVATION_NOTES.equals(eventType)
+ || EVENT_ROOMING_LIST.equals(eventType)
+ || EVENT_PAYMENT.equals(eventType);
}
private boolean isTraceCard(ReservationV4TaskCardSnapshot card) {
@@ -1225,9 +1248,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
model.put("event_type", eventType);
model.put("booking_type", bookingType);
model.set("current_values", EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues);
- model.set("proposed_values", EVENT_CANCEL_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : proposedValues);
+ model.set("proposed_values", isCurrentOnlyRoomInformationEvent(eventType)
+ ? objectMapper.createObjectNode()
+ : proposedValues);
model.set("final_values", finalValues);
- model.set("change_summary", changeSummary(EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues, finalValues));
+ model.set("change_summary", roomInformationChangeSummary(
+ eventType,
+ EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues,
+ finalValues));
return model;
}
@@ -1251,12 +1279,23 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
model.put("event_type", eventType);
model.put("booking_type", bookingType);
model.set("current_values", currentValues);
- model.set("proposed_values", proposedValues);
+ model.set("proposed_values", isCurrentOnlyRoomInformationEvent(eventType)
+ ? objectMapper.createObjectNode()
+ : proposedValues);
model.set("final_values", finalValues);
- model.set("change_summary", changeSummary(currentValues, finalValues));
+ model.set("change_summary", roomInformationChangeSummary(eventType, currentValues, finalValues));
return model;
}
+ private ArrayNode roomInformationChangeSummary(
+ String eventType,
+ ObjectNode currentValues,
+ ObjectNode finalValues) {
+ return isCurrentOnlyRoomInformationEvent(eventType)
+ ? objectMapper.createArrayNode()
+ : changeSummary(currentValues, finalValues);
+ }
+
private ObjectNode currentRoomInformationProjection(ReservationV4OrderTaskSnapshot orderTask) {
ObjectNode current = objectMapper.createObjectNode();
if (orderTask.orderId() == null) {
@@ -1289,134 +1328,6 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return roomInformationDisplayValues(orderTask.targetBookingType(), current, true);
}
- /**
- * Rooming List 确认后,Group 订单需要自动把 Group Booking Status 收口为 DEF。
- */
- private void applyRoomingListGroupDefIfNeeded(
- ReservationV4OrderTaskSnapshot orderTask,
- ReservationV4TaskCardSnapshot card,
- String actorId,
- LocalDateTime now) {
- if (!ReservationV4CardType.ROOMING_LIST.name().equals(card.cardType())
- || !BOOKING_TYPE_GROUP.equals(orderTask.targetBookingType())) {
- return;
- }
- ReservationV4TaskCardSnapshot roomInformationCard = latestConfirmedRoomInformationCard(orderTask);
- if (roomInformationCard == null) {
- writeRoomingListAutoDefAudit(
- orderTask,
- card,
- null,
- actorId,
- null,
- "SKIPPED_NO_ROOM_INFORMATION_PROJECTION",
- now);
- return;
- }
- ObjectNode payload = confirmedPayloadObject(roomInformationCard.confirmedPayloadJson());
- JsonNode finalValuesNode = payload.path("room_information").path("final_values");
- if (!finalValuesNode.isObject()) {
- writeRoomingListAutoDefAudit(
- orderTask,
- card,
- roomInformationCard,
- actorId,
- null,
- "SKIPPED_ROOM_INFORMATION_FINAL_VALUES_MISSING",
- now);
- return;
- }
- ObjectNode finalValues = (ObjectNode) finalValuesNode;
- String previousStatus = textAt(finalValues, "group_booking_status");
- finalValues.put("group_booking_status", GROUP_BOOKING_STATUS_DEF);
- normalizeRoomInformationDerivedFields(BOOKING_TYPE_GROUP, finalValues);
- payload.withObject("/room_information").set(
- "change_summary",
- changeSummary(payload.path("room_information").path("current_values"), finalValues));
- boolean updated = workflowRepository.updateConfirmedTaskCardPayloadWithVersion(
- roomInformationCard.hotelId(),
- roomInformationCard.id(),
- roomInformationCard.version(),
- toJson(payload),
- now);
- writeRoomingListAutoDefAudit(
- orderTask,
- card,
- roomInformationCard,
- actorId,
- previousStatus,
- updated ? "UPDATED" : "SKIPPED_VERSION_CHANGED",
- now);
- }
-
- /**
- * 查找同订单最新一张已确认 Room Information 卡,作为 Rooming List 自动 DEF 的可更新订单投影。
- */
- private ReservationV4TaskCardSnapshot latestConfirmedRoomInformationCard(ReservationV4OrderTaskSnapshot orderTask) {
- if (orderTask.orderId() == null) {
- return null;
- }
- List orderTasks = workflowRepository.findOrderTasksByOrderIds(
- orderTask.hotelId(),
- List.of(orderTask.orderId()));
- List updatableOrderTaskIds = new ArrayList<>();
- for (ReservationV4OrderTaskSnapshot candidate : orderTasks) {
- updatableOrderTaskIds.add(candidate.id());
- if (Objects.equals(candidate.id(), orderTask.id())) {
- break;
- }
- }
- if (updatableOrderTaskIds.isEmpty()) {
- return null;
- }
- ReservationV4TaskCardSnapshot latest = null;
- for (ReservationV4TaskCardSnapshot candidate : workflowRepository.findTaskCardsByOrderTaskIds(
- orderTask.hotelId(),
- updatableOrderTaskIds)) {
- if (!isUpdatableConfirmedRoomInformationCard(candidate)) {
- continue;
- }
- if (latest == null || isNewerTaskCard(candidate, latest)) {
- latest = candidate;
- }
- }
- return latest;
- }
-
- /**
- * 判断任务卡是否是已确认且具备稳定 final_values 的 Room Information 快照。
- */
- private boolean isUpdatableConfirmedRoomInformationCard(ReservationV4TaskCardSnapshot card) {
- if (card == null
- || !ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())
- || !ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
- return false;
- }
- ObjectNode confirmedPayload = confirmedPayloadObject(card.confirmedPayloadJson());
- return BOOKING_TYPE_GROUP.equals(textAt(confirmedPayload.path("room_information"), "booking_type"))
- && confirmedPayload.path("room_information").path("final_values").isObject();
- }
-
- /**
- * 在候选 Room Information 快照中选择最近一次更新的卡片。
- */
- private boolean isNewerTaskCard(ReservationV4TaskCardSnapshot candidate, ReservationV4TaskCardSnapshot current) {
- LocalDateTime candidateTime = candidate.updatedAt() == null ? candidate.createdAt() : candidate.updatedAt();
- LocalDateTime currentTime = current.updatedAt() == null ? current.createdAt() : current.updatedAt();
- if (candidateTime != null && currentTime != null && !candidateTime.equals(currentTime)) {
- return candidateTime.isAfter(currentTime);
- }
- return candidate.id() != null && current.id() != null && candidate.id() > current.id();
- }
-
- /**
- * 读取确认快照为可变 ObjectNode,解析失败或非对象时降级为空对象。
- */
- private ObjectNode confirmedPayloadObject(String confirmedPayloadJson) {
- JsonNode payload = parseJson(confirmedPayloadJson);
- return payload.isObject() ? ((ObjectNode) payload).deepCopy() : objectMapper.createObjectNode();
- }
-
private ObjectNode confirmedRoomInformationFinalValues(String confirmedPayloadJson) {
JsonNode payload = parseJson(confirmedPayloadJson);
JsonNode finalValues = payload.path("room_information").path("final_values");
@@ -1439,7 +1350,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
String locatorType,
String locatorValue,
JsonNode displayPayload) {
- if (EVENT_CANCEL_BOOKING.equals(eventType)) {
+ if (isCurrentOnlyRoomInformationEvent(eventType)) {
return objectMapper.createObjectNode();
}
JsonNode businessFields = displayPayload.path("business_fields");
@@ -1465,7 +1376,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
ObjectNode finalValues = EVENT_NEW_BOOKING.equals(eventType)
? objectMapper.createObjectNode()
: currentValues.deepCopy();
- if (!EVENT_CANCEL_BOOKING.equals(eventType)) {
+ if (!isCurrentOnlyRoomInformationEvent(eventType)) {
mergeObject(finalValues, proposedValues);
}
if (EVENT_NEW_BOOKING.equals(eventType)) {
@@ -1481,17 +1392,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
String locatorType,
String locatorValue,
JsonNode source) {
- if (BOOKING_TYPE_GROUP.equals(bookingType)) {
- if (!hasText(textAt(values, "group_block_name")) && LOCATOR_TYPE_GROUP_CODE.equals(locatorType)) {
- values.put("group_block_name", locatorValue);
+ if (!hasText(textAt(values, "names_text"))) {
+ String namesText = namesText(source);
+ if (!hasText(namesText) && BOOKING_TYPE_FIT.equals(bookingType)) {
+ namesText = firstText(textAt(source, "guest_name"), textAt(source, "fit_name"));
}
- } else if (BOOKING_TYPE_FIT.equals(bookingType)) {
- String fitName = firstText(textAt(values, "fit_name"), textAt(source, "guest_name"));
- if (!hasText(fitName)) {
- fitName = locatorValue;
- }
- if (hasText(fitName)) {
- values.put("fit_name", fitName);
+ if (hasText(namesText)) {
+ values.put("names_text", namesText);
}
}
}
@@ -1525,15 +1432,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (EVENT_CANCEL_BOOKING.equals(card.eventType())) {
return;
}
+ putNullIfMissing(finalValues, "names_text");
putNullIfMissing(finalValues, "arrival_date");
putNullIfMissing(finalValues, "departure_date");
+ if (!finalValues.has("room_items")) {
+ finalValues.putArray("room_items");
+ }
if (EVENT_NEW_BOOKING.equals(card.eventType())) {
putNullIfMissing(finalValues, "rate_code");
}
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
- if (EVENT_NEW_BOOKING.equals(card.eventType())) {
- putNullIfMissing(finalValues, "group_block_name");
- }
+ putNullIfMissing(finalValues, "group_code");
+ putNullIfMissing(finalValues, "group_name");
putNullIfMissing(finalValues, "group_booking_status");
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
putNullIfMissing(finalValues, "fit_name");
@@ -1563,6 +1473,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (source == null || !source.isObject()) {
return values;
}
+ String namesText = namesText(source);
+ if (hasText(namesText)) {
+ values.put("names_text", namesText);
+ }
+ copyScalarField(source, values, "group_code");
+ String groupName = firstText(textAt(source, "group_name"), textAt(source, "group_block_name"));
+ if (hasText(groupName)) {
+ values.put("group_name", groupName);
+ }
copyScalarField(source, values, "group_block_name");
copyScalarField(source, values, "fit_name");
copyScalarField(source, values, "arrival_date");
@@ -1573,16 +1492,34 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
copyScalarField(source, values, "breakfast_included");
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
- copyScalarField(source, values, "block_id");
copyScalarField(source, values, "group_booking_status");
copyScalarField(source, values, "group_booking_status_label");
- } else if (BOOKING_TYPE_FIT.equals(bookingType)) {
- copyScalarField(source, values, "confirmation_number");
}
+ copyScalarField(source, values, "confirmation_number");
copyRoomItems(source.path("room_items"), values);
return values;
}
+ private String namesText(JsonNode source) {
+ String existing = textAt(source, "names_text");
+ if (hasText(existing)) {
+ return existing;
+ }
+ JsonNode names = source == null ? null : source.path("names");
+ if (names != null && names.isArray()) {
+ List values = new ArrayList<>();
+ for (JsonNode name : names) {
+ if (name != null && name.isTextual() && hasText(name.asText())) {
+ values.add(name.asText().trim());
+ }
+ }
+ if (!values.isEmpty()) {
+ return String.join("\n", values);
+ }
+ }
+ return firstText(textAt(source, "fit_name"), textAt(source, "guest_name"));
+ }
+
private void copyScalarField(JsonNode source, ObjectNode target, String fieldName) {
JsonNode value = source.get(fieldName);
if (value == null || value.isMissingNode() || value.isNull() || value.isContainerNode()) {
@@ -1634,6 +1571,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (!currentValues.isObject() || !finalValues.isObject()) {
return changes;
}
+ addChangeIfDifferent(changes, "names_text", currentValues, finalValues);
+ addChangeIfDifferent(changes, "group_code", currentValues, finalValues);
+ addChangeIfDifferent(changes, "group_name", currentValues, finalValues);
addChangeIfDifferent(changes, "group_block_name", currentValues, finalValues);
addChangeIfDifferent(changes, "fit_name", currentValues, finalValues);
addChangeIfDifferent(changes, "arrival_date", currentValues, finalValues);
@@ -2203,6 +2143,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|| !ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus());
if (confirmedOrderId == null) {
if (ownershipUnresolved) {
+ if (isNewBookingOrderTask(orderTask)) {
+ return null;
+ }
throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_ORDER_ID_REQUIRED", "订单归属未解决时必须提交确认订单 ID。");
}
return orderTask.orderId();
@@ -2218,6 +2161,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return confirmedOrderId;
}
+ /**
+ * New Booking 在本期边界内尚未经过 PMS 创建,因此允许以“待生成订单”状态完成字段确认。
+ * Update/Cancel 等后续事件仍必须绑定已存在的本地订单,避免把变更应用到未知目标。
+ */
+ private boolean isNewBookingOrderTask(ReservationV4OrderTaskSnapshot orderTask) {
+ return workflowRepository.findTaskCardsByOrderTaskId(orderTask.hotelId(), orderTask.id()).stream()
+ .anyMatch(card -> EVENT_NEW_BOOKING.equals(card.eventType()));
+ }
+
private String reviewResolutionJson(
ReservationV4ReviewResolutionRequest request,
List