实现 V4 复核解阻接口

This commit is contained in:
andy
2026-07-19 11:36:20 +07:00
parent 080266c633
commit d0d26c7fbe
18 changed files with 1162 additions and 43 deletions

View File

@@ -10,6 +10,7 @@ public enum PlatformPermissionCode {
RESERVATION_TASK_READ,
RESERVATION_TASK_EDIT,
RESERVATION_TASK_CONFIRM,
RESERVATION_MANUAL_REVIEW_RESOLVE,
RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_AUDIT_READ,
RESERVATION_INVOICE_GENERATE,

View File

@@ -288,6 +288,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
PlatformPermissionCode.RESERVATION_TASK_READ,
PlatformPermissionCode.RESERVATION_TASK_EDIT,
PlatformPermissionCode.RESERVATION_TASK_CONFIRM,
PlatformPermissionCode.RESERVATION_MANUAL_REVIEW_RESOLVE,
PlatformPermissionCode.RESERVATION_OPERA_SIM_EXECUTE,
PlatformPermissionCode.RESERVATION_AUDIT_READ,
PlatformPermissionCode.RESERVATION_INVOICE_GENERATE,
@@ -315,6 +316,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
case RESERVATION_TASK_READ -> "读取任务";
case RESERVATION_TASK_EDIT -> "编辑任务草稿";
case RESERVATION_TASK_CONFIRM -> "确认任务";
case RESERVATION_MANUAL_REVIEW_RESOLVE -> "处理人工复核";
case RESERVATION_OPERA_SIM_EXECUTE -> "执行 OPERA 模拟";
case RESERVATION_AUDIT_READ -> "读取任务审计";
case RESERVATION_INVOICE_GENERATE -> "生成预订发票";
@@ -337,7 +339,8 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
return switch (code) {
case SOURCE_MESSAGE_READ, SOURCE_MESSAGE_ORIGINAL_READ -> "SOURCE_MESSAGE";
case RESERVATION_ORDER_READ, RESERVATION_TASK_READ, RESERVATION_TASK_EDIT,
RESERVATION_TASK_CONFIRM, RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_TASK_CONFIRM, RESERVATION_MANUAL_REVIEW_RESOLVE,
RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE,
RESERVATION_ROOMING_LIST_GENERATE -> "RESERVATION";
case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL";

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Reservation V4 复核字段修正项。field_pointer 必须是当前卡展示 payload 内允许编辑的 RFC 6901 JSON Pointer。
*
* @param fieldPointer 当前卡字段 JSON Pointer
* @param value 用户复核后的字段值
*/
public record ReservationV4ReviewFieldOverrideRequest(
@JsonProperty("field_pointer")
String fieldPointer,
JsonNode value
) {
}

View File

@@ -0,0 +1,22 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Reservation V4 人工复核解阻请求。只用于 REVIEW_REQUIRED 卡片,不用于普通任务任意切换订单。
*
* @param version 前端读取到的任务卡乐观锁版本
* @param fieldOverrides 用户复核提交的字段修正列表
* @param reason 可选复核说明,用于业务审计摘要
* @param confirmedOrderId 复核场景确认后的本地订单 ID订单归属未解决时必填
*/
public record ReservationV4ReviewResolutionRequest(
Long version,
@JsonProperty("field_overrides")
List<ReservationV4ReviewFieldOverrideRequest> fieldOverrides,
String reason,
@JsonProperty("confirmed_order_id")
String confirmedOrderId
) {
}

View File

@@ -9,6 +9,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
* @param readOnly 当前是否只读
* @param editable 当前是否允许编辑
* @param confirmable 当前是否允许最终确认
* @param reviewable 当前是否允许提交人工复核解阻
* @param ackable 当前是否允许确认来源通知已处理
* @param readonlyReasonCode 只读原因稳定码
* @param blockedByOrderTaskId 阻塞当前条目的前置订单任务 ID
@@ -21,6 +22,7 @@ public record ReservationV4ActionAvailabilityResult(
boolean readOnly,
boolean editable,
boolean confirmable,
boolean reviewable,
boolean ackable,
@JsonProperty("readonly_reason_code")
String readonlyReasonCode,

View File

@@ -4,6 +4,7 @@ import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewResolutionRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
@@ -51,6 +52,22 @@ public class ReservationV4CommandController {
return commandService.confirmTaskCard(orderTaskId, cardId, request, actor);
}
/**
* 复核解阻 V4 REVIEW_REQUIRED 卡片;支持字段修正和复核场景订单归属确认。
*/
@PostMapping(
value = "/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationV4OrderTaskDetailResult resolveTaskCardReview(
@PathVariable Long orderTaskId,
@PathVariable Long cardId,
@RequestBody(required = false) ReservationV4ReviewResolutionRequest request) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_MANUAL_REVIEW_RESOLVE.name());
return commandService.resolveTaskCardReview(orderTaskId, cardId, request, actor);
}
/**
* 确认 S10/S99 来源通知已读或已处理;不创建订单、不参与订单阻塞。
*/

View File

@@ -6,11 +6,13 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapsho
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4OrderTaskQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4OrderTaskMapper;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationV4TaskCardMapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.time.LocalDateTime;
@@ -138,6 +140,8 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt)
.orderByAsc(ReservationV4OrderTaskEntity::getOrderId)
.orderByAsc(ReservationV4OrderTaskEntity::getSourceReceivedAt)
.orderByAsc(ReservationV4OrderTaskEntity::getSourceMessageId)
.orderByAsc(ReservationV4OrderTaskEntity::getOrderContextIndex)
.orderByAsc(ReservationV4OrderTaskEntity::getCreatedAt)
.orderByAsc(ReservationV4OrderTaskEntity::getId))
.stream()
@@ -309,6 +313,67 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work
return updated == 1;
}
/**
* 按 version 乐观锁完成 V4 人工复核卡,同时写入复核结果、确认 payload、确认人和确认时间。
*/
@Override
public boolean resolveTaskCardReviewWithVersion(
String hotelId,
Long taskCardId,
Long expectedVersion,
String reviewResolutionJson,
String confirmedPayloadJson,
String confirmedBy,
LocalDateTime confirmedAt) {
int updated = taskCardMapper.update(Wrappers.<ReservationV4TaskCardEntity>lambdaUpdate()
.set(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.CONFIRMED.name())
.set(ReservationV4TaskCardEntity::getReviewStatus, ReservationReviewStatus.RESOLVED.name())
.set(ReservationV4TaskCardEntity::getReviewResolutionJson, reviewResolutionJson)
.set(ReservationV4TaskCardEntity::getConfirmedPayloadJson, confirmedPayloadJson)
.set(ReservationV4TaskCardEntity::getConfirmedBy, confirmedBy)
.set(ReservationV4TaskCardEntity::getConfirmedAt, confirmedAt)
.set(ReservationV4TaskCardEntity::getUpdatedAt, confirmedAt)
.setSql("version = version + 1")
.eq(ReservationV4TaskCardEntity::getHotelId, hotelId)
.eq(ReservationV4TaskCardEntity::getId, taskCardId)
.eq(ReservationV4TaskCardEntity::getVersion, expectedVersion)
.eq(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.REVIEW_REQUIRED.name())
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt));
return updated == 1;
}
/**
* 在复核场景确认 V4 订单任务归属,只更新当前订单任务的本地订单绑定和归属状态。
*/
@Override
public boolean updateOrderTaskBinding(
String hotelId,
Long orderTaskId,
Long expectedVersion,
Long expectedOrderId,
String expectedTargetResolutionStatus,
Long confirmedOrderId,
String targetResolutionStatus,
LocalDateTime now) {
LambdaUpdateWrapper<ReservationV4OrderTaskEntity> update = Wrappers.lambdaUpdate(ReservationV4OrderTaskEntity.class)
.set(ReservationV4OrderTaskEntity::getOrderId, confirmedOrderId)
.set(ReservationV4OrderTaskEntity::getTargetResolutionStatus, targetResolutionStatus)
.set(ReservationV4OrderTaskEntity::getUpdatedAt, now)
.setSql("version = version + 1")
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
.eq(ReservationV4OrderTaskEntity::getId, orderTaskId)
.eq(ReservationV4OrderTaskEntity::getVersion, expectedVersion)
.eq(ReservationV4OrderTaskEntity::getTargetResolutionStatus, expectedTargetResolutionStatus)
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt);
if (expectedOrderId == null) {
update.isNull(ReservationV4OrderTaskEntity::getOrderId);
} else {
update.eq(ReservationV4OrderTaskEntity::getOrderId, expectedOrderId);
}
int updated = orderTaskMapper.update(update);
return updated == 1;
}
/**
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
*/

