修复V4 Trace确认态字段刷新

This commit is contained in:
andy
2026-07-21 19:31:05 +07:00
parent 6e97778633
commit 350bb3310b
2 changed files with 205 additions and 2 deletions

View File

@@ -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());
}
/**
* 生成卡片安全展示 payloadRoom Information 卡在这里补稳定业务展示模型。
*/

View File

@@ -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
? ""