收口V4 Trace卡后端契约
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Reservation V4 任务卡字段固定选项结果。用于无独立 lookup API 的小范围固定枚举。
|
||||
*
|
||||
* @param value 选项稳定值,前端提交时使用
|
||||
* @param label 选项展示文案
|
||||
*/
|
||||
public record ReservationV4TaskCardFieldOptionResult(
|
||||
@JsonProperty("value")
|
||||
String value,
|
||||
@JsonProperty("label")
|
||||
String label
|
||||
) {
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import java.util.List;
|
||||
* @param editScope 编辑范围,区分普通确认、人工复核和只读
|
||||
* @param writeTarget 用户修改值写入目标
|
||||
* @param optionsSource 选项来源,目录未接口化前使用稳定代码
|
||||
* @param fixedOptions 固定选项列表;仅用于无 lookup API 的小范围枚举
|
||||
* @param rawReadonly 是否来源、派生或诊断字段,只读展示
|
||||
* @param validationErrors 当前字段对应的后端校验错误
|
||||
* @param controlHint 给前端的控件补充提示
|
||||
@@ -39,6 +40,8 @@ public record ReservationV4TaskCardFieldResult(
|
||||
String writeTarget,
|
||||
@JsonProperty("options_source")
|
||||
String optionsSource,
|
||||
@JsonProperty("fixed_options")
|
||||
List<ReservationV4TaskCardFieldOptionResult> fixedOptions,
|
||||
@JsonProperty("raw_readonly")
|
||||
Boolean rawReadonly,
|
||||
@JsonProperty("validation_errors")
|
||||
|
||||
@@ -423,7 +423,7 @@ final class ReservationAiV4CallbackValidator {
|
||||
|| !traceItem.has("department_code")
|
||||
|| !isStringOrNull(traceItem.path("target_room_type_code"))
|
||||
|| !isStringOrNull(traceItem.path("department_code"))
|
||||
|| !isPositiveIntOrNull(traceItem.path("extra_bed_room_count"))) {
|
||||
|| !isNumberOrNull(traceItem.path("extra_bed_room_count"))) {
|
||||
return new ReservationAiV4CallbackIssue("TRACE_EXTRA_BED_ITEM_INVALID", "V4 EXTRA_BED trace item 字段无效。");
|
||||
}
|
||||
continue;
|
||||
@@ -624,6 +624,13 @@ final class ReservationAiV4CallbackValidator {
|
||||
return node != null && (node.isNull() || node.isTextual());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段值是否为数字或 null。Trace 加床数量的正整数业务校验由 V4 卡片校验承接,避免可人工修正值被契约层拦截。
|
||||
*/
|
||||
private boolean isNumberOrNull(JsonNode node) {
|
||||
return node != null && (node.isNull() || node.isNumber());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 JSON 节点是否为布尔 true。
|
||||
*/
|
||||
|
||||
@@ -538,6 +538,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
overlayRoomInformationPayload(orderTask, card, payload, submittedPayload);
|
||||
return;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
overlayTracePayload(payload, submittedPayload);
|
||||
return;
|
||||
}
|
||||
overlayBusinessPayload(card, payload, submittedPayload);
|
||||
}
|
||||
|
||||
@@ -684,6 +688,40 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace 卡只允许前端写入当前正式字段,不接受 content、target_order 或新增结构。
|
||||
*/
|
||||
private void overlayTracePayload(ObjectNode payload, ObjectNode submittedPayload) {
|
||||
JsonNode traceItems = payload.path("trace_items");
|
||||
if (!traceItems.isArray()) {
|
||||
return;
|
||||
}
|
||||
JsonNode submittedTraceItems = submittedPayload.path("trace_items");
|
||||
if (!submittedTraceItems.isArray()) {
|
||||
submittedTraceItems = submittedPayload.path("business_fields").path("trace_items");
|
||||
}
|
||||
if (!submittedTraceItems.isArray()) {
|
||||
return;
|
||||
}
|
||||
int size = Math.min(traceItems.size(), submittedTraceItems.size());
|
||||
for (int index = 0; index < size; index++) {
|
||||
JsonNode currentItem = traceItems.get(index);
|
||||
JsonNode submittedItem = submittedTraceItems.get(index);
|
||||
if (currentItem == null || !currentItem.isObject() || submittedItem == null || !submittedItem.isObject()) {
|
||||
continue;
|
||||
}
|
||||
ObjectNode targetItem = (ObjectNode) currentItem;
|
||||
for (ReservationV4TraceFieldPolicy.TraceEditableField field :
|
||||
ReservationV4TraceFieldPolicy.editableFields(currentItem)) {
|
||||
JsonNode submittedValue = submittedItem.get(field.fieldName());
|
||||
JsonNode normalizedValue = ReservationV4TraceFieldPolicy.normalizedScalarValue(field.fieldName(), submittedValue);
|
||||
if (normalizedValue != null) {
|
||||
targetItem.set(field.fieldName(), normalizedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRoomInformationPathWritable(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String bookingType,
|
||||
@@ -782,12 +820,26 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType()) && "attachment_ids".equals(path.get(0))) {
|
||||
return false;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
return isTraceBusinessPathWritable(path);
|
||||
}
|
||||
if (path.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
|
||||
return false;
|
||||
}
|
||||
return wrappedBusinessFields || BUSINESS_WRITABLE_ROOT_FIELDS.contains(path.get(0));
|
||||
}
|
||||
|
||||
private boolean isTraceBusinessPathWritable(List<String> path) {
|
||||
if (path == null || path.size() != 3 || !"trace_items".equals(path.get(0))) {
|
||||
return false;
|
||||
}
|
||||
String fieldName = path.get(2);
|
||||
return ReservationV4TraceFieldPolicy.FIELD_TEXT.equals(fieldName)
|
||||
|| ReservationV4TraceFieldPolicy.FIELD_DEPARTMENT_CODE.equals(fieldName)
|
||||
|| ReservationV4TraceFieldPolicy.FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName)
|
||||
|| ReservationV4TraceFieldPolicy.FIELD_EXTRA_BED_ROOM_COUNT.equals(fieldName);
|
||||
}
|
||||
|
||||
private void validateAndEnrichConfirmedPayload(String hotelId, ReservationV4TaskCardSnapshot card, ObjectNode payload) {
|
||||
List<String> details = new ArrayList<>();
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
@@ -851,6 +903,16 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
validateRoomInformationPayload(hotelId, card, payload.path("room_information"), roomInformationFinalValues, details);
|
||||
return;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
JsonNode traceItems = ReservationV4TraceFieldPolicy.traceItems(payload);
|
||||
String fieldPath = payload.path("business_fields").isObject() ? "business_fields.trace_items" : "trace_items";
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
traceItems,
|
||||
fieldPath,
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
return;
|
||||
}
|
||||
JsonNode businessFields = payload.path("business_fields");
|
||||
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
|
||||
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
|
||||
@@ -953,6 +1015,12 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("room_items".equals(field.getKey())) {
|
||||
validateRoomItems(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("trace_items".equals(field.getKey())) {
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
field.getValue(),
|
||||
childPath,
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
} else {
|
||||
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
|
||||
}
|
||||
@@ -1018,6 +1086,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
|
||||
}
|
||||
|
||||
private boolean isTraceCard(ReservationV4TaskCardSnapshot card) {
|
||||
return card != null && ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType());
|
||||
}
|
||||
|
||||
private String textValue(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) {
|
||||
return null;
|
||||
@@ -1056,6 +1128,9 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
if (shouldUseStableRoomInformationPayload(card, displayPayload, submittedPayload, null)) {
|
||||
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
return traceConfirmedPayloadBase(card, displayPayload);
|
||||
}
|
||||
return displayPayload;
|
||||
}
|
||||
|
||||
@@ -1070,9 +1145,25 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
if (shouldUseStableRoomInformationPayload(card, displayPayload, null, fieldOverrides)) {
|
||||
return roomInformationConfirmedPayloadBase(orderTask, card, displayPayload);
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
return traceConfirmedPayloadBase(card, displayPayload);
|
||||
}
|
||||
return displayPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Trace 确认基础快照,对外只保留正式 trace_items 字段。
|
||||
*/
|
||||
private ObjectNode traceConfirmedPayloadBase(ReservationV4TaskCardSnapshot card, ObjectNode displayPayload) {
|
||||
ObjectNode payload = objectMapper.createObjectNode();
|
||||
payload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
|
||||
payload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type")));
|
||||
payload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
|
||||
objectMapper,
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
|
||||
return payload;
|
||||
}
|
||||
|
||||
private boolean shouldUseStableRoomInformationPayload(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
JsonNode displayPayload,
|
||||
@@ -1711,9 +1802,10 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null,不能替换对象或数组。");
|
||||
}
|
||||
boolean stableRoomInformationPointer = isStableRoomInformationReviewPointer(confirmedPayload, pointer);
|
||||
boolean tracePointer = isTraceReviewPointer(card, confirmedPayload, segments);
|
||||
JsonNode current = findPointerValue(confirmedPayload, segments);
|
||||
if (current == null || current.isMissingNode()) {
|
||||
if (stableRoomInformationPointer) {
|
||||
if (stableRoomInformationPointer || tracePointer) {
|
||||
return;
|
||||
}
|
||||
logReviewPointerRejected(card, pointer, "CURRENT_POINTER_MISSING", confirmedPayload);
|
||||
@@ -1750,6 +1842,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
ensureRoomInformationReviewPointerWritable(card, confirmedPayload, pointer, segments);
|
||||
return;
|
||||
}
|
||||
if (isTraceCard(card)) {
|
||||
if (!ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload)) {
|
||||
logReviewPointerRejected(card, pointer, "TRACE_POINTER_NOT_WRITABLE", confirmedPayload);
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Trace 卡允许编辑字段内。");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ("business_fields".equals(root)) {
|
||||
return;
|
||||
}
|
||||
@@ -1826,6 +1925,13 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
&& pointer.startsWith("/room_information/final_values/");
|
||||
}
|
||||
|
||||
private boolean isTraceReviewPointer(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload,
|
||||
List<String> segments) {
|
||||
return isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer(segments, confirmedPayload);
|
||||
}
|
||||
|
||||
private List<String> roomInformationQuerySideEditablePointers(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
ObjectNode confirmedPayload) {
|
||||
@@ -1970,6 +2076,11 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
|
||||
&& pointer.startsWith("/room_information/final_values/")) {
|
||||
return true;
|
||||
}
|
||||
if (isTraceCard(card) && ReservationV4TraceFieldPolicy.isWritablePointer(
|
||||
decodeJsonPointer(pointer),
|
||||
confirmedPayload)) {
|
||||
return true;
|
||||
}
|
||||
Set<String> validationPointers = collectValidationErrorPointers(card.validationErrorsJson());
|
||||
if (validationPointers.contains(pointer)) {
|
||||
return true;
|
||||
|
||||
@@ -39,6 +39,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4Source
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldOptionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult;
|
||||
@@ -88,6 +89,10 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
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 List<ReservationV4TaskCardFieldOptionResult> TRACE_DEPARTMENT_FIXED_OPTIONS = List.of(
|
||||
new ReservationV4TaskCardFieldOptionResult("FO", "FO"),
|
||||
new ReservationV4TaskCardFieldOptionResult("HSK", "HSK"),
|
||||
new ReservationV4TaskCardFieldOptionResult("FO+HSK", "FO+HSK"));
|
||||
private static final String LOCATOR_TYPE_GROUP_CODE = "GROUP_CODE";
|
||||
private static final String WRITE_TARGET_CONFIRMED_PAYLOAD = "confirmed_payload";
|
||||
private static final String WRITE_TARGET_REVIEW_FIELD_OVERRIDES = "review_resolution.field_overrides";
|
||||
@@ -460,6 +465,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) {
|
||||
return paymentDisplayPayload(displayPayload, paymentAttachmentSources);
|
||||
}
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
|
||||
return traceDisplayPayload(card, displayPayload);
|
||||
}
|
||||
if (!isRoomInformationEventCard(card) || !displayPayload.isObject()) {
|
||||
return safeBusinessPayload(displayPayload);
|
||||
}
|
||||
@@ -482,6 +490,12 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return safeBasicInformationPayload(confirmedPayload);
|
||||
}
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
|
||||
if (!hasText(card.confirmedPayloadJson())) {
|
||||
return NullNode.getInstance();
|
||||
}
|
||||
return traceDisplayPayload(card, confirmedPayload);
|
||||
}
|
||||
return safeBusinessPayload(confirmedPayload);
|
||||
}
|
||||
|
||||
@@ -532,6 +546,22 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return copied;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Trace 卡安全展示 payload;前端只读取 trace_items[].text 等正式字段。
|
||||
*/
|
||||
private JsonNode traceDisplayPayload(ReservationV4TaskCardSnapshot card, JsonNode displayPayload) {
|
||||
ObjectNode safePayload = objectMapper.createObjectNode();
|
||||
safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
|
||||
safePayload.put("event_type", firstText(card.eventType(), textAt(displayPayload, "event_type")));
|
||||
if (card.sourceEventIndex() != null) {
|
||||
safePayload.put("source_event_index", card.sourceEventIndex());
|
||||
}
|
||||
safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
|
||||
objectMapper,
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
|
||||
return safePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Payment 卡展示 payload,补充付款凭证附件安全摘要,不返回任何 URL 字段。
|
||||
*/
|
||||
@@ -1366,6 +1396,9 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (isRoomInformationEventCard(card) && displayPayload.path("room_information").isObject()) {
|
||||
return roomInformationFields(card, displayPayload.path("room_information"), validationErrors, availability);
|
||||
}
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
|
||||
return traceFields(card, displayPayload, validationErrors, availability);
|
||||
}
|
||||
return businessFields(card, displayPayload, validationErrors, availability);
|
||||
}
|
||||
|
||||
@@ -2033,6 +2066,77 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
|| EVENT_CANCEL_BOOKING.equals(card.eventType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Trace 卡专属字段白名单;字段指针固定使用 /trace_items/{index}/...。
|
||||
*/
|
||||
private List<ReservationV4TaskCardFieldResult> traceFields(
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
JsonNode displayPayload,
|
||||
JsonNode validationErrors,
|
||||
ReservationV4ActionAvailabilityResult availability) {
|
||||
JsonNode traceItems = displayPayload.path("trace_items");
|
||||
if (!traceItems.isArray()) {
|
||||
traceItems = ReservationV4TraceFieldPolicy.traceItems(displayPayload);
|
||||
}
|
||||
if (!traceItems.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
|
||||
for (int index = 0; index < traceItems.size(); index++) {
|
||||
JsonNode traceItem = traceItems.get(index);
|
||||
for (ReservationV4TraceFieldPolicy.TraceEditableField editableField :
|
||||
ReservationV4TraceFieldPolicy.editableFields(traceItem)) {
|
||||
String fieldPath = "trace_items." + index + "." + editableField.fieldName();
|
||||
String pointer = "/trace_items/" + index + "/" + escapeJsonPointer(editableField.fieldName());
|
||||
JsonNode value = traceItem == null ? NullNode.getInstance() : traceItem.get(editableField.fieldName());
|
||||
if (value == null || value.isMissingNode()) {
|
||||
value = NullNode.getInstance();
|
||||
}
|
||||
List<String> fieldValidationErrors = traceValidationMessages(validationErrors, pointer, fieldPath);
|
||||
boolean editable = traceFieldEditable(availability);
|
||||
fields.add(field(
|
||||
fieldPath,
|
||||
pointer,
|
||||
editableField.displayName(),
|
||||
value,
|
||||
editable,
|
||||
true,
|
||||
editableField.controlType(),
|
||||
availability.reviewable() ? "manual_review_only" : "confirm",
|
||||
availability.reviewable() ? WRITE_TARGET_REVIEW_FIELD_OVERRIDES : WRITE_TARGET_CONFIRMED_PAYLOAD,
|
||||
editableField.optionsSource(),
|
||||
traceFixedOptions(editableField),
|
||||
false,
|
||||
fieldValidationErrors,
|
||||
editableField.controlHint()));
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private List<ReservationV4TaskCardFieldOptionResult> traceFixedOptions(
|
||||
ReservationV4TraceFieldPolicy.TraceEditableField editableField) {
|
||||
if (ReservationV4TraceFieldPolicy.OPTIONS_SOURCE_DEPARTMENT.equals(editableField.optionsSource())) {
|
||||
return TRACE_DEPARTMENT_FIXED_OPTIONS;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private boolean traceFieldEditable(ReservationV4ActionAvailabilityResult availability) {
|
||||
return availability.editable();
|
||||
}
|
||||
|
||||
private List<String> traceValidationMessages(JsonNode validationErrors, String pointer, String fieldPath) {
|
||||
List<String> messages = validationMessages(validationErrors, pointer, fieldPath);
|
||||
if (!messages.isEmpty()) {
|
||||
return messages;
|
||||
}
|
||||
return validationMessages(
|
||||
validationErrors,
|
||||
"/business_fields" + pointer,
|
||||
"business_fields." + fieldPath);
|
||||
}
|
||||
|
||||
private void collectBusinessLeafFields(
|
||||
List<ReservationV4TaskCardFieldResult> fields,
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
@@ -2122,6 +2226,38 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
Boolean rawReadonly,
|
||||
List<String> validationErrors,
|
||||
String controlHint) {
|
||||
return field(
|
||||
fieldPath,
|
||||
fieldPointer,
|
||||
displayName,
|
||||
value,
|
||||
editable,
|
||||
required,
|
||||
controlType,
|
||||
editScope,
|
||||
writeTarget,
|
||||
optionsSource,
|
||||
List.of(),
|
||||
rawReadonly,
|
||||
validationErrors,
|
||||
controlHint);
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardFieldResult field(
|
||||
String fieldPath,
|
||||
String fieldPointer,
|
||||
String displayName,
|
||||
JsonNode value,
|
||||
Boolean editable,
|
||||
Boolean required,
|
||||
String controlType,
|
||||
String editScope,
|
||||
String writeTarget,
|
||||
String optionsSource,
|
||||
List<ReservationV4TaskCardFieldOptionResult> fixedOptions,
|
||||
Boolean rawReadonly,
|
||||
List<String> validationErrors,
|
||||
String controlHint) {
|
||||
return new ReservationV4TaskCardFieldResult(
|
||||
fieldPath,
|
||||
fieldPointer,
|
||||
@@ -2133,6 +2269,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
editScope,
|
||||
writeTarget,
|
||||
optionsSource,
|
||||
fixedOptions == null ? List.of() : fixedOptions,
|
||||
rawReadonly,
|
||||
validationErrors == null ? List.of() : validationErrors,
|
||||
controlHint);
|
||||
|
||||
@@ -214,7 +214,9 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
Long orderTaskId,
|
||||
ReservationV4AcceptedEventDraft acceptedEvent,
|
||||
LocalDateTime now) {
|
||||
ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson());
|
||||
ObjectNode displayPayload = normalizedBusinessDisplayPayload(
|
||||
acceptedEvent.eventType(),
|
||||
objectJson(acceptedEvent.displayPayloadJson()));
|
||||
List<String> validationDetails = validateBusinessCardDisplayPayload(sourceMessage.hotelId(), displayPayload);
|
||||
boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty();
|
||||
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
|
||||
@@ -235,6 +237,22 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化业务卡展示快照;Trace 卡只保留正式字段,避免 content 或原始定位字段进入后端存储展示层。
|
||||
*/
|
||||
private ObjectNode normalizedBusinessDisplayPayload(String eventType, ObjectNode displayPayload) {
|
||||
if (!"TRACE_RESERVATION_NOTES".equals(eventType)) {
|
||||
return displayPayload;
|
||||
}
|
||||
ObjectNode safePayload = objectMapper.createObjectNode();
|
||||
safePayload.put("card_type", ReservationV4CardType.TRACE_RESERVATION_NOTES.name());
|
||||
safePayload.put("event_type", eventType);
|
||||
safePayload.set("trace_items", ReservationV4TraceFieldPolicy.sanitizedTraceItems(
|
||||
objectMapper,
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload)));
|
||||
return safePayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 order_ref 保留 V4 event 原始数组顺序。
|
||||
*/
|
||||
@@ -399,6 +417,14 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
*/
|
||||
private List<String> validateBusinessCardDisplayPayload(String hotelId, ObjectNode displayPayload) {
|
||||
List<String> details = new ArrayList<>();
|
||||
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(textAt(displayPayload, "card_type"))) {
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
ReservationV4TraceFieldPolicy.traceItems(displayPayload),
|
||||
"trace_items",
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
return details;
|
||||
}
|
||||
JsonNode businessFields = displayPayload.path("business_fields");
|
||||
JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload;
|
||||
validateBusinessCatalogFields(hotelId, fieldRoot, "business_fields", details);
|
||||
@@ -420,6 +446,12 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
|
||||
validateRoomTypeCode(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("room_items".equals(field.getKey())) {
|
||||
validateRoomItems(hotelId, field.getValue(), childPath, details);
|
||||
} else if ("trace_items".equals(field.getKey())) {
|
||||
ReservationV4TraceFieldPolicy.validateTraceItems(
|
||||
field.getValue(),
|
||||
childPath,
|
||||
details,
|
||||
code -> directoryService.isKnownRoomTypeCode(hotelId, code));
|
||||
} else {
|
||||
validateBusinessCatalogFields(hotelId, field.getValue(), childPath, details);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
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.NullNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Reservation V4 Trace 卡字段策略。统一查询展示、确认写入、复核指针和字段校验的白名单。
|
||||
*/
|
||||
final class ReservationV4TraceFieldPolicy {
|
||||
|
||||
static final String OPTIONS_SOURCE_DEPARTMENT = "reservation_v4_trace_department_fixed";
|
||||
static final String OPTIONS_SOURCE_ROOM_TYPE = "reservation_v4_room_type_catalog";
|
||||
static final String ITEM_TYPE_GENERAL = "GENERAL";
|
||||
static final String ITEM_TYPE_EXTRA_BED = "EXTRA_BED";
|
||||
static final String FIELD_TEXT = "text";
|
||||
static final String FIELD_DEPARTMENT_CODE = "department_code";
|
||||
static final String FIELD_TARGET_ROOM_TYPE_CODE = "target_room_type_code";
|
||||
static final String FIELD_EXTRA_BED_ROOM_COUNT = "extra_bed_room_count";
|
||||
private static final String FIELD_ITEM_TYPE = "item_type";
|
||||
private static final Set<String> ALLOWED_DEPARTMENT_CODES = Set.of("FO", "HSK", "FO+HSK");
|
||||
|
||||
private ReservationV4TraceFieldPolicy() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前 payload 中提取 Trace 事项数组,兼容历史 business_fields 包裹结构。
|
||||
*/
|
||||
static JsonNode traceItems(JsonNode payload) {
|
||||
if (payload == null || payload.isMissingNode() || payload.isNull()) {
|
||||
return NullNode.getInstance();
|
||||
}
|
||||
JsonNode businessFields = payload.path("business_fields");
|
||||
if (businessFields.isObject()) {
|
||||
return businessFields.path("trace_items");
|
||||
}
|
||||
return payload.path("trace_items");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成安全 Trace 事项数组,只保留当前后端正式字段,不透出 content、target_order 或原始证据。
|
||||
*/
|
||||
static ArrayNode sanitizedTraceItems(ObjectMapper objectMapper, JsonNode traceItems) {
|
||||
ArrayNode sanitizedItems = objectMapper.createArrayNode();
|
||||
if (traceItems == null || !traceItems.isArray()) {
|
||||
return sanitizedItems;
|
||||
}
|
||||
for (JsonNode traceItem : traceItems) {
|
||||
if (traceItem == null || !traceItem.isObject()) {
|
||||
continue;
|
||||
}
|
||||
ObjectNode sanitizedItem = objectMapper.createObjectNode();
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_ITEM_TYPE);
|
||||
String itemType = textAt(traceItem, FIELD_ITEM_TYPE);
|
||||
if (ITEM_TYPE_GENERAL.equals(itemType)) {
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TEXT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
|
||||
} else if (ITEM_TYPE_EXTRA_BED.equals(itemType)) {
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
|
||||
} else {
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TEXT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_TARGET_ROOM_TYPE_CODE);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_EXTRA_BED_ROOM_COUNT);
|
||||
copyScalarField(traceItem, sanitizedItem, FIELD_DEPARTMENT_CODE);
|
||||
}
|
||||
sanitizedItems.add(sanitizedItem);
|
||||
}
|
||||
return sanitizedItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回指定 Trace item 当前允许前端编辑的字段清单。
|
||||
*/
|
||||
static List<TraceEditableField> editableFields(JsonNode traceItem) {
|
||||
String itemType = textAt(traceItem, FIELD_ITEM_TYPE);
|
||||
if (ITEM_TYPE_EXTRA_BED.equals(itemType)) {
|
||||
return List.of(
|
||||
new TraceEditableField(FIELD_TARGET_ROOM_TYPE_CODE, "目标房型", "select",
|
||||
OPTIONS_SOURCE_ROOM_TYPE, "Room Type 使用酒店目录 lookup,不能自由输入。"),
|
||||
new TraceEditableField(FIELD_EXTRA_BED_ROOM_COUNT, "加床房间数", "number",
|
||||
null, "加床房间数必须是正整数。"),
|
||||
new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select",
|
||||
OPTIONS_SOURCE_DEPARTMENT, "固定选项:FO / HSK / FO+HSK。"));
|
||||
}
|
||||
return List.of(
|
||||
new TraceEditableField(FIELD_TEXT, "Trace 内容", "textarea",
|
||||
null, "普通 Trace 事项内容,以 trace_items[].text 为准。"),
|
||||
new TraceEditableField(FIELD_DEPARTMENT_CODE, "Department Code", "select",
|
||||
OPTIONS_SOURCE_DEPARTMENT, "固定选项:FO / HSK / FO+HSK。"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断复核或确认写入 pointer 是否属于 Trace 卡当前正式字段白名单。
|
||||
*/
|
||||
static boolean isWritablePointer(List<String> segments, JsonNode payload) {
|
||||
if (segments == null || segments.size() != 3 || !"trace_items".equals(segments.get(0))) {
|
||||
return false;
|
||||
}
|
||||
Integer index = parseArrayIndex(segments.get(1));
|
||||
if (index == null) {
|
||||
return false;
|
||||
}
|
||||
JsonNode traceItems = traceItems(payload);
|
||||
if (!traceItems.isArray() || index >= traceItems.size()) {
|
||||
return false;
|
||||
}
|
||||
String fieldName = segments.get(2);
|
||||
for (TraceEditableField field : editableFields(traceItems.get(index))) {
|
||||
if (field.fieldName().equals(fieldName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Trace 字段值,字段路径由调用方决定是否带 business_fields 前缀。
|
||||
*/
|
||||
static void validateTraceItems(
|
||||
JsonNode traceItems,
|
||||
String fieldPath,
|
||||
List<String> details,
|
||||
Predicate<String> roomTypeExists) {
|
||||
if (traceItems == null || traceItems.isMissingNode() || traceItems.isNull()) {
|
||||
details.add(fieldPath + ": Trace 事项不能为空。");
|
||||
return;
|
||||
}
|
||||
if (!traceItems.isArray() || traceItems.isEmpty()) {
|
||||
details.add(fieldPath + ": Trace 事项必须是非空数组。");
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < traceItems.size(); index++) {
|
||||
JsonNode item = traceItems.get(index);
|
||||
String itemPath = fieldPath + "." + index;
|
||||
if (item == null || !item.isObject()) {
|
||||
details.add(itemPath + ": Trace 事项必须是对象。");
|
||||
continue;
|
||||
}
|
||||
String itemType = textAt(item, FIELD_ITEM_TYPE);
|
||||
if (!ITEM_TYPE_GENERAL.equals(itemType) && !ITEM_TYPE_EXTRA_BED.equals(itemType)) {
|
||||
details.add(itemPath + ".item_type: Trace item_type 只能为 GENERAL 或 EXTRA_BED。");
|
||||
continue;
|
||||
}
|
||||
validateDepartmentCode(item, itemPath, details);
|
||||
if (ITEM_TYPE_GENERAL.equals(itemType)) {
|
||||
validateRequiredText(item, FIELD_TEXT, itemPath + "." + FIELD_TEXT, "Trace 内容", details);
|
||||
} else {
|
||||
validateTargetRoomTypeCode(item, itemPath, details, roomTypeExists);
|
||||
validateExtraBedRoomCount(item, itemPath, details);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Department Code 是否属于第一版固定三值。
|
||||
*/
|
||||
static boolean isAllowedDepartmentCode(String departmentCode) {
|
||||
return departmentCode != null && ALLOWED_DEPARTMENT_CODES.contains(departmentCode);
|
||||
}
|
||||
|
||||
private static void validateDepartmentCode(JsonNode item, String itemPath, List<String> details) {
|
||||
String departmentCode = textAt(item, FIELD_DEPARTMENT_CODE);
|
||||
if (!isAllowedDepartmentCode(departmentCode)) {
|
||||
details.add(itemPath + "." + FIELD_DEPARTMENT_CODE + ": Department Code 只允许 FO、HSK、FO+HSK。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateTargetRoomTypeCode(
|
||||
JsonNode item,
|
||||
String itemPath,
|
||||
List<String> details,
|
||||
Predicate<String> roomTypeExists) {
|
||||
String code = textAt(item, FIELD_TARGET_ROOM_TYPE_CODE);
|
||||
if (code == null) {
|
||||
details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 加床目标房型不能为空。");
|
||||
return;
|
||||
}
|
||||
if (roomTypeExists != null && !roomTypeExists.test(code)) {
|
||||
details.add(itemPath + "." + FIELD_TARGET_ROOM_TYPE_CODE + ": 房型代码不在第一版目录中。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateExtraBedRoomCount(JsonNode item, String itemPath, List<String> details) {
|
||||
JsonNode value = item.get(FIELD_EXTRA_BED_ROOM_COUNT);
|
||||
if (value == null
|
||||
|| value.isNull()
|
||||
|| !value.isIntegralNumber()
|
||||
|| value.bigIntegerValue().compareTo(BigInteger.ZERO) <= 0
|
||||
|| value.bigIntegerValue().compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) {
|
||||
details.add(itemPath + "." + FIELD_EXTRA_BED_ROOM_COUNT + ": 加床房间数必须为正整数。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateRequiredText(
|
||||
JsonNode item,
|
||||
String fieldName,
|
||||
String fieldPath,
|
||||
String displayName,
|
||||
List<String> details) {
|
||||
if (textAt(item, fieldName) == null) {
|
||||
details.add(fieldPath + ": " + displayName + "不能为空。");
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyScalarField(JsonNode source, ObjectNode target, String fieldName) {
|
||||
JsonNode value = source.get(fieldName);
|
||||
JsonNode normalizedValue = normalizedScalarValue(fieldName, value);
|
||||
if (normalizedValue == null) {
|
||||
return;
|
||||
}
|
||||
target.set(fieldName, normalizedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 Trace 稳定代码字段,避免查询、确认和复核因为前后空格产生目录校验或展示差异。
|
||||
*/
|
||||
static JsonNode normalizedScalarValue(String fieldName, JsonNode value) {
|
||||
if (value == null || value.isMissingNode() || value.isContainerNode()) {
|
||||
return null;
|
||||
}
|
||||
if (value.isTextual() && isStableCodeField(fieldName)) {
|
||||
return TextNode.valueOf(value.asText().trim());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean isStableCodeField(String fieldName) {
|
||||
return FIELD_ITEM_TYPE.equals(fieldName)
|
||||
|| FIELD_DEPARTMENT_CODE.equals(fieldName)
|
||||
|| FIELD_TARGET_ROOM_TYPE_CODE.equals(fieldName);
|
||||
}
|
||||
|
||||
private static String textAt(JsonNode node, String fieldName) {
|
||||
if (node == null || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
JsonNode value = node.get(fieldName);
|
||||
if (value == null || value.isNull() || !value.isTextual()) {
|
||||
return null;
|
||||
}
|
||||
String text = value.asText().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private static Integer parseArrayIndex(String segment) {
|
||||
if (segment == null || segment.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
if (segment.length() > 1 && segment.startsWith("0")) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < segment.length(); i++) {
|
||||
if (!Character.isDigit(segment.charAt(i))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(segment);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace 前端可编辑字段元数据。
|
||||
*
|
||||
* @param fieldName 字段名
|
||||
* @param displayName 中文展示名
|
||||
* @param controlType 前端控件类型
|
||||
* @param optionsSource 选项来源
|
||||
* @param controlHint 控件提示
|
||||
*/
|
||||
record TraceEditableField(
|
||||
String fieldName,
|
||||
String displayName,
|
||||
String controlType,
|
||||
String optionsSource,
|
||||
String controlHint) {
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,11 @@ class ReservationV4CommandControllerTest {
|
||||
"mail-v4-command-confirm-unknown-trace-target-room-001",
|
||||
Instant.parse("2026-07-19T01:13:05Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-UNKNOWN-ROOM-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","text":"Please add one extra bed.","target_room_type_code":"UNKNOWN_ROOM","extra_bed_room_count":1,"department_code":"FO"}]}}
|
||||
""");
|
||||
@@ -572,7 +577,217 @@ class ReservationV4CommandControllerTest {
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("business_fields.trace_items.0.target_room_type_code: 房型代码不在第一版目录中。"));
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.target_room_type_code: 房型代码不在第一版目录中。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmWhenTraceDepartmentCodeInvalid() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-invalid-trace-department-001",
|
||||
Instant.parse("2026-07-19T01:13:06Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-DEPARTMENT-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"GENERAL","text":"Guest requests quiet rooms.","department_code":"SALES"}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountNotPositive() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-invalid-trace-extra-bed-count-001",
|
||||
Instant.parse("2026-07-19T01:13:07Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":0,"department_code":"FO+HSK"}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmWhenTraceExtraBedRoomCountDecimal() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-decimal-trace-extra-bed-count-001",
|
||||
Instant.parse("2026-07-19T01:13:07Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-EXTRA-BED-COUNT-DECIMAL-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1.5,"department_code":"FO+HSK"}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value("trace_items.0.extra_bed_room_count: 加床房间数必须为正整数。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeTraceStableCodesWhenConfirming() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-trace-normalize-codes-001",
|
||||
Instant.parse("2026-07-19T01:13:07Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-NORMALIZE-CODES-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{"card_type":"TRACE_RESERVATION_NOTES","event_type":"TRACE_RESERVATION_NOTES","business_fields":{"order_ref":"ORDER-COMMAND","event_type":"TRACE_RESERVATION_NOTES","trace_items":[{"item_type":"EXTRA_BED","target_room_type_code":" TWN ","extra_bed_room_count":1,"department_code":" FO+HSK "}]}}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"trace_items": [
|
||||
{
|
||||
"item_type": "EXTRA_BED",
|
||||
"target_room_type_code": " DBL ",
|
||||
"extra_bed_room_count": 2,
|
||||
"department_code": " FO "
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].target_room_type_code")
|
||||
.value("DBL"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
|
||||
.value("FO"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmTraceCardWithOnlyWhitelistedFields() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-trace-whitelist-001",
|
||||
Instant.parse("2026-07-19T01:13:08Z"),
|
||||
null,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-TRACE-WHITELIST-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{
|
||||
"card_type":"TRACE_RESERVATION_NOTES",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE-TARGET"},
|
||||
"business_fields":{
|
||||
"trace_items":[
|
||||
{"item_type":"GENERAL","text":"Original trace","department_code":"FO","content":"SHOULD-NOT-PERSIST-CONTENT"},
|
||||
{"item_type":"EXTRA_BED","target_room_type_code":"TWN","extra_bed_room_count":1,"department_code":"FO+HSK","content":"SHOULD-NOT-PERSIST-EXTRA-CONTENT","attachment_url":"https://oss.example.test/trace-confirm-secret.png"}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"target_order": {"locator_value": "SHOULD-NOT-PERSIST-SUBMITTED-TARGET"},
|
||||
"trace_items": [
|
||||
{
|
||||
"item_type": "GENERAL",
|
||||
"text": "Updated trace",
|
||||
"department_code": "HSK",
|
||||
"content": "SHOULD-NOT-PERSIST-SUBMITTED-CONTENT"
|
||||
},
|
||||
{
|
||||
"item_type": "EXTRA_BED",
|
||||
"target_room_type_code": "DBL",
|
||||
"extra_bed_room_count": 2,
|
||||
"department_code": "FO",
|
||||
"content": "SHOULD-NOT-PERSIST-SUBMITTED-EXTRA-CONTENT",
|
||||
"attachment_url": "https://oss.example.test/trace-submitted-secret.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].text")
|
||||
.value("Updated trace"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
|
||||
.value("HSK"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].target_room_type_code")
|
||||
.value("DBL"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].extra_bed_room_count")
|
||||
.value(2))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].department_code")
|
||||
.value("FO"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[1].attachment_url").doesNotExist())
|
||||
.andExpect(content().string(not(containsString("SHOULD-NOT-PERSIST"))))
|
||||
.andExpect(content().string(not(containsString("https://oss.example.test"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1266,6 +1481,95 @@ class ReservationV4CommandControllerTest {
|
||||
.value("RM2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptEditableTraceReviewFieldPointersReturnedByTaskDetail() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-review-trace-pointer-runtime-001",
|
||||
Instant.parse("2026-07-19T01:22:28Z"),
|
||||
990000000000070126L,
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
"""
|
||||
{
|
||||
"card_type":"TRACE_RESERVATION_NOTES",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"target_order":{"locator_value":"SHOULD-NOT-PERSIST-TRACE"},
|
||||
"business_fields":{
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"trace_items":[
|
||||
{
|
||||
"item_type":"GENERAL",
|
||||
"text":"Guest requests quiet rooms.",
|
||||
"department_code":"SALES",
|
||||
"content":"SHOULD-NOT-PERSIST-CONTENT"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""",
|
||||
"""
|
||||
[
|
||||
{
|
||||
"field_path": "trace_items.0.department_code",
|
||||
"field_pointer": "/trace_items/0/department_code",
|
||||
"message": "Department Code 只允许 FO、HSK、FO+HSK。",
|
||||
"detail": "trace_items.0.department_code: Department Code 只允许 FO、HSK、FO+HSK。"
|
||||
}
|
||||
]
|
||||
""");
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
MvcResult detailResult = performAuthorized(mockMvc, adminToken(),
|
||||
get("/api/reservation/order-tasks/{orderTaskId}", seeded.orderTask().id())
|
||||
.param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
JsonNode detail = objectMapper.readTree(detailResult.getResponse().getContentAsString());
|
||||
ArrayNode overrides = objectMapper.createArrayNode();
|
||||
for (JsonNode field : detail.path("business_cards").get(0).path("fields")) {
|
||||
if (!field.path("editable").asBoolean(false)
|
||||
|| !"review_resolution.field_overrides".equals(field.path("write_target").asText())) {
|
||||
continue;
|
||||
}
|
||||
String pointer = field.path("field_pointer").asText();
|
||||
ObjectNode override = objectMapper.createObjectNode();
|
||||
override.put("field_pointer", pointer);
|
||||
override.set("value", reviewOverrideValue(pointer));
|
||||
overrides.add(override);
|
||||
}
|
||||
org.assertj.core.api.Assertions.assertThat(overrides)
|
||||
.as("Trace 详情返回的可复核字段必须能直接提交给 review-resolution")
|
||||
.isNotEmpty();
|
||||
org.assertj.core.api.Assertions.assertThat(overrides.toString())
|
||||
.contains("/trace_items/0/text")
|
||||
.contains("/trace_items/0/department_code")
|
||||
.doesNotContain("/business_fields/");
|
||||
|
||||
ObjectNode request = objectMapper.createObjectNode();
|
||||
request.put("version", 0);
|
||||
request.put("reason", "回归验证 Trace 详情页可编辑 pointer 与命令侧白名单一致");
|
||||
request.set("field_overrides", overrides);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post(
|
||||
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].text")
|
||||
.value("UPDATED"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].department_code")
|
||||
.value("FO"))
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.trace_items[0].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.target_order").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveEditableRoomInformationPointerWhenLeafMissingInPayload() throws Exception {
|
||||
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
|
||||
@@ -2111,6 +2415,12 @@ class ReservationV4CommandControllerTest {
|
||||
if (pointer.endsWith("/room_count")) {
|
||||
return objectMapper.getNodeFactory().numberNode(2);
|
||||
}
|
||||
if (pointer.endsWith("/extra_bed_room_count")) {
|
||||
return objectMapper.getNodeFactory().numberNode(1);
|
||||
}
|
||||
if (pointer.endsWith("/department_code")) {
|
||||
return objectMapper.getNodeFactory().textNode("FO");
|
||||
}
|
||||
if (pointer.endsWith("/group_booking_status")) {
|
||||
return objectMapper.getNodeFactory().textNode("TEN");
|
||||
}
|
||||
|
||||
@@ -789,6 +789,77 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(content().string(not(containsString("att-payment-real-external-001"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnStableTraceFieldsAndSafeDisplayPayload() throws Exception {
|
||||
ReservationV4OrderTaskSnapshot orderTask = seedOrderTaskWithBusinessCardType(
|
||||
"mail-v4-query-trace-fields-001",
|
||||
Instant.parse("2026-07-18T02:45:00Z"),
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-TRACE-FIELDS-001",
|
||||
ReservationV4CardType.TRACE_RESERVATION_NOTES.name(),
|
||||
"TRACE_RESERVATION_NOTES",
|
||||
ReservationV4CardStatus.CONFIRMED.name(),
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(),
|
||||
"""
|
||||
{
|
||||
"card_type":"TRACE_RESERVATION_NOTES",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"target_order":{"locator_value":"SHOULD-NOT-LEAK-TRACE"},
|
||||
"business_fields":{
|
||||
"order_ref":"ORDER-TRACE",
|
||||
"event_type":"TRACE_RESERVATION_NOTES",
|
||||
"trace_items":[
|
||||
{
|
||||
"item_type":"GENERAL",
|
||||
"text":"Guest requests quiet rooms.",
|
||||
"content":"SHOULD-NOT-LEAK-CONTENT",
|
||||
"department_code":"FO"
|
||||
},
|
||||
{
|
||||
"item_type":"EXTRA_BED",
|
||||
"content":"SHOULD-NOT-LEAK-EXTRA-BED-CONTENT",
|
||||
"target_room_type_code":"TWN",
|
||||
"extra_bed_room_count":1,
|
||||
"department_code":"FO+HSK",
|
||||
"raw_evidence":"SHOULD-NOT-LEAK-RAW",
|
||||
"attachment_url":"https://oss.example.test/trace-secret.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""",
|
||||
null);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
|
||||
.param("hotel_id", HOTEL_ID))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].text")
|
||||
.value("Guest requests quiet rooms."))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].target_room_type_code")
|
||||
.value("TWN"))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].extra_bed_room_count")
|
||||
.value(1))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.business_fields").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.target_order").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[0].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.trace_items[1].content").doesNotExist())
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].editable")
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/text')].control_type")
|
||||
.value(contains("textarea")))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/0/department_code')].options_source")
|
||||
.value(contains("reservation_v4_trace_department_fixed")))
|
||||
.andExpect(content().string(containsString(
|
||||
"\"fixed_options\":[{\"value\":\"FO\",\"label\":\"FO\"},{\"value\":\"HSK\",\"label\":\"HSK\"},{\"value\":\"FO+HSK\",\"label\":\"FO+HSK\"}]")))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/target_room_type_code')].options_source")
|
||||
.value(contains("reservation_v4_room_type_catalog")))
|
||||
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/trace_items/1/extra_bed_room_count')].control_type")
|
||||
.value(contains("number")))
|
||||
.andExpect(content().string(not(containsString("SHOULD-NOT-LEAK"))))
|
||||
.andExpect(content().string(not(containsString("https://oss.example.test"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnReadonlyRoomInformationDisplayModelForCancelBooking() throws Exception {
|
||||
Long orderId = 990000000000777002L;
|
||||
|
||||
@@ -1383,10 +1383,64 @@ class SuperAgentTaskResultControllerTest {
|
||||
LIMIT 1
|
||||
""", String.class, source.inboxId());
|
||||
assertThat(traceValidationErrors)
|
||||
.contains("business_fields.trace_items.0.target_room_type_code")
|
||||
.contains("trace_items.0.target_room_type_code")
|
||||
.contains("房型代码不在第一版目录中");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkV4TraceReviewRequiredWhenDepartmentCodeInvalid() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-department-001");
|
||||
String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-department-001")
|
||||
.replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-DEPARTMENT-001")
|
||||
.replace("\"department_code\": \"FO\"", "\"department_code\": \"SALES\"");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-department-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(3));
|
||||
|
||||
String traceValidationErrors = jdbcTemplate.queryForObject("""
|
||||
SELECT validation_errors_json
|
||||
FROM workflow_reservation_v4_task_card
|
||||
WHERE source_message_id = ?
|
||||
AND card_type = 'TRACE_RESERVATION_NOTES'
|
||||
AND card_status = 'REVIEW_REQUIRED'
|
||||
AND review_status = 'PENDING'
|
||||
LIMIT 1
|
||||
""", String.class, source.inboxId());
|
||||
assertThat(traceValidationErrors)
|
||||
.contains("trace_items.0.department_code")
|
||||
.contains("Department Code 只允许 FO、HSK、FO+HSK。");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMarkV4TraceReviewRequiredWhenExtraBedRoomCountInvalid() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-trace-invalid-extra-bed-count-001");
|
||||
String body = v4CancelTraceRoomingListBody("mail-v4-trace-invalid-extra-bed-count-001")
|
||||
.replace("GRP-V4-CTR-001", "GRP-V4-TRACE-INVALID-EXTRA-BED-COUNT-001")
|
||||
.replace("\"item_type\": \"GENERAL\"", "\"item_type\": \"EXTRA_BED\"")
|
||||
.replace("\"text\": \"Guest requests quiet rooms.\"", "\"text\": \"Please add one extra bed.\"")
|
||||
.replace("\"department_code\": \"FO\"", "\"target_room_type_code\": \"TWN\",\n"
|
||||
+ " \"extra_bed_room_count\": 0,\n"
|
||||
+ " \"department_code\": \"FO\"");
|
||||
|
||||
mockMvc.perform(signedPost(body, "nonce-v4-trace-invalid-extra-bed-count-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(3));
|
||||
|
||||
String traceValidationErrors = jdbcTemplate.queryForObject("""
|
||||
SELECT validation_errors_json
|
||||
FROM workflow_reservation_v4_task_card
|
||||
WHERE source_message_id = ?
|
||||
AND card_type = 'TRACE_RESERVATION_NOTES'
|
||||
AND card_status = 'REVIEW_REQUIRED'
|
||||
AND review_status = 'PENDING'
|
||||
LIMIT 1
|
||||
""", String.class, source.inboxId());
|
||||
assertThat(traceValidationErrors)
|
||||
.contains("trace_items.0.extra_bed_room_count")
|
||||
.contains("加床房间数必须为正整数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");
|
||||
|
||||
Reference in New Issue
Block a user