实现前端P0订单任务查询接口

This commit is contained in:
andy
2026-07-08 14:18:57 +08:00
parent b58c6baafe
commit 936a5921b0
29 changed files with 1582 additions and 56 deletions

View File

@@ -1,15 +1,15 @@
package cn.nianxx.thhotel.platform.message.repository;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxDraft;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalAccessAuditDraft;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageBodyContentType;
import cn.nianxx.thhotel.platform.message.common.enums.SourceMessageCaptureStatus;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.domain.SourceMessageBodyEntity;
import cn.nianxx.thhotel.platform.message.domain.SourceMessageInboxEntity;
import cn.nianxx.thhotel.platform.message.domain.SourceMessageMediaEntity;
@@ -112,6 +112,34 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe
return new SourceMessagePageResult<>(items, page.getTotal(), pageNum, pageSize);
}
/**
* 按安全摘要关键词查询 SourceMessage ID。这里只查询列表可展示字段不触碰正文、HTML、附件 URL 或 payload。
*/
@Override
public List<Long> findIdsBySafeKeyword(String hotelId, String keyword, int limit) {
if (!hasText(keyword) || limit < 1) {
return List.of();
}
String trimmedKeyword = trim(keyword);
Page<SourceMessageInboxEntity> page = inboxMapper.selectPage(Page.of(1, limit),
Wrappers.<SourceMessageInboxEntity>lambdaQuery()
.eq(hasText(hotelId), SourceMessageInboxEntity::getHotelId, trim(hotelId))
.and(wrapper -> wrapper
.like(SourceMessageInboxEntity::getExternalMessageId, trimmedKeyword)
.or()
.like(SourceMessageInboxEntity::getExternalConversationId, trimmedKeyword)
.or()
.like(SourceMessageInboxEntity::getSenderSummary, trimmedKeyword)
.or()
.like(SourceMessageInboxEntity::getSubject, trimmedKeyword)
.or()
.like(SourceMessageInboxEntity::getSafeSnippet, trimmedKeyword))
.orderByDesc(SourceMessageInboxEntity::getReceivedAt));
return page.getRecords().stream()
.map(SourceMessageInboxEntity::getId)
.toList();
}
/**
* 插入 Inbox 主记录及原始 payloadRECEIVED 状态额外保存正文和媒体引用。
*/

View File

@@ -7,6 +7,7 @@ import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalConten
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
@@ -36,6 +37,11 @@ public interface SourceMessageInboxRepository {
int pageNum,
int pageSize);
/**
* 按来源消息安全摘要关键词查询内部 SourceMessage ID只匹配普通列表可展示字段。
*/
List<Long> findIdsBySafeKeyword(String hotelId, String keyword, int limit);
/**
* 插入 Inbox 及其 payload/body/media 子记录,返回内部 SourceMessage ID。
*/

View File

@@ -3,6 +3,7 @@ package cn.nianxx.thhotel.platform.message.service;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import java.util.List;
import java.util.Optional;
/**
@@ -18,6 +19,15 @@ public interface SourceMessageQueryService {
*/
SourceMessagePageResult<SourceMessageSummaryResponse> query(SourceMessageQueryRequest request);
/**
* 按来源消息安全摘要关键词查询内部 SourceMessage ID供业务列表做安全关联过滤。
*
* @param hotelId 酒店上下文 ID
* @param keyword 来源消息安全摘要关键词
* @return 匹配的内部 SourceMessage ID 列表
*/
List<Long> findIdsBySafeKeyword(String hotelId, String keyword);
/**
* 按内部 SourceMessage ID 读取单条安全摘要。
*

View File

@@ -1,11 +1,11 @@
package cn.nianxx.thhotel.platform.message.service.impl;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageInboxSnapshot;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
import cn.nianxx.thhotel.platform.message.common.request.SourceMessageQueryRequest;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessagePageResult;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
@@ -22,6 +22,7 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
private static final int DEFAULT_PAGE_NUM = 1;
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
private static final int MAX_SAFE_KEYWORD_MATCHES = 500;
private final SourceMessageInboxRepository inboxRepository;
@@ -44,6 +45,14 @@ public class SourceMessageQueryServiceImpl implements SourceMessageQueryService
return new SourceMessagePageResult<>(items, page.total(), pageNum, pageSize);
}
/**
* 查询匹配来源消息安全摘要关键词的内部 SourceMessage ID供业务查询做关联过滤。
*/
@Override
public List<Long> findIdsBySafeKeyword(String hotelId, String keyword) {
return inboxRepository.findIdsBySafeKeyword(hotelId, keyword, MAX_SAFE_KEYWORD_MATCHES);
}
/**
* 读取单条 SourceMessage 安全摘要不返回正文、HTML、媒体 URL 或原始 payload。
*/

View File

@@ -24,6 +24,7 @@ import java.time.LocalDateTime;
* @param blockedUntilParentCompleted 是否等待父任务完成
* @param lastFailureReason 最近失败原因
* @param completedAt 任务完成时间
* @param createdAt 创建时间
* @param updatedAt 最近更新时间
* @param transitionSourceEventIndex AI transition 来源事件序号
* @param catalogCode Skill 目录代码
@@ -49,6 +50,7 @@ public record ReservationAiQueryTaskSnapshot(
Boolean blockedUntilParentCompleted,
String lastFailureReason,
LocalDateTime completedAt,
LocalDateTime createdAt,
LocalDateTime updatedAt,
Integer transitionSourceEventIndex,
String catalogCode,

View File

@@ -0,0 +1,20 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.util.List;
/**
* Reservation 模块内部分页快照。Repository 返回该对象Service 再转换为前端响应结构。
*
* @param items 当前页记录
* @param total 符合条件的总记录数
* @param pageNum 当前页码,从 1 开始
* @param pageSize 每页数量
* @param <T> 分页记录类型
*/
public record ReservationPageSnapshot<T>(
List<T> items,
long total,
int pageNum,
int pageSize
) {
}

View File

@@ -0,0 +1,27 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
/**
* 前端任务列表 / 工作台查询条件。第一版只做只读查询,不改变任务或订单状态。
*
* @param hotelId 酒店上下文 ID
* @param orderId 订单 ID 过滤
* @param taskType 系统主任务类型过滤
* @param taskStatus 任务状态过滤
* @param taskSubtype 任务 subtype 过滤
* @param queueParticipation 是否参与订单执行队列
* @param keyword 业务号、临时订单号、任务字段或来源消息安全摘要关键词
* @param pageNum 页码,从 1 开始
* @param pageSize 每页数量
*/
public record ReservationTaskWorkbenchQueryRequest(
String hotelId,
Long orderId,
String taskType,
String taskStatus,
String taskSubtype,
Boolean queueParticipation,
String keyword,
Integer pageNum,
Integer pageSize
) {
}

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import java.util.List;
/**
* 前端订单详情响应,包含订单摘要、任务时间线和非阻塞警告。
*
* @param order 订单摘要
* @param tasks 同订单任务时间线
* @param warnings 当前无法提供的扩展信息或非阻塞提醒
*/
public record ReservationOrderDetailResult(
ReservationOrderSummaryResult order,
List<ReservationOrderTaskTimelineItemResult> tasks,
List<String> warnings
) {
}

View File

@@ -0,0 +1,44 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 前端订单详情页的订单摘要。只包含本系统已有订单快照,不伪造 OPERA 投影字段。
*
* @param orderId 订单 ID
* @param hotelId 酒店上下文 ID
* @param orderStatus 订单状态
* @param temporaryOrderNo 临时订单号
* @param confirmationNumber Confirmation No.
* @param groupCode Group Code
* @param blockCode Block Code当前无可靠来源时为空
* @param allotmentCode Allotment Code当前无可靠来源时为空
* @param displayName 前端展示名称
* @param createdAt 创建时间
* @param updatedAt 最近更新时间
*/
public record ReservationOrderSummaryResult(
@JsonProperty("order_id")
String orderId,
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("order_status")
String orderStatus,
@JsonProperty("temporary_order_no")
String temporaryOrderNo,
@JsonProperty("confirmation_number")
String confirmationNumber,
@JsonProperty("group_code")
String groupCode,
@JsonProperty("block_code")
String blockCode,
@JsonProperty("allotment_code")
String allotmentCode,
@JsonProperty("display_name")
String displayName,
@JsonProperty("created_at")
String createdAt,
@JsonProperty("updated_at")
String updatedAt
) {
}

View File

@@ -0,0 +1,41 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 订单详情页任务时间线单项。用于前端按同订单任务顺序展示处理状态。
*
* @param taskId 任务 ID
* @param taskType 系统主任务类型
* @param taskSubtype 任务 subtype
* @param taskStatus 任务状态
* @param cardName 任务卡展示名称
* @param queueSequence 同订单队列顺序
* @param queueParticipation 是否参与执行队列
* @param canProcess 当前是否可处理
* @param readonlyReasonCode 只读原因代码
* @param createdAt 任务创建时间
*/
public record ReservationOrderTaskTimelineItemResult(
@JsonProperty("task_id")
String taskId,
@JsonProperty("task_type")
String taskType,
@JsonProperty("task_subtype")
String taskSubtype,
@JsonProperty("task_status")
String taskStatus,
@JsonProperty("card_name")
String cardName,
@JsonProperty("queue_sequence")
Integer queueSequence,
@JsonProperty("queue_participation")
Boolean queueParticipation,
@JsonProperty("can_process")
Boolean canProcess,
@JsonProperty("readonly_reason_code")
String readonlyReasonCode,
@JsonProperty("created_at")
String createdAt
) {
}

View File

@@ -0,0 +1,19 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 前端分页元数据。字段名使用 snake_case避免前端自行转换。
*
* @param pageNum 当前页码,从 1 开始
* @param pageSize 每页数量
* @param total 符合条件的总记录数
*/
public record ReservationPaginationResult(
@JsonProperty("page_num")
int pageNum,
@JsonProperty("page_size")
int pageSize,
long total
) {
}

View File

@@ -0,0 +1,62 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* 前端任务列表 / 工作台单行结果。只返回摘要和可处理状态,不返回 AI 原始 payload。
*
* @param taskId 任务 ID按字符串返回避免前端长整型精度问题
* @param orderId 订单 ID
* @param hotelId 酒店上下文 ID
* @param displayOrderKey 前端优先展示的订单业务号或临时订单号
* @param temporaryOrderNo 临时订单号
* @param taskType 系统主任务类型
* @param taskSubtype 任务 subtype
* @param taskStatus 任务状态
* @param cardName 任务卡展示名称,第一版使用任务卡类型
* @param queueSequence 同订单队列顺序
* @param queueParticipation 是否参与订单执行队列
* @param canProcess 当前是否可处理
* @param readonlyReasonCode 只读原因代码
* @param sourceMessageId 来源 SourceMessage Inbox ID
* @param sourceSubject 来源消息主题摘要
* @param createdAt 任务创建时间
* @param updatedAt 任务更新时间
*/
public record ReservationTaskWorkbenchItemResult(
@JsonProperty("task_id")
String taskId,
@JsonProperty("order_id")
String orderId,
@JsonProperty("hotel_id")
String hotelId,
@JsonProperty("display_order_key")
String displayOrderKey,
@JsonProperty("temporary_order_no")
String temporaryOrderNo,
@JsonProperty("task_type")
String taskType,
@JsonProperty("task_subtype")
String taskSubtype,
@JsonProperty("task_status")
String taskStatus,
@JsonProperty("card_name")
String cardName,
@JsonProperty("queue_sequence")
Integer queueSequence,
@JsonProperty("queue_participation")
Boolean queueParticipation,
@JsonProperty("can_process")
Boolean canProcess,
@JsonProperty("readonly_reason_code")
String readonlyReasonCode,
@JsonProperty("source_message_id")
String sourceMessageId,
@JsonProperty("source_subject")
String sourceSubject,
@JsonProperty("created_at")
String createdAt,
@JsonProperty("updated_at")
String updatedAt
) {
}

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.workflows.reservation.common.result;
import java.util.List;
/**
* 前端任务列表 / 工作台分页响应。
*
* @param items 当前页任务摘要
* @param page 分页元数据
*/
public record ReservationTaskWorkbenchListResult(
List<ReservationTaskWorkbenchItemResult> items,
ReservationPaginationResult page
) {
}

View File

@@ -0,0 +1,67 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Reservation 前端查询 Controller。提供页面读取接口不做业务写操作。
*/
@RestController
@RequestMapping("/api/reservation")
public class ReservationFrontendQueryController {
private final ReservationFrontendQueryService frontendQueryService;
/**
* 注入前端查询服务Controller 只负责 HTTP 参数到查询对象的转换。
*/
public ReservationFrontendQueryController(ReservationFrontendQueryService frontendQueryService) {
this.frontendQueryService = frontendQueryService;
}
/**
* 查询任务列表 / 工作台摘要,供前端按任务处理状态展示入口。
*/
@GetMapping(value = "/tasks", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationTaskWorkbenchListResult listTasks(
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(name = "order_id", required = false) Long orderId,
@RequestParam(name = "task_type", required = false) String taskType,
@RequestParam(name = "task_status", required = false) String taskStatus,
@RequestParam(name = "task_subtype", required = false) String taskSubtype,
@RequestParam(name = "queue_participation", required = false) Boolean queueParticipation,
@RequestParam(required = false) String keyword,
@RequestParam(name = "page_num", required = false) Integer pageNum,
@RequestParam(name = "page_size", required = false) Integer pageSize) {
return frontendQueryService.queryTaskWorkbench(new ReservationTaskWorkbenchQueryRequest(
hotelId,
orderId,
taskType,
taskStatus,
taskSubtype,
queueParticipation,
keyword,
pageNum,
pageSize));
}
/**
* 查询订单详情和任务时间线,供订单详情页展示当前订单处理脉络。
*/
@GetMapping(value = "/orders/{orderId}", produces = MediaType.APPLICATION_JSON_VALUE)
public ReservationOrderDetailResult orderDetail(
@PathVariable Long orderId,
@RequestParam(name = "hotel_id", required = false) String hotelId,
@RequestParam(name = "include_tasks", required = false) Boolean includeTasks,
@RequestParam(name = "include_source_summary", required = false) Boolean includeSourceSummary) {
return frontendQueryService.getOrderDetail(hotelId, orderId, includeTasks, includeSourceSummary);
}
}

View File

@@ -9,7 +9,10 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Reservation 任务接口统一异常处理。错误响应不暴露内部堆栈或原始 AI payload。
*/
@RestControllerAdvice(assignableTypes = ReservationTaskController.class)
@RestControllerAdvice(assignableTypes = {
ReservationTaskController.class,
ReservationFrontendQueryController.class
})
public class ReservationTaskControllerAdvice {
/**

View File

@@ -13,10 +13,12 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperat
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
@@ -37,6 +39,7 @@ import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationOrderMapper;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskCardMapper;
import cn.nianxx.thhotel.workflows.reservation.mapper.ReservationTaskMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.LinkedHashMap;
@@ -352,6 +355,63 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
return Optional.ofNullable(entity).map(this::toAiQueryOrderSnapshot);
}
/**
* 按订单 ID 批量查询订单快照,供前端任务列表补充订单展示键。
*/
@Override
public List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersByIds(String hotelId, List<Long> orderIds) {
if (orderIds == null || orderIds.isEmpty()) {
return List.of();
}
return orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, hotelId)
.in(ReservationOrderEntity::getId, orderIds))
.stream()
.map(this::toAiQueryOrderSnapshot)
.toList();
}
/**
* 前端任务列表分页查询。只读取任务摘要字段,完整任务卡 payload 仍走任务详情接口。
*/
@Override
public ReservationPageSnapshot<ReservationAiQueryTaskSnapshot> queryFrontendTasks(
ReservationTaskWorkbenchQueryRequest request,
List<Long> keywordSourceMessageIds,
int pageNum,
int pageSize) {
List<Long> keywordOrderIds = findOrderIdsByKeyword(request.hotelId(), request.keyword());
List<Long> sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds;
Page<ReservationTaskEntity> page = taskMapper.selectPage(Page.of(pageNum, pageSize),
Wrappers.<ReservationTaskEntity>lambdaQuery()
.eq(ReservationTaskEntity::getHotelId, request.hotelId())
.eq(request.orderId() != null, ReservationTaskEntity::getOrderId, request.orderId())
.eq(hasText(request.taskType()), ReservationTaskEntity::getSystemTaskType, trim(request.taskType()))
.eq(hasText(request.taskStatus()), ReservationTaskEntity::getTaskStatus, trim(request.taskStatus()))
.eq(hasText(request.taskSubtype()), ReservationTaskEntity::getTaskSubtype, trim(request.taskSubtype()))
.eq(request.queueParticipation() != null,
ReservationTaskEntity::getQueueParticipation,
request.queueParticipation())
.and(hasText(request.keyword()), wrapper -> {
wrapper.like(ReservationTaskEntity::getSystemTaskType, trim(request.keyword()))
.or()
.like(ReservationTaskEntity::getTaskSubtype, trim(request.keyword()));
if (!keywordOrderIds.isEmpty()) {
wrapper.or().in(ReservationTaskEntity::getOrderId, keywordOrderIds);
}
if (!sourceMessageIds.isEmpty()) {
wrapper.or().in(ReservationTaskEntity::getSourceMessageId, sourceMessageIds);
}
})
.orderByAsc(ReservationTaskEntity::getOrderId)
.orderByAsc(ReservationTaskEntity::getExecutionOrder));
return new ReservationPageSnapshot<>(
toAiQueryTaskSnapshots(request.hotelId(), page.getRecords()),
page.getTotal(),
pageNum,
pageSize);
}
/**
* 查询指定订单下全部任务,并补充对应 AI transition 的事件序号和 Skill 信息。
*/
@@ -738,6 +798,36 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
return value != null && !value.isBlank();
}
/**
* 去除查询文本首尾空白,避免空格参与等值查询。
*/
private String trim(String value) {
return value == null ? null : value.trim();
}
/**
* 按订单业务号、临时订单号和展示名查询订单 ID用于任务列表关键词过滤。
*/
private List<Long> findOrderIdsByKeyword(String hotelId, String keyword) {
if (!hasText(keyword)) {
return List.of();
}
String trimmedKeyword = trim(keyword);
return orderMapper.selectList(Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, hotelId)
.and(wrapper -> wrapper
.like(ReservationOrderEntity::getOrderBusinessKey, trimmedKeyword)
.or()
.like(ReservationOrderEntity::getActiveBusinessKey, trimmedKeyword)
.or()
.like(ReservationOrderEntity::getTemporaryOrderCode, trimmedKeyword)
.or()
.like(ReservationOrderEntity::getDisplayName, trimmedKeyword)))
.stream()
.map(ReservationOrderEntity::getId)
.toList();
}
/**
* 转换批次实体为快照。
*/
@@ -839,6 +929,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.getBlockedUntilParentCompleted(),
entity.getLastFailureReason(),
entity.getCompletedAt(),
entity.getCreatedAt(),
entity.getUpdatedAt(),
transition == null ? null : transition.getSourceEventIndex(),
transition == null ? null : transition.getCatalogCode(),

