修复V4房型信息复核与自动DEF展示
This commit is contained in:
@@ -61,7 +61,8 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
|
||||
private static final List<String> V4_ORDER_TASK_AUDIT_ACTIONS = List.of(
|
||||
"V4_CARD_CONFIRM",
|
||||
"V4_CARD_REVIEW_RESOLVE");
|
||||
"V4_CARD_REVIEW_RESOLVE",
|
||||
"V4_ROOMING_LIST_AUTO_DEF");
|
||||
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
|
||||
|
||||
private final ReservationAiBatchMapper batchMapper;
|
||||
|
||||
@@ -594,7 +594,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
JsonNode submittedFinalValues = submittedPayload.path("room_information").path("final_values");
|
||||
JsonNode submittedRoot = submittedFinalValues.isObject() ? submittedFinalValues : submittedPayload;
|
||||
String bookingType = textAt(payload.path("room_information"), "booking_type");
|
||||
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, submittedRoot, List.of());
|
||||
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, finalValues, submittedRoot, List.of());
|
||||
normalizeRoomInformationDerivedFields(bookingType, finalValues);
|
||||
payload.withObject("/room_information").set("change_summary",
|
||||
changeSummary(payload.path("room_information").path("current_values"), finalValues));
|
||||
@@ -603,6 +603,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private void overlayEditableRoomInformationLeaves(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String bookingType,
|
||||
JsonNode finalValues,
|
||||
ObjectNode target,
|
||||
JsonNode submitted,
|
||||
List<String> path) {
|
||||
@@ -613,11 +614,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
List<String> childPath = appendPath(path, field.getKey());
|
||||
if (!isRoomInformationPathWritable(card, bookingType, childPath)) {
|
||||
if (!isRoomInformationPathWritable(card, bookingType, childPath, finalValues)) {
|
||||
continue;
|
||||
}
|
||||
JsonNode submittedValue = submitted.get(field.getKey());
|
||||
overlayEditableRoomInformationValue(card, bookingType, target, field.getKey(), field.getValue(),
|
||||
overlayEditableRoomInformationValue(card, bookingType, finalValues, target, field.getKey(), field.getValue(),
|
||||
submittedValue, childPath);
|
||||
}
|
||||
}
|
||||
@@ -625,6 +626,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private void overlayEditableRoomInformationArray(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String bookingType,
|
||||
JsonNode finalValues,
|
||||
ArrayNode target,
|
||||
JsonNode submitted,
|
||||
List<String> path) {
|
||||
@@ -634,7 +636,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
int size = Math.min(target.size(), submitted.size());
|
||||
for (int index = 0; index < size; index++) {
|
||||
List<String> childPath = appendPath(path, String.valueOf(index));
|
||||
overlayEditableRoomInformationValue(card, bookingType, target, index, target.get(index), submitted.get(index),
|
||||
overlayEditableRoomInformationValue(card, bookingType, finalValues, target, index, target.get(index), submitted.get(index),
|
||||
childPath);
|
||||
}
|
||||
}
|
||||
@@ -642,21 +644,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private void overlayEditableRoomInformationValue(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String bookingType,
|
||||
JsonNode finalValues,
|
||||
ObjectNode parent,
|
||||
String fieldName,
|
||||
JsonNode currentValue,
|
||||
JsonNode submittedValue,
|
||||
List<String> path) {
|
||||
if (currentValue != null && currentValue.isObject()) {
|
||||
overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path);
|
||||
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, (ObjectNode) currentValue, submittedValue, path);
|
||||
return;
|
||||
}
|
||||
if (currentValue != null && currentValue.isArray()) {
|
||||
overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path);
|
||||
overlayEditableRoomInformationArray(card, bookingType, finalValues, (ArrayNode) currentValue, submittedValue, path);
|
||||
return;
|
||||
}
|
||||
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()
|
||||
&& isRoomInformationPathWritable(card, bookingType, path)) {
|
||||
&& isRoomInformationPathWritable(card, bookingType, path, finalValues)) {
|
||||
parent.set(fieldName, submittedValue);
|
||||
}
|
||||
}
|
||||
@@ -664,21 +667,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private void overlayEditableRoomInformationValue(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String bookingType,
|
||||
JsonNode finalValues,
|
||||
ArrayNode parent,
|
||||
int index,
|
||||
JsonNode currentValue,
|
||||
JsonNode submittedValue,
|
||||
List<String> path) {
|
||||
if (currentValue != null && currentValue.isObject()) {
|
||||
overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path);
|
||||
overlayEditableRoomInformationLeaves(card, bookingType, finalValues, (ObjectNode) currentValue, submittedValue, path);
|
||||
return;
|
||||
}
|
||||
if (currentValue != null && currentValue.isArray()) {
|
||||
overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path);
|
||||
overlayEditableRoomInformationArray(card, bookingType, finalValues, (ArrayNode) currentValue, submittedValue, path);
|
||||
return;
|
||||
}
|
||||
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()
|
||||
&& isRoomInformationPathWritable(card, bookingType, path)) {
|
||||
&& isRoomInformationPathWritable(card, bookingType, path, finalValues)) {
|
||||
parent.set(index, submittedValue);
|
||||
}
|
||||
}
|
||||
@@ -686,7 +690,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
private boolean isRoomInformationPathWritable(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String bookingType,
|
||||
List<String> path) {
|
||||
List<String> path,
|
||||
JsonNode finalValues) {
|
||||
if (path == null || path.isEmpty() || EVENT_CANCEL_BOOKING.equals(card.eventType())) {
|
||||
return false;
|
||||
}
|
||||
@@ -708,7 +713,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
return EVENT_NEW_BOOKING.equals(card.eventType());
|
||||
}
|
||||
if ("breakfast_included".equals(fieldName)) {
|
||||
return BOOKING_TYPE_FIT.equals(bookingType);
|
||||
JsonNode currentBreakfast = finalValues == null ? null : finalValues.path("breakfast_included");
|
||||
return BOOKING_TYPE_FIT.equals(bookingType) && isMissingOrNull(currentBreakfast);
|
||||
}
|
||||
if ("group_booking_status".equals(fieldName)) {
|
||||
return BOOKING_TYPE_GROUP.equals(bookingType);
|
||||
@@ -1739,7 +1745,8 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
|
||||
}
|
||||
String bookingType = textAt(confirmedPayload.path("room_information"), "booking_type");
|
||||
if (!isRoomInformationPathWritable(card, bookingType, finalValuePath)) {
|
||||
JsonNode finalValues = confirmedPayload.path("room_information").path("final_values");
|
||||
if (!isRoomInformationPathWritable(card, bookingType, finalValuePath, finalValues)) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
|
||||
}
|
||||
}
|
||||
@@ -1829,6 +1836,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
ObjectNode confirmedPayload,
|
||||
String pointer,
|
||||
JsonNode current) {
|
||||
if (isStableRoomInformationPayload(confirmedPayload)
|
||||
&& pointer.startsWith("/room_information/final_values/")) {
|
||||
return true;
|
||||
}
|
||||
Set<String> validationPointers = collectValidationErrorPointers(card.validationErrorsJson());
|
||||
if (validationPointers.contains(pointer)) {
|
||||
return true;
|
||||
|
||||
@@ -88,6 +88,8 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
private static final String BOOKING_TYPE_GROUP = "GROUP";
|
||||
private static final String BOOKING_TYPE_FIT = "FIT";
|
||||
private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE";
|
||||
private static final String WRITE_TARGET_CONFIRMED_PAYLOAD = "confirmed_payload";
|
||||
private static final String WRITE_TARGET_REVIEW_FIELD_OVERRIDES = "review_resolution.field_overrides";
|
||||
private static final String GROUP_BOOKING_STATUS_TEN = "TEN";
|
||||
private static final Map<String, String> GROUP_BOOKING_STATUS_LABELS = Map.of(
|
||||
"TEN", "TEN-Tentative",
|
||||
@@ -397,7 +399,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
ReservationV4ActionAvailabilityResult orderAvailability,
|
||||
ReservationV4TaskCardSnapshot basicCard) {
|
||||
JsonNode displayPayload = displayPayloadForCard(orderTask, card);
|
||||
JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson());
|
||||
JsonNode confirmedPayload = confirmedPayloadForCard(card);
|
||||
JsonNode reviewResolution = parseJson(card.reviewResolutionJson());
|
||||
JsonNode validationErrors = parseJson(card.validationErrorsJson());
|
||||
ReservationV4ActionAvailabilityResult availability = cardAvailability(card, orderAvailability, basicCard);
|
||||
@@ -429,6 +431,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card) {
|
||||
JsonNode displayPayload = parseJson(card.displayPayloadJson());
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return safeBasicInformationPayload(displayPayload);
|
||||
}
|
||||
if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) {
|
||||
return displayPayload;
|
||||
}
|
||||
@@ -443,6 +448,52 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return safePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成卡片安全确认 payload;Basic Information 不向前端暴露 Agent target_order 或其它定位原文。
|
||||
*/
|
||||
private JsonNode confirmedPayloadForCard(ReservationV4TaskCardSnapshot card) {
|
||||
JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson());
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return safeBasicInformationPayload(confirmedPayload);
|
||||
}
|
||||
return confirmedPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗 Basic Information 展示 / 确认 payload,只保留前端需要的订单级基础字段。
|
||||
*/
|
||||
private JsonNode safeBasicInformationPayload(JsonNode payload) {
|
||||
if (payload == null || !payload.isObject()) {
|
||||
return payload == null ? NullNode.getInstance() : payload;
|
||||
}
|
||||
ObjectNode safePayload = objectMapper.createObjectNode();
|
||||
safePayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name());
|
||||
String orderRef = textAt(payload, "order_ref");
|
||||
if (hasText(orderRef)) {
|
||||
safePayload.put("order_ref", orderRef);
|
||||
}
|
||||
JsonNode source = payload.path("basic_information").isObject()
|
||||
? payload.path("basic_information")
|
||||
: payload;
|
||||
ObjectNode basicInformation = objectMapper.createObjectNode();
|
||||
copySafeBasicInformationField(source, basicInformation, "account_code");
|
||||
copySafeBasicInformationField(source, basicInformation, "account_name");
|
||||
copySafeBasicInformationField(source, basicInformation, "market_code");
|
||||
copySafeBasicInformationField(source, basicInformation, "source_code");
|
||||
copySafeBasicInformationField(source, basicInformation, "manual_review");
|
||||
copySafeBasicInformationField(source, basicInformation, "missing_fields");
|
||||
safePayload.set("basic_information", basicInformation);
|
||||
return safePayload;
|
||||
}
|
||||
|
||||
private void copySafeBasicInformationField(JsonNode source, ObjectNode target, String fieldName) {
|
||||
JsonNode value = source == null ? null : source.get(fieldName);
|
||||
if (value == null || value.isMissingNode() || value.isNull()) {
|
||||
return;
|
||||
}
|
||||
target.set(fieldName, value);
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardResult toSourceNotificationCard(
|
||||
ReservationV4SourceNotificationSnapshot notification,
|
||||
ReservationV4ActionAvailabilityResult availability) {
|
||||
@@ -1019,6 +1070,11 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
ObjectNode currentValues = currentRoomInformationProjection(orderTask, card);
|
||||
ObjectNode proposedValues = proposedRoomInformationValues(eventType, bookingType, locatorType, locatorValue, displayPayload);
|
||||
ObjectNode finalValues = finalRoomInformationValues(eventType, bookingType, locatorType, locatorValue, currentValues, proposedValues);
|
||||
ObjectNode confirmedFinalValues = confirmedStableRoomInformationFinalValues(card);
|
||||
if (confirmedFinalValues != null) {
|
||||
normalizeRoomInformationDerivedFields(bookingType, confirmedFinalValues);
|
||||
finalValues = roomInformationDisplayValues(bookingType, confirmedFinalValues, true);
|
||||
}
|
||||
|
||||
ObjectNode model = objectMapper.createObjectNode();
|
||||
model.put("event_type", eventType);
|
||||
@@ -1031,6 +1087,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return model;
|
||||
}
|
||||
|
||||
private ObjectNode confirmedStableRoomInformationFinalValues(ReservationV4TaskCardSnapshot card) {
|
||||
JsonNode payload = parseJson(card.confirmedPayloadJson());
|
||||
JsonNode finalValues = payload.path("room_information").path("final_values");
|
||||
return finalValues.isObject() ? ((ObjectNode) finalValues).deepCopy() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从同一本地订单更早已确认 Room Information 卡中提取当前订单投影。
|
||||
*/
|
||||
@@ -1334,7 +1396,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
true,
|
||||
"select",
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
|
||||
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
|
||||
"reservation_v4_account_catalog",
|
||||
false,
|
||||
validationMessages(validationErrors, "/basic_information/account_code", "basic_information.account_code"),
|
||||
@@ -1462,7 +1524,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
required,
|
||||
controlType,
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
|
||||
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
|
||||
optionsSource,
|
||||
false,
|
||||
validationMessages(validationErrors, pointer, fieldPath),
|
||||
@@ -1491,7 +1553,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
required,
|
||||
controlType,
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
|
||||
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
|
||||
optionsSource,
|
||||
false,
|
||||
validationMessages(validationErrors, pointer, fieldPath),
|
||||
@@ -1506,13 +1568,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (!availability.editable()) {
|
||||
return false;
|
||||
}
|
||||
if (!availability.reviewable()) {
|
||||
return true;
|
||||
}
|
||||
if (validationErrorPointers(validationErrors).contains(pointer)) {
|
||||
return true;
|
||||
}
|
||||
return isUnresolvedReviewLeaf(currentValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
private Set<String> validationErrorPointers(JsonNode validationErrors) {
|
||||
@@ -1641,7 +1697,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
false,
|
||||
controlType(fieldPath),
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
|
||||
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
|
||||
optionsSource(fieldPath),
|
||||
false,
|
||||
fieldValidationErrors,
|
||||
|
||||
@@ -2,8 +2,10 @@ package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
@@ -228,6 +230,18 @@ class ReservationV4CommandControllerTest {
|
||||
org.assertj.core.api.Assertions.assertThat(confirmedPayload)
|
||||
.contains("\"group_booking_status\":\"DEF\"")
|
||||
.contains("\"group_booking_status_label\":\"DEF-Definite\"");
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}",
|
||||
roomInformation.orderTask().id()).param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.group_booking_status")
|
||||
.value("DEF"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.group_booking_status")
|
||||
.value("DEF"));
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}/audits",
|
||||
roomingList.orderTask().id()).param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items[?(@.action=='V4_ROOMING_LIST_AUTO_DEF')].after_snapshot.update_status")
|
||||
.value(contains("UPDATED")));
|
||||
assertAuditCount("V4_ROOMING_LIST_AUTO_DEF", "v4-command-admin",
|
||||
roomInformation.businessCard().id().toString(), "\"update_status\":\"UPDATED\"", 1);
|
||||
}
|
||||
@@ -980,6 +994,10 @@ class ReservationV4CommandControllerTest {
|
||||
"version": 0,
|
||||
"reason": "按稳定 Room Information 模型修正房型",
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_pointer": "/room_information/final_values/arrival_date",
|
||||
"value": "2026-08-02"
|
||||
},
|
||||
{
|
||||
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
|
||||
"value": "RM2"
|
||||
@@ -990,10 +1008,14 @@ class ReservationV4CommandControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.room_items[0].room_type_code")
|
||||
.value("RM2"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.arrival_date")
|
||||
.value("2026-08-02"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.room_information.final_values.nights")
|
||||
.value(2))
|
||||
.value(1))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/room_information/final_values/arrival_date"))
|
||||
.andExpect(jsonPath("$.business_cards[0].review_resolution.field_overrides[1].field_pointer")
|
||||
.value("/room_information/final_values/room_items/0/room_type_code"));
|
||||
}
|
||||
|
||||
@@ -1049,6 +1071,67 @@ class ReservationV4CommandControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRoomInformationReviewWhenFitBreakfastIsDerivedFromRateCode() throws Exception {
|
||||
seedRateCode(HOTEL_ID, 990000000000070199L, "FIT-RB");
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-review-room-info-derived-breakfast-001",
|
||||
Instant.parse("2026-07-19T01:22:08Z"),
|
||||
990000000000070106L,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
|
||||
"""
|
||||
{
|
||||
"card_type":"ROOM_INFORMATION",
|
||||
"event_type":"NEW_BOOKING",
|
||||
"target_order":{"booking_type":"FIT","locator_type":"BOOKING_CODE","locator_value":"FIT-V4-REVIEW-RI-001"},
|
||||
"business_fields":{
|
||||
"event_type":"NEW_BOOKING",
|
||||
"guest_name":"FIT REVIEW",
|
||||
"arrival_date":"2026-08-01",
|
||||
"departure_date":"2026-08-03",
|
||||
"rate_code":"FIT-RB",
|
||||
"room_items":[{"room_type_code":"TWN","room_count":1}]
|
||||
}
|
||||
}
|
||||
""",
|
||||
"""
|
||||
[
|
||||
{
|
||||
"field_path": "room_information.final_values.room_items.0.room_type_code",
|
||||
"field_pointer": "/room_information/final_values/room_items/0/room_type_code",
|
||||
"message": "人工复核测试",
|
||||
"detail": "room_information.final_values.room_items.0.room_type_code: 人工复核测试。"
|
||||
}
|
||||
]
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.room_information.final_values.breakfast_included")
|
||||
.value(true))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/breakfast_included')]")
|
||||
.doesNotExist());
|
||||
|
||||
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,
|
||||
"field_overrides": [
|
||||
{"field_pointer": "/room_information/final_values/breakfast_included", "value": false}
|
||||
]
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_NOT_ALLOWED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception {
|
||||
Long currentOrderId = 990000000000070111L;
|
||||
@@ -1577,6 +1660,20 @@ class ReservationV4CommandControllerTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
private void seedRateCode(String hotelId, Long id, String code) {
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
INSERT INTO workflow_reservation_catalog_code (
|
||||
id, hotel_id, catalog_type, code, display_name, status, source_system, external_id,
|
||||
sort_order, catalog_version, last_synced_at, metadata_json, version,
|
||||
created_at, updated_at, logic_deleted_at, logic_deleted_reason
|
||||
) VALUES (?, ?, 'RATE_CODE', ?, ?, 'ACTIVE', 'SYSTEM_MANAGED', NULL,
|
||||
990, 'test-review-derived-breakfast-v1', NULL, '{"pricing_available":false}', 0,
|
||||
'2026-07-19 00:00:00.000000', '2026-07-19 00:00:00.000000', NULL, NULL)
|
||||
""",
|
||||
id, hotelId, code, code);
|
||||
}
|
||||
|
||||
private void resolveBusinessReviewCard(SeededOrderTask seeded) throws Exception {
|
||||
performAuthorized(mockMvc, adminToken(), post(
|
||||
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
|
||||
|
||||
@@ -429,7 +429,7 @@ class ReservationV4QueryControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldOnlyMarkRoomInformationReviewProblemFieldsEditable() throws Exception {
|
||||
void shouldMarkRoomInformationReviewBusinessWhitelistFieldsEditable() throws Exception {
|
||||
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
|
||||
"mail-v4-room-info-review-fields-001",
|
||||
Instant.parse("2026-07-18T03:02:30Z"),
|
||||
@@ -470,11 +470,60 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/room_items/0/room_type_code')].editable")
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/arrival_date')].editable")
|
||||
.value(contains(false)))
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/departure_date')].editable")
|
||||
.value(contains(false)))
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].editable")
|
||||
.value(contains(false)));
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/rate_code')].write_target")
|
||||
.value(contains("review_resolution.field_overrides")))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_information/final_values/nights')]")
|
||||
.doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotExposeBasicInformationTargetOrderInDisplayOrConfirmedPayload() throws Exception {
|
||||
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessPayload(
|
||||
"mail-v4-query-basic-target-order-safe-001",
|
||||
Instant.parse("2026-07-18T03:02:40Z"),
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-BASIC-SAFE-001",
|
||||
"NEW_BOOKING",
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
"""
|
||||
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
|
||||
""",
|
||||
null);
|
||||
ReservationV4TaskCardSnapshot basicCard = workflowRepository.findTaskCardsByOrderTaskId(HOTEL_ID, orderTask.id())
|
||||
.stream()
|
||||
.filter(card -> ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
workflowRepository.confirmTaskCardWithVersion(
|
||||
HOTEL_ID,
|
||||
basicCard.id(),
|
||||
basicCard.version(),
|
||||
"""
|
||||
{
|
||||
"basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"},
|
||||
"target_order":{"locator_value":"SHOULD-NOT-LEAK-CONFIRMED"}
|
||||
}
|
||||
""",
|
||||
"v4-query-admin",
|
||||
LocalDateTime.ofInstant(Instant.parse("2026-07-18T03:02:50Z"), ZoneOffset.UTC));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
|
||||
.param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.basic_information_card.display_payload.basic_information.account_code")
|
||||
.value("QBD_TRAVEL"))
|
||||
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.account_code")
|
||||
.value("QBD_TRAVEL"))
|
||||
.andExpect(jsonPath("$.basic_information_card.display_payload.target_order").doesNotExist())
|
||||
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.target_order").doesNotExist())
|
||||
.andExpect(content().string(not(containsString("SHOULD-NOT-LEAK"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1024,7 +1073,12 @@ class ReservationV4QueryControllerTest {
|
||||
""");
|
||||
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
|
||||
basicCardStatus, reviewStatusFor(basicCardStatus), """
|
||||
{"card_type":"BASIC_INFORMATION","order_ref":"order-1","basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}}
|
||||
{
|
||||
"card_type":"BASIC_INFORMATION",
|
||||
"order_ref":"order-1",
|
||||
"target_order":{"locator_value":"SHOULD-NOT-LEAK-DISPLAY"},
|
||||
"basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}
|
||||
}
|
||||
""");
|
||||
ReservationV4TaskCardSnapshot businessCard = insertCard(
|
||||
orderTask,
|
||||
|
||||
Reference in New Issue
Block a user