实现 V4 卡片确认和来源通知确认
This commit is contained in:
@@ -10,5 +10,6 @@ public enum ReservationV4ReadonlyReasonCode {
|
||||
PRIOR_CARD_NOT_CONFIRMED,
|
||||
PRIOR_ORDER_TASK_NOT_COMPLETED,
|
||||
COMMAND_API_PENDING,
|
||||
ACK_API_PENDING
|
||||
ACK_API_PENDING,
|
||||
REVIEW_API_PENDING
|
||||
}
|
||||
|
||||
@@ -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 卡片确认请求。confirmed_payload 为空时后端使用当前展示 payload 作为确认快照。
|
||||
*
|
||||
* @param version 前端读取到的卡片乐观锁版本
|
||||
* @param confirmedPayload 用户确认后的卡片 payload
|
||||
*/
|
||||
public record ReservationV4CardConfirmRequest(
|
||||
Long version,
|
||||
@JsonProperty("confirmed_payload")
|
||||
JsonNode confirmedPayload
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.common.request;
|
||||
|
||||
/**
|
||||
* Reservation V4 来源通知确认请求。第一版只要求带上前端读取到的 version。
|
||||
*
|
||||
* @param version 来源通知乐观锁版本
|
||||
* @param reason 可选处理说明,当前只写入审计摘要
|
||||
*/
|
||||
public record ReservationV4SourceNotificationAckRequest(
|
||||
Long version,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Reservation V4 查询侧动作可用性。CP5 只读查询阶段不开放写接口,因此 confirmable / ackable 默认由后续 CP 打开。
|
||||
* Reservation V4 查询侧动作可用性。用于前端判断卡片确认、来源通知确认和只读原因。
|
||||
*
|
||||
* @param blocked 是否被前置订单任务或卡片阻塞
|
||||
* @param readOnly 当前是否只读
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
ReservationTaskController.class,
|
||||
ReservationFrontendQueryController.class,
|
||||
ReservationV4QueryController.class,
|
||||
ReservationV4CommandController.class,
|
||||
ReservationDemoDataController.class,
|
||||
ReservationInvoiceGenerationController.class,
|
||||
ReservationRoomingListGenerationController.class
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
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.ReservationV4SourceNotificationAckRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Reservation V4 前端写操作 Controller。只暴露卡片确认和来源通知确认入口。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/reservation")
|
||||
public class ReservationV4CommandController {
|
||||
|
||||
private final ReservationV4CommandService commandService;
|
||||
private final FrontendAuthorizationService authorizationService;
|
||||
|
||||
/**
|
||||
* 注入 V4 命令服务和前端鉴权服务,Controller 不直接访问 Repository。
|
||||
*/
|
||||
public ReservationV4CommandController(
|
||||
ReservationV4CommandService commandService,
|
||||
FrontendAuthorizationService authorizationService) {
|
||||
this.commandService = commandService;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 V4 订单任务下的一张业务卡;Basic Information 未确认时业务卡不可确认。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ReservationV4OrderTaskDetailResult confirmTaskCard(
|
||||
@PathVariable Long orderTaskId,
|
||||
@PathVariable Long cardId,
|
||||
@RequestBody(required = false) ReservationV4CardConfirmRequest request) {
|
||||
AuthenticatedUserContext actor = authorizationService.requirePermission(
|
||||
PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name());
|
||||
return commandService.confirmTaskCard(orderTaskId, cardId, request, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 S10/S99 来源通知已读或已处理;不创建订单、不参与订单阻塞。
|
||||
*/
|
||||
@PostMapping(
|
||||
value = "/source-notifications/{notificationId}/ack",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE,
|
||||
produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ReservationV4SourceNotificationDetailResult ackSourceNotification(
|
||||
@PathVariable Long notificationId,
|
||||
@RequestBody(required = false) ReservationV4SourceNotificationAckRequest request) {
|
||||
AuthenticatedUserContext actor = authorizationService.requirePermission(
|
||||
PlatformPermissionCode.RESERVATION_TASK_CONFIRM.name());
|
||||
return commandService.ackSourceNotification(notificationId, request, actor);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTask
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
|
||||
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.request.ReservationV4OrderTaskQueryRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4OrderTaskEntity;
|
||||
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationV4TaskCardEntity;
|
||||
@@ -282,6 +283,51 @@ public class MybatisReservationV4WorkflowRepository implements ReservationV4Work
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。
|
||||
*/
|
||||
@Override
|
||||
public boolean confirmTaskCardWithVersion(
|
||||
String hotelId,
|
||||
Long taskCardId,
|
||||
Long expectedVersion,
|
||||
String confirmedPayloadJson,
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt) {
|
||||
int updated = taskCardMapper.update(Wrappers.<ReservationV4TaskCardEntity>lambdaUpdate()
|
||||
.set(ReservationV4TaskCardEntity::getCardStatus, ReservationV4CardStatus.CONFIRMED.name())
|
||||
.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.PENDING_CONFIRM.name())
|
||||
.isNull(ReservationV4TaskCardEntity::getLogicDeletedAt));
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
|
||||
*/
|
||||
@Override
|
||||
public boolean updateOrderTaskStatus(
|
||||
String hotelId,
|
||||
Long orderTaskId,
|
||||
String orderTaskStatus,
|
||||
LocalDateTime now) {
|
||||
int updated = orderTaskMapper.update(Wrappers.<ReservationV4OrderTaskEntity>lambdaUpdate()
|
||||
.set(ReservationV4OrderTaskEntity::getOrderTaskStatus, orderTaskStatus)
|
||||
.set(ReservationV4OrderTaskEntity::getUpdatedAt, now)
|
||||
.setSql("version = version + 1")
|
||||
.eq(ReservationV4OrderTaskEntity::getHotelId, hotelId)
|
||||
.eq(ReservationV4OrderTaskEntity::getId, orderTaskId)
|
||||
.isNull(ReservationV4OrderTaskEntity::getLogicDeletedAt));
|
||||
return updated == 1;
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskSnapshot toOrderTaskSnapshot(ReservationV4OrderTaskEntity entity) {
|
||||
return new ReservationV4OrderTaskSnapshot(
|
||||
entity.getId(),
|
||||
|
||||
@@ -86,4 +86,24 @@ public interface ReservationV4WorkflowRepository {
|
||||
Long expectedVersion,
|
||||
String cardStatus,
|
||||
LocalDateTime now);
|
||||
|
||||
/**
|
||||
* 按 version 乐观锁确认 V4 任务卡,同时写入用户确认 payload、确认人和确认时间。
|
||||
*/
|
||||
boolean confirmTaskCardWithVersion(
|
||||
String hotelId,
|
||||
Long taskCardId,
|
||||
Long expectedVersion,
|
||||
String confirmedPayloadJson,
|
||||
String confirmedBy,
|
||||
LocalDateTime confirmedAt);
|
||||
|
||||
/**
|
||||
* 更新 V4 订单任务派生状态,用于卡片确认后刷新 OPEN / COMPLETED。
|
||||
*/
|
||||
boolean updateOrderTaskStatus(
|
||||
String hotelId,
|
||||
Long orderTaskId,
|
||||
String orderTaskStatus,
|
||||
LocalDateTime now);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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.ReservationV4SourceNotificationAckRequest;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4OrderTaskDetailResult;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4SourceNotificationDetailResult;
|
||||
|
||||
/**
|
||||
* Reservation V4 命令服务。承接卡片确认和 S10/S99 来源通知确认写操作。
|
||||
*/
|
||||
public interface ReservationV4CommandService {
|
||||
|
||||
/**
|
||||
* 确认指定 V4 任务卡,并返回刷新后的订单任务详情。
|
||||
*/
|
||||
ReservationV4OrderTaskDetailResult confirmTaskCard(
|
||||
Long orderTaskId,
|
||||
Long cardId,
|
||||
ReservationV4CardConfirmRequest request,
|
||||
AuthenticatedUserContext actor);
|
||||
|
||||
/**
|
||||
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
|
||||
*/
|
||||
ReservationV4SourceNotificationDetailResult ackSourceNotification(
|
||||
Long notificationId,
|
||||
ReservationV4SourceNotificationAckRequest request,
|
||||
AuthenticatedUserContext actor);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.service.impl;
|
||||
|
||||
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.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.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.request.ReservationV4CardConfirmRequest;
|
||||
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;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4CommandService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.service.ReservationV4QueryService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Reservation V4 命令服务实现。负责卡片确认、来源通知确认、状态刷新和业务审计。
|
||||
*/
|
||||
@Service
|
||||
public class ReservationV4CommandServiceImpl implements ReservationV4CommandService {
|
||||
|
||||
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_SOURCE_NOTIFICATION_ACK = "V4_SOURCE_NOTIFICATION_ACK";
|
||||
|
||||
private final ReservationV4WorkflowRepository workflowRepository;
|
||||
private final ReservationV4SourceNotificationRepository sourceNotificationRepository;
|
||||
private final ReservationAiWorkflowRepository auditRepository;
|
||||
private final ReservationV4QueryService queryService;
|
||||
private final HotelContextService hotelContextService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 注入 V4 Repository、查询服务、酒店上下文和审计边界,命令服务不直接依赖 Mapper。
|
||||
*/
|
||||
public ReservationV4CommandServiceImpl(
|
||||
ReservationV4WorkflowRepository workflowRepository,
|
||||
ReservationV4SourceNotificationRepository sourceNotificationRepository,
|
||||
ReservationAiWorkflowRepository auditRepository,
|
||||
ReservationV4QueryService queryService,
|
||||
HotelContextService hotelContextService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.workflowRepository = workflowRepository;
|
||||
this.sourceNotificationRepository = sourceNotificationRepository;
|
||||
this.auditRepository = auditRepository;
|
||||
this.queryService = queryService;
|
||||
this.hotelContextService = hotelContextService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认指定 V4 任务卡,并返回刷新后的订单任务详情。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public ReservationV4OrderTaskDetailResult confirmTaskCard(
|
||||
Long orderTaskId,
|
||||
Long cardId,
|
||||
ReservationV4CardConfirmRequest 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());
|
||||
validateCardConfirmable(orderTask, card);
|
||||
ensureNoPriorOrderTaskBlocking(orderTask);
|
||||
ensureBasicInformationConfirmed(orderTask, card);
|
||||
|
||||
String confirmedPayloadJson = confirmedPayloadJson(card, request == null ? null : request.confirmedPayload());
|
||||
String actorId = actorIdentifier(actor);
|
||||
boolean updated = workflowRepository.confirmTaskCardWithVersion(
|
||||
orderTask.hotelId(),
|
||||
card.id(),
|
||||
expectedVersion,
|
||||
confirmedPayloadJson,
|
||||
actorId,
|
||||
now);
|
||||
if (!updated) {
|
||||
handleCardConfirmRace(orderTask.hotelId(), card.id());
|
||||
}
|
||||
refreshOrderTaskStatus(orderTask, now);
|
||||
writeCardConfirmAudit(orderTask, card, actorId, confirmedPayloadJson, now);
|
||||
return queryService.getOrderTaskDetail(orderTask.hotelId(), orderTask.id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认 S10/S99 来源通知已读或已处理,并返回刷新后的来源通知详情。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public ReservationV4SourceNotificationDetailResult ackSourceNotification(
|
||||
Long notificationId,
|
||||
ReservationV4SourceNotificationAckRequest request,
|
||||
AuthenticatedUserContext actor) {
|
||||
Long expectedVersion = requireVersion(request == null ? null : request.version());
|
||||
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
|
||||
ReservationV4SourceNotificationSnapshot notification = requireSourceNotification(notificationId);
|
||||
requireHotelAccess(notification.hotelId());
|
||||
if (ReservationV4NotificationStatus.ACKED.name().equals(notification.notificationStatus())) {
|
||||
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
|
||||
}
|
||||
if (!ReservationV4NotificationStatus.ACK_REQUIRED.name().equals(notification.notificationStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_NOT_ACKABLE", "当前来源通知状态不允许确认。");
|
||||
}
|
||||
String actorId = actorIdentifier(actor);
|
||||
boolean updated = sourceNotificationRepository.updateNotificationStatusWithVersion(
|
||||
notification.hotelId(),
|
||||
notification.id(),
|
||||
expectedVersion,
|
||||
ReservationV4NotificationStatus.ACKED.name(),
|
||||
actorId,
|
||||
now);
|
||||
if (!updated) {
|
||||
ReservationV4SourceNotificationSnapshot latest = requireSourceNotification(notificationId);
|
||||
if (ReservationV4NotificationStatus.ACKED.name().equals(latest.notificationStatus())) {
|
||||
return queryService.getSourceNotificationDetail(latest.hotelId(), latest.id());
|
||||
}
|
||||
throw error(HttpStatus.CONFLICT, "V4_SOURCE_NOTIFICATION_VERSION_CONFLICT", "来源通知版本已变化,请刷新后重试。");
|
||||
}
|
||||
writeSourceNotificationAckAudit(notification, actorId, request == null ? null : request.reason(), now);
|
||||
return queryService.getSourceNotificationDetail(notification.hotelId(), notification.id());
|
||||
}
|
||||
|
||||
private void validateCardConfirmable(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card) {
|
||||
if (ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "来源邮件展示卡不允许确认。");
|
||||
}
|
||||
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_REVIEW_REQUIRED", "该卡片需要先完成人工复核。");
|
||||
}
|
||||
if (!ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_CARD_NOT_CONFIRMABLE", "当前卡片状态不允许确认。");
|
||||
}
|
||||
if (ReservationV4OrderTaskStatus.COMPLETED.name().equals(orderTask.orderTaskStatus())) {
|
||||
throw error(HttpStatus.CONFLICT, "V4_ORDER_TASK_ALREADY_COMPLETED", "该 V4 订单任务已经完成。");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureBasicInformationConfirmed(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card) {
|
||||
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
|
||||
return;
|
||||
}
|
||||
ReservationV4TaskCardSnapshot basicCard = workflowRepository.findTaskCardsByOrderTaskId(
|
||||
orderTask.hotelId(),
|
||||
orderTask.id()).stream()
|
||||
.filter(candidate -> ReservationV4CardType.BASIC_INFORMATION.name().equals(candidate.cardType()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_BASIC_INFORMATION_MISSING",
|
||||
"V4 订单任务缺少 Basic Information 卡。"));
|
||||
if (!ReservationV4CardStatus.CONFIRMED.name().equals(basicCard.cardStatus())) {
|
||||
throw error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_BASIC_INFORMATION_NOT_CONFIRMED",
|
||||
"Basic Information 卡未确认前,业务卡不可确认。");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureNoPriorOrderTaskBlocking(ReservationV4OrderTaskSnapshot orderTask) {
|
||||
if (orderTask.orderId() == null) {
|
||||
return;
|
||||
}
|
||||
List<ReservationV4OrderTaskSnapshot> sameOrderTasks = workflowRepository.findOrderTasksByOrderIds(
|
||||
orderTask.hotelId(),
|
||||
List.of(orderTask.orderId()));
|
||||
for (ReservationV4OrderTaskSnapshot candidate : sameOrderTasks) {
|
||||
if (Objects.equals(candidate.id(), orderTask.id())) {
|
||||
return;
|
||||
}
|
||||
if (!ReservationV4OrderTaskStatus.COMPLETED.name().equals(candidate.orderTaskStatus())) {
|
||||
throw error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_PRIOR_ORDER_TASK_NOT_COMPLETED",
|
||||
"同一本地订单下存在更早未完成的 V4 订单任务。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshOrderTaskStatus(ReservationV4OrderTaskSnapshot orderTask, LocalDateTime now) {
|
||||
List<ReservationV4TaskCardSnapshot> cards = workflowRepository.findTaskCardsByOrderTaskId(
|
||||
orderTask.hotelId(),
|
||||
orderTask.id());
|
||||
boolean hasUnfinishedCard = cards.stream()
|
||||
.filter(card -> !ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name().equals(card.cardType()))
|
||||
.anyMatch(card -> !ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus()));
|
||||
String nextStatus = hasUnfinishedCard
|
||||
? ReservationV4OrderTaskStatus.OPEN.name()
|
||||
: ReservationV4OrderTaskStatus.COMPLETED.name();
|
||||
if (!Objects.equals(orderTask.orderTaskStatus(), nextStatus)) {
|
||||
boolean updated = workflowRepository.updateOrderTaskStatus(orderTask.hotelId(), orderTask.id(), nextStatus, now);
|
||||
if (!updated) {
|
||||
throw error(
|
||||
HttpStatus.CONFLICT,
|
||||
"V4_ORDER_TASK_STATUS_REFRESH_FAILED",
|
||||
"V4 订单任务状态刷新失败,请刷新后重试。");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCardConfirmRace(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);
|
||||
}
|
||||
if (hasText(card.displayPayloadJson())) {
|
||||
return card.displayPayloadJson();
|
||||
}
|
||||
return "{}";
|
||||
}
|
||||
|
||||
private ReservationV4OrderTaskSnapshot requireOrderTask(Long orderTaskId) {
|
||||
return workflowRepository.findOrderTaskById(orderTaskId)
|
||||
.orElseThrow(() -> notFound("V4_ORDER_TASK_NOT_FOUND", "V4 订单任务不存在。"));
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardSnapshot requireTaskCard(String hotelId, Long cardId, Long orderTaskId) {
|
||||
ReservationV4TaskCardSnapshot card = workflowRepository.findTaskCardById(hotelId, cardId)
|
||||
.orElseThrow(() -> notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。"));
|
||||
if (!Objects.equals(orderTaskId, card.v4OrderTaskId())) {
|
||||
throw notFound("V4_TASK_CARD_NOT_FOUND", "V4 任务卡不存在。");
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private ReservationV4SourceNotificationSnapshot requireSourceNotification(Long notificationId) {
|
||||
return sourceNotificationRepository.findSourceNotificationById(notificationId)
|
||||
.orElseThrow(() -> notFound("V4_SOURCE_NOTIFICATION_NOT_FOUND", "V4 来源通知不存在。"));
|
||||
}
|
||||
|
||||
private void requireHotelAccess(String hotelId) {
|
||||
try {
|
||||
hotelContextService.requireAccessibleHotel(hotelId);
|
||||
} catch (HotelContextException exception) {
|
||||
throw new ReservationTaskWorkflowException(
|
||||
exception.getStatus(),
|
||||
exception.getErrorCode(),
|
||||
exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Long requireVersion(Long version) {
|
||||
if (version == null) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_REQUIRED", "请求必须携带当前 version。");
|
||||
}
|
||||
if (version < 0) {
|
||||
throw error(HttpStatus.BAD_REQUEST, "V4_VERSION_INVALID", "version 不能小于 0。");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
private String actorIdentifier(AuthenticatedUserContext actor) {
|
||||
if (actor == null) {
|
||||
return "unknown-user";
|
||||
}
|
||||
if (hasText(actor.username())) {
|
||||
return actor.username();
|
||||
}
|
||||
return actor.userId() == null ? "unknown-user" : actor.userId().toString();
|
||||
}
|
||||
|
||||
private void writeCardConfirmAudit(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot card,
|
||||
String actorId,
|
||||
String confirmedPayloadJson,
|
||||
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("version", card.version());
|
||||
beforeSnapshot.put("has_confirmed_payload", hasText(card.confirmedPayloadJson()));
|
||||
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("confirmed_by", actorId);
|
||||
afterSnapshot.put("confirmed_payload_keys", payloadFieldNames(confirmedPayloadJson));
|
||||
auditRepository.insertAuditLog(new ReservationAuditLogDraft(
|
||||
orderTask.hotelId(),
|
||||
orderTask.orderId(),
|
||||
null,
|
||||
null,
|
||||
ACTOR_TYPE_USER,
|
||||
actorId,
|
||||
ACTION_V4_CARD_CONFIRM,
|
||||
null,
|
||||
toJson(beforeSnapshot),
|
||||
toJson(afterSnapshot),
|
||||
now));
|
||||
}
|
||||
|
||||
private void writeSourceNotificationAckAudit(
|
||||
ReservationV4SourceNotificationSnapshot notification,
|
||||
String actorId,
|
||||
String reason,
|
||||
LocalDateTime now) {
|
||||
Map<String, Object> beforeSnapshot = new LinkedHashMap<>();
|
||||
beforeSnapshot.put("v4_source_notification_id", notification.id().toString());
|
||||
beforeSnapshot.put("route_code", notification.routeCode());
|
||||
beforeSnapshot.put("notification_status", notification.notificationStatus());
|
||||
beforeSnapshot.put("version", notification.version());
|
||||
Map<String, Object> afterSnapshot = new LinkedHashMap<>();
|
||||
afterSnapshot.put("v4_source_notification_id", notification.id().toString());
|
||||
afterSnapshot.put("route_code", notification.routeCode());
|
||||
afterSnapshot.put("notification_status", ReservationV4NotificationStatus.ACKED.name());
|
||||
afterSnapshot.put("ack_by", actorId);
|
||||
auditRepository.insertAuditLog(new ReservationAuditLogDraft(
|
||||
notification.hotelId(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ACTOR_TYPE_USER,
|
||||
actorId,
|
||||
ACTION_V4_SOURCE_NOTIFICATION_ACK,
|
||||
trimToNull(reason),
|
||||
toJson(beforeSnapshot),
|
||||
toJson(afterSnapshot),
|
||||
now));
|
||||
}
|
||||
|
||||
private List<String> payloadFieldNames(String json) {
|
||||
if (!hasText(json)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
if (node == null || !node.isObject()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> fieldNames = new ArrayList<>();
|
||||
Iterator<String> iterator = node.fieldNames();
|
||||
while (iterator.hasNext()) {
|
||||
fieldNames.add(iterator.next());
|
||||
}
|
||||
return fieldNames;
|
||||
} catch (Exception exception) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception exception) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private ReservationTaskWorkflowException notFound(String errorCode, String message) {
|
||||
return new ReservationTaskWorkflowException(HttpStatus.NOT_FOUND, errorCode, message);
|
||||
}
|
||||
|
||||
private ReservationTaskWorkflowException error(HttpStatus status, String errorCode, String message) {
|
||||
return new ReservationTaskWorkflowException(status, errorCode, message);
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return trimToNull(value) != null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -423,14 +423,14 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
}
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(),
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
"V4 卡片确认和复核写接口将在后续 checkpoint 开放。");
|
||||
null);
|
||||
}
|
||||
|
||||
private ReservationV4ActionAvailabilityResult cardAvailability(
|
||||
@@ -467,18 +467,29 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
if (ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())) {
|
||||
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
|
||||
}
|
||||
if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())
|
||||
|| ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
|
||||
if (ReservationV4CardStatus.PENDING_CONFIRM.name().equals(card.cardStatus())) {
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
if (ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(card.cardStatus())) {
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.COMMAND_API_PENDING.name(),
|
||||
ReservationV4ReadonlyReasonCode.REVIEW_API_PENDING.name(),
|
||||
null,
|
||||
null,
|
||||
"V4 卡片确认和复核写接口将在后续 checkpoint 开放。");
|
||||
"V4 人工复核写接口将在后续 checkpoint 开放。");
|
||||
}
|
||||
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
|
||||
}
|
||||
@@ -489,15 +500,15 @@ public class ReservationV4QueryServiceImpl implements ReservationV4QueryService
|
||||
return readOnlyAvailability(ReservationV4ReadonlyReasonCode.CARD_LOCKED.name(), null, null);
|
||||
}
|
||||
return new ReservationV4ActionAvailabilityResult(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ReservationV4ReadonlyReasonCode.ACK_API_PENDING.name(),
|
||||
ReservationV4ReadonlyReasonCode.PROCESSABLE.name(),
|
||||
null,
|
||||
null,
|
||||
"V4 来源通知确认写接口将在后续 checkpoint 开放。");
|
||||
null);
|
||||
}
|
||||
|
||||
private ReservationV4ActionAvailabilityResult readOnlyAvailability(
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package cn.nianxx.thhotel.workflows.reservation.control;
|
||||
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
|
||||
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
|
||||
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
|
||||
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
|
||||
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4OrderTaskSnapshot;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationDraft;
|
||||
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationV4SourceNotificationSnapshot;
|
||||
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.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.repository.ReservationV4SourceNotificationRepository;
|
||||
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:reservation_v4_command_controller_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"auth.bootstrap.admin.username=v4-command-admin",
|
||||
"auth.bootstrap.admin.password=Admin@123456",
|
||||
"auth.bootstrap.admin.display-name=V4命令管理员",
|
||||
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
|
||||
"auth.bootstrap.default-hotel-name=测试酒店",
|
||||
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
|
||||
"auth.session.ttl-minutes=720"
|
||||
})
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ReservationV4CommandControllerTest {
|
||||
|
||||
private static final String HOTEL_ID = "HOTEL-TEST";
|
||||
private static final String OTHER_HOTEL_ID = "HOTEL-OTHER";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private ReservationV4WorkflowRepository workflowRepository;
|
||||
|
||||
@Autowired
|
||||
private ReservationV4SourceNotificationRepository sourceNotificationRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformIdentityRepository identityRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformHotelRepository hotelRepository;
|
||||
|
||||
@Autowired
|
||||
private AuthPasswordService passwordService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
private String adminToken;
|
||||
private String noPermissionToken;
|
||||
|
||||
@BeforeEach
|
||||
void ensureNoPermissionUser() {
|
||||
PlatformUserEntity user = identityRepository.findUserByUsername("v4-command-no-permission")
|
||||
.orElseGet(() -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
PlatformUserEntity created = new PlatformUserEntity();
|
||||
created.setUsername("v4-command-no-permission");
|
||||
created.setPasswordHash(passwordService.hash("NoPerm@123456"));
|
||||
created.setDisplayName("V4 命令无权限用户");
|
||||
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
|
||||
created.setSuperAdmin(false);
|
||||
created.setPasswordChangedAt(now);
|
||||
created.setCreatedAt(now);
|
||||
created.setUpdatedAt(now);
|
||||
identityRepository.insertUser(created);
|
||||
return created;
|
||||
});
|
||||
hotelRepository.ensureUserHotel(user.getId(), HOTEL_ID, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectBusinessCardConfirmBeforeBasicInformation() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-basic-block-001",
|
||||
Instant.parse("2026-07-19T01:00:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"room_items": [{"room_type_code": "TWN", "room_count": 2}]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_BASIC_INFORMATION_NOT_CONFIRMED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldConfirmBasicInformationAndThenBusinessCard() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-confirm-001",
|
||||
Instant.parse("2026-07-19T01:10:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"company": "Q.B.D. TRAVEL GROUP CO., LTD",
|
||||
"group_code": "GRP-V4-COMMAND-001"
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED"))
|
||||
.andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin"))
|
||||
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.group_code")
|
||||
.value("GRP-V4-COMMAND-001"))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.read_only").value(false))
|
||||
.andExpect(content().string(not(org.hamcrest.Matchers.containsString("private.example.test"))));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.businessCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {
|
||||
"room_items": [{"room_type_code": "TWN", "room_count": 2}]
|
||||
}
|
||||
}
|
||||
"""))
|
||||
.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].confirmed_payload.room_items[0].room_type_code")
|
||||
.value("TWN"))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(false))
|
||||
.andExpect(jsonPath("$.business_cards[0].availability.readonly_reason_code").value("CARD_LOCKED"));
|
||||
assertAuditCount("V4_CARD_CONFIRM", "v4-command-admin", seeded.orderTask().id().toString(), 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRepeatedCardConfirm() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-repeat-001",
|
||||
Instant.parse("2026-07-19T01:20:00Z"));
|
||||
confirmBasicCard(seeded);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 1,
|
||||
"confirmed_payload": {"group_code": "SHOULD-NOT-REWRITE"}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCardConfirmWhenPriorOrderTaskIsOpen() throws Exception {
|
||||
Long sharedOrderId = 990000000000040001L;
|
||||
seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-prior-open-001",
|
||||
Instant.parse("2026-07-19T01:25:00Z"),
|
||||
sharedOrderId);
|
||||
SeededOrderTask later = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-prior-open-002",
|
||||
Instant.parse("2026-07-19T01:26:00Z"),
|
||||
sharedOrderId);
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
later.orderTask().id(),
|
||||
later.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.error_code").value("V4_PRIOR_ORDER_TASK_NOT_COMPLETED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAckS10SourceNotification() throws Exception {
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-s10-ack-001",
|
||||
"S10",
|
||||
Instant.parse("2026-07-19T01:30:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notification.notification_status").value("ACKED"))
|
||||
.andExpect(jsonPath("$.notification.ack_by").value("v4-command-admin"))
|
||||
.andExpect(jsonPath("$.source_message_card.card_status").value("ACKED"))
|
||||
.andExpect(jsonPath("$.availability.ackable").value(false))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("CARD_LOCKED"));
|
||||
assertAuditCount("V4_SOURCE_NOTIFICATION_ACK", "v4-command-admin", notification.id().toString(), 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV4CommandsWhenPermissionMissing() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-no-permission-001",
|
||||
Instant.parse("2026-07-19T01:40:00Z"));
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
HOTEL_ID,
|
||||
"mail-v4-command-no-permission-s10-001",
|
||||
"S10",
|
||||
Instant.parse("2026-07-19T01:41:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, noPermissionToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
performAuthorized(mockMvc, noPermissionToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectV4CommandsWhenHotelAccessDenied() throws Exception {
|
||||
SeededOrderTask seeded = seedOrderTask(
|
||||
OTHER_HOTEL_ID,
|
||||
"mail-v4-command-cross-hotel-001",
|
||||
Instant.parse("2026-07-19T01:50:00Z"));
|
||||
ReservationV4SourceNotificationSnapshot notification = seedSourceNotification(
|
||||
OTHER_HOTEL_ID,
|
||||
"mail-v4-command-cross-hotel-s10-001",
|
||||
"S99",
|
||||
Instant.parse("2026-07-19T01:51:00Z"));
|
||||
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/source-notifications/{notificationId}/ack",
|
||||
notification.id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"version": 0}
|
||||
"""))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 V4 命令管理员 token,测试通过真实登录链路覆盖前端鉴权。
|
||||
*/
|
||||
private String adminToken() throws Exception {
|
||||
if (adminToken == null) {
|
||||
adminToken = loginToken(mockMvc, "v4-command-admin", "Admin@123456");
|
||||
}
|
||||
return adminToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取没有 RESERVATION_TASK_CONFIRM 权限的普通用户 token。
|
||||
*/
|
||||
private String noPermissionToken() throws Exception {
|
||||
if (noPermissionToken == null) {
|
||||
noPermissionToken = loginToken(mockMvc, "v4-command-no-permission", "NoPerm@123456");
|
||||
}
|
||||
return noPermissionToken;
|
||||
}
|
||||
|
||||
private void confirmBasicCard(SeededOrderTask seeded) throws Exception {
|
||||
performAuthorized(mockMvc, adminToken(), post("/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/confirm",
|
||||
seeded.orderTask().id(),
|
||||
seeded.basicCard().id())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"version": 0,
|
||||
"confirmed_payload": {"group_code": "GRP-V4-COMMAND-001"}
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
private SeededOrderTask seedOrderTask(String hotelId, String externalMessageId, Instant receivedAt) {
|
||||
return seedOrderTask(hotelId, externalMessageId, receivedAt, null);
|
||||
}
|
||||
|
||||
private SeededOrderTask seedOrderTask(
|
||||
String hotelId,
|
||||
String externalMessageId,
|
||||
Instant receivedAt,
|
||||
Long orderId) {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
hotelId,
|
||||
externalMessageId,
|
||||
"V4 Command Business",
|
||||
receivedAt);
|
||||
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
|
||||
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
|
||||
hotelId,
|
||||
source.inboxId(),
|
||||
990000000000010001L + Math.abs(externalMessageId.hashCode()),
|
||||
"order-command-" + externalMessageId,
|
||||
1,
|
||||
orderId,
|
||||
"GROUP",
|
||||
"GROUP_CODE",
|
||||
"GRP-V4-COMMAND-001",
|
||||
ReservationV4TargetResolutionStatus.RESOLVED.name(),
|
||||
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 Business"}}
|
||||
""");
|
||||
ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId,
|
||||
ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
|
||||
{"card_type":"BASIC_INFORMATION","company":"Q.B.D. TRAVEL GROUP CO., LTD","group_code":"GRP-V4-COMMAND-001"}
|
||||
""");
|
||||
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
|
||||
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
|
||||
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
|
||||
{"card_type":"ROOM_INFORMATION","event_type":"NEW_BOOKING","room_items":[{"room_type_code":"TWN","room_count":2}]}
|
||||
""");
|
||||
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
|
||||
}
|
||||
|
||||
private ReservationV4SourceNotificationSnapshot seedSourceNotification(
|
||||
String hotelId,
|
||||
String externalMessageId,
|
||||
String routeCode,
|
||||
Instant receivedAt) {
|
||||
SourceMessageCaptureResult source = captureSourceMessage(
|
||||
hotelId,
|
||||
externalMessageId,
|
||||
"V4 Command Notification",
|
||||
receivedAt);
|
||||
return sourceNotificationRepository.findOrCreateSourceNotification(new ReservationV4SourceNotificationDraft(
|
||||
hotelId,
|
||||
source.inboxId(),
|
||||
990000000000020001L + Math.abs(externalMessageId.hashCode()),
|
||||
null,
|
||||
routeCode,
|
||||
ReservationV4NotificationStatus.ACK_REQUIRED.name(),
|
||||
"""
|
||||
{"route_code":"%s","source_message":{"body":"Sensitive raw notification body"}}
|
||||
""".formatted(routeCode),
|
||||
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
|
||||
LocalDateTime.ofInstant(receivedAt.plusSeconds(20), ZoneOffset.UTC)));
|
||||
}
|
||||
|
||||
private ReservationV4TaskCardSnapshot insertCard(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
String hotelId,
|
||||
String cardType,
|
||||
String eventType,
|
||||
Integer sourceEventIndex,
|
||||
Integer sortOrder,
|
||||
String cardStatus,
|
||||
String reviewStatus,
|
||||
String displayPayloadJson) {
|
||||
return workflowRepository.insertTaskCard(new ReservationV4TaskCardDraft(
|
||||
hotelId,
|
||||
orderTask.id(),
|
||||
orderTask.sourceMessageId(),
|
||||
eventType == null ? null : 990000000000030001L + sourceEventIndex,
|
||||
cardType,
|
||||
eventType,
|
||||
sourceEventIndex,
|
||||
sortOrder,
|
||||
cardStatus,
|
||||
reviewStatus,
|
||||
"""
|
||||
{"private_url":"https://private.example.test/raw","raw":"must stay internal"}
|
||||
""",
|
||||
displayPayloadJson,
|
||||
null,
|
||||
orderTask.createdAt()));
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(
|
||||
String hotelId,
|
||||
String externalMessageId,
|
||||
String subject,
|
||||
Instant receivedAt) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
hotelId,
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-v4-command",
|
||||
receivedAt,
|
||||
null,
|
||||
"guest@example.test",
|
||||
subject,
|
||||
"Please handle V4 command message.",
|
||||
"<html><body>Please handle V4 command message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private void assertAuditCount(String action, String actorId, String snapshotFragment, int expectedCount) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_audit_log
|
||||
WHERE hotel_id = ?
|
||||
AND action = ?
|
||||
AND actor_id = ?
|
||||
AND after_snapshot_json LIKE ?
|
||||
""",
|
||||
Integer.class,
|
||||
HOTEL_ID,
|
||||
action,
|
||||
actorId,
|
||||
"%" + snapshotFragment + "%");
|
||||
org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount);
|
||||
}
|
||||
|
||||
private record SeededOrderTask(
|
||||
ReservationV4OrderTaskSnapshot orderTask,
|
||||
ReservationV4TaskCardSnapshot sourceCard,
|
||||
ReservationV4TaskCardSnapshot basicCard,
|
||||
ReservationV4TaskCardSnapshot businessCard
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -243,8 +243,9 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(jsonPath("$.business_cards[0].card_type").value("ROOM_INFORMATION"))
|
||||
.andExpect(jsonPath("$.business_cards[0].display_payload.event_type").value("NEW_BOOKING"))
|
||||
.andExpect(jsonPath("$.business_cards[0].ai_payload_json").doesNotExist())
|
||||
.andExpect(jsonPath("$.availability.read_only").value(true))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("COMMAND_API_PENDING"))
|
||||
.andExpect(jsonPath("$.availability.read_only").value(false))
|
||||
.andExpect(jsonPath("$.availability.confirmable").value(true))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE"))
|
||||
.andExpect(jsonPath("$.source_message_summary.subject").value("V4 Query Business"))
|
||||
.andExpect(content().string(not(containsString("https://private.example.test"))));
|
||||
}
|
||||
@@ -266,8 +267,9 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(jsonPath("$.items[0].order_task.display_order_key").value("GRP-V4-QUERY-001"))
|
||||
.andExpect(jsonPath("$.items[0].card_counts.pending_confirm_count").value(2))
|
||||
.andExpect(jsonPath("$.items[0].display_status").value("OPEN"))
|
||||
.andExpect(jsonPath("$.items[0].availability.read_only").value(true))
|
||||
.andExpect(jsonPath("$.items[0].availability.readonly_reason_code").value("COMMAND_API_PENDING"))
|
||||
.andExpect(jsonPath("$.items[0].availability.read_only").value(false))
|
||||
.andExpect(jsonPath("$.items[0].availability.confirmable").value(true))
|
||||
.andExpect(jsonPath("$.items[0].availability.readonly_reason_code").value("PROCESSABLE"))
|
||||
.andExpect(jsonPath("$.page.total").value(1));
|
||||
}
|
||||
|
||||
@@ -326,8 +328,9 @@ class ReservationV4QueryControllerTest {
|
||||
.andExpect(jsonPath("$.source_message_card.card_type").value("SOURCE_MESSAGE_NOTIFICATION"))
|
||||
.andExpect(jsonPath("$.conversation_summary.source_message_id")
|
||||
.value(notification.sourceMessageId().toString()))
|
||||
.andExpect(jsonPath("$.availability.ackable").value(false))
|
||||
.andExpect(jsonPath("$.availability.read_only").value(true))
|
||||
.andExpect(jsonPath("$.availability.ackable").value(true))
|
||||
.andExpect(jsonPath("$.availability.read_only").value(false))
|
||||
.andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE"))
|
||||
.andExpect(jsonPath("$.raw_payload_json").doesNotExist())
|
||||
.andExpect(content().string(not(containsString("Sensitive raw notification body"))));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user