修复V4房型信息展示安全边界

This commit is contained in:
andy
2026-07-21 00:58:10 +07:00
parent c1163457a9
commit 1d593f05fd
11 changed files with 2122 additions and 62 deletions

View File

@@ -673,6 +673,10 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
* 读取业务字段主体Update Booking 的 after 结构优先作为订单当前快照来源。
*/
private JsonNode businessFields(JsonNode payload) {
JsonNode roomInformationFinalValues = payload.path("room_information").path("final_values");
if (roomInformationFinalValues.isObject()) {
return roomInformationFinalValues;
}
JsonNode businessFields = objectOrSelf(payload, "business_fields");
JsonNode after = businessFields.path("after");
return after.isObject() ? after : businessFields;

View File

@@ -34,13 +34,16 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@@ -58,6 +61,17 @@ 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 EVENT_NEW_BOOKING = "NEW_BOOKING";
private static final String EVENT_UPDATE_BOOKING = "UPDATE_BOOKING";
private static final String EVENT_CANCEL_BOOKING = "CANCEL_BOOKING";
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 GROUP_BOOKING_STATUS_TEN = "TEN";
private static final Map<String, String> GROUP_BOOKING_STATUS_LABELS = Map.of(
"TEN", "TEN-Tentative",
"DEF", "DEF-Definite",
"INQ", "INQ-Inquiry");
private static final Set<String> REVIEW_READONLY_ROOT_FIELDS = Set.of(
"ai_payload_json",
"attachments",
@@ -94,6 +108,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
"rate_code",
"room_items",
"trace_items");
private static final Set<String> ROOM_INFORMATION_READONLY_FINAL_FIELDS = Set.of(
"adult",
"adults",
"adult_count",
"block_id",
"confirmation_number",
"group_booking_status_label",
"nights",
"target_order");
private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
@@ -144,6 +167,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
String confirmedPayloadJson = confirmedPayloadJson(
orderTask.hotelId(),
orderTask,
card,
request == null ? null : request.confirmedPayload());
String actorId = actorIdentifier(actor);
@@ -184,11 +208,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
ensureNoPriorOrderTaskBlocking(orderTask, targetOrderId);
ensureBasicInformationConfirmed(orderTask, card);
ObjectNode confirmedPayload = mutableDisplayPayload(card);
ObjectNode confirmedPayload = mutableConfirmedPayloadBase(
orderTask,
card,
request == null ? null : request.fieldOverrides());
List<Map<String, Object>> normalizedOverrides = applyReviewFieldOverrides(
card,
confirmedPayload,
request == null ? null : request.fieldOverrides());
normalizeStableRoomInformationPayload(confirmedPayload);
validateAndEnrichConfirmedPayload(orderTask.hotelId(), card, confirmedPayload);
String actorId = actorIdentifier(actor);
String confirmedPayloadJson = toJson(confirmedPayload);
@@ -472,19 +500,26 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。");
}
private String confirmedPayloadJson(String hotelId, ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
ObjectNode payload = confirmedPayloadObject(card, confirmedPayload);
private String confirmedPayloadJson(
String hotelId,
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
JsonNode confirmedPayload) {
ObjectNode payload = confirmedPayloadObject(orderTask, card, confirmedPayload);
validateAndEnrichConfirmedPayload(hotelId, card, payload);
return toJson(payload);
}
private ObjectNode confirmedPayloadObject(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
ObjectNode payload = mutableDisplayPayload(card);
private ObjectNode confirmedPayloadObject(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
JsonNode confirmedPayload) {
ObjectNode payload = mutableConfirmedPayloadBase(orderTask, card, confirmedPayload);
if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) {
if (!confirmedPayload.isObject()) {
throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_PAYLOAD_NOT_OBJECT", "确认 payload 必须是对象结构。");
}
overlayConfirmedPayload(card, payload, (ObjectNode) confirmedPayload);
overlayConfirmedPayload(orderTask, card, payload, (ObjectNode) confirmedPayload);
}
return payload;
}
@@ -492,11 +527,19 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
/**
* 按当前卡片白名单合并前端确认值,避免未开放字段污染 confirmed_payload_json。
*/
private void overlayConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload, ObjectNode submittedPayload) {
private void overlayConfirmedPayload(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
ObjectNode payload,
ObjectNode submittedPayload) {
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
overlayBasicInformationPayload(payload, submittedPayload);
return;
}
if (isStableRoomInformationPayload(payload)) {
overlayRoomInformationPayload(orderTask, card, payload, submittedPayload);
return;
}
overlayBusinessPayload(payload, submittedPayload);
}
@@ -528,6 +571,155 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
overlayEditableBusinessLeaves(payload, submittedRoot, List.of(), false);
}
/**
* Room Information 新契约只允许写 final_values 中的业务投影字段,不接受 Agent target_order 或派生字段回写。
*/
private void overlayRoomInformationPayload(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
ObjectNode payload,
ObjectNode submittedPayload) {
if (EVENT_CANCEL_BOOKING.equals(card.eventType())) {
return;
}
JsonNode finalValuesNode = payload.path("room_information").path("final_values");
if (!finalValuesNode.isObject()) {
return;
}
ObjectNode finalValues = (ObjectNode) finalValuesNode;
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());
normalizeRoomInformationDerivedFields(bookingType, finalValues);
payload.withObject("/room_information").set("change_summary",
changeSummary(payload.path("room_information").path("current_values"), finalValues));
}
private void overlayEditableRoomInformationLeaves(
ReservationV4TaskCardSnapshot card,
String bookingType,
ObjectNode target,
JsonNode submitted,
List<String> path) {
if (submitted == null || !submitted.isObject()) {
return;
}
Iterator<Map.Entry<String, JsonNode>> fields = target.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
List<String> childPath = appendPath(path, field.getKey());
if (!isRoomInformationPathWritable(card, bookingType, childPath)) {
continue;
}
JsonNode submittedValue = submitted.get(field.getKey());
overlayEditableRoomInformationValue(card, bookingType, target, field.getKey(), field.getValue(),
submittedValue, childPath);
}
}
private void overlayEditableRoomInformationArray(
ReservationV4TaskCardSnapshot card,
String bookingType,
ArrayNode target,
JsonNode submitted,
List<String> path) {
if (submitted == null || !submitted.isArray()) {
return;
}
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),
childPath);
}
}
private void overlayEditableRoomInformationValue(
ReservationV4TaskCardSnapshot card,
String bookingType,
ObjectNode parent,
String fieldName,
JsonNode currentValue,
JsonNode submittedValue,
List<String> path) {
if (currentValue != null && currentValue.isObject()) {
overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path);
return;
}
if (currentValue != null && currentValue.isArray()) {
overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path);
return;
}
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()
&& isRoomInformationPathWritable(card, bookingType, path)) {
parent.set(fieldName, submittedValue);
}
}
private void overlayEditableRoomInformationValue(
ReservationV4TaskCardSnapshot card,
String bookingType,
ArrayNode parent,
int index,
JsonNode currentValue,
JsonNode submittedValue,
List<String> path) {
if (currentValue != null && currentValue.isObject()) {
overlayEditableRoomInformationLeaves(card, bookingType, (ObjectNode) currentValue, submittedValue, path);
return;
}
if (currentValue != null && currentValue.isArray()) {
overlayEditableRoomInformationArray(card, bookingType, (ArrayNode) currentValue, submittedValue, path);
return;
}
if (submittedValue != null && !submittedValue.isMissingNode() && !submittedValue.isContainerNode()
&& isRoomInformationPathWritable(card, bookingType, path)) {
parent.set(index, submittedValue);
}
}
private boolean isRoomInformationPathWritable(
ReservationV4TaskCardSnapshot card,
String bookingType,
List<String> path) {
if (path == null || path.isEmpty() || EVENT_CANCEL_BOOKING.equals(card.eventType())) {
return false;
}
if (path.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) {
return false;
}
if (path.size() == 1) {
String fieldName = path.get(0);
if ("group_block_name".equals(fieldName)) {
return BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(card.eventType());
}
if ("fit_name".equals(fieldName)) {
return BOOKING_TYPE_FIT.equals(bookingType);
}
if ("arrival_date".equals(fieldName) || "departure_date".equals(fieldName)) {
return true;
}
if ("rate_code".equals(fieldName)) {
return EVENT_NEW_BOOKING.equals(card.eventType());
}
if ("breakfast_included".equals(fieldName)) {
return BOOKING_TYPE_FIT.equals(bookingType);
}
if ("group_booking_status".equals(fieldName)) {
return BOOKING_TYPE_GROUP.equals(bookingType);
}
if ("room_items".equals(fieldName)) {
return true;
}
return false;
}
if (path.size() == 3 && "room_items".equals(path.get(0)) && parseArrayIndex(path.get(1)) != null) {
return "room_type_code".equals(path.get(2)) || "room_count".equals(path.get(2));
}
return false;
}
private void overlayEditableBusinessLeaves(
ObjectNode target,
JsonNode submitted,
@@ -618,7 +810,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
validateAndEnrichBasicInformation(hotelId, payload, details);
} else {
validateBusinessCardPayload(hotelId, payload, details);
validateBusinessCardPayload(hotelId, card, payload, details);
}
if (!details.isEmpty()) {
throw new ReservationTaskWorkflowException(
@@ -666,12 +858,103 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
private void validateBusinessCardPayload(String hotelId, ObjectNode payload, List<String> details) {
private void validateBusinessCardPayload(
String hotelId,
ReservationV4TaskCardSnapshot card,
ObjectNode payload,
List<String> details) {
JsonNode roomInformationFinalValues = payload.path("room_information").path("final_values");
if (isRoomInformationEventCard(card) && roomInformationFinalValues.isObject()) {
validateRoomInformationPayload(hotelId, card, payload.path("room_information"), roomInformationFinalValues, details);
return;
}
JsonNode businessFields = payload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
}
private void validateRoomInformationPayload(
String hotelId,
ReservationV4TaskCardSnapshot card,
JsonNode roomInformation,
JsonNode finalValues,
List<String> details) {
String eventType = firstText(card.eventType(), textAt(roomInformation, "event_type"));
String bookingType = textAt(roomInformation, "booking_type");
if (!EVENT_CANCEL_BOOKING.equals(eventType)) {
validateRequiredText(finalValues, "arrival_date", "room_information.final_values.arrival_date", "入住日期", details);
validateRequiredText(finalValues, "departure_date", "room_information.final_values.departure_date", "离店日期", details);
validateRoomInformationDates(finalValues, details);
validateRequiredRoomItems(finalValues.path("room_items"), details);
if (EVENT_NEW_BOOKING.equals(eventType)) {
validateRequiredText(finalValues, "rate_code", "room_information.final_values.rate_code", "Rate Code", details);
}
if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) {
validateRequiredText(finalValues, "group_block_name", "room_information.final_values.group_block_name",
"Group Block Name", details);
}
if (BOOKING_TYPE_FIT.equals(bookingType)) {
validateRequiredText(finalValues, "fit_name", "room_information.final_values.fit_name", "Fit Name", details);
if (breakfastIncludedFromRateCode(textAt(finalValues, "rate_code")) == null
&& isMissingOrNull(finalValues.path("breakfast_included"))) {
details.add("room_information.final_values.breakfast_included: 含早不能为空。");
}
}
}
validateBusinessCatalogFields(hotelId, finalValues, "room_information.final_values", details);
}
private void validateRoomInformationDates(JsonNode finalValues, List<String> details) {
String arrival = textAt(finalValues, "arrival_date");
String departure = textAt(finalValues, "departure_date");
LocalDate arrivalDate = parseDate(arrival);
LocalDate departureDate = parseDate(departure);
if (hasText(arrival) && arrivalDate == null) {
details.add("room_information.final_values.arrival_date: 入住日期格式必须为 yyyy-MM-dd。");
}
if (hasText(departure) && departureDate == null) {
details.add("room_information.final_values.departure_date: 离店日期格式必须为 yyyy-MM-dd。");
}
if (arrivalDate != null && departureDate != null && departureDate.isBefore(arrivalDate)) {
details.add("room_information.final_values.departure_date: 离店日期不能早于入住日期。");
}
}
private void validateRequiredText(
JsonNode node,
String fieldName,
String fieldPath,
String displayName,
List<String> details) {
if (!hasText(textAt(node, fieldName))) {
details.add(fieldPath + ": " + displayName + " 不能为空。");
}
}
private boolean isMissingOrNull(JsonNode node) {
return node == null || node.isMissingNode() || node.isNull();
}
private void validateRequiredRoomItems(JsonNode roomItems, List<String> details) {
if (roomItems == null || !roomItems.isArray() || roomItems.isEmpty()) {
details.add("room_information.final_values.room_items: 房型明细不能为空。");
return;
}
for (int index = 0; index < roomItems.size(); index++) {
JsonNode item = roomItems.get(index);
if (item == null || !item.isObject()) {
details.add("room_information.final_values.room_items." + index + ": 房型明细必须是对象。");
continue;
}
validateRequiredText(item, "room_type_code",
"room_information.final_values.room_items." + index + ".room_type_code", "房型代码", details);
JsonNode roomCount = item.get("room_count");
if (roomCount == null || roomCount.isNull() || !roomCount.canConvertToInt() || roomCount.asInt() <= 0) {
details.add("room_information.final_values.room_items." + index + ".room_count: 房间数必须大于 0。");
}
}
}
private void validateBusinessCatalogFields(String hotelId, JsonNode node, String fieldPath, List<String> details) {
if (node == null || node.isMissingNode() || node.isNull()) {
return;
@@ -739,6 +1022,19 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return textValue(node.get(fieldName));
}
private String firstText(String first, String second) {
return hasText(first) ? first : second;
}
private boolean isRoomInformationEventCard(ReservationV4TaskCardSnapshot card) {
if (card == null || !ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())) {
return false;
}
return EVENT_NEW_BOOKING.equals(card.eventType())
|| EVENT_UPDATE_BOOKING.equals(card.eventType())
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
}
private String textValue(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) {
return null;
@@ -766,6 +1062,421 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
/**
* 根据当前卡契约选择确认基础快照V4 Room Information 新 payload 使用稳定 room_information 模型。
*/
private ObjectNode mutableConfirmedPayloadBase(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
JsonNode submittedPayload) {
ObjectNode displayPayload = mutableDisplayPayload(card);
if (shouldUseStableRoomInformationPayload(card, displayPayload, submittedPayload, null)) {
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
}
return displayPayload;
}
/**
* 复核场景根据 pointer 判断是否使用 Room Information 新模型,避免旧兼容测试夹具被强制升级。
*/
private ObjectNode mutableConfirmedPayloadBase(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
List<ReservationV4ReviewFieldOverrideRequest> fieldOverrides) {
ObjectNode displayPayload = mutableDisplayPayload(card);
if (shouldUseStableRoomInformationPayload(card, displayPayload, null, fieldOverrides)) {
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
}
return displayPayload;
}
private boolean shouldUseStableRoomInformationPayload(
ReservationV4TaskCardSnapshot card,
JsonNode displayPayload,
JsonNode submittedPayload,
List<ReservationV4ReviewFieldOverrideRequest> fieldOverrides) {
if (!isRoomInformationEventCard(card)) {
return false;
}
if (submittedPayload != null && submittedPayload.path("room_information").isObject()) {
return true;
}
if (fieldOverrides != null && fieldOverrides.stream()
.map(override -> trimToNull(override == null ? null : override.fieldPointer()))
.filter(Objects::nonNull)
.anyMatch(pointer -> pointer.startsWith("/room_information/"))) {
return true;
}
return displayPayload != null && displayPayload.path("target_order").isObject();
}
private boolean isStableRoomInformationPayload(JsonNode payload) {
return payload != null
&& payload.path("room_information").path("final_values").isObject();
}
private ObjectNode roomInformationConfirmedPayloadBase(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
ObjectNode displayPayload) {
ObjectNode model = roomInformationDisplayModel(orderTask, card, displayPayload);
ObjectNode payload = objectMapper.createObjectNode();
payload.put("card_type", ReservationV4CardType.ROOM_INFORMATION.name());
payload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type")));
payload.set("room_information", model);
return payload;
}
/**
* 构造确认和复核共用的 Room Information 业务投影快照。
*/
private ObjectNode roomInformationDisplayModel(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
ObjectNode displayPayload) {
String eventType = firstText(card.eventType(), textAt(displayPayload, "event_type"));
JsonNode targetOrder = displayPayload.path("target_order");
String bookingType = firstText(textAt(targetOrder, "booking_type"), orderTask.targetBookingType());
String locatorType = firstText(textAt(targetOrder, "locator_type"), orderTask.targetLocatorType());
String locatorValue = firstText(textAt(targetOrder, "locator_value"), orderTask.targetLocatorValue());
ObjectNode currentValues = currentRoomInformationProjection(orderTask);
ObjectNode proposedValues = proposedRoomInformationValues(eventType, bookingType, locatorType, locatorValue, displayPayload);
ObjectNode finalValues = finalRoomInformationValues(eventType, bookingType, locatorType, locatorValue, currentValues, proposedValues);
ensureRoomInformationEditablePlaceholders(card, bookingType, finalValues);
ObjectNode model = objectMapper.createObjectNode();
model.put("event_type", eventType);
model.put("booking_type", bookingType);
model.set("current_values", EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues);
model.set("proposed_values", EVENT_CANCEL_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : proposedValues);
model.set("final_values", finalValues);
model.set("change_summary", changeSummary(EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues, finalValues));
return model;
}
private ObjectNode currentRoomInformationProjection(ReservationV4OrderTaskSnapshot orderTask) {
ObjectNode current = objectMapper.createObjectNode();
if (orderTask.orderId() == null) {
return current;
}
List<ReservationV4OrderTaskSnapshot> orderTasks = workflowRepository.findOrderTasksByOrderIds(
orderTask.hotelId(),
List.of(orderTask.orderId()));
List<Long> priorOrderTaskIds = new ArrayList<>();
for (ReservationV4OrderTaskSnapshot candidate : orderTasks) {
if (Objects.equals(candidate.id(), orderTask.id())) {
break;
}
priorOrderTaskIds.add(candidate.id());
}
if (priorOrderTaskIds.isEmpty()) {
return current;
}
List<ReservationV4TaskCardSnapshot> cards = workflowRepository.findTaskCardsByOrderTaskIds(
orderTask.hotelId(),
priorOrderTaskIds);
for (ReservationV4TaskCardSnapshot candidate : cards) {
if (!ReservationV4CardType.ROOM_INFORMATION.name().equals(candidate.cardType())
|| !ReservationV4CardStatus.CONFIRMED.name().equals(candidate.cardStatus())) {
continue;
}
mergeObject(current, confirmedRoomInformationFinalValues(candidate.confirmedPayloadJson()));
}
normalizeRoomInformationDerivedFields(orderTask.targetBookingType(), current);
return roomInformationDisplayValues(orderTask.targetBookingType(), current, true);
}
private ObjectNode confirmedRoomInformationFinalValues(String confirmedPayloadJson) {
JsonNode payload = parseJson(confirmedPayloadJson);
JsonNode finalValues = payload.path("room_information").path("final_values");
if (finalValues.isObject()) {
return ((ObjectNode) finalValues).deepCopy();
}
JsonNode businessFields = payload.path("business_fields");
if (businessFields.isObject()) {
return ((ObjectNode) businessFields).deepCopy();
}
if (payload.isObject()) {
return ((ObjectNode) payload).deepCopy();
}
return objectMapper.createObjectNode();
}
private ObjectNode proposedRoomInformationValues(
String eventType,
String bookingType,
String locatorType,
String locatorValue,
JsonNode displayPayload) {
if (EVENT_CANCEL_BOOKING.equals(eventType)) {
return objectMapper.createObjectNode();
}
JsonNode businessFields = displayPayload.path("business_fields");
JsonNode source = businessFields.isObject() ? businessFields : displayPayload;
if (EVENT_UPDATE_BOOKING.equals(eventType) && businessFields.path("after").isObject()) {
source = businessFields.path("after");
}
ObjectNode proposed = roomInformationDisplayValues(bookingType, source, EVENT_NEW_BOOKING.equals(eventType));
if (EVENT_NEW_BOOKING.equals(eventType)) {
applyNewBookingNameDefaults(proposed, bookingType, locatorType, locatorValue, source);
}
normalizeRoomInformationDerivedFields(bookingType, proposed);
return proposed;
}
private ObjectNode finalRoomInformationValues(
String eventType,
String bookingType,
String locatorType,
String locatorValue,
ObjectNode currentValues,
ObjectNode proposedValues) {
ObjectNode finalValues = EVENT_NEW_BOOKING.equals(eventType)
? objectMapper.createObjectNode()
: currentValues.deepCopy();
if (!EVENT_CANCEL_BOOKING.equals(eventType)) {
mergeObject(finalValues, proposedValues);
}
if (EVENT_NEW_BOOKING.equals(eventType)) {
applyNewBookingNameDefaults(finalValues, bookingType, locatorType, locatorValue, proposedValues);
}
normalizeRoomInformationDerivedFields(bookingType, finalValues);
return roomInformationDisplayValues(bookingType, finalValues, true);
}
private void applyNewBookingNameDefaults(
ObjectNode values,
String bookingType,
String locatorType,
String locatorValue,
JsonNode source) {
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
if (!hasText(textAt(values, "group_block_name")) && LOCATOR_TYPE_GROUP_CODE.equals(locatorType)) {
values.put("group_block_name", locatorValue);
}
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
String fitName = firstText(textAt(values, "fit_name"), textAt(source, "guest_name"));
if (!hasText(fitName)) {
fitName = locatorValue;
}
if (hasText(fitName)) {
values.put("fit_name", fitName);
}
}
}
private void normalizeRoomInformationDerivedFields(String bookingType, ObjectNode values) {
putNights(values);
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
values.put("breakfast_included", true);
if (!hasText(textAt(values, "group_booking_status"))) {
values.put("group_booking_status", GROUP_BOOKING_STATUS_TEN);
}
putGroupBookingStatusLabel(values);
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
Boolean breakfast = breakfastIncludedFromRateCode(textAt(values, "rate_code"));
if (breakfast != null) {
values.put("breakfast_included", breakfast);
}
}
}
private void removeRoomInformationNonDisplayFields(String bookingType, ObjectNode values) {
ObjectNode filtered = roomInformationDisplayValues(bookingType, values, true);
values.removeAll();
values.setAll(filtered);
}
private void ensureRoomInformationEditablePlaceholders(
ReservationV4TaskCardSnapshot card,
String bookingType,
ObjectNode finalValues) {
if (EVENT_CANCEL_BOOKING.equals(card.eventType())) {
return;
}
putNullIfMissing(finalValues, "arrival_date");
putNullIfMissing(finalValues, "departure_date");
if (EVENT_NEW_BOOKING.equals(card.eventType())) {
putNullIfMissing(finalValues, "rate_code");
}
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
if (EVENT_NEW_BOOKING.equals(card.eventType())) {
putNullIfMissing(finalValues, "group_block_name");
}
putNullIfMissing(finalValues, "group_booking_status");
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
putNullIfMissing(finalValues, "fit_name");
if (breakfastIncludedFromRateCode(textAt(finalValues, "rate_code")) == null) {
putNullIfMissing(finalValues, "breakfast_included");
}
}
}
private void putNullIfMissing(ObjectNode values, String fieldName) {
if (!values.has(fieldName)) {
values.set(fieldName, objectMapper.nullNode());
}
}
private void putNights(ObjectNode values) {
values.remove("nights");
LocalDate arrivalDate = parseDate(textAt(values, "arrival_date"));
LocalDate departureDate = parseDate(textAt(values, "departure_date"));
if (arrivalDate != null && departureDate != null && !departureDate.isBefore(arrivalDate)) {
values.put("nights", ChronoUnit.DAYS.between(arrivalDate, departureDate));
}
}
private ObjectNode roomInformationDisplayValues(String bookingType, JsonNode source, boolean includeRateCode) {
ObjectNode values = objectMapper.createObjectNode();
if (source == null || !source.isObject()) {
return values;
}
copyScalarField(source, values, "group_block_name");
copyScalarField(source, values, "fit_name");
copyScalarField(source, values, "arrival_date");
copyScalarField(source, values, "departure_date");
copyScalarField(source, values, "nights");
if (includeRateCode) {
copyScalarField(source, values, "rate_code");
}
copyScalarField(source, values, "breakfast_included");
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
copyScalarField(source, values, "block_id");
copyScalarField(source, values, "group_booking_status");
copyScalarField(source, values, "group_booking_status_label");
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
copyScalarField(source, values, "confirmation_number");
}
copyRoomItems(source.path("room_items"), values);
return values;
}
private void copyScalarField(JsonNode source, ObjectNode target, String fieldName) {
JsonNode value = source.get(fieldName);
if (value == null || value.isMissingNode() || value.isNull() || value.isContainerNode()) {
return;
}
target.set(fieldName, value);
}
private void copyRoomItems(JsonNode roomItems, ObjectNode target) {
if (roomItems == null || !roomItems.isArray()) {
return;
}
ArrayNode sanitizedItems = objectMapper.createArrayNode();
for (JsonNode item : roomItems) {
if (item == null || !item.isObject()) {
continue;
}
ObjectNode sanitizedItem = objectMapper.createObjectNode();
copyScalarField(item, sanitizedItem, "room_type_code");
copyScalarField(item, sanitizedItem, "room_count");
sanitizedItems.add(sanitizedItem);
}
target.set("room_items", sanitizedItems);
}
private void putGroupBookingStatusLabel(ObjectNode values) {
String status = textAt(values, "group_booking_status");
if (hasText(status)) {
values.put("group_booking_status_label", GROUP_BOOKING_STATUS_LABELS.getOrDefault(status, status));
}
}
private Boolean breakfastIncludedFromRateCode(String rateCode) {
if (!hasText(rateCode)) {
return null;
}
String normalized = rateCode.toUpperCase(Locale.ROOT);
if (normalized.contains("RB")) {
return true;
}
if (normalized.contains("RO")) {
return false;
}
return null;
}
private ArrayNode changeSummary(JsonNode currentValues, JsonNode finalValues) {
ArrayNode changes = objectMapper.createArrayNode();
if (!currentValues.isObject() || !finalValues.isObject()) {
return changes;
}
addChangeIfDifferent(changes, "group_block_name", currentValues, finalValues);
addChangeIfDifferent(changes, "fit_name", currentValues, finalValues);
addChangeIfDifferent(changes, "arrival_date", currentValues, finalValues);
addChangeIfDifferent(changes, "departure_date", currentValues, finalValues);
addChangeIfDifferent(changes, "nights", currentValues, finalValues);
addChangeIfDifferent(changes, "rate_code", currentValues, finalValues);
addChangeIfDifferent(changes, "breakfast_included", currentValues, finalValues);
addChangeIfDifferent(changes, "group_booking_status", currentValues, finalValues);
addChangeIfDifferent(changes, "room_items", currentValues, finalValues);
return changes;
}
private void addChangeIfDifferent(ArrayNode changes, String fieldName, JsonNode currentValues, JsonNode finalValues) {
JsonNode before = currentValues.get(fieldName);
JsonNode after = finalValues.get(fieldName);
if (Objects.equals(before, after)) {
return;
}
if ((before == null || before.isMissingNode()) && (after == null || after.isMissingNode())) {
return;
}
ObjectNode change = changes.addObject();
change.put("field", fieldName);
change.set("before", before == null ? objectMapper.nullNode() : before);
change.set("after", after == null ? objectMapper.nullNode() : after);
}
private void mergeObject(ObjectNode target, JsonNode source) {
if (source == null || !source.isObject()) {
return;
}
Iterator<Map.Entry<String, JsonNode>> fields = source.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
target.set(field.getKey(), field.getValue());
}
}
private LocalDate parseDate(String value) {
if (!hasText(value)) {
return null;
}
try {
return LocalDate.parse(value);
} catch (Exception exception) {
return null;
}
}
private JsonNode parseJson(String json) {
if (!hasText(json)) {
return objectMapper.createObjectNode();
}
try {
JsonNode node = objectMapper.readTree(json);
return node == null || node.isNull() ? objectMapper.createObjectNode() : node;
} catch (Exception exception) {
return objectMapper.createObjectNode();
}
}
private void normalizeStableRoomInformationPayload(ObjectNode payload) {
if (!isStableRoomInformationPayload(payload)) {
return;
}
JsonNode roomInformationNode = payload.path("room_information");
ObjectNode roomInformation = (ObjectNode) roomInformationNode;
ObjectNode finalValues = (ObjectNode) roomInformation.path("final_values");
String bookingType = textAt(roomInformation, "booking_type");
normalizeRoomInformationDerivedFields(bookingType, finalValues);
removeRoomInformationNonDisplayFields(bookingType, finalValues);
roomInformation.set("change_summary", changeSummary(roomInformation.path("current_values"), finalValues));
}
private List<Map<String, Object>> applyReviewFieldOverrides(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
@@ -869,6 +1580,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
return;
}
if ("room_information".equals(root) && isStableRoomInformationPayload(confirmedPayload)) {
ensureRoomInformationReviewPointerWritable(card, confirmedPayload, segments);
return;
}
if ("business_fields".equals(root)) {
return;
}
@@ -880,6 +1595,23 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
private void ensureRoomInformationReviewPointerWritable(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
List<String> segments) {
if (segments.size() < 3 || !"final_values".equals(segments.get(1))) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
}
List<String> finalValuePath = segments.subList(2, segments.size());
if (finalValuePath.stream().anyMatch(ROOM_INFORMATION_READONLY_FINAL_FIELDS::contains)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
}
String bookingType = textAt(confirmedPayload.path("room_information"), "booking_type");
if (!isRoomInformationPathWritable(card, bookingType, finalValuePath)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Room Information 卡允许编辑字段内。");
}
}
private JsonNode findPointerValue(JsonNode root, List<String> segments) {
JsonNode current = root;
for (String segment : segments) {

View File

@@ -51,7 +51,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
@@ -80,6 +82,17 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
private static final int MAX_MIXED_WORKBENCH_FETCH_SIZE = 10_000;
private static final String DISPLAY_STATUS_BLOCKED = "BLOCKED";
private static final String DISPLAY_STATUS_OPEN = "OPEN";
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";
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 GROUP_BOOKING_STATUS_TEN = "TEN";
private static final Map<String, String> GROUP_BOOKING_STATUS_LABELS = Map.of(
"TEN", "TEN-Tentative",
"DEF", "DEF-Definite",
"INQ", "INQ-Inquiry");
private static final Set<String> V4_BUSINESS_FIELD_ROOTS = Set.of(
"arrival_date",
"departure_date",
@@ -232,15 +245,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
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))
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard))
.orElse(null);
ReservationV4TaskCardResult basicInformationCard = Optional.ofNullable(basicCard)
.map(card -> toCardResult(card, orderAvailability, basicCard))
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard))
.orElse(null);
List<ReservationV4TaskCardResult> businessCards = cards.stream()
.filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()))
.filter(card -> !ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType()))
.map(card -> toCardResult(card, orderAvailability, basicCard))
.map(card -> toCardResult(orderTask, card, orderAvailability, basicCard))
.toList();
ReservationV4SourceMessageSummaryResult sourceSummary = sourceSummaries(
orderTask.hotelId(),
@@ -379,10 +392,11 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
}
private ReservationV4TaskCardResult toCardResult(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
ReservationV4ActionAvailabilityResult orderAvailability,
ReservationV4TaskCardSnapshot basicCard) {
JsonNode displayPayload = parseJson(card.displayPayloadJson());
JsonNode displayPayload = displayPayloadForCard(orderTask, card);
JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson());
JsonNode reviewResolution = parseJson(card.reviewResolutionJson());
JsonNode validationErrors = parseJson(card.validationErrorsJson());
@@ -408,6 +422,27 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
availability);
}
/**
* 生成卡片安全展示 payloadRoom Information 卡在这里补稳定业务展示模型。
*/
private JsonNode displayPayloadForCard(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card) {
JsonNode displayPayload = parseJson(card.displayPayloadJson());
if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) {
return displayPayload;
}
ObjectNode source = (ObjectNode) displayPayload;
ObjectNode safePayload = objectMapper.createObjectNode();
safePayload.put("card_type", firstText(textAt(source, "card_type"), card.cardType()));
safePayload.put("event_type", firstText(card.eventType(), textAt(source, "event_type")));
if (card.sourceEventIndex() != null) {
safePayload.put("source_event_index", card.sourceEventIndex());
}
safePayload.set("room_information", roomInformationDisplayModel(orderTask, card, source));
return safePayload;
}
private ReservationV4TaskCardResult toSourceNotificationCard(
ReservationV4SourceNotificationSnapshot notification,
ReservationV4ActionAvailabilityResult availability) {
@@ -963,9 +998,322 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|| ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) {
return List.of();
}
if (isRoomInformationEventCard(card) && displayPayload.path("room_information").isObject()) {
return roomInformationFields(card, displayPayload.path("room_information"), validationErrors, availability);
}
return businessFields(card, displayPayload, validationErrors, availability);
}
/**
* 构造 Room Information 稳定展示模型,避免前端直接解析 Agent target_order 或 raw event。
*/
private ObjectNode roomInformationDisplayModel(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
ObjectNode displayPayload) {
String eventType = firstText(card.eventType(), textAt(displayPayload, "event_type"));
JsonNode targetOrder = displayPayload.path("target_order");
String bookingType = firstText(textAt(targetOrder, "booking_type"), orderTask.targetBookingType());
String locatorType = firstText(textAt(targetOrder, "locator_type"), orderTask.targetLocatorType());
String locatorValue = firstText(textAt(targetOrder, "locator_value"), orderTask.targetLocatorValue());
ObjectNode currentValues = currentRoomInformationProjection(orderTask, card);
ObjectNode proposedValues = proposedRoomInformationValues(eventType, bookingType, locatorType, locatorValue, displayPayload);
ObjectNode finalValues = finalRoomInformationValues(eventType, bookingType, locatorType, locatorValue, currentValues, proposedValues);
ObjectNode model = objectMapper.createObjectNode();
model.put("event_type", eventType);
model.put("booking_type", bookingType);
model.set("current_values", EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues);
model.set("proposed_values", EVENT_CANCEL_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : proposedValues);
model.set("final_values", finalValues);
model.set("change_summary", changeSummary(EVENT_NEW_BOOKING.equals(eventType) ? objectMapper.createObjectNode() : currentValues, finalValues));
model.set("group_booking_status_options", groupBookingStatusOptions());
return model;
}
/**
* 从同一本地订单更早已确认 Room Information 卡中提取当前订单投影。
*/
private ObjectNode currentRoomInformationProjection(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot currentCard) {
ObjectNode current = objectMapper.createObjectNode();
if (orderTask.orderId() == null) {
return current;
}
List<ReservationV4OrderTaskSnapshot> orderTasks = workflowRepository.findOrderTasksByOrderIds(
orderTask.hotelId(),
List.of(orderTask.orderId()));
List<Long> orderTaskIds = orderTasks.stream().map(ReservationV4OrderTaskSnapshot::id).toList();
List<ReservationV4TaskCardSnapshot> cards = workflowRepository.findTaskCardsByOrderTaskIds(
orderTask.hotelId(),
orderTaskIds);
Set<Long> priorTaskIds = new HashSet<>();
for (ReservationV4OrderTaskSnapshot candidate : orderTasks) {
if (Objects.equals(candidate.id(), orderTask.id())) {
break;
}
priorTaskIds.add(candidate.id());
}
for (ReservationV4TaskCardSnapshot card : cards) {
if (!priorTaskIds.contains(card.v4OrderTaskId())
|| !ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())
|| !ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
continue;
}
ObjectNode finalValues = confirmedRoomInformationFinalValues(card.confirmedPayloadJson());
mergeObject(current, finalValues);
}
normalizeRoomInformationDerivedFields(orderTask.targetBookingType(), current);
return roomInformationDisplayValues(orderTask.targetBookingType(), current, true);
}
private ObjectNode confirmedRoomInformationFinalValues(String confirmedPayloadJson) {
JsonNode payload = parseJson(confirmedPayloadJson);
JsonNode finalValues = payload.path("room_information").path("final_values");
if (finalValues.isObject()) {
return ((ObjectNode) finalValues).deepCopy();
}
JsonNode businessFields = payload.path("business_fields");
if (businessFields.isObject()) {
return ((ObjectNode) businessFields).deepCopy();
}
if (payload.isObject()) {
return ((ObjectNode) payload).deepCopy();
}
return objectMapper.createObjectNode();
}
private ObjectNode proposedRoomInformationValues(
String eventType,
String bookingType,
String locatorType,
String locatorValue,
JsonNode displayPayload) {
if (EVENT_CANCEL_BOOKING.equals(eventType)) {
return objectMapper.createObjectNode();
}
JsonNode businessFields = displayPayload.path("business_fields");
JsonNode source = businessFields.isObject() ? businessFields : displayPayload;
if (EVENT_UPDATE_BOOKING.equals(eventType) && businessFields.path("after").isObject()) {
source = businessFields.path("after");
}
ObjectNode proposed = roomInformationDisplayValues(bookingType, source, EVENT_NEW_BOOKING.equals(eventType));
if (EVENT_NEW_BOOKING.equals(eventType)) {
applyNewBookingNameDefaults(proposed, bookingType, locatorType, locatorValue, source);
}
normalizeRoomInformationDerivedFields(bookingType, proposed);
return proposed;
}
private ObjectNode finalRoomInformationValues(
String eventType,
String bookingType,
String locatorType,
String locatorValue,
ObjectNode currentValues,
ObjectNode proposedValues) {
ObjectNode finalValues = EVENT_NEW_BOOKING.equals(eventType)
? objectMapper.createObjectNode()
: currentValues.deepCopy();
if (!EVENT_CANCEL_BOOKING.equals(eventType)) {
mergeObject(finalValues, proposedValues);
}
if (EVENT_NEW_BOOKING.equals(eventType)) {
applyNewBookingNameDefaults(finalValues, bookingType, locatorType, locatorValue, proposedValues);
}
normalizeRoomInformationDerivedFields(bookingType, finalValues);
return roomInformationDisplayValues(bookingType, finalValues, true);
}
private void applyNewBookingNameDefaults(
ObjectNode values,
String bookingType,
String locatorType,
String locatorValue,
JsonNode source) {
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
if (!hasText(textAt(values, "group_block_name")) && LOCATOR_TYPE_GROUP_CODE.equals(locatorType)) {
values.put("group_block_name", locatorValue);
}
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
String fitName = firstText(textAt(values, "fit_name"), textAt(source, "guest_name"));
if (!hasText(fitName)) {
fitName = locatorValue;
}
if (hasText(fitName)) {
values.put("fit_name", fitName);
}
}
}
private void normalizeRoomInformationDerivedFields(String bookingType, ObjectNode values) {
putNights(values);
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
values.put("breakfast_included", true);
if (!hasText(textAt(values, "group_booking_status"))) {
values.put("group_booking_status", GROUP_BOOKING_STATUS_TEN);
}
putGroupBookingStatusLabel(values);
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
Boolean breakfast = breakfastIncludedFromRateCode(textAt(values, "rate_code"));
if (breakfast != null) {
values.put("breakfast_included", breakfast);
}
}
}
private void removeRoomInformationNonDisplayFields(String bookingType, ObjectNode values) {
ObjectNode filtered = roomInformationDisplayValues(bookingType, values, true);
values.removeAll();
values.setAll(filtered);
}
private void putNights(ObjectNode values) {
values.remove("nights");
LocalDate arrivalDate = parseDate(textAt(values, "arrival_date"));
LocalDate departureDate = parseDate(textAt(values, "departure_date"));
if (arrivalDate != null && departureDate != null && !departureDate.isBefore(arrivalDate)) {
values.put("nights", ChronoUnit.DAYS.between(arrivalDate, departureDate));
}
}
private ObjectNode roomInformationDisplayValues(String bookingType, JsonNode source, boolean includeRateCode) {
ObjectNode values = objectMapper.createObjectNode();
if (source == null || !source.isObject()) {
return values;
}
copyScalarField(source, values, "group_block_name");
copyScalarField(source, values, "fit_name");
copyScalarField(source, values, "arrival_date");
copyScalarField(source, values, "departure_date");
copyScalarField(source, values, "nights");
if (includeRateCode) {
copyScalarField(source, values, "rate_code");
}
copyScalarField(source, values, "breakfast_included");
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
copyScalarField(source, values, "block_id");
copyScalarField(source, values, "group_booking_status");
copyScalarField(source, values, "group_booking_status_label");
} else if (BOOKING_TYPE_FIT.equals(bookingType)) {
copyScalarField(source, values, "confirmation_number");
}
copyRoomItems(source.path("room_items"), values);
return values;
}
private void copyScalarField(JsonNode source, ObjectNode target, String fieldName) {
JsonNode value = source.get(fieldName);
if (value == null || value.isMissingNode() || value.isNull() || value.isContainerNode()) {
return;
}
target.set(fieldName, value);
}
private void copyRoomItems(JsonNode roomItems, ObjectNode target) {
if (roomItems == null || !roomItems.isArray()) {
return;
}
ArrayNode sanitizedItems = objectMapper.createArrayNode();
for (JsonNode item : roomItems) {
if (item == null || !item.isObject()) {
continue;
}
ObjectNode sanitizedItem = objectMapper.createObjectNode();
copyScalarField(item, sanitizedItem, "room_type_code");
copyScalarField(item, sanitizedItem, "room_count");
sanitizedItems.add(sanitizedItem);
}
target.set("room_items", sanitizedItems);
}
private void putGroupBookingStatusLabel(ObjectNode values) {
String status = textAt(values, "group_booking_status");
if (hasText(status)) {
values.put("group_booking_status_label", GROUP_BOOKING_STATUS_LABELS.getOrDefault(status, status));
}
}
private Boolean breakfastIncludedFromRateCode(String rateCode) {
if (!hasText(rateCode)) {
return null;
}
String normalized = rateCode.toUpperCase(Locale.ROOT);
if (normalized.contains("RB")) {
return true;
}
if (normalized.contains("RO")) {
return false;
}
return null;
}
private ArrayNode changeSummary(ObjectNode currentValues, ObjectNode finalValues) {
ArrayNode changes = objectMapper.createArrayNode();
addChangeIfDifferent(changes, "group_block_name", currentValues, finalValues);
addChangeIfDifferent(changes, "fit_name", currentValues, finalValues);
addChangeIfDifferent(changes, "arrival_date", currentValues, finalValues);
addChangeIfDifferent(changes, "departure_date", currentValues, finalValues);
addChangeIfDifferent(changes, "nights", currentValues, finalValues);
addChangeIfDifferent(changes, "rate_code", currentValues, finalValues);
addChangeIfDifferent(changes, "breakfast_included", currentValues, finalValues);
addChangeIfDifferent(changes, "group_booking_status", currentValues, finalValues);
addChangeIfDifferent(changes, "room_items", currentValues, finalValues);
return changes;
}
private void addChangeIfDifferent(ArrayNode changes, String fieldName, ObjectNode currentValues, ObjectNode finalValues) {
JsonNode before = currentValues.get(fieldName);
JsonNode after = finalValues.get(fieldName);
if (Objects.equals(before, after)) {
return;
}
if ((before == null || before.isMissingNode()) && (after == null || after.isMissingNode())) {
return;
}
ObjectNode change = changes.addObject();
change.put("field", fieldName);
change.set("before", before == null ? NullNode.getInstance() : before);
change.set("after", after == null ? NullNode.getInstance() : after);
}
private ArrayNode groupBookingStatusOptions() {
ArrayNode options = objectMapper.createArrayNode();
addGroupBookingStatusOption(options, "TEN");
addGroupBookingStatusOption(options, "DEF");
addGroupBookingStatusOption(options, "INQ");
return options;
}
private void addGroupBookingStatusOption(ArrayNode options, String code) {
ObjectNode option = options.addObject();
option.put("code", code);
option.put("label", GROUP_BOOKING_STATUS_LABELS.get(code));
}
private void mergeObject(ObjectNode target, JsonNode source) {
if (source == null || !source.isObject()) {
return;
}
Iterator<Map.Entry<String, JsonNode>> fields = source.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
target.set(field.getKey(), field.getValue());
}
}
private LocalDate parseDate(String value) {
if (!hasText(value)) {
return null;
}
try {
return LocalDate.parse(value);
} catch (Exception exception) {
return null;
}
}
private List<ReservationV4TaskCardFieldResult> basicInformationFields(
String hotelId,
JsonNode displayPayload,
@@ -1030,6 +1378,164 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
return displayPayload;
}
private List<ReservationV4TaskCardFieldResult> roomInformationFields(
ReservationV4TaskCardSnapshot card,
JsonNode roomInformation,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
String eventType = firstText(card.eventType(), textAt(roomInformation, "event_type"));
if (EVENT_CANCEL_BOOKING.equals(eventType)) {
return List.of();
}
String bookingType = textAt(roomInformation, "booking_type");
JsonNode finalValues = roomInformation.path("final_values");
if (!finalValues.isObject()) {
return List.of();
}
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
if (BOOKING_TYPE_GROUP.equals(bookingType) && EVENT_NEW_BOOKING.equals(eventType)) {
addRoomInformationField(fields, "group_block_name", "Group Block Name", finalValues, validationErrors, availability,
true, "text", null);
}
if (BOOKING_TYPE_FIT.equals(bookingType) && (EVENT_NEW_BOOKING.equals(eventType) || EVENT_UPDATE_BOOKING.equals(eventType))) {
addRoomInformationField(fields, "fit_name", "Fit Name", finalValues, validationErrors, availability,
true, "text", null);
}
addRoomInformationField(fields, "arrival_date", "入住日期", finalValues, validationErrors, availability,
true, "date", null);
addRoomInformationField(fields, "departure_date", "离店日期", finalValues, validationErrors, availability,
true, "date", null);
if (EVENT_NEW_BOOKING.equals(eventType)) {
addRoomInformationField(fields, "rate_code", "Rate Code", finalValues, validationErrors, availability,
true, "select", "reservation_v4_rate_code_catalog");
}
addRoomItemsFields(fields, finalValues.path("room_items"), validationErrors, availability);
if (BOOKING_TYPE_FIT.equals(bookingType) && !finalValues.hasNonNull("breakfast_included")) {
addRoomInformationField(fields, "breakfast_included", "含早", finalValues, validationErrors, availability,
true, "checkbox", null);
}
if (BOOKING_TYPE_GROUP.equals(bookingType)) {
addRoomInformationField(fields, "group_booking_status", "Group Booking Status", finalValues, validationErrors, availability,
true, "select", "reservation_v4_group_booking_status_fixed");
}
return fields;
}
private void addRoomItemsFields(
List<ReservationV4TaskCardFieldResult> fields,
JsonNode roomItems,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
if (roomItems == null || !roomItems.isArray()) {
return;
}
for (int index = 0; index < roomItems.size(); index++) {
JsonNode item = roomItems.get(index);
if (item == null || !item.isObject()) {
continue;
}
addRoomItemField(fields, index, "room_type_code", "房型代码", item, validationErrors, availability,
true, "select", "reservation_v4_room_type_catalog");
addRoomItemField(fields, index, "room_count", "房间数", item, validationErrors, availability,
true, "number", null);
}
}
private void addRoomInformationField(
List<ReservationV4TaskCardFieldResult> fields,
String fieldName,
String displayName,
JsonNode finalValues,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability,
boolean required,
String controlType,
String optionsSource) {
String pointer = "/room_information/final_values/" + escapeJsonPointer(fieldName);
String fieldPath = "room_information.final_values." + fieldName;
fields.add(field(
fieldPath,
pointer,
displayName,
finalValues.path(fieldName),
roomInformationFieldEditable(availability, validationErrors, pointer, finalValues.path(fieldName)),
required,
controlType,
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
optionsSource,
false,
validationMessages(validationErrors, pointer, fieldPath),
null));
}
private void addRoomItemField(
List<ReservationV4TaskCardFieldResult> fields,
int index,
String fieldName,
String displayName,
JsonNode roomItem,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability,
boolean required,
String controlType,
String optionsSource) {
String pointer = "/room_information/final_values/room_items/" + index + "/" + escapeJsonPointer(fieldName);
String fieldPath = "room_information.final_values.room_items." + index + "." + fieldName;
fields.add(field(
fieldPath,
pointer,
displayName,
roomItem.path(fieldName),
roomInformationFieldEditable(availability, validationErrors, pointer, roomItem.path(fieldName)),
required,
controlType,
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
optionsSource,
false,
validationMessages(validationErrors, pointer, fieldPath),
null));
}
private boolean roomInformationFieldEditable(
ReservationV4ActionAvailabilityResult availability,
JsonNode validationErrors,
String pointer,
JsonNode currentValue) {
if (!availability.editable()) {
return false;
}
if (!availability.reviewable()) {
return true;
}
if (validationErrorPointers(validationErrors).contains(pointer)) {
return true;
}
return isUnresolvedReviewLeaf(currentValue);
}
private Set<String> validationErrorPointers(JsonNode validationErrors) {
if (validationErrors == null || !validationErrors.isArray()) {
return Set.of();
}
Set<String> pointers = new HashSet<>();
for (JsonNode item : validationErrors) {
String pointer = textAt(item, "field_pointer");
if (pointer != null && pointer.startsWith("/")) {
pointers.add(pointer);
}
}
return pointers;
}
private boolean isUnresolvedReviewLeaf(JsonNode currentValue) {
if (currentValue == null || currentValue.isMissingNode() || currentValue.isNull()) {
return true;
}
return currentValue.isTextual() && !hasText(currentValue.asText());
}
private List<ReservationV4TaskCardFieldResult> businessFields(
ReservationV4TaskCardSnapshot card,
JsonNode displayPayload,
@@ -1073,6 +1579,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
return V4_BUSINESS_FIELD_ROOTS.contains(fieldName);
}
private boolean isRoomInformationEventCard(ReservationV4TaskCardSnapshot card) {
if (card == null || !ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())) {
return false;
}
return EVENT_NEW_BOOKING.equals(card.eventType())
|| EVENT_UPDATE_BOOKING.equals(card.eventType())
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
}
private void collectBusinessLeafFields(
List<ReservationV4TaskCardFieldResult> fields,
ReservationV4TaskCardSnapshot card,