实现M002 V3 P0.1 Parent Group路由修订

This commit is contained in:
andy
2026-07-12 11:42:01 +08:00
parent 0358b34159
commit 92489af18e
42 changed files with 3971 additions and 79 deletions

View File

@@ -106,22 +106,6 @@ public enum ReservationAiRouteDefinition {
ReservationAiSystemProcessCategory.BUSINESS_TASK,
ReservationSystemTaskType.CANCEL_BOOKING,
ReservationTaskCardType.CANCEL_BOOKING),
LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL(
"R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_NORMAL",
"normal_task",
"Cancel Booking",
"linked_parent_release_after_child_split",
ReservationAiSystemProcessCategory.BUSINESS_TASK,
ReservationSystemTaskType.CANCEL_BOOKING,
ReservationTaskCardType.CANCEL_BOOKING),
LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_REVIEW(
"R07_LINKED_PARENT_RELEASE_AFTER_CHILD_SPLIT_REVIEW",
"manual_review",
"Cancel Booking",
"linked_parent_release_after_child_split",
ReservationAiSystemProcessCategory.BUSINESS_TASK,
ReservationSystemTaskType.CANCEL_BOOKING,
ReservationTaskCardType.CANCEL_BOOKING),
CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL(
"R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_NORMAL",
"normal_task",

View File

@@ -213,6 +213,10 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
if (!taskResults.isArray() || taskResults.isEmpty()) {
throw error(HttpStatus.BAD_REQUEST, "TASK_RESULTS_EMPTY", "ai_task_results 不能为空。");
}
V3EventContractIssue legacyIssue = inspectV2TaskResultsContractIssue(taskResults);
if (legacyIssue != null) {
throw error(HttpStatus.BAD_REQUEST, "ADAPTER_CONTRACT_ERROR", legacyIssue.message());
}
String hotelId = sourceMessage.hotelId();
String requestPayloadSha256 = sha256(rawBody == null ? "" : rawBody);
@@ -364,10 +368,14 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
List<SuperAgentTaskResultItemResponse> responseItems = new ArrayList<>(itemCount);
Set<String> acceptedParentSplitCodes = new LinkedHashSet<>();
for (int index = 0; index < messageEvents.size(); index++) {
JsonNode event = messageEvents.get(index);
int arrayIndex = index + 1;
V3EventContractIssue contractIssue = inspectV3EventContractIssue(event, messageEvents);
if (contractIssue == null) {
contractIssue = inspectV3ParentSplitDuplicateIssue(event, acceptedParentSplitCodes);
}
if (contractIssue != null) {
responseItems.add(createAdapterContractErrorTransition(
hotelId,
@@ -426,6 +434,32 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
);
}
/**
* 校验 V2 兼容 ai_task_results[] 中不再允许当前 producer 继续提交的旧 Parent split 三元组。
*/
private V3EventContractIssue inspectV2TaskResultsContractIssue(JsonNode taskResults) {
for (JsonNode item : taskResults) {
if (isLegacyParentSplitTriplet(
textAt(item, "result_type"),
textAt(item, "task_type"),
textAt(item, "task_subtype"))) {
return new V3EventContractIssue(
"LINKED_PARENT_RELEASE_LEGACY_CANCEL_BOOKING_UNSUPPORTED",
"当前 producer 不再支持 Cancel Booking + linked_parent_release_after_child_splitParent split 父事件必须使用 Cancel Allotment。");
}
}
return null;
}
/**
* 识别 P0.1 已删除的旧 Parent split 三元组。
*/
private boolean isLegacyParentSplitTriplet(String resultType, String taskType, String taskSubtype) {
return ("normal_task".equals(resultType) || "manual_review".equals(resultType))
&& "Cancel Booking".equals(taskType)
&& "linked_parent_release_after_child_split".equals(taskSubtype);
}
/**
* 校验 V3 业务根的批次级 P0 结构;单个事件内容错误由 event 级 transition 承接。
*/
@@ -705,13 +739,9 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
/**
* Cancel Booking 需要先识别 parent split 关系,再识别普通取消对象
* Cancel Booking 只识别普通取消对象Parent split 在 P0.1 后统一走 Cancel Allotment
*/
private String cancelBookingSubtype(JsonNode event, JsonNode extractedFields) {
String relationshipType = firstText(extractedFields, event, "relationship_type");
if ("linked_parent_release_after_child_split".equals(relationshipType)) {
return "linked_parent_release_after_child_split";
}
return switch (nullToEmpty(firstText(extractedFields, event, "cancel_object_type"))) {
case "fit_reservation" -> "cancel_fit_reservation";
case "group_block" -> "cancel_group_block";
@@ -740,6 +770,11 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
return manualReviewIssue;
}
}
if (isLegacyLinkedParentCancelBooking(event)) {
return new V3EventContractIssue(
"LINKED_PARENT_RELEASE_LEGACY_CANCEL_BOOKING_UNSUPPORTED",
"当前 producer 不再支持 Cancel Booking + linked_parent_release_after_child_splitParent split 父事件必须使用 Cancel Allotment。");
}
if (isLinkedParentReleaseCandidate(event) && !validLinkedParentReleaseCandidate(event, messageEvents)) {
return new V3EventContractIssue(
"LINKED_PARENT_RELEASE_CONTRACT_INCOMPLETE",
@@ -770,14 +805,54 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
}
/**
* 校验 parent split 候选的最小可追溯关系字段,避免只凭 relationship_type 建业务任务
* 判断当前 producer 是否仍在输出旧 Parent Cancel Booking 组合
*/
private boolean isLegacyLinkedParentCancelBooking(JsonNode event) {
JsonNode extractedFields = event == null ? null : event.path("extracted_fields");
return isLegacyParentSplitTriplet(
event == null || isNullOrMissing(event.get("manual_review"))
? AiResultType.NORMAL_TASK.code()
: AiResultType.MANUAL_REVIEW.code(),
trimToNull(textAt(event, "event_type")),
firstText(extractedFields, event, "relationship_type"));
}
/**
* 校验同一 V3 业务根中同一个 Parent split cluster 只能有一个 Parent 候选。
*/
private V3EventContractIssue inspectV3ParentSplitDuplicateIssue(JsonNode event, Set<String> acceptedParentSplitCodes) {
if (!isLinkedParentReleaseCandidate(event)) {
return null;
}
String parentGroupCode = trimToNull(textAt(event.path("case_keys"), "group_code"));
if (parentGroupCode != null && !acceptedParentSplitCodes.add(parentGroupCode)) {
return new V3EventContractIssue(
"LINKED_PARENT_RELEASE_DUPLICATE_PARENT",
"同一个 Parent split cluster 只能有一个 Parent Cancel Allotment 候选。");
}
return null;
}
/**
* 校验 P0.1 parent split 候选的最小可追溯关系字段,避免只凭 relationship_type 建业务任务。
*/
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");
if (!("Cancel Booking".equals(trimToNull(textAt(event, "event_type")))
&& "group_block".equals(firstText(extractedFields, event, "cancel_object_type"))
&& trimToNull(textAt(caseKeys, "group_code")) != null
String parentGroupCode = trimToNull(textAt(caseKeys, "group_code"));
String parentBlockCode = trimToNull(textAt(caseKeys, "block_code"));
if (!("Cancel Allotment".equals(trimToNull(textAt(event, "event_type")))
&& "entire_allotment_control_block".equals(firstText(extractedFields, event, "cancel_scope"))
&& isBooleanTrue(extractedFields.path("parent_release_or_cancel_candidate"))
&& "parent_to_child_allocation_split".equals(firstText(extractedFields, event, "release_reason"))
&& parentGroupCode != null
&& parentGroupCode.equals(firstText(extractedFields, event, "parent_group_code"))
&& isBooleanTrue(extractedFields.path("allocation_split_from_parent"))
&& "New Booking".equals(trimToNull(textAt(event, "related_event_type")))
&& event != null
&& isBooleanTrue(event.path("requires_downstream_hard_validation"))
&& parentGroupCode != null
&& parentGroupCode.equals(parentBlockCode)
&& nonEmptyArray(extractedFields, "child_group_codes")
&& nonEmptyArray(event, "related_source_event_indices"))) {
return false;
@@ -789,25 +864,28 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|| childGroupCodes.size() != relatedSourceEventIndices.size()) {
return false;
}
Set<String> expectedChildGroups = new LinkedHashSet<>(childGroupCodes);
Set<String> childGroupCodeSet = new LinkedHashSet<>(childGroupCodes);
Set<String> relatedEventIds = new LinkedHashSet<>(relatedSourceEventIndices);
if (expectedChildGroups.size() != childGroupCodes.size()
if (childGroupCodeSet.size() != childGroupCodes.size()
|| relatedEventIds.size() != relatedSourceEventIndices.size()) {
return false;
}
Set<String> actualChildGroups = new LinkedHashSet<>();
for (String sourceEventIndex : relatedSourceEventIndices) {
for (int index = 0; index < relatedSourceEventIndices.size(); index++) {
String sourceEventIndex = relatedSourceEventIndices.get(index);
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)) {
JsonNode childExtractedFields = childEvent.path("extracted_fields");
if (!"Group Block".equals(firstText(childExtractedFields, childEvent, "booking_object_type"))) {
return false;
}
String childGroupCode = trimToNull(textAt(childEvent.path("case_keys"), "group_code"));
if (!childGroupCodes.get(index).equals(childGroupCode)) {
return false;
}
actualChildGroups.add(childGroupCode);
}
return actualChildGroups.equals(expectedChildGroups);
return true;
}
/**