实现 V4 复核解阻接口
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 来源通知已读或已处理;不创建订单、不参与订单阻塞。
|
||||
*/
|
||||
|
||||
@@ -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。
|
||||
*/
|
||||
|
||||
@@ -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。
|
||||
*/
|
||||
|
||||
@@ -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 来源通知已读或已处理,并返回刷新后的来源通知详情。
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user