View File

@@ -13,10 +13,12 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperat
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOperaOperationSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskCardSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskDraft;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@@ -102,6 +104,20 @@ public interface ReservationAiWorkflowRepository {
*/
Optional<ReservationAiQueryOrderSnapshot> findAiQueryOrderById(String hotelId, Long orderId);
/**
* 按订单 ID 批量查询订单快照,供前端列表补充展示字段。
*/
List<ReservationAiQueryOrderSnapshot> findAiQueryOrdersByIds(String hotelId, List<Long> orderIds);
/**
* 分页查询前端任务列表 / 工作台摘要。
*/
ReservationPageSnapshot<ReservationAiQueryTaskSnapshot> queryFrontendTasks(
ReservationTaskWorkbenchQueryRequest request,
List<Long> keywordSourceMessageIds,
int pageNum,
int pageSize);
/**
* 查询指定订单下全部任务,并带上对应 AI transition 的路由字段。
*/

View File

@@ -0,0 +1,25 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult;
/**
* Reservation 前端页面查询服务。提供任务工作台和订单详情时间线,不执行任何业务写操作。
*/
public interface ReservationFrontendQueryService {
/**
* 查询任务列表 / 工作台摘要,并实时计算每条任务当前是否可处理。
*/
ReservationTaskWorkbenchListResult queryTaskWorkbench(ReservationTaskWorkbenchQueryRequest request);
/**
* 查询订单详情和同订单任务时间线,供前端订单详情页展示。
*/
ReservationOrderDetailResult getOrderDetail(
String hotelId,
Long orderId,
Boolean includeTasks,
Boolean includeSourceSummary);
}

View File

@@ -0,0 +1,361 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageSummaryResponse;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryOrderSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationAiQueryTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationPageSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationOrderKeyType;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationTaskWorkbenchQueryRequest;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationOrderDetailResult;
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.ReservationPaginationResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchItemResult;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskWorkbenchListResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationFrontendQueryService;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Reservation 前端查询服务实现。只组装安全摘要和实时可处理状态,不返回 AI 原始 payload。
*/
@Service
public class ReservationFrontendQueryServiceImpl implements ReservationFrontendQueryService {
private static final String DEFAULT_HOTEL_ID = "HOTEL-TEST";
private static final int DEFAULT_PAGE_NUM = 1;
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
private static final String REASON_PROCESSABLE = "PROCESSABLE";
private static final String REASON_PREVIOUS_TASK_NOT_FINISHED = "PREVIOUS_TASK_NOT_FINISHED";
private static final String REASON_TASK_FINISHED = "TASK_FINISHED";
private static final String REASON_READ_ONLY = "READ_ONLY";
private final ReservationAiWorkflowRepository workflowRepository;
private final SourceMessageQueryService sourceMessageQueryService;
private final ReservationTaskAvailabilityResolver availabilityResolver;
/**
* 注入持久化边界、SourceMessage 安全摘要服务和可处理状态解析器。
*/
public ReservationFrontendQueryServiceImpl(
ReservationAiWorkflowRepository workflowRepository,
SourceMessageQueryService sourceMessageQueryService,
ReservationTaskAvailabilityResolver availabilityResolver) {
this.workflowRepository = workflowRepository;
this.sourceMessageQueryService = sourceMessageQueryService;
this.availabilityResolver = availabilityResolver;
}
/**
* 查询前端任务列表,并补充订单展示键和来源消息安全主题摘要。
*/
@Override
@Transactional(readOnly = true)
public ReservationTaskWorkbenchListResult queryTaskWorkbench(ReservationTaskWorkbenchQueryRequest request) {
ReservationTaskWorkbenchQueryRequest normalizedRequest = normalizeRequest(request);
int pageNum = normalizePageNum(normalizedRequest.pageNum());
int pageSize = normalizePageSize(normalizedRequest.pageSize());
List<Long> keywordSourceMessageIds = findSourceMessageIdsByKeyword(
normalizedRequest.hotelId(),
normalizedRequest.keyword());
ReservationPageSnapshot<ReservationAiQueryTaskSnapshot> page =
workflowRepository.queryFrontendTasks(normalizedRequest, keywordSourceMessageIds, pageNum, pageSize);
Map<Long, ReservationAiQueryOrderSnapshot> ordersById = findOrdersById(
normalizedRequest.hotelId(),
page.items().stream().map(ReservationAiQueryTaskSnapshot::orderId).toList());
List<ReservationTaskWorkbenchItemResult> items = page.items().stream()
.map(task -> toWorkbenchItem(task, ordersById.get(task.orderId())))
.toList();
return new ReservationTaskWorkbenchListResult(
items,
new ReservationPaginationResult(page.pageNum(), page.pageSize(), page.total()));
}
/**
* 查询订单详情。includeTasks=false 时只返回订单摘要,便于后续前端轻量使用。
*/
@Override
@Transactional(readOnly = true)
public ReservationOrderDetailResult getOrderDetail(
String hotelId,
Long orderId,
Boolean includeTasks,
Boolean includeSourceSummary) {
String normalizedHotelId = normalizeHotelId(hotelId);
ReservationAiQueryOrderSnapshot order = workflowRepository
.findAiQueryOrderById(normalizedHotelId, orderId)
.orElseThrow(() -> new ReservationTaskWorkflowException(
HttpStatus.NOT_FOUND,
"ORDER_NOT_FOUND",
"订单不存在。"));
List<ReservationOrderTaskTimelineItemResult> tasks = Boolean.FALSE.equals(includeTasks)
? List.of()
: workflowRepository.findAiQueryTasksByOrderIds(normalizedHotelId, List.of(order.id()))
.stream()
.map(this::toTimelineItem)
.toList();
return new ReservationOrderDetailResult(toOrderSummary(order), tasks, List.of());
}
/**
* 标准化任务列表查询条件,避免 Controller 层散落默认值规则。
*/
private ReservationTaskWorkbenchQueryRequest normalizeRequest(ReservationTaskWorkbenchQueryRequest request) {
if (request == null) {
return new ReservationTaskWorkbenchQueryRequest(
DEFAULT_HOTEL_ID,
null,
null,
null,
null,
null,
null,
DEFAULT_PAGE_NUM,
DEFAULT_PAGE_SIZE);
}
return new ReservationTaskWorkbenchQueryRequest(
normalizeHotelId(request.hotelId()),
request.orderId(),
trimToNull(request.taskType()),
trimToNull(request.taskStatus()),
trimToNull(request.taskSubtype()),
request.queueParticipation(),
trimToNull(request.keyword()),
request.pageNum(),
request.pageSize());
}
/**
* 批量读取订单快照并按订单 ID 建立索引。
*/
private Map<Long, ReservationAiQueryOrderSnapshot> findOrdersById(String hotelId, List<Long> orderIds) {
Map<Long, ReservationAiQueryOrderSnapshot> result = new LinkedHashMap<>();
workflowRepository.findAiQueryOrdersByIds(hotelId, orderIds)
.forEach(order -> result.put(order.id(), order));
return result;
}
/**
* 按来源消息安全摘要关键词查找 SourceMessage ID避免任务列表直接读取来源消息正文。
*/
private List<Long> findSourceMessageIdsByKeyword(String hotelId, String keyword) {
if (trimToNull(keyword) == null) {
return List.of();
}
return sourceMessageQueryService.findIdsBySafeKeyword(hotelId, keyword);
}
/**
* 转换任务快照为前端工作台单行结果。
*/
private ReservationTaskWorkbenchItemResult toWorkbenchItem(
ReservationAiQueryTaskSnapshot task,
ReservationAiQueryOrderSnapshot order) {
ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(toTaskSnapshot(task));
return new ReservationTaskWorkbenchItemResult(
task.id().toString(),
task.orderId().toString(),
task.hotelId(),
displayOrderKey(order),
order == null ? null : order.temporaryOrderCode(),
task.systemTaskType(),
task.taskSubtype(),
task.taskStatus(),
task.taskCardType(),
task.executionOrder(),
task.queueParticipation(),
canProcess(availability),
readonlyReasonCode(task, availability),
task.sourceMessageId().toString(),
sourceSubject(task.sourceMessageId()),
toIsoString(task.createdAt()),
toIsoString(task.updatedAt()));
}
/**
* 转换任务快照为订单详情任务时间线结果。
*/
private ReservationOrderTaskTimelineItemResult toTimelineItem(ReservationAiQueryTaskSnapshot task) {
ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(toTaskSnapshot(task));
return new ReservationOrderTaskTimelineItemResult(
task.id().toString(),
task.systemTaskType(),
task.taskSubtype(),
task.taskStatus(),
task.taskCardType(),
task.executionOrder(),
task.queueParticipation(),
canProcess(availability),
readonlyReasonCode(task, availability),
toIsoString(task.createdAt()));
}
/**
* 转换订单快照为前端订单摘要。
*/
private ReservationOrderSummaryResult toOrderSummary(ReservationAiQueryOrderSnapshot order) {
return new ReservationOrderSummaryResult(
order.id().toString(),
order.hotelId(),
order.orderStatus(),
order.temporaryOrderCode(),
confirmationNumber(order),
groupCode(order),
null,
null,
order.displayName(),
toIsoString(order.createdAt()),
toIsoString(order.updatedAt()));
}
/**
* 转换 AI 查询任务快照为可处理状态解析器使用的通用任务快照。
*/
private ReservationTaskSnapshot toTaskSnapshot(ReservationAiQueryTaskSnapshot task) {
return new ReservationTaskSnapshot(
task.id(),
task.hotelId(),
task.orderId(),
task.sourceMessageId(),
task.aiTransitionId(),
task.resultType(),
task.aiTaskType(),
task.systemTaskType(),
task.taskCardType(),
task.taskSubtype(),
task.taskStatus(),
task.queueParticipation(),
task.executionOrder(),
task.parentTaskId(),
task.parentSourceEventIndex(),
task.linkedTaskGroupId(),
task.blockedUntilParentCompleted(),
null);
}
/**
* 判断当前任务是否可由前端呈现为可处理状态。
*/
private boolean canProcess(ReservationTaskAvailabilityResult availability) {
return availability.editable() || availability.confirmable() || availability.executable();
}
/**
* 将实时可处理状态转换为前端稳定原因代码。
*/
private String readonlyReasonCode(
ReservationAiQueryTaskSnapshot task,
ReservationTaskAvailabilityResult availability) {
if (availability.blocked()) {
return REASON_PREVIOUS_TASK_NOT_FINISHED;
}
if (ReservationTaskStatus.COMPLETED.name().equals(task.taskStatus())
|| ReservationTaskStatus.FAILED.name().equals(task.taskStatus())) {
return REASON_TASK_FINISHED;
}
if (canProcess(availability)) {
return REASON_PROCESSABLE;
}
return REASON_READ_ONLY;
}
/**
* 生成订单展示键,优先真实业务号,其次临时订单号。
*/
private String displayOrderKey(ReservationAiQueryOrderSnapshot order) {
if (order == null) {
return null;
}
String activeBusinessKey = trimToNull(order.activeBusinessKey());
if (activeBusinessKey != null) {
return activeBusinessKey;
}
String orderBusinessKey = trimToNull(order.orderBusinessKey());
return orderBusinessKey == null ? order.temporaryOrderCode() : orderBusinessKey;
}
/**
* 读取来源消息安全主题摘要,不返回邮件正文或附件 URL。
*/
private String sourceSubject(Long sourceMessageId) {
if (sourceMessageId == null) {
return null;
}
Optional<SourceMessageSummaryResponse> summary = sourceMessageQueryService.getSummary(sourceMessageId);
return summary.map(SourceMessageSummaryResponse::subject).orElse(null);
}
/**
* 从订单快照中提取 Group Code。
*/
private String groupCode(ReservationAiQueryOrderSnapshot order) {
if (ReservationOrderKeyType.GROUP_CODE.name().equals(order.orderKeyType())) {
return displayOrderKey(order);
}
return null;
}
/**
* 从订单快照中提取 Confirmation No.。
*/
private String confirmationNumber(ReservationAiQueryOrderSnapshot order) {
if (ReservationOrderKeyType.CONFIRMATION_NUMBER.name().equals(order.orderKeyType())) {
return displayOrderKey(order);
}
return null;
}
/**
* 标准化酒店 ID。第一版未接用户酒店上下文时使用本地默认酒店。
*/
private String normalizeHotelId(String hotelId) {
String trimmedHotelId = trimToNull(hotelId);
return trimmedHotelId == null ? DEFAULT_HOTEL_ID : trimmedHotelId;
}
/**
* 标准化页码,避免非法参数导致全表读取。
*/
private int normalizePageNum(Integer pageNum) {
return pageNum == null || pageNum < 1 ? DEFAULT_PAGE_NUM : pageNum;
}
/**
* 标准化页大小,并限制最大页大小。
*/
private int normalizePageSize(Integer pageSize) {
if (pageSize == null || pageSize < 1) {
return DEFAULT_PAGE_SIZE;
}
return Math.min(pageSize, MAX_PAGE_SIZE);
}
/**
* 去除空白字符串,空字符串按 null 处理。
*/
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
/**
* 将数据库 UTC 时间转换为 ISO 字符串。
*/
private String toIsoString(LocalDateTime value) {
return value == null ? null : value.toString();
}
}

View File

@@ -0,0 +1,59 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationTaskSnapshot;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationTaskAvailabilityResult;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import org.springframework.stereotype.Component;
/**
* Reservation 任务实时可处理状态解析器。BLOCKED 不落库,统一按同订单前置任务实时计算。
*/
@Component
public class ReservationTaskAvailabilityResolver {
private final ReservationAiWorkflowRepository workflowRepository;
/**
* 注入工作流持久化边界,用于读取同订单前置队列任务。
*/
public ReservationTaskAvailabilityResolver(ReservationAiWorkflowRepository workflowRepository) {
this.workflowRepository = workflowRepository;
}
/**
* 计算任务可处理状态。FAILED 和 COMPLETED 视为前置任务结束,不阻塞后续任务。
*/
public ReservationTaskAvailabilityResult calculateAvailability(ReservationTaskSnapshot task) {
if (!Boolean.TRUE.equals(task.queueParticipation())) {
return new ReservationTaskAvailabilityResult(false, true, false, false, false, null, null);
}
ReservationTaskSnapshot blockingTask = workflowRepository
.findQueueTasksBefore(task.hotelId(), task.orderId(), task.executionOrder())
.stream()
.filter(this::isBlockingTask)
.findFirst()
.orElse(null);
if (blockingTask != null) {
return new ReservationTaskAvailabilityResult(
true,
true,
false,
false,
false,
blockingTask.id().toString(),
"同订单前置任务未完成。");
}
boolean pendingConfirm = ReservationTaskStatus.PENDING_CONFIRM.name().equals(task.taskStatus());
boolean ready = ReservationTaskStatus.READY.name().equals(task.taskStatus());
return new ReservationTaskAvailabilityResult(false, false, pendingConfirm, pendingConfirm, ready, null, null);
}
/**
* 判断前置任务是否仍阻塞后续任务。
*/
private boolean isBlockingTask(ReservationTaskSnapshot task) {
return !ReservationTaskStatus.COMPLETED.name().equals(task.taskStatus())
&& !ReservationTaskStatus.FAILED.name().equals(task.taskStatus());
}
}

View File

@@ -73,6 +73,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
private final ReservationAiWorkflowRepository workflowRepository;
private final ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider;
private final ReservationTaskAvailabilityResolver availabilityResolver;
private final ObjectMapper objectMapper;
/**
@@ -81,9 +82,11 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
public ReservationTaskWorkflowServiceImpl(
ReservationAiWorkflowRepository workflowRepository,
ReservationTaskCardFieldDefinitionProvider fieldDefinitionProvider,
ReservationTaskAvailabilityResolver availabilityResolver,
ObjectMapper objectMapper) {
this.workflowRepository = workflowRepository;
this.fieldDefinitionProvider = fieldDefinitionProvider;
this.availabilityResolver = availabilityResolver;
this.objectMapper = objectMapper;
}
@@ -97,7 +100,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
ReservationTaskCardSnapshot taskCard = workflowRepository
.findTaskCardByTaskId(task.hotelId(), task.id())
.orElseThrow(() -> error(HttpStatus.NOT_FOUND, "TASK_CARD_NOT_FOUND", "任务卡不存在。"));
ReservationTaskAvailabilityResult availability = calculateAvailability(task);
ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(task);
List<ReservationTaskFieldResult> fields = buildFieldResults(task, taskCard);
List<ReservationOperaOperationResult> operaOperations = findOperaOperationResults(task);
return new ReservationTaskDetailResult(
@@ -380,7 +383,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
if (!ReservationTaskStatus.READY.name().equals(task.taskStatus())) {
throw error(HttpStatus.CONFLICT, "TASK_STATUS_NOT_EXECUTABLE", "只有 READY 任务允许执行 OPERA 模拟操作。");
}
ReservationTaskAvailabilityResult availability = calculateAvailability(task);
ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(task);
if (availability.readOnly() || !availability.executable()) {
throw error(HttpStatus.CONFLICT, "TASK_READ_ONLY", "任务当前只读,不能执行 OPERA 模拟操作。");
}
@@ -622,7 +625,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
if (!ReservationTaskStatus.PENDING_CONFIRM.name().equals(task.taskStatus())) {
throw error(HttpStatus.CONFLICT, "TASK_STATUS_NOT_EDITABLE", "只有 PENDING_CONFIRM 任务允许编辑或确认。");
}
ReservationTaskAvailabilityResult availability = calculateAvailability(task);
ReservationTaskAvailabilityResult availability = availabilityResolver.calculateAvailability(task);
if (availability.readOnly() || !availability.editable() || !availability.confirmable()) {
throw error(HttpStatus.CONFLICT, "TASK_READ_ONLY", "任务当前只读,不能编辑或确认。");
}
@@ -1214,42 +1217,6 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
return text != null && text.contains(keyword);
}
/**
* 计算任务可处理状态。FAILED 和 COMPLETED 视为前置任务结束,不阻塞后续任务。
*/
private ReservationTaskAvailabilityResult calculateAvailability(ReservationTaskSnapshot task) {
if (!Boolean.TRUE.equals(task.queueParticipation())) {
return new ReservationTaskAvailabilityResult(false, true, false, false, false, null, null);
}
ReservationTaskSnapshot blockingTask = workflowRepository
.findQueueTasksBefore(task.hotelId(), task.orderId(), task.executionOrder())
.stream()
.filter(this::isBlockingTask)
.findFirst()
.orElse(null);
if (blockingTask != null) {
return new ReservationTaskAvailabilityResult(
true,
true,
false,
false,
false,
blockingTask.id().toString(),
"同订单前置任务未完成。");
}
boolean pendingConfirm = ReservationTaskStatus.PENDING_CONFIRM.name().equals(task.taskStatus());
boolean ready = ReservationTaskStatus.READY.name().equals(task.taskStatus());
return new ReservationTaskAvailabilityResult(false, false, pendingConfirm, pendingConfirm, ready, null, null);
}
/**
* 判断前置任务是否仍阻塞后续任务。
*/
private boolean isBlockingTask(ReservationTaskSnapshot task) {
return !ReservationTaskStatus.COMPLETED.name().equals(task.taskStatus())
&& !ReservationTaskStatus.FAILED.name().equals(task.taskStatus());
}
/**
* 根据任务卡矩阵和 AI 原始快照组装详情字段。
*/

View File

@@ -0,0 +1,218 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import java.time.Instant;
import java.util.List;
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.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = ThHotelApplication.class)
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationFrontendQueryControllerTest {
private static final String HOTEL_ID = "HOTEL-TEST";
@Autowired
private MockMvc mockMvc;
@Autowired
private SourceMessageCaptureService captureService;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void shouldReturnTaskWorkbenchListWithRealtimeAvailability() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
"mail-frontend-task-list-001",
"Frontend Query List Smoke");
Long orderId = 930000000000000101L;
Long firstTaskId = 930000000000000301L;
Long secondTaskId = 930000000000000302L;
insertActiveGroupOrder(orderId, source.inboxId(), "GRP-FRONTEND-LIST-001");
insertTransition(930000000000000201L, source.inboxId(), 1, "GRP-FRONTEND-LIST-001",
"New Booking", "NEW_BOOKING", "NEW_BOOKING");
insertTransition(930000000000000202L, source.inboxId(), 2, "GRP-FRONTEND-LIST-001",
"Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING");
insertTask(firstTaskId, orderId, source.inboxId(), 930000000000000201L, "New Booking",
"NEW_BOOKING", "NEW_BOOKING", "PENDING_CONFIRM", 1);
insertTask(secondTaskId, orderId, source.inboxId(), 930000000000000202L, "Update Booking",
"UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 2);
mockMvc.perform(get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("order_id", orderId.toString())
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].task_id").value(firstTaskId.toString()))
.andExpect(jsonPath("$.items[0].order_id").value(orderId.toString()))
.andExpect(jsonPath("$.items[0].hotel_id").value(HOTEL_ID))
.andExpect(jsonPath("$.items[0].display_order_key").value("GRP-FRONTEND-LIST-001"))
.andExpect(jsonPath("$.items[0].task_type").value("NEW_BOOKING"))
.andExpect(jsonPath("$.items[0].task_status").value("PENDING_CONFIRM"))
.andExpect(jsonPath("$.items[0].queue_sequence").value(1))
.andExpect(jsonPath("$.items[0].queue_participation").value(true))
.andExpect(jsonPath("$.items[0].can_process").value(true))
.andExpect(jsonPath("$.items[0].readonly_reason_code").value("PROCESSABLE"))
.andExpect(jsonPath("$.items[0].source_message_id").value(source.inboxId().toString()))
.andExpect(jsonPath("$.items[0].source_subject").value("Frontend Query List Smoke"))
.andExpect(jsonPath("$.items[1].task_id").value(secondTaskId.toString()))
.andExpect(jsonPath("$.items[1].task_type").value("UPDATE_BOOKING"))
.andExpect(jsonPath("$.items[1].can_process").value(false))
.andExpect(jsonPath("$.items[1].readonly_reason_code").value("PREVIOUS_TASK_NOT_FINISHED"))
.andExpect(jsonPath("$.page.page_num").value(1))
.andExpect(jsonPath("$.page.page_size").value(20))
.andExpect(jsonPath("$.page.total").value(2));
mockMvc.perform(get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("keyword", "Frontend Query List Smoke")
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].source_subject").value("Frontend Query List Smoke"))
.andExpect(jsonPath("$.items[1].source_subject").value("Frontend Query List Smoke"))
.andExpect(jsonPath("$.page.total").value(2));
}
@Test
void shouldReturnOrderDetailWithTaskTimeline() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
"mail-frontend-order-detail-001",
"Frontend Query Detail Smoke");
Long orderId = 930000000000000401L;
Long firstTaskId = 930000000000000601L;
Long secondTaskId = 930000000000000602L;
insertActiveGroupOrder(orderId, source.inboxId(), "GRP-FRONTEND-DETAIL-001");
insertTransition(930000000000000501L, source.inboxId(), 1, "GRP-FRONTEND-DETAIL-001",
"New Booking", "NEW_BOOKING", "NEW_BOOKING");
insertTransition(930000000000000502L, source.inboxId(), 2, "GRP-FRONTEND-DETAIL-001",
"Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING");
insertTask(firstTaskId, orderId, source.inboxId(), 930000000000000501L, "New Booking",
"NEW_BOOKING", "NEW_BOOKING", "COMPLETED", 1);
insertTask(secondTaskId, orderId, source.inboxId(), 930000000000000502L, "Update Booking",
"UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 2);
mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId)
.param("hotel_id", HOTEL_ID)
.param("include_tasks", "true")
.param("include_source_summary", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order.order_id").value(orderId.toString()))
.andExpect(jsonPath("$.order.hotel_id").value(HOTEL_ID))
.andExpect(jsonPath("$.order.order_status").value("ACTIVE"))
.andExpect(jsonPath("$.order.temporary_order_no").value("TMP-" + orderId))
.andExpect(jsonPath("$.order.group_code").value("GRP-FRONTEND-DETAIL-001"))
.andExpect(jsonPath("$.order.confirmation_number").value(nullValue()))
.andExpect(jsonPath("$.order.display_name").value("GRP-FRONTEND-DETAIL-001"))
.andExpect(jsonPath("$.tasks[0].task_id").value(firstTaskId.toString()))
.andExpect(jsonPath("$.tasks[0].task_status").value("COMPLETED"))
.andExpect(jsonPath("$.tasks[0].readonly_reason_code").value("TASK_FINISHED"))
.andExpect(jsonPath("$.tasks[1].task_id").value(secondTaskId.toString()))
.andExpect(jsonPath("$.tasks[1].task_status").value("PENDING_CONFIRM"))
.andExpect(jsonPath("$.tasks[1].can_process").value(true))
.andExpect(jsonPath("$.tasks[1].readonly_reason_code").value("PROCESSABLE"))
.andExpect(jsonPath("$.warnings.length()").value(0));
mockMvc.perform(get("/api/reservation/orders/{orderId}", orderId)
.param("hotel_id", HOTEL_ID)
.param("include_tasks", "false"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order.order_id").value(orderId.toString()))
.andExpect(jsonPath("$.tasks.length()").value(0));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String subject) {
return captureService.capture(new CaptureSourceMessageCommand(
HOTEL_ID,
"AGENTBUS",
"EMAIL",
externalMessageId,
"thread-" + externalMessageId,
"frame-" + externalMessageId,
"session-frontend-query",
Instant.parse("2026-07-08T08:00:00Z"),
"guest@example.test",
subject,
"Please handle booking message.",
"<html><body>Please handle booking message.</body></html>",
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
"agentbus-outlook-v1",
List.of()
));
}
private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, business_key_source, display_name,
source_message_id, version, created_at, updated_at
)
VALUES (?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'AI_CANDIDATE', ?, ?, 0,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", orderId, HOTEL_ID, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId);
}
private void insertTransition(
Long transitionId,
Long sourceMessageId,
Integer sourceEventIndex,
String groupCode,
String aiTaskType,
String systemTaskType,
String taskCardType) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_ai_transition (
id, hotel_id, batch_id, source_message_id, source_event_index, array_index,
execution_order, catalog_code, skill_id, result_type, ai_task_type,
system_task_type, task_card_type, task_subtype, current_or_history,
group_code, item_payload_sha256, item_idempotency_key, blocked_until_parent_completed,
ai_payload_json, case_keys_json, extracted_fields_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, 'S02', 'frontend_query_skill',
'normal_task', ?, ?, ?, 'frontend_query', 'current', ?, ?, ?, 0,
'{}', '{}', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", transitionId, HOTEL_ID, transitionId - 1, sourceMessageId, sourceEventIndex,
sourceEventIndex, sourceEventIndex, aiTaskType, systemTaskType, taskCardType, groupCode,
"0".repeat(64), transitionId.toString());
}
private void insertTask(
Long taskId,
Long orderId,
Long sourceMessageId,
Long transitionId,
String aiTaskType,
String systemTaskType,
String taskCardType,
String taskStatus,
Integer executionOrder) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_task (
id, hotel_id, order_id, source_message_id, ai_transition_id,
result_type, ai_task_type, system_task_type, task_card_type, task_subtype,
task_status, queue_participation, execution_order, blocked_until_parent_completed,
version, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, 'normal_task', ?, ?, ?, 'frontend_query',
?, 1, ?, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", taskId, HOTEL_ID, orderId, sourceMessageId, transitionId, aiTaskType,
systemTaskType, taskCardType, taskStatus, executionOrder);
}
}