Merge pull request 'feature/huangting' (#3) from feature/huangting into develop

Reviewed-on: #3
This commit is contained in:
2026-07-13 14:21:24 +08:00
18 changed files with 438 additions and 47 deletions

View File

@@ -259,6 +259,7 @@ export default {
tableTitle: 'Task data',
totalSuffix: 'tasks',
noSourceSubject: 'No source subject',
sourceReceivedAt: 'Source time',
canProcess: 'Processable',
cannotProcess: 'Not processable',
queueParticipation: 'Queue participation',

View File

@@ -259,6 +259,7 @@ export default {
tableTitle: 'ข้อมูลงาน',
totalSuffix: 'งาน',
noSourceSubject: 'ไม่มีหัวข้ออีเมลต้นทาง',
sourceReceivedAt: 'เวลารับอีเมล',
canProcess: 'ประมวลผลได้',
cannotProcess: 'ประมวลผลไม่ได้',
queueParticipation: 'อยู่ในคิวหรือไม่',

View File

@@ -259,6 +259,7 @@ export default {
tableTitle: '任务数据',
totalSuffix: '条任务',
noSourceSubject: '暂无来源主题',
sourceReceivedAt: '来源时间',
canProcess: '可处理',
cannotProcess: '不可处理',
queueParticipation: '是否参与队列',

View File

@@ -494,6 +494,22 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).not.toContain('NEW_BOOKING / NEW_BOOKING')
})
it('renders task source received time in the source email column', async () => {
const taskListResult = createTaskListResult('GRP-001', 'Booking Request')
taskListResult.items[0] = {
...taskListResult.items[0]!,
source_sender_summary: 'guest@example.test',
source_received_at: '2026-07-08T03:00:00Z',
}
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(taskListResult)
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
expect(wrapper.text()).toContain('来源时间')
expect(wrapper.text()).toContain('2026-07-08 10:00:00')
})
it('filters source-message-only tasks with S10 and S99 subtypes', async () => {
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createSourceMessageOnlyTaskListResult('S10'))

View File

@@ -187,6 +187,7 @@
<td>
<span>{{ task.source_subject ?? t('taskList.noSourceSubject') }}</span>
<small>{{ task.source_sender_summary ?? task.source_message_id ?? '-' }}</small>
<small>{{ t('taskList.sourceReceivedAt') }}: {{ formatReservationDateTime(task.source_received_at) }}</small>
</td>
<td>
<span>{{ task.queue_sequence ?? '-' }}</span>
@@ -279,6 +280,7 @@ import {
formatReservationTaskRouteSummary,
isReservationReadonlyDiagnosticTask,
} from '@/utils/reservationDisplay'
import { formatReservationDateTime } from '@/utils/reservationFormat'
const { t } = useI18n()
const authStore = useAuthStore()

View File

@@ -50,8 +50,8 @@
| `POST /api/auth/login` | 用户名密码登录 | 成功后返回 `access_token`、当前用户、可访问酒店、权限码和可见菜单token 只放 `sessionStorage`,不要放 `localStorage`、URL、日志或错误上报。 |
| `GET /api/auth/me` | 恢复当前登录态 | 前端启动后带 `Authorization: Bearer <access_token>` 调用401 时清理 token 并进入登录页。 |
| `POST /api/auth/logout` | 登出当前 session | 带 `Authorization: Bearer <access_token>`;成功后前端必须清理本地 token 和当前用户上下文。 |
| `GET /api/reservation/orders` | 查询订单列表 | 默认返回全部订单状态;`open_task_count` 排除 `COMPLETED``FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 |
| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process``readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL已返回来源邮件会话摘要字段并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和新 S10/S99 都以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type``ai_task_type``route_code``system_process_category`。 |
| `GET /api/reservation/orders` | 查询订单列表 | 默认返回全部订单状态;按后端维护的订单最近业务活动时间倒序,当前落库字段为 `workflow_reservation_order.latest_activity_at`,前端不要自行重排;`open_task_count` 排除 `COMPLETED``FAILED`;隐藏技术订单不返回,因此 S10/S99 和旧 S000/S999 不会在订单列表形成订单。 |
| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 未传 `order_id` 时按来源消息接收时间倒序,传 `order_id` 时按同订单队列顺序正序;`can_process``readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL已返回来源邮件会话摘要字段并支持 `order_status` 按任务所属订单状态筛选;旧 S000/S999 和新 S10/S99 都以 `task_type=SOURCE_MESSAGE_ONLY` 只读任务返回,列表已透出 `result_type``ai_task_type``route_code``system_process_category`。 |
| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排;`tasks[]` 已返回来源邮件会话摘要字段和 V3 路由字段;隐藏技术订单详情不可作为普通订单页打开。 |
| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态;`fields[]` 已包含 P0 字段元数据;源邮件只读通知卡字段列表和 OPERA 操作列表为空;结构化 S10/S99 通过 `source_message_only_result.agent_assessment``notification``manual_review` 展示;普通业务任务可通过 `adapter_contract_errors[]``unhandled_intents[]` 查看同批次未建任务的诊断信息type-known manual review 会返回顶层 `review_status``review_resolution``manual_review`。 |
| `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 |

View File

@@ -60,6 +60,8 @@ GET /api/reservation/tasks
本轮前端新增“任务列表”菜单,并且任务列表、订单详情任务队列都需要能跳转到该任务来源消息所在的完整邮件会话。因此建议在现有返回项上补齐来源邮件会话摘要字段。
默认排序:未传 `order_id` 时按来源消息接收时间倒序返回,保证任务列表最新消息 / 最新任务在前;传 `order_id` 时按同订单 `queue_sequence` 正序返回,保证订单队列处理顺序不被打乱。
已完成字段:
| 字段 | 说明 |
@@ -240,6 +242,8 @@ GET /api/reservation/orders
当前状态:后端已完成第一版。默认查询全部订单状态;`open_task_count` 排除 `COMPLETED``FAILED``next_processable_task_id` 按同订单队列可处理状态实时计算。
默认排序:按后端维护的订单最近业务活动时间倒序返回,保证最近有业务活动的订单排在前面。后端当前使用 `workflow_reservation_order.latest_activity_at` 作为排序字段,并在订单创建、任务创建、草稿保存、最终确认、人工复核解阻、任务状态变更等写路径维护;前端不要再基于任务时间或更新时间自行重排。
建议入参:
| 参数 | 必填 | 说明 |

View File

@@ -215,6 +215,7 @@
- `server/src/main/resources/db/migration/V6__create_reservation_opera_simulation_tables.sql`
- `server/src/main/resources/db/migration/V11__add_reservation_order_visibility.sql`
- `server/src/main/resources/db/migration/V16__add_m002_v3_ai_route_fields.sql`
- `server/src/main/resources/db/migration/V21__add_reservation_order_latest_activity.sql`
当前 M004 Debug EML 相关 migration
@@ -242,6 +243,7 @@
- 目标数据库为空库或 Flyway history 与当前代码一致。
- 如果某个环境已经在缺少 V10 的临时提交上执行过 V11 / V12不能直接用默认 Flyway 策略补跑 V10应先重建测试库或按运维窗口明确 out-of-order / repair 策略。
- V21 会为 `workflow_reservation_order` 增加 `latest_activity_at`,并按订单更新时间和历史任务最新来源 / 创建时间回填一次;上线后订单列表依赖该字段排序,不再在列表查询时聚合全量任务。发布后需要确认 Flyway 已执行到 V21且订单列表能按最新业务活动倒序返回。
- MySQL 版本满足项目要求,默认使用 MySQL 8.0+。
- migration 在 UAT 或测试库已经跑过。
- 表和字段中文注释能正常创建。

View File

@@ -325,7 +325,7 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity
- 已补齐 `GET /api/reservation/tasks` 来源邮件会话摘要字段:`source_sender_summary``source_received_at``external_conversation_id``conversation_message_count`
- 已补齐 `GET /api/reservation/orders/{orderId}``tasks[]` 来源邮件会话摘要字段。
- 已补齐 `GET /api/reservation/tasks/{taskId}` 顶层来源邮件会话字段,并在 `fields[]` 透出 `result_type``task_type``task_subtype``default_value_source`
- 已实现订单列表接口 `GET /api/reservation/orders`默认查询全部订单状态支持酒店、订单状态、Group Code、Confirmation No.、关键词和分页筛选;`keyword` 可匹配订单字段,也可匹配来源消息安全摘要命中的 SourceMessage ID`open_task_count` 排除 `COMPLETED``FAILED`
- 已实现订单列表接口 `GET /api/reservation/orders`默认查询全部订单状态支持酒店、订单状态、Group Code、Confirmation No.、关键词和分页筛选;`keyword` 可匹配订单字段,也可匹配来源消息安全摘要命中的 SourceMessage ID`open_task_count` 排除 `COMPLETED``FAILED`;订单列表排序已改为读取 `workflow_reservation_order.latest_activity_at`,避免列表查询每次聚合全量任务
- 已实现邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation`,根据 SourceMessage 定位外部会话,返回完整 text/html、`html_body_sanitized``html_render_mode`、附件外链、内联图片、来源摘要和关联订单 / 任务摘要;原文读取审计由后端内部写入,前端展示 HTML 时优先使用清洗字段。
- 已实现 dev/test 受控演示数据 seed 接口 `POST /api/system/reservation/demo-data`,默认关闭,需配置 `reservation.demo-data.enabled=true` 和访问口令;生成真实落库的任务列表、订单列表、订单详情、任务详情和邮件会话详情演示数据。
- Message Notification 独立列表 / 详情、任务卡前端字段白名单独立接口继续后置;第一版分别复用任务列表 / 任务详情和 `fields[]` 元数据。

View File

@@ -38,6 +38,8 @@ public class ReservationOrderEntity {
private Long sourceMessageId;
/** 首次创建该订单的任务 IDCP1-3 允许为空。 */
private Long createdFromTaskId;
/** 订单最近业务活动 UTC 时间,用于订单列表按最新活动排序。 */
private LocalDateTime latestActivityAt;
/** 订单进入 ENDED 状态的 UTC 时间。 */
private LocalDateTime endedAt;
/** 逻辑删除 UTC 时间。 */
@@ -77,6 +79,8 @@ public class ReservationOrderEntity {
public void setSourceMessageId(Long sourceMessageId) { this.sourceMessageId = sourceMessageId; }
public Long getCreatedFromTaskId() { return createdFromTaskId; }
public void setCreatedFromTaskId(Long createdFromTaskId) { this.createdFromTaskId = createdFromTaskId; }
public LocalDateTime getLatestActivityAt() { return latestActivityAt; }
public void setLatestActivityAt(LocalDateTime latestActivityAt) { this.latestActivityAt = latestActivityAt; }
public LocalDateTime getEndedAt() { return endedAt; }
public void setEndedAt(LocalDateTime endedAt) { this.endedAt = endedAt; }
public LocalDateTime getLogicDeletedAt() { return logicDeletedAt; }

View File

@@ -2,11 +2,72 @@ package cn.nianxx.thhotel.workflows.reservation.mapper;
import cn.nianxx.thhotel.workflows.reservation.domain.ReservationOrderEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* Reservation 订单 Mapper只负责本表持久化访问。
*/
@Mapper
public interface ReservationOrderMapper extends BaseMapper<ReservationOrderEntity> {
/**
* 分页查询前端订单列表。排序按订单维护的最新业务活动时间倒序,避免每次列表查询聚合全量任务。
*/
@Select("""
<script>
SELECT o.*
FROM workflow_reservation_order o
WHERE o.hotel_id = #{hotelId}
AND o.order_visibility = #{visibleVisibility}
<if test="orderStatus != null and orderStatus != ''">
AND o.order_status = #{orderStatus}
</if>
<if test="groupCode != null and groupCode != ''">
AND (
o.order_business_key LIKE CONCAT('%', #{groupCode}, '%')
OR o.active_business_key LIKE CONCAT('%', #{groupCode}, '%')
OR o.display_name LIKE CONCAT('%', #{groupCode}, '%')
)
</if>
<if test="confirmationNumber != null and confirmationNumber != ''">
AND (
o.order_business_key LIKE CONCAT('%', #{confirmationNumber}, '%')
OR o.active_business_key LIKE CONCAT('%', #{confirmationNumber}, '%')
OR o.display_name LIKE CONCAT('%', #{confirmationNumber}, '%')
)
</if>
<if test="keyword != null and keyword != ''">
AND (
o.order_business_key LIKE CONCAT('%', #{keyword}, '%')
OR o.active_business_key LIKE CONCAT('%', #{keyword}, '%')
OR o.temporary_order_code LIKE CONCAT('%', #{keyword}, '%')
OR o.order_status LIKE CONCAT('%', #{keyword}, '%')
OR o.display_name LIKE CONCAT('%', #{keyword}, '%')
<if test="sourceMessageIds != null and sourceMessageIds.size() > 0">
OR o.source_message_id IN
<foreach collection="sourceMessageIds" item="sourceMessageIdItem" open="(" separator="," close=")">
#{sourceMessageIdItem}
</foreach>
</if>
)
</if>
ORDER BY
o.latest_activity_at DESC,
o.updated_at DESC,
o.id DESC
</script>
""")
Page<ReservationOrderEntity> selectFrontendOrderPage(
Page<ReservationOrderEntity> page,
@Param("hotelId") String hotelId,
@Param("orderStatus") String orderStatus,
@Param("groupCode") String groupCode,
@Param("confirmationNumber") String confirmationNumber,
@Param("keyword") String keyword,
@Param("visibleVisibility") String visibleVisibility,
@Param("sourceMessageIds") List<Long> sourceMessageIds);
}

View File

@@ -24,6 +24,9 @@ public interface ReservationTaskMapper extends BaseMapper<ReservationTaskEntity>
LEFT JOIN workflow_reservation_order o
ON o.hotel_id = t.hotel_id
AND o.id = t.order_id
LEFT JOIN platform_source_message_inbox sm
ON sm.hotel_id = t.hotel_id
AND sm.id = t.source_message_id
WHERE t.hotel_id = #{hotelId}
<if test="orderId != null">
AND t.order_id = #{orderId}
@@ -61,7 +64,14 @@ public interface ReservationTaskMapper extends BaseMapper<ReservationTaskEntity>
</if>
)
</if>
ORDER BY t.order_id ASC, t.execution_order ASC
<choose>
<when test="orderId != null">
ORDER BY t.execution_order ASC, t.id ASC
</when>
<otherwise>
ORDER BY COALESCE(sm.received_at, t.created_at) DESC, t.created_at DESC, t.id DESC
</otherwise>
</choose>
</script>
""")
Page<ReservationTaskEntity> selectFrontendTaskPage(

View File

@@ -204,6 +204,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.setDisplayName(draft.displayName());
entity.setSourceMessageId(draft.sourceMessageId());
entity.setVersion(0L);
entity.setLatestActivityAt(draft.now());
entity.setCreatedAt(draft.now());
entity.setUpdatedAt(draft.now());
orderMapper.insert(entity);
@@ -249,6 +250,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.setCreatedAt(draft.now());
entity.setUpdatedAt(draft.now());
taskMapper.insert(entity);
touchOrderLatestActivity(draft.hotelId(), draft.orderId(), draft.now());
return entity.getId();
}
@@ -444,41 +446,15 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
int pageNum,
int pageSize) {
List<Long> sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds;
Page<ReservationOrderEntity> page = orderMapper.selectPage(Page.of(pageNum, pageSize),
Wrappers.<ReservationOrderEntity>lambdaQuery()
.eq(ReservationOrderEntity::getHotelId, request.hotelId())
.ne(ReservationOrderEntity::getOrderVisibility, ReservationOrderVisibility.HIDDEN_SYSTEM.name())
.eq(hasText(request.orderStatus()),
ReservationOrderEntity::getOrderStatus,
trim(request.orderStatus()))
.and(hasText(request.groupCode()), wrapper -> wrapper
.like(ReservationOrderEntity::getOrderBusinessKey, trim(request.groupCode()))
.or()
.like(ReservationOrderEntity::getActiveBusinessKey, trim(request.groupCode()))
.or()
.like(ReservationOrderEntity::getDisplayName, trim(request.groupCode())))
.and(hasText(request.confirmationNumber()), wrapper -> wrapper
.like(ReservationOrderEntity::getOrderBusinessKey, trim(request.confirmationNumber()))
.or()
.like(ReservationOrderEntity::getActiveBusinessKey, trim(request.confirmationNumber()))
.or()
.like(ReservationOrderEntity::getDisplayName, trim(request.confirmationNumber())))
.and(hasText(request.keyword()), wrapper -> wrapper
.like(ReservationOrderEntity::getOrderBusinessKey, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getActiveBusinessKey, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getTemporaryOrderCode, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getOrderStatus, trim(request.keyword()))
.or()
.like(ReservationOrderEntity::getDisplayName, trim(request.keyword()))
.or(!sourceMessageIds.isEmpty())
.in(!sourceMessageIds.isEmpty(),
ReservationOrderEntity::getSourceMessageId,
sourceMessageIds))
.orderByDesc(ReservationOrderEntity::getUpdatedAt)
.orderByDesc(ReservationOrderEntity::getId));
Page<ReservationOrderEntity> page = orderMapper.selectFrontendOrderPage(
Page.of(pageNum, pageSize),
request.hotelId(),
trim(request.orderStatus()),
trim(request.groupCode()),
trim(request.confirmationNumber()),
trim(request.keyword()),
ReservationOrderVisibility.VISIBLE.name(),
sourceMessageIds);
return new ReservationPageSnapshot<>(
page.getRecords().stream().map(this::toAiQueryOrderSnapshot).toList(),
page.getTotal(),
@@ -592,6 +568,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.set(ReservationOrderEntity::getOrderStatus, ReservationOrderStatus.ACTIVE.name())
.set(ReservationOrderEntity::getBusinessKeySource, businessKeySource)
.set(ReservationOrderEntity::getDisplayName, orderBusinessKey)
.set(ReservationOrderEntity::getLatestActivityAt, now)
.set(ReservationOrderEntity::getUpdatedAt, now)
.eq(ReservationOrderEntity::getHotelId, hotelId)
.eq(ReservationOrderEntity::getId, orderId)
@@ -618,7 +595,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
entity.setTaskCardType(taskCardType);
entity.setExecutionOrder(executionOrder);
entity.setUpdatedAt(now);
taskMapper.update(entity, Wrappers.<ReservationTaskEntity>lambdaUpdate()
int updatedTaskCount = taskMapper.update(entity, Wrappers.<ReservationTaskEntity>lambdaUpdate()
.eq(ReservationTaskEntity::getHotelId, hotelId)
.eq(ReservationTaskEntity::getId, taskId));
ReservationTaskCardEntity cardEntity = new ReservationTaskCardEntity();
@@ -627,6 +604,9 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
taskCardMapper.update(cardEntity, Wrappers.<ReservationTaskCardEntity>lambdaUpdate()
.eq(ReservationTaskCardEntity::getHotelId, hotelId)
.eq(ReservationTaskCardEntity::getTaskId, taskId));
if (updatedTaskCount > 0) {
touchOrderLatestActivity(hotelId, orderId, now);
}
}
/**
@@ -634,11 +614,15 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
*/
@Override
public boolean updateTaskDraftPayload(String hotelId, Long taskId, String draftPayloadJson, LocalDateTime now) {
return taskCardMapper.update(null, Wrappers.<ReservationTaskCardEntity>lambdaUpdate()
boolean updated = taskCardMapper.update(null, Wrappers.<ReservationTaskCardEntity>lambdaUpdate()
.set(ReservationTaskCardEntity::getDraftPayloadJson, draftPayloadJson)
.set(ReservationTaskCardEntity::getUpdatedAt, now)
.eq(ReservationTaskCardEntity::getHotelId, hotelId)
.eq(ReservationTaskCardEntity::getTaskId, taskId)) > 0;
if (updated) {
touchOrderLatestActivityByTaskId(hotelId, taskId, now);
}
return updated;
}
/**
@@ -669,6 +653,9 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.set(ReservationTaskCardEntity::getUpdatedAt, confirmedAt)
.eq(ReservationTaskCardEntity::getHotelId, hotelId)
.eq(ReservationTaskCardEntity::getTaskId, taskId));
if (updatedCardCount > 0) {
touchOrderLatestActivityByTaskId(hotelId, taskId, confirmedAt);
}
return updatedCardCount > 0;
}
@@ -704,6 +691,9 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.set(ReservationTaskCardEntity::getUpdatedAt, confirmedAt)
.eq(ReservationTaskCardEntity::getHotelId, hotelId)
.eq(ReservationTaskCardEntity::getTaskId, taskId));
if (updatedCardCount > 0) {
touchOrderLatestActivityByTaskId(hotelId, taskId, confirmedAt);
}
return updatedCardCount > 0;
}
@@ -798,11 +788,12 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
public boolean updateOperaOperationAfterAttempt(
String hotelId,
Long operationId,
Long orderId,
String operationStatus,
Long lastAttemptId,
String lastErrorMessage,
LocalDateTime now) {
return operaOperationMapper.update(null, Wrappers.<ReservationOperaOperationEntity>lambdaUpdate()
boolean updated = operaOperationMapper.update(null, Wrappers.<ReservationOperaOperationEntity>lambdaUpdate()
.set(ReservationOperaOperationEntity::getOperationStatus, operationStatus)
.set(ReservationOperaOperationEntity::getLastAttemptId, lastAttemptId)
.set(ReservationOperaOperationEntity::getLastErrorMessage, lastErrorMessage)
@@ -810,6 +801,10 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.setSql("attempt_count = attempt_count + 1")
.eq(ReservationOperaOperationEntity::getHotelId, hotelId)
.eq(ReservationOperaOperationEntity::getId, operationId)) > 0;
if (updated) {
touchOrderLatestActivity(hotelId, orderId, now);
}
return updated;
}
/**
@@ -817,7 +812,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
*/
@Override
public boolean updateTaskStatus(String hotelId, Long taskId, String taskStatus, LocalDateTime now) {
return taskMapper.update(null, Wrappers.<ReservationTaskEntity>lambdaUpdate()
boolean updated = taskMapper.update(null, Wrappers.<ReservationTaskEntity>lambdaUpdate()
.set(ReservationTaskEntity::getTaskStatus, taskStatus)
.set(ReservationTaskEntity::getCompletedAt,
ReservationTaskStatus.COMPLETED.name().equals(taskStatus) ? now : null)
@@ -825,6 +820,10 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.setSql("version = version + 1")
.eq(ReservationTaskEntity::getHotelId, hotelId)
.eq(ReservationTaskEntity::getId, taskId)) > 0;
if (updated) {
touchOrderLatestActivityByTaskId(hotelId, taskId, now);
}
return updated;
}
/**
@@ -847,6 +846,7 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
.set(ReservationOrderEntity::getActiveBusinessKey, null)
.set(ReservationOrderEntity::getLogicDeletedAt, now)
.set(ReservationOrderEntity::getLogicDeletedReason, reason)
.set(ReservationOrderEntity::getLatestActivityAt, now)
.set(ReservationOrderEntity::getUpdatedAt, now)
.eq(ReservationOrderEntity::getHotelId, hotelId)
.eq(ReservationOrderEntity::getId, orderId)
@@ -916,6 +916,40 @@ public class MybatisReservationAiWorkflowRepository implements ReservationAiWork
}));
}
/**
* 按任务定位订单并刷新订单最近业务活动时间,用于任务状态、确认和草稿等写操作。
*/
private void touchOrderLatestActivityByTaskId(String hotelId, Long taskId, LocalDateTime activityAt) {
if (taskId == null) {
return;
}
ReservationTaskEntity task = taskMapper.selectOne(Wrappers.<ReservationTaskEntity>lambdaQuery()
.eq(ReservationTaskEntity::getHotelId, hotelId)
.eq(ReservationTaskEntity::getId, taskId)
.last("LIMIT 1"));
if (task == null) {
return;
}
touchOrderLatestActivity(hotelId, task.getOrderId(), activityAt);
}
/**
* 刷新订单最近业务活动时间。只前进不回退,避免旧事件覆盖更新的用户操作。
*/
private void touchOrderLatestActivity(String hotelId, Long orderId, LocalDateTime activityAt) {
if (!hasText(hotelId) || orderId == null || activityAt == null) {
return;
}
orderMapper.update(null, Wrappers.<ReservationOrderEntity>lambdaUpdate()
.set(ReservationOrderEntity::getLatestActivityAt, activityAt)
.eq(ReservationOrderEntity::getHotelId, hotelId)
.eq(ReservationOrderEntity::getId, orderId)
.and(wrapper -> wrapper
.isNull(ReservationOrderEntity::getLatestActivityAt)
.or()
.lt(ReservationOrderEntity::getLatestActivityAt, activityAt)));
}
/**
* 将任务实体批量转换为 AI 查询任务快照,补充 transition 中的 Skill 元数据。
*/

View File

@@ -248,6 +248,7 @@ public interface ReservationAiWorkflowRepository {
boolean updateOperaOperationAfterAttempt(
String hotelId,
Long operationId,
Long orderId,
String operationStatus,
Long lastAttemptId,
String lastErrorMessage,

View File

@@ -500,6 +500,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl
workflowRepository.updateOperaOperationAfterAttempt(
task.hotelId(),
operation.id(),
task.orderId(),
operationStatus,
attemptId,
errorMessage,

View File

@@ -0,0 +1,22 @@
-- M002 前端订单列表排序优化:订单自身维护最近业务活动时间,避免列表查询每次聚合全量任务。
ALTER TABLE workflow_reservation_order
ADD COLUMN latest_activity_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '订单最近业务活动 UTC 时间,用于订单列表最新活动排序';
-- 历史数据回填:优先取订单更新时间和所属任务最新来源/创建时间中的较大值。
UPDATE workflow_reservation_order
SET latest_activity_at = GREATEST(
updated_at,
COALESCE((
SELECT MAX(COALESCE(sm.received_at, t.created_at))
FROM workflow_reservation_task t
LEFT JOIN platform_source_message_inbox sm
ON sm.hotel_id = t.hotel_id
AND sm.id = t.source_message_id
WHERE t.hotel_id = workflow_reservation_order.hotel_id
AND t.order_id = workflow_reservation_order.id
), updated_at)
);
-- 订单列表按可见性和最新活动时间倒序分页。
CREATE INDEX idx_reservation_order_visibility_activity
ON workflow_reservation_order (hotel_id, order_visibility, latest_activity_at DESC, updated_at DESC, id DESC);

View File

@@ -1,7 +1,8 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static org.hamcrest.Matchers.nullValue;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.matchesPattern;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -18,8 +19,17 @@ import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCom
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import cn.nianxx.thhotel.platform.message.service.SourceMessageQueryService;
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.ReservationTaskDraft;
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.ReservationOrderVisibility;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -156,6 +166,176 @@ class ReservationFrontendQueryControllerTest {
.andExpect(jsonPath("$.page.total").value(1));
}
@Test
void shouldReturnTaskWorkbenchListWithLatestSourceMessageFirst() throws Exception {
SourceMessageCaptureResult oldSource = captureSourceMessage(
"mail-frontend-task-sort-old-001",
"Frontend Query Task Sort Old",
Instant.parse("2026-07-08T01:00:00Z"));
SourceMessageCaptureResult latestSource = captureSourceMessage(
"mail-frontend-task-sort-latest-001",
"Frontend Query Task Sort Latest",
Instant.parse("2026-07-10T09:30:00Z"));
Long oldOrderId = 930000000000001401L;
Long latestOrderId = 930000000000001402L;
Long oldTaskId = 930000000000001601L;
Long latestTaskId = 930000000000001602L;
insertGroupOrder(oldOrderId, oldSource.inboxId(), "GRP-FRONTEND-TASK-SORT-OLD", "ACTIVE");
insertGroupOrder(latestOrderId, latestSource.inboxId(), "GRP-FRONTEND-TASK-SORT-LATEST", "ACTIVE");
insertTransition(930000000000001501L, oldSource.inboxId(), 1, "GRP-FRONTEND-TASK-SORT-OLD",
"New Booking", "NEW_BOOKING", "NEW_BOOKING");
insertTransition(930000000000001502L, latestSource.inboxId(), 1, "GRP-FRONTEND-TASK-SORT-LATEST",
"Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING");
insertTask(oldTaskId, oldOrderId, oldSource.inboxId(), 930000000000001501L, "New Booking",
"NEW_BOOKING", "NEW_BOOKING", "PENDING_CONFIRM", 1);
insertTask(latestTaskId, latestOrderId, latestSource.inboxId(), 930000000000001502L, "Update Booking",
"UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 1);
mockMvc.perform(get("/api/reservation/tasks")
.param("hotel_id", HOTEL_ID)
.param("keyword", "GRP-FRONTEND-TASK-SORT-")
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].task_id").value(latestTaskId.toString()))
.andExpect(jsonPath("$.items[0].source_subject").value("Frontend Query Task Sort Latest"))
.andExpect(jsonPath("$.items[1].task_id").value(oldTaskId.toString()))
.andExpect(jsonPath("$.items[1].source_subject").value("Frontend Query Task Sort Old"))
.andExpect(jsonPath("$.page.total").value(2));
}
@Test
void shouldReturnOrderListWithLatestTaskActivityFirst() throws Exception {
SourceMessageCaptureResult oldSource = captureSourceMessage(
"mail-frontend-order-sort-old-001",
"Frontend Query Order Sort Old",
Instant.parse("2026-07-08T01:00:00Z"));
SourceMessageCaptureResult latestSource = captureSourceMessage(
"mail-frontend-order-sort-latest-001",
"Frontend Query Order Sort Latest",
Instant.parse("2026-07-10T09:30:00Z"));
Long staleButRecentlyUpdatedOrderId = 930000000000001701L;
Long latestActivityOrderId = 930000000000001702L;
Long staleTaskId = 930000000000001901L;
Long latestTaskId = 930000000000001902L;
insertGroupOrder(staleButRecentlyUpdatedOrderId, oldSource.inboxId(),
"GRP-FRONTEND-ORDER-SORT-STALE", "ACTIVE");
insertGroupOrder(latestActivityOrderId, latestSource.inboxId(),
"GRP-FRONTEND-ORDER-SORT-LATEST", "ACTIVE");
updateOrderUpdatedAt(staleButRecentlyUpdatedOrderId, Instant.parse("2026-07-09T00:00:00Z"));
updateOrderUpdatedAt(latestActivityOrderId, Instant.parse("2026-07-08T02:00:00Z"));
insertTransition(930000000000001801L, oldSource.inboxId(), 1, "GRP-FRONTEND-ORDER-SORT-STALE",
"New Booking", "NEW_BOOKING", "NEW_BOOKING");
insertTransition(930000000000001802L, latestSource.inboxId(), 1, "GRP-FRONTEND-ORDER-SORT-LATEST",
"Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING");
insertTask(staleTaskId, staleButRecentlyUpdatedOrderId, oldSource.inboxId(), 930000000000001801L,
"New Booking", "NEW_BOOKING", "NEW_BOOKING", "PENDING_CONFIRM", 1);
insertTask(latestTaskId, latestActivityOrderId, latestSource.inboxId(), 930000000000001802L,
"Update Booking", "UPDATE_BOOKING", "UPDATE_BOOKING", "PENDING_CONFIRM", 1);
updateOrderLatestActivityAt(staleButRecentlyUpdatedOrderId, Instant.parse("2026-07-09T00:00:00Z"));
updateOrderLatestActivityAt(latestActivityOrderId, Instant.parse("2026-07-10T09:30:00Z"));
mockMvc.perform(get("/api/reservation/orders")
.param("hotel_id", HOTEL_ID)
.param("keyword", "GRP-FRONTEND-ORDER-SORT-")
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].order_id").value(latestActivityOrderId.toString()))
.andExpect(jsonPath("$.items[0].group_code").value("GRP-FRONTEND-ORDER-SORT-LATEST"))
.andExpect(jsonPath("$.items[1].order_id").value(staleButRecentlyUpdatedOrderId.toString()))
.andExpect(jsonPath("$.items[1].group_code").value("GRP-FRONTEND-ORDER-SORT-STALE"))
.andExpect(jsonPath("$.page.total").value(2));
}
@Test
void shouldKeepOrderLatestActivityAtRequiredForIndexedSorting() {
String isNullable = jdbcTemplate.queryForObject("""
SELECT IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'WORKFLOW_RESERVATION_ORDER'
AND COLUMN_NAME = 'LATEST_ACTIVITY_AT'
""", String.class);
assertThat(isNullable).isEqualTo("NO");
}
@Test
void shouldOnlyReturnVisibleOrdersInFrontendOrderList() throws Exception {
SourceMessageCaptureResult visibleSource = captureSourceMessage(
"mail-frontend-order-visible-filter-001",
"Frontend Query Visible Filter");
SourceMessageCaptureResult archivedSource = captureSourceMessage(
"mail-frontend-order-visible-filter-archived-001",
"Frontend Query Visible Filter Archived");
Long visibleOrderId = 930000000000002201L;
Long archivedOrderId = 930000000000002202L;
insertGroupOrder(visibleOrderId, visibleSource.inboxId(),
"GRP-FRONTEND-VISIBLE-FILTER-001", "ACTIVE");
insertGroupOrder(archivedOrderId, archivedSource.inboxId(),
"GRP-FRONTEND-VISIBLE-FILTER-ARCHIVED", "ACTIVE");
updateOrderVisibility(archivedOrderId, "ARCHIVED_SYSTEM");
mockMvc.perform(get("/api/reservation/orders")
.param("hotel_id", HOTEL_ID)
.param("keyword", "GRP-FRONTEND-VISIBLE-FILTER-")
.param("page_num", "1")
.param("page_size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.items[0].order_id").value(visibleOrderId.toString()))
.andExpect(jsonPath("$.page.total").value(1));
}
@Test
void shouldTouchOrderLatestActivityWhenRepositoryInsertsTask() {
SourceMessageCaptureResult source = captureSourceMessage(
"mail-frontend-order-activity-touch-001",
"Frontend Query Order Activity Touch",
Instant.parse("2026-07-08T01:00:00Z"));
LocalDateTime orderCreatedAt = LocalDateTime.parse("2026-07-08T01:00:00");
LocalDateTime taskCreatedAt = LocalDateTime.parse("2026-07-10T09:30:00");
ReservationOrderSnapshot order = workflowRepository.insertOrder(new ReservationOrderDraft(
HOTEL_ID,
ReservationOrderKeyType.GROUP_CODE.name(),
"GRP-FRONTEND-ORDER-ACTIVITY-TOUCH",
"GRP-FRONTEND-ORDER-ACTIVITY-TOUCH",
"TMP-FRONTEND-ORDER-ACTIVITY-TOUCH",
ReservationOrderStatus.ACTIVE.name(),
ReservationOrderVisibility.VISIBLE.name(),
"AI_CANDIDATE",
null,
"GRP-FRONTEND-ORDER-ACTIVITY-TOUCH",
source.inboxId(),
orderCreatedAt));
workflowRepository.insertTask(new ReservationTaskDraft(
HOTEL_ID,
order.id(),
source.inboxId(),
930000000000002001L,
"normal_task",
"Update Booking",
"UPDATE_BOOKING",
"UPDATE_BOOKING",
"frontend_query",
ReservationTaskStatus.PENDING_CONFIRM.name(),
true,
1,
null,
null,
false,
null,
taskCreatedAt));
Timestamp latestActivityAt = jdbcTemplate.queryForObject("""
SELECT latest_activity_at
FROM workflow_reservation_order
WHERE id = ?
""", Timestamp.class, order.id());
assertThat(latestActivityAt).isNotNull();
assertThat(latestActivityAt.toLocalDateTime()).isEqualTo(taskCreatedAt);
}
@Test
void shouldReturnOrderDetailWithTaskTimeline() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(
@@ -272,6 +452,13 @@ class ReservationFrontendQueryControllerTest {
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId, String subject) {
return captureSourceMessage(externalMessageId, subject, Instant.parse("2026-07-08T08:00:00Z"));
}
private SourceMessageCaptureResult captureSourceMessage(
String externalMessageId,
String subject,
Instant receivedAt) {
return captureService.capture(new CaptureSourceMessageCommand(
HOTEL_ID,
"AGENTBUS",
@@ -280,7 +467,8 @@ class ReservationFrontendQueryControllerTest {
"thread-" + externalMessageId,
"frame-" + externalMessageId,
"session-frontend-query",
Instant.parse("2026-07-08T08:00:00Z"),
receivedAt,
null,
"guest@example.test",
subject,
"Please handle booking message.",
@@ -291,6 +479,30 @@ class ReservationFrontendQueryControllerTest {
));
}
private void updateOrderUpdatedAt(Long orderId, Instant updatedAt) {
jdbcTemplate.update("""
UPDATE workflow_reservation_order
SET updated_at = ?
WHERE id = ?
""", Timestamp.from(updatedAt), orderId);
}
private void updateOrderLatestActivityAt(Long orderId, Instant latestActivityAt) {
jdbcTemplate.update("""
UPDATE workflow_reservation_order
SET latest_activity_at = ?
WHERE id = ?
""", Timestamp.from(latestActivityAt), orderId);
}
private void updateOrderVisibility(Long orderId, String orderVisibility) {
jdbcTemplate.update("""
UPDATE workflow_reservation_order
SET order_visibility = ?
WHERE id = ?
""", orderVisibility, orderId);
}
private void insertActiveGroupOrder(Long orderId, Long sourceMessageId, String groupCode) {
insertGroupOrder(orderId, sourceMessageId, groupCode, "ACTIVE");
}
@@ -300,10 +512,10 @@ class ReservationFrontendQueryControllerTest {
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
source_message_id, version, latest_activity_at, created_at, updated_at
)
VALUES (?, ?, 'GROUP_CODE', ?, ?, ?, ?, 'AI_CANDIDATE', ?, ?, 0,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", orderId, HOTEL_ID, groupCode, groupCode, "TMP-" + orderId, orderStatus, groupCode, sourceMessageId);
}

View File

@@ -19,6 +19,7 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResu
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
@@ -1968,6 +1969,18 @@ class SuperAgentTaskResultControllerTest {
"CNF-CP5-OPERA-RETRY-001");
String taskId = taskAndOperationIds[0];
String firstOperationId = taskAndOperationIds[1];
Long orderId = jdbcTemplate.queryForObject("""
SELECT order_id
FROM workflow_reservation_task
WHERE id = ?
""", Long.class, Long.valueOf(taskId));
Timestamp staleLatestActivityAt = Timestamp.from(Instant.parse("2026-07-01T00:00:00Z"));
jdbcTemplate.update("""
UPDATE workflow_reservation_order
SET latest_activity_at = ?,
updated_at = ?
WHERE id = ?
""", staleLatestActivityAt, staleLatestActivityAt, orderId);
mockMvc.perform(post(
"/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
@@ -1984,6 +1997,12 @@ class SuperAgentTaskResultControllerTest {
.andExpect(jsonPath("$.operation_status").value("FAILED"))
.andExpect(jsonPath("$.attempt_count").value(1))
.andExpect(jsonPath("$.attempts[0].attempt_status").value("FAILED"));
Timestamp failedAttemptLatestActivityAt = jdbcTemplate.queryForObject("""
SELECT latest_activity_at
FROM workflow_reservation_order
WHERE id = ?
""", Timestamp.class, orderId);
assertThat(failedAttemptLatestActivityAt.toInstant()).isAfter(staleLatestActivityAt.toInstant());
mockMvc.perform(post(
"/api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry",