完成 V4 目录校验和字段白名单
This commit is contained in:
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 指针清单。
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 前把空字符串统一为安全默认值。
|
||||
*/
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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, """
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user