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 38bd436..9b47734 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 @@ -20,6 +20,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4Notific import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4ReadonlyReasonCode; import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4WorkbenchItemType; +import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4WorkbenchQueryRequest; @@ -426,6 +427,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService JsonNode reviewResolution = parseJson(card.reviewResolutionJson()); JsonNode validationErrors = parseJson(card.validationErrorsJson()); ReservationV4ActionAvailabilityResult availability = cardAvailability(card, orderAvailability, basicCard); + JsonNode responseValidationErrors = validationErrorsForCardResponse(card, confirmedPayload, validationErrors); + JsonNode fieldsPayload = fieldsPayloadForCard(card, displayPayload, confirmedPayload); return new ReservationV4TaskCardResult( card.id().toString(), card.cardType(), @@ -437,8 +440,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService displayPayload, confirmedPayload, reviewResolution, - validationErrors, - cardFields(card, displayPayload, validationErrors, availability), + responseValidationErrors, + cardFields(card, fieldsPayload, responseValidationErrors, availability), card.confirmedBy(), UtcTimeFormatter.toUtcOffsetDateTime(card.confirmedAt()), card.version(), @@ -447,6 +450,49 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService availability); } + /** + * 选择字段展示的数据源;Trace 确认后以用户确认快照为准,避免刷新时回到 AI 原始建议。 + */ + private JsonNode fieldsPayloadForCard( + ReservationV4TaskCardSnapshot card, + JsonNode displayPayload, + JsonNode confirmedPayload) { + if (shouldUseTraceConfirmedPayload(card, confirmedPayload)) { + return confirmedPayload; + } + return displayPayload; + } + + /** + * 选择响应校验错误;Trace 确认或复核成功后旧入站错误已经被解消,前端不再展示。 + */ + private JsonNode validationErrorsForCardResponse( + ReservationV4TaskCardSnapshot card, + JsonNode confirmedPayload, + JsonNode validationErrors) { + if (shouldUseTraceConfirmedPayload(card, confirmedPayload)) { + return objectMapper.createArrayNode(); + } + return validationErrors; + } + + /** + * 判断 Trace 卡是否已经有可用于刷新字段展示的安全确认快照。 + */ + private boolean shouldUseTraceConfirmedPayload(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) { + if (!ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) { + return false; + } + if (confirmedPayload == null || confirmedPayload.isNull() || confirmedPayload.isMissingNode()) { + return false; + } + if (!confirmedPayload.path("trace_items").isArray()) { + return false; + } + return ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus()) + || ReservationReviewStatus.RESOLVED.name().equals(card.reviewStatus()); + } + /** * 生成卡片安全展示 payload;Room Information 卡在这里补稳定业务展示模型。 */ 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 1e079ce..b027eb8 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 @@ -788,6 +788,24 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].attachment_url").doesNotExist()) .andExpect(content().string(not(containsString("SHOULD-NOT-PERSIST")))) .andExpect(content().string(not(containsString("https://oss.example.test")))); + + MvcResult refreshedResult = performAuthorized(mockMvc, adminToken(), + get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode refreshedDetail = objectMapper.readTree(refreshedResult.getResponse().getContentAsString()); + assertTraceFieldValue(refreshedDetail, "/trace_items/0/text", "Updated trace"); + assertTraceFieldValue(refreshedDetail, "/trace_items/0/department_code", "HSK"); + assertTraceFieldValue(refreshedDetail, "/trace_items/1/target_room_type_code", "RM3"); + assertTraceFieldValue(refreshedDetail, "/trace_items/1/extra_bed_room_count", 2); + assertTraceFieldValidationErrorsEmpty(refreshedDetail, "/trace_items/0/text"); + org.assertj.core.api.Assertions.assertThat(refreshedDetail.path("business_cards").get(0) + .path("validation_errors").isArray()) + .isTrue(); + org.assertj.core.api.Assertions.assertThat(refreshedDetail.path("business_cards").get(0) + .path("validation_errors").size()) + .isZero(); } @Test @@ -1570,6 +1588,107 @@ class ReservationV4CommandControllerTest { .andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist()); } + @Test + void shouldRefreshExtraBedTraceFieldsFromConfirmedPayloadAfterReviewResolution() throws Exception { + SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( + HOTEL_ID, + "mail-v4-command-review-trace-extra-bed-refresh-001", + Instant.parse("2026-07-19T01:22:29Z"), + 990000000000070127L, + ReservationV4TargetResolutionStatus.RESOLVED.name(), + ReservationV4CardStatus.PENDING_CONFIRM.name(), + ReservationV4CardStatus.REVIEW_REQUIRED.name(), + ReservationV4CardType.TRACE_RESERVATION_NOTES.name(), + "TRACE_RESERVATION_NOTES", + """ + { + "card_type":"TRACE_RESERVATION_NOTES", + "event_type":"TRACE_RESERVATION_NOTES", + "target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE"}, + "business_fields":{ + "event_type":"TRACE_RESERVATION_NOTES", + "trace_items":[ + { + "item_type":"EXTRA_BED", + "target_room_type_code":"UNKNOWN_ROOM", + "extra_bed_room_count":0, + "department_code":"SALES", + "content":"SHOULD-NOT-PERSIST-CONTENT", + "attachment_url":"https://oss.example.test/trace-review-secret.png" + } + ] + } + } + """, + """ + [ + { + "field_path": "trace_items.0.target_room_type_code", + "field_pointer": "/trace_items/0/target_room_type_code", + "message": "房型代码不在第一版目录中。", + "detail": "trace_items.0.target_room_type_code: 房型代码不在第一版目录中。" + }, + { + "field_path": "trace_items.0.extra_bed_room_count", + "field_pointer": "/trace_items/0/extra_bed_room_count", + "message": "加床房间数必须为正整数。", + "detail": "trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。" + }, + { + "field_path": "trace_items.0.department_code", + "field_pointer": "/trace_items/0/department_code", + "message": "Department Code 只允许 FO、HSK、FO+HSK。", + "detail": "trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。" + } + ] + """); + confirmBasicCard(seeded); + + performAuthorized(mockMvc, adminToken(), post( + "/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution", + seeded.orderTask().id(), + seeded.businessCard().id()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "version": 0, + "reason": "修正 Trace Extra Bed 字段", + "field_overrides": [ + {"field_pointer": "/trace_items/0/target_room_type_code", "value": "RM2"}, + {"field_pointer": "/trace_items/0/extra_bed_room_count", "value": 2}, + {"field_pointer": "/trace_items/0/department_code", "value": "FO+HSK"} + ] + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].target_room_type_code") + .value("RM2")) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].extra_bed_room_count") + .value(2)) + .andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code") + .value("FO+HSK")); + + MvcResult refreshedResult = performAuthorized(mockMvc, adminToken(), + get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id()) + .param("hotel_id", HOTEL_ID)) + .andExpect(status().isOk()) + .andReturn(); + JsonNode refreshedDetail = objectMapper.readTree(refreshedResult.getResponse().getContentAsString()); + assertTraceFieldValue(refreshedDetail, "/trace_items/0/target_room_type_code", "RM2"); + assertTraceFieldValue(refreshedDetail, "/trace_items/0/extra_bed_room_count", 2); + assertTraceFieldValue(refreshedDetail, "/trace_items/0/department_code", "FO+HSK"); + assertTraceFieldValidationErrorsEmpty(refreshedDetail, "/trace_items/0/target_room_type_code"); + assertTraceFieldValidationErrorsEmpty(refreshedDetail, "/trace_items/0/extra_bed_room_count"); + assertTraceFieldValidationErrorsEmpty(refreshedDetail, "/trace_items/0/department_code"); + org.assertj.core.api.Assertions.assertThat(refreshedDetail.path("business_cards").get(0) + .path("validation_errors").isArray()) + .isTrue(); + org.assertj.core.api.Assertions.assertThat(refreshedDetail.path("business_cards").get(0) + .path("validation_errors").size()) + .isZero(); + } + @Test void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception { SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard( @@ -2430,6 +2549,44 @@ class ReservationV4CommandControllerTest { return objectMapper.getNodeFactory().textNode("UPDATED"); } + private void assertTraceFieldValue(JsonNode detail, String pointer, String expectedValue) { + JsonNode field = traceField(detail, pointer); + org.assertj.core.api.Assertions.assertThat(field.path("value").asText()) + .as(pointer + " 刷新后的字段值") + .isEqualTo(expectedValue); + } + + private void assertTraceFieldValue(JsonNode detail, String pointer, int expectedValue) { + JsonNode field = traceField(detail, pointer); + org.assertj.core.api.Assertions.assertThat(field.path("value").asInt()) + .as(pointer + " 刷新后的字段值") + .isEqualTo(expectedValue); + } + + private void assertTraceFieldValidationErrorsEmpty(JsonNode detail, String pointer) { + JsonNode field = traceField(detail, pointer); + org.assertj.core.api.Assertions.assertThat(field.path("validation_errors").isArray()) + .as(pointer + " 字段错误集合") + .isTrue(); + org.assertj.core.api.Assertions.assertThat(field.path("validation_errors").size()) + .as(pointer + " 字段错误数量") + .isZero(); + } + + private JsonNode traceField(JsonNode detail, String pointer) { + for (JsonNode card : detail.path("business_cards")) { + if (!ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.path("card_type").asText())) { + continue; + } + for (JsonNode field : card.path("fields")) { + if (pointer.equals(field.path("field_pointer").asText())) { + return field; + } + } + } + throw new AssertionError("未找到 Trace 字段:" + pointer); + } + private void confirmRoomInformationBusinessCard(SeededOrderTask seeded, String groupBookingStatus) throws Exception { String groupBookingStatusFragment = groupBookingStatus == null ? ""