diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 939466e..24fa967 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -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', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 3520db3..9036eed 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -259,6 +259,7 @@ export default { tableTitle: 'ข้อมูลงาน', totalSuffix: 'งาน', noSourceSubject: 'ไม่มีหัวข้ออีเมลต้นทาง', + sourceReceivedAt: 'เวลารับอีเมล', canProcess: 'ประมวลผลได้', cannotProcess: 'ประมวลผลไม่ได้', queueParticipation: 'อยู่ในคิวหรือไม่', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 83bb21d..58946f3 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -259,6 +259,7 @@ export default { tableTitle: '任务数据', totalSuffix: '条任务', noSourceSubject: '暂无来源主题', + sourceReceivedAt: '来源时间', canProcess: '可处理', cannotProcess: '不可处理', queueParticipation: '是否参与队列', diff --git a/client/src/tests/reservationViews.spec.ts b/client/src/tests/reservationViews.spec.ts index b4d1091..10c1bbb 100644 --- a/client/src/tests/reservationViews.spec.ts +++ b/client/src/tests/reservationViews.spec.ts @@ -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')) diff --git a/client/src/views/reservation/ReservationTaskListView.vue b/client/src/views/reservation/ReservationTaskListView.vue index 8f9955e..c200893 100644 --- a/client/src/views/reservation/ReservationTaskListView.vue +++ b/client/src/views/reservation/ReservationTaskListView.vue @@ -187,6 +187,7 @@ {{ task.source_subject ?? t('taskList.noSourceSubject') }} {{ task.source_sender_summary ?? task.source_message_id ?? '-' }} + {{ t('taskList.sourceReceivedAt') }}: {{ formatReservationDateTime(task.source_received_at) }} {{ task.queue_sequence ?? '-' }} @@ -279,6 +280,7 @@ import { formatReservationTaskRouteSummary, isReservationReadonlyDiagnosticTask, } from '@/utils/reservationDisplay' +import { formatReservationDateTime } from '@/utils/reservationFormat' const { t } = useI18n() const authStore = useAuthStore() diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 3a7fa09..ce1f2ec 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -50,8 +50,8 @@ | `POST /api/auth/login` | 用户名密码登录 | 成功后返回 `access_token`、当前用户、可访问酒店、权限码和可见菜单;token 只放 `sessionStorage`,不要放 `localStorage`、URL、日志或错误上报。 | | `GET /api/auth/me` | 恢复当前登录态 | 前端启动后带 `Authorization: Bearer ` 调用;401 时清理 token 并进入登录页。 | | `POST /api/auth/logout` | 登出当前 session | 带 `Authorization: Bearer `;成功后前端必须清理本地 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` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index c5c3077..f98e096 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -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` 作为排序字段,并在订单创建、任务创建、草稿保存、最终确认、人工复核解阻、任务状态变更等写路径维护;前端不要再基于任务时间或更新时间自行重排。 + 建议入参: | 参数 | 必填 | 说明 | diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index 7c9ffdd..6740f3b 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -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 或测试库已经跑过。 - 表和字段中文注释能正常创建。 diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index b520c48..a2f03a1 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -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[]` 元数据。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java index 73ecdf2..2defc02 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/domain/ReservationOrderEntity.java @@ -38,6 +38,8 @@ public class ReservationOrderEntity { private Long sourceMessageId; /** 首次创建该订单的任务 ID,CP1-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; } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOrderMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOrderMapper.java index 060607d..a0b506c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOrderMapper.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationOrderMapper.java @@ -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 { + + /** + * 分页查询前端订单列表。排序按订单维护的最新业务活动时间倒序,避免每次列表查询聚合全量任务。 + */ + @Select(""" + + """) + Page selectFrontendOrderPage( + Page 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 sourceMessageIds); } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java index 94b66c1..2d026ad 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/mapper/ReservationTaskMapper.java @@ -24,6 +24,9 @@ public interface ReservationTaskMapper extends BaseMapper 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} AND t.order_id = #{orderId} @@ -61,7 +64,14 @@ public interface ReservationTaskMapper extends BaseMapper ) - ORDER BY t.order_id ASC, t.execution_order ASC + + + ORDER BY t.execution_order ASC, t.id ASC + + + ORDER BY COALESCE(sm.received_at, t.created_at) DESC, t.created_at DESC, t.id DESC + + """) Page selectFrontendTaskPage( diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java index f68d7fd..c0f6035 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/MybatisReservationAiWorkflowRepository.java @@ -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 sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds; - Page page = orderMapper.selectPage(Page.of(pageNum, pageSize), - Wrappers.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 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.lambdaUpdate() + int updatedTaskCount = taskMapper.update(entity, Wrappers.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.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.lambdaUpdate() + boolean updated = taskCardMapper.update(null, Wrappers.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.lambdaUpdate() + boolean updated = operaOperationMapper.update(null, Wrappers.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.lambdaUpdate() + boolean updated = taskMapper.update(null, Wrappers.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.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.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 元数据。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java index cd7a739..115e647 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/repository/ReservationAiWorkflowRepository.java @@ -248,6 +248,7 @@ public interface ReservationAiWorkflowRepository { boolean updateOperaOperationAfterAttempt( String hotelId, Long operationId, + Long orderId, String operationStatus, Long lastAttemptId, String lastErrorMessage, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java index 4c73723..b53ace7 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskWorkflowServiceImpl.java @@ -500,6 +500,7 @@ public class ReservationTaskWorkflowServiceImpl implements ReservationTaskWorkfl workflowRepository.updateOperaOperationAfterAttempt( task.hotelId(), operation.id(), + task.orderId(), operationStatus, attemptId, errorMessage, diff --git a/server/src/main/resources/db/migration/V21__add_reservation_order_latest_activity.sql b/server/src/main/resources/db/migration/V21__add_reservation_order_latest_activity.sql new file mode 100644 index 0000000..bf8e053 --- /dev/null +++ b/server/src/main/resources/db/migration/V21__add_reservation_order_latest_activity.sql @@ -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); diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java index 6996c99..01ff0d3 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -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); } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java index 79a8d71..b10a6d5 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/SuperAgentTaskResultControllerTest.java @@ -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",