修复 M002 V3 P0 回归校验问题
This commit is contained in:
@@ -36,7 +36,9 @@ import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -65,6 +67,86 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
private static final int LENGTH_256 = 256;
|
||||
private static final int LENGTH_512 = 512;
|
||||
private static final int MAX_QUEUE_ORDER_RETRY = 5;
|
||||
private static final List<String> V3_SOURCE_MESSAGE_KEYS = List.of(
|
||||
"source_message_id",
|
||||
"subject",
|
||||
"from",
|
||||
"cc",
|
||||
"received_at",
|
||||
"source_channel");
|
||||
private static final List<String> V3_MAIN_OUTCOME_KEYS = List.of(
|
||||
"source_message",
|
||||
"route_code",
|
||||
"handler_type",
|
||||
"result_type",
|
||||
"current_or_history",
|
||||
"agent_assessment",
|
||||
"notification",
|
||||
"manual_review");
|
||||
private static final List<String> V3_AGENT_ASSESSMENT_KEYS = List.of(
|
||||
"status",
|
||||
"reason_code",
|
||||
"automation_action");
|
||||
private static final List<String> V3_NOTIFICATION_KEYS = List.of(
|
||||
"required",
|
||||
"notification_type",
|
||||
"show_source_message",
|
||||
"requires_user_decision",
|
||||
"visible_message");
|
||||
private static final List<String> V3_REVIEW_KEYS = List.of(
|
||||
"reason_code",
|
||||
"visible_reason",
|
||||
"review_record_type",
|
||||
"missing_fields",
|
||||
"blocking_points",
|
||||
"conflicting_points",
|
||||
"suggested_human_actions",
|
||||
"evidence_to_check",
|
||||
"known_fields");
|
||||
private static final List<String> V3_BUSINESS_ROOT_KEYS = List.of(
|
||||
"source_message",
|
||||
"message_events",
|
||||
"case_candidates",
|
||||
"extraction_warnings",
|
||||
"unhandled_current_intents");
|
||||
private static final List<String> V3_EVENT_REQUIRED_KEYS = List.of(
|
||||
"event_type",
|
||||
"event_role",
|
||||
"current_or_history",
|
||||
"source_event_index",
|
||||
"case_keys",
|
||||
"relevant_message_excerpt",
|
||||
"attachments",
|
||||
"file_references",
|
||||
"context_used",
|
||||
"extracted_fields",
|
||||
"manual_review");
|
||||
private static final List<String> V3_CASE_KEY_KEYS = List.of(
|
||||
"group_code",
|
||||
"confirmation_number",
|
||||
"reservation_number",
|
||||
"block_code");
|
||||
private static final List<String> V3_REVIEW_ARRAY_KEYS = List.of(
|
||||
"missing_fields",
|
||||
"blocking_points",
|
||||
"conflicting_points",
|
||||
"suggested_human_actions",
|
||||
"evidence_to_check");
|
||||
private static final Set<String> V3_ACTIVE_EVENT_TYPES = Set.of(
|
||||
"New Booking",
|
||||
"Update Booking / Amendment",
|
||||
"Cancel Booking",
|
||||
"Cancel Allotment",
|
||||
"Voucher Received",
|
||||
"Payment Evidence",
|
||||
"Rooming List",
|
||||
"AMEND GROUP CODE",
|
||||
"Invoice Generation",
|
||||
"Invoice Received",
|
||||
"Payment Notice",
|
||||
"Trace",
|
||||
"Manual RateCode",
|
||||
"TA RECORDER");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final SourceMessageInboxRepository sourceMessageInboxRepository;
|
||||
@@ -192,7 +274,11 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
String requestId,
|
||||
String defaultHotelId,
|
||||
String rawBody) {
|
||||
String routeCode = requireText(textAt(root, "route_code"), "route_code", LENGTH_64);
|
||||
String routeCode = trimToNull(textAt(root, "route_code"));
|
||||
if (routeCode == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 route_code 缺失。");
|
||||
}
|
||||
validateLength(routeCode, "route_code", LENGTH_64);
|
||||
ReservationAiRouteDefinition route = ReservationAiRouteDefinition.findByRouteCode(routeCode)
|
||||
.filter(ReservationAiRouteDefinition::sourceMessageNotification)
|
||||
.orElseThrow(() -> error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 route_code 无效。"));
|
||||
@@ -226,6 +312,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
String requestId,
|
||||
String defaultHotelId,
|
||||
String rawBody) {
|
||||
V3EventContractIssue rootIssue = inspectV3BusinessRootIssue(root);
|
||||
if (rootIssue != null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", rootIssue.message());
|
||||
}
|
||||
ResolvedSourceMessage resolvedSourceMessage = resolveV3SourceMessage(root, defaultHotelId);
|
||||
SourceMessageInboxSnapshot sourceMessage = resolvedSourceMessage.snapshot();
|
||||
String hotelId = sourceMessage.hotelId();
|
||||
@@ -277,7 +367,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
for (int index = 0; index < messageEvents.size(); index++) {
|
||||
JsonNode event = messageEvents.get(index);
|
||||
int arrayIndex = index + 1;
|
||||
V3EventContractIssue contractIssue = inspectV3EventContractIssue(event);
|
||||
V3EventContractIssue contractIssue = inspectV3EventContractIssue(event, messageEvents);
|
||||
if (contractIssue != null) {
|
||||
responseItems.add(createAdapterContractErrorTransition(
|
||||
hotelId,
|
||||
@@ -336,11 +426,34 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 业务根的批次级 P0 结构;单个事件内容错误由 event 级 transition 承接。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3BusinessRootIssue(JsonNode root) {
|
||||
if (!hasExactFields(root, V3_BUSINESS_ROOT_KEYS)
|
||||
|| !root.path("message_events").isArray()
|
||||
|| !root.path("case_candidates").isArray()
|
||||
|| !root.path("extraction_warnings").isArray()
|
||||
|| !root.path("unhandled_current_intents").isArray()) {
|
||||
return new V3EventContractIssue("BUSINESS_ROOT_CONTRACT_INVALID", "V3 业务根结构不符合 0711 P0 契约。");
|
||||
}
|
||||
V3EventContractIssue sourceMessageIssue = inspectV3SourceMessageIssue(root.path("source_message"));
|
||||
if (sourceMessageIssue != null) {
|
||||
return sourceMessageIssue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验结构化 S10/S99 的最小形态,避免把联合值或空复核误收为正常通知。
|
||||
*/
|
||||
private void validateV3SourceMessageNotificationShape(JsonNode root, ReservationAiRouteDefinition route) {
|
||||
if (!"main_agent_outcome".equals(textAt(root, "handler_type"))
|
||||
V3EventContractIssue sourceMessageIssue = inspectV3SourceMessageIssue(root.path("source_message"));
|
||||
if (!hasExactFields(root, V3_MAIN_OUTCOME_KEYS)
|
||||
|| sourceMessageIssue != null
|
||||
|| !hasExactFields(root.path("agent_assessment"), V3_AGENT_ASSESSMENT_KEYS)
|
||||
|| !hasExactFields(root.path("notification"), V3_NOTIFICATION_KEYS)
|
||||
|| !"main_agent_outcome".equals(textAt(root, "handler_type"))
|
||||
|| !AiResultType.SOURCE_MESSAGE_REVIEW_NOTIFICATION.code().equals(textAt(root, "result_type"))
|
||||
|| !"current".equals(textAt(root, "current_or_history"))) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", "S10/S99 根结构字段无效。");
|
||||
@@ -379,23 +492,115 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
* 校验 S99 main_agent_entry_review 的 P0 九字段结构。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3MainAgentReviewIssue(JsonNode manualReview) {
|
||||
if (!manualReview.isObject()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INVALID", "manual_review 必须是对象。");
|
||||
}
|
||||
if (!"main_agent_entry_review".equals(textAt(manualReview, "review_record_type"))
|
||||
|| trimToNull(textAt(manualReview, "reason_code")) == null
|
||||
|| trimToNull(textAt(manualReview, "visible_reason")) == null
|
||||
|| !manualReview.path("known_fields").isObject()
|
||||
|| !manualReview.path("missing_fields").isArray()
|
||||
|| !manualReview.path("blocking_points").isArray()
|
||||
|| !manualReview.path("conflicting_points").isArray()
|
||||
|| !manualReview.path("suggested_human_actions").isArray()
|
||||
|| !manualReview.path("evidence_to_check").isArray()) {
|
||||
return new V3EventContractIssue("MAIN_AGENT_REVIEW_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
return inspectV3ReviewIssue(
|
||||
manualReview,
|
||||
"main_agent_entry_review",
|
||||
null,
|
||||
"MAIN_AGENT_REVIEW_INVALID",
|
||||
"MAIN_AGENT_REVIEW_INCOMPLETE",
|
||||
"MAIN_AGENT_REVIEW_MISSING_FIELD_POINTER_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 SourceMessage 元数据;source_message_id 缺失由入口预检查返回 typed infrastructure error。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3SourceMessageIssue(JsonNode sourceMessage) {
|
||||
if (!hasExactFields(sourceMessage, V3_SOURCE_MESSAGE_KEYS)
|
||||
|| trimToNull(textAt(sourceMessage, "source_message_id")) == null
|
||||
|| !isStringOrNull(sourceMessage.path("subject"))
|
||||
|| !isStringOrNull(sourceMessage.path("from"))
|
||||
|| !isStringArray(sourceMessage.path("cc"), false)
|
||||
|| !isStringOrNull(sourceMessage.path("received_at"))
|
||||
|| !"Email".equals(textAt(sourceMessage, "source_channel"))) {
|
||||
return new V3EventContractIssue("SOURCE_MESSAGE_CONTRACT_INVALID", "source_message 结构不符合 0711 P0 契约。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 单个 message_event 的基础形态;缺字段时 fail closed,不猜测任务含义。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3BusinessEventShapeIssue(JsonNode event) {
|
||||
if (event == null || !event.isObject() || !hasRequiredFields(event, V3_EVENT_REQUIRED_KEYS)) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 必填字段不完整。");
|
||||
}
|
||||
if ((!V3_ACTIVE_EVENT_TYPES.contains(textAt(event, "event_type"))
|
||||
&& !"Need Manual Review".equals(textAt(event, "event_type")))
|
||||
|| trimToNull(textAt(event, "event_role")) == null
|
||||
|| !"current".equals(textAt(event, "current_or_history"))
|
||||
|| trimToNull(textAt(event, "source_event_index")) == null
|
||||
|| inspectV3CaseKeysIssue(event.path("case_keys")) != null
|
||||
|| !event.path("relevant_message_excerpt").isTextual()
|
||||
|| !event.path("attachments").isArray()
|
||||
|| !event.path("file_references").isArray()
|
||||
|| !event.path("context_used").isObject()
|
||||
|| !event.path("extracted_fields").isObject()) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 字段类型或基础值无效。");
|
||||
}
|
||||
JsonNode manualReview = event.get("manual_review");
|
||||
if (!manualReview.isNull() && !manualReview.isObject()) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event.manual_review 必须为 null 或对象。");
|
||||
}
|
||||
if ("Need Manual Review".equals(textAt(event, "event_type")) && manualReview.isNull()) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "Need Manual Review 必须携带 manual_review。");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 case_keys 的 P0 四字段结构。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3CaseKeysIssue(JsonNode caseKeys) {
|
||||
if (!hasExactFields(caseKeys, V3_CASE_KEY_KEYS)) {
|
||||
return new V3EventContractIssue("CASE_KEYS_CONTRACT_INVALID", "case_keys 结构不符合 0711 P0 契约。");
|
||||
}
|
||||
for (String key : V3_CASE_KEY_KEYS) {
|
||||
if (!isStringOrNull(caseKeys.path(key))) {
|
||||
return new V3EventContractIssue("CASE_KEYS_CONTRACT_INVALID", "case_keys 字段必须为 string 或 null。");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复用 0711 P0 manual_review 九字段结构校验,业务事件复核还要确认缺失字段 pointer 能定位到当前事件。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3ReviewIssue(
|
||||
JsonNode manualReview,
|
||||
String expectedRecordType,
|
||||
JsonNode businessEvent,
|
||||
String invalidCode,
|
||||
String incompleteCode,
|
||||
String pointerInvalidCode) {
|
||||
if (manualReview == null || !manualReview.isObject()) {
|
||||
return new V3EventContractIssue(invalidCode, "manual_review 必须是对象。");
|
||||
}
|
||||
if (!hasExactFields(manualReview, V3_REVIEW_KEYS)
|
||||
|| trimToNull(textAt(manualReview, "reason_code")) == null
|
||||
|| trimToNull(textAt(manualReview, "visible_reason")) == null
|
||||
|| !expectedRecordType.equals(textAt(manualReview, "review_record_type"))
|
||||
|| !manualReview.path("known_fields").isObject()) {
|
||||
return new V3EventContractIssue(incompleteCode, "manual_review 九字段不完整。");
|
||||
}
|
||||
for (String arrayKey : V3_REVIEW_ARRAY_KEYS) {
|
||||
if (!isStringArray(manualReview.path(arrayKey), false)) {
|
||||
return new V3EventContractIssue(incompleteCode, "manual_review 数组字段必须只包含字符串。");
|
||||
}
|
||||
}
|
||||
if (businessEvent != null) {
|
||||
for (JsonNode pointer : manualReview.path("missing_fields")) {
|
||||
String pointerValue = pointer.isTextual() ? pointer.asText() : null;
|
||||
if (!jsonPointerTargetExists(businessEvent, pointerValue)) {
|
||||
return new V3EventContractIssue(
|
||||
pointerInvalidCode,
|
||||
"manual_review.missing_fields 必须使用能定位当前事件字段的 RFC 6901 JSON Pointer。");
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 将 V3 业务 event 转为旧任务结果 item 形态,复用现有订单、任务和任务卡创建逻辑。
|
||||
*/
|
||||
@@ -516,21 +721,25 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
/**
|
||||
* 识别 V3 event 中已经明确暴露的契约问题。命中后该 event 不建业务卡,只保存 transition。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3EventContractIssue(JsonNode event) {
|
||||
private V3EventContractIssue inspectV3EventContractIssue(JsonNode event, JsonNode messageEvents) {
|
||||
if (nonEmptyArray(event, "contract_errors")) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_ERROR", "message_event 携带 contract_errors,第一版不建业务任务。");
|
||||
}
|
||||
if (nonEmptyArray(event, "missing_fields")) {
|
||||
return new V3EventContractIssue("EVENT_CONTRACT_INCOMPLETE", "message_event 根节点携带 missing_fields,第一版不建业务任务。");
|
||||
}
|
||||
V3EventContractIssue shapeIssue = inspectV3BusinessEventShapeIssue(event);
|
||||
if (shapeIssue != null) {
|
||||
return shapeIssue;
|
||||
}
|
||||
JsonNode manualReview = event == null ? null : event.get("manual_review");
|
||||
if (!isNullOrMissing(manualReview)) {
|
||||
V3EventContractIssue manualReviewIssue = inspectV3ManualReviewContractIssue(manualReview);
|
||||
V3EventContractIssue manualReviewIssue = inspectV3ManualReviewContractIssue(manualReview, event);
|
||||
if (manualReviewIssue != null) {
|
||||
return manualReviewIssue;
|
||||
}
|
||||
}
|
||||
if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event)) {
|
||||
if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event, messageEvents)) {
|
||||
return new V3EventContractIssue(
|
||||
"LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE",
|
||||
"linked_parent_release_after_child_split 关系字段不完整,第一版不建业务任务。");
|
||||
@@ -541,33 +750,14 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
/**
|
||||
* 校验 V3 type-known manual_review 的九字段基础结构,后续同卡复核解阻会继续校验缺失字段。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview) {
|
||||
if (!manualReview.isObject()) {
|
||||
return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INVALID", "manual_review 必须是对象。");
|
||||
}
|
||||
String reviewRecordType = trimToNull(textAt(manualReview, "review_record_type"));
|
||||
String reasonCode = trimToNull(textAt(manualReview, "reason_code"));
|
||||
String visibleReason = trimToNull(textAt(manualReview, "visible_reason"));
|
||||
if (!"business_event_review".equals(reviewRecordType) || reasonCode == null || visibleReason == null) {
|
||||
return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
}
|
||||
if (!manualReview.path("known_fields").isObject()
|
||||
|| !manualReview.path("missing_fields").isArray()
|
||||
|| !manualReview.path("blocking_points").isArray()
|
||||
|| !manualReview.path("conflicting_points").isArray()
|
||||
|| !manualReview.path("suggested_human_actions").isArray()
|
||||
|| !manualReview.path("evidence_to_check").isArray()) {
|
||||
return new V3EventContractIssue("MANUAL_REVIEW_CONTRACT_INCOMPLETE", "manual_review 九字段不完整。");
|
||||
}
|
||||
for (JsonNode pointer : manualReview.path("missing_fields")) {
|
||||
String pointerValue = pointer.isTextual() ? trimToNull(pointer.asText()) : null;
|
||||
if (pointerValue == null || !pointerValue.startsWith("/")) {
|
||||
return new V3EventContractIssue(
|
||||
"MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID",
|
||||
"manual_review.missing_fields 必须使用 RFC 6901 JSON Pointer。");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview, JsonNode event) {
|
||||
return inspectV3ReviewIssue(
|
||||
manualReview,
|
||||
"business_event_review",
|
||||
event,
|
||||
"MANUAL_REVIEW_CONTRACT_INVALID",
|
||||
"MANUAL_REVIEW_CONTRACT_INCOMPLETE",
|
||||
"MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,14 +771,42 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
/**
|
||||
* 校验 parent split 候选的最小可追溯关系字段,避免只凭 relationship_type 建业务任务。
|
||||
*/
|
||||
private boolean validLinkedParentReleaseCandidate(JsonNode event) {
|
||||
private boolean validLinkedParentReleaseCandidate(JsonNode event, JsonNode messageEvents) {
|
||||
JsonNode extractedFields = event == null ? null : event.path("extracted_fields");
|
||||
JsonNode caseKeys = event == null ? null : event.path("case_keys");
|
||||
return "Cancel Booking".equals(trimToNull(textAt(event, "event_type")))
|
||||
if (!("Cancel Booking".equals(trimToNull(textAt(event, "event_type")))
|
||||
&& "group_block".equals(firstText(extractedFields, event, "cancel_object_type"))
|
||||
&& trimToNull(textAt(caseKeys, "group_code")) != null
|
||||
&& nonEmptyArray(extractedFields, "child_group_codes")
|
||||
&& nonEmptyArray(event, "related_source_event_indices");
|
||||
&& nonEmptyArray(event, "related_source_event_indices"))) {
|
||||
return false;
|
||||
}
|
||||
List<String> childGroupCodes = stringArrayValues(extractedFields.path("child_group_codes"));
|
||||
List<String> relatedSourceEventIndices = stringArrayValues(event.path("related_source_event_indices"));
|
||||
if (childGroupCodes.isEmpty()
|
||||
|| relatedSourceEventIndices.isEmpty()
|
||||
|| childGroupCodes.size() != relatedSourceEventIndices.size()) {
|
||||
return false;
|
||||
}
|
||||
Set<String> expectedChildGroups = new LinkedHashSet<>(childGroupCodes);
|
||||
Set<String> relatedEventIds = new LinkedHashSet<>(relatedSourceEventIndices);
|
||||
if (expectedChildGroups.size() != childGroupCodes.size()
|
||||
|| relatedEventIds.size() != relatedSourceEventIndices.size()) {
|
||||
return false;
|
||||
}
|
||||
Set<String> actualChildGroups = new LinkedHashSet<>();
|
||||
for (String sourceEventIndex : relatedSourceEventIndices) {
|
||||
JsonNode childEvent = findV3MessageEventBySourceEventIndex(messageEvents, sourceEventIndex);
|
||||
if (childEvent == null || !"New Booking".equals(trimToNull(textAt(childEvent, "event_type")))) {
|
||||
return false;
|
||||
}
|
||||
String childGroupCode = trimToNull(textAt(childEvent.path("case_keys"), "group_code"));
|
||||
if (childGroupCode == null || !expectedChildGroups.contains(childGroupCode)) {
|
||||
return false;
|
||||
}
|
||||
actualChildGroups.add(childGroupCode);
|
||||
}
|
||||
return actualChildGroups.equals(expectedChildGroups);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1469,10 +1687,13 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
* V3 或入口网关形态输入必须先有非空 source_message_id;纯 V2 兼容结构不受该预检查影响。
|
||||
*/
|
||||
private boolean isV3SourceMessageIdentityMissing(JsonNode root) {
|
||||
if (root == null || !root.has("source_message") || !isV3OrGatewayInputShape(root)) {
|
||||
if (root == null || !isV3OrGatewayInputShape(root)) {
|
||||
return false;
|
||||
}
|
||||
return trimToNull(textAt(root.path("source_message"), "source_message_id")) == null;
|
||||
JsonNode sourceMessage = root.get("source_message");
|
||||
return sourceMessage == null
|
||||
|| !sourceMessage.isObject()
|
||||
|| trimToNull(textAt(sourceMessage, "source_message_id")) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1713,6 +1934,189 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
return value != null && value.isArray() && value.size() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验对象字段集合必须与 P0 契约完全一致。
|
||||
*/
|
||||
private boolean hasExactFields(JsonNode node, List<String> expectedFields) {
|
||||
if (node == null || !node.isObject() || node.size() != expectedFields.size()) {
|
||||
return false;
|
||||
}
|
||||
for (String fieldName : expectedFields) {
|
||||
if (!node.has(fieldName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验对象必须包含指定字段;允许 P0 event 的关系扩展字段继续保留。
|
||||
*/
|
||||
private boolean hasRequiredFields(JsonNode node, List<String> requiredFields) {
|
||||
if (node == null || !node.isObject()) {
|
||||
return false;
|
||||
}
|
||||
for (String fieldName : requiredFields) {
|
||||
if (!node.has(fieldName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段值是否为字符串或 null。
|
||||
*/
|
||||
private boolean isStringOrNull(JsonNode node) {
|
||||
return node != null && (node.isNull() || node.isTextual());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段值是否为字符串数组。
|
||||
*/
|
||||
private boolean isStringArray(JsonNode node, boolean nonempty) {
|
||||
if (node == null || !node.isArray() || (nonempty && node.isEmpty())) {
|
||||
return false;
|
||||
}
|
||||
for (JsonNode item : node) {
|
||||
if (!item.isTextual()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取非空字符串数组;数组不存在或包含非字符串时返回空集合,交由调用方 fail closed。
|
||||
*/
|
||||
private List<String> stringArrayValues(JsonNode node) {
|
||||
if (node == null || !node.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> values = new ArrayList<>(node.size());
|
||||
for (JsonNode item : node) {
|
||||
String value = item.isTextual() ? trimToNull(item.asText()) : null;
|
||||
if (value == null) {
|
||||
return List.of();
|
||||
}
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 source_event_index 在同一 V3 根的 message_events 中查找事件。
|
||||
*/
|
||||
private JsonNode findV3MessageEventBySourceEventIndex(JsonNode messageEvents, String sourceEventIndex) {
|
||||
if (messageEvents == null || !messageEvents.isArray() || trimToNull(sourceEventIndex) == null) {
|
||||
return null;
|
||||
}
|
||||
for (JsonNode candidate : messageEvents) {
|
||||
if (sourceEventIndex.equals(trimToNull(textAt(candidate, "source_event_index")))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 RFC 6901 JSON Pointer 是否能定位到指定文档节点,null 值也视为有效目标。
|
||||
*/
|
||||
private boolean jsonPointerTargetExists(JsonNode document, String pointer) {
|
||||
List<String> tokens = jsonPointerTokens(pointer);
|
||||
if (tokens == null) {
|
||||
return false;
|
||||
}
|
||||
JsonNode current = document;
|
||||
for (String token : tokens) {
|
||||
if (current == null || current.isMissingNode()) {
|
||||
return false;
|
||||
}
|
||||
if (current.isObject()) {
|
||||
if (!current.has(token)) {
|
||||
return false;
|
||||
}
|
||||
current = current.get(token);
|
||||
continue;
|
||||
}
|
||||
if (current.isArray()) {
|
||||
Integer arrayIndex = jsonPointerArrayIndex(token);
|
||||
if (arrayIndex == null || arrayIndex >= current.size()) {
|
||||
return false;
|
||||
}
|
||||
current = current.get(arrayIndex);
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return current != null && !current.isMissingNode();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 RFC 6901 JSON Pointer token,拒绝非法 ~ 转义。
|
||||
*/
|
||||
private List<String> jsonPointerTokens(String pointer) {
|
||||
String safePointer = trimToNull(pointer);
|
||||
if (safePointer == null || !safePointer.startsWith("/")) {
|
||||
return null;
|
||||
}
|
||||
String[] rawTokens = safePointer.substring(1).split("/", -1);
|
||||
List<String> tokens = new ArrayList<>(rawTokens.length);
|
||||
for (String rawToken : rawTokens) {
|
||||
String token = jsonPointerToken(rawToken);
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
tokens.add(token);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个 RFC 6901 token。
|
||||
*/
|
||||
private String jsonPointerToken(String rawToken) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < rawToken.length(); i++) {
|
||||
char current = rawToken.charAt(i);
|
||||
if (current != '~') {
|
||||
builder.append(current);
|
||||
continue;
|
||||
}
|
||||
if (i + 1 >= rawToken.length()) {
|
||||
return null;
|
||||
}
|
||||
char escaped = rawToken.charAt(++i);
|
||||
if (escaped == '0') {
|
||||
builder.append('~');
|
||||
} else if (escaped == '1') {
|
||||
builder.append('/');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON Pointer 数组下标,负数和非数字不接受。
|
||||
*/
|
||||
private Integer jsonPointerArrayIndex(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < token.length(); i++) {
|
||||
if (!Character.isDigit(token.charAt(i))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(token);
|
||||
} catch (NumberFormatException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文本是否有效。
|
||||
*/
|
||||
|
||||
@@ -898,7 +898,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (!collapsed.equals(candidates.get(0))) {
|
||||
candidates.add(collapsed);
|
||||
}
|
||||
String legacyAlias = p0ReviewPointerLegacyAlias(collapsed);
|
||||
String legacyAlias = p0ReviewPointerLegacyAlias(candidates.get(0), collapsed);
|
||||
if (legacyAlias != null && !candidates.contains(legacyAlias)) {
|
||||
candidates.add(legacyAlias);
|
||||
}
|
||||
@@ -908,8 +908,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
/**
|
||||
* 兼容 0711 P0 fixtures 中已迁移为数组结构、但当前后端矩阵仍是扁平字段的复核 pointer。
|
||||
*/
|
||||
private String p0ReviewPointerLegacyAlias(String collapsedFieldPath) {
|
||||
if ("extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) {
|
||||
private String p0ReviewPointerLegacyAlias(String plainFieldPath, String collapsedFieldPath) {
|
||||
if ("extracted_fields.room_items.0.pms_room_type_code".equals(plainFieldPath)
|
||||
&& "extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) {
|
||||
return "extracted_fields.pms_room_type_code";
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -850,12 +850,19 @@ class SuperAgentTaskResultControllerTest {
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "CNF-V3-NEW-001"
|
||||
"confirmation_number": "CNF-V3-NEW-001",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create a new FIT reservation.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "2026-09-01"
|
||||
@@ -942,12 +949,19 @@ class SuperAgentTaskResultControllerTest {
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "Trace",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E2",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "GRP-V3-ERR-001",
|
||||
"confirmation_number": null
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please add a trace note.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {},
|
||||
"manual_review": null
|
||||
}
|
||||
@@ -1000,12 +1014,19 @@ class SuperAgentTaskResultControllerTest {
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "%s",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "%s",
|
||||
"confirmation_number": null
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Unsupported event with explicit contract error.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {},
|
||||
"contract_errors": [
|
||||
{
|
||||
@@ -1017,12 +1038,19 @@ class SuperAgentTaskResultControllerTest {
|
||||
},
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E2",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "CNF-V3-SIBLING-001"
|
||||
"confirmation_number": "CNF-V3-SIBLING-001",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create sibling FIT reservation.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation"
|
||||
},
|
||||
@@ -1073,12 +1101,19 @@ class SuperAgentTaskResultControllerTest {
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "Trace",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "GRP-V3-FRONTEND-BLOCK-001",
|
||||
"confirmation_number": null
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Trace event has explicit contract error.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {},
|
||||
"contract_errors": [
|
||||
{
|
||||
@@ -1090,12 +1125,19 @@ class SuperAgentTaskResultControllerTest {
|
||||
},
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E2",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "CNF-V3-FRONTEND-BLOCK-001"
|
||||
"confirmation_number": "CNF-V3-FRONTEND-BLOCK-001",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create frontend-visible FIT reservation.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation"
|
||||
},
|
||||
@@ -1185,12 +1227,19 @@ class SuperAgentTaskResultControllerTest {
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "CNF-V3-MR-001"
|
||||
"confirmation_number": "CNF-V3-MR-001",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Manual review payload is incomplete.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation"
|
||||
},
|
||||
@@ -2456,18 +2505,26 @@ class SuperAgentTaskResultControllerTest {
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "%s"
|
||||
"confirmation_number": "%s",
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create a new FIT reservation with manual room type review.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "%s",
|
||||
"departure_date": "%s",
|
||||
"room_quantity": 2,
|
||||
"room_type": "Deluxe King"
|
||||
"room_type": "Deluxe King",
|
||||
"pms_room_type_code": null
|
||||
},
|
||||
"manual_review": {
|
||||
"review_record_type": "business_event_review",
|
||||
|
||||
@@ -158,6 +158,24 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
.andExpect(jsonPath("$.result_type").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnTypedInfrastructureInputErrorWhenV3ShapeMissesSourceMessageObject() throws Exception {
|
||||
mockMvc.perform(signedPost("""
|
||||
{
|
||||
"body_current": "Please cancel group HD260710A",
|
||||
"message_events": [],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
""", "nonce-p0-missing-source-object-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.result_type").value("infrastructure_input_error"))
|
||||
.andExpect(jsonPath("$.error_code").value("missing_source_message_id"))
|
||||
.andExpect(jsonPath("$.retryable").value(true))
|
||||
.andExpect(jsonPath("$.missing_fields[0]").value("source_message.source_message_id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLetNonBlankSourceIdentityPassP0GuardWithoutTypedMissingError() throws Exception {
|
||||
JsonNode validInput = caseItem(
|
||||
@@ -197,9 +215,57 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
.isEqualTo("Need Manual Review");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectS99WhenMainAgentReviewHasUnexpectedField() throws Exception {
|
||||
JsonNode s99 = caseValue(fixture("main_outcomes.json").path("cases"), "legal_s99_material_unavailable");
|
||||
ObjectNode copy = s99.deepCopy();
|
||||
((ObjectNode) copy.path("manual_review")).put("unexpected_field", "not-allowed");
|
||||
String externalId = "p0-s99-unexpected-review-field-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(withSourceMessageId(copy, externalId), "nonce-p0-s99-extra-field-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));
|
||||
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(taskCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV3BusinessRootWhenP0RootArraysAreMissing() throws Exception {
|
||||
ObjectNode root = objectMapper.createObjectNode();
|
||||
ObjectNode sourceMessage = root.putObject("source_message");
|
||||
String externalId = "p0-root-arrays-missing-001";
|
||||
sourceMessage.put("source_message_id", externalId);
|
||||
sourceMessage.put("subject", "P0 fixture");
|
||||
sourceMessage.put("from", "agent@example.test");
|
||||
sourceMessage.putArray("cc");
|
||||
sourceMessage.put("received_at", "2026-07-11T10:00:00+08:00");
|
||||
sourceMessage.put("source_channel", "Email");
|
||||
root.putArray("message_events")
|
||||
.add(fixture("row_multiple_derived.json").path("message_events").get(0));
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(objectMapper.writeValueAsString(root), "nonce-p0-root-arrays-missing-001"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("ADAPTER_CONTRACT_ERROR"));
|
||||
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(taskCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateParentSplitTasksFromP0FixtureInEventOrder() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
useParentSplitBusinessKeys(root, "PARENT-2608-OK", "CHILD-2608-OK-A", "CHILD-2608-OK-B");
|
||||
String externalId = "p0-parent-split-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
@@ -234,6 +300,33 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
assertThat(transitionCount).isEqualTo(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenParentSplitReferencesDoNotMatchChildEvents() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
useParentSplitBusinessKeys(root, "PARENT-2608-MIS", "CHILD-2608-MIS-A", "CHILD-2608-MIS-B");
|
||||
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
|
||||
((ArrayNode) parentEvent.path("related_source_event_indices"))
|
||||
.set(1, objectMapper.getNodeFactory().textNode("E_UNKNOWN_CHILD"));
|
||||
String externalId = "p0-parent-split-mismatch-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(withSourceMessageId(root, externalId), "nonce-p0-parent-split-mismatch-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(3))
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[1].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL"))
|
||||
.andExpect(jsonPath("$.items[2].adapter_error_code")
|
||||
.value("LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE"))
|
||||
.andExpect(jsonPath("$.items[2].task_id").doesNotExist());
|
||||
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(taskCount).isEqualTo(2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateRowDerivedMainParentAndTraceTasksFromP0Fixture() throws Exception {
|
||||
ObjectNode root = fixture("row_multiple_derived.json").deepCopy();
|
||||
@@ -255,6 +348,27 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
assertThat(taskCount).isEqualTo(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenV3BusinessEventMissesRequiredField() throws Exception {
|
||||
ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy();
|
||||
event.remove("attachments");
|
||||
String externalId = "p0-event-required-field-missing-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-event-required-missing-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].adapter_error_code").value("EVENT_CONTRACT_INCOMPLETE"))
|
||||
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
|
||||
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(taskCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveMixedAllotmentUnhandledIntentAndRejectPartialAllotmentAsBusinessTask() throws Exception {
|
||||
JsonNode fixture = fixture("allotment_scope.json");
|
||||
@@ -286,6 +400,7 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
void shouldCreateTypeKnownManualReviewAndResolveSameCardFromP0Fixture() throws Exception {
|
||||
JsonNode manualReview = fixture("manual_review_resolution.json");
|
||||
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
|
||||
useManualReviewGroupCode(event, "CHILD-SUITE-RESOLVE-001");
|
||||
String externalId = "p0-manual-review-same-card-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String body = businessRoot(externalId, event);
|
||||
@@ -347,6 +462,69 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenManualReviewMissingFieldPointerDoesNotResolve() throws Exception {
|
||||
ObjectNode event = fixture("manual_review_resolution.json")
|
||||
.path("known_subtype_manual_review")
|
||||
.path("event")
|
||||
.deepCopy();
|
||||
((ArrayNode) event.path("manual_review").path("missing_fields"))
|
||||
.set(0, objectMapper.getNodeFactory()
|
||||
.textNode("/extracted_fields/room_items/99/pms_room_type_code"));
|
||||
String externalId = "p0-manual-review-invalid-pointer-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
mockMvc.perform(signedPost(businessRoot(externalId, event), "nonce-p0-manual-review-invalid-pointer-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.accepted_count").value(1))
|
||||
.andExpect(jsonPath("$.items[0].adapter_error_code")
|
||||
.value("MANUAL_REVIEW_MISSING_FIELD_POINTER_INVALID"))
|
||||
.andExpect(jsonPath("$.items[0].task_id").doesNotExist());
|
||||
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(taskCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectOutOfRangeRoomItemPointerWhenResolvingManualReview() throws Exception {
|
||||
JsonNode manualReview = fixture("manual_review_resolution.json");
|
||||
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
|
||||
useManualReviewGroupCode(event, "CHILD-SUITE-POINTER-001");
|
||||
String externalId = "p0-manual-review-resolution-pointer-001";
|
||||
captureSourceMessage(externalId);
|
||||
MvcResult result = mockMvc.perform(signedPost(
|
||||
businessRoot(externalId, event),
|
||||
"nonce-p0-manual-review-resolution-pointer-create-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
result.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
result.getResponse().getContentAsString(),
|
||||
"$.items[0].order_id");
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"confirmed_order_id": "%s",
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_pointer": "/extracted_fields/room_items/99/pms_room_type_code",
|
||||
"value": "SU1"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"));
|
||||
}
|
||||
|
||||
private JsonNode fixture(String fileName) throws Exception {
|
||||
return objectMapper.readTree(FIXTURES_DIR.resolve(fileName).toFile());
|
||||
}
|
||||
@@ -397,6 +575,36 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private void useParentSplitBusinessKeys(
|
||||
ObjectNode root,
|
||||
String parentGroupCode,
|
||||
String childGroupCodeOne,
|
||||
String childGroupCodeTwo) {
|
||||
ArrayNode events = (ArrayNode) root.path("message_events");
|
||||
useEventGroupCode((ObjectNode) events.get(0), childGroupCodeOne);
|
||||
useEventGroupCode((ObjectNode) events.get(1), childGroupCodeTwo);
|
||||
((ObjectNode) events.get(0).path("extracted_fields")).put("parent_group_code", parentGroupCode);
|
||||
((ObjectNode) events.get(1).path("extracted_fields")).put("parent_group_code", parentGroupCode);
|
||||
|
||||
ObjectNode parentEvent = (ObjectNode) events.get(2);
|
||||
useEventGroupCode(parentEvent, parentGroupCode);
|
||||
ObjectNode parentExtractedFields = (ObjectNode) parentEvent.path("extracted_fields");
|
||||
parentExtractedFields.put("parent_group_code", parentGroupCode);
|
||||
ArrayNode childGroupCodes = (ArrayNode) parentExtractedFields.path("child_group_codes");
|
||||
childGroupCodes.removeAll();
|
||||
childGroupCodes.add(childGroupCodeOne);
|
||||
childGroupCodes.add(childGroupCodeTwo);
|
||||
}
|
||||
|
||||
private void useManualReviewGroupCode(ObjectNode event, String groupCode) {
|
||||
useEventGroupCode(event, groupCode);
|
||||
((ObjectNode) event.path("manual_review").path("known_fields")).put("group_code", groupCode);
|
||||
}
|
||||
|
||||
private void useEventGroupCode(ObjectNode event, String groupCode) {
|
||||
((ObjectNode) event.path("case_keys")).put("group_code", groupCode);
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
|
||||
Reference in New Issue
Block a user