From b44ab941138847067323a73cd502ebe449fd5bab Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 21 Jul 2026 09:16:05 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0Rooming=20List=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E8=87=AA=E5=8A=A8=E7=BD=AEDEF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 10 +- ...ybatisReservationV4WorkflowRepository.java | 22 ++ .../ReservationV4WorkflowRepository.java | 10 + .../impl/ReservationV4CommandServiceImpl.java | 173 +++++++++ .../ReservationV4CommandControllerTest.java | 354 +++++++++++++++++- 5 files changed, 558 insertions(+), 11 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 783b39e..9a40d5f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -9,11 +9,11 @@ ## 1. 当前 Checkpoint -- 名称:`M002-V4-room-information-display-model-frontend` -- 状态:Frontend Done,已把 V4 任务详情页 `ROOM_INFORMATION` 卡从通用 JSON 展示改为业务表单展示。 -- 目标:前端消费后端 `display_payload.room_information` 中的 `current_values`、`proposed_values`、`final_values`、`change_summary[]` 和 `group_booking_status_options[]`,并按 `fields[]` 的 `/room_information/final_values/...` 白名单提交确认 / 复核。 -- 边界:本 checkpoint 不展示 Agent raw payload、`business_fields` 推导结果、`target_order.locator_value` 编辑项、邮件正文、附件 URL、PMS raw response;不做 Payment 附件预览、Rooming List Excel、Account + booking type Rate Code lookup 或真实 PMS / OPERA / OHIP。 -- 联调备注:`PENDING_CONFIRM` 点击“确认卡片”调用普通 confirm,提交 `confirmed_payload.room_information.final_values` 中当前 `fields[]` 可编辑字段;`REVIEW_REQUIRED` 保持同一张业务卡,按钮仍显示“确认卡片”,内部调用 `review-resolution`,`field_overrides[].field_pointer` 使用 `/room_information/final_values/...`。 +- 名称:`M002-V4-rooming-list-confirm-def-backend` +- 状态:Backend Done,已实现 V4 `ROOMING_LIST` 卡确认后 Group Booking Status 自动置 `DEF` 的后端联动。 +- 目标:普通确认或 `REVIEW_REQUIRED` 复核确认 Rooming List 卡时,如果同订单为 Group 且存在可更新的已确认 Room Information 快照,后端强制覆盖 `group_booking_status=DEF` 并保留审计;Fit 不变更。 +- 边界:本 checkpoint 不改变 Room Information 查询响应结构;不解析名单、不预览附件、不生成 Excel、不导入 PMS、不调用 M010 Rooming List 工具;不推进 Payment 附件预览、Account + booking type Rate Code lookup、真实 PMS / OPERA / OHIP 或 M011 CP4。 +- 联调备注:前端确认 `ROOMING_LIST` 时只需按卡片状态调用普通 confirm 或 `review-resolution`;后端负责自动 DEF 和 `V4_ROOMING_LIST_AUTO_DEF` 审计。没有可更新 Room Information 投影时确认仍成功,不创建不完整 Room Information,只写安全审计提示。 ## 2. 当前优先级 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java index 3624a30..092c0f9 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationV4WorkflowRepository.java @@ -342,6 +342,28 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work return updated == 1; } + /** + * 按 version 乐观锁更新已确认 V4 任务卡的确认快照,用于后续业务卡触发的订单事实派生修正。 + */ + @Override + public boolean updateConfirmedTaskCardPayloadWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String confirmedPayloadJson, + LocalDateTime updatedAt) { + int updated = taskCardMapper.update(Wrappers.lambdaUpdate() + .set(ReservationV4TaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson) + .set(ReservationV4TaskCardEntity::getUpdatedAt, updatedAt) + .setSql("version = version + 1") + .eq(ReservationV4TaskCardEntity::getHotelId, hotelId) + .eq(ReservationV4TaskCardEntity::getId, taskCardId) + .eq(ReservationV4TaskCardEntity::getVersion, expectedVersion) + .eq(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.CONFIRMED.name()) + .isNull(ReservationV4TaskCardEntity::getLogicDeletedAt)); + return updated == 1; + } + /** * 在复核场景确认 V4 订单任务归属,只更新当前订单任务的本地订单绑定和归属状态。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java index 279e459..a3d464f 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationV4WorkflowRepository.java @@ -110,6 +110,16 @@ public interface ReservationV4WorkflowRepository { String confirmedBy, LocalDateTime confirmedAt); + /** + * 按 version 乐观锁更新已确认 V4 任务卡的确认快照,用于后续业务卡触发的订单事实派生修正。 + */ + boolean updateConfirmedTaskCardPayloadWithVersion( + String hotelId, + Long taskCardId, + Long expectedVersion, + String confirmedPayloadJson, + LocalDateTime updatedAt); + /** * 在复核场景确认 V4 订单任务归属,只更新当前订单任务的本地订单绑定和归属状态。 */ 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 2c76800..e688c67 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 @@ -61,6 +61,7 @@ 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"; @@ -68,6 +69,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ 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", @@ -183,6 +185,7 @@ 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()); } @@ -258,6 +261,7 @@ 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()); } @@ -1186,6 +1190,134 @@ 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"); @@ -1944,6 +2076,47 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ now)); } + /** + * 写入 Rooming List 确认触发 Group Booking Status 自动 DEF 的安全审计摘要。 + */ + private void writeRoomingListAutoDefAudit( + ReservationV4OrderTaskSnapshot orderTask, + ReservationV4TaskCardSnapshot roomingListCard, + ReservationV4TaskCardSnapshot roomInformationCard, + String actorId, + String previousStatus, + String updateStatus, + LocalDateTime now) { + Map beforeSnapshot = new LinkedHashMap<>(); + beforeSnapshot.put("trigger", "ROOMING_LIST_CONFIRM"); + beforeSnapshot.put("v4_order_task_id", orderTask.id().toString()); + beforeSnapshot.put("rooming_list_card_id", roomingListCard.id().toString()); + beforeSnapshot.put("room_information_card_id", + roomInformationCard == null ? null : roomInformationCard.id().toString()); + beforeSnapshot.put("previous_group_booking_status", previousStatus); + Map afterSnapshot = new LinkedHashMap<>(); + afterSnapshot.put("trigger", "ROOMING_LIST_CONFIRM"); + afterSnapshot.put("v4_order_task_id", orderTask.id().toString()); + afterSnapshot.put("rooming_list_card_id", roomingListCard.id().toString()); + afterSnapshot.put("room_information_card_id", + roomInformationCard == null ? null : roomInformationCard.id().toString()); + afterSnapshot.put("update_status", updateStatus); + afterSnapshot.put("target_group_booking_status", GROUP_BOOKING_STATUS_DEF); + afterSnapshot.put("target_group_booking_status_label", GROUP_BOOKING_STATUS_LABELS.get(GROUP_BOOKING_STATUS_DEF)); + auditRepository.insertAuditLog(new ReservationAuditLogDraft( + orderTask.hotelId(), + orderTask.orderId(), + null, + null, + ACTOR_TYPE_USER, + actorId, + ACTION_V4_ROOMING_LIST_AUTO_DEF, + "Rooming List 确认触发 Group Booking Status 自动置 DEF。", + toJson(beforeSnapshot), + toJson(afterSnapshot), + now)); + } + private void writeCardReviewResolveAudit( ReservationV4OrderTaskSnapshot orderTask, ReservationV4TaskCardSnapshot card, 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 6d51810..29d2b84 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 @@ -194,6 +194,199 @@ class ReservationV4CommandControllerTest { assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2); } + @Test + void shouldSetGroupBookingStatusDefWhenConfirmingGroupRoomingListFromTen() throws Exception { + Long orderId = 990000000000080001L; + SeededOrderTask roomInformation = seedOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-ten-room-info-001", + Instant.parse("2026-07-19T01:14:00Z"), + orderId); + confirmBasicCard(roomInformation); + confirmRoomInformationBusinessCard(roomInformation, "TEN"); + SeededOrderTask roomingList = seedRoomingListOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-ten-list-001", + Instant.parse("2026-07-19T01:15:00Z"), + orderId, + "GROUP"); + confirmBasicCard(roomingList); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + roomingList.orderTask().id(), + roomingList.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")); + + String confirmedPayload = confirmedPayloadJson(roomInformation.businessCard().id()); + org.assertj.core.api.Assertions.assertThat(confirmedPayload) + .contains("\"group_booking_status\":\"DEF\"") + .contains("\"group_booking_status_label\":\"DEF-Definite\""); + assertAuditCount("V4_ROOMING_LIST_AUTO_DEF", "v4-command-admin", + roomInformation.businessCard().id().toString(), "\"update_status\":\"UPDATED\"", 1); + } + + @Test + void shouldOverrideGroupBookingStatusInqToDefWhenConfirmingGroupRoomingList() throws Exception { + Long orderId = 990000000000080002L; + SeededOrderTask roomInformation = seedOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-inq-room-info-001", + Instant.parse("2026-07-19T01:16:00Z"), + orderId); + confirmBasicCard(roomInformation); + confirmRoomInformationBusinessCard(roomInformation, "INQ"); + SeededOrderTask roomingList = seedRoomingListOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-inq-list-001", + Instant.parse("2026-07-19T01:17:00Z"), + orderId, + "GROUP"); + confirmBasicCard(roomingList); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + roomingList.orderTask().id(), + roomingList.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isOk()); + + org.assertj.core.api.Assertions.assertThat(confirmedPayloadJson(roomInformation.businessCard().id())) + .contains("\"group_booking_status\":\"DEF\"") + .contains("\"group_booking_status_label\":\"DEF-Definite\""); + } + + @Test + void shouldSetGroupBookingStatusDefWhenResolvingGroupRoomingListReview() throws Exception { + Long orderId = 990000000000080005L; + SeededOrderTask roomInformation = seedOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-review-room-info-001", + Instant.parse("2026-07-19T01:20:10Z"), + orderId); + confirmBasicCard(roomInformation); + confirmRoomInformationBusinessCard(roomInformation, "TEN"); + SeededOrderTask roomingList = seedReviewRoomingListOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-review-list-001", + Instant.parse("2026-07-19T01:20:20Z"), + orderId); + confirmBasicCard(roomingList); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + roomingList.orderTask().id(), + roomingList.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "reason": "确认 Rooming List 事项" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")); + + org.assertj.core.api.Assertions.assertThat(confirmedPayloadJson(roomInformation.businessCard().id())) + .contains("\"group_booking_status\":\"DEF\"") + .contains("\"group_booking_status_label\":\"DEF-Definite\""); + assertAuditCount("V4_ROOMING_LIST_AUTO_DEF", "v4-command-admin", + roomInformation.businessCard().id().toString(), "\"update_status\":\"UPDATED\"", 1); + } + + @Test + void shouldNotChangeGroupBookingStatusWhenConfirmingFitRoomingList() throws Exception { + Long orderId = 990000000000080003L; + SeededOrderTask roomInformation = seedOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-rooming-def-fit-room-info-001", + Instant.parse("2026-07-19T01:18:00Z"), + orderId, + "FIT", + "CONFIRMATION_NUMBER", + "FIT-V4-COMMAND-001", + ReservationV4CardType.ROOM_INFORMATION.name(), + "NEW_BOOKING", + """ + {"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","guest_name":"FIT Guest","room_items":[{"room_type_code":"KING","room_count":1}]} + """); + confirmBasicCard(roomInformation); + confirmRoomInformationBusinessCard(roomInformation, null); + String beforePayload = confirmedPayloadJson(roomInformation.businessCard().id()); + SeededOrderTask roomingList = seedRoomingListOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-fit-list-001", + Instant.parse("2026-07-19T01:19:00Z"), + orderId, + "FIT"); + confirmBasicCard(roomingList); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + roomingList.orderTask().id(), + roomingList.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isOk()); + + org.assertj.core.api.Assertions.assertThat(confirmedPayloadJson(roomInformation.businessCard().id())) + .isEqualTo(beforePayload) + .doesNotContain("\"group_booking_status\":\"DEF\""); + } + + @Test + void shouldConfirmGroupRoomingListWithoutRoomInformationProjection() throws Exception { + Long orderId = 990000000000080004L; + SeededOrderTask roomingList = seedRoomingListOrderTask( + HOTEL_ID, + "mail-v4-command-rooming-def-no-projection-001", + Instant.parse("2026-07-19T01:20:00Z"), + orderId, + "GROUP"); + confirmBasicCard(roomingList); + + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + roomingList.orderTask().id(), + roomingList.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0 + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.order_task.order_task_status").value("COMPLETED")) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")); + Integer roomInformationCount = jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM workflow_reservation_v4_task_card + WHERE hotel_id = ? + AND card_type = 'ROOM_INFORMATION' + AND source_message_id = ? + AND logic_deleted_at IS NULL + """, + Integer.class, + HOTEL_ID, + roomingList.orderTask().sourceMessageId()); + org.assertj.core.api.Assertions.assertThat(roomInformationCount).isZero(); + assertAuditCount("V4_ROOMING_LIST_AUTO_DEF", "v4-command-admin", + roomingList.businessCard().id().toString(), "\"update_status\":\"SKIPPED_NO_ROOM_INFORMATION_PROJECTION\"", 1); + } + @Test void shouldRejectBasicInformationConfirmWhenAccountCodeUnknown() throws Exception { SeededOrderTask seeded = seedOrderTask( @@ -1354,6 +1547,36 @@ class ReservationV4CommandControllerTest { .andExpect(status().isOk()); } + private void confirmRoomInformationBusinessCard(SeededOrderTask seeded, String groupBookingStatus) throws Exception { + String groupBookingStatusFragment = groupBookingStatus == null + ? "" + : """ + ,"group_booking_status": "%s" + """.formatted(groupBookingStatus); + performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "confirmed_payload": { + "room_information": { + "final_values": { + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "BAR", + "fit_name": "FIT Guest", + "breakfast_included": true, + "room_items": [{"room_type_code": "TWN", "room_count": 2}]%s + } + } + } + } + """.formatted(groupBookingStatusFragment))) + .andExpect(status().isOk()); + } + private void resolveBusinessReviewCard(SeededOrderTask seeded) throws Exception { performAuthorized(mockMvc, adminToken(), post( "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", @@ -1371,6 +1594,17 @@ class ReservationV4CommandControllerTest { .andExpect(status().isOk()); } + private String confirmedPayloadJson(Long cardId) { + return jdbcTemplate.queryForObject( + """ + SELECT confirmed_payload_json + FROM workflow_reservation_v4_task_card + WHERE id = ? + """, + String.class, + cardId); + } + private SeededOrderTask seedOrderTask(String hotelId, String externalMessageId, Instant receivedAt) { return seedOrderTask(hotelId, externalMessageId, receivedAt, null); } @@ -1391,6 +1625,30 @@ class ReservationV4CommandControllerTest { Instant receivedAt, Long orderId, String businessDisplayPayloadJson) { + return seedOrderTaskWithBusinessCard( + hotelId, + externalMessageId, + receivedAt, + orderId, + "GROUP", + "GROUP_CODE", + "GRP-V4-COMMAND-001", + ReservationV4CardType.ROOM_INFORMATION.name(), + "NEW_BOOKING", + businessDisplayPayloadJson); + } + + private SeededOrderTask seedOrderTaskWithBusinessCard( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String targetBookingType, + String targetLocatorType, + String targetLocatorValue, + String businessCardType, + String businessEventType, + String businessDisplayPayloadJson) { SourceMessageCaptureResult source = captureSourceMessage( hotelId, externalMessageId, @@ -1404,9 +1662,9 @@ class ReservationV4CommandControllerTest { "order-command-" + externalMessageId, 1, orderId, - "GROUP", - "GROUP_CODE", - "GRP-V4-COMMAND-001", + targetBookingType, + targetLocatorType, + targetLocatorValue, ReservationV4TargetResolutionStatus.RESOLVED.name(), ReservationV4OrderTaskStatus.OPEN.name(), LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC), @@ -1422,11 +1680,34 @@ class ReservationV4CommandControllerTest { {"card_type":"BASIC_INFORMATION","order_ref":"ORDER-COMMAND","basic_information":{"account_code":"QBD_TRAVEL","manual_review":null}} """); ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId, - ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, + businessCardType, businessEventType, 1, 30, ReservationV4CardStatus.PENDING_CONFIRM.name(), null, businessDisplayPayloadJson); return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard); } + private SeededOrderTask seedRoomingListOrderTask( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String targetBookingType) { + String locatorType = "GROUP".equals(targetBookingType) ? "GROUP_CODE" : "CONFIRMATION_NUMBER"; + String locatorValue = "GROUP".equals(targetBookingType) ? "GRP-V4-ROOMING-001" : "FIT-V4-ROOMING-001"; + return seedOrderTaskWithBusinessCard( + hotelId, + externalMessageId, + receivedAt, + orderId, + targetBookingType, + locatorType, + locatorValue, + ReservationV4CardType.ROOMING_LIST.name(), + "ROOMING_LIST", + """ + {"card_type":"ROOMING_LIST","event_type":"ROOMING_LIST","business_fields":{"attachment_ids":["att-rooming-list-001"],"rooming_list_action":"CONFIRM_RECEIVED"}} + """); + } + private SeededOrderTask seedReviewOrderTask( String hotelId, String externalMessageId, @@ -1501,6 +1782,32 @@ class ReservationV4CommandControllerTest { String businessStatus, String businessDisplayPayloadJson, String businessValidationErrorsJson) { + return seedReviewOrderTaskWithBusinessCard( + hotelId, + externalMessageId, + receivedAt, + orderId, + targetResolutionStatus, + basicStatus, + businessStatus, + ReservationV4CardType.ROOM_INFORMATION.name(), + "NEW_BOOKING", + businessDisplayPayloadJson, + businessValidationErrorsJson); + } + + private SeededOrderTask seedReviewOrderTaskWithBusinessCard( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId, + String targetResolutionStatus, + String basicStatus, + String businessStatus, + String businessCardType, + String businessEventType, + String businessDisplayPayloadJson, + String businessValidationErrorsJson) { SourceMessageCaptureResult source = captureSourceMessage( hotelId, externalMessageId, @@ -1532,11 +1839,34 @@ class ReservationV4CommandControllerTest { {"card_type":"BASIC_INFORMATION","order_ref":"ORDER-REVIEW","basic_information":{"account_code":null,"manual_review":true,"missing_fields":["/basic_information/account_code"]}} """); ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId, - ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30, + businessCardType, businessEventType, 1, 30, businessStatus, reviewStatusFor(businessStatus), businessDisplayPayloadJson, businessValidationErrorsJson); return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard); } + private SeededOrderTask seedReviewRoomingListOrderTask( + String hotelId, + String externalMessageId, + Instant receivedAt, + Long orderId) { + return seedReviewOrderTaskWithBusinessCard( + hotelId, + externalMessageId, + receivedAt, + orderId, + orderId == null + ? ReservationV4TargetResolutionStatus.UNRESOLVED.name() + : ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardType.ROOMING_LIST.name(), + "ROOMING_LIST", + """ + {"card_type":"ROOMING_LIST","event_type":"ROOMING_LIST","business_fields":{"attachment_ids":["att-rooming-list-review-001"],"rooming_list_action":"CONFIRM_RECEIVED"}} + """, + null); + } + private String reviewStatusFor(String cardStatus) { return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(cardStatus) ? ReservationReviewStatus.PENDING.name() @@ -1637,6 +1967,15 @@ class ReservationV4CommandControllerTest { } private void assertAuditCount(String action, String actorId, String snapshotFragment, int expectedCount) { + assertAuditCount(action, actorId, snapshotFragment, null, expectedCount); + } + + private void assertAuditCount( + String action, + String actorId, + String snapshotFragment, + String secondSnapshotFragment, + int expectedCount) { Integer count = jdbcTemplate.queryForObject( """ SELECT COUNT(*) @@ -1645,12 +1984,15 @@ class ReservationV4CommandControllerTest { AND action = ? AND actor_id = ? AND after_snapshot_json LIKE ? + AND (? IS NULL OR after_snapshot_json LIKE ?) """, Integer.class, HOTEL_ID, action, actorId, - "%" + snapshotFragment + "%"); + "%" + snapshotFragment + "%", + secondSnapshotFragment, + secondSnapshotFragment == null ? null : "%" + secondSnapshotFragment + "%"); org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount); }