View File

@@ -98,6 +98,31 @@ public interface ReservationV4WorkflowRepository {
String confirmedBy,
LocalDateTime confirmedAt);
/**
* 按 version 乐观锁完成 V4 人工复核卡,同时写入复核结果、确认 payload、确认人和确认时间。
*/
boolean resolveTaskCardReviewWithVersion(
String hotelId,
Long taskCardId,
Long expectedVersion,
String reviewResolutionJson,
String confirmedPayloadJson,
String confirmedBy,
LocalDateTime confirmedAt);
/**
* 在复核场景确认 V4 订单任务归属,只更新当前订单任务的本地订单绑定和归属状态。
*/
boolean updateOrderTaskBinding(
String hotelId,
Long orderTaskId,
Long expectedVersion,
Long expectedOrderId,
String expectedTargetResolutionStatus,
Long confirmedOrderId,
String targetResolutionStatus,
LocalDateTime now);
/**
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
*/

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewResolutionRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
@@ -20,6 +21,15 @@ public interface ReservationV4CommandService {
ReservationV4CardConfirmRequest request,
AuthenticatedUserContext actor);
/**
* 复核解阻指定 V4 REVIEW_REQUIRED 任务卡,并返回刷新后的订单任务详情。
*/
ReservationV4OrderTaskDetailResult resolveTaskCardReview(
Long orderTaskId,
Long cardId,
ReservationV4ReviewResolutionRequest request,
AuthenticatedUserContext actor);
/**
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
*/

View File

