完成 M002 V3 同卡复核解阻
This commit is contained in:
@@ -11,6 +11,7 @@ public record ReservationTaskCardDraft(
|
||||
String taskCardType,
|
||||
String fieldContractVersion,
|
||||
String aiPayloadJson,
|
||||
String reviewStatus,
|
||||
LocalDateTime now
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ public record ReservationTaskCardSnapshot(
|
||||
String draftPayloadJson,
|
||||
String confirmedPayloadJson,
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt
|
||||
LocalDateTime confirmedAt,
|
||||
String reviewStatus,
|
||||
String reviewResolutionJson
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.enums;
|
||||
|
||||
/**
|
||||
* Reservation 同卡人工复核状态。仅用于 type-known manual_review 任务在原业务任务卡上解阻。
|
||||
*/
|
||||
public enum ReservationReviewStatus {
|
||||
/** 等待人工在原任务卡上确认缺失字段或订单归属。 */
|
||||
PENDING,
|
||||
/** 人工复核已完成,任务可以进入 READY 后续流程。 */
|
||||
RESOLVED
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 同卡人工复核字段修正请求。field_pointer 使用 RFC 6901 JSON Pointer。
|
||||
*
|
||||
* @param fieldPointer 指向任务卡可编辑字段的 JSON Pointer
|
||||
* @param value 人工修正后的字段值
|
||||
*/
|
||||
public record ManualReviewResolutionFieldOverrideRequest(
|
||||
@JsonProperty("field_pointer")
|
||||
String fieldPointer,
|
||||
Object value
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* type-known manual_review 同卡复核解阻请求。第一版只允许确认当前订单归属,不做普通任务任意切换订单。
|
||||
*
|
||||
* @param confirmedOrderId 用户确认的订单 ID,第一版必须等于当前任务所属订单
|
||||
* @param reason 人工复核原因或备注
|
||||
* @param fieldOverrides 用户提交的字段修正列表
|
||||
*/
|
||||
public record ManualReviewResolutionRequest(
|
||||
@JsonProperty("confirmed_order_id")
|
||||
String confirmedOrderId,
|
||||
String reason,
|
||||
@JsonProperty("field_overrides")
|
||||
List<ManualReviewResolutionFieldOverrideRequest> fieldOverrides
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* 同卡人工复核字段修正结果。返回 JSON Pointer 与矩阵 field_path 的对应关系。
|
||||
*
|
||||
* @param fieldPointer 请求中的 JSON Pointer
|
||||
* @param fieldPath 后端矩阵 field_path
|
||||
* @param value 人工修正后的字段值
|
||||
*/
|
||||
public record ManualReviewResolutionFieldOverrideResult(
|
||||
@JsonProperty("field_pointer")
|
||||
String fieldPointer,
|
||||
@JsonProperty("field_path")
|
||||
String fieldPath,
|
||||
Object value
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* type-known manual_review 同卡复核解阻结果。
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
* @param orderId 订单 ID
|
||||
* @param taskStatus 解阻后的任务状态
|
||||
* @param reviewStatus 解阻后的复核状态
|
||||
* @param reviewResolution 复核解阻结果 JSON
|
||||
* @param confirmedPayload 最终确认 payload
|
||||
* @param operaOperations 复核后生成的 OPERA 模拟操作
|
||||
*/
|
||||
public record ManualReviewResolutionResult(
|
||||
@JsonProperty("task_id")
|
||||
String taskId,
|
||||
@JsonProperty("order_id")
|
||||
String orderId,
|
||||
@JsonProperty("task_status")
|
||||
String taskStatus,
|
||||
@JsonProperty("review_status")
|
||||
String reviewStatus,
|
||||
@JsonProperty("review_resolution")
|
||||
Object reviewResolution,
|
||||
@JsonProperty("confirmed_payload")
|
||||
Object confirmedPayload,
|
||||
@JsonProperty("opera_operations")
|
||||
List<ReservationOperaOperationResult> operaOperations
|
||||
) {
|
||||
}
|
||||
@@ -3,7 +3,8 @@ package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* S000/S999 特殊入口结果详情。仅用于 SOURCE_MESSAGE_ONLY 任务,不承载普通任务 AI 原始 payload。
|
||||
* 来源邮件只读入口结果详情。仅用于 SOURCE_MESSAGE_ONLY 任务,兼容旧 S000/S999 和新 S10/S99,
|
||||
* 不承载普通任务 AI 原始 payload。
|
||||
*
|
||||
* @param entryResultCode SuperAgent 入口结果码
|
||||
* @param entryResultMeaning 入口结果语义
|
||||
|
||||
@@ -26,6 +26,9 @@ import java.util.List;
|
||||
* @param fieldContractVersion 字段矩阵契约版本
|
||||
* @param draftPayload 草稿 payload
|
||||
* @param confirmedPayload 最终确认 payload
|
||||
* @param reviewStatus type-known manual_review 同卡复核状态
|
||||
* @param reviewResolution 同卡复核解阻结果
|
||||
* @param manualReview AI 原始 manual_review 白名单对象
|
||||
* @param sourceMessageOnlyResult S000/S999 特殊入口结果,普通任务为空
|
||||
* @param availability 当前可处理状态
|
||||
* @param fields 按字段矩阵生成的字段列表
|
||||
@@ -72,6 +75,12 @@ public record ReservationTaskDetailResult(
|
||||
Object draftPayload,
|
||||
@JsonProperty("confirmed_payload")
|
||||
Object confirmedPayload,
|
||||
@JsonProperty("review_status")
|
||||
String reviewStatus,
|
||||
@JsonProperty("review_resolution")
|
||||
Object reviewResolution,
|
||||
@JsonProperty("manual_review")
|
||||
Object manualReview,
|
||||
@JsonProperty("source_message_only_result")
|
||||
ReservationSourceMessageOnlyResult sourceMessageOnlyResult,
|
||||
ReservationTaskAvailabilityResult availability,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult;
|
||||
@@ -81,6 +83,19 @@ public class ReservationTaskController {
|
||||
return taskWorkflowService.confirmTask(taskId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在原业务任务卡上完成 type-known manual_review 复核,提交字段修正并确认订单归属。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/{taskId}/manual-review-resolutions",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ManualReviewResolutionResult resolveManualReview(
|
||||
@PathVariable Long taskId,
|
||||
@RequestBody(required = false) ManualReviewResolutionRequest request) {
|
||||
return taskWorkflowService.resolveManualReview(taskId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一条 OPERA 模拟操作;第一版只写入模拟 attempt,不调用真实 OPERA。
|
||||
*/
|
||||
|
||||
@@ -32,6 +32,10 @@ public class ReservationTaskCardEntity {
|
||||
private String confirmedBy;
|
||||
/** 任务卡最终确认 UTC 时间。 */
|
||||
private LocalDateTime confirmedAt;
|
||||
/** 同卡人工复核状态,仅 type-known manual_review 任务使用。 */
|
||||
private String reviewStatus;
|
||||
/** 同卡人工复核解阻结果 JSON,不改写 AI 原始快照。 */
|
||||
private String reviewResolutionJson;
|
||||
/** 记录创建 UTC 时间。 */
|
||||
private LocalDateTime createdAt;
|
||||
/** 记录更新 UTC 时间。 */
|
||||
@@ -57,6 +61,10 @@ public class ReservationTaskCardEntity {
|
||||
public void setConfirmedBy(String confirmedBy) { this.confirmedBy = confirmedBy; }
|
||||
public LocalDateTime getConfirmedAt() { return confirmedAt; }
|
||||
public void setConfirmedAt(LocalDateTime confirmedAt) { this.confirmedAt = confirmedAt; }
|
||||
public String getReviewStatus() { return reviewStatus; }
|
||||
public void setReviewStatus(String reviewStatus) { this.reviewStatus = reviewStatus; }
|
||||
public String getReviewResolutionJson() { return reviewResolutionJson; }
|
||||
public void setReviewResolutionJson(String reviewResolutionJson) { this.reviewResolutionJson = reviewResolutionJson; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
|
||||
@@ -263,6 +263,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
entity.setTaskCardType(draft.taskCardType());
|
||||
entity.setFieldContractVersion(draft.fieldContractVersion());
|
||||
entity.setAiPayloadJson(draft.aiPayloadJson());
|
||||
entity.setReviewStatus(draft.reviewStatus());
|
||||
entity.setCreatedAt(draft.now());
|
||||
entity.setUpdatedAt(draft.now());
|
||||
taskCardMapper.insert(entity);
|
||||
@@ -671,6 +672,41 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
return updatedCardCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存同卡人工复核解阻结果和最终确认 payload,并将任务状态从 PENDING_CONFIRM 流转为 READY。
|
||||
*/
|
||||
@Override
|
||||
public boolean resolveManualReview(
|
||||
String hotelId,
|
||||
Long taskId,
|
||||
String reviewStatus,
|
||||
String reviewResolutionJson,
|
||||
String confirmedPayloadJson,
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt) {
|
||||
int updatedTaskCount = taskMapper.update(null, Wrappers.<ReservationTaskEntity>lambdaUpdate()
|
||||
.set(ReservationTaskEntity::getTaskStatus, ReservationTaskStatus.READY.name())
|
||||
.set(ReservationTaskEntity::getConfirmedAt, confirmedAt)
|
||||
.set(ReservationTaskEntity::getUpdatedAt, confirmedAt)
|
||||
.setSql("version = version + 1")
|
||||
.eq(ReservationTaskEntity::getHotelId, hotelId)
|
||||
.eq(ReservationTaskEntity::getId, taskId)
|
||||
.eq(ReservationTaskEntity::getTaskStatus, ReservationTaskStatus.PENDING_CONFIRM.name()));
|
||||
if (updatedTaskCount == 0) {
|
||||
return false;
|
||||
}
|
||||
int updatedCardCount = taskCardMapper.update(null, Wrappers.<ReservationTaskCardEntity>lambdaUpdate()
|
||||
.set(ReservationTaskCardEntity::getReviewStatus, reviewStatus)
|
||||
.set(ReservationTaskCardEntity::getReviewResolutionJson, reviewResolutionJson)
|
||||
.set(ReservationTaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson)
|
||||
.set(ReservationTaskCardEntity::getConfirmedBy, confirmedBy)
|
||||
.set(ReservationTaskCardEntity::getConfirmedAt, confirmedAt)
|
||||
.set(ReservationTaskCardEntity::getUpdatedAt, confirmedAt)
|
||||
.eq(ReservationTaskCardEntity::getHotelId, hotelId)
|
||||
.eq(ReservationTaskCardEntity::getTaskId, taskId));
|
||||
return updatedCardCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入 OPERA 模拟操作。
|
||||
*/
|
||||
@@ -1106,7 +1142,9 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
|
||||
entity.getDraftPayloadJson(),
|
||||
entity.getConfirmedPayloadJson(),
|
||||
entity.getConfirmedBy(),
|
||||
entity.getConfirmedAt());
|
||||
entity.getConfirmedAt(),
|
||||
entity.getReviewStatus(),
|
||||
entity.getReviewResolutionJson());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -205,6 +205,18 @@ public interface ReservationAiWorkflowRepository {
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt);
|
||||
|
||||
/**
|
||||
* 保存同卡人工复核解阻结果、最终确认 payload,并将任务状态从 PENDING_CONFIRM 流转为 READY。
|
||||
*/
|
||||
boolean resolveManualReview(
|
||||
String hotelId,
|
||||
Long taskId,
|
||||
String reviewStatus,
|
||||
String reviewResolutionJson,
|
||||
String confirmedPayloadJson,
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt);
|
||||
|
||||
/**
|
||||
* 新增 OPERA 模拟操作,并返回操作 ID。
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service;
|
||||
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAuditListResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskDetailResult;
|
||||
@@ -34,6 +36,11 @@ public interface ReservationTaskWorkflowService {
|
||||
*/
|
||||
ReservationTaskPayloadMutationResult confirmTask(Long taskId, ReservationTaskPayloadMutationRequest request);
|
||||
|
||||
/**
|
||||
* 在原业务任务卡上完成 type-known manual_review 复核,并将任务解阻为 READY。
|
||||
*/
|
||||
ManualReviewResolutionResult resolveManualReview(Long taskId, ManualReviewResolutionRequest request);
|
||||
|
||||
/**
|
||||
* 执行一条 OPERA 模拟操作;只允许前置模拟操作已成功的操作执行。
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessK
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
|
||||
@@ -488,7 +489,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 V3 type-known manual_review 的九字段基础结构,完整字段解阻逻辑后续 checkpoint 再做。
|
||||
* 校验 V3 type-known manual_review 的九字段基础结构,后续同卡复核解阻会继续校验缺失字段。
|
||||
*/
|
||||
private V3EventContractIssue inspectV3ManualReviewContractIssue(JsonNode manualReview) {
|
||||
if (!manualReview.isObject()) {
|
||||
@@ -724,6 +725,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
ReservationTaskCardType.SOURCE_MESSAGE_ONLY.name(),
|
||||
FIELD_CONTRACT_VERSION,
|
||||
itemPayloadJson,
|
||||
null,
|
||||
now
|
||||
));
|
||||
return new SuperAgentTaskResultItemResponse(
|
||||
@@ -901,6 +903,7 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
mapping.taskCardType().name(),
|
||||
FIELD_CONTRACT_VERSION,
|
||||
itemPayloadJson,
|
||||
initialReviewStatus(resultType, mapping),
|
||||
now
|
||||
));
|
||||
|
||||
@@ -1158,6 +1161,18 @@ public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskInta
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅 type-known manual_review 业务任务需要在原任务卡上进入待复核状态,Fallback 转换任务继续走原人工转换流程。
|
||||
*/
|
||||
private String initialReviewStatus(String resultType, TaskTypeMapping mapping) {
|
||||
if (!AiResultType.MANUAL_REVIEW.code().equals(resultType)) {
|
||||
return null;
|
||||
}
|
||||
return mapping.systemTaskType() == ReservationSystemTaskType.MANUAL_REVIEW
|
||||
? null
|
||||
: ReservationReviewStatus.PENDING.name();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SuperAgent 入口阶段的纯文本结果。兼容旧 S000/S999,也允许调试期文本 S10/S99。
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
import cn.nianxx.thhotel.platform.common.time.UtcTimeFormatter;
|
||||
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
|
||||
@@ -13,19 +14,25 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapsh
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardFieldDefinition;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.AiResultType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiSystemProcessCategory;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationBusinessKeySource;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOperaAttemptStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOperaOperationStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationSystemTaskType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskCardType;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewConversionRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionFieldOverrideRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ManualReviewResolutionRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationOperaSimulationRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskPayloadMutationRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewConversionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionFieldOverrideResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ManualReviewResolutionResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiTransitionDisplayResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationAttemptResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOperaOperationResult;
|
||||
@@ -65,6 +72,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkflowService {
|
||||
|
||||
private static final String ACTION_MANUAL_REVIEW_CONVERT = "MANUAL_REVIEW_CONVERT";
|
||||
private static final String ACTION_MANUAL_REVIEW_RESOLVE = "MANUAL_REVIEW_RESOLVE";
|
||||
private static final String ACTION_TASK_DRAFT_SAVE = "TASK_DRAFT_SAVE";
|
||||
private static final String ACTION_TASK_CONFIRM = "TASK_CONFIRM";
|
||||
private static final String ACTION_OPERA_OPERATION_EXECUTE = "OPERA_OPERATION_EXECUTE";
|
||||
@@ -74,6 +82,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
private static final String PAYLOAD_SCHEMA_VERSION = "field_path-v1";
|
||||
private static final String PAYLOAD_SOURCE_DRAFT = "TASK_DRAFT";
|
||||
private static final String PAYLOAD_SOURCE_CONFIRMATION = "TASK_CONFIRMATION";
|
||||
private static final String PAYLOAD_SOURCE_MANUAL_REVIEW_RESOLUTION = "MANUAL_REVIEW_RESOLUTION";
|
||||
private static final String OPERA_OPERATION_CODE_PRECHECK = "SIMULATE_PRECHECK";
|
||||
private static final String OPERA_OPERATION_CODE_WRITE = "SIMULATE_WRITE";
|
||||
private static final int MAX_QUEUE_ORDER_RETRY = 5;
|
||||
@@ -137,6 +146,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
taskCard.fieldContractVersion(),
|
||||
jsonPayloadToObject(taskCard.draftPayloadJson()),
|
||||
jsonPayloadToObject(taskCard.confirmedPayloadJson()),
|
||||
reviewStatusForDetail(task, taskCard),
|
||||
jsonPayloadToObject(taskCard.reviewResolutionJson()),
|
||||
manualReviewForDetail(task, taskCard),
|
||||
sourceMessageOnlyResult,
|
||||
availability,
|
||||
fields,
|
||||
@@ -229,6 +241,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
ReservationTaskSnapshot task = findTaskOrThrow(taskId);
|
||||
ReservationTaskCardSnapshot taskCard = findTaskCardOrThrow(task);
|
||||
ensureTaskEditable(task);
|
||||
ensureGenericConfirmAllowed(task, taskCard);
|
||||
|
||||
JsonNode aiPayload = parseJson(taskCard.aiPayloadJson());
|
||||
Map<String, Object> submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues());
|
||||
@@ -268,6 +281,68 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
operaOperations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在原业务任务卡上完成 type-known manual_review 复核。字段修正写入确认 payload,不改写 AI 原始快照。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public ManualReviewResolutionResult resolveManualReview(
|
||||
Long taskId,
|
||||
ManualReviewResolutionRequest request) {
|
||||
ReservationTaskSnapshot task = findTaskOrThrow(taskId);
|
||||
ReservationTaskCardSnapshot taskCard = findTaskCardOrThrow(task);
|
||||
ensureTypeKnownManualReviewTask(task, taskCard);
|
||||
ensureTaskEditable(task);
|
||||
validateConfirmedOrder(task, request == null ? null : request.confirmedOrderId());
|
||||
|
||||
JsonNode aiPayload = parseJson(taskCard.aiPayloadJson());
|
||||
Map<String, Object> draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson());
|
||||
List<ReservationTaskCardFieldDefinition> activeDefinitions = activeDefinitions(task, aiPayload, draftValues);
|
||||
List<ManualReviewResolutionFieldOverrideResult> fieldOverrides =
|
||||
resolveFieldOverrides(reviewPointerDefinitions(task), request == null ? null : request.fieldOverrides());
|
||||
Map<String, Object> submittedValues = fieldOverrideValues(fieldOverrides);
|
||||
Map<String, Object> editedValues = mergeFieldValues(draftValues, submittedValues);
|
||||
activeDefinitions = activeDefinitions(task, aiPayload, editedValues);
|
||||
List<String> validationErrors = validateSubmittedFields(activeDefinitions, submittedValues);
|
||||
validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues,
|
||||
submittedValues.keySet(), false));
|
||||
validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues,
|
||||
definitionFieldPaths(activeDefinitions), true, true));
|
||||
validationErrors.addAll(validateManualReviewMissingFieldsResolved(aiPayload, activeDefinitions, editedValues));
|
||||
validationErrors.addAll(validateDateRange(activeDefinitions, aiPayload, editedValues));
|
||||
if (!validationErrors.isEmpty()) {
|
||||
throw validationError(validationErrors);
|
||||
}
|
||||
|
||||
LocalDateTime now = nowUtc();
|
||||
Map<String, Object> confirmedValues = buildConfirmedFieldValues(activeDefinitions, aiPayload, editedValues);
|
||||
String confirmedPayloadJson = toPayloadJson(PAYLOAD_SOURCE_MANUAL_REVIEW_RESOLUTION, confirmedValues, now);
|
||||
String reviewResolutionJson = toReviewResolutionJson(task, request, fieldOverrides, now);
|
||||
boolean resolved = workflowRepository.resolveManualReview(
|
||||
task.hotelId(),
|
||||
task.id(),
|
||||
ReservationReviewStatus.RESOLVED.name(),
|
||||
reviewResolutionJson,
|
||||
confirmedPayloadJson,
|
||||
ACTOR_ID_LOCAL,
|
||||
now);
|
||||
if (!resolved) {
|
||||
throw error(HttpStatus.CONFLICT, "TASK_REVIEW_RESOLVE_CONFLICT", "任务状态已变化,无法完成复核。");
|
||||
}
|
||||
writeManualReviewResolveAudit(task, taskCard, reviewResolutionJson, request, now);
|
||||
ReservationTaskSnapshot resolvedTask = findTaskOrThrow(taskId);
|
||||
List<ReservationOperaOperationResult> operaOperations =
|
||||
createDefaultOperaOperations(resolvedTask, confirmedPayloadJson, now);
|
||||
return new ManualReviewResolutionResult(
|
||||
resolvedTask.id().toString(),
|
||||
resolvedTask.orderId().toString(),
|
||||
resolvedTask.taskStatus(),
|
||||
ReservationReviewStatus.RESOLVED.name(),
|
||||
jsonPayloadToObject(reviewResolutionJson),
|
||||
jsonPayloadToObject(confirmedPayloadJson),
|
||||
operaOperations);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 OPERA 模拟操作。第一版不接真实 OPERA,只根据请求开关生成模拟结果和 attempt。
|
||||
*/
|
||||
@@ -314,6 +389,13 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
ReservationTaskSnapshot task,
|
||||
String confirmedPayloadJson,
|
||||
LocalDateTime now) {
|
||||
List<ReservationOperaOperationSnapshot> existingOperations =
|
||||
workflowRepository.findOperaOperationsByTaskId(task.hotelId(), task.id());
|
||||
if (!existingOperations.isEmpty()) {
|
||||
return existingOperations.stream()
|
||||
.map(operation -> toOperaOperationResult(operation, List.of()))
|
||||
.toList();
|
||||
}
|
||||
List<ReservationOperaOperationDraft> drafts = List.of(
|
||||
new ReservationOperaOperationDraft(
|
||||
task.hotelId(),
|
||||
@@ -662,6 +744,283 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前任务是已知业务类型的 manual_review,同卡解阻不处理 Fallback 人工转换任务。
|
||||
*/
|
||||
private void ensureTypeKnownManualReviewTask(
|
||||
ReservationTaskSnapshot task,
|
||||
ReservationTaskCardSnapshot taskCard) {
|
||||
if (!isTypeKnownManualReviewTask(task) && trimToNull(taskCard.reviewStatus()) == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "TASK_NOT_TYPE_KNOWN_MANUAL_REVIEW", "当前任务不是同卡人工复核任务。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用确认接口不能处理 type-known manual_review,避免绕过复核解阻审计和 field_overrides 保存。
|
||||
*/
|
||||
private void ensureGenericConfirmAllowed(
|
||||
ReservationTaskSnapshot task,
|
||||
ReservationTaskCardSnapshot taskCard) {
|
||||
if (isTypeKnownManualReviewTask(task) || trimToNull(taskCard.reviewStatus()) != null) {
|
||||
throw error(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"TASK_REVIEW_RESOLUTION_REQUIRED",
|
||||
"type-known manual_review 任务必须通过复核解阻接口确认。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断任务是否为已知业务类型的 manual_review,而非 Fallback 人工转换任务。
|
||||
*/
|
||||
private boolean isTypeKnownManualReviewTask(ReservationTaskSnapshot task) {
|
||||
return AiResultType.MANUAL_REVIEW.code().equals(task.resultType())
|
||||
&& !ReservationSystemTaskType.MANUAL_REVIEW.name().equals(task.systemTaskType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验复核确认的订单归属。第一版只允许确认当前订单,不提供普通任务任意切换订单能力。
|
||||
*/
|
||||
private void validateConfirmedOrder(ReservationTaskSnapshot task, String rawConfirmedOrderId) {
|
||||
String confirmedOrderIdText = trimToNull(rawConfirmedOrderId);
|
||||
if (confirmedOrderIdText == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "REVIEW_ORDER_REQUIRED", "confirmed_order_id 必填。");
|
||||
}
|
||||
Long confirmedOrderId = parseLong(confirmedOrderIdText, "REVIEW_ORDER_INVALID", "confirmed_order_id 格式无效。");
|
||||
if (!task.orderId().equals(confirmedOrderId)) {
|
||||
throw new ReservationTaskWorkflowException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"REVIEW_ORDER_ASSIGNMENT_MISMATCH",
|
||||
"复核确认订单与当前任务订单不一致。",
|
||||
List.of("confirmed_order_id: 第一版必须等于当前任务所属订单。"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将复核字段 JSON Pointer 校验并映射到矩阵 field_path,只允许当前任务卡可编辑字段。
|
||||
*/
|
||||
private List<ManualReviewResolutionFieldOverrideResult> resolveFieldOverrides(
|
||||
List<ReservationTaskCardFieldDefinition> activeDefinitions,
|
||||
List<ManualReviewResolutionFieldOverrideRequest> requests) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath = new LinkedHashMap<>();
|
||||
activeDefinitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition));
|
||||
List<ManualReviewResolutionFieldOverrideResult> results = new ArrayList<>();
|
||||
for (ManualReviewResolutionFieldOverrideRequest request : requests) {
|
||||
String fieldPointer = trimToNull(request == null ? null : request.fieldPointer());
|
||||
ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
results.add(new ManualReviewResolutionFieldOverrideResult(
|
||||
fieldPointer,
|
||||
definition.fieldPath(),
|
||||
request.value()));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复核 pointer 白名单使用当前卡型的可编辑字段全集;展示条件由合并后的 activeDefinitions 再兜底校验。
|
||||
*/
|
||||
private List<ReservationTaskCardFieldDefinition> reviewPointerDefinitions(ReservationTaskSnapshot task) {
|
||||
return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType())
|
||||
.stream()
|
||||
.filter(definition -> isYes(definition.visible()))
|
||||
.filter(definition -> matchesTaskResultType(definition.resultType(), task))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType()))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 JSON Pointer 查找可编辑字段定义。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByPointer(
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPointer) {
|
||||
if (fieldPointer == null || !fieldPointer.startsWith("/")) {
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
for (String fieldPath : fieldPathCandidatesFromPointer(fieldPointer)) {
|
||||
ReservationTaskCardFieldDefinition definition = definitionByPath.get(fieldPath);
|
||||
if (definition != null && isYes(definition.editable())) {
|
||||
return definition;
|
||||
}
|
||||
if (definition != null) {
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
}
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 的映射。
|
||||
*/
|
||||
private List<String> fieldPathCandidatesFromPointer(String fieldPointer) {
|
||||
String[] rawTokens = fieldPointer.substring(1).split("/", -1);
|
||||
List<String> plainTokens = new ArrayList<>();
|
||||
List<String> collapsedArrayTokens = new ArrayList<>();
|
||||
for (String rawToken : rawTokens) {
|
||||
String token = unescapeJsonPointerToken(rawToken, fieldPointer);
|
||||
if (token.isEmpty()) {
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
plainTokens.add(token);
|
||||
if (isNumericToken(token)) {
|
||||
if (collapsedArrayTokens.isEmpty()) {
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
int lastIndex = collapsedArrayTokens.size() - 1;
|
||||
String previous = collapsedArrayTokens.get(lastIndex);
|
||||
collapsedArrayTokens.set(lastIndex, previous.endsWith("[]") ? previous : previous + "[]");
|
||||
} else {
|
||||
collapsedArrayTokens.add(token);
|
||||
}
|
||||
}
|
||||
List<String> candidates = new ArrayList<>();
|
||||
candidates.add(String.join(".", plainTokens));
|
||||
String collapsed = String.join(".", collapsedArrayTokens);
|
||||
if (!collapsed.equals(candidates.get(0))) {
|
||||
candidates.add(collapsed);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON Pointer token,拒绝非法的 ~ 转义。
|
||||
*/
|
||||
private String unescapeJsonPointerToken(String rawToken, String fieldPointer) {
|
||||
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()) {
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
char escaped = rawToken.charAt(++i);
|
||||
if (escaped == '0') {
|
||||
builder.append('~');
|
||||
} else if (escaped == '1') {
|
||||
builder.append('/');
|
||||
} else {
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 JSON Pointer token 是否为数组下标。
|
||||
*/
|
||||
private boolean isNumericToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < token.length(); i++) {
|
||||
if (!Character.isDigit(token.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将复核字段修正结果转换为 field_path -> value,用于复用现有字段矩阵校验。
|
||||
*/
|
||||
private Map<String, Object> fieldOverrideValues(
|
||||
List<ManualReviewResolutionFieldOverrideResult> fieldOverrides) {
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
for (ManualReviewResolutionFieldOverrideResult fieldOverride : fieldOverrides) {
|
||||
values.put(fieldOverride.fieldPath(), fieldOverride.value());
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 SuperAgent 明确标记的缺失字段已在复核解阻后有值,避免只靠矩阵必填遗漏业务阻塞点。
|
||||
*/
|
||||
private List<String> validateManualReviewMissingFieldsResolved(
|
||||
JsonNode aiPayload,
|
||||
List<ReservationTaskCardFieldDefinition> activeDefinitions,
|
||||
Map<String, Object> editedValues) {
|
||||
JsonNode missingFieldsNode = aiPayload.path("manual_review").path("missing_fields");
|
||||
if (!missingFieldsNode.isArray() || missingFieldsNode.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath = new LinkedHashMap<>();
|
||||
activeDefinitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition));
|
||||
List<String> errors = new ArrayList<>();
|
||||
for (JsonNode missingFieldNode : missingFieldsNode) {
|
||||
String fieldPointer = trimToNull(missingFieldNode.asText(null));
|
||||
ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
if (isEmptyValue(editedValues.get(definition.fieldPath()))) {
|
||||
errors.add(fieldPointer + ": 复核缺失字段仍未补齐。");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建同卡人工复核解阻结果 JSON,保存人工修正和订单归属确认,不写回 AI 原始 payload。
|
||||
*/
|
||||
private String toReviewResolutionJson(
|
||||
ReservationTaskSnapshot task,
|
||||
ManualReviewResolutionRequest request,
|
||||
List<ManualReviewResolutionFieldOverrideResult> fieldOverrides,
|
||||
LocalDateTime now) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("schema_version", "manual-review-resolution-v1");
|
||||
payload.put("resolved_at", UtcTimeFormatter.toUtcOffsetDateTime(now).toString());
|
||||
payload.put("resolved_by", ACTOR_ID_LOCAL);
|
||||
payload.put("confirmed_order_id", task.orderId().toString());
|
||||
payload.put("reason", request == null ? null : trimToNull(request.reason()));
|
||||
payload.put("field_overrides", fieldOverrides);
|
||||
return toJson(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入同卡人工复核解阻审计。审计只保存复核摘要,完整字段值保存在任务卡确认 payload。
|
||||
*/
|
||||
private void writeManualReviewResolveAudit(
|
||||
ReservationTaskSnapshot task,
|
||||
ReservationTaskCardSnapshot taskCard,
|
||||
String reviewResolutionJson,
|
||||
ManualReviewResolutionRequest request,
|
||||
LocalDateTime now) {
|
||||
Map<String, Object> beforeSnapshot = new LinkedHashMap<>();
|
||||
beforeSnapshot.put("review_status", taskCard.reviewStatus());
|
||||
beforeSnapshot.put("has_confirmed_payload", trimToNull(taskCard.confirmedPayloadJson()) != null);
|
||||
Map<String, Object> afterSnapshot = new LinkedHashMap<>();
|
||||
afterSnapshot.put("review_status", ReservationReviewStatus.RESOLVED.name());
|
||||
afterSnapshot.put("resolution", jsonPayloadToObject(reviewResolutionJson));
|
||||
workflowRepository.insertAuditLog(new ReservationAuditLogDraft(
|
||||
task.hotelId(),
|
||||
task.orderId(),
|
||||
task.id(),
|
||||
null,
|
||||
ACTOR_TYPE_USER,
|
||||
ACTOR_ID_LOCAL,
|
||||
ACTION_MANUAL_REVIEW_RESOLVE,
|
||||
request == null ? null : trimToNull(request.reason()),
|
||||
toJson(beforeSnapshot),
|
||||
toJson(afterSnapshot),
|
||||
now));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 JSON Pointer 错误,details 保留原始 pointer 方便前端定位。
|
||||
*/
|
||||
private ReservationTaskWorkflowException reviewPointerError(String fieldPointer) {
|
||||
String pointer = fieldPointer == null ? "<null>" : fieldPointer;
|
||||
return new ReservationTaskWorkflowException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"TASK_REVIEW_POINTER_INVALID",
|
||||
"复核字段指针无效。",
|
||||
List.of(pointer + ": 必须指向当前任务卡允许编辑字段。"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化请求中的 field_values。空请求按空草稿处理。
|
||||
*/
|
||||
@@ -695,7 +1054,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType())
|
||||
.stream()
|
||||
.filter(definition -> isYes(definition.visible()))
|
||||
.filter(definition -> matchesMatrixExpression(definition.resultType(), task.resultType()))
|
||||
.filter(definition -> matchesTaskResultType(definition.resultType(), task))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType()))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype()))
|
||||
.filter(definition -> displayConditionMatches(task, definition, aiPayload, editedValues))
|
||||
@@ -734,10 +1093,34 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
Map<String, Object> editedValues,
|
||||
Set<String> fieldPathsToValidate,
|
||||
boolean validateRequired) {
|
||||
return validateDefinitionValues(
|
||||
task,
|
||||
activeDefinitions,
|
||||
aiPayload,
|
||||
editedValues,
|
||||
fieldPathsToValidate,
|
||||
validateRequired,
|
||||
false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按字段矩阵执行校验。复核解阻场景允许只读证据字段缺失,因为用户无法在该接口补齐。
|
||||
*/
|
||||
private List<String> validateDefinitionValues(
|
||||
ReservationTaskSnapshot task,
|
||||
List<ReservationTaskCardFieldDefinition> activeDefinitions,
|
||||
JsonNode aiPayload,
|
||||
Map<String, Object> editedValues,
|
||||
Set<String> fieldPathsToValidate,
|
||||
boolean validateRequired,
|
||||
boolean allowMissingReadonlyRequired) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
for (ReservationTaskCardFieldDefinition definition : activeDefinitions) {
|
||||
Object value = valueForField(aiPayload, editedValues, definition.fieldPath());
|
||||
if (validateRequired && isMissingRequired(task, definition, aiPayload, editedValues, value)) {
|
||||
if (allowMissingReadonlyRequired && !isYes(definition.editable())) {
|
||||
continue;
|
||||
}
|
||||
errors.add(definition.fieldPath() + ": 必填字段缺失。");
|
||||
continue;
|
||||
}
|
||||
@@ -856,7 +1239,33 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
}
|
||||
|
||||
/**
|
||||
* 只为 S000/S999 特殊任务透出入口结果,避免普通任务详情暴露完整 AI 原始 payload。
|
||||
* 任务详情顶层复核状态。仅 type-known manual_review 业务任务默认展示 PENDING。
|
||||
*/
|
||||
private String reviewStatusForDetail(ReservationTaskSnapshot task, ReservationTaskCardSnapshot taskCard) {
|
||||
if (!AiResultType.MANUAL_REVIEW.code().equals(task.resultType())
|
||||
|| ReservationSystemTaskType.MANUAL_REVIEW.name().equals(task.systemTaskType())) {
|
||||
return null;
|
||||
}
|
||||
String reviewStatus = trimToNull(taskCard.reviewStatus());
|
||||
return reviewStatus == null ? ReservationReviewStatus.PENDING.name() : reviewStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务详情透出 AI manual_review 白名单对象,供前端展示缺失字段和人工复核原因。
|
||||
*/
|
||||
private Object manualReviewForDetail(ReservationTaskSnapshot task, ReservationTaskCardSnapshot taskCard) {
|
||||
if (!AiResultType.MANUAL_REVIEW.code().equals(task.resultType())) {
|
||||
return null;
|
||||
}
|
||||
JsonNode manualReviewNode = parseJson(taskCard.aiPayloadJson()).path("manual_review");
|
||||
if (manualReviewNode.isMissingNode() || manualReviewNode.isNull()) {
|
||||
return null;
|
||||
}
|
||||
return objectMapper.convertValue(manualReviewNode, Object.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 只为来源邮件只读任务透出入口结果,兼容旧 S000/S999 和新 S10/S99,不暴露普通任务完整 AI 原始 payload。
|
||||
*/
|
||||
private ReservationSourceMessageOnlyResult buildSourceMessageOnlyResult(
|
||||
ReservationTaskSnapshot task,
|
||||
@@ -927,7 +1336,40 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
transition.systemProcessCategory(),
|
||||
transition.adapterErrorCode(),
|
||||
transition.adapterErrorMessage(),
|
||||
jsonPayloadToObject(transition.aiPayloadJson()));
|
||||
diagnosticPayloadFragment(transition.aiPayloadJson()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 AI item 中提取前端可展示的诊断白名单字段,避免泄露完整业务字段、附件和上下文。
|
||||
*/
|
||||
private Object diagnosticPayloadFragment(String aiPayloadJson) {
|
||||
if (trimToNull(aiPayloadJson) == null) {
|
||||
return null;
|
||||
}
|
||||
JsonNode payload = parseJson(aiPayloadJson);
|
||||
Map<String, Object> fragment = new LinkedHashMap<>();
|
||||
copyDiagnosticPayloadField(payload, fragment, "event_type");
|
||||
copyDiagnosticPayloadField(payload, fragment, "source_event_index");
|
||||
copyDiagnosticPayloadField(payload, fragment, "current_or_history");
|
||||
copyDiagnosticPayloadField(payload, fragment, "contract_errors");
|
||||
copyDiagnosticPayloadField(payload, fragment, "intent_type");
|
||||
copyDiagnosticPayloadField(payload, fragment, "reason_code");
|
||||
copyDiagnosticPayloadField(payload, fragment, "missing_fields");
|
||||
copyDiagnosticPayloadField(payload, fragment, "parent_source_event_index");
|
||||
copyDiagnosticPayloadField(payload, fragment, "linked_task_group_id");
|
||||
copyDiagnosticPayloadField(payload, fragment, "blocked_until_parent_completed");
|
||||
return fragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制单个诊断字段到展示片段,忽略空值和不存在字段。
|
||||
*/
|
||||
private void copyDiagnosticPayloadField(JsonNode payload, Map<String, Object> fragment, String fieldName) {
|
||||
JsonNode value = payload == null ? null : payload.get(fieldName);
|
||||
if (value == null || value.isNull() || value.isMissingNode()) {
|
||||
return;
|
||||
}
|
||||
fragment.put(fieldName, objectMapper.convertValue(value, Object.class));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1024,6 +1466,18 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* type-known manual_review 在原业务任务卡上复核,因此字段矩阵复用 normal_task 的业务卡规则。
|
||||
*/
|
||||
private boolean matchesTaskResultType(String matrixResultType, ReservationTaskSnapshot task) {
|
||||
if (AiResultType.MANUAL_REVIEW.code().equals(task.resultType())
|
||||
&& !ReservationSystemTaskType.MANUAL_REVIEW.name().equals(task.systemTaskType())
|
||||
&& matchesMatrixExpression(matrixResultType, AiResultType.NORMAL_TASK.code())) {
|
||||
return true;
|
||||
}
|
||||
return matchesMatrixExpression(matrixResultType, task.resultType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前已知矩阵展示条件判断字段是否参与本次后端校验。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE workflow_reservation_task_card
|
||||
ADD COLUMN review_status VARCHAR(32) NULL COMMENT '同卡人工复核状态:PENDING、RESOLVED,仅 type-known manual_review 任务使用';
|
||||
|
||||
ALTER TABLE workflow_reservation_task_card
|
||||
ADD COLUMN review_resolution_json LONGTEXT NULL COMMENT '同卡人工复核解阻结果 JSON,保存字段修正、订单归属确认、操作人和时间';
|
||||
|
||||
CREATE INDEX idx_reservation_task_card_review_status
|
||||
ON workflow_reservation_task_card (hotel_id, review_status);
|
||||
@@ -1141,6 +1141,8 @@ class SuperAgentTaskResultControllerTest {
|
||||
.value("EVENT_CONTRACT_ERROR"))
|
||||
.andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.contract_errors[0].code")
|
||||
.value("unsupported_contract"))
|
||||
.andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.case_keys").doesNotExist())
|
||||
.andExpect(jsonPath("$.adapter_contract_errors[0].payload_fragment.extracted_fields").doesNotExist())
|
||||
.andExpect(jsonPath("$.unhandled_intents.length()").value(1))
|
||||
.andExpect(jsonPath("$.unhandled_intents[0].route_code")
|
||||
.value("R42_UNHANDLED_CURRENT_INTENT"))
|
||||
@@ -1730,6 +1732,258 @@ class SuperAgentTaskResultControllerTest {
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_READ_ONLY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveTypeKnownManualReviewOnSameTaskAndMoveToReady() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-resolve-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-resolve-001",
|
||||
"CNF-V3-MR-RESOLVE-001",
|
||||
"2026-09-01",
|
||||
"2026-09-03");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-resolve-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R01_NEW_FIT_RESERVATION_REVIEW"))
|
||||
.andExpect(jsonPath("$.items[0].system_task_type").value("NEW_BOOKING"))
|
||||
.andExpect(jsonPath("$.items[0].task_card_type").value("NEW_BOOKING"))
|
||||
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"))
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].order_id");
|
||||
Long sourceTaskCountBefore = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
|
||||
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result_type").value("manual_review"))
|
||||
.andExpect(jsonPath("$.review_status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.manual_review.reason_code").value("missing_room_type_code"))
|
||||
.andExpect(jsonPath("$.manual_review.missing_fields[0]").value("/extracted_fields/pms_room_type_code"));
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"confirmed_order_id": "%s",
|
||||
"reason": "确认 PMS 房型代码后解阻。",
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_pointer": "/extracted_fields/pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_id").value(taskId))
|
||||
.andExpect(jsonPath("$.order_id").value(orderId))
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.review_status").value("RESOLVED"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/extracted_fields/pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
|
||||
.value("extracted_fields.pms_room_type_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].value").value("RM3"))
|
||||
.andExpect(jsonPath("$.review_resolution.resolved_at").value(matchesPattern(UTC_INSTANT_PATTERN)))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']")
|
||||
.value("RM3"))
|
||||
.andExpect(jsonPath("$.opera_operations.length()").value(2));
|
||||
|
||||
Long sourceTaskCountAfter = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
String taskStatus = jdbcTemplate.queryForObject("""
|
||||
SELECT task_status
|
||||
FROM workflow_reservation_task
|
||||
WHERE id = ?
|
||||
""", String.class, Long.valueOf(taskId));
|
||||
String reviewStatus = jdbcTemplate.queryForObject("""
|
||||
SELECT review_status
|
||||
FROM workflow_reservation_task_card
|
||||
WHERE task_id = ?
|
||||
""", String.class, Long.valueOf(taskId));
|
||||
String reviewResolutionJson = jdbcTemplate.queryForObject("""
|
||||
SELECT review_resolution_json
|
||||
FROM workflow_reservation_task_card
|
||||
WHERE task_id = ?
|
||||
""", String.class, Long.valueOf(taskId));
|
||||
String aiPayloadJson = jdbcTemplate.queryForObject("""
|
||||
SELECT ai_payload_json
|
||||
FROM workflow_reservation_task_card
|
||||
WHERE task_id = ?
|
||||
""", String.class, Long.valueOf(taskId));
|
||||
Long auditCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_audit_log
|
||||
WHERE task_id = ?
|
||||
AND action = 'MANUAL_REVIEW_RESOLVE'
|
||||
""", Long.class, Long.valueOf(taskId));
|
||||
assertThat(sourceTaskCountAfter).isEqualTo(sourceTaskCountBefore);
|
||||
assertThat(taskStatus).isEqualTo("READY");
|
||||
assertThat(reviewStatus).isEqualTo("RESOLVED");
|
||||
assertThat(reviewResolutionJson).contains("\"field_pointer\":\"/extracted_fields/pms_room_type_code\"");
|
||||
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"RM3\"");
|
||||
assertThat(auditCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectGenericConfirmForTypeKnownManualReview() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-confirm-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-confirm-001",
|
||||
"CNF-V3-MR-CONFIRM-001",
|
||||
"2026-09-04",
|
||||
"2026-09-06");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-confirm-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"field_values": {
|
||||
"extracted_fields.pms_room_type_code": "RM3"
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_RESOLUTION_REQUIRED"));
|
||||
|
||||
String taskStatus = jdbcTemplate.queryForObject("""
|
||||
SELECT task_status
|
||||
FROM workflow_reservation_task
|
||||
WHERE id = ?
|
||||
""", String.class, Long.valueOf(taskId));
|
||||
assertThat(taskStatus).isEqualTo("PENDING_CONFIRM");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectManualReviewResolutionWhenPointerIsNotEditable() throws Exception {
|
||||
SourceMessageCaptureResult source = captureSourceMessage("mail-v3-manual-review-pointer-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-pointer-001",
|
||||
"CNF-V3-MR-POINTER-001",
|
||||
"2026-09-05",
|
||||
"2026-09-07");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-pointer-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.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": "/manual_review/visible_reason",
|
||||
"value": "不允许覆盖复核说明"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"))
|
||||
.andExpect(jsonPath("$.details[0]").value(containsString("/manual_review/visible_reason")));
|
||||
|
||||
Long sourceTaskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(sourceTaskCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectManualReviewResolutionWhenMissingFieldsAreNotResolved() throws Exception {
|
||||
captureSourceMessage("mail-v3-manual-review-missing-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-missing-001",
|
||||
"CNF-V3-MR-MISSING-001",
|
||||
"2026-09-06",
|
||||
"2026-09-08");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-missing-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.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": []
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(content().string(containsString("/extracted_fields/pms_room_type_code")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectManualReviewResolutionWhenConfirmedOrderDoesNotMatchCurrentTask() throws Exception {
|
||||
SourceMessageCaptureResult firstSource = captureSourceMessage("mail-v3-manual-review-order-001");
|
||||
String body = typeKnownManualReviewBody(
|
||||
"mail-v3-manual-review-order-001",
|
||||
"CNF-V3-MR-ORDER-001",
|
||||
"2026-09-09",
|
||||
"2026-09-11");
|
||||
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-v3-manual-review-order-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
|
||||
SourceMessageCaptureResult otherSource = captureSourceMessage("mail-v3-manual-review-other-order-001");
|
||||
String otherBody = completeNewBookingBody(otherSource.inboxId().toString(), "CNF-V3-MR-OTHER-001");
|
||||
MvcResult otherResult = mockMvc.perform(signedPost(otherBody, "nonce-v3-manual-review-other-order-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String otherOrderId = com.jayway.jsonpath.JsonPath.read(
|
||||
otherResult.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/pms_room_type_code",
|
||||
"value": "RM3"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(otherOrderId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("REVIEW_ORDER_ASSIGNMENT_MISMATCH"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception {
|
||||
SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001");
|
||||
@@ -2120,6 +2374,68 @@ class SuperAgentTaskResultControllerTest {
|
||||
""".formatted(caseKeys));
|
||||
}
|
||||
|
||||
private String typeKnownManualReviewBody(
|
||||
String externalSourceMessageId,
|
||||
String confirmationNumber,
|
||||
String arrivalDate,
|
||||
String departureDate) {
|
||||
return """
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "%s",
|
||||
"subject": "Manual review new booking",
|
||||
"from": null,
|
||||
"cc": [],
|
||||
"received_at": null,
|
||||
"source_channel": "Email"
|
||||
},
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"source_event_index": "E1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": null,
|
||||
"confirmation_number": "%s"
|
||||
},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "FIT Reservation",
|
||||
"arrival_date": "%s",
|
||||
"departure_date": "%s",
|
||||
"room_quantity": 2,
|
||||
"room_type": "Deluxe King"
|
||||
},
|
||||
"manual_review": {
|
||||
"review_record_type": "business_event_review",
|
||||
"reason_code": "missing_room_type_code",
|
||||
"visible_reason": "需要人工确认 PMS 房型代码。",
|
||||
"known_fields": {
|
||||
"/extracted_fields/booking_object_type": "FIT Reservation",
|
||||
"/extracted_fields/room_type": "Deluxe King"
|
||||
},
|
||||
"missing_fields": [
|
||||
"/extracted_fields/pms_room_type_code"
|
||||
],
|
||||
"blocking_points": [
|
||||
"缺少 PMS 房型代码,不能进入 OPERA 写入。"
|
||||
],
|
||||
"conflicting_points": [],
|
||||
"suggested_human_actions": [
|
||||
"查看邮件和附件后补充 PMS 房型代码。"
|
||||
],
|
||||
"evidence_to_check": [
|
||||
"邮件正文房型描述"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
""".formatted(externalSourceMessageId, confirmationNumber, arrivalDate, departureDate);
|
||||
}
|
||||
|
||||
private String[] createReadyTaskWithTwoOperaOperations(
|
||||
String externalMessageId,
|
||||
String nonce,
|
||||
|
||||
Reference in New Issue
Block a user