diff --git a/docs/import/20260708/任务卡前端展示字段表 3.0.xlsx b/docs/import/20260708/任务卡前端展示字段表 3.0.xlsx new file mode 100644 index 0000000..c756121 Binary files /dev/null and b/docs/import/20260708/任务卡前端展示字段表 3.0.xlsx differ diff --git a/docs/project/README.md b/docs/project/README.md index 282181e..d8a0abf 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -8,6 +8,7 @@ - `backend-development-guidelines.md`:当前项目后端专属规范。 - `frontend-development-guidelines.md`:当前项目前端专属规范。 +- `frontend-backend/README.md`:前后端协作入口,记录接口契约来源、字段白名单和当前后置事项。 - `go-live-notes.md`:当前项目上线注意事项,记录上线前检查、环境变量、安全、AgentBus、验证和回滚。 - `requirements/M001-source-message-inbox-prd.md`:M001 邮件来源入口 PRD,记录 AgentBus 邮件 JSON 入库、历史查询、原文读取和媒体引用边界。 - `requirements/M002-order-task-workflow-v1.md`:M002 订单任务主流程 V1,记录 SourceMessage 之后的 SuperAgent 抽取、订单挂靠、任务处理和 OPERA/OHIP 模拟操作边界。 diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md new file mode 100644 index 0000000..aed4f35 --- /dev/null +++ b/docs/project/frontend-backend/README.md @@ -0,0 +1,54 @@ +# 前后端协作入口 + +## 1. 文档定位 + +本文用于后端 agent、前端 agent 和测试在开发任务详情、订单详情、任务列表、Message Notification 等页面时统一查找接口契约、字段来源和当前后置事项。 + +如本文与 `AGENTS.md`、`docs/project/requirements/` 中的业务需求冲突,以 `AGENTS.md` 和对应需求文档为准。 + +## 2. 沟通文件 + +| 文件 | 用途 | +| --- | --- | +| `backend-to-frontend-notes.md` | 后端提醒前端的注意事项,包含项目开发、业务规则、接口使用和安全边界。 | +| `frontend-to-backend-api-requests.md` | 前端提醒后端需要增加或补齐的接口,包含建议入参和返参草案。 | + +## 3. 当前字段来源分工 + +| 来源 | 当前用途 | +| --- | --- | +| `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` | 前端任务卡展示 / 编辑白名单。前端页面优先按该表决定哪些字段展示、哪些字段可编辑。 | +| `docs/import/20260706/任务卡展示编辑矩阵.xlsx` | 后端完整规则来源。用于后端校验、最终确认写入、OPERA 映射、展示条件和任务卡完整约束。 | +| `docs/import/20260706/AI输出参数并集字典.xlsx` | AI 输出字段路径、字段含义、建议存储方式和索引参考。 | + +约束说明: + +- 最新前端 Excel 只作为展示 / 编辑白名单,不替代后端完整规则矩阵。 +- 后端不应因为前端白名单缺少字段而自动放宽必填、枚举、校验或 OPERA 映射规则。 +- 如果前端白名单与后端完整矩阵冲突,应先记录到 `frontend-to-backend-api-requests.md` 的待确认问题,再由产品 / 后端 / 前端一起确认。 + +## 4. 当前接口契约来源 + +| 契约 | 文档 | +| --- | --- | +| SuperAgent 任务结果入站接口 | `docs/project/requirements/M002-superagent-task-result-api-contract.md` | +| SuperAgent 查询上下文接口 1、2 | `docs/project/requirements/M002-ai-query-minimal-fields.md` | +| SuperAgent 对接总契约 | `docs/project/integrations/superagent-api-contract.md` | +| 订单任务主流程 | `docs/project/requirements/M002-order-task-workflow-v2.md` | +| 后端 checkpoint | `docs/project/requirements/M002-backend-checkpoint-plan.md` | + +## 5. 当前已明确后置事项 + +- 普通任务切换订单接口后置。 +- SuperAgent 查询接口 4 后置,当前先不开发。 +- SuperAgent 查询接口 3 涉及附件解析、OCR、Excel、voucher、rooming list 等能力,当前系统暂不具备,仍后置。 +- 用户身份 / 权限方案后置;当前后端审计 actor 仍是本地占位。 +- 真实 OPERA / OHIP 接入后置;当前仅有 OPERA 模拟骨架。 + +## 6. 前端开发注意事项 + +- 前端不得直接调用 SuperAgent、AgentBus、OPERA、OHIP 或数据库。 +- 前端不得发送后端 Secret、replay access key、Provider API Key 或 Oracle 凭证。 +- 页面展示文案可以本地化,但业务判断必须使用接口返回的稳定 code。 +- 任务详情页保存草稿和最终确认是两个接口,不能合并成一个前端动作。 +- 同一订单下,如果前置任务未结束,后续任务只能查看,不能编辑、确认或执行 OPERA 模拟操作。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md new file mode 100644 index 0000000..af5f467 --- /dev/null +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -0,0 +1,62 @@ +# 后端提醒前端注意事项 + +## 1. 文档定位 + +本文记录后端侧提醒前端开发时必须注意的项目规范、业务规则、接口边界和安全要求。当前内容服务于 Reservation 任务详情、订单详情、任务列表、Message Notification 等第一版页面。 + +## 2. 项目开发注意事项 + +- 前端只调用本项目后端接口,不直接调用 SuperAgent、AgentBus、OPERA、OHIP 或数据库。 +- API 调用应统一放在前端 `src/services`,页面组件不要直接拼接后端 URL。 +- 业务判断必须使用后端返回的稳定 code,不使用中文或英文展示文案做判断。 +- 前端不得保存或传递后端 Secret、replay access key、Provider API Key、Oracle 凭证、AgentBus Token。 +- 后端数据库 ID 未来应尽量以字符串形式给前端,避免 JavaScript 长整型精度问题;如果当前接口仍返回数字,前端不要自行做数学运算。 +- 接口字段发生变化前,需要先更新本目录沟通文档或对应需求文档。 + +## 3. 字段来源注意事项 + +- `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 是前端展示 / 编辑白名单。 +- `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 是后端校验、最终确认写入、OPERA 映射和展示条件的完整规则来源。 +- 前端不要直接把整个 `ai_task_results[]` 渲染成表单,只展示白名单允许的字段。 +- 如果 3.0 白名单与旧矩阵冲突,应记录为前后端待确认问题,不由前端单方面放宽必填、枚举或校验规则。 + +## 4. 业务规则注意事项 + +- 任务详情页里,保存草稿和最终确认是两个独立动作,不能合并。 +- 用户可以修改任务字段内容;最终确认后,后端使用 `confirmed_payload_json` 作为 OPERA 模拟输入来源。 +- 同一订单下,前置任务未结束时,后续任务只能查看,不能编辑、确认或执行 OPERA 模拟操作。 +- 任务状态 `FAILED` 第一版视为结束状态,不阻塞后续任务;但失败的 OPERA 操作不能跳过,必须展示失败原因并允许重试。 +- Message Notification 挂临时订单,只读展示,不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 +- Fallback / manual_review 转为 New / Update / Cancel 时需要展示审计轨迹;当前 actor 仍是本地占位,正式用户身份后置。 +- 普通任务切换订单接口已确认后置,前端第一版不要把普通任务拖拽或切换订单做成可提交能力。 + +## 5. 当前前端可用接口注意事项 + +| 接口 | 用途 | 前端注意 | +| --- | --- | --- | +| `GET /api/reservation/tasks` | 查询任务列表 / 工作台 | 用 `can_process` 和 `readonly_reason_code` 控制入口按钮;列表不返回 AI 原始 payload、邮件正文或附件 URL。 | +| `GET /api/reservation/orders/{orderId}` | 查询订单详情与任务时间线 | `include_tasks=false` 可只取订单摘要;时间线按后端队列顺序返回,前端不要自行按创建时间重排。 | +| `GET /api/reservation/tasks/{taskId}` | 查询任务详情 | 以返回的可处理状态和只读原因控制按钮,不只看任务状态。 | +| `PUT /api/reservation/tasks/{taskId}/draft` | 保存任务草稿 | 只保存草稿,不代表用户最终确认。 | +| `POST /api/reservation/tasks/{taskId}/confirm` | 最终确认任务 | 后端会做第一版字段校验,通过后进入 `READY`。 | +| `POST /api/reservation/tasks/{taskId}/manual-review-conversions` | Fallback 人工转换 | 只用于 manual_review / fallback,不用于普通任务切换订单。 | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute` | 执行 OPERA 模拟操作 | 当前是模拟,不调用真实 OPERA。 | +| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | 重试失败 OPERA 模拟操作 | 重试会追加 attempt 历史,前端不要覆盖旧失败记录。 | +| `GET /api/reservation/tasks/{taskId}/audits` | 查询任务审计流水 | 用于展示人工确认、转换、模拟操作等轨迹。 | +| `GET /api/source-messages` | 查询来源消息安全摘要 | 列表不返回邮件正文、HTML、附件 URL 或原始 payload。 | +| `GET /api/source-messages/{id}` | 查询来源消息安全详情 | 只用于安全摘要详情。 | +| `GET /api/source-messages/{id}/original` | 读取来源消息原文 | 需要受控访问头,返回 HTML 时前端展示前必须 sanitize。 | + +## 6. 不给前端直接调用的接口 + +- `POST /api/integrations/superagent/task-results` 是 SuperAgent 到后端的服务到服务入站接口。 +- `POST /api/ai-query/v1/case-context` 和 `POST /api/ai-query/v1/object-detail` 是 SuperAgent 查询上下文接口,不是前端页面接口。 +- AgentBus probe、fixture、replay、system 类接口不应放到普通业务前端页面。 + +## 7. 需要持续提醒的后置事项 + +- SuperAgent 查询接口 3 文件解析当前不能做。 +- SuperAgent 查询接口 4 已确认继续后置。 +- 普通任务切换订单接口继续后置。 +- 用户 / 权限方案继续后置。 +- 真实 OPERA / OHIP 接入继续后置。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md new file mode 100644 index 0000000..1a00d9b --- /dev/null +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -0,0 +1,284 @@ +# 前端提醒后端待补接口 + +## 1. 文档定位 + +本文记录前端页面开发时希望后端新增、补齐或稳定的接口草案。本文中的路径、入参和返参是前端视角的最小诉求,不代表后端已经承诺实现;进入开发前需要后端按当前包结构、权限、安全和业务规则二次确认。 + +## 2. 当前待补接口总览 + +| 优先级 | 接口 | 页面 / 场景 | 状态 | +| --- | --- | --- | --- | +| P0 | 任务列表 / 工作台接口 | 任务列表页、首页工作台 | 已实现第一版 | +| P0 | 订单详情与任务时间线接口 | 订单详情页 | 已实现第一版 | +| P1 | 订单列表接口 | 订单检索、订单入口 | 待后端设计 | +| P1 | Message Notification 列表 / 详情接口 | 信息提醒页或订单详情只读卡片 | 待后端设计 | +| P1 | 任务卡前端字段白名单元数据接口 | 任务详情动态渲染 | 待确认是否需要后端提供 | +| 后置 | 普通任务切换订单接口 | 任务详情订单归属调整 | 已确认后置 | + +## 3. 任务列表 / 工作台接口 + +建议路径: + +```text +GET /api/reservation/tasks +``` + +当前状态:后端已按 P0 最小诉求实现第一版。接口只返回任务摘要、订单展示键、来源消息主题和实时可处理状态,不返回完整 AI payload、邮件正文或附件 URL。 + +建议入参: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `hotel_id` | 否 | 酒店 ID。第一版如果只有单酒店,可为空。 | +| `order_id` | 否 | 按订单过滤。 | +| `task_type` | 否 | `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`、`INFORMATIONAL_MESSAGE`。 | +| `task_status` | 否 | 任务状态过滤。 | +| `task_subtype` | 否 | 任务卡 subtype 过滤。 | +| `queue_participation` | 否 | 是否参与订单执行队列。 | +| `keyword` | 否 | Group Code、Confirmation No、临时订单号、来源消息安全摘要关键词。 | +| `page_num` | 否 | 页码,建议从 1 开始。 | +| `page_size` | 否 | 每页条数。 | + +建议返参: + +```json +{ + "items": [ + { + "task_id": "10001", + "order_id": "20001", + "hotel_id": "HOTEL-TEST", + "display_order_key": "GRP-001", + "temporary_order_no": "TMP-20260708-001", + "task_type": "UPDATE_BOOKING", + "task_subtype": "RATE_CHANGE", + "task_status": "PENDING_CONFIRM", + "card_name": "Rate Change", + "queue_sequence": 2, + "queue_participation": true, + "can_process": false, + "readonly_reason_code": "PREVIOUS_TASK_NOT_FINISHED", + "source_message_id": "30001", + "source_subject": "Booking Update", + "created_at": "2026-07-08T03:00:00Z", + "updated_at": "2026-07-08T03:10:00Z" + } + ], + "page": { + "page_num": 1, + "page_size": 20, + "total": 1 + } +} +``` + +## 4. 订单详情与任务时间线接口 + +建议路径: + +```text +GET /api/reservation/orders/{orderId} +``` + +当前状态:后端已按 P0 最小诉求实现第一版。接口返回订单摘要和同订单任务时间线;`include_tasks=false` 时只返回订单摘要。`include_source_summary` 第一版保留入参但订单时间线暂不展开来源摘要,来源主题请优先从任务列表接口读取。 + +建议入参: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `orderId` | 是 | 订单 ID。 | +| `include_tasks` | 否 | 是否返回任务时间线,默认 `true`。 | +| `include_source_summary` | 否 | 是否返回来源消息摘要,默认 `true`。 | + +建议返参: + +```json +{ + "order": { + "order_id": "20001", + "hotel_id": "HOTEL-TEST", + "order_status": "ACTIVE", + "temporary_order_no": "TMP-20260708-001", + "confirmation_number": "CNF123456", + "group_code": "GRP-001", + "block_code": null, + "allotment_code": null, + "display_name": "GRP-001", + "created_at": "2026-07-08T03:00:00Z", + "updated_at": "2026-07-08T03:10:00Z" + }, + "tasks": [ + { + "task_id": "10001", + "task_type": "NEW_BOOKING", + "task_subtype": "NEW_BOOKING", + "task_status": "COMPLETED", + "card_name": "New Booking", + "queue_sequence": 1, + "queue_participation": true, + "can_process": false, + "readonly_reason_code": "TASK_FINISHED", + "created_at": "2026-07-08T03:00:00Z" + } + ], + "warnings": [] +} +``` + +## 5. 订单列表接口 + +建议路径: + +```text +GET /api/reservation/orders +``` + +建议入参: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `hotel_id` | 否 | 酒店 ID。 | +| `order_status` | 否 | `TEMPORARY`、`ACTIVE`、`ENDED`、`LOGIC_DELETED`。 | +| `group_code` | 否 | 按 Group Code 精确或模糊查询,后端决定。 | +| `confirmation_number` | 否 | 按 Confirmation No 查询。 | +| `keyword` | 否 | 前端搜索框统一关键词。 | +| `page_num` | 否 | 页码。 | +| `page_size` | 否 | 每页条数。 | + +建议返参: + +```json +{ + "items": [ + { + "order_id": "20001", + "hotel_id": "HOTEL-TEST", + "order_status": "ACTIVE", + "temporary_order_no": null, + "confirmation_number": "CNF123456", + "group_code": "GRP-001", + "display_name": "GRP-001", + "open_task_count": 2, + "next_processable_task_id": "10002", + "updated_at": "2026-07-08T03:10:00Z" + } + ], + "page": { + "page_num": 1, + "page_size": 20, + "total": 1 + } +} +``` + +## 6. Message Notification 列表 / 详情接口 + +建议路径: + +```text +GET /api/reservation/message-notifications +GET /api/reservation/message-notifications/{taskId} +``` + +列表建议入参: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `hotel_id` | 否 | 酒店 ID。 | +| `order_id` | 否 | 按临时订单或真实订单过滤。 | +| `keyword` | 否 | 邮件主题、摘要、发送人关键词。 | +| `page_num` | 否 | 页码。 | +| `page_size` | 否 | 每页条数。 | + +详情建议返参: + +```json +{ + "task_id": "10009", + "order_id": "20009", + "task_type": "INFORMATIONAL_MESSAGE", + "task_status": "COMPLETED", + "queue_participation": false, + "readonly": true, + "visible_reason": "FYI message without booking operation", + "relevant_message_excerpt": "Noted with thanks.", + "informational_message": "该消息仅作信息提醒,不需要执行 OPERA 操作。", + "attachments": [], + "source_message_id": "30009", + "created_at": "2026-07-08T03:00:00Z" +} +``` + +## 7. 任务卡前端字段白名单元数据接口 + +是否需要该接口待确认。如果前端直接读取或内置 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 转换后的配置,则第一版可以不做。 + +建议路径: + +```text +GET /api/reservation/task-card-field-whitelist +``` + +建议入参: + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `task_type` | 否 | 按系统主任务类型过滤。 | +| `task_subtype` | 否 | 按任务卡 subtype 过滤。 | +| `version` | 否 | 字段白名单版本,例如 `20260708-3.0`。 | + +建议返参: + +```json +{ + "version": "20260708-3.0", + "items": [ + { + "task_type": "UPDATE_BOOKING", + "task_subtype": "RATE_CHANGE", + "field_path": "rate.rate_code", + "display_name": "Rate Code", + "visible": true, + "editable": true, + "input_type": "select", + "options": [] + } + ] +} +``` + +## 8. 已确认后置接口 + +普通任务切换订单接口继续后置,前端暂不开发提交能力。后续如果恢复开发,建议另行确认: + +```text +POST /api/reservation/tasks/{taskId}/order-binding +``` + +待确认入参: + +```json +{ + "target_order_id": "20002", + "reason": "人工确认该任务属于另一个订单" +} +``` + +待确认返参: + +```json +{ + "task_id": "10001", + "previous_order_id": "20001", + "target_order_id": "20002", + "task_status": "PENDING_CONFIRM", + "audit_id": "90001" +} +``` + +## 9. 待确认问题 + +- 任务列表和订单列表是否统一使用同一个分页结构。 +- 前端字段白名单由后端接口提供,还是由前端从 Excel 转成静态配置。 +- Message Notification 是否独立成页面,还是只在订单详情中展示。 +- SourceMessage 原文读取在前端页面中的入口和权限方案仍待用户 / 权限体系确认。 diff --git a/docs/project/requirements/M002-backend-checkpoint-plan.md b/docs/project/requirements/M002-backend-checkpoint-plan.md index 6bd3d77..b4a2cde 100644 --- a/docs/project/requirements/M002-backend-checkpoint-plan.md +++ b/docs/project/requirements/M002-backend-checkpoint-plan.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.1 | -| 日期 | 2026-07-07 | -| 状态 | 后端开发计划草稿 | +| 文档版本 | 0.2 | +| 日期 | 2026-07-08 | +| 状态 | 后端 checkpoint 计划与阶段实现记录 | | 适用范围 | M002 后端实现拆分、交付物和验收标准 | | 主要读者 | 后端、测试、产品、后续协作 agent | @@ -295,16 +295,27 @@ Controller、Service、Service 实现类的方法必须有中文注释。Entity - 订单任务审计查询。 - 任务列表基础筛选。 - 订单详情下任务时间线。 +- 前端 P0 任务列表 / 工作台接口:`GET /api/reservation/tasks`。 +- 前端 P0 订单详情与任务时间线接口:`GET /api/reservation/orders/{orderId}`。 - 安全日志检查。 - 关键接口集成测试。 验收标准: - 能追溯 AI 接收、任务创建、订单挂靠、用户修改、确认、迁移、OPERA 模拟和重试。 +- 前端可通过 `GET /api/reservation/tasks` 获取任务摘要、订单展示键、来源消息主题和实时可处理状态。 +- 前端可通过 `GET /api/reservation/orders/{orderId}` 获取订单摘要和同订单任务时间线。 - 列表不返回完整 AI payload、邮件正文、附件 URL 或 Secret。 - 所有关键路径有测试覆盖。 - `./mvnw test` 通过,或明确说明环境问题。 +当前实现记录: + +- 已实现 `GET /api/reservation/tasks` 第一版,支持酒店、订单、任务类型、任务状态、任务 subtype、队列参与、关键词和分页筛选。 +- 已实现 `GET /api/reservation/orders/{orderId}` 第一版,支持返回订单摘要和任务时间线,`include_tasks=false` 时只返回订单摘要。 +- 两个接口均复用同订单队列可处理状态计算,`FAILED` 和 `COMPLETED` 视为结束,不阻塞后续任务。 +- 本轮未实现订单列表、Message Notification 独立列表 / 详情、任务卡前端字段白名单元数据接口。 + ## 12. 建议开发节奏 建议一次只做一个 checkpoint。每个 checkpoint 完成后: diff --git a/docs/project/requirements/M002-order-task-workflow-v2.md b/docs/project/requirements/M002-order-task-workflow-v2.md index 9959e53..3a542cc 100644 --- a/docs/project/requirements/M002-order-task-workflow-v2.md +++ b/docs/project/requirements/M002-order-task-workflow-v2.md @@ -14,7 +14,7 @@ 本文是 `M002-order-task-workflow-v1.md` 的第二版修正,目标是把本项目已经讨论确认的订单任务主流程,与 2026-07-06 导入的 AI 任务卡契约对齐。 -本文定义业务边界、数据语义和后续实现约束。当前后端已经按拆分 checkpoint 实现了 AI 结果接收、订单任务基础流转、任务草稿保存、最终确认、审计列表、OPERA 模拟骨架,以及 SuperAgent 查询上下文接口 1、2 的最小字段版;前端页面、真实 OPERA、普通任务切换订单、查询接口 3 和查询接口 4 仍未实现。 +本文定义业务边界、数据语义和后续实现约束。当前后端已经按拆分 checkpoint 实现了 AI 结果接收、订单任务基础流转、任务草稿保存、最终确认、审计列表、OPERA 模拟骨架、SuperAgent 查询上下文接口 1、2 的最小字段版,以及前端 P0 任务列表 / 订单详情查询接口;前端页面、真实 OPERA、普通任务切换订单、查询接口 3 和查询接口 4 仍未实现。 ## 2. 本版核心修正 @@ -25,7 +25,8 @@ - 系统主任务类型收敛为 `NEW_BOOKING`、`UPDATE_BOOKING`、`CANCEL_BOOKING`、`MANUAL_REVIEW`,另保留 `INFORMATIONAL_MESSAGE` 作为不参与执行队列的只读提醒任务。 - 除 `New Booking`、`Cancel Booking`、`Fallback/manual_review` 外,其他业务处理类卡片原则上都归到 `UPDATE_BOOKING` 下的不同任务卡。 - `Message Notification` 也挂到临时订单下面,便于归档和详情查看,但不参与订单任务执行队列,不阻塞其他任务,也不被其他任务阻塞。 -- 任务卡字段以 `任务卡展示编辑矩阵.xlsx` 为权威来源,不在代码里随意扩展或重命名字段路径。 +- 任务卡后端完整规则以 `任务卡展示编辑矩阵.xlsx` 为权威来源,不在代码里随意扩展或重命名字段路径。 +- 前端展示 / 编辑白名单以最新导入的 `任务卡前端展示字段表 3.0.xlsx` 为准;该文件只约束前端展示和可编辑范围,不替代后端完整校验、确认写入和 OPERA 映射规则。 - 系统必须同时保存 AI 原始 `task_type`、系统主任务类型、任务卡类型和业务动作 subtype。 - `ai_payload_json` 与 `confirmed_payload_json` 必须分离。OPERA 模拟只能读取用户确认后的 `confirmed_payload_json`。 - 订单业务号候选字段和 OPERA 模拟回填边界需要明确记录,但 OPERA 返回 JSON 字段名目前未知,不能写死。 @@ -40,7 +41,8 @@ | `docs/project/requirements/M002-order-task-workflow-v1.md` | 第一版整体业务流程 | | `docs/import/20260706/开发AI先读_工作顺序.md` | AI 过渡表、任务卡、确认 payload 和 OPERA 写入工作顺序 | | `docs/import/20260706/AI输出参数并集字典.xlsx` | AI 输出字段路径、字段含义、建议存储方式和索引建议 | -| `docs/import/20260706/任务卡展示编辑矩阵.xlsx` | 任务卡字段展示、编辑、校验和 OPERA 写入约束 | +| `docs/import/20260706/任务卡展示编辑矩阵.xlsx` | 后端任务卡完整规则来源,包括校验、确认写入、OPERA 映射和展示条件 | +| `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` | 前端任务卡展示 / 编辑白名单,不替代后端完整规则矩阵 | | `docs/import/20260706/0630AI_副本 2/01_main_agent_prompt.md` | Main Agent 输出职责和聚合 JSON 结构 | | `docs/import/20260706/0630AI_副本 2/02_agent_workflow.md` | AI 侧事件拆分、Skill 调用和结果聚合流程 | | `docs/import/20260706/0630AI_副本 2/03_skill_catalog.md` | Skill 目录、结果类型、任务类型和统一输出字段 | @@ -49,7 +51,7 @@ | `docs/import/20260706/0630AI_副本 2/skill/*/references/rate_code_rules.md` | Rate Code 判断规则 | | `docs/import/20260706/0630AI_副本 2/skill/*/references/room_type_mapping_rules.md` | 房型映射规则 | -说明:`开发AI先读_工作顺序.md` 中提到的 `AI过渡表开发契约_README.md` 和 `AI过渡表开发契约_两张样例表.xlsx` 在当前导入目录下未找到同名文件。当前以已经导入的两张独立 Excel 作为字段和任务卡矩阵来源。 +说明:`开发AI先读_工作顺序.md` 中提到的 `AI过渡表开发契约_README.md` 和 `AI过渡表开发契约_两张样例表.xlsx` 在当前导入目录下未找到同名文件。当前以后端完整规则矩阵、AI 输出参数字典和前端 3.0 白名单共同作为字段来源;三者职责不同,不能互相覆盖。 ## 4. 总体流程 @@ -213,7 +215,12 @@ AI 第一版结果类型只接受: ## 9. 任务卡字段约束 -任务卡字段以 `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 为权威来源。系统不得直接把整个 `ai_task_results[]` 渲染成表单,也不得绕过矩阵自行扩展字段含义。 +任务卡字段来源按职责拆分: + +- 后端校验、确认写入、OPERA 映射和展示条件以 `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 为权威来源。 +- 前端展示 / 编辑白名单以 `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 为准。 +- 系统不得直接把整个 `ai_task_results[]` 渲染成表单,也不得绕过矩阵自行扩展字段含义。 +- 如果后端完整规则与前端白名单出现冲突,例如旧矩阵要求必填但 3.0 白名单未开放展示或编辑,应先记录为前后端契约待确认问题,不由前端或后端单方面放宽规则。 字段矩阵通过以下列控制任务详情页行为: @@ -505,4 +512,4 @@ Fallback 处理规则: 6. 实现任务详情字段展示、编辑、确认和 `confirmed_payload_json`。当前后端已实现保存草稿和最终确认接口,前端页面待定。 7. 实现任务队列阻塞规则和 `Message Notification` 只读归档规则。当前后端已实现阻塞规则和只读提醒归档的基础能力,前端页面待定。 8. 实现 OPERA 模拟操作结果底表、重试和订单业务号回填。当前后端已实现固定两条模拟操作、attempt、执行、重试和审计列表;订单业务号回填待真实 OPERA 返回结构确认后再做。 -9. 根据前端页面范围实现列表、详情和只读/可处理状态展示。 +9. 根据前端页面范围实现列表、详情和只读/可处理状态展示。当前后端已实现 `GET /api/reservation/tasks` 和 `GET /api/reservation/orders/{orderId}` 第一版;前端页面和订单列表、Message Notification 独立列表 / 详情、任务卡字段白名单元数据接口仍待后续确认。 diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java index 6eab17b..2f51a93 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java @@ -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 findIdsBySafeKeyword(String hotelId, String keyword, int limit) { + if (!hasText(keyword) || limit < 1) { + return List.of(); + } + String trimmedKeyword = trim(keyword); + Page page = inboxMapper.selectPage(Page.of(1, limit), + Wrappers.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 主记录及原始 payload;RECEIVED 状态额外保存正文和媒体引用。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java index ef90593..8243b33 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java @@ -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 findIdsBySafeKeyword(String hotelId, String keyword, int limit); + /** * 插入 Inbox 及其 payload/body/media 子记录,返回内部 SourceMessage ID。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java index fb11800..209c52b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/SourceMessageQueryService.java @@ -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 query(SourceMessageQueryRequest request); + /** + * 按来源消息安全摘要关键词查询内部 SourceMessage ID,供业务列表做安全关联过滤。 + * + * @param hotelId 酒店上下文 ID + * @param keyword 来源消息安全摘要关键词 + * @return 匹配的内部 SourceMessage ID 列表 + */ + List findIdsBySafeKeyword(String hotelId, String keyword); + /** * 按内部 SourceMessage ID 读取单条安全摘要。 * diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java index 4d16e39..83b0f0b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/service/impl/SourceMessageQueryServiceImpl.java @@ -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 findIdsBySafeKeyword(String hotelId, String keyword) { + return inboxRepository.findIdsBySafeKeyword(hotelId, keyword, MAX_SAFE_KEYWORD_MATCHES); + } + /** * 读取单条 SourceMessage 安全摘要,不返回正文、HTML、媒体 URL 或原始 payload。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java index 96c26e7..b5ce5f0 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationAiQueryTaskSnapshot.java @@ -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, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationPageSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationPageSnapshot.java new file mode 100644 index 0000000..6d67f0b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationPageSnapshot.java @@ -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 分页记录类型 + */ +public record ReservationPageSnapshot( + List items, + long total, + int pageNum, + int pageSize +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskWorkbenchQueryRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskWorkbenchQueryRequest.java new file mode 100644 index 0000000..12f0757 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationTaskWorkbenchQueryRequest.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderDetailResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderDetailResult.java new file mode 100644 index 0000000..2524df3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderDetailResult.java @@ -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 tasks, + List warnings +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderSummaryResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderSummaryResult.java new file mode 100644 index 0000000..892b902 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderSummaryResult.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java new file mode 100644 index 0000000..116770d --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationOrderTaskTimelineItemResult.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationPaginationResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationPaginationResult.java new file mode 100644 index 0000000..d737633 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationPaginationResult.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java new file mode 100644 index 0000000..e881374 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchItemResult.java @@ -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 +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchListResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchListResult.java new file mode 100644 index 0000000..4e55807 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/result/ReservationTaskWorkbenchListResult.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.common.result; + +import java.util.List; + +/** + * 前端任务列表 / 工作台分页响应。 + * + * @param items 当前页任务摘要 + * @param page 分页元数据 + */ +public record ReservationTaskWorkbenchListResult( + List items, + ReservationPaginationResult page +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java new file mode 100644 index 0000000..6b3b128 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryController.java @@ -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); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java index 6bda4a5..e62be5d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -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 { /** 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 848091b..59028cc 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 @@ -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 findAiQueryOrdersByIds(String hotelId, List orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + return List.of(); + } + return orderMapper.selectList(Wrappers.lambdaQuery() + .eq(ReservationOrderEntity::getHotelId, hotelId) + .in(ReservationOrderEntity::getId, orderIds)) + .stream() + .map(this::toAiQueryOrderSnapshot) + .toList(); + } + + /** + * 前端任务列表分页查询。只读取任务摘要字段,完整任务卡 payload 仍走任务详情接口。 + */ + @Override + public ReservationPageSnapshot queryFrontendTasks( + ReservationTaskWorkbenchQueryRequest request, + List keywordSourceMessageIds, + int pageNum, + int pageSize) { + List keywordOrderIds = findOrderIdsByKeyword(request.hotelId(), request.keyword()); + List sourceMessageIds = keywordSourceMessageIds == null ? List.of() : keywordSourceMessageIds; + Page page = taskMapper.selectPage(Page.of(pageNum, pageSize), + Wrappers.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 findOrderIdsByKeyword(String hotelId, String keyword) { + if (!hasText(keyword)) { + return List.of(); + } + String trimmedKeyword = trim(keyword); + return orderMapper.selectList(Wrappers.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(), 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 24a66d7..5b11b56 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 @@ -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 findAiQueryOrderById(String hotelId, Long orderId); + /** + * 按订单 ID 批量查询订单快照,供前端列表补充展示字段。 + */ + List findAiQueryOrdersByIds(String hotelId, List orderIds); + + /** + * 分页查询前端任务列表 / 工作台摘要。 + */ + ReservationPageSnapshot queryFrontendTasks( + ReservationTaskWorkbenchQueryRequest request, + List keywordSourceMessageIds, + int pageNum, + int pageSize); + /** * 查询指定订单下全部任务,并带上对应 AI transition 的路由字段。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationFrontendQueryService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationFrontendQueryService.java new file mode 100644 index 0000000..1541e59 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationFrontendQueryService.java @@ -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); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java new file mode 100644 index 0000000..2a51725 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationFrontendQueryServiceImpl.java @@ -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 keywordSourceMessageIds = findSourceMessageIdsByKeyword( + normalizedRequest.hotelId(), + normalizedRequest.keyword()); + ReservationPageSnapshot page = + workflowRepository.queryFrontendTasks(normalizedRequest, keywordSourceMessageIds, pageNum, pageSize); + Map ordersById = findOrdersById( + normalizedRequest.hotelId(), + page.items().stream().map(ReservationAiQueryTaskSnapshot::orderId).toList()); + List 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 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 findOrdersById(String hotelId, List orderIds) { + Map result = new LinkedHashMap<>(); + workflowRepository.findAiQueryOrdersByIds(hotelId, orderIds) + .forEach(order -> result.put(order.id(), order)); + return result; + } + + /** + * 按来源消息安全摘要关键词查找 SourceMessage ID,避免任务列表直接读取来源消息正文。 + */ + private List 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 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(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskAvailabilityResolver.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskAvailabilityResolver.java new file mode 100644 index 0000000..91b672f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationTaskAvailabilityResolver.java @@ -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()); + } +} 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 3d09e5d..ad59ddd 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 @@ -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 fields = buildFieldResults(task, taskCard); List 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 原始快照组装详情字段。 */ 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 new file mode 100644 index 0000000..39dd4c0 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationFrontendQueryControllerTest.java @@ -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.", + "Please handle booking message.", + "{\"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); + } +}