实现 V4 卡片确认和来源通知确认

This commit is contained in:
andy
2026-07-19 10:40:23 +07:00
parent 9404a0e5c1
commit fdb16cc4c1
19 changed files with 1190 additions and 55 deletions

View File

@@ -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
}

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
/**
* Reservation V4 卡片确认请求。confirmed_payload 为空时后端使用当前展示 payload 作为确认快照。
*
* @param version 前端读取到的卡片乐观锁版本
* @param confirmedPayload 用户确认后的卡片 payload
*/
public record ReservationV4CardConfirmRequest(
Long version,
@JsonProperty("confirmed_payload")
JsonNode confirmedPayload
) {
}

View File

@@ -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
) {
}

View File

@@ -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 当前是否只读

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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(),

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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(

View File

@@ -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
) {
}
}

View File

@@ -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"))));
}