实现M002 V3字段控件契约后端第一版
This commit is contained in:
@@ -32,6 +32,12 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
* @param operaWriteParticipation 是否参与 OPERA 参数组装
|
||||
* @param operaParameterMapping OPERA 参数映射说明
|
||||
* @param notes 备注说明
|
||||
* @param controlType 前端控件类型,后端基于矩阵列和字段语义推导
|
||||
* @param editScope 字段可编辑范围,区分普通任务、同卡复核和永远只读
|
||||
* @param writeTarget 用户修改值写入目标,不允许指向 AI 原始 payload
|
||||
* @param optionsSource 字段选项来源,静态枚举、目录或 lookup 未接入时用稳定代码提示
|
||||
* @param rawReadonly 是否为来源原文、证据、路由或 AI 原始诊断字段,只读保留
|
||||
* @param controlHint 给前端的补充控件提示,第一版主要用于目录或表格待接入提示
|
||||
* @param value 当前回显值
|
||||
*/
|
||||
public record ReservationTaskFieldResult(
|
||||
@@ -86,6 +92,18 @@ public record ReservationTaskFieldResult(
|
||||
@JsonProperty("opera_parameter_mapping")
|
||||
String operaParameterMapping,
|
||||
String notes,
|
||||
@JsonProperty("control_type")
|
||||
String controlType,
|
||||
@JsonProperty("edit_scope")
|
||||
String editScope,
|
||||
@JsonProperty("write_target")
|
||||
String writeTarget,
|
||||
@JsonProperty("options_source")
|
||||
String optionsSource,
|
||||
@JsonProperty("raw_readonly")
|
||||
Boolean rawReadonly,
|
||||
@JsonProperty("control_hint")
|
||||
String controlHint,
|
||||
Object value
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ public class JsonReservationTaskCardFieldDefinitionProvider implements Reservati
|
||||
Map.entry(ReservationTaskCardType.NEW_BOOKING.name(), "New Booking 卡"),
|
||||
Map.entry(ReservationTaskCardType.UPDATE_BOOKING.name(), "Update Booking 卡"),
|
||||
Map.entry(ReservationTaskCardType.CANCEL_BOOKING.name(), "Cancel Booking 卡"),
|
||||
Map.entry(ReservationTaskCardType.CANCEL_ALLOTMENT.name(), "Cancel Allotment 卡"),
|
||||
// 字段矩阵当前未单列 Cancel Allotment 卡,P0.1 的 cancel_allotment_control_block 复用 Cancel Booking 规则。
|
||||
Map.entry(ReservationTaskCardType.CANCEL_ALLOTMENT.name(), "Cancel Booking 卡"),
|
||||
Map.entry(ReservationTaskCardType.VOUCHER_RECEIVED.name(), "Voucher Received 卡"),
|
||||
Map.entry(ReservationTaskCardType.PAYMENT_EVIDENCE.name(), "Payment Evidence 卡"),
|
||||
Map.entry(ReservationTaskCardType.ROOMING_LIST.name(), "Rooming List 卡"),
|
||||
|
||||
@@ -60,6 +60,7 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -208,9 +209,11 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
List<ReservationTaskCardFieldDefinition> allDefinitions =
|
||||
fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType());
|
||||
Map<String, Object> submittedValues = normalizeSubmittedFieldValues(
|
||||
task,
|
||||
allDefinitions,
|
||||
request == null ? null : request.fieldValues());
|
||||
Map<String, Object> draftValues = normalizeStoredFieldValues(
|
||||
task,
|
||||
allDefinitions,
|
||||
fieldValuesFromPayloadJson(taskCard.draftPayloadJson()));
|
||||
Map<String, Object> mergedDraftValues = mergeFieldValues(draftValues, submittedValues);
|
||||
@@ -256,12 +259,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
List<ReservationTaskCardFieldDefinition> allDefinitions =
|
||||
fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType());
|
||||
Map<String, Object> submittedValues = normalizeSubmittedFieldValues(
|
||||
task,
|
||||
allDefinitions,
|
||||
request == null ? null : request.fieldValues());
|
||||
Map<String, Object> draftValues = normalizeStoredFieldValues(
|
||||
task,
|
||||
allDefinitions,
|
||||
fieldValuesFromPayloadJson(taskCard.draftPayloadJson()));
|
||||
Map<String, Object> editedValues = mergeFieldValues(draftValues, submittedValues);
|
||||
Map<String, Object> editedValues = applySystemDerivedFieldDefaults(
|
||||
task,
|
||||
aiPayload,
|
||||
mergeFieldValues(draftValues, submittedValues));
|
||||
List<ReservationTaskCardFieldDefinition> activeDefinitions = activeDefinitions(task, aiPayload, editedValues);
|
||||
List<String> validationErrors = validateSubmittedFields(activeDefinitions, submittedValues);
|
||||
validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues,
|
||||
@@ -314,16 +322,22 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
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());
|
||||
resolveFieldOverrides(
|
||||
task,
|
||||
reviewPointerDefinitions(task),
|
||||
request == null ? null : request.fieldOverrides());
|
||||
Map<String, Object> submittedValues = fieldOverrideValues(fieldOverrides);
|
||||
Map<String, Object> editedValues = mergeFieldValues(draftValues, submittedValues);
|
||||
Map<String, Object> editedValues = applySystemDerivedFieldDefaults(
|
||||
task,
|
||||
aiPayload,
|
||||
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(validateManualReviewMissingFieldsResolved(task, aiPayload, activeDefinitions, editedValues));
|
||||
validationErrors.addAll(validateDateRange(activeDefinitions, aiPayload, editedValues));
|
||||
if (!validationErrors.isEmpty()) {
|
||||
throw validationError(validationErrors);
|
||||
@@ -814,6 +828,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 将复核字段 JSON Pointer 校验并映射到矩阵 field_path,只允许当前任务卡可编辑字段。
|
||||
*/
|
||||
private List<ManualReviewResolutionFieldOverrideResult> resolveFieldOverrides(
|
||||
ReservationTaskSnapshot task,
|
||||
List<ReservationTaskCardFieldDefinition> activeDefinitions,
|
||||
List<ManualReviewResolutionFieldOverrideRequest> requests) {
|
||||
if (requests == null || requests.isEmpty()) {
|
||||
@@ -828,7 +843,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
String fieldPointer = trimToNull(request == null ? null : request.fieldPointer());
|
||||
String fieldPath = trimToNull(request == null ? null : request.fieldPath());
|
||||
ReservationTaskCardFieldDefinition definition =
|
||||
findEditableDefinitionByOverride(definitionByPath, fieldPointer, fieldPath);
|
||||
findEditableDefinitionByOverride(task, definitionByPath, fieldPointer, fieldPath);
|
||||
String resultFieldPointer = fieldPointer != null ? fieldPointer : fieldPointerFor(definition.fieldPath());
|
||||
if (!seenPointers.add(resultFieldPointer) || !seenFieldPaths.add(definition.fieldPath())) {
|
||||
throw reviewPointerDuplicateError(resultFieldPointer, definition.fieldPath());
|
||||
@@ -861,7 +876,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
.stream()
|
||||
.filter(definition -> isYes(definition.visible()))
|
||||
.filter(definition -> matchesTaskResultType(definition.resultType(), task))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType()))
|
||||
.filter(definition -> matchesTaskTypeExpression(definition.taskType(), task))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype()))
|
||||
.toList();
|
||||
}
|
||||
@@ -870,14 +885,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 按 JSON Pointer 查找可编辑字段定义。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByPointer(
|
||||
ReservationTaskSnapshot task,
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPointer) {
|
||||
if (fieldPointer == null || !fieldPointer.startsWith("/")) {
|
||||
throw reviewPointerError(fieldPointer);
|
||||
}
|
||||
for (String fieldPath : fieldPathCandidatesFromPointer(fieldPointer)) {
|
||||
for (String fieldPath : fieldPathCandidatesFromPointer(task, fieldPointer)) {
|
||||
ReservationTaskCardFieldDefinition definition = definitionByPath.get(fieldPath);
|
||||
if (definition != null && isYes(definition.editable())) {
|
||||
if (definition != null && isUserEditableField(definition)) {
|
||||
return definition;
|
||||
}
|
||||
if (definition != null) {
|
||||
@@ -891,16 +907,17 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 按复核请求定位可编辑字段,支持 JSON Pointer、P0 主 field_path 和旧扁平 field_path。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByOverride(
|
||||
ReservationTaskSnapshot task,
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPointer,
|
||||
String fieldPath) {
|
||||
ReservationTaskCardFieldDefinition pointerDefinition = null;
|
||||
if (fieldPointer != null) {
|
||||
pointerDefinition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
pointerDefinition = findEditableDefinitionByPointer(task, definitionByPath, fieldPointer);
|
||||
}
|
||||
ReservationTaskCardFieldDefinition pathDefinition = null;
|
||||
if (fieldPath != null) {
|
||||
pathDefinition = findEditableDefinitionByFieldPath(definitionByPath, fieldPath);
|
||||
pathDefinition = findEditableDefinitionByFieldPath(task, definitionByPath, fieldPath);
|
||||
}
|
||||
if (pointerDefinition != null && pathDefinition != null
|
||||
&& !pointerDefinition.fieldPath().equals(pathDefinition.fieldPath())) {
|
||||
@@ -919,11 +936,12 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 按矩阵字段路径查找可编辑字段,先将旧扁平路径归一化到 P0 主路径。
|
||||
*/
|
||||
private ReservationTaskCardFieldDefinition findEditableDefinitionByFieldPath(
|
||||
ReservationTaskSnapshot task,
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPath) {
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath);
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(task, definitionByPath, fieldPath);
|
||||
ReservationTaskCardFieldDefinition definition = definitionByPath.get(canonicalFieldPath);
|
||||
if (definition == null || !isYes(definition.editable())) {
|
||||
if (definition == null || !isUserEditableField(definition)) {
|
||||
throw reviewPointerError(fieldPath);
|
||||
}
|
||||
return definition;
|
||||
@@ -932,7 +950,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
/**
|
||||
* 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 与旧扁平路径。
|
||||
*/
|
||||
private List<String> fieldPathCandidatesFromPointer(String fieldPointer) {
|
||||
private List<String> fieldPathCandidatesFromPointer(ReservationTaskSnapshot task, String fieldPointer) {
|
||||
String[] rawTokens = fieldPointer.substring(1).split("/", -1);
|
||||
List<String> plainTokens = new ArrayList<>();
|
||||
List<String> collapsedArrayTokens = new ArrayList<>();
|
||||
@@ -959,7 +977,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (!collapsed.equals(candidates.get(0))) {
|
||||
candidates.add(collapsed);
|
||||
}
|
||||
for (String legacyAlias : p0PointerFieldPathAliases(candidates.get(0), collapsed)) {
|
||||
for (String legacyAlias : p0PointerFieldPathAliases(task, candidates.get(0), collapsed)) {
|
||||
if (!candidates.contains(legacyAlias)) {
|
||||
candidates.add(legacyAlias);
|
||||
}
|
||||
@@ -970,7 +988,14 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
/**
|
||||
* 兼容 P0 room_items[] 和旧扁平字段在复核指针中的双向过渡。
|
||||
*/
|
||||
private List<String> p0PointerFieldPathAliases(String plainFieldPath, String collapsedFieldPath) {
|
||||
private List<String> p0PointerFieldPathAliases(
|
||||
ReservationTaskSnapshot task,
|
||||
String plainFieldPath,
|
||||
String collapsedFieldPath) {
|
||||
// Parent/Allotment 在本系统内统一用 group_code 落库,block_code 只作为 SuperAgent 输入侧同义 key。
|
||||
if (allowBlockCodeAsGroupCodeAlias(task) && "case_keys.block_code".equals(plainFieldPath)) {
|
||||
return List.of("case_keys.group_code");
|
||||
}
|
||||
String legacyAlias = legacyFieldPathFor(plainFieldPath);
|
||||
if (legacyAlias != null) {
|
||||
return List.of(legacyAlias);
|
||||
@@ -1039,6 +1064,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 校验 SuperAgent 明确标记的缺失字段已在复核解阻后有值,避免只靠矩阵必填遗漏业务阻塞点。
|
||||
*/
|
||||
private List<String> validateManualReviewMissingFieldsResolved(
|
||||
ReservationTaskSnapshot task,
|
||||
JsonNode aiPayload,
|
||||
List<ReservationTaskCardFieldDefinition> activeDefinitions,
|
||||
Map<String, Object> editedValues) {
|
||||
@@ -1051,7 +1077,8 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
List<String> errors = new ArrayList<>();
|
||||
for (JsonNode missingFieldNode : missingFieldsNode) {
|
||||
String fieldPointer = trimToNull(missingFieldNode.asText(null));
|
||||
ReservationTaskCardFieldDefinition definition = findEditableDefinitionByPointer(definitionByPath, fieldPointer);
|
||||
ReservationTaskCardFieldDefinition definition =
|
||||
findEditableDefinitionByPointer(task, definitionByPath, fieldPointer);
|
||||
if (isEmptyValue(editedValues.get(definition.fieldPath()))) {
|
||||
errors.add(fieldPointer + ": 复核缺失字段仍未补齐。");
|
||||
}
|
||||
@@ -1122,6 +1149,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 标准化请求中的 field_values。支持前端传矩阵 field_path、RFC 6901 pointer 或旧扁平字段。
|
||||
*/
|
||||
private Map<String, Object> normalizeSubmittedFieldValues(
|
||||
ReservationTaskSnapshot task,
|
||||
List<ReservationTaskCardFieldDefinition> definitions,
|
||||
Map<String, Object> rawFieldValues) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
@@ -1132,7 +1160,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
List<String> errors = new ArrayList<>();
|
||||
rawFieldValues.forEach((rawFieldPath, value) -> {
|
||||
String fieldPath = rawFieldPath == null ? "" : rawFieldPath.trim();
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath);
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(task, definitionByPath, fieldPath);
|
||||
if (normalized.containsKey(canonicalFieldPath)) {
|
||||
errors.add(fieldPath + ": 与其他提交字段指向同一字段 " + canonicalFieldPath + "。");
|
||||
return;
|
||||
@@ -1149,12 +1177,13 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 标准化已保存草稿里的历史字段 key,避免旧 payload 影响 P0 新矩阵校验。
|
||||
*/
|
||||
private Map<String, Object> normalizeStoredFieldValues(
|
||||
ReservationTaskSnapshot task,
|
||||
List<ReservationTaskCardFieldDefinition> definitions,
|
||||
Map<String, Object> storedFieldValues) {
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath = definitionByPath(definitions);
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
storedFieldValues.forEach((fieldPath, value) -> {
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath);
|
||||
String canonicalFieldPath = canonicalSubmittedFieldPath(task, definitionByPath, fieldPath);
|
||||
normalized.put(canonicalFieldPath, value);
|
||||
});
|
||||
return normalized;
|
||||
@@ -1174,6 +1203,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
* 将提交字段 key 转成当前矩阵主 field_path;未知字段保留原值,后续由矩阵校验报错。
|
||||
*/
|
||||
private String canonicalSubmittedFieldPath(
|
||||
ReservationTaskSnapshot task,
|
||||
Map<String, ReservationTaskCardFieldDefinition> definitionByPath,
|
||||
String fieldPathOrPointer) {
|
||||
String normalized = trimToNull(fieldPathOrPointer);
|
||||
@@ -1181,7 +1211,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
return "";
|
||||
}
|
||||
if (normalized.startsWith("/")) {
|
||||
for (String candidate : fieldPathCandidatesFromPointer(normalized)) {
|
||||
for (String candidate : fieldPathCandidatesFromPointer(task, normalized)) {
|
||||
if (definitionByPath.containsKey(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
@@ -1191,6 +1221,10 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (definitionByPath.containsKey(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
String businessAlias = businessFieldPathAlias(task, normalized);
|
||||
if (businessAlias != null && definitionByPath.containsKey(businessAlias)) {
|
||||
return businessAlias;
|
||||
}
|
||||
String canonicalAlias = canonicalP0RoomItemFieldPath(normalized);
|
||||
if (canonicalAlias != null && definitionByPath.containsKey(canonicalAlias)) {
|
||||
return canonicalAlias;
|
||||
@@ -1209,6 +1243,21 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 补齐系统可确定的派生字段。只影响草稿/确认后的业务 payload,不回写 SuperAgent 原始 AI payload。
|
||||
*/
|
||||
private Map<String, Object> applySystemDerivedFieldDefaults(
|
||||
ReservationTaskSnapshot task,
|
||||
JsonNode aiPayload,
|
||||
Map<String, Object> editedValues) {
|
||||
Map<String, Object> values = new LinkedHashMap<>(editedValues);
|
||||
if (isCancelAllotmentTask(task)
|
||||
&& isEmptyValue(valueForField(aiPayload, values, "extracted_fields.cancel_object_type"))) {
|
||||
values.put("extracted_fields.cancel_object_type", "allotment_control_block");
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前任务类型、subtype 和展示条件筛出本次需要参与后端校验的字段。
|
||||
*/
|
||||
@@ -1220,7 +1269,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
.stream()
|
||||
.filter(definition -> isYes(definition.visible()))
|
||||
.filter(definition -> matchesTaskResultType(definition.resultType(), task))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskType(), task.aiTaskType()))
|
||||
.filter(definition -> matchesTaskTypeExpression(definition.taskType(), task))
|
||||
.filter(definition -> matchesMatrixExpression(definition.taskSubtype(), task.taskSubtype()))
|
||||
.filter(definition -> displayConditionMatches(task, definition, aiPayload, editedValues))
|
||||
.toList();
|
||||
@@ -1241,7 +1290,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
errors.add(fieldPath + ": 不属于当前任务卡、task_subtype 或展示条件。");
|
||||
continue;
|
||||
}
|
||||
if (!isYes(definition.editable())) {
|
||||
if (!isUserEditableField(definition)) {
|
||||
errors.add(fieldPath + ": 当前字段为只读,不允许人工修改。");
|
||||
}
|
||||
}
|
||||
@@ -1292,7 +1341,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
if (!fieldPathsToValidate.contains(definition.fieldPath()) || isEmptyValue(value)) {
|
||||
continue;
|
||||
}
|
||||
errors.addAll(validateEnumOptions(definition, value));
|
||||
errors.addAll(validateEnumOptions(task, definition, value));
|
||||
errors.addAll(validateBasicRule(definition, value));
|
||||
}
|
||||
return errors;
|
||||
@@ -1749,6 +1798,26 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
return matchesMatrixExpression(matrixResultType, task.resultType());
|
||||
}
|
||||
|
||||
/**
|
||||
* P0.1 将 Parent release 路由为 Cancel Allotment,但字段矩阵仍复用 Cancel Booking 行。
|
||||
*/
|
||||
private boolean matchesTaskTypeExpression(String matrixTaskType, ReservationTaskSnapshot task) {
|
||||
if (ReservationTaskCardType.CANCEL_ALLOTMENT.name().equals(task.taskCardType())
|
||||
&& matchesMatrixExpression(matrixTaskType, "Cancel Booking")) {
|
||||
return true;
|
||||
}
|
||||
return matchesMatrixExpression(matrixTaskType, task.aiTaskType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 P0.1 Parent / Allotment 取消卡,用于兼容旧 Cancel Booking 字段矩阵。
|
||||
*/
|
||||
private boolean isCancelAllotmentTask(ReservationTaskSnapshot task) {
|
||||
return task != null
|
||||
&& ReservationTaskCardType.CANCEL_ALLOTMENT.name().equals(task.taskCardType())
|
||||
&& "cancel_allotment_control_block".equals(task.taskSubtype());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前已知矩阵展示条件判断字段是否参与本次后端校验。
|
||||
*/
|
||||
@@ -1863,7 +1932,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
/**
|
||||
* 校验字段枚举值。对象型字段和 key=value 复合枚举先不做强校验。
|
||||
*/
|
||||
private List<String> validateEnumOptions(ReservationTaskCardFieldDefinition definition, Object value) {
|
||||
private List<String> validateEnumOptions(
|
||||
ReservationTaskSnapshot task,
|
||||
ReservationTaskCardFieldDefinition definition,
|
||||
Object value) {
|
||||
if (isCancelAllotmentTask(task)
|
||||
&& "extracted_fields.cancel_scope".equals(definition.fieldPath())
|
||||
&& "entire_allotment_control_block".equals(String.valueOf(value))) {
|
||||
return List.of();
|
||||
}
|
||||
String enumOptions = trimToNull(definition.enumOptions());
|
||||
if (enumOptions == null || "-".equals(enumOptions) || enumOptions.contains("=") || value instanceof Map) {
|
||||
return List.of();
|
||||
@@ -1961,6 +2038,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
*/
|
||||
private boolean isNumberField(ReservationTaskCardFieldDefinition definition) {
|
||||
return isYes(definition.numberInput())
|
||||
|| containsText(definition.validationRule(), "数字")
|
||||
|| containsText(definition.validationRule(), "正整数")
|
||||
|| containsText(definition.validationRule(), "非负数");
|
||||
}
|
||||
@@ -2024,6 +2102,23 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 P0.1 Parent / Allotment 的业务同义字段。普通 Group/FIT 卡不启用该别名。
|
||||
*/
|
||||
private String businessFieldPathAlias(ReservationTaskSnapshot task, String fieldPath) {
|
||||
if (allowBlockCodeAsGroupCodeAlias(task) && "case_keys.block_code".equals(fieldPath)) {
|
||||
return "case_keys.group_code";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent/Allotment 的 block_code 只在 Cancel Allotment 卡上作为 group_code 输入侧别名。
|
||||
*/
|
||||
private boolean allowBlockCodeAsGroupCodeAlias(ReservationTaskSnapshot task) {
|
||||
return isCancelAllotmentTask(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* P0 room_items[0] 主字段转旧扁平字段路径,用于前端兼容显示。
|
||||
*/
|
||||
@@ -2164,7 +2259,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
JsonNode aiPayload = parseJson(taskCard.aiPayloadJson());
|
||||
return fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType())
|
||||
.stream()
|
||||
.map(definition -> toFieldResult(definition, aiPayload))
|
||||
.map(definition -> toFieldResult(task, definition, aiPayload))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -2195,7 +2290,15 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
/**
|
||||
* 将字段定义转换为详情字段结果,并从 AI payload 中读取当前值。
|
||||
*/
|
||||
private ReservationTaskFieldResult toFieldResult(ReservationTaskCardFieldDefinition definition, JsonNode aiPayload) {
|
||||
private ReservationTaskFieldResult toFieldResult(
|
||||
ReservationTaskSnapshot task,
|
||||
ReservationTaskCardFieldDefinition definition,
|
||||
JsonNode aiPayload) {
|
||||
boolean rawReadonly = isRawReadonlyField(definition);
|
||||
String controlType = resolveControlType(definition, rawReadonly);
|
||||
String editScope = resolveEditScope(task, definition, aiPayload, rawReadonly, controlType);
|
||||
String writeTarget = resolveWriteTarget(editScope);
|
||||
String optionsSource = resolveOptionsSource(definition, controlType);
|
||||
return new ReservationTaskFieldResult(
|
||||
definition.rowNumber(),
|
||||
definition.cardName(),
|
||||
@@ -2224,9 +2327,236 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
|
||||
definition.operaWriteParticipation(),
|
||||
definition.operaParameterMapping(),
|
||||
definition.notes(),
|
||||
controlType,
|
||||
editScope,
|
||||
writeTarget,
|
||||
optionsSource,
|
||||
rawReadonly,
|
||||
resolveControlHint(controlType, optionsSource, editScope),
|
||||
valueForField(aiPayload, Map.of(), definition.fieldPath()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按字段矩阵和只读语义推导前端控件类型,前端优先使用该字段而不是自行猜矩阵列。
|
||||
*/
|
||||
private String resolveControlType(ReservationTaskCardFieldDefinition definition, boolean rawReadonly) {
|
||||
if (isYes(definition.fileDisplay())) {
|
||||
return "file";
|
||||
}
|
||||
if (isYes(definition.tableEditable()) || safeFieldPath(definition).endsWith("[]")) {
|
||||
return "structured_table";
|
||||
}
|
||||
if (rawReadonly) {
|
||||
return "readonly";
|
||||
}
|
||||
if (isDateField(definition)) {
|
||||
return "date";
|
||||
}
|
||||
if (isNumberField(definition)) {
|
||||
return "number";
|
||||
}
|
||||
if (isYes(definition.selectEditable()) && hasEnumOptions(definition)) {
|
||||
return "select";
|
||||
}
|
||||
if (isFieldEditable(definition)) {
|
||||
return isLongTextField(definition) ? "textarea" : "text";
|
||||
}
|
||||
return "readonly";
|
||||
}
|
||||
|
||||
/**
|
||||
* 按任务结果类型和字段只读边界推导编辑范围;同卡复核只把可编辑字段写入复核解阻结果。
|
||||
*/
|
||||
private String resolveEditScope(
|
||||
ReservationTaskSnapshot task,
|
||||
ReservationTaskCardFieldDefinition definition,
|
||||
JsonNode aiPayload,
|
||||
boolean rawReadonly,
|
||||
String controlType) {
|
||||
if (rawReadonly || !isFieldEditable(definition) || "file".equals(controlType)) {
|
||||
return "never";
|
||||
}
|
||||
if (isTypeKnownManualReviewTask(task)) {
|
||||
return isManualReviewMissingField(task, definition, aiPayload)
|
||||
? "manual_review_only"
|
||||
: "normal_and_manual_review";
|
||||
}
|
||||
return "normal_task";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将编辑范围映射为前端提交后实际落点,避免前端把用户修改写回 ai_payload_json。
|
||||
*/
|
||||
private String resolveWriteTarget(String editScope) {
|
||||
return switch (editScope) {
|
||||
case "normal_task" -> "draft_payload.field_values";
|
||||
case "manual_review_only" -> "review_resolution.field_overrides";
|
||||
case "normal_and_manual_review" -> "draft_payload_and_review_resolution";
|
||||
case "workflow_only", "system_only" -> "system_state";
|
||||
default -> "none";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 推导字段选项来源。目录和 lookup 第一版只表达来源,不代表后端已经提供通用查询接口。
|
||||
*/
|
||||
private String resolveOptionsSource(ReservationTaskCardFieldDefinition definition, String controlType) {
|
||||
String fieldPath = safeFieldPath(definition);
|
||||
if (fieldPath.endsWith("pms_room_type_code") || fieldPath.contains(".pms_room_type_code")) {
|
||||
return "active_pms_room_type_catalog";
|
||||
}
|
||||
if (fieldPath.endsWith("rate_code") || fieldPath.contains("rate_code_result.rate_code")) {
|
||||
return "rate_code_catalog";
|
||||
}
|
||||
if (fieldPath.startsWith("case_keys.")) {
|
||||
return "system_case_lookup";
|
||||
}
|
||||
if (("select".equals(controlType) || "multiselect".equals(controlType)) && hasEnumOptions(definition)) {
|
||||
return "static_enum";
|
||||
}
|
||||
if ("structured_table".equals(controlType) && hasEnumOptions(definition)) {
|
||||
return "pending_contract";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* 给前端补充非强制提示;真实目录、lookup 和通用表格编辑未接入前用于安全降级。
|
||||
*/
|
||||
private String resolveControlHint(String controlType, String optionsSource, String editScope) {
|
||||
if ("active_pms_room_type_catalog".equals(optionsSource) || "rate_code_catalog".equals(optionsSource)) {
|
||||
return "catalog_backend_pending";
|
||||
}
|
||||
if ("system_case_lookup".equals(optionsSource)) {
|
||||
return "lookup_backend_pending";
|
||||
}
|
||||
if ("structured_table".equals(controlType) && !"never".equals(editScope)) {
|
||||
return "structured_table_editor_pending";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否属于原文、证据、路由或 AI 诊断字段,这类字段只能只读保留。
|
||||
*/
|
||||
private boolean isRawReadonlyField(ReservationTaskCardFieldDefinition definition) {
|
||||
String fieldPath = safeFieldPath(definition);
|
||||
String lowerPath = fieldPath.toLowerCase(Locale.ROOT);
|
||||
if (isYes(definition.fileDisplay())) {
|
||||
return true;
|
||||
}
|
||||
if (!isFieldEditable(definition) && safeText(definition.writePath()).startsWith("ai_payload_json.")) {
|
||||
return true;
|
||||
}
|
||||
if (lowerPath.equals("source_message_id") || lowerPath.startsWith("source_message.")) {
|
||||
return true;
|
||||
}
|
||||
if (lowerPath.equals("attachments") || lowerPath.startsWith("attachments")
|
||||
|| lowerPath.startsWith("file_references") || lowerPath.startsWith("context_used")) {
|
||||
return true;
|
||||
}
|
||||
if (lowerPath.equals("relevant_message_excerpt")
|
||||
|| lowerPath.equals("text_raw")
|
||||
|| lowerPath.endsWith("room_type_raw")
|
||||
|| lowerPath.contains("evidence")
|
||||
|| lowerPath.startsWith("manual_review.")) {
|
||||
return true;
|
||||
}
|
||||
return Set.of(
|
||||
"event_type",
|
||||
"event_role",
|
||||
"current_or_history",
|
||||
"source_event_index",
|
||||
"related_source_event_index",
|
||||
"related_source_event_indices",
|
||||
"relationship_type",
|
||||
"related_event_type",
|
||||
"route_code",
|
||||
"result_type",
|
||||
"task_type",
|
||||
"task_subtype",
|
||||
"system_process_category"
|
||||
).contains(lowerPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否可由用户通过当前任务卡修改。
|
||||
*/
|
||||
private boolean isFieldEditable(ReservationTaskCardFieldDefinition definition) {
|
||||
return isYes(definition.editable()) || isYes(definition.inputEditable()) || isYes(definition.selectEditable());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否最终允许用户写入;字段矩阵可编辑列不能覆盖 raw evidence、原文、路由等强只读边界。
|
||||
*/
|
||||
private boolean isUserEditableField(ReservationTaskCardFieldDefinition definition) {
|
||||
return isFieldEditable(definition) && !isRawReadonlyField(definition);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断同卡人工复核中当前字段是否被 missing_fields 指向。
|
||||
*/
|
||||
private boolean isManualReviewMissingField(
|
||||
ReservationTaskSnapshot task,
|
||||
ReservationTaskCardFieldDefinition definition,
|
||||
JsonNode aiPayload) {
|
||||
JsonNode missingFieldsNode = aiPayload.path("manual_review").path("missing_fields");
|
||||
if (!missingFieldsNode.isArray()) {
|
||||
return false;
|
||||
}
|
||||
String fieldPath = safeFieldPath(definition);
|
||||
String fieldPointer = fieldPointerFor(fieldPath);
|
||||
for (JsonNode missingFieldNode : missingFieldsNode) {
|
||||
String missingPointer = trimToNull(missingFieldNode.asText(null));
|
||||
if (missingPointer == null) {
|
||||
continue;
|
||||
}
|
||||
if (missingPointer.equals(fieldPointer)
|
||||
|| (missingPointer.startsWith("/")
|
||||
&& fieldPathCandidatesFromPointer(task, missingPointer).contains(fieldPath))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否有静态枚举选项。
|
||||
*/
|
||||
private boolean hasEnumOptions(ReservationTaskCardFieldDefinition definition) {
|
||||
String enumOptions = trimToNull(definition.enumOptions());
|
||||
return enumOptions != null && !"-".equals(enumOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字段是否适合长文本控件展示。
|
||||
*/
|
||||
private boolean isLongTextField(ReservationTaskCardFieldDefinition definition) {
|
||||
String fieldPath = safeFieldPath(definition).toLowerCase(Locale.ROOT);
|
||||
String displayName = safeText(definition.displayName());
|
||||
return fieldPath.contains("note")
|
||||
|| fieldPath.contains("remark")
|
||||
|| fieldPath.contains("reason")
|
||||
|| fieldPath.contains("comment")
|
||||
|| displayName.contains("备注")
|
||||
|| displayName.contains("说明")
|
||||
|| displayName.contains("原因");
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全读取字段路径,避免空字段路径影响控件推导。
|
||||
*/
|
||||
private String safeFieldPath(ReservationTaskCardFieldDefinition definition) {
|
||||
return safeText(definition == null ? null : definition.fieldPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全读取字符串,空值返回空串。
|
||||
*/
|
||||
private String safeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务详情来源消息展示上下文。只包含 SourceMessage Inbox 安全摘要字段。
|
||||
*/
|
||||
|
||||
@@ -96,6 +96,13 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
.andExpect(jsonPath("$.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"))
|
||||
.andReturn();
|
||||
|
||||
String s10TaskId = com.jayway.jsonpath.JsonPath.read(
|
||||
s10Result.getResponse().getContentAsString(),
|
||||
"$.items[0].task_id");
|
||||
mockMvc.perform(get("/api/reservation/tasks/{taskId}", s10TaskId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.fields.length()").value(0));
|
||||
|
||||
assertSourceMessageOnlyTaskCount(s10Result, "S10");
|
||||
assertSourceMessageOnlyTaskCount(s99Result, "S99");
|
||||
}
|
||||
@@ -407,6 +414,92 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
assertThat(parentReviewTaskCount).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveParentKeyConflictManualReviewWithGroupCodeAlias() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
useParentSplitBusinessKeys(root, "PARENT-2608-RESOLVE", "CHILD-2608-RESOLVE-A", "CHILD-2608-RESOLVE-B");
|
||||
convertParentSplitRootToP01(root);
|
||||
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
|
||||
ObjectNode parentCaseKeys = (ObjectNode) parentEvent.path("case_keys");
|
||||
parentCaseKeys.putNull("group_code");
|
||||
parentCaseKeys.putNull("block_code");
|
||||
ObjectNode contextUsed = (ObjectNode) parentEvent.path("context_used");
|
||||
ArrayNode candidates = contextUsed.putArray("parent_identity_candidates");
|
||||
candidates.addObject()
|
||||
.put("field", "group_code")
|
||||
.put("value", "PARENT-2608-RESOLVE-A")
|
||||
.put("evidence_source", "subject");
|
||||
candidates.addObject()
|
||||
.put("field", "block_code")
|
||||
.put("value", "PARENT-2608-RESOLVE-B")
|
||||
.put("evidence_source", "attachment");
|
||||
ObjectNode manualReview = parentEvent.putObject("manual_review");
|
||||
manualReview.put("reason_code", "target_object_unclear");
|
||||
manualReview.put("visible_reason", "Parent Group 的 group_code 与 block_code 原始候选冲突,请人工确认目标。");
|
||||
manualReview.put("review_record_type", "business_event_review");
|
||||
manualReview.putArray("missing_fields")
|
||||
.add("/case_keys/group_code")
|
||||
.add("/case_keys/block_code");
|
||||
manualReview.putArray("blocking_points")
|
||||
.add("Parent Group identity cannot be safely normalized.");
|
||||
manualReview.putArray("conflicting_points")
|
||||
.add("PARENT-2608-RESOLVE-A")
|
||||
.add("PARENT-2608-RESOLVE-B");
|
||||
manualReview.putArray("suggested_human_actions")
|
||||
.add("confirm_parent_group_identity");
|
||||
manualReview.putArray("evidence_to_check")
|
||||
.add("parent_identity_candidates");
|
||||
manualReview.putObject("known_fields");
|
||||
String externalId = "p0-parent-split-key-conflict-resolve-001";
|
||||
captureSourceMessage(externalId);
|
||||
|
||||
MvcResult createResult = mockMvc.perform(signedPost(
|
||||
withSourceMessageId(root, externalId),
|
||||
"nonce-p0-parent-key-conflict-resolve-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.items[2].route_code").value("R08_CANCEL_ALLOTMENT_CONTROL_BLOCK_REVIEW"))
|
||||
.andReturn();
|
||||
String taskId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[2].task_id");
|
||||
String orderId = com.jayway.jsonpath.JsonPath.read(
|
||||
createResult.getResponse().getContentAsString(),
|
||||
"$.items[2].order_id");
|
||||
|
||||
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.group_code')].field_pointer")
|
||||
.value(contains("/case_keys/group_code")));
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"confirmed_order_id": "%s",
|
||||
"reason": "确认 Parent Group 目标。",
|
||||
"field_overrides": [
|
||||
{
|
||||
"field_pointer": "/case_keys/group_code",
|
||||
"value": "PARENT-2608-RESOLVED"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.task_status").value("READY"))
|
||||
.andExpect(jsonPath("$.review_status").value("RESOLVED"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer")
|
||||
.value("/case_keys/group_code"))
|
||||
.andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path")
|
||||
.value("case_keys.group_code"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['case_keys.group_code']")
|
||||
.value("PARENT-2608-RESOLVED"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.cancel_object_type']")
|
||||
.value("allotment_control_block"))
|
||||
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.cancel_scope']")
|
||||
.value("entire_allotment_control_block"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenCurrentProducerUsesLegacyParentCancelBooking() throws Exception {
|
||||
ObjectNode root = fixture("parent_split_two_children.json").deepCopy();
|
||||
@@ -590,12 +683,67 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].field_pointer")
|
||||
.value(contains("/extracted_fields/room_items/0/room_quantity")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].control_type")
|
||||
.value(contains("number")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].edit_scope")
|
||||
.value(contains("normal_task")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].write_target")
|
||||
.value(contains("draft_payload.field_values")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].options_source")
|
||||
.value(contains("none")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_quantity')].raw_readonly")
|
||||
.value(contains(false)))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].field_pointer")
|
||||
.value(contains("/extracted_fields/room_items/0/room_type_raw")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].control_type")
|
||||
.value(contains("readonly")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].edit_scope")
|
||||
.value(contains("never")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].raw_readonly")
|
||||
.value(contains(true)))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer")
|
||||
.value(contains("/extracted_fields/room_items/0/pms_room_type_code")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].control_type")
|
||||
.value(contains("select")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].options_source")
|
||||
.value(contains("active_pms_room_type_catalog")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].value")
|
||||
.value(contains("RM2")));
|
||||
.value(contains("RM2")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].control_type")
|
||||
.value(contains("readonly")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].edit_scope")
|
||||
.value(contains("never")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].write_target")
|
||||
.value(contains("none")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='relevant_message_excerpt')].raw_readonly")
|
||||
.value(contains(true)));
|
||||
|
||||
mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"field_values": {
|
||||
"extracted_fields.room_items.0.room_type_raw": "SHOULD-NOT-OVERWRITE-RAW",
|
||||
"extracted_fields.room_items.0.room_quantity": 5
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.room_items.0.room_type_raw")));
|
||||
|
||||
mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"field_values": {
|
||||
"extracted_fields.room_type": "SHOULD-NOT-OVERWRITE-LEGACY-RAW"
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
|
||||
.andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.room_items.0.room_type_raw")));
|
||||
|
||||
mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -708,9 +856,59 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].field_pointer")
|
||||
.value(contains("/extracted_fields/room_items/0/pms_room_type_code")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].control_type")
|
||||
.value(contains("select")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].edit_scope")
|
||||
.value(contains("manual_review_only")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].write_target")
|
||||
.value(contains("review_resolution.field_overrides")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].options_source")
|
||||
.value(contains("active_pms_room_type_catalog")))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.pms_room_type_code')].raw_readonly")
|
||||
.value(contains(false)))
|
||||
.andExpect(jsonPath("$.fields[?(@.field_path=='extracted_fields.room_items.0.room_type_raw')].value")
|
||||
.value(contains("SUITE")));
|
||||
|
||||
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/0/room_type_raw",
|
||||
"value": "SHOULD-NOT-OVERWRITE-RAW"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"))
|
||||
.andExpect(jsonPath("$.details[0]")
|
||||
.value(containsString("/extracted_fields/room_items/0/room_type_raw")));
|
||||
|
||||
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/0/pms_room_type_code",
|
||||
"value": "SU1"
|
||||
},
|
||||
{
|
||||
"field_pointer": "/extracted_fields/room_items/0/room_type_raw",
|
||||
"value": "SHOULD-NOT-OVERWRITE-RAW"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"))
|
||||
.andExpect(jsonPath("$.details[0]")
|
||||
.value(containsString("/extracted_fields/room_items/0/room_type_raw")));
|
||||
|
||||
mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
@@ -758,6 +956,45 @@ class SuperAgentTaskResultP0FixtureRegressionTest {
|
||||
assertThat(aiPayloadJson).doesNotContain("\"pms_room_type_code\":\"SU1\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBlockCodeAliasOutsideCancelAllotmentManualReview() throws Exception {
|
||||
JsonNode manualReview = fixture("manual_review_resolution.json");
|
||||
ObjectNode event = manualReview.path("known_subtype_manual_review").path("event").deepCopy();
|
||||
useManualReviewGroupCode(event, "CHILD-SUITE-BLOCK-ALIAS-001");
|
||||
String externalId = "p0-manual-review-block-alias-scope-001";
|
||||
captureSourceMessage(externalId);
|
||||
|
||||
MvcResult createResult = mockMvc.perform(signedPost(
|
||||
businessRoot(externalId, event),
|
||||
"nonce-p0-manual-review-block-alias-scope-001"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_REVIEW"))
|
||||
.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": "/case_keys/block_code",
|
||||
"value": "SHOULD-NOT-BECOME-GROUP-CODE"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".formatted(orderId)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error_code").value("TASK_REVIEW_POINTER_INVALID"))
|
||||
.andExpect(jsonPath("$.details[0]").value(containsString("/case_keys/block_code")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenManualReviewMissingFieldPointerDoesNotResolve() throws Exception {
|
||||
ObjectNode event = fixture("manual_review_resolution.json")
|
||||
|
||||
Reference in New Issue
Block a user