实现 M002 V3 字段矩阵 room_items 迁移

This commit is contained in:
andy
2026-07-12 00:40:12 +08:00
parent 155db63028
commit ab303eea39
13 changed files with 404 additions and 53 deletions

View File

@@ -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`,响应体不使用通用错误包装:

View File

@@ -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 |

View File

@@ -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")

View File

@@ -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
) {
}

View File

@@ -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,

View File

@@ -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")

View File

@@ -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;

View File

@@ -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";

View File

@@ -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<String, Object> submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues());
Map<String, Object> draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson());
List<ReservationTaskCardFieldDefinition> allDefinitions =
fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType());
Map<String, Object> submittedValues = normalizeSubmittedFieldValues(
allDefinitions,
request == null ? null : request.fieldValues());
Map<String, Object> draftValues = normalizeStoredFieldValues(
allDefinitions,
fieldValuesFromPayloadJson(taskCard.draftPayloadJson()));
Map<String, Object> mergedDraftValues = mergeFieldValues(draftValues, submittedValues);
List<ReservationTaskCardFieldDefinition> activeDefinitions =
activeDefinitions(task, aiPayload, mergedDraftValues);
@@ -244,13 +252,19 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
ensureGenericConfirmAllowed(task, taskCard);
JsonNode aiPayload = parseJson(taskCard.aiPayloadJson());
Map<String, Object> submittedValues = normalizeFieldValues(request == null ? null : request.fieldValues());
Map<String, Object> draftValues = fieldValuesFromPayloadJson(taskCard.draftPayloadJson());
List<ReservationTaskCardFieldDefinition> allDefinitions =
fieldDefinitionProvider.listDefinitions(task.taskCardType(), task.resultType());
Map<String, Object> submittedValues = normalizeSubmittedFieldValues(
allDefinitions,
request == null ? null : request.fieldValues());
Map<String, Object> draftValues = normalizeStoredFieldValues(
allDefinitions,
fieldValuesFromPayloadJson(taskCard.draftPayloadJson()));
Map<String, Object> editedValues = mergeFieldValues(draftValues, submittedValues);
List<ReservationTaskCardFieldDefinition> activeDefinitions = activeDefinitions(task, aiPayload, editedValues);
List<String> 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<String> 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<String> 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<String, Object> normalizeFieldValues(Map<String, Object> rawFieldValues) {
private Map<String, Object> normalizeSubmittedFieldValues(
List<ReservationTaskCardFieldDefinition> definitions,
Map<String, Object> rawFieldValues) {
Map<String, Object> normalized = new LinkedHashMap<>();
if (rawFieldValues == null) {
return normalized;
}
rawFieldValues.forEach((fieldPath, value) -> normalized.put(fieldPath == null ? "" : fieldPath.trim(), value));
Map<String, ReservationTaskCardFieldDefinition> definitionByPath = definitionByPath(definitions);
List<String> 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<String, Object> normalizeStoredFieldValues(
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);
normalized.put(canonicalFieldPath, value);
});
return normalized;
}
/**
* 构建字段定义索引,供提交字段和复核 pointer 映射使用。
*/
private Map<String, ReservationTaskCardFieldDefinition> definitionByPath(
List<ReservationTaskCardFieldDefinition> definitions) {
Map<String, ReservationTaskCardFieldDefinition> definitionByPath = new LinkedHashMap<>();
definitions.forEach(definition -> definitionByPath.put(definition.fieldPath(), definition));
return definitionByPath;
}
/**
* 将提交字段 key 转成当前矩阵主 field_path未知字段保留原值后续由矩阵校验报错。
*/
private String canonicalSubmittedFieldPath(
Map<String, ReservationTaskCardFieldDefinition> 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<String, Object> fieldValues, LocalDateTime now) {
Map<String, Object> 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<String, Object> legacyFieldValues(Map<String, Object> fieldValues) {
Map<String, Object> 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<String, Object> 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<String> p0LegacyValuePathCandidates(String fieldPath) {
private List<String> 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(),

View File

@@ -0,0 +1,3 @@
UPDATE workflow_reservation_task_card
SET field_contract_version = '20260711-p0'
WHERE field_contract_version = 'code-v1';

View File

@@ -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
},
{

View File

@@ -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));

View File

@@ -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("""