From fd027f124c45cf05e47518b78f4554f3e1cf02e3 Mon Sep 17 00:00:00 2001 From: andy Date: Sat, 11 Jul 2026 23:18:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20M002=20V3=20P0=20=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=A0=A1=E9=AA=8C=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../任务卡前端字段变更与路由说明_3.0_to_current.md | 6 +- .../prompt_skill_p1_p2_reassessment_2026-07-11.md | 4 +- .../M002-order-task-workflow-v3.md | 4 +- .../ReservationAiTaskIntakeServiceImpl.java | 506 ++++++++++++++++-- .../ReservationTaskWorkflowServiceImpl.java | 7 +- .../SuperAgentTaskResultControllerTest.java | 75 ++- ...gentTaskResultP0FixtureRegressionTest.java | 208 +++++++ 7 files changed, 740 insertions(+), 70 deletions(-) diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md index d27a112..0c5bdf5 100644 --- a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更与路由说明_3.0_to_current.md @@ -1,8 +1,8 @@ # 任务卡前端字段变更与路由说明(3.0 → 当前版) -> 对比基线:`任务卡前端展示字段表 3.0.xlsx` -> P0 契约基线:2026-07-11 00:32:56(Asia/Shanghai) -> `booking-desk-event.skill` SHA-256:`64063a6d24c2e70bb0d9d557923fdbecb9e133c1ea5971f3858df03469e48693` +> 对比基线:`任务卡前端展示字段表 3.0.xlsx` +> P0 契约基线:2026-07-11 00:32:56(Asia/Shanghai) +> `booking-desk-event.skill` SHA-256:`64063a6d24c2e70bb0d9d557923fdbecb9e133c1ea5971f3858df03469e48693` > `归档.zip` SHA-256:`ebf3806cbdf1605d3a023fecff76654ba228c4d5eaf1cf68b609633bb07520cb` > 文档定位:本文是生产 Prompt、Skill 和 references 的派生开发交接件,不是覆盖上游的第二份规则源。如本文与生产契约不一致,adapter 必须 fail closed,停止使用冲突路由并修复交接件;不得声称本文自动覆盖 Prompt/Skill。 diff --git a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md index 7ce38e9..ecb8ef2 100644 --- a/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md +++ b/docs/import/20260711/开发交付_P0冻结基线_2026-07-11/04_Known_Issues_非契约/prompt_skill_p1_p2_reassessment_2026-07-11.md @@ -1,7 +1,7 @@ # Prompt / Skill P1、P2 重新评估(P0 后现状) -评估日期:2026-07-11 -评估对象:当前 Main Prompt、`booking-desk-event` Skill、全部生产 references、README、P0 fixtures/validator、开发交接 MD/Excel 与 ADR-001。 +评估日期:2026-07-11 +评估对象:当前 Main Prompt、`booking-desk-event` Skill、全部生产 references、README、P0 fixtures/validator、开发交接 MD/Excel 与 ADR-001。 评估性质:只读复核;本报告没有修改生产规则。 ## 1. 结论先说 diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index d8d6988..ac75fe6 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -6,7 +6,7 @@ | --- | --- | | 文档版本 | 0.1 | | 日期 | 2026-07-11 | -| 状态 | 0711 P0 基线确认版;后端已完成 M002 V3 CP1-CP5 入站、路由持久化、列表 / 详情展示和同卡复核解阻第一版 | +| 状态 | 0711 P0 基线确认版;后端已完成 M002 V3 CP1-CP6 入站、路由持久化、列表 / 详情展示、同卡复核解阻和 P0 fixtures 回归第一版 | | 适用范围 | SourceMessage 之后的 SuperAgent 输出适配、任务路由、只读通知卡、人工复核同卡解阻、前后端协作边界 | | 主要读者 | 产品、后端、前端、测试、SuperAgent 对接方、后续协作 agent | @@ -288,7 +288,7 @@ Agent payload 不可变。本系统在同一张卡上维护复核状态: - 订单归属确认可作为复核解阻的一部分保存;当前第一版只允许确认当前任务所属订单,不开放普通任务随意切换订单能力。 - 全部缺失字段、订单归属、目录值和依赖校验通过后,才进入 Preflight / READY。 -当前 CP6 兼容说明:0711 P0 fixtures 中 `room_items[]` 已经是新结构,但后端第一版字段矩阵仍使用 `extracted_fields.room_type`、`extracted_fields.room_quantity`、`extracted_fields.pms_room_type_code` 等扁平字段。入站 payload 保留原始 `room_items[]`;后端在字段读取、展示、校验和确认 payload 组装时,从第一条 `room_items[]` 做扁平字段别名解析。解阻接口接受 `/extracted_fields/room_items/{index}/pms_room_type_code`,响应中的矩阵 `field_path` 仍返回当前后端字段 `extracted_fields.pms_room_type_code`。后续字段矩阵整体迁移时再取消这层兼容。 +当前 CP6 兼容说明:0711 P0 fixtures 中 `room_items[]` 已经是新结构,但后端第一版字段矩阵仍使用 `extracted_fields.room_type`、`extracted_fields.room_quantity`、`extracted_fields.pms_room_type_code` 等扁平字段。入站 payload 保留原始 `room_items[]`;后端在字段读取、展示、校验和确认 payload 组装时,从第一条 `room_items[]` 做扁平字段别名解析。解阻接口第一版仅接受 `/extracted_fields/room_items/0/pms_room_type_code` 兼容到当前扁平矩阵字段,其他数组下标不会被自动 alias;响应中的矩阵 `field_path` 仍返回当前后端字段 `extracted_fields.pms_room_type_code`。后续字段矩阵整体迁移时再取消这层兼容。 ### 9.3 后端第一版接口 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 e3479d8..b9c9dee 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 @@ -36,7 +36,9 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.HexFormat; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -65,6 +67,86 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta private static final int LENGTH_256 = 256; private static final int LENGTH_512 = 512; private static final int MAX_QUEUE_ORDER_RETRY = 5; + private static final List V3_SOURCE_MESSAGE_KEYS = List.of( + "source_message_id", + "subject", + "from", + "cc", + "received_at", + "source_channel"); + private static final List V3_MAIN_OUTCOME_KEYS = List.of( + "source_message", + "route_code", + "handler_type", + "result_type", + "current_or_history", + "agent_assessment", + "notification", + "manual_review"); + private static final List V3_AGENT_ASSESSMENT_KEYS = List.of( + "status", + "reason_code", + "automation_action"); + private static final List V3_NOTIFICATION_KEYS = List.of( + "required", + "notification_type", + "show_source_message", + "requires_user_decision", + "visible_message"); + private static final List V3_REVIEW_KEYS = List.of( + "reason_code", + "visible_reason", + "review_record_type", + "missing_fields", + "blocking_points", + "conflicting_points", + "suggested_human_actions", + "evidence_to_check", + "known_fields"); + private static final List V3_BUSINESS_ROOT_KEYS = List.of( + "source_message", + "message_events", + "case_candidates", + "extraction_warnings", + "unhandled_current_intents"); + private static final List V3_EVENT_REQUIRED_KEYS = List.of( + "event_type", + "event_role", + "current_or_history", + "source_event_index", + "case_keys", + "relevant_message_excerpt", + "attachments", + "file_references", + "context_used", + "extracted_fields", + "manual_review"); + private static final List V3_CASE_KEY_KEYS = List.of( + "group_code", + "confirmation_number", + "reservation_number", + "block_code"); + private static final List V3_REVIEW_ARRAY_KEYS = List.of( + "missing_fields", + "blocking_points", + "conflicting_points", + "suggested_human_actions", + "evidence_to_check"); + private static final Set V3_ACTIVE_EVENT_TYPES = Set.of( + "New Booking", + "Update Booking / Amendment", + "Cancel Booking", + "Cancel Allotment", + "Voucher Received", + "Payment Evidence", + "Rooming List", + "AMEND GROUP CODE", + "Invoice Generation", + "Invoice Received", + "Payment Notice", + "Trace", + "Manual RateCode", + "TA RECORDER"); private final ObjectMapper objectMapper; private final SourceMessageInboxRepository sourceMessageInboxRepository; @@ -192,7 +274,11 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta String requestId, String defaultHotelId, String rawBody) { - String routeCode = requireText(textAt(root, "route_code"), "route_code", LENGTH_64); + String routeCode = trimToNull(textAt(root, "route_code")); + if (routeCode == null) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 route_code 缺失。"); + } + validateLength(routeCode, "route_code", LENGTH_64); ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode) .filter(ReservationAiRouteDefinition::sourceMessageNotification) .orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 route_code 无效。")); @@ -226,6 +312,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta String requestId, String defaultHotelId, String rawBody) { + V3EventContractIssue rootIssue = inspectV3BusinessRootIssue(root); + if (rootIssue != null) { + throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", rootIssue.message()); + } ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId); SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot(); String hotelId = sourceMessage.hotelId(); @@ -277,7 +367,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta for (int index = 0; index < messageEvents.size(); index++) { JsonNode event = messageEvents.get(index); int arrayIndex = index + 1; - V3EventContractIssue contractIssue = inspectV3EventContractIssue(event); + V3EventContractIssue contractIssue = inspectV3EventContractIssue(event, messageEvents); if (contractIssue != null) { responseItems.add(createAdapterContractErrorTransition( hotelId, @@ -336,11 +426,34 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta ); } + /** + * 校验 V3 业务根的批次级 P0 结构;单个事件内容错误由 event 级 transition 承接。 + */ + private V3EventContractIssue inspectV3BusinessRootIssue(JsonNode root) { + if (!hasExactFields(root, V3_BUSINESS_ROOT_KEYS) + || !root.path("message_events").isArray() + || !root.path("case_candidates").isArray() + || !root.path("extraction_warnings").isArray() + || !root.path("unhandled_current_intents").isArray()) { + return new V3EventContractIssue("BUSINESS_ROOT_CONTRACT_INVALID", "V3 业务根结构不符合 0711 P0 契约。"); + } + V3EventContractIssue sourceMessageIssue = inspectV3SourceMessageIssue(root.path("source_message")); + if (sourceMessageIssue != null) { + return sourceMessageIssue; + } + return null; + } + /** * 校验结构化 S10/S99 的最小形态,避免把联合值或空复核误收为正常通知。 */ private void validateV3SourceMessageNotificationShape(JsonNode root, ReservationAiRouteDefinition route) { - if (!"main_agent_outcome".equals(textAt(root, "handler_type")) + V3EventContractIssue sourceMessageIssue = inspectV3SourceMessageIssue(root.path("source_message")); + if (!hasExactFields(root, V3_MAIN_OUTCOME_KEYS) + || sourceMessageIssue != null + || !hasExactFields(root.path("agent_assessment"), V3_AGENT_ASSESSMENT_KEYS) + || !hasExactFields(root.path("notification"), V3_NOTIFICATION_KEYS) + || !"main_agent_outcome".equals(textAt(root, "handler_type")) || !AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type")) || !"current".equals(textAt(root, "current_or_history"))) { throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 根结构字段无效。"); @@ -379,23 +492,115 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta * 校验 S99 main_agent_entry_review 的 P0 九字段结构。 */ private V3EventContractIssue inspectV3MainAgentReviewIssue(JsonNode manualReview) { - if (!manualReview.isObject()) { - return new V3EventContractIssue("MAIN_AGENT_REVIEW_INVALID", "manual_review 必须是对象。"); - } - if (!"main_agent_entry_review".equals(textAt(manualReview, "review_record_type")) - || trimToNull(textAt(manualReview, "reason_code")) == null - || trimToNull(textAt(manualReview, "visible_reason")) == null - || !manualReview.path("known_fields").isObject() - || !manualReview.path("missing_fields").isArray() - || !manualReview.path("blocking_points").isArray() - || !manualReview.path("conflicting_points").isArray() - || !manualReview.path("suggested_human_actions").isArray() - || !manualReview.path("evidence_to_check").isArray()) { - return new V3EventContractIssue("MAIN_AGENT_REVIEW_INCOMPLETE", "manual_review 九字段不完整。"); + return inspectV3ReviewIssue( + manualReview, + "main_agent_entry_review", + null, + "MAIN_AGENT_REVIEW_INVALID", + "MAIN_AGENT_REVIEW_INCOMPLETE", + "MAIN_AGENT_REVIEW_MISSING_FIELD_POINTER_INVALID"); + } + + /** + * 校验 V3 SourceMessage 元数据;source_message_id 缺失由入口预检查返回 typed infrastructure error。 + */ + private V3EventContractIssue inspectV3SourceMessageIssue(JsonNode sourceMessage) { + if (!hasExactFields(sourceMessage, V3_SOURCE_MESSAGE_KEYS) + || trimToNull(textAt(sourceMessage, "source_message_id")) == null + || !isStringOrNull(sourceMessage.path("subject")) + || !isStringOrNull(sourceMessage.path("from")) + || !isStringArray(sourceMessage.path("cc"), false) + || !isStringOrNull(sourceMessage.path("received_at")) + || !"Email".equals(textAt(sourceMessage, "source_channel"))) { + return new V3EventContractIssue("SOURCE_MESSAGE_CONTRACT_INVALID", "source_message 结构不符合 0711 P0 契约。"); } return null; } + /** + * 校验 V3 单个 message_event 的基础形态;缺字段时 fail closed,不猜测任务含义。 + */ + private V3EventContractIssue inspectV3BusinessEventShapeIssue(JsonNode event) { + if (event == null || !event.isObject() || !hasRequiredFields(event, V3_EVENT_REQUIRED_KEYS)) { + return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 必填字段不完整。"); + } + if ((!V3_ACTIVE_EVENT_TYPES.contains(textAt(event, "event_type")) + && !"Need Manual Review".equals(textAt(event, "event_type"))) + || trimToNull(textAt(event, "event_role")) == null + || !"current".equals(textAt(event, "current_or_history")) + || trimToNull(textAt(event, "source_event_index")) == null + || inspectV3CaseKeysIssue(event.path("case_keys")) != null + || !event.path("relevant_message_excerpt").isTextual() + || !event.path("attachments").isArray() + || !event.path("file_references").isArray() + || !event.path("context_used").isObject() + || !event.path("extracted_fields").isObject()) { + return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 字段类型或基础值无效。"); + } + JsonNode manualReview = event.get("manual_review"); + if (!manualReview.isNull() && !manualReview.isObject()) { + return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event.manual_review 必须为 null 或对象。"); + } + if ("Need Manual Review".equals(textAt(event, "event_type")) && manualReview.isNull()) { + return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "Need Manual Review 必须携带 manual_review。"); + } + return null; + } + + /** + * 校验 case_keys 的 P0 四字段结构。 + */ + private V3EventContractIssue inspectV3CaseKeysIssue(JsonNode caseKeys) { + if (!hasExactFields(caseKeys, V3_CASE_KEY_KEYS)) { + return new V3EventContractIssue("CASE_KEYS_CONTRACT_INVALID", "case_keys 结构不符合 0711 P0 契约。"); + } + for (String key : V3_CASE_KEY_KEYS) { + if (!isStringOrNull(caseKeys.path(key))) { + return new V3EventContractIssue("CASE_KEYS_CONTRACT_INVALID", "case_keys 字段必须为 string 或 null。"); + } + } + return null; + } + + /** + * 复用 0711 P0 manual_review 九字段结构校验,业务事件复核还要确认缺失字段 pointer 能定位到当前事件。 + */ + private V3EventContractIssue inspectV3ReviewIssue( + JsonNode manualReview, + String expectedRecordType, + JsonNode businessEvent, + String invalidCode, + String incompleteCode, + String pointerInvalidCode) { + if (manualReview == null || !manualReview.isObject()) { + return new V3EventContractIssue(invalidCode, "manual_review 必须是对象。"); + } + if (!hasExactFields(manualReview, V3_REVIEW_KEYS) + || trimToNull(textAt(manualReview, "reason_code")) == null + || trimToNull(textAt(manualReview, "visible_reason")) == null + || !expectedRecordType.equals(textAt(manualReview, "review_record_type")) + || !manualReview.path("known_fields").isObject()) { + return new V3EventContractIssue(incompleteCode, "manual_review 九字段不完整。"); + } + for (String arrayKey : V3_REVIEW_ARRAY_KEYS) { + if (!isStringArray(manualReview.path(arrayKey), false)) { + return new V3EventContractIssue(incompleteCode, "manual_review 数组字段必须只包含字符串。"); + } + } + if (businessEvent != null) { + for (JsonNode pointer : manualReview.path("missing_fields")) { + String pointerValue = pointer.isTextual() ? pointer.asText() : null; + if (!jsonPointerTargetExists(businessEvent, pointerValue)) { + return new V3EventContractIssue( + pointerInvalidCode, + "manual_review.missing_fields 必须使用能定位当前事件字段的 RFC 6901 JSON Pointer。"); + } + } + } + return null; + } + + /** * 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。 */ @@ -516,21 +721,25 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta /** * 识别 V3 event 中已经明确暴露的契约问题。命中后该 event 不建业务卡,只保存 transition。 */ - private V3EventContractIssue inspectV3EventContractIssue(JsonNode event) { + private V3EventContractIssue inspectV3EventContractIssue(JsonNode event, JsonNode messageEvents) { if (nonEmptyArray(event, "contract_errors")) { return new V3EventContractIssue("EVENT_CONTRACT_ERROR", "message_event 携带 contract_errors,第一版不建业务任务。"); } if (nonEmptyArray(event, "missing_fields")) { return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 根节点携带 missing_fields,第一版不建业务任务。"); } + V3EventContractIssue shapeIssue = inspectV3BusinessEventShapeIssue(event); + if (shapeIssue != null) { + return shapeIssue; + } JsonNode manualReview = event == null ? null : event.get("manual_review"); if (!isNullOrMissing(manualReview)) { - V3EventContractIssue manualReviewIssue = inspectV3ManualReviewContractIssue(manualReview); + V3EventContractIssue manualReviewIssue = inspectV3ManualReviewContractIssue(manualReview, event); if (manualReviewIssue != null) { return manualReviewIssue; } } - if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event)) { + if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event, messageEvents)) { return new V3EventContractIssue( "LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE", "linked_parent_release_after_child_split 关系字段不完整,第一版不建业务任务。"); @@ -541,33 +750,14 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta /** * 校验 V3 type-known manual_review 的九字段基础结构,后续同卡复核解阻会继续校验缺失字段。 */ - private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview) { - if (!manualReview.isObject()) { - return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INVALID", "manual_review 必须是对象。"); - } - String reviewRecordType = trimToNull(textAt(manualReview, "review_record_type")); - String reasonCode = trimToNull(textAt(manualReview, "reason_code")); - String visibleReason = trimToNull(textAt(manualReview, "visible_reason")); - if (!"business_event_review".equals(reviewRecordType) || reasonCode == null || visibleReason == null) { - return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。"); - } - if (!manualReview.path("known_fields").isObject() - || !manualReview.path("missing_fields").isArray() - || !manualReview.path("blocking_points").isArray() - || !manualReview.path("conflicting_points").isArray() - || !manualReview.path("suggested_human_actions").isArray() - || !manualReview.path("evidence_to_check").isArray()) { - return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。"); - } - for (JsonNode pointer : manualReview.path("missing_fields")) { - String pointerValue = pointer.isTextual() ? trimToNull(pointer.asText()) : null; - if (pointerValue == null || !pointerValue.startsWith("/")) { - return new V3EventContractIssue( - "MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID", - "manual_review.missing_fields 必须使用 RFC 6901 JSON Pointer。"); - } - } - return null; + private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview, JsonNode event) { + return inspectV3ReviewIssue( + manualReview, + "business_event_review", + event, + "MANUAL_REVIEW_CONTRACT_INVALID", + "MANUAL_REVIEW_CONTRACT_INCOMPLETE", + "MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID"); } /** @@ -581,14 +771,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta /** * 校验 parent split 候选的最小可追溯关系字段,避免只凭 relationship_type 建业务任务。 */ - private boolean validLinkedParentReleaseCandidate(JsonNode event) { + private boolean validLinkedParentReleaseCandidate(JsonNode event, JsonNode messageEvents) { JsonNode extractedFields = event == null ? null : event.path("extracted_fields"); JsonNode caseKeys = event == null ? null : event.path("case_keys"); - return "Cancel Booking".equals(trimToNull(textAt(event, "event_type"))) + if (!("Cancel Booking".equals(trimToNull(textAt(event, "event_type"))) && "group_block".equals(firstText(extractedFields, event, "cancel_object_type")) && trimToNull(textAt(caseKeys, "group_code")) != null && nonEmptyArray(extractedFields, "child_group_codes") - && nonEmptyArray(event, "related_source_event_indices"); + && nonEmptyArray(event, "related_source_event_indices"))) { + return false; + } + List childGroupCodes = stringArrayValues(extractedFields.path("child_group_codes")); + List relatedSourceEventIndices = stringArrayValues(event.path("related_source_event_indices")); + if (childGroupCodes.isEmpty() + || relatedSourceEventIndices.isEmpty() + || childGroupCodes.size() != relatedSourceEventIndices.size()) { + return false; + } + Set expectedChildGroups = new LinkedHashSet<>(childGroupCodes); + Set relatedEventIds = new LinkedHashSet<>(relatedSourceEventIndices); + if (expectedChildGroups.size() != childGroupCodes.size() + || relatedEventIds.size() != relatedSourceEventIndices.size()) { + return false; + } + Set actualChildGroups = new LinkedHashSet<>(); + for (String sourceEventIndex : relatedSourceEventIndices) { + JsonNode childEvent = findV3MessageEventBySourceEventIndex(messageEvents, sourceEventIndex); + if (childEvent == null || !"New Booking".equals(trimToNull(textAt(childEvent, "event_type")))) { + return false; + } + String childGroupCode = trimToNull(textAt(childEvent.path("case_keys"), "group_code")); + if (childGroupCode == null || !expectedChildGroups.contains(childGroupCode)) { + return false; + } + actualChildGroups.add(childGroupCode); + } + return actualChildGroups.equals(expectedChildGroups); } /** @@ -1469,10 +1687,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta * V3 或入口网关形态输入必须先有非空 source_message_id;纯 V2 兼容结构不受该预检查影响。 */ private boolean isV3SourceMessageIdentityMissing(JsonNode root) { - if (root == null || !root.has("source_message") || !isV3OrGatewayInputShape(root)) { + if (root == null || !isV3OrGatewayInputShape(root)) { return false; } - return trimToNull(textAt(root.path("source_message"), "source_message_id")) == null; + JsonNode sourceMessage = root.get("source_message"); + return sourceMessage == null + || !sourceMessage.isObject() + || trimToNull(textAt(sourceMessage, "source_message_id")) == null; } /** @@ -1713,6 +1934,189 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta return value != null && value.isArray() && value.size() > 0; } + /** + * 校验对象字段集合必须与 P0 契约完全一致。 + */ + private boolean hasExactFields(JsonNode node, List expectedFields) { + if (node == null || !node.isObject() || node.size() != expectedFields.size()) { + return false; + } + for (String fieldName : expectedFields) { + if (!node.has(fieldName)) { + return false; + } + } + return true; + } + + /** + * 校验对象必须包含指定字段;允许 P0 event 的关系扩展字段继续保留。 + */ + private boolean hasRequiredFields(JsonNode node, List requiredFields) { + if (node == null || !node.isObject()) { + return false; + } + for (String fieldName : requiredFields) { + if (!node.has(fieldName)) { + return false; + } + } + return true; + } + + /** + * 判断字段值是否为字符串或 null。 + */ + private boolean isStringOrNull(JsonNode node) { + return node != null && (node.isNull() || node.isTextual()); + } + + /** + * 判断字段值是否为字符串数组。 + */ + private boolean isStringArray(JsonNode node, boolean nonempty) { + if (node == null || !node.isArray() || (nonempty && node.isEmpty())) { + return false; + } + for (JsonNode item : node) { + if (!item.isTextual()) { + return false; + } + } + return true; + } + + /** + * 读取非空字符串数组;数组不存在或包含非字符串时返回空集合,交由调用方 fail closed。 + */ + private List stringArrayValues(JsonNode node) { + if (node == null || !node.isArray()) { + return List.of(); + } + List values = new ArrayList<>(node.size()); + for (JsonNode item : node) { + String value = item.isTextual() ? trimToNull(item.asText()) : null; + if (value == null) { + return List.of(); + } + values.add(value); + } + return values; + } + + /** + * 按 source_event_index 在同一 V3 根的 message_events 中查找事件。 + */ + private JsonNode findV3MessageEventBySourceEventIndex(JsonNode messageEvents, String sourceEventIndex) { + if (messageEvents == null || !messageEvents.isArray() || trimToNull(sourceEventIndex) == null) { + return null; + } + for (JsonNode candidate : messageEvents) { + if (sourceEventIndex.equals(trimToNull(textAt(candidate, "source_event_index")))) { + return candidate; + } + } + return null; + } + + /** + * 判断 RFC 6901 JSON Pointer 是否能定位到指定文档节点,null 值也视为有效目标。 + */ + private boolean jsonPointerTargetExists(JsonNode document, String pointer) { + List tokens = jsonPointerTokens(pointer); + if (tokens == null) { + return false; + } + JsonNode current = document; + for (String token : tokens) { + if (current == null || current.isMissingNode()) { + return false; + } + if (current.isObject()) { + if (!current.has(token)) { + return false; + } + current = current.get(token); + continue; + } + if (current.isArray()) { + Integer arrayIndex = jsonPointerArrayIndex(token); + if (arrayIndex == null || arrayIndex >= current.size()) { + return false; + } + current = current.get(arrayIndex); + continue; + } + return false; + } + return current != null && !current.isMissingNode(); + } + + /** + * 解析 RFC 6901 JSON Pointer token,拒绝非法 ~ 转义。 + */ + private List jsonPointerTokens(String pointer) { + String safePointer = trimToNull(pointer); + if (safePointer == null || !safePointer.startsWith("/")) { + return null; + } + String[] rawTokens = safePointer.substring(1).split("/", -1); + List tokens = new ArrayList<>(rawTokens.length); + for (String rawToken : rawTokens) { + String token = jsonPointerToken(rawToken); + if (token == null) { + return null; + } + tokens.add(token); + } + return tokens; + } + + /** + * 解析单个 RFC 6901 token。 + */ + private String jsonPointerToken(String rawToken) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < rawToken.length(); i++) { + char current = rawToken.charAt(i); + if (current != '~') { + builder.append(current); + continue; + } + if (i + 1 >= rawToken.length()) { + return null; + } + char escaped = rawToken.charAt(++i); + if (escaped == '0') { + builder.append('~'); + } else if (escaped == '1') { + builder.append('/'); + } else { + return null; + } + } + return builder.toString(); + } + + /** + * 解析 JSON Pointer 数组下标,负数和非数字不接受。 + */ + private Integer jsonPointerArrayIndex(String token) { + if (token == null || token.isEmpty()) { + return null; + } + for (int i = 0; i < token.length(); i++) { + if (!Character.isDigit(token.charAt(i))) { + return null; + } + } + try { + return Integer.valueOf(token); + } catch (NumberFormatException exception) { + return null; + } + } + /** * 判断文本是否有效。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index 60c58b2..1cd1945 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -898,7 +898,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (!collapsed.equals(candidates.get(0))) { candidates.add(collapsed); } - String legacyAlias = p0ReviewPointerLegacyAlias(collapsed); + String legacyAlias = p0ReviewPointerLegacyAlias(candidates.get(0), collapsed); if (legacyAlias != null && !candidates.contains(legacyAlias)) { candidates.add(legacyAlias); } @@ -908,8 +908,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl /** * 兼容 0711 P0 fixtures 中已迁移为数组结构、但当前后端矩阵仍是扁平字段的复核 pointer。 */ - private String p0ReviewPointerLegacyAlias(String collapsedFieldPath) { - if ("extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) { + private String p0ReviewPointerLegacyAlias(String plainFieldPath, String collapsedFieldPath) { + if ("extracted_fields.room_items.0.pms_room_type_code".equals(plainFieldPath) + && "extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) { return "extracted_fields.pms_room_type_code"; } return null; diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 57c4fec..1c7bb50 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -850,12 +850,19 @@ class SuperAgentTaskResultControllerTest { "message_events": [ { "event_type": "New Booking", + "event_role": "travel_agent_request", "source_event_index": "E1", "current_or_history": "current", "case_keys": { "group_code": null, - "confirmation_number": "CNF-V3-NEW-001" + "confirmation_number": "CNF-V3-NEW-001", + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Please create a new FIT reservation.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": { "booking_object_type": "FIT Reservation", "arrival_date": "2026-09-01" @@ -942,12 +949,19 @@ class SuperAgentTaskResultControllerTest { "message_events": [ { "event_type": "Trace", + "event_role": "travel_agent_request", "source_event_index": "E2", "current_or_history": "current", "case_keys": { "group_code": "GRP-V3-ERR-001", - "confirmation_number": null + "confirmation_number": null, + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Please add a trace note.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": {}, "manual_review": null } @@ -1000,12 +1014,19 @@ class SuperAgentTaskResultControllerTest { "message_events": [ { "event_type": "%s", + "event_role": "travel_agent_request", "source_event_index": "E1", "current_or_history": "current", "case_keys": { "group_code": "%s", - "confirmation_number": null + "confirmation_number": null, + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Unsupported event with explicit contract error.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": {}, "contract_errors": [ { @@ -1017,12 +1038,19 @@ class SuperAgentTaskResultControllerTest { }, { "event_type": "New Booking", + "event_role": "travel_agent_request", "source_event_index": "E2", "current_or_history": "current", "case_keys": { "group_code": null, - "confirmation_number": "CNF-V3-SIBLING-001" + "confirmation_number": "CNF-V3-SIBLING-001", + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Please create sibling FIT reservation.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": { "booking_object_type": "FIT Reservation" }, @@ -1073,12 +1101,19 @@ class SuperAgentTaskResultControllerTest { "message_events": [ { "event_type": "Trace", + "event_role": "travel_agent_request", "source_event_index": "E1", "current_or_history": "current", "case_keys": { "group_code": "GRP-V3-FRONTEND-BLOCK-001", - "confirmation_number": null + "confirmation_number": null, + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Trace event has explicit contract error.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": {}, "contract_errors": [ { @@ -1090,12 +1125,19 @@ class SuperAgentTaskResultControllerTest { }, { "event_type": "New Booking", + "event_role": "travel_agent_request", "source_event_index": "E2", "current_or_history": "current", "case_keys": { "group_code": null, - "confirmation_number": "CNF-V3-FRONTEND-BLOCK-001" + "confirmation_number": "CNF-V3-FRONTEND-BLOCK-001", + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Please create frontend-visible FIT reservation.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": { "booking_object_type": "FIT Reservation" }, @@ -1185,12 +1227,19 @@ class SuperAgentTaskResultControllerTest { "message_events": [ { "event_type": "New Booking", + "event_role": "travel_agent_request", "source_event_index": "E1", "current_or_history": "current", "case_keys": { "group_code": null, - "confirmation_number": "CNF-V3-MR-001" + "confirmation_number": "CNF-V3-MR-001", + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Manual review payload is incomplete.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": { "booking_object_type": "FIT Reservation" }, @@ -2456,18 +2505,26 @@ class SuperAgentTaskResultControllerTest { "message_events": [ { "event_type": "New Booking", + "event_role": "travel_agent_request", "source_event_index": "E1", "current_or_history": "current", "case_keys": { "group_code": null, - "confirmation_number": "%s" + "confirmation_number": "%s", + "reservation_number": null, + "block_code": null }, + "relevant_message_excerpt": "Please create a new FIT reservation with manual room type review.", + "attachments": [], + "file_references": [], + "context_used": {}, "extracted_fields": { "booking_object_type": "FIT Reservation", "arrival_date": "%s", "departure_date": "%s", "room_quantity": 2, - "room_type": "Deluxe King" + "room_type": "Deluxe King", + "pms_room_type_code": null }, "manual_review": { "review_record_type": "business_event_review", diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java index 082c93a..26d2c39 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java @@ -158,6 +158,24 @@ class SuperAgentTaskResultP0FixtureRegressionTest { .andExpect(jsonPath("$.result_type").doesNotExist()); } + @Test + void shouldReturnTypedInfrastructureInputErrorWhenV3ShapeMissesSourceMessageObject() throws Exception { + mockMvc.perform(signedPost(""" + { + "body_current": "Please cancel group HD260710A", + "message_events": [], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] + } + """, "nonce-p0-missing-source-object-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.result_type").value("infrastructure_input_error")) + .andExpect(jsonPath("$.error_code").value("missing_source_message_id")) + .andExpect(jsonPath("$.retryable").value(true)) + .andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id")); + } + @Test void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception { JsonNode validInput = caseItem( @@ -197,9 +215,57 @@ class SuperAgentTaskResultP0FixtureRegressionTest { .isEqualTo("Need Manual Review"); } + @Test + void shouldRejectS99WhenMainAgentReviewHasUnexpectedField() throws Exception { + JsonNode s99 = caseValue(fixture("main_outcomes.json").path("cases"), "legal_s99_material_unavailable"); + ObjectNode copy = s99.deepCopy(); + ((ObjectNode) copy.path("manual_review")).put("unexpected_field", "not-allowed"); + String externalId = "p0-s99-unexpected-review-field-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(withSourceMessageId(copy, externalId), "nonce-p0-s99-extra-field-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR")); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + } + + @Test + void shouldRejectV3BusinessRootWhenP0RootArraysAreMissing() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode sourceMessage = root.putObject("source_message"); + String externalId = "p0-root-arrays-missing-001"; + sourceMessage.put("source_message_id", externalId); + sourceMessage.put("subject", "P0 fixture"); + sourceMessage.put("from", "agent@example.test"); + sourceMessage.putArray("cc"); + sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00"); + sourceMessage.put("source_channel", "Email"); + root.putArray("message_events") + .add(fixture("row_multiple_derived.json").path("message_events").get(0)); + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(objectMapper.writeValueAsString(root), "nonce-p0-root-arrays-missing-001")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR")); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + } + @Test void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception { ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); + useParentSplitBusinessKeys(root, "PARENT-2608-OK", "CHILD-2608-OK-A", "CHILD-2608-OK-B"); String externalId = "p0-parent-split-001"; SourceMessageCaptureResult source = captureSourceMessage(externalId); @@ -234,6 +300,33 @@ class SuperAgentTaskResultP0FixtureRegressionTest { assertThat(transitionCount).isEqualTo(3L); } + @Test + void shouldFailClosedWhenParentSplitReferencesDoNotMatchChildEvents() throws Exception { + ObjectNode root = fixture("parent_split_two_children.json").deepCopy(); + useParentSplitBusinessKeys(root, "PARENT-2608-MIS", "CHILD-2608-MIS-A", "CHILD-2608-MIS-B"); + ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2); + ((ArrayNode) parentEvent.path("related_source_event_indices")) + .set(1, objectMapper.getNodeFactory().textNode("E_UNKNOWN_CHILD")); + String externalId = "p0-parent-split-mismatch-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-mismatch-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(3)) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.items[2].adapter_error_code") + .value("LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE")) + .andExpect(jsonPath("$.items[2].task_id").doesNotExist()); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(taskCount).isEqualTo(2L); + } + @Test void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception { ObjectNode root = fixture("row_multiple_derived.json").deepCopy(); @@ -255,6 +348,27 @@ class SuperAgentTaskResultP0FixtureRegressionTest { assertThat(taskCount).isEqualTo(3L); } + @Test + void shouldFailClosedWhenV3BusinessEventMissesRequiredField() throws Exception { + ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy(); + event.remove("attachments"); + String externalId = "p0-event-required-field-missing-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-event-required-missing-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_INCOMPLETE")) + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + } + @Test void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception { JsonNode fixture = fixture("allotment_scope.json"); @@ -286,6 +400,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest { void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception { JsonNode manualReview = fixture("manual_review_resolution.json"); ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy(); + useManualReviewGroupCode(event, "CHILD-SUITE-RESOLVE-001"); String externalId = "p0-manual-review-same-card-001"; SourceMessageCaptureResult source = captureSourceMessage(externalId); String body = businessRoot(externalId, event); @@ -347,6 +462,69 @@ class SuperAgentTaskResultP0FixtureRegressionTest { assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\""); } + @Test + void shouldFailClosedWhenManualReviewMissingFieldPointerDoesNotResolve() throws Exception { + ObjectNode event = fixture("manual_review_resolution.json") + .path("known_subtype_manual_review") + .path("event") + .deepCopy(); + ((ArrayNode) event.path("manual_review").path("missing_fields")) + .set(0, objectMapper.getNodeFactory() + .textNode("/extracted_fields/room_items/99/pms_room_type_code")); + String externalId = "p0-manual-review-invalid-pointer-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-manual-review-invalid-pointer-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accepted_count").value(1)) + .andExpect(jsonPath("$.items[0].adapter_error_code") + .value("MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID")) + .andExpect(jsonPath("$.items[0].task_id").doesNotExist()); + + Long taskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, source.inboxId()); + assertThat(taskCount).isZero(); + } + + @Test + void shouldRejectOutOfRangeRoomItemPointerWhenResolvingManualReview() throws Exception { + JsonNode manualReview = fixture("manual_review_resolution.json"); + ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy(); + useManualReviewGroupCode(event, "CHILD-SUITE-POINTER-001"); + String externalId = "p0-manual-review-resolution-pointer-001"; + captureSourceMessage(externalId); + MvcResult result = mockMvc.perform(signedPost( + businessRoot(externalId, event), + "nonce-p0-manual-review-resolution-pointer-create-001")) + .andExpect(status().isCreated()) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].task_id"); + String orderId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].order_id"); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "confirmed_order_id": "%s", + "field_overrides": [ + { + "field_pointer": "/extracted_fields/room_items/99/pms_room_type_code", + "value": "SU1" + } + ] + } + """.formatted(orderId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID")); + } + private JsonNode fixture(String fileName) throws Exception { return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile()); } @@ -397,6 +575,36 @@ class SuperAgentTaskResultP0FixtureRegressionTest { return objectMapper.writeValueAsString(root); } + private void useParentSplitBusinessKeys( + ObjectNode root, + String parentGroupCode, + String childGroupCodeOne, + String childGroupCodeTwo) { + ArrayNode events = (ArrayNode) root.path("message_events"); + useEventGroupCode((ObjectNode) events.get(0), childGroupCodeOne); + useEventGroupCode((ObjectNode) events.get(1), childGroupCodeTwo); + ((ObjectNode) events.get(0).path("extracted_fields")).put("parent_group_code", parentGroupCode); + ((ObjectNode) events.get(1).path("extracted_fields")).put("parent_group_code", parentGroupCode); + + ObjectNode parentEvent = (ObjectNode) events.get(2); + useEventGroupCode(parentEvent, parentGroupCode); + ObjectNode parentExtractedFields = (ObjectNode) parentEvent.path("extracted_fields"); + parentExtractedFields.put("parent_group_code", parentGroupCode); + ArrayNode childGroupCodes = (ArrayNode) parentExtractedFields.path("child_group_codes"); + childGroupCodes.removeAll(); + childGroupCodes.add(childGroupCodeOne); + childGroupCodes.add(childGroupCodeTwo); + } + + private void useManualReviewGroupCode(ObjectNode event, String groupCode) { + useEventGroupCode(event, groupCode); + ((ObjectNode) event.path("manual_review").path("known_fields")).put("group_code", groupCode); + } + + private void useEventGroupCode(ObjectNode event, String groupCode) { + ((ObjectNode) event.path("case_keys")).put("group_code", groupCode); + } + private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { return captureService.capture(new CaptureSourceMessageCommand( "HOTEL-TEST",