完成 V4 目录校验和字段白名单

This commit is contained in:
andy
2026-07-19 12:25:06 +07:00
parent b4f1b3c856
commit 39518b3cf2
17 changed files with 1124 additions and 53 deletions

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* Reservation V4 第一版 Account 目录项。当前为后端固定种子,后续可替换为酒店目录表。
*
* @param accountCode Account 稳定代码SuperAgent 与前端提交均使用该值
* @param accountName Account 显示名称
* @param marketCode Account 派生的 Market 代码
* @param sourceCode Account 派生的 Source 代码
*/
public record ReservationV4AccountCatalogItem(
String accountCode,
String accountName,
String marketCode,
String sourceCode
) {
}

View File

@@ -0,0 +1,49 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
/**
* Reservation V4 任务卡字段白名单结果。前端只应基于 fields[] 渲染可编辑字段。
*
* @param fieldPath 点分字段路径,便于前端日志和表单绑定
* @param fieldPointer RFC 6901 JSON Pointer复核和确认写入优先使用
* @param displayName 字段中文展示名
* @param value 当前回显值
* @param editable 当前卡片状态下是否允许编辑
* @param required 是否第一版必填
* @param controlType 前端控件类型
* @param editScope 编辑范围,区分普通确认、人工复核和只读
* @param writeTarget 用户修改值写入目标
* @param optionsSource 选项来源,目录未接口化前使用稳定代码
* @param rawReadonly 是否来源、派生或诊断字段,只读展示
* @param validationErrors 当前字段对应的后端校验错误
* @param controlHint 给前端的控件补充提示
*/
public record ReservationV4TaskCardFieldResult(
@JsonProperty("field_path")
String fieldPath,
@JsonProperty("field_pointer")
String fieldPointer,
@JsonProperty("display_name")
String displayName,
JsonNode value,
Boolean editable,
Boolean required,
@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("validation_errors")
List<String> validationErrors,
@JsonProperty("control_hint")
String controlHint
) {
}

View File

