From 3bce336b6cb2449e431b9653985d742f577d1267 Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 20 Aug 2026 11:39:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=AC=AC=E5=9B=9B=E7=A7=8D?= =?UTF-8?q?=E5=A4=8D=E6=9D=82=E7=9A=84rooming=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 4 +- .../backend-to-frontend-notes.md | 15 +- .../M010-rooming-list-excel-generation-v1.md | 57 +++++- .../ReservationRoomingListSourceDataDto.java | 11 +- ...ationRoomingListGenerationServiceImpl.java | 7 +- .../impl/RoomingListSourceExcelParser.java | 171 +++++++++++++++++- ...nRoomingListGenerationServiceImplTest.java | 73 ++++++++ 7 files changed, 315 insertions(+), 23 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 69c9ee1..32211cc 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -36,7 +36,7 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - AI-NSES v0.2 和 TH Hotel 项目级 Overlay 已落地;近期 M002 V4 新增需求已新增模板化 Spec 入口。后续新增或变更 V4 需求时必须继续维护该 Spec、后续 Change Request 或新 Spec,避免只散落在当前有效大文档中。 -- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单并下载后端同步生成的 `.xlsx`,第一版不落库、不上传 OSS。CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality,Payment Type 默认 `BTQR` 且当前允许 `BTQR` / `CA`,Nationality 只允许 `KR` / `CN` / `TH` / `MM` / `RS` / `TW`。CP3 已实现:兼容第二种 `英文姓` + `英文名` 名单样式;CP4 已实现:兼容第三种单列 `英文名` 名单样式。无旅游日期来源样式由用户补充 Arrival / Departure,且 `23+1`、`19+1` 中领队也进入房表。 +- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单并下载后端同步生成的 `.xlsx`,第一版不落库、不上传 OSS。CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality,Payment Type 默认 `BTQR` 且当前允许 `BTQR` / `CA`,Nationality 只允许 `KR` / `CN` / `TH` / `MM` / `RS` / `TW`。CP3 已实现:兼容第二种 `英文姓` + `英文名` 名单样式;CP4 已实现:兼容第三种单列 `英文名` 名单样式;CP5 已实现:兼容第四种 `拼音` + `分房` 原表分房样式,尊重来源表 `用房1间` 分房,`外住` 不进目标 Rooming List,`儿童情况=不占床` 进入 Accompanying Guests 但不计入 Adults。无旅游日期来源样式由用户补充 Arrival / Departure,且 `23+1`、`19+1` 中领队也进入房表。 - M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel,按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSON;Debug EML 和 AgentBus dispatch 在各自 include 开关与总开关同时启用时,会在调用 SuperAgent 前追加 `attachment_extractions[]`。测试机 AgentBus 增强已开启;生产链路仍默认关闭,生产开启需单独确认。 - M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;MCP `th_hotel_submit_task_results` 已收口为 M002 V4-only,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不再影响 SuperAgent 输出契约;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成,前端轻量事项确认卡也已完成;Payment 附件安全摘要后端和前端预览接入均已完成。OWNER RATE Room Type / Rate Code 目录口径已落文档;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Rooming List 卡第一版只做事项确认;Account 通过数据库目录选择,Market / Source 可默认来自目录并允许前端人工覆盖提交;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 @@ -47,7 +47,7 @@ - SuperAgent 通过 MCP 提交时,排障优先查询 `platform_superagent_mcp_call_diagnostic`,对比 `arguments_json`、`adapted_payload_json`、`mapping_diagnostics_json` 和业务 batch / transition,判断问题来自 SuperAgent 原始参数、MCP adapter 还是业务入站层;V4-only 模式下 `mapping_diagnostics_json` 通常为空对象,若错误码为 `MCP_SUBMIT_V4_REQUIRED`,说明 SuperAgent 仍按旧 V2/V3 schema 输出;旧 V2/V3 被拒也会入本诊断表但不会进入业务写入 Service;V4 `source_message.conversation_id` 可缺省;该诊断表不作为业务事实来源,不进入普通前端接口。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码;V4 相关需求必须按 `docs/project/ai-nses-project-overlay.md` 补核心概念守门、需求追踪表和 agent 交接边界。 - 单卡可操作态测试数据已回填;后续演示或回归如果需要重新造数,应继续使用 fresh runId,避免复用旧 SourceMessage 时间线造成阻塞误判。 -- M010 CP4 第三种单列 `英文名` 名单样式兼容已完成;预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。 +- M010 CP5 第四种 `拼音` + `分房` 原表分房名单样式兼容已完成;预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。 - M011 CP4 暂不推进;当前停留在 CP3 边界,只增强 SuperAgent 输入,不直接落订单、任务或长期解析历史。后续如确实需要运营查询或长期追踪,再单独设计 Excel 解析批次 / 行级持久化表。 ## 6. 文档同步提醒 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index e44adbe..b48d173 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -96,7 +96,7 @@ | `GET/POST/PUT /api/admin/menus...` | 系统管理菜单维护 | 需要 `SYSTEM_MENU_MANAGE`;允许保存未知路由,前端必须有未知路由兜底页;已提供完整菜单树查询和批量树排序保存。 | | `GET/POST/PUT /api/admin/hotels...` | 系统管理酒店维护 | 需要 `HOTEL_MANAGE`;新增酒店默认 `DISABLED`,单酒店阶段不能启用第二家 `ACTIVE`。 | | `GET /api/admin/audits` | 系统管理操作审计 | 需要 `SYSTEM_ADMIN_CONSOLE_ACCESS`;用于查看管理后台写操作审计,不包含密码、token、secret。 | -| `POST /api/reservation/rooming-lists/generations` | 生成 Rooming List Excel | 必须带 Bearer token,需要 `RESERVATION_ROOMING_LIST_GENERATE`;请求为 multipart/form-data,成功后直接返回 `.xlsx` 文件流,前端按 Blob 下载处理。CP2 已实现:后端从来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源样式。CP4 已实现:兼容单列 `英文名` 第三种来源样式。无旅游日期来源样式使用前端提交的 `arrival` / `departure`。 | +| `POST /api/reservation/rooming-lists/generations` | 生成 Rooming List Excel | 必须带 Bearer token,需要 `RESERVATION_ROOMING_LIST_GENERATE`;请求为 multipart/form-data,成功后直接返回 `.xlsx` 文件流,前端按 Blob 下载处理。CP2 已实现:后端从来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源样式。CP4 已实现:兼容单列 `英文名` 第三种来源样式。CP5 已实现:兼容 `拼音` + `分房` 第四种来源样式并尊重原表分房,`儿童情况=不占床` 进入 Accompanying Guests 但不计入 Adults。无旅游日期来源样式使用前端提交的 `arrival` / `departure`。 | ### 5.1 本轮新增 / 修改接口说明 @@ -464,7 +464,7 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填 ### 5.12 Rooming List Excel 生成页面接入方向 -M010 后端 CP1 已实现。第一版生成结果直接下载 `.xlsx`,不落库、不上传 OSS,不依赖订单或任务。CP2 已实现,前后端按本节新口径接入。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源名单样式。CP4 已实现:兼容单列 `英文名` 第三种来源名单样式。无旅游日期来源样式均由用户补充入住 / 离店日期。 +M010 后端 CP1 已实现。第一版生成结果直接下载 `.xlsx`,不落库、不上传 OSS,不依赖订单或任务。CP2 已实现,前后端按本节新口径接入。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源名单样式。CP4 已实现:兼容单列 `英文名` 第三种来源名单样式。CP5 已实现:兼容 `拼音` + `分房` 第四种来源名单样式并尊重原表分房。无旅游日期来源样式均由用户补充入住 / 离店日期。 接口: @@ -484,12 +484,13 @@ RESERVATION_ROOMING_LIST_GENERATE - 前端 V1 已新增 `/reservation/rooming-lists/new` 页面,按 `RESERVATION_ROOMING_LIST_GENERATE` 路由权限保护,并使用登录态 Bearer token 调用正式业务接口。 - 侧边栏入口不由前端硬编码公开,仍依赖后端登录态 `menus[]`。如需菜单中展示,建议菜单管理配置 `menu_code=RESERVATION_ROOMING_LIST`、`route_path=/reservation/rooming-lists/new`、`permission_code=RESERVATION_ROOMING_LIST_GENERATE`。 -- 该页面上传来源名单 Excel;第一种来源样式后端识别 `护照全名` 和 `旅游日期`,第二种来源样式后端识别 `英文姓` 和 `英文名`,第三种来源样式后端识别单列 `英文名`。 -- 第一种来源样式姓名支持 `LI/CHUNHONG` 和 `LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`。第二种来源样式直接使用 `英文姓` 写入 `Name`,使用 `英文名` 写入 `First Name`。第三种来源样式复用第一种姓名拆分规则,支持 `CAI/HAIYUN` 或 `CAI HAIYUN`。 -- 前端必须让用户输入 `people_per_room` 和目标 `Room Type`,后端按名单顺序分组,并用 `ceil(total_people / people_per_room)` 生成房间行。 +- 该页面上传来源名单 Excel;第一种来源样式后端识别 `护照全名` 和 `旅游日期`,第二种来源样式后端识别 `英文姓` 和 `英文名`,第三种来源样式后端识别单列 `英文名`,第四种来源样式后端识别 `拼音` 和 `分房`。 +- 第一种来源样式姓名支持 `LI/CHUNHONG` 和 `LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`。第二种来源样式直接使用 `英文姓` 写入 `Name`,使用 `英文名` 写入 `First Name`。第三种来源样式复用第一种姓名拆分规则,支持 `CAI/HAIYUN` 或 `CAI HAIYUN`。第四种来源样式使用 `拼音` 列,支持 `ZHANG JIANWEI` 这类空格拆分。 +- 前端必须让用户输入 `people_per_room` 和目标 `Room Type`。第一、二、三种来源样式后端按名单顺序分组,并用 `ceil(total_people / people_per_room)` 生成房间行;第四种 `拼音` + `分房` 来源样式中,`people_per_room` 仅为接口兼容必填,后端忽略其分组作用并完全尊重来源表原始分房。 - 每组第一位旅客写入 `Name` / `First Name`,同组剩余旅客写入 `Accompanying Guests`,多人用英文逗号分隔。 -- CP3 起前端恢复 `Arrival` / `Departure` 日期输入:第一种来源样式可不填,后端优先从来源 Excel 的 `旅游日期` 派生;无旅游日期来源样式必须填写,后端使用用户提交日期写入目标 Excel。样例 `2026年5月9日-5月14日` 仍应生成 `Arrival=2026/05/09`、`Departure=2026/05/14`。 +- CP3 起前端恢复 `Arrival` / `Departure` 日期输入:第一种来源样式可不填,后端优先从来源 Excel 的 `旅游日期` 派生;无旅游日期来源样式必须填写,后端使用用户提交日期写入目标 Excel。样例 `2026年5月9日-5月14日` 仍应生成 `Arrival=09/05/2026`、`Departure=14/05/2026`。 - 第二种和第三种来源样式中领队也进入房表;例如 `23+1` 或 `19+1` 表示客人 + 领队全部参与分房,实际人数以后端读取到的有效名单行数为准。 +- 第四种来源样式中 `分房=外住` 行不进入目标 Rooming List;`分房=用房1间` 开始一间新房,后续 `分房` 为空的名单行归入当前房间;`儿童情况=不占床` 的旅客仍显示在 `Accompanying Guests`,但不计入 `Adults`。 - CP2 起前端不再展示或提交 `Adults`,后端按每个分房行的实际人数派生;尾房不足满房人数时按尾房实际人数写入。`Children` 第一版固定为 `0`。 - CP2 起目标默认值区域只保留 `Payment Type` 和 `Nationality`:`Payment Type` 默认 `BTQR`,当前允许 `BTQR`、`CA`;`Nationality` 只允许 `KR`、`CN`、`TH`、`MM`、`RS`、`TW`。 - CP2 页面不再展示或提交 `Title`、`Rate Code`、`VIP`、`Email`、`ID Type`、`ID Number`,这些目标列第一版固定为空。 @@ -497,7 +498,7 @@ RESERVATION_ROOMING_LIST_GENERATE - 失败时返回统一 JSON 错误,例如 `ROOMING_LIST_VALIDATION_FAILED`、`ROOMING_LIST_SOURCE_FILE_INVALID`、`HOTEL_ACCESS_DENIED` 或 `FRONTEND_PERMISSION_DENIED`。 - 该上传接口会在 multipart 参数绑定前先校验 Bearer token 和 `RESERVATION_ROOMING_LIST_GENERATE` 权限;未登录时优先返回 401,不会因为缺少业务字段先返回 400。 - `ROOMING_LIST_VALIDATION_FAILED` 应覆盖 multipart 必填字段缺失、`people_per_room` 数字格式错误、无旅游日期来源样式下缺少或错误的 `arrival` / `departure`、`payment_type` / `nationality` 枚举错误,`details[]` 返回字段级提示。 -- `ROOMING_LIST_SOURCE_FILE_INVALID` 应覆盖来源文件缺失、非 Excel、无法读取、既不能识别第一种 `护照全名` + `旅游日期`、也不能识别第二种 `英文姓` + `英文名` 或第三种单列 `英文名`、第一种来源样式旅游日期格式无法解析或同一文件出现多个不同旅游日期区间。 +- `ROOMING_LIST_SOURCE_FILE_INVALID` 应覆盖来源文件缺失、非 Excel、无法读取、既不能识别第一种 `护照全名` + `旅游日期`、也不能识别第二种 `英文姓` + `英文名`、第三种单列 `英文名` 或第四种 `拼音` + `分房`、第一种来源样式旅游日期格式无法解析或同一文件出现多个不同旅游日期区间。 - 页面不要把上传文件内容、客人名单、生成文件内容写入浏览器日志、埋点、错误上报、URL 或 localStorage。 - 第一版没有 preview 接口、生成记录接口、OSS URL、历史下载和订单 / 任务预填;前端不要在页面上承诺这些能力。 diff --git a/docs/project/requirements/M010-rooming-list-excel-generation-v1.md b/docs/project/requirements/M010-rooming-list-excel-generation-v1.md index 8db2003..c54aa0d 100644 --- a/docs/project/requirements/M010-rooming-list-excel-generation-v1.md +++ b/docs/project/requirements/M010-rooming-list-excel-generation-v1.md @@ -2,9 +2,9 @@ | 项目 | 内容 | | --- | --- | -| 文档状态 | 后端 CP1 已实现;CP2 已实现旅游日期派生、Adults 系统计算和目标默认值字段收口;CP3 第二种来源名单样式已实现;CP4 第三种单列 `英文名` 来源名单样式已实现 | +| 文档状态 | 后端 CP1 已实现;CP2 已实现旅游日期派生、Adults 系统计算和目标默认值字段收口;CP3 第二种来源名单样式已实现;CP4 第三种单列 `英文名` 来源名单样式已实现;CP5 第四种 `拼音` + `分房` 原表分房样式已实现 | | 适用范围 | 从旅行团名单 Excel 解析护照姓名,并生成酒店 / PMS 可导入的 Rooming List Excel | -| 当前目标 | CP1 已支持前端上传来源 Excel、填写分房和目标列默认值,后端同步生成目标格式 `.xlsx`;CP2 收口为后端从来源 `旅游日期` 派生入住 / 离店日期,成人数由系统按分房结果计算,目标默认值区域只保留付款方式和国籍;CP3 兼容 `英文姓` + `英文名` 第二种名单样式;CP4 兼容单列 `英文名` 第三种名单样式;无旅游日期来源样式均由用户补充入住 / 离店日期 | +| 当前目标 | CP1 已支持前端上传来源 Excel、填写分房和目标列默认值,后端同步生成目标格式 `.xlsx`;CP2 收口为后端从来源 `旅游日期` 派生入住 / 离店日期,成人数由系统按分房结果计算,目标默认值区域只保留付款方式和国籍;CP3 兼容 `英文姓` + `英文名` 第二种名单样式;CP4 兼容单列 `英文名` 第三种名单样式;CP5 兼容 `拼音` + `分房` 第四种名单样式并尊重原表分房;无旅游日期来源样式均由用户补充入住 / 离店日期 | | 依赖能力 | 登录权限、酒店上下文、Apache POI 或等价 Excel 读写能力 | ## 1. 背景 @@ -53,6 +53,17 @@ CP4 目标调整: - 第三种样式中的 `领队` 行也进入房表。例如 `19+1` 应理解为 19 位客人 + 1 位领队,合计 20 位入住人全部参与分房。 - 来源表中的中文名、性别、证件号、生日、出生地、签发地、联系方式、小费、住宿说明等字段第一版仍不写入目标 Excel,也不在错误响应或日志中回显。 +CP5 目标调整: + +- 兼容第四种来源名单样式:表头包含 `拼音`、`分房`,可选包含 `儿童情况`。 +- 第四种样式的 `拼音` 单元格值形如 `ZHANG JIANWEI`,后端复用空格拆分规则,第一段写入目标 `Name`,剩余部分写入目标 `First Name`。 +- 第四种样式必须尊重来源表已经标注的分房结果:`用房1间` 表示开始一间新房,后续 `分房` 为空的名单行归入当前房间,直到下一次 `用房1间` 或 `外住`。 +- `分房=外住` 的行不进入目标 Rooming List;如果当前房间已开始,遇到 `外住` 前会先结束当前房间。 +- `儿童情况=不占床` 的旅客仍进入当前房间的 `Accompanying Guests`,但不计入目标 `Adults`;`儿童情况=占床` 或空白按占床人员计入 `Adults`。 +- 第四种样式没有可靠入住 / 离店日期来源,后端不得从标题、团号或航班日期猜测酒店入住 / 离店日期;必须使用前端提交的 `arrival` / `departure`。 +- 第四种样式下 `people_per_room` 仍因当前接口兼容保持必填,但实际生成时忽略该字段,目标房间行完全以后端读取到的来源原表分房为准。 +- 来源表中的中文名、性别、出生日期、出生地、护照、签发地、签发日期、有效期、联系方式、备注等字段第一版仍不写入目标 Excel,也不在错误响应或日志中回显。 + 第一阶段不做: - 不落业务订单。 @@ -78,7 +89,7 @@ CP4 目标调整: ## 4. 来源 Excel 解析规则 -基于当前样例来源文件,当前支持三类来源名单格式。后端应先在来源 Excel 中识别表头组合,再选择对应解析器。 +基于当前样例来源文件,当前支持四类来源名单格式。后端应先在来源 Excel 中识别表头组合,再选择对应解析器。 ### 4.1 第一种来源样式:护照全名 + 旅游日期 @@ -199,6 +210,36 @@ Sheet1 第 4 行起:名单数据 - 不读取也不返回中文名、性别、证件号、生日、出生地、签发地、联系方式、小费、住宿说明。 - 第三种样式没有 `旅游日期`,后端不得从航班行猜测入住 / 离店日期;必须使用前端提交的 `arrival` / `departure`。 +### 4.4 第四种来源样式:拼音 + 分房 + +第四种样式识别以下表头: + +| 来源字段 | 中文说明 | 第一版用途 | +| --- | --- | --- | +| `拼音` | 护照英文姓名,通常为空格分隔 | 拆分后写入目标 Excel 的 `Name` 和 `First Name` | +| `分房` | 来源表人工分房标记,例如 `用房1间`、`外住` | 用于生成目标 Excel 的房间行,不再按 `people_per_room` 自动切房 | +| `儿童情况` | 儿童占床说明,例如 `不占床` | `不占床` 不计入 `Adults`,但仍进入 `Accompanying Guests` | + +已确认样例: + +```text +Sheet1:团队名单表 +表头:序号 / 姓名 / 拼音 / 性别 / 出生日期 / 出生地 / 护照 / 签发地 / 签发日期 / 有效期 / 联系方式 / 儿童情况 / 分房 / 备注 +名单行:1L 领队可标记 `外住`;客人行通过 `用房1间` 和空白 `分房` 表示同房关系。 +``` + +解析规则: + +- 在前若干行内查找同一行同时包含 `拼音` 和 `分房` 的表头;`儿童情况` 可选。 +- 从表头下一行开始读取名单行。 +- `拼音` 为空的行跳过。 +- `分房` 包含 `外住` 的行不进入目标 Rooming List。 +- `分房` 包含 `用房` 的行开始一间新房;后续 `分房` 为空的有效姓名行归入当前房间。 +- 如果在第一个 `用房` 标记前出现普通名单行,第一版跳过,避免把未分房人员误归入目标房间。 +- 每间房第一位旅客写入目标 `Name` / `First Name`;同房其他旅客写入 `Accompanying Guests`。 +- `儿童情况=不占床` 的旅客仍保留在 `Accompanying Guests`,但不计入 `Adults`;`Children` 第一版仍固定为 `0`。 +- 第四种样式没有 `旅游日期`,后端不得从标题、团号或航班行猜测入住 / 离店日期;必须使用前端提交的 `arrival` / `departure`。 + ## 5. 分房生成规则 前端需要传入 `people_per_room`,表示每间房人数。 @@ -206,6 +247,7 @@ Sheet1 第 4 行起:名单数据 规则: - 按来源 Excel 中旅客出现顺序分组。 +- 第四种 `拼音` + `分房` 来源样式例外:后端尊重来源表原始分房,忽略 `people_per_room` 的分组作用。 - `room_count = ceil(total_people / people_per_room)`。 - 每组第一位旅客写入目标 Excel 的 `Name` 和 `First Name`。 - 同组剩余旅客写入目标 Excel 的 `Accompanying Guests`。 @@ -325,7 +367,7 @@ Content-Disposition: attachment; filename="rooming-list-.xlsx" - `payment_type` 非 `BTQR` / `CA`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 - `nationality` 非 `KR` / `CN` / `TH` / `MM` / `RS` / `TW`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 - 无旅游日期来源样式缺少 `arrival` / `departure`、日期格式错误或 `departure` 不晚于 `arrival`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 -- 来源文件缺失、非 `.xls` / `.xlsx`、无法读取、既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名` 或第三种单列 `英文名`,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`。 +- 来源文件缺失、非 `.xls` / `.xlsx`、无法读取、既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名`、第三种单列 `英文名` 或第四种 `拼音` + `分房`,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`。 - 第一种来源样式缺少 / 无法解析 `旅游日期` 表头,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`。 - 同一来源文件出现多个不同旅游日期区间,CP2 返回受控错误,不生成 Excel。 - 错误详情只返回字段名或行号,不回显完整名单、证件号或源文件内容。 @@ -359,7 +401,7 @@ RESERVATION_ROOMING_LIST_GENERATE | `ReservationRoomingListGenerationController` | 接收前端上传请求,做登录、权限和酒店上下文校验 | | `ReservationRoomingListGenerationService` | 编排来源解析、分房、目标 Excel 渲染和文件响应 | | `ReservationRoomingListGenerationServiceImpl` | 具体业务实现 | -| `RoomingListSourceExcelParser` | CP1 解析来源 Excel,提取护照姓名;CP2 已扩展为同时提取并校验统一旅游日期区间;CP3 已扩展第二种 `英文姓` + `英文名` 样式;CP4 已扩展第三种单列 `英文名` 样式;无旅游日期来源样式要求用户补日期 | +| `RoomingListSourceExcelParser` | CP1 解析来源 Excel,提取护照姓名;CP2 已扩展为同时提取并校验统一旅游日期区间;CP3 已扩展第二种 `英文姓` + `英文名` 样式;CP4 已扩展第三种单列 `英文名` 样式;CP5 已扩展第四种 `拼音` + `分房` 原表分房样式;无旅游日期来源样式要求用户补日期 | | `RoomingListNameParser` | 拆分 `/` 或空格格式的护照姓名 | | `RoomingListGroupingService` | 按每房人数分组并生成目标行模型 | | `RoomingListExcelRenderer` | 生成目标格式 `.xlsx` | @@ -419,6 +461,11 @@ RESERVATION_ROOMING_LIST_GENERATE - 能识别第三种来源样式单列 `英文名` 表头。 - 第三种来源样式下 `英文名=CAI/HAIYUN` 能生成 `Name=CAI`、`First Name=HAIYUN`。 - 第三种来源样式下领队也进入房表,实际人数以后端读取到的有效名单行数为准。 +- 能识别第四种来源样式 `拼音` / `分房` 表头。 +- 第四种来源样式下 `拼音=ZHANG JIANWEI` 能生成 `Name=ZHANG`、`First Name=JIANWEI`。 +- 第四种来源样式尊重原表 `用房1间` 分房,不按 `people_per_room` 重新切房。 +- 第四种来源样式下 `分房=外住` 行不进入目标 Rooming List。 +- 第四种来源样式下 `儿童情况=不占床` 的旅客进入 `Accompanying Guests`,但不计入 `Adults`。 - 无旅游日期来源样式使用前端提交的 `arrival` / `departure` 写入目标 Excel。 - 无旅游日期来源样式缺少 `arrival` / `departure` 返回受控错误。 - `LI/CHUNHONG` 能拆成 `Name=LI`、`First Name=CHUNHONG`。 diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListSourceDataDto.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListSourceDataDto.java index c6036c0..5766568 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListSourceDataDto.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListSourceDataDto.java @@ -9,9 +9,18 @@ import java.util.List; * @param guests 来源 Excel 中按原始顺序解析出的旅客名单 * @param arrival 从来源 `旅游日期` 派生的入住酒店本地日期 * @param departure 从来源 `旅游日期` 派生的离店酒店本地日期 + * @param preGroupedRooms 来源 Excel 已明确给出的分房结果;为空时调用方按每房人数自动分房 */ public record ReservationRoomingListSourceDataDto( List guests, LocalDate arrival, - LocalDate departure) { + LocalDate departure, + List preGroupedRooms) { + + public ReservationRoomingListSourceDataDto( + List guests, + LocalDate arrival, + LocalDate departure) { + this(guests, arrival, departure, null); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java index 348b86d..c535127 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java @@ -66,10 +66,9 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR sourceFile, normalizedRequest.arrival(), normalizedRequest.departure()); - List rooms = groupingService.group( - sourceData.guests(), - normalizedRequest.peoplePerRoom(), - null); + List rooms = sourceData.preGroupedRooms() == null + ? groupingService.group(sourceData.guests(), normalizedRequest.peoplePerRoom(), null) + : sourceData.preGroupedRooms(); byte[] content = excelRenderer.render( rooms, normalizedRequest, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java index b575c57..93f1283 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java @@ -1,6 +1,7 @@ package cn.nianxx.thhotel.workflows.reservation.service.impl; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGuestDto; +import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListRoomDto; import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListSourceDataDto; import java.io.IOException; import java.io.InputStream; @@ -31,6 +32,9 @@ public class RoomingListSourceExcelParser { private static final String TRAVEL_DATE_HEADER = "旅游日期"; private static final String ENGLISH_LAST_NAME_HEADER = "英文姓"; private static final String ENGLISH_FIRST_NAME_HEADER = "英文名"; + private static final String PINYIN_NAME_HEADER = "拼音"; + private static final String CHILD_BED_HEADER = "儿童情况"; + private static final String SOURCE_ROOM_GROUP_HEADER = "分房"; private static final int MAX_HEADER_SCAN_ROWS = 20; private static final Pattern FULL_DATE_PATTERN = Pattern.compile( "^(\\d{4})\\s*(?:年|[-/.])\\s*(\\d{1,2})\\s*(?:月|[-/.])\\s*(\\d{1,2})\\s*(?:日)?$"); @@ -95,14 +99,24 @@ public class RoomingListSourceExcelParser { formatter, requireManualStayDateRange(manualArrival, manualDeparture)); } else { - throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期、英文姓/英文名 或 英文名。")); + PinyinRoomHeaders pinyinRoomHeaders = findPinyinRoomHeaders(sheet, formatter); + if (pinyinRoomHeaders != null) { + sourceRows = parsePinyinRoomRows( + sheet, + pinyinRoomHeaders, + formatter, + requireManualStayDateRange(manualArrival, manualDeparture)); + } else { + throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期、英文姓/英文名、英文名 或 拼音/分房。")); + } } } } return new ReservationRoomingListSourceDataDto( sourceRows.guests(), sourceRows.travelDateRange().arrival(), - sourceRows.travelDateRange().departure()); + sourceRows.travelDateRange().departure(), + sourceRows.preGroupedRooms()); } catch (ReservationRoomingListGenerationException exception) { throw exception; } catch (IOException | RuntimeException exception) { @@ -226,6 +240,46 @@ public class RoomingListSourceExcelParser { return findHeaderIfPresent(sheet, formatter, ENGLISH_FIRST_NAME_HEADER); } + /** + * 查找第四种来源样式的拼音分房表头;要求 `拼音` 和 `分房` 位于同一表头行,`儿童情况` 可选。 + */ + private PinyinRoomHeaders findPinyinRoomHeaders(Sheet sheet, DataFormatter formatter) { + int lastRowNum = Math.min(sheet.getLastRowNum(), MAX_HEADER_SCAN_ROWS - 1); + for (int rowIndex = 0; rowIndex <= lastRowNum; rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + continue; + } + HeaderLocation pinyinHeader = null; + HeaderLocation childBedHeader = null; + HeaderLocation roomGroupHeader = null; + int firstCellIndex = row.getFirstCellNum(); + int lastCellIndex = row.getLastCellNum(); + if (firstCellIndex < 0 || lastCellIndex < 0) { + continue; + } + for (int cellIndex = firstCellIndex; cellIndex < lastCellIndex; cellIndex++) { + String text = cellText(row.getCell(cellIndex), formatter); + if (PINYIN_NAME_HEADER.equals(text)) { + pinyinHeader = new HeaderLocation(rowIndex, cellIndex); + } else if (CHILD_BED_HEADER.equals(text)) { + childBedHeader = new HeaderLocation(rowIndex, cellIndex); + } else if (SOURCE_ROOM_GROUP_HEADER.equals(text)) { + roomGroupHeader = new HeaderLocation(rowIndex, cellIndex); + } + } + if (pinyinHeader != null && roomGroupHeader != null) { + Integer childBedCellIndex = childBedHeader == null ? null : childBedHeader.cellIndex(); + return new PinyinRoomHeaders( + rowIndex, + pinyinHeader.cellIndex(), + childBedCellIndex, + roomGroupHeader.cellIndex()); + } + } + return null; + } + /** * 第二种来源样式直接使用英文姓 / 英文名,不读取证件号、中文名或生日等敏感字段。 */ @@ -285,6 +339,79 @@ public class RoomingListSourceExcelParser { return new SourceRows(List.copyOf(guests), manualStayDateRange); } + /** + * 第四种来源样式读取 `拼音` 和 `分房`,按原表 `用房1间` 边界生成房间,`外住` 行排除。 + */ + private SourceRows parsePinyinRoomRows( + Sheet sheet, + PinyinRoomHeaders headers, + DataFormatter formatter, + TravelDateRange manualStayDateRange) { + List currentRoomGuests = new ArrayList<>(); + List> sourceRooms = new ArrayList<>(); + for (int rowIndex = headers.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + continue; + } + String pinyinName = cellText(row.getCell(headers.pinyinCellIndex()), formatter); + if (isBlank(pinyinName)) { + continue; + } + String roomGroup = cellText(row.getCell(headers.roomGroupCellIndex()), formatter); + if (contains(roomGroup, "外住")) { + finishCurrentRoom(sourceRooms, currentRoomGuests); + continue; + } + if (contains(roomGroup, "用房")) { + finishCurrentRoom(sourceRooms, currentRoomGuests); + } else if (currentRoomGuests.isEmpty()) { + continue; + } + ReservationRoomingListGuestDto guest = nameParser.parse(rowIndex + 1, pinyinName); + String childBed = headers.childBedCellIndex() == null + ? "" + : cellText(row.getCell(headers.childBedCellIndex()), formatter); + currentRoomGuests.add(new RoomGuest(guest, !contains(childBed, "不占床"))); + } + finishCurrentRoom(sourceRooms, currentRoomGuests); + if (sourceRooms.isEmpty()) { + throw invalidFile(List.of("拼音/分房列没有可用房间。")); + } + List guests = sourceRooms.stream() + .flatMap(room -> room.stream()) + .map(RoomGuest::guest) + .toList(); + List rooms = new ArrayList<>(); + for (List roomGuests : sourceRooms) { + RoomGuest primaryRoomGuest = roomGuests.stream() + .filter(RoomGuest::occupiesBed) + .findFirst() + .orElseThrow(() -> invalidFile(List.of("拼音/分房: 每间房至少需要一位占床旅客。"))); + ReservationRoomingListGuestDto primaryGuest = primaryRoomGuest.guest(); + List accompanyingGuests = roomGuests.stream() + .filter(roomGuest -> roomGuest != primaryRoomGuest) + .map(RoomGuest::guest) + .toList(); + int adultCount = (int) roomGuests.stream() + .filter(RoomGuest::occupiesBed) + .count(); + rooms.add(new ReservationRoomingListRoomDto( + rooms.size() + 1, + primaryGuest, + accompanyingGuests, + adultCount)); + } + return new SourceRows(List.copyOf(guests), manualStayDateRange, List.copyOf(rooms)); + } + + private void finishCurrentRoom(List> sourceRooms, List currentRoomGuests) { + if (!currentRoomGuests.isEmpty()) { + sourceRooms.add(List.copyOf(currentRoomGuests)); + currentRoomGuests.clear(); + } + } + /** * 无旅游日期来源样式没有可靠旅游日期,必须使用用户提交的入住 / 离店日期。 */ @@ -404,6 +531,10 @@ public class RoomingListSourceExcelParser { return value == null || value.trim().isEmpty(); } + private boolean contains(String value, String keyword) { + return value != null && value.contains(keyword); + } + /** * 来源表头坐标。 * @@ -424,12 +555,44 @@ public class RoomingListSourceExcelParser { } /** - * 来源名单行和统一旅游日期区间。 + * 第四种来源样式的拼音姓名、儿童占床和原表分房表头坐标。 + * + * @param rowIndex 0 基表头行号 + * @param pinyinCellIndex `拼音` 列号 + * @param childBedCellIndex `儿童情况` 列号;来源没有该列时为空 + * @param roomGroupCellIndex `分房` 列号 + */ + private record PinyinRoomHeaders( + int rowIndex, + int pinyinCellIndex, + Integer childBedCellIndex, + int roomGroupCellIndex) { + } + + /** + * 来源原表分房中的单个旅客。 + * + * @param guest 目标房表可输出的旅客姓名 + * @param occupiesBed 是否占床;`儿童情况=不占床` 不计入 Adults,但仍进入 Accompanying Guest + */ + private record RoomGuest(ReservationRoomingListGuestDto guest, boolean occupiesBed) { + } + + /** + * 来源名单行、统一旅游日期区间和可选原表分房结果。 * * @param guests 来源旅客名单 * @param travelDateRange 统一旅游日期区间 + * @param preGroupedRooms 来源已明确给出的分房结果;为空时按每间房人数自动分房 */ - private record SourceRows(List guests, TravelDateRange travelDateRange) { + private record SourceRows( + List guests, + TravelDateRange travelDateRange, + List preGroupedRooms) { + + private SourceRows(List guests, TravelDateRange travelDateRange) { + this(guests, travelDateRange, null); + } } /** diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java index 11f2f47..6cefd26 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java @@ -206,6 +206,35 @@ class ReservationRoomingListGenerationServiceImplTest { } } + @Test + void shouldGenerateRoomingListWorkbookByPinyinColumnAndSourceRoomGroups() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + ReservationRoomingListGeneratedFile generatedFile = service.generate( + sourceFileWithPinyinRoomGroups(), + request(99, LocalDate.of(2026, 8, 23), LocalDate.of(2026, 8, 26)), + actor()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) { + assertThat(workbook.getSheetAt(0).getPhysicalNumberOfRows()).isEqualTo(3); + Row firstRoom = workbook.getSheetAt(0).getRow(1); + assertThat(cellText(firstRoom.getCell(0))).isEqualTo("1"); + assertThat(cellText(firstRoom.getCell(1))).isEqualTo("ZHANG"); + assertThat(cellText(firstRoom.getCell(2))).isEqualTo("JIANWEI"); + assertThat(cellText(firstRoom.getCell(4))).isEqualTo("23/08/2026"); + assertThat(cellText(firstRoom.getCell(5))).isEqualTo("26/08/2026"); + assertThat(cellText(firstRoom.getCell(9))).isEqualTo("2"); + assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG QIWEI, ZHOU YANLI"); + + Row secondRoom = workbook.getSheetAt(0).getRow(2); + assertThat(cellText(secondRoom.getCell(0))).isEqualTo("2"); + assertThat(cellText(secondRoom.getCell(1))).isEqualTo("ZHANG"); + assertThat(cellText(secondRoom.getCell(2))).isEqualTo("TONGFANG"); + assertThat(cellText(secondRoom.getCell(9))).isEqualTo("2"); + assertThat(cellText(secondRoom.getCell(13))).isEqualTo("CHEN WENXIAN"); + } + } + @Test void shouldRejectEnglishNameSourceWorkbookWithoutManualStayDates() throws Exception { when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); @@ -561,6 +590,34 @@ class ReservationRoomingListGenerationServiceImplTest { } } + private MockMultipartFile sourceFileWithPinyinRoomGroups() throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("团队名单表"); + Row titleRow = sheet.createRow(0); + titleRow.createCell(0).setCellValue("TJT260821A 2026-08-23 - 2026-08-26"); + Row headerRow = sheet.createRow(4); + headerRow.createCell(0).setCellValue("序号"); + headerRow.createCell(1).setCellValue("姓名"); + headerRow.createCell(2).setCellValue("拼音"); + headerRow.createCell(11).setCellValue("儿童情况"); + headerRow.createCell(12).setCellValue("分房"); + headerRow.createCell(13).setCellValue("备注"); + pinyinRoomRow(sheet.createRow(5), "1L", "领队", "CHEN ZHAOWEN", "", "外住", "领队"); + pinyinRoomRow(sheet.createRow(6), "1", "儿童A", "ZHANG QIWEI", "不占床", "用房1间", "一家"); + pinyinRoomRow(sheet.createRow(7), "2", "旅客A", "ZHANG JIANWEI", "", "", ""); + pinyinRoomRow(sheet.createRow(8), "3", "旅客B", "ZHOU YANLI", "", "", ""); + pinyinRoomRow(sheet.createRow(9), "4", "旅客C", "ZHANG TONGFANG", "", "用房1间", "夫妻"); + pinyinRoomRow(sheet.createRow(10), "5", "旅客D", "CHEN WENXIAN", "", "", ""); + workbook.write(outputStream); + return new MockMultipartFile( + "file", + "synthetic-pinyin-room-groups.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + outputStream.toByteArray()); + } + } + private void singleEnglishNameRow( Row row, String index, @@ -574,6 +631,22 @@ class ReservationRoomingListGenerationServiceImplTest { row.createCell(4).setCellValue(passportNo); } + private void pinyinRoomRow( + Row row, + String index, + String chineseName, + String pinyin, + String childBed, + String rooming, + String remark) { + row.createCell(0).setCellValue(index); + row.createCell(1).setCellValue(chineseName); + row.createCell(2).setCellValue(pinyin); + row.createCell(11).setCellValue(childBed); + row.createCell(12).setCellValue(rooming); + row.createCell(13).setCellValue(remark); + } + private void englishNameRow( Row row, int index,