diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index 8f419c9..9df400a 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -546,7 +546,7 @@ V3 字段说明: - 业务 event 能派生到稳定路由时,复用现有订单 / 任务 / 任务卡创建链路。 - event 判别字段不完整、显式携带 `contract_errors`、根 `missing_fields`、不完整 `manual_review` 或不完整 parent split 候选时,写入 `adapter_contract_error` transition,不创建订单和任务;同一邮件其他 sibling event 继续处理。 - `unhandled_current_intents[]` 写入 `UNHANDLED_CURRENT_INTENT` transition,不返回 `adapter_error_code`。 -- type-known manual review 第一版在同一业务任务卡解阻;当前字段矩阵仍使用扁平字段,`/extracted_fields/room_items/{index}/pms_room_type_code` 会兼容映射到 `extracted_fields.pms_room_type_code`。 +- type-known manual review 第一版在同一业务任务卡解阻;New Booking 房型字段主路径已迁移为 `room_items[0]`,例如 `/extracted_fields/room_items/0/pms_room_type_code`。旧扁平字段仍可作为过渡提交 key,但响应会归一化为 P0 主 `field_path`。 `source_message.source_message_id` 缺失时返回 `HTTP 400`,响应体不使用通用错误包装: diff --git a/docs/project/requirements/M002-backend-data-model-design.md b/docs/project/requirements/M002-backend-data-model-design.md index e1e9a95..aa75371 100644 --- a/docs/project/requirements/M002-backend-data-model-design.md +++ b/docs/project/requirements/M002-backend-data-model-design.md @@ -264,7 +264,7 @@ V2 一条 `ai_task_results[]` item 对应一行。M002 V3 后,`message_events[ | `hotel_id` | `VARCHAR(64)` | 酒店或业务上下文 | | `task_id` | `BIGINT` | 所属任务 | | `task_card_type` | `VARCHAR(64)` | 任务卡类型 | -| `field_contract_version` | `VARCHAR(64)` | 字段契约版本,第一版可写 `code-v1` | +| `field_contract_version` | `VARCHAR(64)` | 字段契约版本,当前 P0 字段矩阵写 `20260711-p0` | | `ai_payload_json` | `LONGTEXT` | 任务卡使用的 AI 原始 JSON 快照 | | `draft_payload_json` | `LONGTEXT` | 用户编辑草稿,可为空 | | `confirmed_payload_json` | `LONGTEXT` | 用户确认后的最终 payload | diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskPayloadMutationRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskPayloadMutationRequest.java index d8a2805..9bdd3b2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskPayloadMutationRequest.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskPayloadMutationRequest.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Map; /** - * 任务卡草稿保存和最终确认请求。第一版 field_values 使用矩阵 field_path 作为 key,不使用 write_path 生成 OPERA 参数。 + * 任务卡草稿保存和最终确认请求。field_values 支持矩阵 field_path、RFC 6901 pointer 和旧扁平字段兼容 key。 */ public record ReservationTaskPayloadMutationRequest( @JsonProperty("field_values") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionFieldOverrideResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionFieldOverrideResult.java index c8cb630..6b1b394 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionFieldOverrideResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ManualReviewResolutionFieldOverrideResult.java @@ -6,7 +6,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; * 同卡人工复核字段修正结果。返回 JSON Pointer 与矩阵 field_path 的对应关系。 * * @param fieldPointer 请求中的 JSON Pointer - * @param fieldPath 后端矩阵 field_path + * @param fieldPath 后端矩阵主 field_path + * @param legacyFieldPath 旧扁平字段路径,仅用于前端过渡兼容 * @param value 人工修正后的字段值 */ public record ManualReviewResolutionFieldOverrideResult( @@ -14,6 +15,8 @@ public record ManualReviewResolutionFieldOverrideResult( String fieldPointer, @JsonProperty("field_path") String fieldPath, + @JsonProperty("legacy_field_path") + String legacyFieldPath, Object value ) { } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java index 8c6fef1..d7f4a7d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskFieldResult.java @@ -11,7 +11,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; * @param taskType 任务类型白名单值 * @param taskSubtype 任务 subtype 白名单值 * @param displayArea 前端展示区域 - * @param fieldPath 字段路径 + * @param fieldPath 后端矩阵主字段路径,P0 起 room_items 使用显式 0 下标路径 + * @param fieldPointer RFC 6901 JSON Pointer,前端复核和编辑定位优先使用 + * @param legacyFieldPath 旧扁平字段路径,仅用于前端过渡兼容 * @param displayName 字段展示名称 * @param visible 是否可见 * @param editable 是否可编辑 @@ -47,6 +49,10 @@ public record ReservationTaskFieldResult( String displayArea, @JsonProperty("field_path") String fieldPath, + @JsonProperty("field_pointer") + String fieldPointer, + @JsonProperty("legacy_field_path") + String legacyFieldPath, @JsonProperty("display_name") String displayName, String visible, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskPayloadMutationResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskPayloadMutationResult.java index e0bd869..498a7ed 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskPayloadMutationResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskPayloadMutationResult.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; /** - * 任务卡草稿保存或最终确认结果。payload 为第一版 field_path 简单结构,供前端回显和后续 OPERA 转换使用。 + * 任务卡草稿保存或最终确认结果。payload 同时包含主 field_values、兼容 legacy_field_values 和嵌套 effective_payload。 */ public record ReservationTaskPayloadMutationResult( @JsonProperty("task_id") diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java index da5f888..277967e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationTaskCardEntity.java @@ -20,7 +20,7 @@ public class ReservationTaskCardEntity { private Long taskId; /** 任务卡类型。 */ private String taskCardType; - /** 字段契约版本,第一版使用 code-v1。 */ + /** 字段契约版本,当前 P0 字段矩阵使用 20260711-p0。 */ private String fieldContractVersion; /** 任务卡使用的 AI 原始 JSON 快照。 */ private String aiPayloadJson; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java index b9c9dee..4b9f21f 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationAiTaskIntakeServiceImpl.java @@ -50,7 +50,7 @@ import org.springframework.transaction.annotation.Transactional; @Service public class ReservationAiTaskIntakeServiceImpl implements ReservationAiTaskIntakeService { - private static final String FIELD_CONTRACT_VERSION = "code-v1"; + private static final String FIELD_CONTRACT_VERSION = "20260711-p0"; private static final String BATCH_KEY_PREFIX = "superagent-task-result-batch:v1"; private static final String ITEM_KEY_PREFIX = "superagent-task-result-item:v1"; private static final String SOURCE_MESSAGE_ONLY_RESULT_TYPE = "source_message_only"; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index 1cd1945..2a58a10 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -48,6 +48,8 @@ import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskCardFieldD import cn.nianxx.thhotel.workflows.reservation.service.ReservationTaskWorkflowService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; @@ -79,7 +81,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl private static final String ACTION_OPERA_OPERATION_RETRY = "OPERA_OPERATION_RETRY"; private static final String ACTOR_TYPE_USER = "USER"; private static final String ACTOR_ID_LOCAL = "local-user"; - private static final String PAYLOAD_SCHEMA_VERSION = "field_path-v1"; + private static final String PAYLOAD_SCHEMA_VERSION = "field_matrix-p0-room-items-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"; @@ -202,8 +204,14 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ensureTaskEditable(task); JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); - Map submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues()); - Map draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson()); + List allDefinitions = + fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()); + Map submittedValues = normalizeSubmittedFieldValues( + allDefinitions, + request == null ? null : request.fieldValues()); + Map draftValues = normalizeStoredFieldValues( + allDefinitions, + fieldValuesFromPayloadJson(taskCard.draftPayloadJson())); Map mergedDraftValues = mergeFieldValues(draftValues, submittedValues); List activeDefinitions = activeDefinitions(task, aiPayload, mergedDraftValues); @@ -244,13 +252,19 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl ensureGenericConfirmAllowed(task, taskCard); JsonNode aiPayload = parseJson(taskCard.aiPayloadJson()); - Map submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues()); - Map draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson()); + List allDefinitions = + fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType()); + Map submittedValues = normalizeSubmittedFieldValues( + allDefinitions, + request == null ? null : request.fieldValues()); + Map draftValues = normalizeStoredFieldValues( + allDefinitions, + fieldValuesFromPayloadJson(taskCard.draftPayloadJson())); Map editedValues = mergeFieldValues(draftValues, submittedValues); List activeDefinitions = activeDefinitions(task, aiPayload, editedValues); List validationErrors = validateSubmittedFields(activeDefinitions, submittedValues); validationErrors.addAll(validateDefinitionValues(task, activeDefinitions, aiPayload, editedValues, - definitionFieldPaths(activeDefinitions), true)); + definitionFieldPaths(activeDefinitions), true, true)); validationErrors.addAll(validateDateRange(activeDefinitions, aiPayload, editedValues)); if (!validationErrors.isEmpty()) { throw validationError(validationErrors); @@ -818,6 +832,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl results.add(new ManualReviewResolutionFieldOverrideResult( fieldPointer, definition.fieldPath(), + legacyFieldPathFor(definition.fieldPath()), request.value())); } return results; @@ -869,7 +884,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } /** - * 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 的映射。 + * 将 RFC 6901 JSON Pointer 转换为可能的矩阵 field_path,兼容数组下标到 [] 与旧扁平路径。 */ private List fieldPathCandidatesFromPointer(String fieldPointer) { String[] rawTokens = fieldPointer.substring(1).split("/", -1); @@ -898,22 +913,27 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (!collapsed.equals(candidates.get(0))) { candidates.add(collapsed); } - String legacyAlias = p0ReviewPointerLegacyAlias(candidates.get(0), collapsed); - if (legacyAlias != null && !candidates.contains(legacyAlias)) { - candidates.add(legacyAlias); + for (String legacyAlias : p0PointerFieldPathAliases(candidates.get(0), collapsed)) { + if (!candidates.contains(legacyAlias)) { + candidates.add(legacyAlias); + } } return candidates; } /** - * 兼容 0711 P0 fixtures 中已迁移为数组结构、但当前后端矩阵仍是扁平字段的复核 pointer。 + * 兼容 P0 room_items[] 和旧扁平字段在复核指针中的双向过渡。 */ - private String p0ReviewPointerLegacyAlias(String plainFieldPath, String collapsedFieldPath) { - if ("extracted_fields.room_items.0.pms_room_type_code".equals(plainFieldPath) - && "extracted_fields.room_items[].pms_room_type_code".equals(collapsedFieldPath)) { - return "extracted_fields.pms_room_type_code"; + private List p0PointerFieldPathAliases(String plainFieldPath, String collapsedFieldPath) { + String legacyAlias = legacyFieldPathFor(plainFieldPath); + if (legacyAlias != null) { + return List.of(legacyAlias); } - return null; + String canonicalAlias = canonicalP0RoomItemFieldPath(plainFieldPath); + if (canonicalAlias != null) { + return List.of(canonicalAlias); + } + return List.of(); } /** @@ -1053,14 +1073,82 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } /** - * 标准化请求中的 field_values。空请求按空草稿处理。 + * 标准化请求中的 field_values。支持前端传矩阵 field_path、RFC 6901 pointer 或旧扁平字段。 */ - private Map normalizeFieldValues(Map rawFieldValues) { + private Map normalizeSubmittedFieldValues( + List definitions, + Map rawFieldValues) { Map normalized = new LinkedHashMap<>(); if (rawFieldValues == null) { return normalized; } - rawFieldValues.forEach((fieldPath, value) -> normalized.put(fieldPath == null ? "" : fieldPath.trim(), value)); + Map definitionByPath = definitionByPath(definitions); + List errors = new ArrayList<>(); + rawFieldValues.forEach((rawFieldPath, value) -> { + String fieldPath = rawFieldPath == null ? "" : rawFieldPath.trim(); + String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath); + if (normalized.containsKey(canonicalFieldPath)) { + errors.add(fieldPath + ": 与其他提交字段指向同一字段 " + canonicalFieldPath + "。"); + return; + } + normalized.put(canonicalFieldPath, value); + }); + if (!errors.isEmpty()) { + throw validationError(errors); + } + return normalized; + } + + /** + * 标准化已保存草稿里的历史字段 key,避免旧 payload 影响 P0 新矩阵校验。 + */ + private Map normalizeStoredFieldValues( + List definitions, + Map storedFieldValues) { + Map definitionByPath = definitionByPath(definitions); + Map normalized = new LinkedHashMap<>(); + storedFieldValues.forEach((fieldPath, value) -> { + String canonicalFieldPath = canonicalSubmittedFieldPath(definitionByPath, fieldPath); + normalized.put(canonicalFieldPath, value); + }); + return normalized; + } + + /** + * 构建字段定义索引,供提交字段和复核 pointer 映射使用。 + */ + private Map definitionByPath( + List definitions) { + Map definitionByPath = new LinkedHashMap<>(); + definitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition)); + return definitionByPath; + } + + /** + * 将提交字段 key 转成当前矩阵主 field_path;未知字段保留原值,后续由矩阵校验报错。 + */ + private String canonicalSubmittedFieldPath( + Map definitionByPath, + String fieldPathOrPointer) { + String normalized = trimToNull(fieldPathOrPointer); + if (normalized == null) { + return ""; + } + if (normalized.startsWith("/")) { + for (String candidate : fieldPathCandidatesFromPointer(normalized)) { + if (definitionByPath.containsKey(candidate)) { + return candidate; + } + } + return normalized; + } + if (definitionByPath.containsKey(normalized)) { + return normalized; + } + String canonicalAlias = canonicalP0RoomItemFieldPath(normalized); + if (canonicalAlias != null && definitionByPath.containsKey(canonicalAlias)) { + return canonicalAlias; + } return normalized; } @@ -1240,7 +1328,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } /** - * 生成第一版任务卡 payload JSON。当前按 field_path 保存,后续 OPERA 转换层再消费。 + * 生成任务卡 payload JSON。field_values 保留矩阵主路径,effective_payload 给后续 OPERA 映射使用。 */ private String toPayloadJson(String generatedFrom, Map fieldValues, LocalDateTime now) { Map payload = new LinkedHashMap<>(); @@ -1248,6 +1336,8 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl payload.put("generated_from", generatedFrom); payload.put("generated_at", now.toString()); payload.put("field_values", new LinkedHashMap<>(fieldValues)); + payload.put("legacy_field_values", legacyFieldValues(fieldValues)); + payload.put("effective_payload", effectivePayload(fieldValues)); try { return objectMapper.writeValueAsString(payload); } catch (Exception exception) { @@ -1255,6 +1345,98 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } } + /** + * 生成旧扁平字段兼容值,供前端过渡读取;主业务逻辑不再依赖这些 key。 + */ + private Map legacyFieldValues(Map fieldValues) { + Map legacyValues = new LinkedHashMap<>(); + fieldValues.forEach((fieldPath, value) -> { + String legacyFieldPath = legacyFieldPathFor(fieldPath); + if (legacyFieldPath != null) { + legacyValues.put(legacyFieldPath, value); + } + }); + return legacyValues; + } + + /** + * 按矩阵 field_path 生成嵌套 effective payload,后续 OPERA 参数组装优先消费该结构。 + */ + private Object effectivePayload(Map fieldValues) { + ObjectNode root = objectMapper.createObjectNode(); + fieldValues.forEach((fieldPath, value) -> setEffectivePayloadValue(root, fieldPath, objectMapper.valueToTree(value))); + return objectMapper.convertValue(root, Object.class); + } + + /** + * 将单个点号 field_path 写入嵌套 JSON,支持 room_items.0 这类显式数组下标。 + */ + private void setEffectivePayloadValue(ObjectNode root, String fieldPath, JsonNode valueNode) { + if (trimToNull(fieldPath) == null) { + return; + } + setEffectivePayloadValue(root, fieldPath.split("\\."), 0, valueNode); + } + + /** + * 递归写入嵌套 effective payload。 + */ + private void setEffectivePayloadValue(JsonNode current, String[] rawParts, int index, JsonNode valueNode) { + if (index >= rawParts.length) { + return; + } + String part = rawParts[index].replace("[]", ""); + boolean last = index == rawParts.length - 1; + if (current instanceof ObjectNode objectNode) { + if (last) { + objectNode.set(part, valueNode); + return; + } + String nextPart = rawParts[index + 1].replace("[]", ""); + JsonNode child = objectNode.path(part); + if (child.isMissingNode() || child.isNull() || !matchesExpectedContainer(child, nextPart)) { + child = isNumericToken(nextPart) ? objectMapper.createArrayNode() : objectMapper.createObjectNode(); + objectNode.set(part, child); + } + setEffectivePayloadValue(child, rawParts, index + 1, valueNode); + return; + } + if (current instanceof ArrayNode arrayNode) { + if (!isNumericToken(part)) { + return; + } + int arrayIndex = Integer.parseInt(part); + ensureArraySize(arrayNode, arrayIndex); + if (last) { + arrayNode.set(arrayIndex, valueNode); + return; + } + String nextPart = rawParts[index + 1].replace("[]", ""); + JsonNode child = arrayNode.get(arrayIndex); + if (child == null || child.isNull() || !matchesExpectedContainer(child, nextPart)) { + child = isNumericToken(nextPart) ? objectMapper.createArrayNode() : objectMapper.createObjectNode(); + arrayNode.set(arrayIndex, child); + } + setEffectivePayloadValue(child, rawParts, index + 1, valueNode); + } + } + + /** + * 判断当前 JSON 容器是否匹配下一段路径需要的对象或数组。 + */ + private boolean matchesExpectedContainer(JsonNode child, String nextPart) { + return isNumericToken(nextPart) ? child.isArray() : child.isObject(); + } + + /** + * 扩展数组到指定下标,缺位填 null。 + */ + private void ensureArraySize(ArrayNode arrayNode, int index) { + while (arrayNode.size() <= index) { + arrayNode.addNull(); + } + } + /** * 将已保存 payload JSON 转为响应对象。空 payload 返回 null,避免前端误认为已有内容。 */ @@ -1732,11 +1914,16 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl if (editedValues.containsKey(fieldPath)) { return editedValues.get(fieldPath); } + for (String aliasPath : p0ValuePathCandidates(fieldPath)) { + if (editedValues.containsKey(aliasPath)) { + return editedValues.get(aliasPath); + } + } Object value = valueAt(aiPayload, fieldPath); if (value != null) { return value; } - for (String legacyPath : p0LegacyValuePathCandidates(fieldPath)) { + for (String legacyPath : p0ValuePathCandidates(fieldPath)) { Object legacyValue = valueAt(aiPayload, legacyPath); if (legacyValue != null) { return legacyValue; @@ -1746,9 +1933,9 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl } /** - * 0711 P0 已将房型字段迁移到 room_items[];当前矩阵仍使用扁平字段,读取时做最小别名兼容。 + * 0711 P0 已将房型字段迁移到 room_items[];读取时保留旧扁平字段双向兼容。 */ - private List p0LegacyValuePathCandidates(String fieldPath) { + private List p0ValuePathCandidates(String fieldPath) { String safeFieldPath = fieldPath == null ? "" : fieldPath; return switch (safeFieldPath) { case "extracted_fields.room_type" -> List.of( @@ -1757,10 +1944,66 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl case "extracted_fields.room_quantity" -> List.of("extracted_fields.room_items.0.room_quantity"); case "extracted_fields.pms_room_type_code" -> List.of( "extracted_fields.room_items.0.pms_room_type_code"); + case "extracted_fields.room_items.0.room_type_raw" -> List.of( + "extracted_fields.room_type", + "extracted_fields.room_items.0.room_type_normalized"); + case "extracted_fields.room_items.0.room_quantity" -> List.of("extracted_fields.room_quantity"); + case "extracted_fields.room_items.0.pms_room_type_code" -> List.of( + "extracted_fields.pms_room_type_code"); default -> List.of(); }; } + /** + * 旧扁平字段转 P0 room_items[0] 主字段路径。未迁移字段返回 null。 + */ + private String canonicalP0RoomItemFieldPath(String fieldPath) { + return switch (fieldPath == null ? "" : fieldPath) { + case "extracted_fields.room_quantity" -> "extracted_fields.room_items.0.room_quantity"; + case "extracted_fields.room_type" -> "extracted_fields.room_items.0.room_type_raw"; + case "extracted_fields.pms_room_type_code" -> "extracted_fields.room_items.0.pms_room_type_code"; + default -> null; + }; + } + + /** + * P0 room_items[0] 主字段转旧扁平字段路径,用于前端兼容显示。 + */ + private String legacyFieldPathFor(String fieldPath) { + return switch (fieldPath == null ? "" : fieldPath) { + case "extracted_fields.room_items.0.room_quantity" -> "extracted_fields.room_quantity"; + case "extracted_fields.room_items.0.room_type_raw" -> "extracted_fields.room_type"; + case "extracted_fields.room_items.0.pms_room_type_code" -> "extracted_fields.pms_room_type_code"; + default -> null; + }; + } + + /** + * 将矩阵 field_path 转为 RFC 6901 JSON Pointer,供前端编辑和复核定位。 + */ + private String fieldPointerFor(String fieldPath) { + String normalized = trimToNull(fieldPath); + if (normalized == null) { + return null; + } + StringBuilder pointer = new StringBuilder(); + for (String rawPart : normalized.split("\\.")) { + String part = rawPart.replace("[]", ""); + if (part.isBlank()) { + continue; + } + pointer.append('/').append(escapeJsonPointerToken(part)); + } + return pointer.isEmpty() ? null : pointer.toString(); + } + + /** + * 转义 JSON Pointer token。 + */ + private String escapeJsonPointerToken(String token) { + return token.replace("~", "~0").replace("/", "~1"); + } + /** * 判断值是否为空。false 和 0 是有效值,不当作空。 */ @@ -1903,6 +2146,8 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl definition.taskSubtype(), definition.displayArea(), definition.fieldPath(), + fieldPointerFor(definition.fieldPath()), + legacyFieldPathFor(definition.fieldPath()), definition.displayName(), definition.visible(), definition.editable(), diff --git a/server/src/main/resources/db/migration/V18__update_reservation_field_contract_version.sql b/server/src/main/resources/db/migration/V18__update_reservation_field_contract_version.sql new file mode 100644 index 0000000..48c877f --- /dev/null +++ b/server/src/main/resources/db/migration/V18__update_reservation_field_contract_version.sql @@ -0,0 +1,3 @@ +UPDATE workflow_reservation_task_card +SET field_contract_version = '20260711-p0' +WHERE field_contract_version = 'code-v1'; diff --git a/server/src/main/resources/reservation-task-card/field-matrix-v20260706.json b/server/src/main/resources/reservation-task-card/field-matrix-v20260706.json index 145d53f..1949d34 100644 --- a/server/src/main/resources/reservation-task-card/field-matrix-v20260706.json +++ b/server/src/main/resources/reservation-task-card/field-matrix-v20260706.json @@ -1,5 +1,5 @@ { - "version": "code-v1", + "version": "20260711-p0", "source": "docs/import/20260706/任务卡展示编辑矩阵.xlsx", "row_count": 158, "fields": [ @@ -242,7 +242,7 @@ "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", "applicable_scenario": "normal task", "display_area": "房量房型", - "field_path": "extracted_fields.room_quantity", + "field_path": "extracted_fields.room_items.0.room_quantity", "display_name": "房量", "field_source": "AI输出/人工确认", "visible": "是", @@ -258,10 +258,10 @@ "default_value_source": "AI提取值", "display_condition": "始终展示", "validation_rule": "正整数;extra bed 不计入房量", - "write_path": "confirmed_payload_json.extracted_fields.room_quantity", + "write_path": "confirmed_payload_json.extracted_fields.room_items[0].room_quantity", "opera_write_participation": "是", "opera_parameter_mapping": "Opera room quantity/block allocation quantity", - "notes": "数字输入。", + "notes": "P0 起主路径使用 room_items[0];旧 extracted_fields.room_quantity 仅作为兼容提交字段。", "row_number": 11 }, { @@ -271,7 +271,7 @@ "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", "applicable_scenario": "normal task", "display_area": "房量房型", - "field_path": "extracted_fields.room_type", + "field_path": "extracted_fields.room_items.0.room_type_raw", "display_name": "房型原文", "field_source": "AI输出/人工确认", "visible": "是", @@ -287,10 +287,10 @@ "default_value_source": "AI提取值", "display_condition": "始终展示", "validation_rule": "文本非空;保留原始房型线索", - "write_path": "confirmed_payload_json.extracted_fields.room_type", + "write_path": "confirmed_payload_json.extracted_fields.room_items[0].room_type_raw", "opera_write_participation": "是", "opera_parameter_mapping": "房型映射输入", - "notes": "可人工修正原文。", + "notes": "P0 起保留 room_type_raw 原文;旧 extracted_fields.room_type 仅作为兼容提交字段。", "row_number": 12 }, { @@ -300,7 +300,7 @@ "task_subtype": "new_fit_reservation / new_group_block / new_allotment_control_block", "applicable_scenario": "normal task", "display_area": "房量房型", - "field_path": "extracted_fields.pms_room_type_code", + "field_path": "extracted_fields.room_items.0.pms_room_type_code", "display_name": "Opera房型代码", "field_source": "AI输出/人工确认", "visible": "是", @@ -316,10 +316,10 @@ "default_value_source": "room_type_mapping_rules唯一命中", "display_condition": "涉及房型时展示", "validation_rule": "必须命中房型映射规则或人工确认", - "write_path": "confirmed_payload_json.extracted_fields.pms_room_type_code", + "write_path": "confirmed_payload_json.extracted_fields.room_items[0].pms_room_type_code", "opera_write_participation": "是", "opera_parameter_mapping": "Opera roomTypeCode", - "notes": "SUITE无床型时可能标记 requires_downstream_hard_validation。", + "notes": "P0 起使用 room_items[0].pms_room_type_code 作为复核和编辑主路径;旧 extracted_fields.pms_room_type_code 仅作为兼容提交字段。", "row_number": 13 }, { diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 1c7bb50..221dcfb 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -140,7 +140,7 @@ class SuperAgentTaskResultControllerTest { SELECT COUNT(*) FROM workflow_reservation_task_card WHERE ai_payload_json LIKE '%New Booking%' - AND field_contract_version = 'code-v1' + AND field_contract_version = '20260711-p0' """, Long.class); org.assertj.core.api.Assertions.assertThat(transitionCount).isEqualTo(1L); @@ -1416,7 +1416,7 @@ class SuperAgentTaskResultControllerTest { .andExpect(jsonPath("$.source_received_at").value(matchesPattern(UTC_INSTANT_PATTERN))) .andExpect(jsonPath("$.external_conversation_id").value("thread-mail-detail-matrix-001")) .andExpect(jsonPath("$.conversation_message_count").value(1)) - .andExpect(jsonPath("$.field_contract_version").value("code-v1")) + .andExpect(jsonPath("$.field_contract_version").value("20260711-p0")) .andExpect(jsonPath("$.fields.length()").value(18)) .andExpect(jsonPath("$.fields[?(@.field_path=='case_keys.confirmation_number')].display_name") .value(contains("Confirmation No."))) @@ -1461,8 +1461,10 @@ class SuperAgentTaskResultControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.task_id").value(taskId)) .andExpect(jsonPath("$.task_status").value("PENDING_CONFIRM")) - .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_quantity']").value(3)) - .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.pms_room_type_code']").value("RM3")); + .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_items.0.room_quantity']").value(3)) + .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']").value("RM3")) + .andExpect(jsonPath("$.draft_payload.legacy_field_values['extracted_fields.room_quantity']").value(3)) + .andExpect(jsonPath("$.draft_payload.legacy_field_values['extracted_fields.pms_room_type_code']").value("RM3")); String draftPayloadJson = jdbcTemplate.queryForObject(""" SELECT draft_payload_json @@ -1474,6 +1476,7 @@ class SuperAgentTaskResultControllerTest { FROM workflow_reservation_task_card WHERE task_id = ? """, String.class, Long.valueOf(taskId)); + assertThat(draftPayloadJson).contains("\"extracted_fields.room_items.0.room_quantity\":3"); assertThat(draftPayloadJson).contains("\"extracted_fields.room_quantity\":3"); assertThat(aiPayloadJson).contains("\"room_quantity\":2"); } @@ -1503,8 +1506,12 @@ class SuperAgentTaskResultControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.task_id").value(taskId)) .andExpect(jsonPath("$.task_status").value("READY")) - .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_quantity']").value(4)) - .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']").value("RM4")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.room_quantity']").value(4)) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']").value("RM4")) + .andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.room_quantity']").value(4)) + .andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.pms_room_type_code']").value("RM4")) + .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].room_quantity").value(4)) + .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code").value("RM4")) .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.arrival_date']").value("2026-08-01")) .andExpect(jsonPath("$.opera_operations.length()").value(2)) .andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1)) @@ -1544,6 +1551,7 @@ class SuperAgentTaskResultControllerTest { """, Long.class, Long.valueOf(taskId)); assertThat(taskStatus).isEqualTo("READY"); assertThat(confirmedAtCount).isEqualTo(1L); + assertThat(confirmedPayloadJson).contains("\"extracted_fields.room_items.0.room_quantity\":4"); assertThat(confirmedPayloadJson).contains("\"extracted_fields.room_quantity\":4"); assertThat(auditCount).isEqualTo(1L); } @@ -1744,7 +1752,8 @@ class SuperAgentTaskResultControllerTest { """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED")) - .andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.pms_room_type_code"))); + .andExpect(jsonPath("$.details[0]") + .value(containsString("extracted_fields.room_items.0.pms_room_type_code"))); } @Test @@ -1854,10 +1863,16 @@ class SuperAgentTaskResultControllerTest { .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.room_items.0.pms_room_type_code")) + .andExpect(jsonPath("$.review_resolution.field_overrides[0].legacy_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']") + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']") + .value("RM3")) + .andExpect(jsonPath("$.confirmed_payload.legacy_field_values['extracted_fields.pms_room_type_code']") + .value("RM3")) + .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code") .value("RM3")) .andExpect(jsonPath("$.opera_operations.length()").value(2)); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java index 26d2c39..8a7d912 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultP0FixtureRegressionTest.java @@ -1,8 +1,11 @@ package cn.nianxx.thhotel.workflows.reservation.control; import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -348,6 +351,73 @@ class SuperAgentTaskResultP0FixtureRegressionTest { assertThat(taskCount).isEqualTo(3L); } + @Test + void shouldSaveDraftAndConfirmRoomItemsFromP0FieldPaths() throws Exception { + ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy(); + useEventGroupCode(event, "CHILD-ROW-FIELD-001"); + ((ObjectNode) event.path("extracted_fields")).put("parent_group_code", "PARENT-ROW-FIELD-001"); + String externalId = "p0-room-items-field-migration-001"; + captureSourceMessage(externalId); + + MvcResult result = mockMvc.perform(signedPost( + businessRoot(externalId, event), + "nonce-p0-room-items-field-migration-create-001")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andReturn(); + String taskId = com.jayway.jsonpath.JsonPath.read( + result.getResponse().getContentAsString(), + "$.items[0].task_id"); + + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .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_type_raw')].field_pointer") + .value(contains("/extracted_fields/room_items/0/room_type_raw"))) + .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')].value") + .value(contains("RM2"))); + + mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.room_items.0.room_quantity": 4, + "/extracted_fields/room_items/0/pms_room_type_code": "RM3" + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_items.0.room_quantity']") + .value(4)) + .andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']") + .value("RM3")); + + mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "field_values": { + "extracted_fields.room_items.0.room_quantity": 5, + "extracted_fields.room_items.0.pms_room_type_code": "RM4" + } + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.task_status").value("READY")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.room_quantity']") + .value(5)) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']") + .value("RM4")) + .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].room_quantity") + .value(5)) + .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code") + .value("RM4")); + } + @Test void shouldFailClosedWhenV3BusinessEventMissesRequiredField() throws Exception { ObjectNode event = fixture("row_multiple_derived.json").path("message_events").get(0).deepCopy(); @@ -417,6 +487,13 @@ class SuperAgentTaskResultP0FixtureRegressionTest { result.getResponse().getContentAsString(), "$.items[0].order_id"); + mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId)) + .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.room_type_raw')].value") + .value(contains("SUITE"))); + mockMvc.perform(post("/api/reservation/tasks/{taskId}/manual-review-resolutions", taskId) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -436,12 +513,14 @@ class SuperAgentTaskResultP0FixtureRegressionTest { .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_pointer") .value("/extracted_fields/room_items/0/pms_room_type_code")) .andExpect(jsonPath("$.review_resolution.field_overrides[0].field_path") - .value("extracted_fields.pms_room_type_code")) - .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_type']") + .value("extracted_fields.room_items.0.pms_room_type_code")) + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.room_type_raw']") .value("SUITE")) - .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_quantity']") + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.room_quantity']") .value(2)) - .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']") + .andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_items.0.pms_room_type_code']") + .value("SU1")) + .andExpect(jsonPath("$.confirmed_payload.effective_payload.extracted_fields.room_items[0].pms_room_type_code") .value("SU1")); Long sourceTaskCount = jdbcTemplate.queryForObject("""