@@ -3,6 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Reservation V4 任务卡查询结果。只返回展示、确认和复核结果 JSON不返回 ai_payload_json。
@@ -18,6 +19,7 @@ import java.time.OffsetDateTime;
* @param confirmedPayload 用户确认后的 JSON
* @param reviewResolution 复核解阻结果 JSON
* @param validationErrors 后端校验错误 JSON
* @param fields 当前卡片字段展示 / 编辑白名单
* @param confirmedBy 确认人
* @param confirmedAt 确认 UTC 时间
* @param version 卡片乐观锁版本
@@ -48,6 +50,7 @@ public record ReservationV4TaskCardResult(
JsonNode reviewResolution,
@JsonProperty("validation_errors")
JsonNode validationErrors,
List<ReservationV4TaskCardFieldResult> fields,
@JsonProperty("confirmed_by")
String confirmedBy,
@JsonProperty("confirmed_at")

View File

@@ -0,0 +1,25 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import java.util.Optional;
/**
* Reservation V4 第一版目录服务。封装 Account、房型和 Rate Code 的目录边界。
*/
public interface ReservationV4DirectoryService {
/**
* 按 Account Code 查询目录项;不存在时返回空,用于触发人工复核或字段校验错误。
*/
Optional<ReservationV4AccountCatalogItem> findAccount(String accountCode);
/**
* 判断房型代码是否在第一版固定目录中;空值由业务字段必填规则处理。
*/
boolean isKnownRoomTypeCode(String roomTypeCode);
/**
* 判断 Rate Code 是否在第一版固定目录中;空值由业务字段必填规则处理。
*/
boolean isKnownRateCode(String rateCode);
}

View File

@@ -0,0 +1,70 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.springframework.stereotype.Service;
/**
* Reservation V4 固定种子目录实现。开发阶段先用代码种子,后续可替换为数据库目录。
*/
@Service
public class FixedReservationV4DirectoryServiceImpl implements ReservationV4DirectoryService {
private static final Map<String, ReservationV4AccountCatalogItem> ACCOUNT_CATALOG = Map.of(
"QBD_TRAVEL", new ReservationV4AccountCatalogItem(
"QBD_TRAVEL",
"Q.B.D. TRAVEL GROUP CO., LTD",
"LEISURE",
"TRAVEL_AGENT"),
"LIAN_TAI", new ReservationV4AccountCatalogItem(
"LIAN_TAI",
"LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"LEISURE",
"TRAVEL_AGENT"),
"HANATOUR_TD", new ReservationV4AccountCatalogItem(
"HANATOUR_TD",
"HANATOUR TD CO., LTD.",
"LEISURE",
"TRAVEL_AGENT")
);
private static final Set<String> ROOM_TYPE_CODES = Set.of("TWN", "KING", "DBL", "SGL", "TRP", "RM1", "RM2", "RM3");
private static final Set<String> RATE_CODES = Set.of("BAR", "RACK", "PACKAGE", "GROUP", "FIT");
/**
* 按 Account Code 查询固定种子目录,比较保持大小写敏感。
*/
@Override
public Optional<ReservationV4AccountCatalogItem> findAccount(String accountCode) {
String normalized = trimToNull(accountCode);
return normalized == null ? Optional.empty() : Optional.ofNullable(ACCOUNT_CATALOG.get(normalized));
}
/**
* 判断房型代码是否存在于第一版固定目录。
*/
@Override
public boolean isKnownRoomTypeCode(String roomTypeCode) {
String normalized = trimToNull(roomTypeCode);
return normalized != null && ROOM_TYPE_CODES.contains(normalized);
}
/**
* 判断 Rate Code 是否存在于第一版固定目录。
*/
@Override
public boolean isKnownRateCode(String rateCode) {
String normalized = trimToNull(rateCode);
return normalized != null && RATE_CODES.contains(normalized);
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -5,6 +5,7 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAuditLogDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
@@ -27,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowR
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -87,6 +89,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
private final ReservationAiWorkflowRepository auditRepository;
private final ReservationV4QueryService queryService;
private final ReservationV4DirectoryService directoryService;
private final HotelContextService hotelContextService;
private final ObjectMapper objectMapper;
@@ -98,12 +101,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
ReservationV4SourceNotificationRepository sourceNotificationRepository,
ReservationAiWorkflowRepository auditRepository,
ReservationV4QueryService queryService,
ReservationV4DirectoryService directoryService,
HotelContextService hotelContextService,
ObjectMapper objectMapper) {
this.workflowRepository = workflowRepository;
this.sourceNotificationRepository = sourceNotificationRepository;
this.auditRepository = auditRepository;
this.queryService = queryService;
this.directoryService = directoryService;
this.hotelContextService = hotelContextService;
this.objectMapper = objectMapper;
}
@@ -171,6 +176,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
card,
confirmedPayload,
request == null ? null : request.fieldOverrides());
validateAndEnrichConfirmedPayload(card, confirmedPayload);
String actorId = actorIdentifier(actor);
String confirmedPayloadJson = toJson(confirmedPayload);
String reviewResolutionJson = reviewResolutionJson(
@@ -454,13 +460,121 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
ObjectNode payload = confirmedPayloadObject(card, confirmedPayload);
validateAndEnrichConfirmedPayload(card, payload);
return toJson(payload);
}
private ObjectNode confirmedPayloadObject(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) {
return toJson(confirmedPayload);
if (!confirmedPayload.isObject()) {
throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_PAYLOAD_NOT_OBJECT", "确认 payload 必须是对象结构。");
}
return ((ObjectNode) confirmedPayload).deepCopy();
}
if (hasText(card.displayPayloadJson())) {
return card.displayPayloadJson();
return mutableDisplayPayload(card);
}
private void validateAndEnrichConfirmedPayload(ReservationV4TaskCardSnapshot card, ObjectNode payload) {
List<String> details = new ArrayList<>();
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
validateAndEnrichBasicInformation(payload, details);
} else {
validateBusinessCardPayload(payload, details);
}
return "{}";
if (!details.isEmpty()) {
throw new ReservationTaskWorkflowException(
HttpStatus.BAD_REQUEST,
"V4_FIELD_VALIDATION_FAILED",
"V4 任务字段校验失败。",
details);
}
}
private void validateAndEnrichBasicInformation(ObjectNode payload, List<String> details) {
ObjectNode basicInformation = ensureBasicInformationObject(payload);
String accountCode = textAt(basicInformation, "account_code");
if (accountCode == null) {
details.add("basic_information.account_code: Account Code 不能为空。");
return;
}
ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null);
if (account == null) {
details.add("basic_information.account_code: Account Code 不在信息系统目录中。");
return;
}
basicInformation.put("account_name", account.accountName());
basicInformation.put("market_code", account.marketCode());
basicInformation.put("source_code", account.sourceCode());
}
private ObjectNode ensureBasicInformationObject(ObjectNode payload) {
JsonNode existing = payload.get("basic_information");
if (existing != null && existing.isObject()) {
return (ObjectNode) existing;
}
ObjectNode basicInformation = objectMapper.createObjectNode();
copyTextField(payload, basicInformation, "account_code");
copyTextField(payload, basicInformation, "market_code");
copyTextField(payload, basicInformation, "source_code");
payload.set("basic_information", basicInformation);
return basicInformation;
}
private void copyTextField(ObjectNode source, ObjectNode target, String fieldName) {
JsonNode value = source.get(fieldName);
if (value != null && value.isTextual()) {
target.put(fieldName, value.asText());
}
}
private void validateBusinessCardPayload(ObjectNode payload, List<String> details) {
JsonNode businessFields = payload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : payload;
validateRateCode(fieldRoot.path("rate_code"), details);
validateRoomItems(fieldRoot.path("room_items"), details);
}
private void validateRateCode(JsonNode rateCode, List<String> details) {
String code = textValue(rateCode);
if (code != null && !directoryService.isKnownRateCode(code)) {
details.add("business_fields.rate_code: Rate Code 不在第一版目录中。");
}
}
private void validateRoomItems(JsonNode roomItems, List<String> details) {
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
return;
}
if (!roomItems.isArray()) {
details.add("business_fields.room_items: 房型明细必须是数组。");
return;
}
for (int index = 0; index < roomItems.size(); index++) {
JsonNode item = roomItems.get(index);
if (item == null || !item.isObject()) {
details.add("business_fields.room_items." + index + ": 房型明细必须是对象。");
continue;
}
String roomTypeCode = textAt(item, "room_type_code");
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。");
}
}
}
private String textAt(JsonNode node, String fieldName) {
if (node == null || node.isMissingNode()) {
return null;
}
return textValue(node.get(fieldName));
}
private String textValue(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull() || !node.isTextual()) {
return null;
}
return trimToNull(node.asText());
}
private ObjectNode mutableDisplayPayload(ReservationV4TaskCardSnapshot card) {
@@ -567,7 +681,7 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
if (current.isContainerNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。");
}
if (!isReviewPointerAllowedForResolution(confirmedPayload, pointer, current)) {
if (!isReviewPointerAllowedForResolution(card, confirmedPayload, pointer, current)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
}
@@ -664,9 +778,14 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
* 判断复核字段是否属于本次允许修正的字段范围,优先使用显式缺失字段清单。
*/
private boolean isReviewPointerAllowedForResolution(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
String pointer,
JsonNode current) {
Set<String> validationPointers = collectValidationErrorPointers(card.validationErrorsJson());
if (validationPointers.contains(pointer)) {
return true;
}
Set<String> explicitPointers = collectExplicitReviewFieldPointers(confirmedPayload);
if (!explicitPointers.isEmpty()) {
return explicitPointers.contains(pointer);
@@ -674,6 +793,31 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return isUnresolvedReviewLeaf(current);
}
/**
* 从后端字段校验错误中提取可修正字段指针,支持目录错误这类非空值复核。
*/
private Set<String> collectValidationErrorPointers(String validationErrorsJson) {
if (!hasText(validationErrorsJson)) {
return Set.of();
}
try {
JsonNode root = objectMapper.readTree(validationErrorsJson);
if (root == null || !root.isArray()) {
return Set.of();
}
Set<String> pointers = new HashSet<>();
for (JsonNode item : root) {
String pointer = textAt(item, "field_pointer");
if (pointer != null && pointer.startsWith("/")) {
pointers.add(pointer);
}
}
return pointers;
} catch (Exception exception) {
return Set.of();
}
}
/**
* 从当前卡展示 payload 中收集兼容用的 missing_fields 指针清单。
*/

View File

@@ -7,6 +7,7 @@ import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryRespons
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiTransitionSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
@@ -32,12 +33,14 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderT
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceMessageSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardFieldResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4TaskCardResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4WorkbenchListResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -46,11 +49,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -68,11 +74,28 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
private static final int MAX_MIXED_WORKBENCH_FETCH_SIZE = 10_000;
private static final String DISPLAY_STATUS_BLOCKED = "BLOCKED";
private static final String DISPLAY_STATUS_OPEN = "OPEN";
private static final Set<String> V4_BUSINESS_FIELD_ROOTS = Set.of(
"arrival_date",
"departure_date",
"rate_code",
"booking_scenario",
"guest_name",
"room_items",
"trace_items",
"attachment_ids",
"evidence_url");
private static final Set<String> V4_BUSINESS_READONLY_FIELDS = Set.of(
"event_type",
"manual_review",
"order_ref",
"route_code",
"target_order");
private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationAiWorkflowRepository aiWorkflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
private final SourceMessageQueryService sourceMessageQueryService;
private final ReservationV4DirectoryService directoryService;
private final HotelContextService hotelContextService;
private final ObjectMapper objectMapper;
@@ -84,12 +107,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
ReservationAiWorkflowRepository aiWorkflowRepository,
ReservationV4SourceNotificationRepository sourceNotificationRepository,
SourceMessageQueryService sourceMessageQueryService,
ReservationV4DirectoryService directoryService,
HotelContextService hotelContextService,
ObjectMapper objectMapper) {
this.workflowRepository = workflowRepository;
this.aiWorkflowRepository = aiWorkflowRepository;
this.sourceNotificationRepository = sourceNotificationRepository;
this.sourceMessageQueryService = sourceMessageQueryService;
this.directoryService = directoryService;
this.hotelContextService = hotelContextService;
this.objectMapper = objectMapper;
}
@@ -319,6 +344,11 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
ReservationV4TaskCardSnapshot card,
ReservationV4ActionAvailabilityResult orderAvailability,
ReservationV4TaskCardSnapshot basicCard) {
JsonNode displayPayload = parseJson(card.displayPayloadJson());
JsonNode confirmedPayload = parseJson(card.confirmedPayloadJson());
JsonNode reviewResolution = parseJson(card.reviewResolutionJson());
JsonNode validationErrors = parseJson(card.validationErrorsJson());
ReservationV4ActionAvailabilityResult availability = cardAvailability(card, orderAvailability, basicCard);
return new ReservationV4TaskCardResult(
card.id().toString(),
card.cardType(),
@@ -327,16 +357,17 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
card.cardSortOrder(),
card.cardStatus(),
card.reviewStatus(),
parseJson(card.displayPayloadJson()),
parseJson(card.confirmedPayloadJson()),
parseJson(card.reviewResolutionJson()),
parseJson(card.validationErrorsJson()),
displayPayload,
confirmedPayload,
reviewResolution,
validationErrors,
cardFields(card, displayPayload, validationErrors, availability),
card.confirmedBy(),
UtcTimeFormatter.toUtcOffsetDateTime(card.confirmedAt()),
card.version(),
UtcTimeFormatter.toUtcOffsetDateTime(card.createdAt()),
UtcTimeFormatter.toUtcOffsetDateTime(card.updatedAt()),
cardAvailability(card, orderAvailability, basicCard));
availability);
}
private ReservationV4TaskCardResult toSourceNotificationCard(
@@ -359,6 +390,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
NullNode.getInstance(),
NullNode.getInstance(),
NullNode.getInstance(),
List.of(),
notification.ackBy(),
UtcTimeFormatter.toUtcOffsetDateTime(notification.ackAt()),
notification.version(),
@@ -761,6 +793,328 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
fragment.put(fieldName, objectMapper.convertValue(value, Object.class));
}
private List<ReservationV4TaskCardFieldResult> cardFields(
ReservationV4TaskCardSnapshot card,
JsonNode displayPayload,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
if (displayPayload == null || displayPayload.isNull() || displayPayload.isMissingNode()) {
return List.of();
}
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
return basicInformationFields(displayPayload, validationErrors, availability);
}
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())
|| ReservationV4CardType.SOURCE_MESSAGE_NOTIFICATION.name().equals(card.cardType())) {
return List.of();
}
return businessFields(card, displayPayload, validationErrors, availability);
}
private List<ReservationV4TaskCardFieldResult> basicInformationFields(
JsonNode displayPayload,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
JsonNode basicInformation = basicInformationNode(displayPayload);
String accountCode = textAt(basicInformation, "account_code");
Optional<ReservationV4AccountCatalogItem> account = directoryService.findAccount(accountCode);
String marketCode = firstText(textAt(basicInformation, "market_code"), account.map(ReservationV4AccountCatalogItem::marketCode).orElse(null));
String sourceCode = firstText(textAt(basicInformation, "source_code"), account.map(ReservationV4AccountCatalogItem::sourceCode).orElse(null));
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
fields.add(field(
"basic_information.account_code",
"/basic_information/account_code",
"Account Code",
basicInformation.path("account_code"),
availability.editable(),
true,
"select",
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
"reservation_v4_account_catalog",
false,
validationMessages(validationErrors, "/basic_information/account_code", "basic_information.account_code"),
"第一版 Account 使用后端固定目录,不能自由输入。"));
fields.add(field(
"basic_information.market_code",
"/basic_information/market_code",
"Market Code",
valueNode(marketCode),
false,
false,
"readonly",
"readonly",
null,
"reservation_v4_account_catalog",
true,
validationMessages(validationErrors, "/basic_information/market_code", "basic_information.market_code"),
"Market 由 Account Code 派生,前端只读展示。"));
fields.add(field(
"basic_information.source_code",
"/basic_information/source_code",
"Source Code",
valueNode(sourceCode),
false,
false,
"readonly",
"readonly",
null,
"reservation_v4_account_catalog",
true,
validationMessages(validationErrors, "/basic_information/source_code", "basic_information.source_code"),
"Source 由 Account Code 派生,前端只读展示。"));
return fields;
}
private JsonNode basicInformationNode(JsonNode displayPayload) {
JsonNode basicInformation = displayPayload.path("basic_information");
if (basicInformation.isObject()) {
return basicInformation;
}
return displayPayload;
}
private List<ReservationV4TaskCardFieldResult> businessFields(
ReservationV4TaskCardSnapshot card,
JsonNode displayPayload,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
JsonNode fieldRoot = displayPayload.path("business_fields");
String pointerPrefix = "/business_fields";
String pathPrefix = "business_fields";
if (!fieldRoot.isObject()) {
fieldRoot = displayPayload;
pointerPrefix = "";
pathPrefix = "";
}
List<ReservationV4TaskCardFieldResult> fields = new ArrayList<>();
Iterator<Map.Entry<String, JsonNode>> iterator = fieldRoot.fields();
while (iterator.hasNext()) {
Map.Entry<String, JsonNode> entry = iterator.next();
if (!shouldExposeBusinessField(entry.getKey(), pointerPrefix)) {
continue;
}
collectBusinessLeafFields(
fields,
card,
entry.getKey(),
entry.getValue(),
pointerPrefix + "/" + escapeJsonPointer(entry.getKey()),
pathPrefix.isEmpty() ? entry.getKey() : pathPrefix + "." + entry.getKey(),
validationErrors,
availability);
}
return fields;
}
private boolean shouldExposeBusinessField(String fieldName, String pointerPrefix) {
if (V4_BUSINESS_READONLY_FIELDS.contains(fieldName)) {
return false;
}
if ("/business_fields".equals(pointerPrefix)) {
return true;
}
return V4_BUSINESS_FIELD_ROOTS.contains(fieldName);
}
private void collectBusinessLeafFields(
List<ReservationV4TaskCardFieldResult> fields,
ReservationV4TaskCardSnapshot card,
String fieldName,
JsonNode value,
String pointer,
String fieldPath,
JsonNode validationErrors,
ReservationV4ActionAvailabilityResult availability) {
if (value == null || value.isMissingNode()) {
return;
}
if (value.isObject()) {
Iterator<Map.Entry<String, JsonNode>> iterator = value.fields();
while (iterator.hasNext()) {
Map.Entry<String, JsonNode> child = iterator.next();
collectBusinessLeafFields(
fields,
card,
child.getKey(),
child.getValue(),
pointer + "/" + escapeJsonPointer(child.getKey()),
fieldPath + "." + child.getKey(),
validationErrors,
availability);
}
return;
}
if (value.isArray()) {
for (int index = 0; index < value.size(); index++) {
collectBusinessLeafFields(
fields,
card,
fieldName,
value.get(index),
pointer + "/" + index,
fieldPath + "." + index,
validationErrors,
availability);
}
return;
}
List<String> fieldValidationErrors = validationMessages(validationErrors, pointer, fieldPath);
boolean editable = businessFieldEditable(card, value, availability, fieldValidationErrors);
fields.add(field(
fieldPath,
pointer,
displayName(fieldName),
value,
editable,
false,
controlType(fieldPath),
availability.reviewable() ? "manual_review_only" : "confirm",
availability.reviewable() ? "review_resolution.field_overrides" : "confirmed_payload_json",
optionsSource(fieldPath),
false,
fieldValidationErrors,
null));
}
private boolean businessFieldEditable(
ReservationV4TaskCardSnapshot card,
JsonNode value,
ReservationV4ActionAvailabilityResult availability,
List<String> validationErrors) {
if (!availability.editable()) {
return false;
}
if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
return true;
}
return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())
&& (!validationErrors.isEmpty() || isUnresolvedLeaf(value));
}
private ReservationV4TaskCardFieldResult field(
String fieldPath,
String fieldPointer,
String displayName,
JsonNode value,
Boolean editable,
Boolean required,
String controlType,
String editScope,
String writeTarget,
String optionsSource,
Boolean rawReadonly,
List<String> validationErrors,
String controlHint) {
return new ReservationV4TaskCardFieldResult(
fieldPath,
fieldPointer,
displayName,
value == null || value.isMissingNode() ? NullNode.getInstance() : value,
editable,
required,
controlType,
editScope,
writeTarget,
optionsSource,
rawReadonly,
validationErrors == null ? List.of() : validationErrors,
controlHint);
}
private List<String> validationMessages(JsonNode validationErrors, String pointer, String fieldPath) {
if (validationErrors == null || !validationErrors.isArray()) {
return List.of();
}
List<String> messages = new ArrayList<>();
for (JsonNode item : validationErrors) {
String itemPointer = textAt(item, "field_pointer");
String itemPath = textAt(item, "field_path");
String detail = textAt(item, "detail");
if (Objects.equals(pointer, itemPointer) || Objects.equals(fieldPath, itemPath)
|| (detail != null && detail.startsWith(fieldPath + ":"))) {
messages.add(firstText(textAt(item, "message"), detail));
}
}
return messages;
}
private String controlType(String fieldPath) {
if (fieldPath.endsWith("_date") || fieldPath.contains(".date")) {
return "date";
}
if (fieldPath.endsWith("_count") || fieldPath.endsWith("_quantity") || fieldPath.endsWith("_number")) {
return "number";
}
if (fieldPath.endsWith("room_type_code") || fieldPath.endsWith("rate_code")) {
return "select";
}
return "text";
}
private String optionsSource(String fieldPath) {
if (fieldPath.endsWith("room_type_code")) {
return "reservation_v4_room_type_catalog";
}
if (fieldPath.endsWith("rate_code")) {
return "reservation_v4_rate_code_catalog";
}
return null;
}
private String displayName(String fieldName) {
if ("room_type_code".equals(fieldName)) {
return "房型代码";
}
if ("room_count".equals(fieldName)) {
return "房间数";
}
if ("rate_code".equals(fieldName)) {
return "Rate Code";
}
if ("arrival_date".equals(fieldName)) {
return "入住日期";
}
if ("departure_date".equals(fieldName)) {
return "离店日期";
}
return fieldName;
}
private JsonNode valueNode(String value) {
return value == null ? NullNode.getInstance() : objectMapper.valueToTree(value);
}
private String firstText(String first, String second) {
return hasText(first) ? first : second;
}
private String textAt(JsonNode node, String fieldName) {
if (node == null || node.isMissingNode()) {
return null;
}
JsonNode value = node.get(fieldName);
if (value == null || value.isNull() || value.isMissingNode()) {
return null;
}
if (!value.isTextual()) {
return null;
}
String text = value.asText().trim();
return text.isEmpty() ? null : text;
}
private boolean isUnresolvedLeaf(JsonNode value) {
if (value == null || value.isNull()) {
return true;
}
return value.isTextual() && !hasText(value.asText());
}
private String escapeJsonPointer(String segment) {
return segment.replace("~", "~0").replace("/", "~1");
}
private List<Long> findSourceMessageIdsByKeyword(String hotelId, String keyword) {
if (!hasText(keyword)) {
return List.of();

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AcceptedEventDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4AccountCatalogItem;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
@@ -14,6 +15,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTa
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4DirectoryService;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4TaskIntakeService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -43,6 +45,7 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
private final ObjectMapper objectMapper;
private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
private final ReservationV4DirectoryService directoryService;
/**
* 注入 V4 持久化边界和 JSON 工具Service 只编排入站写入,不直接访问 Mapper。
@@ -50,10 +53,12 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
public ReservationV4TaskIntakeServiceImpl(
ObjectMapper objectMapper,
ReservationV4WorkflowRepository workflowRepository,
ReservationV4SourceNotificationRepository sourceNotificationRepository) {
ReservationV4SourceNotificationRepository sourceNotificationRepository,
ReservationV4DirectoryService directoryService) {
this.objectMapper = objectMapper;
this.workflowRepository = workflowRepository;
this.sourceNotificationRepository = sourceNotificationRepository;
this.directoryService = directoryService;
}
/**
@@ -173,10 +178,15 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
LocalDateTime now) {
JsonNode basicInformation = orderContext.path("basic_information");
boolean manualReviewRequired = isBooleanTrue(basicInformation.path("manual_review"));
ObjectNode safeBasicInformation = basicInformation.isObject()
? ((ObjectNode) basicInformation).deepCopy()
: objectMapper.createObjectNode();
List<String> validationDetails = validateAndEnrichBasicInformation(safeBasicInformation);
boolean reviewRequired = manualReviewRequired || !validationDetails.isEmpty();
ObjectNode displayPayload = objectMapper.createObjectNode();
displayPayload.put("card_type", ReservationV4CardType.BASIC_INFORMATION.name());
displayPayload.put("order_ref", textAt(orderContext, "order_ref"));
displayPayload.set("basic_information", basicInformation);
displayPayload.set("basic_information", safeBasicInformation);
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
sourceMessage.hotelId(),
orderTaskId,
@@ -186,11 +196,11 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
null,
0,
BASIC_INFORMATION_CARD_SORT_ORDER,
cardStatus(manualReviewRequired),
reviewStatus(manualReviewRequired),
cardStatus(reviewRequired),
reviewStatus(reviewRequired),
nodeJson(orderContext),
nodeJson(displayPayload),
null,
validationErrorsJson(validationDetails),
now
));
}
@@ -203,6 +213,9 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
Long orderTaskId,
ReservationV4AcceptedEventDraft acceptedEvent,
LocalDateTime now) {
ObjectNode displayPayload = objectJson(acceptedEvent.displayPayloadJson());
List<String> validationDetails = validateBusinessCardDisplayPayload(displayPayload);
boolean reviewRequired = acceptedEvent.manualReviewRequired() || !validationDetails.isEmpty();
workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
sourceMessage.hotelId(),
orderTaskId,
@@ -212,11 +225,11 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
acceptedEvent.eventType(),
acceptedEvent.sourceEventIndex(),
cardSortOrder(acceptedEvent.eventType()),
cardStatus(acceptedEvent.manualReviewRequired()),
reviewStatus(acceptedEvent.manualReviewRequired()),
cardStatus(reviewRequired),
reviewStatus(reviewRequired),
acceptedEvent.aiPayloadJson(),
acceptedEvent.displayPayloadJson(),
null,
nodeJson(displayPayload),
validationErrorsJson(validationDetails),
now
));
}
@@ -359,6 +372,124 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
return sourceMessage.receivedAt() == null ? now : sourceMessage.receivedAt();
}
/**
* 校验并派生 Basic Information 的 Account 目录字段,错误只写展示卡,不改 AI 原始 payload。
*/
private List<String> validateAndEnrichBasicInformation(ObjectNode basicInformation) {
List<String> details = new ArrayList<>();
String accountCode = trimToNull(textAt(basicInformation, "account_code"));
if (accountCode == null) {
details.add("basic_information.account_code: Account Code 不能为空。");
return details;
}
ReservationV4AccountCatalogItem account = directoryService.findAccount(accountCode).orElse(null);
if (account == null) {
details.add("basic_information.account_code: Account Code 不在信息系统目录中。");
return details;
}
basicInformation.put("account_name", account.accountName());
basicInformation.put("market_code", account.marketCode());
basicInformation.put("source_code", account.sourceCode());
return details;
}
/**
* 校验 V4 业务卡展示字段中的第一版目录值,失败时卡片进入人工复核。
*/
private List<String> validateBusinessCardDisplayPayload(ObjectNode displayPayload) {
List<String> details = new ArrayList<>();
JsonNode businessFields = displayPayload.path("business_fields");
JsonNode fieldRoot = businessFields.isObject() ? businessFields : displayPayload;
validateRateCode(fieldRoot.path("rate_code"), details);
validateRoomItems(fieldRoot.path("room_items"), details);
return details;
}
private void validateRateCode(JsonNode rateCode, List<String> details) {
String code = trimToNull(rateCode == null || !rateCode.isTextual() ? null : rateCode.asText());
if (code != null && !directoryService.isKnownRateCode(code)) {
details.add("business_fields.rate_code: Rate Code 不在第一版目录中。");
}
}
private void validateRoomItems(JsonNode roomItems, List<String> details) {
if (roomItems == null || roomItems.isMissingNode() || roomItems.isNull()) {
return;
}
if (!roomItems.isArray()) {
details.add("business_fields.room_items: 房型明细必须是数组。");
return;
}
for (int index = 0; index < roomItems.size(); index++) {
JsonNode item = roomItems.get(index);
if (item == null || !item.isObject()) {
details.add("business_fields.room_items." + index + ": 房型明细必须是对象。");
continue;
}
String roomTypeCode = trimToNull(textAt(item, "room_type_code"));
if (roomTypeCode != null && !directoryService.isKnownRoomTypeCode(roomTypeCode)) {
details.add("business_fields.room_items." + index + ".room_type_code: 房型代码不在第一版目录中。");
}
}
}
/**
* 将字段校验错误转换为前端和复核接口都能识别的 JSON 数组。
*/
private String validationErrorsJson(List<String> details) {
if (details == null || details.isEmpty()) {
return null;
}
ArrayNode errors = objectMapper.createArrayNode();
for (String detail : details) {
String fieldPath = fieldPathFromDetail(detail);
ObjectNode error = errors.addObject();
error.put("field_path", fieldPath);
error.put("field_pointer", jsonPointerFromFieldPath(fieldPath));
error.put("message", messageFromDetail(detail));
error.put("detail", detail);
}
return nodeJson(errors);
}
private String fieldPathFromDetail(String detail) {
int index = detail == null ? -1 : detail.indexOf(':');
if (index <= 0) {
return "unknown";
}
return detail.substring(0, index).trim();
}
private String messageFromDetail(String detail) {
int index = detail == null ? -1 : detail.indexOf(':');
if (index < 0 || index + 1 >= detail.length()) {
return detail;
}
return detail.substring(index + 1).trim();
}
private String jsonPointerFromFieldPath(String fieldPath) {
if (!hasText(fieldPath)) {
return "/";
}
return "/" + fieldPath.replace(".", "/");
}
/**
* 解析 V4 展示 payload该 JSON 来自本系统适配器,异常时必须显式失败,避免落入空白业务卡。
*/
private ObjectNode objectJson(String json) {
if (!hasText(json)) {
return objectMapper.createObjectNode();
}
try {
JsonNode node = objectMapper.readTree(json);
return node != null && node.isObject() ? ((ObjectNode) node).deepCopy() : objectMapper.createObjectNode();
} catch (JsonProcessingException exception) {
throw new IllegalStateException("V4 业务卡展示 payload 解析失败。", exception);
}
}
/**
* 将 V4 卡片或通知 payload 安全序列化为 JSON 字符串。
*/
@@ -396,6 +527,13 @@ public class ReservationV4TaskIntakeServiceImpl implements ReservationV4TaskInta
return value != null && !value.isBlank();
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
/**
* switch 前把空字符串统一为安全默认值。
*/

View File

@@ -149,7 +149,10 @@ class ReservationV4CommandControllerTest {
{
"version": 0,
"confirmed_payload": {
"company": "Q.B.D. TRAVEL GROUP CO., LTD",
"card_type": "BASIC_INFORMATION",
"basic_information": {
"account_code": "QBD_TRAVEL"
},
"group_code": "GRP-V4-COMMAND-001"
}
}
@@ -159,6 +162,10 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code")
.value("GRP-V4-COMMAND-001"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.market_code")
.value("LEISURE"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code")
.value("TRAVEL_AGENT"))
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true))
.andExpect(jsonPath("$.business_cards[0].availability.read_only").value(false))
.andExpect(content().string(not(org.hamcrest.Matchers.containsString("private.example.test"))));
@@ -185,6 +192,33 @@ class ReservationV4CommandControllerTest {
assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2);
}
@Test
void shouldRejectBasicInformationConfirmWhenAccountCodeUnknown() throws Exception {
SeededOrderTask seeded = seedOrderTask(
HOTEL_ID,
"mail-v4-command-confirm-unknown-account-001",
Instant.parse("2026-07-19T01:12:00Z"));
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_payload": {
"card_type": "BASIC_INFORMATION",
"basic_information": {
"account_code": "UNKNOWN_ACCOUNT"
}
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("basic_information.account_code: Account Code 不在信息系统目录中。"));
}
@Test
void shouldRejectRepeatedCardConfirm() throws Exception {
SeededOrderTask seeded = seedOrderTask(
@@ -244,12 +278,44 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.basic_information_card.review_status").value("RESOLVED"))
.andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.account_code").value("QBD_TRAVEL"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.market_code").value("LEISURE"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.source_code").value("TRAVEL_AGENT"))
.andExpect(jsonPath("$.basic_information_card.review_resolution.field_overrides[0].field_pointer")
.value("/basic_information/account_code"))
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true));
assertAuditCount("V4_CARD_REVIEW_RESOLVE", "v4-command-admin", seeded.orderTask().id().toString(), 1);
}
@Test
void shouldRejectBasicInformationReviewWhenAccountCodeUnknown() throws Exception {
Long confirmedOrderId = 990000000000070012L;
seedReservationOrder(HOTEL_ID, confirmedOrderId);
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-unknown-account-001",
Instant.parse("2026-07-19T01:21:10Z"),
null,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_order_id": "990000000000070012",
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "UNKNOWN_ACCOUNT"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_FIELD_VALIDATION_FAILED"));
}
@Test
void shouldResolveOrderOwnershipStatusWhenConfirmedOrderIdAlreadyBound() throws Exception {
Long confirmedOrderId = 990000000000070009L;
@@ -319,6 +385,54 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射"));
}
@Test
void shouldResolveBusinessCardDirectoryValidationError() throws Exception {
SeededOrderTask seeded = seedReviewOrderTaskWithBusinessCard(
HOTEL_ID,
"mail-v4-command-review-directory-001",
Instant.parse("2026-07-19T01:22:05Z"),
990000000000070004L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
"""
{"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"UNKNOWN_TYPE","room_count":2}]}}
""",
"""
[
{
"field_path": "business_fields.room_items.0.room_type_code",
"field_pointer": "/business_fields/room_items/0/room_type_code",
"message": "房型代码不在第一版目录中。",
"detail": "business_fields.room_items.0.room_type_code: 房型代码不在第一版目录中。"
}
]
""");
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"reason": "修正房型目录",
"field_overrides": [
{
"field_pointer": "/business_fields/room_items/0/room_type_code",
"value": "TWN"
}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.room_items[0].room_type_code")
.value("TWN"))
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("修正房型目录"));
}
@Test
void shouldRejectReviewResolutionWhenResolvedOrderTaskRebindsToDifferentOrder() throws Exception {
Long currentOrderId = 990000000000070111L;
@@ -805,7 +919,13 @@ class ReservationV4CommandControllerTest {
.content("""
{
"version": 0,
"confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"}
"confirmed_payload": {
"card_type": "BASIC_INFORMATION",
"basic_information": {
"account_code": "QBD_TRAVEL"
},
"group_code": "GRP-V4-COMMAND-001"
}
}
"""))
.andExpect(status().isOk());
@@ -865,7 +985,7 @@ class ReservationV4CommandControllerTest {
ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId,
ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"BASIC_INFORMATION","company":"Q.B.D. TRAVEL GROUP CO., LTD","group_code":"GRP-V4-COMMAND-001"}
{"card_type":"BASIC_INFORMATION","order_ref":"ORDER-COMMAND","basic_information":{"account_code":"QBD_TRAVEL","manual_review":null}}
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
@@ -902,6 +1022,53 @@ class ReservationV4CommandControllerTest {
String targetResolutionStatus,
String basicStatus,
String businessStatus) {
return seedReviewOrderTaskWithBusinessCard(
hotelId,
externalMessageId,
receivedAt,
orderId,
targetResolutionStatus,
basicStatus,
businessStatus,
"""
{"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":null}]}}
""",
null);
}
private SeededOrderTask seedReviewOrderTaskWithBusinessCard(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String basicStatus,
String businessStatus,
String businessDisplayPayloadJson,
String businessValidationErrorsJson) {
return seedReviewOrderTaskWithBusinessCard(
hotelId,
externalMessageId,
receivedAt,
orderId,
orderId == null
? ReservationV4TargetResolutionStatus.UNRESOLVED.name()
: ReservationV4TargetResolutionStatus.RESOLVED.name(),
basicStatus,
businessStatus,
businessDisplayPayloadJson,
businessValidationErrorsJson);
}
private SeededOrderTask seedReviewOrderTaskWithBusinessCard(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String targetResolutionStatus,
String basicStatus,
String businessStatus,
String businessDisplayPayloadJson,
String businessValidationErrorsJson) {
SourceMessageCaptureResult source = captureSourceMessage(
hotelId,
externalMessageId,
@@ -934,9 +1101,7 @@ class ReservationV4CommandControllerTest {
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
businessStatus, reviewStatusFor(businessStatus), """
{"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":null}]}}
""");
businessStatus, reviewStatusFor(businessStatus), businessDisplayPayloadJson, businessValidationErrorsJson);
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
}
@@ -980,6 +1145,21 @@ class ReservationV4CommandControllerTest {
String cardStatus,
String reviewStatus,
String displayPayloadJson) {
return insertCard(orderTask, hotelId, cardType, eventType, sourceEventIndex, sortOrder, cardStatus, reviewStatus,
displayPayloadJson, null);
}
private ReservationV4TaskCardSnapshot insertCard(
ReservationV4OrderTaskSnapshot orderTask,
String hotelId,
String cardType,
String eventType,
Integer sourceEventIndex,
Integer sortOrder,
String cardStatus,
String reviewStatus,
String displayPayloadJson,
String validationErrorsJson) {
return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
hotelId,
orderTask.id(),
@@ -995,7 +1175,7 @@ class ReservationV4CommandControllerTest {
{"private_url":"https://private.example.test/raw","raw":"must stay internal"}
""",
displayPayloadJson,
null,
validationErrorsJson,
orderTask.createdAt()));
}

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.matchesPattern;
import static org.hamcrest.Matchers.not;
@@ -240,8 +241,20 @@ class ReservationV4QueryControllerTest {
.andExpect(jsonPath("$.order_task.target_locator_value").value("GRP-V4-QUERY-001"))
.andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_DISPLAY"))
.andExpect(jsonPath("$.basic_information_card.card_type").value("BASIC_INFORMATION"))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/account_code')].control_type")
.value(contains("select")))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/account_code')].options_source")
.value(contains("reservation_v4_account_catalog")))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/market_code')].raw_readonly")
.value(contains(true)))
.andExpect(jsonPath("$.basic_information_card.fields[?(@.field_pointer=='/basic_information/source_code')].raw_readonly")
.value(contains(true)))
.andExpect(jsonPath("$.business_cards[0].card_type").value("ROOM_INFORMATION"))
.andExpect(jsonPath("$.business_cards[0].display_payload.event_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_type_code')].control_type")
.value(contains("select")))
.andExpect(jsonPath("$.business_cards[0].fields[?(@.field_pointer=='/room_items/0/room_count')].control_type")
.value(contains("number")))
.andExpect(jsonPath("$.business_cards[0].ai_payload_json").doesNotExist())
.andExpect(jsonPath("$.availability.read_only").value(false))
.andExpect(jsonPath("$.availability.confirmable").value(true))
@@ -488,7 +501,7 @@ class ReservationV4QueryControllerTest {
""");
insertCard(orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{"card_type":"BASIC_INFORMATION","account_code":"QBD_TRAVEL"}
{"card_type":"BASIC_INFORMATION","order_ref":"order-1","basic_information":{"account_code":"QBD_TRAVEL","market_code":"LEISURE","source_code":"TRAVEL_AGENT"}}
""");
insertCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """

View File

@@ -1206,6 +1206,56 @@ class SuperAgentTaskResultControllerTest {
assertThat(basicReviewCount).isEqualTo(1L);
}
@Test
void shouldMarkV4BasicInformationReviewRequiredWhenAccountCodeUnknown() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unknown-account-001");
String body = v4BusinessRootBody("mail-v4-unknown-account-001")
.replace("\"account_code\": \"QBD_TRAVEL\"", "\"account_code\": \"UNKNOWN_ACCOUNT\"")
.replace("GRP-V4-001", "GRP-V4-UNKNOWN-ACCOUNT-001");
mockMvc.perform(signedPost(body, "nonce-v4-unknown-account-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2));
String basicValidationErrors = jdbcTemplate.queryForObject("""
SELECT validation_errors_json
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'BASIC_INFORMATION'
AND card_status = 'REVIEW_REQUIRED'
AND review_status = 'PENDING'
LIMIT 1
""", String.class, source.inboxId());
assertThat(basicValidationErrors)
.contains("basic_information.account_code")
.contains("Account Code 不在信息系统目录中");
}
@Test
void shouldMarkV4BusinessCardReviewRequiredWhenRoomTypeCodeUnknown() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-unknown-room-type-001");
String body = v4BusinessRootBody("mail-v4-unknown-room-type-001")
.replace("\"room_type_code\": \"TWN\"", "\"room_type_code\": \"UNKNOWN_ROOM\"")
.replace("GRP-V4-001", "GRP-V4-UNKNOWN-ROOM-001");
mockMvc.perform(signedPost(body, "nonce-v4-unknown-room-type-001"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accepted_count").value(2));
String roomValidationErrors = jdbcTemplate.queryForObject("""
SELECT validation_errors_json
FROM workflow_reservation_v4_task_card
WHERE source_message_id = ?
AND card_type = 'ROOM_INFORMATION'
AND card_status = 'REVIEW_REQUIRED'
AND review_status = 'PENDING'
LIMIT 1
""", String.class, source.inboxId());
assertThat(roomValidationErrors)
.contains("business_fields.room_items.0.room_type_code")
.contains("房型代码不在第一版目录中");
}
@Test
void shouldCreateV4CancelTraceAndRoomingListTasksInEventOrder() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-v4-cancel-trace-rooming-001");