@@ -4,15 +4,22 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
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.ReservationV4OrderTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4TaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiRouteDefinition;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderVisibility;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4CardConfirmRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewFieldOverrideRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4ReviewResolutionRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationV4SourceNotificationAckRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
@@ -23,14 +30,18 @@ import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandServi
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
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.Set;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -43,7 +54,34 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
private static final String ACTOR_TYPE_USER = "USER";
private static final String ACTION_V4_CARD_CONFIRM = "V4_CARD_CONFIRM";
private static final String ACTION_V4_CARD_REVIEW_RESOLVE = "V4_CARD_REVIEW_RESOLVE";
private static final String ACTION_V4_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
private static final Set<String> REVIEW_READONLY_ROOT_FIELDS = Set.of(
"ai_payload_json",
"attachments",
"blocking_points",
"card_type",
"card_status",
"conflicting_points",
"evidence_to_check",
"event_type",
"field_contract_version",
"known_fields",
"manual_review",
"missing_fields",
"order_ref",
"raw_evidence",
"review_status",
"result_type",
"route",
"route_code",
"source_event_index",
"source_message",
"source_message_id",
"target_order",
"task_subtype",
"task_type",
"validation_errors");
private final ReservationV4WorkflowRepository workflowRepository;
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
@@ -106,6 +144,75 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id());
}
/**
* 复核解阻指定 V4 REVIEW_REQUIRED 卡片,并返回刷新后的订单任务详情。
*/
@Override
@Transactional
public ReservationV4OrderTaskDetailResult resolveTaskCardReview(
Long orderTaskId,
Long cardId,
ReservationV4ReviewResolutionRequest request,
AuthenticatedUserContext actor) {
Long expectedVersion = requireVersion(request == null ? null : request.version());
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
ReservationV4OrderTaskSnapshot orderTask = requireOrderTask(orderTaskId);
requireHotelAccess(orderTask.hotelId());
ReservationV4TaskCardSnapshot card = requireTaskCard(orderTask.hotelId(), cardId, orderTask.id());
validateCardReviewable(orderTask, card);
Long confirmedOrderId = parseConfirmedOrderId(request == null ? null : request.confirmedOrderId());
Long targetOrderId = resolveReviewTargetOrderId(orderTask, confirmedOrderId);
ensureNoPriorOrderTaskBlocking(orderTask, targetOrderId);
ensureBasicInformationConfirmed(orderTask, card);
ObjectNode confirmedPayload = mutableDisplayPayload(card);
List<Map<String, Object>> normalizedOverrides = applyReviewFieldOverrides(
card,
confirmedPayload,
request == null ? null : request.fieldOverrides());
String actorId = actorIdentifier(actor);
String confirmedPayloadJson = toJson(confirmedPayload);
String reviewResolutionJson = reviewResolutionJson(
request,
normalizedOverrides,
confirmedOrderId,
actorId,
now);
boolean updated = workflowRepository.resolveTaskCardReviewWithVersion(
orderTask.hotelId(),
card.id(),
expectedVersion,
reviewResolutionJson,
confirmedPayloadJson,
actorId,
now);
if (!updated) {
handleCardReviewRace(orderTask.hotelId(), card.id());
}
ReservationV4OrderTaskSnapshot latestOrderTask = orderTask;
boolean shouldUpdateOrderBinding = confirmedOrderId != null
&& (!Objects.equals(confirmedOrderId, orderTask.orderId())
|| !ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus()));
if (shouldUpdateOrderBinding) {
boolean bindingUpdated = workflowRepository.updateOrderTaskBinding(
orderTask.hotelId(),
orderTask.id(),
orderTask.version(),
orderTask.orderId(),
orderTask.targetResolutionStatus(),
confirmedOrderId,
ReservationV4TargetResolutionStatus.RESOLVED.name(),
now);
if (!bindingUpdated) {
throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_BINDING_CONFLICT", "V4 订单任务归属已变化,请刷新后重试。");
}
latestOrderTask = requireOrderTask(orderTask.id());
}
refreshOrderTaskStatus(latestOrderTask, now);
writeCardReviewResolveAudit(orderTask, card, actorId, normalizedOverrides, confirmedOrderId, request, now);
return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id());
}
/**
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
*/
@@ -177,6 +284,23 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
private void validateCardReviewable(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card) {
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_REVIEWABLE", "来源邮件展示卡不允许复核解阻。");
}
if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复复核。");
}
if (!ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_REVIEW_REQUIRED", "当前卡片不是待复核状态。");
}
if (ReservationV4OrderTaskStatus.COMPLETED.name().equals(orderTask.orderTaskStatus())) {
throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_ALREADY_COMPLETED", "该 V4 订单任务已经完成。");
}
}
private void ensureBasicInformationConfirmed(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card) {
@@ -201,17 +325,22 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask) {
if (orderTask.orderId() == null) {
ensureNoPriorOrderTaskBlocking(orderTask, orderTask.orderId());
}
private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask, Long targetOrderId) {
if (targetOrderId == null) {
return;
}
List<ReservationV4OrderTaskSnapshot> sameOrderTasks = workflowRepository.findOrderTasksByOrderIds(
orderTask.hotelId(),
List.of(orderTask.orderId()));
List.of(targetOrderId));
for (ReservationV4OrderTaskSnapshot candidate : sameOrderTasks) {
if (Objects.equals(candidate.id(), orderTask.id())) {
return;
}
if (!ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) {
if (isPriorOrderTask(candidate, orderTask)
&& !ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) {
throw error(
HttpStatus.CONFLICT,
"V4_PRIOR_ORDER_TASK_NOT_COMPLETED",
@@ -220,6 +349,70 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
}
}
private boolean isPriorOrderTask(
ReservationV4OrderTaskSnapshot candidate,
ReservationV4OrderTaskSnapshot current) {
int sourceReceivedCompare = compareNullableTime(candidate.sourceReceivedAt(), current.sourceReceivedAt());
if (sourceReceivedCompare != 0) {
return sourceReceivedCompare < 0;
}
int sourceMessageCompare = compareNullableLong(candidate.sourceMessageId(), current.sourceMessageId());
if (sourceMessageCompare != 0) {
return sourceMessageCompare < 0;
}
int orderContextCompare = compareNullableInteger(candidate.orderContextIndex(), current.orderContextIndex());
if (orderContextCompare != 0) {
return orderContextCompare < 0;
}
int createdCompare = compareNullableTime(candidate.createdAt(), current.createdAt());
if (createdCompare != 0) {
return createdCompare < 0;
}
if (candidate.id() == null || current.id() == null) {
return false;
}
return candidate.id() < current.id();
}
private int compareNullableTime(LocalDateTime left, LocalDateTime right) {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return left.compareTo(right);
}
private int compareNullableLong(Long left, Long right) {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return left.compareTo(right);
}
private int compareNullableInteger(Integer left, Integer right) {
if (left == null && right == null) {
return 0;
}
if (left == null) {
return -1;
}
if (right == null) {
return 1;
}
return left.compareTo(right);
}
private void refreshOrderTaskStatus(ReservationV4OrderTaskSnapshot orderTask, LocalDateTime now) {
List<ReservationV4TaskCardSnapshot> cards = workflowRepository.findTaskCardsByOrderTaskId(
orderTask.hotelId(),
@@ -250,6 +443,15 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。");
}
private void handleCardReviewRace(String hotelId, Long cardId) {
ReservationV4TaskCardSnapshot latest = workflowRepository.findTaskCardById(hotelId, cardId)
.orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。"));
if (ReservationV4CardStatus.CONFIRMED.name().equals(latest.cardStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CARD_ALREADY_CONFIRMED", "该卡片已经确认,不能重复复核。");
}
throw error(HttpStatus.CONFLICT, "V4_CARD_VERSION_CONFLICT", "任务卡版本已变化,请刷新后重试。");
}
private String confirmedPayloadJson(ReservationV4TaskCardSnapshot card, JsonNode confirmedPayload) {
if (confirmedPayload != null && !confirmedPayload.isNull() && !confirmedPayload.isMissingNode()) {
return toJson(confirmedPayload);
@@ -260,6 +462,261 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
return "{}";
}
private ObjectNode mutableDisplayPayload(ReservationV4TaskCardSnapshot card) {
if (!hasText(card.displayPayloadJson())) {
return objectMapper.createObjectNode();
}
try {
JsonNode node = objectMapper.readTree(card.displayPayloadJson());
if (node == null || node.isNull()) {
return objectMapper.createObjectNode();
}
if (!node.isObject()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_PAYLOAD_NOT_OBJECT", "当前卡展示 payload 不是对象结构。");
}
return ((ObjectNode) node).deepCopy();
} catch (ReservationTaskWorkflowException exception) {
throw exception;
} catch (Exception exception) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_PAYLOAD_INVALID", "当前卡展示 payload 无法解析。");
}
}
private List<Map<String, Object>> applyReviewFieldOverrides(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
List<ReservationV4ReviewFieldOverrideRequest> fieldOverrides) {
if (fieldOverrides == null || fieldOverrides.isEmpty()) {
return List.of();
}
List<Map<String, Object>> normalized = new ArrayList<>();
Set<String> seenPointers = new HashSet<>();
for (ReservationV4ReviewFieldOverrideRequest override : fieldOverrides) {
String pointer = trimToNull(override == null ? null : override.fieldPointer());
List<String> segments = decodeJsonPointer(pointer);
JsonNode value = override == null || override.value() == null ? objectMapper.nullNode() : override.value();
ensureReviewPointerWritable(card, confirmedPayload, segments, value);
if (!seenPointers.add(pointer)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_DUPLICATED", "复核字段不能重复提交。");
}
setPointerValue(confirmedPayload, segments, value);
Map<String, Object> normalizedOverride = new LinkedHashMap<>();
normalizedOverride.put("field_pointer", pointer);
normalizedOverride.put("value", value);
normalized.add(normalizedOverride);
}
return normalized;
}
private List<String> decodeJsonPointer(String pointer) {
if (!hasText(pointer) || !pointer.startsWith("/")) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针必须是 RFC 6901 JSON Pointer。");
}
String[] rawSegments = pointer.substring(1).split("/", -1);
List<String> segments = new ArrayList<>(rawSegments.length);
for (String rawSegment : rawSegments) {
segments.add(decodeJsonPointerSegment(rawSegment));
}
return segments;
}
private String decodeJsonPointerSegment(String rawSegment) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < rawSegment.length(); i++) {
char current = rawSegment.charAt(i);
if (current != '~') {
builder.append(current);
continue;
}
if (i + 1 >= rawSegment.length()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针转义不合法。");
}
char escaped = rawSegment.charAt(++i);
if (escaped == '0') {
builder.append('~');
} else if (escaped == '1') {
builder.append('/');
} else {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针转义不合法。");
}
}
return builder.toString();
}
private void ensureReviewPointerWritable(
ReservationV4TaskCardSnapshot card,
ObjectNode confirmedPayload,
List<String> segments,
JsonNode value) {
if (segments.isEmpty()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_INVALID", "复核字段指针不能为空。");
}
if (segments.stream().anyMatch(REVIEW_READONLY_ROOT_FIELDS::contains)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_READONLY", "该复核字段为只读字段,不允许修改。");
}
ensureReviewPointerInsideEditableContainer(card, segments);
if (value != null && value.isContainerNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_VALUE_INVALID", "复核字段值必须是标量或 null不能替换对象或数组。");
}
JsonNode current = findPointerValue(confirmedPayload, segments);
if (current == null || current.isMissingNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
if (current.isContainerNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段只能指向当前卡允许编辑的叶子字段。");
}
}
private void ensureReviewPointerInsideEditableContainer(
ReservationV4TaskCardSnapshot card,
List<String> segments) {
if (segments.size() < 2) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
String root = segments.get(0);
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
if (!"basic_information".equals(root)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前 Basic Information 卡允许编辑字段内。");
}
return;
}
if (!"business_fields".equals(root)) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前业务卡允许编辑字段内。");
}
}
private JsonNode findPointerValue(JsonNode root, List<String> segments) {
JsonNode current = root;
for (String segment : segments) {
if (current == null || current.isMissingNode()) {
return null;
}
if (current.isObject()) {
current = current.get(segment);
} else if (current.isArray()) {
Integer index = parseArrayIndex(segment);
current = index == null || index >= current.size() ? null : current.get(index);
} else {
return null;
}
}
return current;
}
private void setPointerValue(ObjectNode root, List<String> segments, JsonNode value) {
JsonNode parent = root;
for (int i = 0; i < segments.size() - 1; i++) {
String segment = segments.get(i);
if (parent.isObject()) {
parent = parent.get(segment);
} else if (parent.isArray()) {
Integer index = parseArrayIndex(segment);
parent = index == null || index >= parent.size() ? null : parent.get(index);
} else {
parent = null;
}
if (parent == null || parent.isMissingNode()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
}
String leaf = segments.get(segments.size() - 1);
JsonNode safeValue = value == null ? objectMapper.nullNode() : value;
if (parent.isObject()) {
((ObjectNode) parent).set(leaf, safeValue);
return;
}
if (parent.isArray()) {
Integer index = parseArrayIndex(leaf);
if (index == null || index >= parent.size()) {
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
((ArrayNode) parent).set(index, safeValue);
return;
}
throw error(HttpStatus.BAD_REQUEST, "V4_REVIEW_POINTER_NOT_ALLOWED", "复核字段不在当前卡允许编辑字段内。");
}
private Integer parseArrayIndex(String segment) {
if (!hasText(segment)) {
return null;
}
if (segment.length() > 1 && segment.startsWith("0")) {
return null;
}
for (int i = 0; i < segment.length(); i++) {
if (!Character.isDigit(segment.charAt(i))) {
return null;
}
}
try {
return Integer.parseInt(segment);
} catch (NumberFormatException exception) {
return null;
}
}
private Long parseConfirmedOrderId(String confirmedOrderId) {
String normalized = trimToNull(confirmedOrderId);
if (normalized == null) {
return null;
}
try {
long parsed = Long.parseLong(normalized);
if (parsed <= 0) {
throw new NumberFormatException("order id must be positive");
}
return parsed;
} catch (NumberFormatException exception) {
throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_ORDER_ID_INVALID", "确认订单 ID 必须是正整数。");
}
}
private Long resolveReviewTargetOrderId(ReservationV4OrderTaskSnapshot orderTask, Long confirmedOrderId) {
boolean ownershipUnresolved = orderTask.orderId() == null
|| !ReservationV4TargetResolutionStatus.RESOLVED.name().equals(orderTask.targetResolutionStatus());
if (confirmedOrderId == null) {
if (ownershipUnresolved) {
throw error(HttpStatus.BAD_REQUEST, "V4_CONFIRMED_ORDER_ID_REQUIRED", "订单归属未解决时必须提交确认订单 ID。");
}
return orderTask.orderId();
}
ReservationOrderSnapshot order = auditRepository.findOrderById(orderTask.hotelId(), confirmedOrderId)
.orElseThrow(() -> notFound("V4_CONFIRMED_ORDER_NOT_FOUND", "确认订单不存在或不属于当前酒店。"));
if (ReservationOrderStatus.LOGIC_DELETED.name().equals(order.orderStatus())) {
throw error(HttpStatus.CONFLICT, "V4_CONFIRMED_ORDER_NOT_AVAILABLE", "确认订单已逻辑删除,不能作为复核归属。");
}
if (ReservationOrderVisibility.HIDDEN_SYSTEM.name().equals(order.orderVisibility())) {
throw error(HttpStatus.CONFLICT, "V4_CONFIRMED_ORDER_NOT_AVAILABLE", "系统隐藏订单不能作为复核归属。");
}
return confirmedOrderId;
}
private String reviewResolutionJson(
ReservationV4ReviewResolutionRequest request,
List<Map<String, Object>> normalizedOverrides,
Long confirmedOrderId,
String actorId,
LocalDateTime now) {
ObjectNode resolution = objectMapper.createObjectNode();
resolution.put("schema_version", "reservation-v4-review-resolution-v1");
resolution.put("resolved_by", actorId);
resolution.put("resolved_at", now.atOffset(ZoneOffset.UTC).toString());
resolution.put("reason", trimToNull(request == null ? null : request.reason()));
resolution.put("order_ownership_confirmed", confirmedOrderId != null);
if (confirmedOrderId == null) {
resolution.putNull("confirmed_order_id");
} else {
resolution.put("confirmed_order_id", confirmedOrderId.toString());
}
ArrayNode overrides = resolution.putArray("field_overrides");
for (Map<String, Object> normalizedOverride : normalizedOverrides) {
ObjectNode overrideNode = overrides.addObject();
overrideNode.put("field_pointer", String.valueOf(normalizedOverride.get("field_pointer")));
overrideNode.set("value", (JsonNode) normalizedOverride.get("value"));
}
return toJson(resolution);
}
private ReservationV4OrderTaskSnapshot requireOrderTask(Long orderTaskId) {
return workflowRepository.findOrderTaskById(orderTaskId)
.orElseThrow(() -> notFound("V4_ORDER_TASK_NOT_FOUND", "V4 订单任务不存在。"));
@@ -344,6 +801,48 @@ public class ReservationV4CommandServiceImpl implements ReservationV4CommandServ
now));
}
private void writeCardReviewResolveAudit(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot card,
String actorId,
List<Map<String, Object>> normalizedOverrides,
Long confirmedOrderId,
ReservationV4ReviewResolutionRequest request,
LocalDateTime now) {
Map<String, Object> beforeSnapshot = new LinkedHashMap<>();
beforeSnapshot.put("v4_order_task_id", orderTask.id().toString());
beforeSnapshot.put("v4_task_card_id", card.id().toString());
beforeSnapshot.put("card_type", card.cardType());
beforeSnapshot.put("card_status", card.cardStatus());
beforeSnapshot.put("review_status", card.reviewStatus());
beforeSnapshot.put("order_id", orderTask.orderId() == null ? null : orderTask.orderId().toString());
beforeSnapshot.put("target_resolution_status", orderTask.targetResolutionStatus());
beforeSnapshot.put("version", card.version());
Map<String, Object> afterSnapshot = new LinkedHashMap<>();
afterSnapshot.put("v4_order_task_id", orderTask.id().toString());
afterSnapshot.put("v4_task_card_id", card.id().toString());
afterSnapshot.put("card_type", card.cardType());
afterSnapshot.put("card_status", ReservationV4CardStatus.CONFIRMED.name());
afterSnapshot.put("review_status", ReservationReviewStatus.RESOLVED.name());
afterSnapshot.put("confirmed_by", actorId);
afterSnapshot.put("field_pointers", normalizedOverrides.stream()
.map(item -> String.valueOf(item.get("field_pointer")))
.toList());
afterSnapshot.put("confirmed_order_id", confirmedOrderId == null ? null : confirmedOrderId.toString());
auditRepository.insertAuditLog(new ReservationAuditLogDraft(
orderTask.hotelId(),
confirmedOrderId == null ? orderTask.orderId() : confirmedOrderId,
null,
null,
ACTOR_TYPE_USER,
actorId,
ACTION_V4_CARD_REVIEW_RESOLVE,
trimToNull(request == null ? null : request.reason()),
toJson(beforeSnapshot),
toJson(afterSnapshot),
now));
}
private void writeSourceNotificationAckAudit(
ReservationV4SourceNotificationSnapshot notification,
String actorId,

View File

@@ -405,6 +405,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
false,
false,
false,
ReservationV4ReadonlyReasonCode.PRIOR_ORDER_TASK_NOT_COMPLETED.name(),
blocker.id().toString(),
null,
@@ -417,6 +418,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
false,
false,
false,
ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(),
null,
null,
@@ -431,10 +433,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
safeCards,
ReservationV4CardType.BASIC_INFORMATION.name()).orElse(null);
if (basicCard != null && ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(basicCard.cardStatus())) {
return readOnlyAvailability(
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
null,
"V4 人工复核写接口将在后续 checkpoint 开放。");
return reviewableAvailability(null, null);
}
boolean hasConfirmableCard = safeCards.stream()
.anyMatch(card -> cardConfirmableAtOrderLevel(card, basicCard));
@@ -445,6 +444,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
true,
true,
false,
false,
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
null,
null,
@@ -458,6 +458,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
false,
false,
false,
ReservationV4ReadonlyReasonCode.PRIOR_CARD_NOT_CONFIRMED.name(),
null,
basicCard.id().toString(),
@@ -466,10 +467,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
boolean hasReviewCard = safeCards.stream()
.anyMatch(card -> ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus()));
if (hasReviewCard) {
return readOnlyAvailability(
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
null,
"V4 人工复核写接口将在后续 checkpoint 开放。");
return reviewableAvailability(null, null);
}
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
}
@@ -510,6 +508,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
false,
false,
false,
orderAvailability.readonlyReasonCode(),
orderAvailability.blockedByOrderTaskId(),
null,
@@ -522,6 +521,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
false,
false,
false,
ReservationV4ReadonlyReasonCode.PRIOR_CARD_NOT_CONFIRMED.name(),
null,
basicCard.id().toString(),
@@ -537,22 +537,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
true,
true,
false,
false,
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
null,
null,
null);
}
if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
return new ReservationV4ActionAvailabilityResult(
false,
true,
false,
false,
false,
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
null,
null,
"V4 人工复核写接口将在后续 checkpoint 开放。");
return reviewableAvailability(null, null);
}
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
}
@@ -567,6 +559,7 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
false,
false,
false,
true,
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
null,
@@ -584,12 +577,29 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
false,
false,
false,
false,
reasonCode,
null,
blockedByCardId,
blockedReason);
}
private ReservationV4ActionAvailabilityResult reviewableAvailability(
String blockedByCardId,
String blockedReason) {
return new ReservationV4ActionAvailabilityResult(
blockedByCardId != null,
false,
true,
false,
true,
false,
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
null,
blockedByCardId,
blockedReason);
}
private boolean isBusinessCardBlockedByBasic(
ReservationV4TaskCardSnapshot card,
ReservationV4TaskCardSnapshot basicCard) {

View File

@@ -73,6 +73,7 @@ class AuthControllerTest {
"RESERVATION_TASK_READ",
"RESERVATION_TASK_EDIT",
"RESERVATION_TASK_CONFIRM",
"RESERVATION_MANUAL_REVIEW_RESOLVE",
"RESERVATION_OPERA_SIM_EXECUTE",
"RESERVATION_AUDIT_READ",
"RESERVATION_INVOICE_GENERATE",

View File

@@ -28,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardTyp
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import java.time.Instant;
@@ -206,6 +207,286 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED"));
}
@Test
void shouldResolveBasicInformationReviewAndConfirmOrderOwnership() throws Exception {
Long confirmedOrderId = 990000000000070002L;
seedReservationOrder(HOTEL_ID, confirmedOrderId);
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-basic-001",
Instant.parse("2026-07-19T01:21:00Z"),
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,
"reason": "确认订单归属并补齐旅行社代码",
"confirmed_order_id": "990000000000070002",
"field_overrides": [
{
"field_pointer": "/basic_information/account_code",
"value": "QBD_TRAVEL"
}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_id").value(confirmedOrderId.toString()))
.andExpect(jsonPath("$.order_task.target_resolution_status").value("RESOLVED"))
.andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED"))
.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.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 shouldResolveOrderOwnershipStatusWhenConfirmedOrderIdAlreadyBound() throws Exception {
Long confirmedOrderId = 990000000000070009L;
seedReservationOrder(HOTEL_ID, confirmedOrderId);
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-same-order-001",
Instant.parse("2026-07-19T01:21:30Z"),
confirmedOrderId,
ReservationV4TargetResolutionStatus.UNRESOLVED.name(),
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": "990000000000070009",
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_id").value(confirmedOrderId.toString()))
.andExpect(jsonPath("$.order_task.target_resolution_status").value("RESOLVED"));
}
@Test
void shouldResolveBusinessCardReviewAfterBasicInformationConfirmed() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-business-001",
Instant.parse("2026-07-19T01:22:00Z"),
990000000000070003L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
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/pms_room_type_code",
"value": "RM2"
}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_task_status").value("COMPLETED"))
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.business_cards[0].review_status").value("RESOLVED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.room_items[0].pms_room_type_code")
.value("RM2"))
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射"));
}
@Test
void shouldRejectReviewResolutionWhenOrderOwnershipUnresolvedAndConfirmedOrderMissing() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-order-required-001",
Instant.parse("2026-07-19T01:22:30Z"),
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,
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_CONFIRMED_ORDER_ID_REQUIRED"));
}
@Test
void shouldRejectReviewResolutionForUnknownConfirmedOrder() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-unknown-order-001",
Instant.parse("2026-07-19T01:22:40Z"),
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": "990000000000070199",
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}
]
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error_code").value("V4_CONFIRMED_ORDER_NOT_FOUND"));
}
@Test
void shouldRejectReviewResolutionForIllegalPointer() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-illegal-pointer-001",
Instant.parse("2026-07-19T01:23:00Z"),
990000000000070004L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
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,
"field_overrides": [
{"field_pointer": "business_fields/room_items/0/pms_room_type_code", "value": "RM2"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID"));
}
@Test
void shouldRejectReviewResolutionForReadonlyPointer() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-readonly-pointer-001",
Instant.parse("2026-07-19T01:24:00Z"),
990000000000070005L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
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,
"field_overrides": [
{"field_pointer": "/card_type", "value": "PAYMENT"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
}
@Test
void shouldRejectReviewResolutionForContainerPointer() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-container-pointer-001",
Instant.parse("2026-07-19T01:24:15Z"),
990000000000070010L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
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,
"field_overrides": [
{"field_pointer": "/business_fields/room_items", "value": []}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_VALUE_INVALID"));
}
@Test
void shouldRejectRepeatedReviewResolution() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-repeat-001",
Instant.parse("2026-07-19T01:24:30Z"),
990000000000070006L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
resolveBusinessReviewCard(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": 1,
"field_overrides": [
{"field_pointer": "/business_fields/room_items/0/pms_room_type_code", "value": "RM3"}
]
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED"));
}
@Test
void shouldRejectCardConfirmWhenPriorOrderTaskIsOpen() throws Exception {
Long sharedOrderId = 990000000000040001L;
@@ -360,6 +641,24 @@ class ReservationV4CommandControllerTest {
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
SeededOrderTask reviewSeeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-no-permission-review-001",
Instant.parse("2026-07-19T01:42:00Z"),
990000000000070007L,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, noPermissionToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
reviewSeeded.orderTask().id(),
reviewSeeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"version": 0, "field_overrides": [{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}]}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
@@ -391,6 +690,24 @@ class ReservationV4CommandControllerTest {
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
SeededOrderTask reviewSeeded = seedReviewOrderTask(
OTHER_HOTEL_ID,
"mail-v4-command-cross-hotel-review-001",
Instant.parse("2026-07-19T01:52:00Z"),
990000000000070008L,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
reviewSeeded.orderTask().id(),
reviewSeeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"version": 0, "field_overrides": [{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}]}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
/**
@@ -427,6 +744,23 @@ class ReservationV4CommandControllerTest {
.andExpect(status().isOk());
}
private void resolveBusinessReviewCard(SeededOrderTask seeded) throws Exception {
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,
"field_overrides": [
{"field_pointer": "/business_fields/room_items/0/pms_room_type_code", "value": "RM2"}
]
}
"""))
.andExpect(status().isOk());
}
private SeededOrderTask seedOrderTask(String hotelId, String externalMessageId, Instant receivedAt) {
return seedOrderTask(hotelId, externalMessageId, receivedAt, null);
}
@@ -474,6 +808,77 @@ class ReservationV4CommandControllerTest {
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
}
private SeededOrderTask seedReviewOrderTask(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String basicStatus,
String businessStatus) {
return seedReviewOrderTask(
hotelId,
externalMessageId,
receivedAt,
orderId,
orderId == null
? ReservationV4TargetResolutionStatus.UNRESOLVED.name()
: ReservationV4TargetResolutionStatus.RESOLVED.name(),
basicStatus,
businessStatus);
}
private SeededOrderTask seedReviewOrderTask(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String targetResolutionStatus,
String basicStatus,
String businessStatus) {
SourceMessageCaptureResult source = captureSourceMessage(
hotelId,
externalMessageId,
"V4 Command Review",
receivedAt);
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
hotelId,
source.inboxId(),
990000000000060001L + Math.abs(externalMessageId.hashCode()),
"order-review-" + externalMessageId,
1,
orderId,
"GROUP",
"GROUP_CODE",
"GRP-V4-REVIEW-001",
targetResolutionStatus,
ReservationV4OrderTaskStatus.OPEN.name(),
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
now));
ReservationV4TaskCardSnapshot sourceCard = insertCard(orderTask, hotelId,
ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
ReservationV4CardStatus.READONLY.name(), null, """
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Command Review"}}
""");
ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId,
ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
basicStatus, reviewStatusFor(basicStatus), """
{"card_type":"BASIC_INFORMATION","order_ref":"ORDER-REVIEW","basic_information":{"account_code":null,"manual_review":true,"missing_fields":["/basic_information/account_code"]}}
""");
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}]}}
""");
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
}
private String reviewStatusFor(String cardStatus) {
return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(cardStatus)
? ReservationReviewStatus.PENDING.name()
: null;
}
private ReservationV4SourceNotificationSnapshot seedSourceNotification(
String hotelId,
String externalMessageId,
@@ -570,6 +975,29 @@ class ReservationV4CommandControllerTest {
org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount);
}
private void seedReservationOrder(String hotelId, Long orderId) {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
jdbcTemplate.update(
"""
INSERT INTO workflow_reservation_order
(id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, order_visibility, business_key_source,
display_name, source_message_id, latest_activity_at, version, created_at, updated_at)
VALUES
(?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'VISIBLE', 'USER_CONFIRMED',
?, 0, ?, 0, ?, ?)
""",
orderId,
hotelId,
"GRP-ORDER-" + orderId,
"GRP-ORDER-" + orderId,
"TMP-ORDER-" + orderId,
"订单 " + orderId,
now,
now,
now);
}
private record SeededOrderTask(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot sourceCard,

View File

@@ -251,7 +251,7 @@ class ReservationV4QueryControllerTest {
}
@Test
void shouldNotExposeOrderTaskAsConfirmableWhenOnlyReviewCardsRemain() throws Exception {
void shouldExposeReviewCardsAsReviewableButNotConfirmable() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask(
"mail-v4-query-review-only-001",
Instant.parse("2026-07-18T03:05:00Z"));
@@ -259,12 +259,14 @@ class ReservationV4QueryControllerTest {
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.availability.read_only").value(false))
.andExpect(jsonPath("$.availability.confirmable").value(false))
.andExpect(jsonPath("$.availability.readonly_reason_code").value("REVIEW_API_PENDING"))
.andExpect(jsonPath("$.availability.reviewable").value(true))
.andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE"))
.andExpect(jsonPath("$.basic_information_card.availability.confirmable").value(false))
.andExpect(jsonPath("$.basic_information_card.availability.reviewable").value(true))
.andExpect(jsonPath("$.basic_information_card.availability.readonly_reason_code")
.value("REVIEW_API_PENDING"));
.value("PROCESSABLE"));
}
@Test