补齐订单详情V4总览接口

This commit is contained in:
andy
2026-07-20 14:38:09 +07:00
parent b50e004b06
commit 8f893991dd
14 changed files with 659 additions and 40 deletions

View File

@@ -7,12 +7,21 @@ import java.util.List;
* 前端订单详情响应包含订单摘要、旧任务时间线、V4 订单任务时间线和非阻塞警告。
*
* @param order 订单摘要
* @param orderOverview V4 当前订单快照摘要
* @param nextV4Action V4 下一步处理入口
* @param relatedSourceMessages 订单关联来源邮件摘要
* @param tasks 同订单旧任务时间线
* @param v4OrderTasks 同订单 V4 订单任务时间线
* @param warnings 当前无法提供的扩展信息或非阻塞提醒
*/
public record ReservationOrderDetailResult(
ReservationOrderSummaryResult order,
@JsonProperty("order_overview")
ReservationOrderV4OverviewResult orderOverview,
@JsonProperty("next_v4_action")
ReservationOrderV4NextActionResult nextV4Action,
@JsonProperty("related_source_messages")
List<ReservationV4SourceMessageSummaryResult> relatedSourceMessages,
List<ReservationOrderTaskTimelineItemResult> tasks,
@JsonProperty("v4_order_tasks")
List<ReservationOrderV4TaskTimelineItemResult> v4OrderTasks,

View File

@@ -0,0 +1,26 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 订单详情页 V4 下一步处理入口摘要。前端用它跳转订单任务详情,不在订单详情页直接处理卡片。
*
* @param orderTaskId 下一步 V4 订单任务 ID
* @param cardId 下一步待处理任务卡 ID
* @param actionType 下一步动作类型CONFIRM / REVIEW / NONE
* @param actionStatus 下一步卡片状态PENDING_CONFIRM / REVIEW_REQUIRED
* @param openOrderTaskCount 当前订单下未完成 V4 订单任务数量
*/
public record ReservationOrderV4NextActionResult(
@JsonProperty("order_task_id")
String orderTaskId,
@JsonProperty("card_id")
String cardId,
@JsonProperty("action_type")
String actionType,
@JsonProperty("action_status")
String actionStatus,
@JsonProperty("open_order_task_count")
Integer openOrderTaskCount
) {
}

View File

@@ -0,0 +1,49 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 订单详情页 V4 当前快照摘要。只从已确认 V4 卡片派生,不把未确认 AI 建议当作订单事实。
*
* @param accountCode Account 目录 code
* @param accountName Account 展示名称
* @param marketCode Market 目录 code
* @param sourceCode Source 目录 code
* @param arrivalDate 入住酒店本地日期
* @param departureDate 离店酒店本地日期
* @param rateCode Rate Code
* @param roomItems 已确认房型房量摘要
* @param traceCardStatus Trace 卡片当前状态
* @param roomingListCardStatus Rooming List 卡片当前状态
* @param paymentCardStatus Payment 卡片当前状态
* @param latestConfirmedAt 最近一次 V4 卡片确认 UTC 时间
*/
public record ReservationOrderV4OverviewResult(
@JsonProperty("account_code")
String accountCode,
@JsonProperty("account_name")
String accountName,
@JsonProperty("market_code")
String marketCode,
@JsonProperty("source_code")
String sourceCode,
@JsonProperty("arrival_date")
String arrivalDate,
@JsonProperty("departure_date")
String departureDate,
@JsonProperty("rate_code")
String rateCode,
@JsonProperty("room_items")
List<ReservationOrderV4RoomSummaryResult> roomItems,
@JsonProperty("trace_card_status")
String traceCardStatus,
@JsonProperty("rooming_list_card_status")
String roomingListCardStatus,
@JsonProperty("payment_card_status")
String paymentCardStatus,
@JsonProperty("latest_confirmed_at")
OffsetDateTime latestConfirmedAt
) {
}

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 订单详情页 V4 房型房量摘要。
*
* @param roomTypeCode 房型目录 code
* @param roomCount 房量
*/
public record ReservationOrderV4RoomSummaryResult(
@JsonProperty("room_type_code")
String roomTypeCode,
@JsonProperty("room_count")
Integer roomCount
) {
}

View File

@@ -0,0 +1,42 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
/**
* 订单详情页 V4 时间线中的任务卡摘要。只返回状态和确认信息,不返回卡片业务字段。
*
* @param cardId V4 任务卡 ID
* @param cardType 卡片类型
* @param eventType 业务事件类型
* @param sourceEventIndex 来源事件序号
* @param cardSortOrder 卡片排序号
* @param cardStatus 卡片状态
* @param reviewStatus 复核状态
* @param confirmedBy 确认人
* @param confirmedAt 确认 UTC 时间
* @param latestActivityAt 卡片最新活动 UTC 时间
*/
public record ReservationOrderV4TaskCardTimelineItemResult(
@JsonProperty("card_id")
String cardId,
@JsonProperty("card_type")
String cardType,
@JsonProperty("event_type")
String eventType,
@JsonProperty("source_event_index")
Integer sourceEventIndex,
@JsonProperty("card_sort_order")
Integer cardSortOrder,
@JsonProperty("card_status")
String cardStatus,
@JsonProperty("review_status")
String reviewStatus,
@JsonProperty("confirmed_by")
String confirmedBy,
@JsonProperty("confirmed_at")
OffsetDateTime confirmedAt,
@JsonProperty("latest_activity_at")
OffsetDateTime latestActivityAt
) {
}

View File

@@ -2,6 +2,7 @@ package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.OffsetDateTime;
import java.util.List;
/**
* 订单详情页 V4 订单任务时间线单项。用于旧订单详情页补充展示 V4 多卡任务摘要。
@@ -10,6 +11,7 @@ import java.time.OffsetDateTime;
* @param orderRef V4 回调包内订单引用
* @param orderTaskStatus V4 订单任务状态
* @param cardCounts V4 卡片数量摘要
* @param cards V4 任务卡时间线摘要
* @param sourceMessageSummary 来源邮件安全摘要
* @param sourceReceivedAt 来源邮件接收 UTC 时间
* @param createdAt V4 订单任务创建 UTC 时间
@@ -25,6 +27,7 @@ public record ReservationOrderV4TaskTimelineItemResult(
String orderTaskStatus,
@JsonProperty("card_counts")
ReservationV4CardCountsResult cardCounts,
List<ReservationOrderV4TaskCardTimelineItemResult> cards,
@JsonProperty("source_message_summary")
ReservationV4SourceMessageSummaryResult sourceMessageSummary,
@JsonProperty("source_received_at")

View File

@@ -25,6 +25,10 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderLis
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderListResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderTaskTimelineItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4NextActionResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4OverviewResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4RoomSummaryResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4TaskCardTimelineItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderV4TaskTimelineItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationPaginationResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult;
@@ -35,6 +39,10 @@ import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationV4Source
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
@@ -64,6 +72,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
private final SourceMessageQueryService sourceMessageQueryService;
private final ReservationTaskAvailabilityResolver availabilityResolver;
private final HotelContextService hotelContextService;
private final ObjectMapper objectMapper;
/**
* 注入持久化边界、SourceMessage 安全摘要服务和可处理状态解析器。
@@ -73,12 +82,14 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
ReservationV4WorkflowRepository v4WorkflowRepository,
SourceMessageQueryService sourceMessageQueryService,
ReservationTaskAvailabilityResolver availabilityResolver,
HotelContextService hotelContextService) {
HotelContextService hotelContextService,
ObjectMapper objectMapper) {
this.workflowRepository = workflowRepository;
this.v4WorkflowRepository = v4WorkflowRepository;
this.sourceMessageQueryService = sourceMessageQueryService;
this.availabilityResolver = availabilityResolver;
this.hotelContextService = hotelContextService;
this.objectMapper = objectMapper;
}
/**
@@ -190,10 +201,19 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
availabilityOrReadOnly(task, availabilityByTaskId),
sourceContextsById.get(task.sourceMessageId())))
.toList();
List<ReservationOrderV4TaskTimelineItemResult> v4OrderTasks = Boolean.FALSE.equals(includeTasks)
? List.of()
: findV4OrderTaskTimeline(orderHotelId, order.id());
return new ReservationOrderDetailResult(toOrderSummary(order), tasks, v4OrderTasks, List.of());
V4OrderDetailContext v4Context = Boolean.FALSE.equals(includeTasks)
? V4OrderDetailContext.empty()
: findV4OrderDetailContext(orderHotelId, order.id());
List<ReservationOrderV4TaskTimelineItemResult> v4OrderTasks = toV4TimelineItems(v4Context);
V4OrderListNextAction v4NextAction = toV4OrderListNextAction(v4Context.orderTasks(), v4Context.cardsByOrderTaskId());
return new ReservationOrderDetailResult(
toOrderSummary(order),
toV4OrderOverview(v4Context.orderTasks(), v4Context.cardsByOrderTaskId()),
toV4NextActionResult(v4NextAction),
relatedV4SourceMessages(v4Context),
tasks,
v4OrderTasks,
List.of());
}
/**
@@ -328,12 +348,12 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
/**
* 查询订单详情页 V4 订单任务时间线,复用当前订单酒店作为对象级隔离边界。
*/
private List<ReservationOrderV4TaskTimelineItemResult> findV4OrderTaskTimeline(String hotelId, Long orderId) {
private V4OrderDetailContext findV4OrderDetailContext(String hotelId, Long orderId) {
List<ReservationV4OrderTaskSnapshot> orderTasks = v4WorkflowRepository.findOrderTasksByOrderIds(
hotelId,
List.of(orderId));
if (orderTasks.isEmpty()) {
return List.of();
return V4OrderDetailContext.empty();
}
Map<Long, List<ReservationV4TaskCardSnapshot>> cardsByOrderTaskId = findV4TaskCardsByOrderTaskId(
hotelId,
@@ -341,11 +361,18 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
Map<Long, ReservationV4SourceMessageSummaryResult> sourceSummariesById = findV4SourceSummariesById(
hotelId,
orderTasks.stream().map(ReservationV4OrderTaskSnapshot::sourceMessageId).toList());
return orderTasks.stream()
return new V4OrderDetailContext(orderTasks, cardsByOrderTaskId, sourceSummariesById);
}
/**
* 转换订单详情页 V4 时间线列表。
*/
private List<ReservationOrderV4TaskTimelineItemResult> toV4TimelineItems(V4OrderDetailContext context) {
return context.orderTasks().stream()
.map(orderTask -> toV4TimelineItem(
orderTask,
cardsByOrderTaskId.getOrDefault(orderTask.id(), List.of()),
sourceSummariesById.get(orderTask.sourceMessageId())))
context.cardsByOrderTaskId().getOrDefault(orderTask.id(), List.of()),
context.sourceSummariesById().get(orderTask.sourceMessageId())))
.toList();
}
@@ -511,6 +538,7 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
orderTask.orderRef(),
orderTask.orderTaskStatus(),
v4CardCounts(cards),
toV4CardTimelineItems(cards),
sourceSummary,
UtcTimeFormatter.toUtcOffsetDateTime(orderTask.sourceReceivedAt()),
UtcTimeFormatter.toUtcOffsetDateTime(orderTask.createdAt()),
@@ -518,6 +546,209 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
UtcTimeFormatter.toUtcOffsetDateTime(latestV4ActivityAt(orderTask, cards)));
}
/**
* 转换 V4 任务卡为订单详情时间线摘要,不返回业务字段 payload。
*/
private List<ReservationOrderV4TaskCardTimelineItemResult> toV4CardTimelineItems(
List<ReservationV4TaskCardSnapshot> cards) {
return (cards == null ? List.<ReservationV4TaskCardSnapshot>of() : cards).stream()
.map(card -> new ReservationOrderV4TaskCardTimelineItemResult(
card.id().toString(),
card.cardType(),
card.eventType(),
card.sourceEventIndex(),
card.cardSortOrder(),
card.cardStatus(),
card.reviewStatus(),
card.confirmedBy(),
UtcTimeFormatter.toUtcOffsetDateTime(card.confirmedAt()),
UtcTimeFormatter.toUtcOffsetDateTime(card.updatedAt() == null
? card.createdAt()
: card.updatedAt())))
.toList();
}
/**
* 从已确认 V4 卡片中派生订单详情当前快照;未确认 AI 建议不进入订单事实展示。
*/
private ReservationOrderV4OverviewResult toV4OrderOverview(
List<ReservationV4OrderTaskSnapshot> orderTasks,
Map<Long, List<ReservationV4TaskCardSnapshot>> cardsByOrderTaskId) {
V4OrderOverviewDraft draft = new V4OrderOverviewDraft();
for (ReservationV4OrderTaskSnapshot orderTask : orderTasks == null
? List.<ReservationV4OrderTaskSnapshot>of()
: orderTasks) {
for (ReservationV4TaskCardSnapshot card : cardsByOrderTaskId.getOrDefault(orderTask.id(), List.of())) {
applyV4CardStatus(draft, card);
applyConfirmedV4CardSnapshot(draft, card);
}
}
return new ReservationOrderV4OverviewResult(
draft.accountCode,
draft.accountName,
draft.marketCode,
draft.sourceCode,
draft.arrivalDate,
draft.departureDate,
draft.rateCode,
draft.roomItems,
draft.traceCardStatus,
draft.roomingListCardStatus,
draft.paymentCardStatus,
UtcTimeFormatter.toUtcOffsetDateTime(draft.latestConfirmedAt));
}
/**
* 提取任务卡状态到订单详情总览,帮助前端展示订单下 Trace / Rooming List / Payment 是否仍待处理。
*/
private void applyV4CardStatus(V4OrderOverviewDraft draft, ReservationV4TaskCardSnapshot card) {
if (ReservationV4CardType.TRACE_RESERVATION_NOTES.name().equals(card.cardType())) {
draft.traceCardStatus = card.cardStatus();
} else if (ReservationV4CardType.ROOMING_LIST.name().equals(card.cardType())) {
draft.roomingListCardStatus = card.cardStatus();
} else if (ReservationV4CardType.PAYMENT.name().equals(card.cardType())) {
draft.paymentCardStatus = card.cardStatus();
}
}
/**
* 从单张已确认 V4 卡片抽取订单总览字段,后出现的确认卡覆盖先前同字段。
*/
private void applyConfirmedV4CardSnapshot(V4OrderOverviewDraft draft, ReservationV4TaskCardSnapshot card) {
if (!ReservationV4CardStatus.CONFIRMED.name().equals(card.cardStatus())
|| trimToNull(card.confirmedPayloadJson()) == null) {
return;
}
JsonNode payload = readJsonOrEmpty(card.confirmedPayloadJson());
if (ReservationV4CardType.BASIC_INFORMATION.name().equals(card.cardType())) {
JsonNode basicInformation = objectOrSelf(payload, "basic_information");
draft.accountCode = coalesceText(basicInformation, "account_code", draft.accountCode);
draft.accountName = coalesceText(basicInformation, "account_name", draft.accountName);
draft.marketCode = coalesceText(basicInformation, "market_code", draft.marketCode);
draft.sourceCode = coalesceText(basicInformation, "source_code", draft.sourceCode);
} else if (ReservationV4CardType.ROOM_INFORMATION.name().equals(card.cardType())) {
JsonNode businessFields = businessFields(payload);
draft.arrivalDate = coalesceText(businessFields, "arrival_date", draft.arrivalDate);
draft.departureDate = coalesceText(businessFields, "departure_date", draft.departureDate);
draft.rateCode = coalesceText(businessFields, "rate_code", draft.rateCode);
if (businessFields.path("room_items").isArray()) {
draft.roomItems = roomSummaries(businessFields.path("room_items"));
}
}
if (card.confirmedAt() != null
&& (draft.latestConfirmedAt == null || card.confirmedAt().isAfter(draft.latestConfirmedAt))) {
draft.latestConfirmedAt = card.confirmedAt();
}
}
/**
* 转换订单详情页 V4 下一步入口结果。
*/
private ReservationOrderV4NextActionResult toV4NextActionResult(V4OrderListNextAction action) {
V4OrderListNextAction safeAction = action == null ? V4OrderListNextAction.none() : action;
return new ReservationOrderV4NextActionResult(
safeAction.orderTaskId(),
safeAction.cardId(),
safeAction.actionType(),
safeAction.actionStatus(),
safeAction.openOrderTaskCount());
}
/**
* 生成订单关联来源邮件列表,按 V4 订单任务时间线去重。
*/
private List<ReservationV4SourceMessageSummaryResult> relatedV4SourceMessages(V4OrderDetailContext context) {
Map<String, ReservationV4SourceMessageSummaryResult> related = new LinkedHashMap<>();
for (ReservationV4OrderTaskSnapshot orderTask : context.orderTasks()) {
ReservationV4SourceMessageSummaryResult summary = context.sourceSummariesById()
.get(orderTask.sourceMessageId());
if (summary != null && summary.sourceMessageId() != null) {
related.putIfAbsent(summary.sourceMessageId(), summary);
}
}
return List.copyOf(related.values());
}
/**
* 读取业务字段主体Update Booking 的 after 结构优先作为订单当前快照来源。
*/
private JsonNode businessFields(JsonNode payload) {
JsonNode businessFields = objectOrSelf(payload, "business_fields");
JsonNode after = businessFields.path("after");
return after.isObject() ? after : businessFields;
}
/**
* 读取对象字段;字段不存在或非对象时返回原节点,兼容测试和早期确认 payload。
*/
private JsonNode objectOrSelf(JsonNode node, String fieldName) {
if (node == null || node.isMissingNode() || node.isNull()) {
return objectMapper.createObjectNode();
}
JsonNode child = node.path(fieldName);
return child.isObject() ? child : node;
}
/**
* JSON 字符串解析兜底,避免脏历史数据让订单详情整体失败。
*/
private JsonNode readJsonOrEmpty(String json) {
if (trimToNull(json) == null) {
return objectMapper.createObjectNode();
}
try {
return objectMapper.readTree(json);
} catch (JsonProcessingException ex) {
return objectMapper.createObjectNode();
}
}
/**
* 读取文本字段,空值保留既有快照值。
*/
private String coalesceText(JsonNode node, String fieldName, String fallback) {
String value = trimToNull(node.path(fieldName).asText(null));
return value == null ? fallback : value;
}
/**
* 转换已确认 room_items[] 为订单页安全摘要。
*/
private List<ReservationOrderV4RoomSummaryResult> roomSummaries(JsonNode roomItemsNode) {
List<ReservationOrderV4RoomSummaryResult> roomItems = new ArrayList<>();
for (JsonNode roomItem : roomItemsNode) {
String roomTypeCode = coalesceText(roomItem, "room_type_code", null);
if (roomTypeCode == null) {
roomTypeCode = coalesceText(roomItem, "pms_room_type_code", null);
}
Integer roomCount = integerAt(roomItem, "room_count");
if (roomCount == null) {
roomCount = integerAt(roomItem, "room_quantity");
}
roomItems.add(new ReservationOrderV4RoomSummaryResult(roomTypeCode, roomCount));
}
return List.copyOf(roomItems);
}
/**
* 宽松读取整数字段,兼容数字和字符串数字。
*/
private Integer integerAt(JsonNode node, String fieldName) {
JsonNode value = node.path(fieldName);
if (value.isInt() || value.isLong()) {
return value.asInt();
}
String text = trimToNull(value.asText(null));
if (text == null) {
return null;
}
try {
return Integer.valueOf(text);
} catch (NumberFormatException ex) {
return null;
}
}
/**
* 统计 V4 订单任务下各状态卡片数量,供订单详情时间线轻量展示。
*/
@@ -974,6 +1205,37 @@ public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQ
Long conversationMessageCount) {
}
/**
* 订单详情页 V4 聚合上下文,避免同一个接口为时间线、总览和下一步入口重复查库。
*/
private record V4OrderDetailContext(
List<ReservationV4OrderTaskSnapshot> orderTasks,
Map<Long, List<ReservationV4TaskCardSnapshot>> cardsByOrderTaskId,
Map<Long, ReservationV4SourceMessageSummaryResult> sourceSummariesById) {
private static V4OrderDetailContext empty() {
return new V4OrderDetailContext(List.of(), Map.of(), Map.of());
}
}
/**
* V4 订单总览派生草稿。仅在 Service 内部按时间线顺序覆盖字段,不向外暴露。
*/
private static final class V4OrderOverviewDraft {
private String accountCode;
private String accountName;
private String marketCode;
private String sourceCode;
private String arrivalDate;
private String departureDate;
private String rateCode;
private List<ReservationOrderV4RoomSummaryResult> roomItems = List.of();
private String traceCardStatus;
private String roomingListCardStatus;
private String paymentCardStatus;
private LocalDateTime latestConfirmedAt;
}
/**
* 订单列表中一个本地订单对应的 V4 下一步处理摘要。
*/

View File

@@ -512,6 +512,111 @@ class ReservationFrontendQueryControllerTest {
.andExpect(jsonPath("$.v4_order_tasks[1].source_received_at").value("2026-07-08T09:00:00Z"));
}
@Test
void shouldReturnOrderDetailWithV4OverviewNextActionAndCardTimeline() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
"mail-frontend-order-detail-v4-overview-001",
"Frontend V4 Overview",
Instant.parse("2026-07-08T11:00:00Z"));
Long orderId = 930000000000005001L;
insertActiveGroupOrder(orderId, source.inboxId(), "GRP-FRONTEND-V4-OVERVIEW-001");
ReservationV4OrderTaskSnapshot orderTask = insertV4OrderTask(
930000000000005101L,
source,
orderId,
"order-overview",
"GRP-FRONTEND-V4-OVERVIEW-001",
Instant.parse("2026-07-08T11:00:00Z"),
HOTEL_ID);
insertV4TaskCard(orderTask, ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
ReservationV4CardStatus.READONLY.name(), null, "{}");
ReservationV4TaskCardSnapshot basicCard = insertV4TaskCard(
orderTask, ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
ReservationV4CardStatus.CONFIRMED.name(), null, """
{"basic_information":{"account_code":"QBD_TRAVEL"}}
""");
confirmV4TaskCard(basicCard.id(), """
{
"basic_information": {
"account_code": "QBD_TRAVEL",
"account_name": "Q.B.D. TRAVEL GROUP CO., LTD",
"market_code": "LEISURE",
"source_code": "TRAVEL_AGENT"
}
}
""", "frontend-query-admin", Instant.parse("2026-07-08T11:10:00Z"));
ReservationV4TaskCardSnapshot roomCard = insertV4TaskCard(
orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
ReservationV4CardStatus.CONFIRMED.name(), null, "{}");
confirmV4TaskCard(roomCard.id(), """
{
"business_fields": {
"arrival_date": "2026-07-26",
"departure_date": "2026-07-29",
"rate_code": "BAR",
"room_items": [
{"room_type_code": "RM1", "room_count": 2},
{"room_type_code": "RM2", "room_count": 1}
]
}
}
""", "frontend-query-admin", Instant.parse("2026-07-08T11:20:00Z"));
insertV4TaskCard(orderTask, ReservationV4CardType.ROOM_INFORMATION.name(), "UPDATE_BOOKING", 2, 40,
ReservationV4CardStatus.PENDING_CONFIRM.name(), null, """
{
"business_fields": {
"arrival_date": "2099-01-01",
"departure_date": "2099-01-02",
"rate_code": "SHOULD_NOT_BE_ORDER_FACT",
"room_items": [
{"room_type_code": "SHOULD_NOT_APPEAR", "room_count": 99}
]
}
}
""");
ReservationV4TaskCardSnapshot paymentCard = insertV4TaskCard(
orderTask, ReservationV4CardType.PAYMENT.name(), "PAYMENT", 3, 60,
ReservationV4CardStatus.REVIEW_REQUIRED.name(), "PENDING", "{}");
performAuthorized(mockMvc, adminToken(), get("/api/reservation/orders/{orderId}", orderId)
.param("hotel_id", HOTEL_ID)
.param("include_tasks", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_overview.account_code").value("QBD_TRAVEL"))
.andExpect(jsonPath("$.order_overview.account_name").value("Q.B.D. TRAVEL GROUP CO., LTD"))
.andExpect(jsonPath("$.order_overview.market_code").value("LEISURE"))
.andExpect(jsonPath("$.order_overview.source_code").value("TRAVEL_AGENT"))
.andExpect(jsonPath("$.order_overview.arrival_date").value("2026-07-26"))
.andExpect(jsonPath("$.order_overview.departure_date").value("2026-07-29"))
.andExpect(jsonPath("$.order_overview.rate_code").value("BAR"))
.andExpect(jsonPath("$.order_overview.room_items.length()").value(2))
.andExpect(jsonPath("$.order_overview.room_items[0].room_type_code").value("RM1"))
.andExpect(jsonPath("$.order_overview.room_items[0].room_count").value(2))
.andExpect(jsonPath("$.order_overview.room_items[?(@.room_type_code=='SHOULD_NOT_APPEAR')]")
.isEmpty())
.andExpect(jsonPath("$.order_overview.payment_card_status").value("REVIEW_REQUIRED"))
.andExpect(jsonPath("$.order_overview.latest_confirmed_at").value("2026-07-08T11:20:00Z"))
.andExpect(jsonPath("$.next_v4_action.order_task_id").value(orderTask.id().toString()))
.andExpect(jsonPath("$.next_v4_action.card_id").value(paymentCard.id().toString()))
.andExpect(jsonPath("$.next_v4_action.action_type").value("REVIEW"))
.andExpect(jsonPath("$.next_v4_action.action_status").value("REVIEW_REQUIRED"))
.andExpect(jsonPath("$.next_v4_action.open_order_task_count").value(1))
.andExpect(jsonPath("$.related_source_messages.length()").value(1))
.andExpect(jsonPath("$.related_source_messages[0].source_message_id").value(source.inboxId().toString()))
.andExpect(jsonPath("$.v4_order_tasks[0].cards.length()").value(5))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].card_id").value(basicCard.id().toString()))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].card_type").value("BASIC_INFORMATION"))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].confirmed_by").value("frontend-query-admin"))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].confirmed_at").value("2026-07-08T11:10:00Z"))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].ai_payload_json").doesNotExist())
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].display_payload_json").doesNotExist())
.andExpect(jsonPath("$.v4_order_tasks[0].cards[1].confirmed_payload_json").doesNotExist())
.andExpect(jsonPath("$.v4_order_tasks[0].cards[4].card_id").value(paymentCard.id().toString()))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[4].card_status").value("REVIEW_REQUIRED"))
.andExpect(jsonPath("$.v4_order_tasks[0].cards[4].review_status").value("PENDING"));
}
@Test
void shouldHideV4OrderTaskTimelineWhenIncludeTasksFalseAndIgnoreCrossHotelRows() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
@@ -853,6 +958,20 @@ class ReservationFrontendQueryControllerTest {
""", Timestamp.valueOf(LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC)), orderTaskId, cardSortOrder);
}
private void confirmV4TaskCard(Long cardId, String confirmedPayloadJson, String confirmedBy, Instant confirmedAt) {
jdbcTemplate.update("""
UPDATE workflow_reservation_v4_task_card
SET confirmed_payload_json = ?,
confirmed_by = ?,
confirmed_at = ?,
updated_at = ?
WHERE id = ?
""", confirmedPayloadJson, confirmedBy,
Timestamp.valueOf(LocalDateTime.ofInstant(confirmedAt, ZoneOffset.UTC)),
Timestamp.valueOf(LocalDateTime.ofInstant(confirmedAt, ZoneOffset.UTC)),
cardId);
}
private ReservationV4OrderTaskSnapshot insertV4OrderTask(
Long aiBatchId,
SourceMessageCaptureResult source,