修复V4房型信息展示安全边界
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成卡片安全展示 payload;Room 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,
|
||||
|
||||
Reference in New Issue
Block a user