From 080266c6334d998d9f39d97ad131ef45633e6dec Mon Sep 17 00:00:00 2001 From: andy Date: Sun, 19 Jul 2026 10:58:58 +0700 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20V4=20=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend-to-frontend-notes.md | 2 +- ...002-v4-order-task-card-domain-model-cp2.md | 5 +- .../security-access-control-boundary.md | 2 +- .../impl/ReservationV4CommandServiceImpl.java | 14 +++ .../impl/ReservationV4QueryServiceImpl.java | 89 ++++++++++++++++--- .../ReservationV4CommandControllerTest.java | 72 +++++++++++++++ .../ReservationV4QueryControllerTest.java | 49 ++++++++++ 7 files changed, 216 insertions(+), 17 deletions(-) diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 1344eab..24b4142 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -59,7 +59,7 @@ | `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 会话接口。 | | `GET /api/reservation/source-notifications/{notificationId}` | 查询 V4 S10/S99 来源通知详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按来源通知实际酒店校验访问权;只返回通知摘要、来源邮件通知卡、会话摘要和 `availability`;不返回订单任务、业务卡、邮件正文、附件 URL 或原始 AI payload。 | | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm` | 确认 V4 订单任务卡 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`,可选 `confirmed_payload`;Basic Information 必须先确认,业务卡第一版不强制逐张顺序确认;确认后卡片 `CONFIRMED`、写 `confirmed_payload_json/confirmed_at/confirmed_by` 并锁定,重复确认返回错误;成功返回刷新后的订单任务详情。 | -| `POST /api/reservation/source-notifications/{notificationId}/ack` | 确认 V4 S10/S99 来源通知已读 / 已处理 | 必须带 Bearer token,需要 `RESERVATION_TASK_CONFIRM`,请求 JSON 带 `version`;确认后 `notification_status=ACKED`,写 `ack_by/ack_at`,成功返回刷新后的来源通知详情;该动作不创建订单、不参与订单阻塞。 | +| `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/orders/{orderId}` | 查询订单详情与任务时间线 | 必须带 Bearer token,需要 `RESERVATION_ORDER_READ`,后端按订单所属酒店做访问校验;`include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 | | `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 必须带 Bearer token,需要 `RESERVATION_TASK_READ`,后端按任务所属酒店做访问校验;以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;旧源邮件只读通知卡字段列表和 OPERA 操作列表为空;V3 结构化 S10/S99 通过 `source_message_only_result.agent_assessment`、`notification`、`manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]` 和 `unhandled_intents[]` 查看同批次未建任务的诊断信息;type-known manual review 会返回顶层 `review_status`、`review_resolution` 和 `manual_review`。 | | `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | 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 168f035..621e3ea 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 @@ -614,7 +614,7 @@ POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution - 校验通过后直接 `CONFIRMED`。 - 写业务审计。 -### 13.3 S10 通知确认 +### 13.3 S10/S99 通知确认 ```text POST /api/reservation/source-notifications/{notificationId}/ack @@ -627,8 +627,9 @@ S10/S99 已确认采用来源通知模型,不继续复用隐藏技术订单或 请求要点: - 必须带 `version` 做并发校验。 +- 仅允许 `route_code=S10/S99` 的来源通知;其它路由即使状态为 `ACK_REQUIRED` 也不能通过该接口确认。 - 确认后 `notification_status=ACKED`,写确认人和 UTC 确认时间。 -- 重复提交已确认通知应返回幂等成功或明确的已确认状态,不允许回退到 `ACK_REQUIRED`。 +- 重复提交已确认通知按幂等成功返回当前已确认状态,不新增审计,不允许回退到 `ACK_REQUIRED`。 - 返回刷新后的 `GET /api/reservation/source-notifications/{notificationId}` 详情结构。 ## 14. 旧 V3 / V2 兼容和废弃边界 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index f6d967a..4c4e1a0 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -55,7 +55,7 @@ | `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;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 订单任务所属酒店访问权 + version 并发校验 | 保持;Basic Information 前置确认,确认后卡片锁定,不返回 AI 原始 payload | 必须写业务审计,actor 使用当前登录用户 | | `POST /api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution` | `FRONTEND_USER` | M002 V4 CP2 草案,尚未实现 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 订单任务所属酒店访问权 + version 并发校验 | 必须写业务审计,记录复核修正和订单归属确认摘要 | -| `POST /api/reservation/source-notifications/{notificationId}/ack` | `FRONTEND_USER` | 已实现 M002 V4 CP6;强制 Bearer 登录 + `RESERVATION_TASK_CONFIRM` + 来源通知所属酒店访问权 + version 并发校验 | 保持;只用于 V4 S10/S99 来源通知确认已读 / 已处理,不创建订单、不参与订单阻塞 | 必须写业务审计,记录已读 / 已处理确认,actor 使用当前登录用户 | +| `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` + 酒店访问权 | 必须写业务审计和原因 | | `POST /api/reservation/tasks/{taskId}/manual-review-resolutions` | `FRONTEND_USER` | 第一版已写业务审计,但 actor 待迁移 | 登录 + `RESERVATION_MANUAL_REVIEW_RESOLVE` + 酒店访问权 | 必须写业务审计 | | `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt | 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 c9d3a18..8eb1524 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 @@ -7,6 +7,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDra import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus; @@ -118,6 +119,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId); requireHotelAccess(notification.hotelId()); + validateSourceNotificationAckRoute(notification); if (ReservationV4NotificationStatus.ACKED.name().equals(notification.notificationStatus())) { return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id()); } @@ -143,6 +145,18 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id()); } + private void validateSourceNotificationAckRoute(ReservationV4SourceNotificationSnapshot notification) { + boolean ackableRoute = ReservationAiRouteDefinition.findByRouteCode(notification.routeCode()) + .map(ReservationAiRouteDefinition::sourceMessageNotification) + .orElse(false); + if (!ackableRoute) { + throw error( + HttpStatus.CONFLICT, + "V4_SOURCE_NOTIFICATION_ROUTE_NOT_ACKABLE", + "该来源通知路由不允许通过 S10/S99 ack 接口确认。"); + } + } + private void validateCardConfirmable( ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot card) { diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java index 50e056d..a9150e6 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationV4QueryServiceImpl.java @@ -195,10 +195,10 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService Map> queueContext = findQueueContext( orderTask.hotelId(), List.of(orderTask)); - ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext); List cards = workflowRepository.findTaskCardsByOrderTaskId( orderTask.hotelId(), orderTask.id()); + ReservationV4ActionAvailabilityResult orderAvailability = orderTaskAvailability(orderTask, queueContext, cards); ReservationV4TaskCardSnapshot basicCard = findFirstCard(cards, ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null); ReservationV4TaskCardResult sourceMessageCard = findFirstCard(cards, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name()) .map(card -> toCardResult(card, orderAvailability, basicCard)) @@ -263,7 +263,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService ReservationV4SourceMessageSummaryResult sourceSummary, Map> queueContext, List cards) { - ReservationV4ActionAvailabilityResult availability = orderTaskAvailability(orderTask, queueContext); + ReservationV4ActionAvailabilityResult availability = orderTaskAvailability(orderTask, queueContext, cards); return new ReservationV4OrderTaskListItemResult( toOrderTaskSummary(orderTask), sourceSummary, @@ -395,7 +395,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService private ReservationV4ActionAvailabilityResult orderTaskAvailability( ReservationV4OrderTaskSnapshot orderTask, - Map> queueContext) { + Map> queueContext, + List cards) { ReservationV4OrderTaskSnapshot blocker = findPriorBlockingOrderTask(orderTask, queueContext); if (blocker != null) { return new ReservationV4ActionAvailabilityResult( @@ -421,16 +422,78 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService null, null); } - return new ReservationV4ActionAvailabilityResult( - false, - false, - true, - true, - false, - ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), - null, - null, - null); + return openOrderTaskAvailability(cards); + } + + private ReservationV4ActionAvailabilityResult openOrderTaskAvailability(List cards) { + List safeCards = cards == null ? List.of() : cards; + ReservationV4TaskCardSnapshot basicCard = findFirstCard( + safeCards, + ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null); + if (basicCard != null && ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(basicCard.cardStatus())) { + return readOnlyAvailability( + ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(), + null, + "V4 人工复核写接口将在后续 checkpoint 开放。"); + } + boolean hasConfirmableCard = safeCards.stream() + .anyMatch(card -> cardConfirmableAtOrderLevel(card, basicCard)); + if (hasConfirmableCard) { + return new ReservationV4ActionAvailabilityResult( + false, + false, + true, + true, + false, + ReservationV4ReadonlyReasonCode.PROCESSABLE.name(), + null, + null, + null); + } + if (basicCard != null && !ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus()) + && hasPendingBusinessCard(safeCards)) { + return new ReservationV4ActionAvailabilityResult( + true, + true, + false, + false, + false, + ReservationV4ReadonlyReasonCode.PRIOR_CARD_NOT_CONFIRMED.name(), + null, + basicCard.id().toString(), + "Basic Information 卡未确认前,业务卡只能查看。"); + } + boolean hasReviewCard = safeCards.stream() + .anyMatch(card -> ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())); + if (hasReviewCard) { + return readOnlyAvailability( + ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(), + null, + "V4 人工复核写接口将在后续 checkpoint 开放。"); + } + return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null); + } + + private boolean cardConfirmableAtOrderLevel( + ReservationV4TaskCardSnapshot card, + ReservationV4TaskCardSnapshot basicCard) { + if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) { + return false; + } + if (!ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) { + return false; + } + if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) { + return true; + } + return basicCard != null && ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus()); + } + + private boolean hasPendingBusinessCard(List cards) { + return cards.stream() + .filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) + .filter(card -> !ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) + .anyMatch(card -> ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())); } private ReservationV4ActionAvailabilityResult cardAvailability( diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java index 5f35ab7..c695684 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4CommandControllerTest.java @@ -259,6 +259,78 @@ class ReservationV4CommandControllerTest { assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1); } + @Test + void shouldAckS99SourceNotification() throws Exception { + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + HOTEL_ID, + "mail-v4-command-s99-ack-001", + "S99", + Instant.parse("2026-07-19T01:35:00Z")); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack", + notification.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notification.notification_status").value("ACKED")) + .andExpect(jsonPath("$.notification.route_code").value("S99")); + assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1); + } + + @Test + void shouldRejectUnsupportedRouteSourceNotificationAck() throws Exception { + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + HOTEL_ID, + "mail-v4-command-unsupported-ack-001", + "S88", + Instant.parse("2026-07-19T01:36:00Z")); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack", + notification.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error_code").value("V4_SOURCE_NOTIFICATION_ROUTE_NOT_ACKABLE")); + } + + @Test + void shouldTreatRepeatedSourceNotificationAckAsIdempotentWithoutExtraAudit() throws Exception { + ReservationV4SourceNotificationSnapshot notification = seedSourceNotification( + HOTEL_ID, + "mail-v4-command-repeat-ack-001", + "S10", + Instant.parse("2026-07-19T01:37:00Z")); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack", + notification.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isOk()); + performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack", + notification.id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 1 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notification.notification_status").value("ACKED")); + assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1); + } + @Test void shouldRejectV4CommandsWhenPermissionMissing() throws Exception { SeededOrderTask seeded = seedOrderTask( diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java index 400346e..52be894 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationV4QueryControllerTest.java @@ -250,6 +250,23 @@ class ReservationV4QueryControllerTest { .andExpect(content().string(not(containsString("https://private.example.test")))); } + @Test + void shouldNotExposeOrderTaskAsConfirmableWhenOnlyReviewCardsRemain() throws Exception { + ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask( + "mail-v4-query-review-only-001", + Instant.parse("2026-07-18T03:05:00Z")); + + performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.availability.read_only").value(true)) + .andExpect(jsonPath("$.availability.confirmable").value(false)) + .andExpect(jsonPath("$.availability.readonly_reason_code").value("REVIEW_API_PENDING")) + .andExpect(jsonPath("$.basic_information_card.availability.confirmable").value(false)) + .andExpect(jsonPath("$.basic_information_card.availability.readonly_reason_code") + .value("REVIEW_API_PENDING")); + } + @Test void shouldReturnV4OrderTaskListWithCardStatusFilter() throws Exception { ReservationV4OrderTaskSnapshot orderTask = seedOrderTask("mail-v4-query-list-001", @@ -478,6 +495,38 @@ class ReservationV4QueryControllerTest { return orderTask; } + private ReservationV4OrderTaskSnapshot seedReviewRequiredOrderTask(String externalMessageId, Instant receivedAt) { + SourceMessageCaptureResult source = captureSourceMessage(externalMessageId, "V4 Query Review", receivedAt, HOTEL_ID); + LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC); + ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft( + HOTEL_ID, + source.inboxId(), + 990000000000000301L, + "order-review-only", + 1, + null, + "GROUP", + "GROUP_CODE", + "GRP-V4-REVIEW-ONLY-001", + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4OrderTaskStatus.OPEN.name(), + LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), + now)); + insertCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10, + ReservationV4CardStatus.READONLY.name(), null, """ + {"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Query Review"}} + """); + insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20, + ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", """ + {"card_type":"BASIC_INFORMATION","missing_fields":["account_code"]} + """); + insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, + ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", """ + {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","missing_fields":["room_items"]} + """); + return orderTask; + } + private ReservationV4SourceNotificationSnapshot seedSourceNotification( String externalMessageId, String routeCode,