实现Rooming List Excel生成接口

This commit is contained in:
andy
2026-07-18 11:36:16 +07:00
parent 75d860129c
commit d3a52e7605
25 changed files with 1709 additions and 3 deletions

View File

@@ -2,7 +2,7 @@
| 项 | 内容 |
| --- | --- |
| 最近更新 | 2026-07-17 |
| 最近更新 | 2026-07-18 |
| 当前分支 | `feature/huangting` |
| 当前阶段 | 项目文档标准化与业务能力迭代并行 |
| 当前重点 | 按 AI-NSES 建立新 Agent 可快速进入项目的文档入口 |
@@ -33,12 +33,14 @@
- 现有历史文档暂不按 AI-NSES 目录大搬迁,先通过索引和采用说明建立映射关系。
- `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。
- 后续每完成一个 Feature 或 Checkpoint需要更新本文件避免项目状态继续沉淀在聊天记录里。
- M010 Rooming List Excel 生成后端 CP1 已实现:前端上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。
## 5. Next Steps
- Review 本次 AI-NSES 文档入口改动。
- 确认通过后,可按后续具体需求继续进入业务开发。
- 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec再实现代码。
- M010 后续可进入前端 checkpoint新增 Rooming List 页面,上传来源名单、填写每房人数和目标列字段,并按 Blob 下载 `.xlsx`;如需预览、历史记录或 OSS 下载再单独开后续后端 checkpoint。
## 6. 文档同步提醒

View File

@@ -56,6 +56,7 @@
| `requirements/M007-agentbus-superagent-auto-dispatch-v1.md` | 当前有效 | M007 AgentBus 新邮件入库后异步分发 SuperAgent 的后端 V1 方案,当前默认关闭,等待测试机联调。 |
| `requirements/M008-excel-to-pdf-conversion-v1.md` | 当前有效 | M008 Excel 转 PDF 文件转换能力方案,记录 LibreOffice headless、手动上传转换、邮件附件自动派生 PDF 和部署要求CP2 已实现手动上传后端接口。 |
| `requirements/M009-manual-invoice-generation-v1.md` | 当前有效 | M009 Manual Invoice 手工开票生成方案;后端 CP2 已支持无订单 / 无任务手工填写、填充 Excel 模板、转 PDF、OSS 输出和生成记录。 |
| `requirements/M010-rooming-list-excel-generation-v1.md` | 当前有效 | M010 Rooming List Excel 生成方案;后端 CP1 已支持前端上传来源名单和手工字段,同步生成 `.xlsx` 直接下载,不落库、不上传 OSS。 |
## 集成契约

View File

@@ -46,6 +46,7 @@
| 订单任务主流程 V3 | `docs/project/requirements/M002-order-task-workflow-v3.md` | 当前开发基线,基于 0711 P0 冻结基线和 0712 P0.1 Parent Group 修订,覆盖 S10/S99、40 路由、方案 C、type-known manual review 同卡解阻和 fail-closed。 |
| 任务卡字段控件契约 V1 | `docs/project/requirements/M002-task-field-control-contract-v1.md` | 后端已返回 `fields[]` 控件元数据,规定人工复核控件复用和前后端边界;前端待接入。 |
| Manual Invoice 手工开票生成 | `docs/project/requirements/M009-manual-invoice-generation-v1.md` | 当前有效;后端 CP2 已支持无订单 / 无任务手工填写字段、填 Excel 模板、转 PDF、OSS 输出和生成记录。 |
| Rooming List Excel 生成 | `docs/project/requirements/M010-rooming-list-excel-generation-v1.md` | 当前有效;后端 CP1 已支持前端上传来源名单并填写目标字段,同步生成 `.xlsx` 直接下载,不落库、不上传 OSS。 |
| 订单任务主流程 V2 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 已实现阶段记录,保留用于理解当前代码中的 S000/S999、订单任务流转和 OPERA 模拟骨架。 |
| 后端 checkpoint | `docs/project/requirements/M002-backend-checkpoint-plan.md` | 阶段记录,用于理解后端拆分和验收。 |
| 前端可用接口与待补接口 | `docs/project/frontend-backend/frontend-to-backend-api-requests.md` | 前后端协作清单,已区分可用、后置和历史候选路径,不替代后端权威契约。 |
@@ -61,6 +62,7 @@
- 真实 OPERA / OHIP 接入后置;当前仅有 OPERA 模拟骨架。
- 任务卡字段控件契约 V1 后端第一版已完成,任务详情 `fields[]` 已返回 `control_type/edit_scope/write_target/options_source/raw_readonly/control_hint`;前端后续按契约接入,不要硬编码 PMS 房型、Rate Code 或未冻结枚举。
- Manual Invoice 第一阶段按 M009 推进:后端已提供 `POST /api/reservation/invoices/manual-generations`,前端已新增 `/reservation/invoices/new` 手工开票页面,并已按 `invoice.html` 原型的三段式业务结构对齐;页面可以不依赖订单或任务,用户手工填写 / 选择字段后由后端业务接口填充 Excel 模板并生成 PDF前端不得直接调用 M008 的调试上传转换接口来完成业务开票。侧边栏入口仍以登录后端返回的 menus 为准,建议后续在菜单管理中配置 `RESERVATION_MANUAL_INVOICE` / `/reservation/invoices/new` / `RESERVATION_INVOICE_GENERATE`
- Rooming List Excel 后端 CP1 已按 M010 落地:接口为 `POST /api/reservation/rooming-lists/generations`,前端上传来源名单、填写每房人数和目标列字段,后端同步返回 `.xlsx` 下载;该能力不依赖订单或任务,第一版不落库、不上传 OSS权限码为 `RESERVATION_ROOMING_LIST_GENERATE`
## 6. 前端开发注意事项

View File

@@ -74,6 +74,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 下载处理。 |
### 5.1 本轮新增 / 修改接口说明
@@ -405,6 +406,38 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填
- 第一版暂未提供 Invoice 历史列表、详情查询、任务 / 订单预填接口和客户联系人目录查询接口;前端客户 / 联系人候选可先按 M009 文档中的种子数据实现。
- 前端不得直接调用 `POST /api/system/document-conversions/excel-to-pdf` 来完成业务开票;该接口是 M008 调试 / 后台工具能力,受 access key 控制,不具备业务开票审计和权限边界。
### 5.12 Rooming List Excel 生成页面接入方向
M010 后端 CP1 已实现。第一版生成结果直接下载 `.xlsx`,不落库、不上传 OSS不依赖订单或任务。
接口:
```text
POST /api/reservation/rooming-lists/generations
Authorization: Bearer <access_token>
Content-Type: multipart/form-data
```
权限:
```text
RESERVATION_ROOMING_LIST_GENERATE
```
前端注意:
- 第一版页面建议作为 Reservation 运营工具入口,例如 `/reservation/rooming-lists/new`
- 该页面上传来源名单 Excel后端默认识别 `护照全名` 列。
- 姓名支持 `LI/CHUNHONG``LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`
- 前端必须让用户输入 `people_per_room`,后端按名单顺序分组,并用 `ceil(total_people / people_per_room)` 生成房间行。
- 每组第一位旅客写入 `Name` / `First Name`,同组剩余旅客写入 `Accompanying Guests`,多人用英文逗号分隔。
-`Line``Name``First Name``Number of Rooms``Accompanying Guests` 外,目标 Excel 其他字段第一版都由前端输入或选择,例如 `Arrival``Departure``Room Type``Rate Code``Payment Type``Nationality`
- 第一版接口成功后直接返回 Excel 文件流,前端应按 Blob 下载处理,不要期待 JSON 里的 URL。
- 失败时返回统一 JSON 错误,例如 `ROOMING_LIST_VALIDATION_FAILED``ROOMING_LIST_SOURCE_FILE_INVALID``HOTEL_ACCESS_DENIED``FRONTEND_PERMISSION_DENIED`
- `ROOMING_LIST_VALIDATION_FAILED` 已覆盖 multipart 必填字段缺失、日期格式错误和数字格式错误,`details[]` 会返回字段级提示。
- 页面不要把上传文件内容、客人名单、生成文件内容写入浏览器日志、埋点、错误上报、URL 或 localStorage。
- 第一版没有 preview 接口、生成记录接口、OSS URL、历史下载和订单 / 任务预填;前端不要在页面上承诺这些能力。
## 6. 不给前端直接调用的接口
- `POST /api/system/reservation/demo-data` 只用于 dev/test 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。

View File

@@ -0,0 +1,293 @@
# M010 Rooming List Excel 生成 V1
| 项目 | 内容 |
| --- | --- |
| 文档状态 | 后端 CP1 已实现,第一版直接下载 `.xlsx`,不落库、不上传 OSS |
| 适用范围 | 从旅行团名单 Excel 解析护照姓名,并生成酒店 / PMS 可导入的 Rooming List Excel |
| 当前目标 | 第一阶段已支持前端上传来源 Excel、填写分房和目标列默认值后端同步生成目标格式 `.xlsx` 供浏览器下载 |
| 依赖能力 | 登录权限、酒店上下文、Apache POI 或等价 Excel 读写能力 |
## 1. 背景
当前系统已经支持 Manual Invoice 生成,但运营中还存在另一类独立工具诉求:旅行社或渠道提供一份名单 Excel里面包含旅客护照姓名酒店侧需要整理成 PMS / 导入模板要求的 Rooming List Excel。
本功能第一版只解决“来源名单到目标 Rooming List”的结构转换和分房填表问题不依赖订单、任务、SuperAgent、AgentBus 或 OPERA。
## 2. 第一阶段定位
第一阶段应做到:
- 前端上传 `.xls` / `.xlsx` 来源名单。
- 后端解析来源名单中的护照姓名字段。
- 前端输入每间房人数。
- 前端填写或选择目标 Excel 中除姓名外的字段。
- 后端按来源名单顺序分组,生成目标格式 Excel。
- 浏览器直接下载生成后的 `.xlsx` 文件。
第一阶段不做:
- 不落业务订单。
- 不创建任务。
- 不上传 OSS。
- 不新增生成记录表。
- 不做历史查询和重下载。
- 不接真实 OPERA / OHIP。
- 不做复杂房型混住、拖拽换房或手工调整入住人顺序。
- 不从任务详情或订单详情自动预填字段。
## 3. 与现有模块关系
本功能属于 Reservation 运营工具,不属于 M008 通用文件转换,也不属于 M009 Manual Invoice。
边界说明:
- M008 负责 Excel 转 PDF面向平台文件转换和调试能力。
- M009 负责 Proforma Invoice 生成,输出 PDF 并上传 OSS。
- M010 负责 Rooming List Excel 生成,第一版只输出 `.xlsx` 文件流。
因此第一版建议在 Reservation 模块内新增独立接口、权限码和服务,不复用发票生成权限,也不让前端调用 M008 调试接口。
## 4. 来源 Excel 解析规则
基于当前样例来源文件,第一版默认识别以下表头:
| 来源字段 | 中文说明 | 第一版用途 |
| --- | --- | --- |
| `护照全名` | 护照姓名,可能包含 `/` 或空格 | 解析为目标 Excel 的 `Name``First Name` |
第一版只使用 `护照全名` 生成目标姓名字段。来源文件中的证件号、生日、性别、年龄等字段暂不自动写入目标 Excel除非后续单独确认。
### 4.1 姓名拆分
支持的输入格式:
```text
LI/CHUNHONG
LI CHUNHONG
LIU/JIAYI
```
规则:
- 先 trim 首尾空格。
- 优先按 `/` 拆分。
- 如果没有 `/`,按连续空白拆分。
- 第一段写入目标 `Name`
- 剩余部分合并后写入目标 `First Name`
- 如果无法拆出两段,返回明确校验错误,由前端提示用户修正来源 Excel 或后续增加字段映射。
中文说明:
- 当前目标模板中 `Name` 更接近姓氏字段,`First Name` 更接近名字段。
- 第一版不尝试判断中文、韩文、英文姓名顺序,也不做大小写改写。
## 5. 分房生成规则
前端需要传入 `people_per_room`,表示每间房人数。
规则:
- 按来源 Excel 中旅客出现顺序分组。
- `room_count = ceil(total_people / people_per_room)`
- 每组第一位旅客写入目标 Excel 的 `Name``First Name`
- 同组剩余旅客写入目标 Excel 的 `Accompanying Guests`
- 多个陪同人使用英文逗号 `,` 分隔。
- 最后一组人数不足 `people_per_room` 时,仍按一间房生成一行。
示例:
```text
people_per_room = 3
第 1 组:
Name = LI
First Name = CHUNHONG
Accompanying Guests = ZHANG GAILI, LI HONG
第 2 组:
Name = LIU
First Name = JIAYI
Accompanying Guests = ZHU XINGTONG, CHEN YUCHAI
```
## 6. 目标 Excel 字段
目标 Excel 第一版按当前样例模板生成以下列:
| 目标列 | 第一版来源 |
| --- | --- |
| `Line` | 后端按生成行号从 1 递增 |
| `Name` | 每间房第一位旅客的姓氏 / 第一段姓名 |
| `First Name` | 每间房第一位旅客的名字 / 剩余姓名 |
| `Title` | 前端传入,第一版可为空 |
| `Arrival` | 前端传入 |
| `Departure` | 前端传入 |
| `Room Type` | 前端传入 |
| `Rate Code` | 前端传入 |
| `Number of Rooms` | 第一版每行固定为 `1`,后续如目标模板变化再调整 |
| `Adults` | 前端传入;为空时可由后端按当前分组人数派生 |
| `Children` | 前端传入;第一版默认可为 `0` |
| `Payment Type` | 前端传入 |
| `VIP` | 前端传入,第一版可为空 |
| `Accompanying Guests` | 同房其他旅客姓名,英文逗号分隔 |
| `Nationality` | 前端传入 |
| `Email` | 前端传入,第一版可为空 |
| `ID Type` | 前端传入,第一版可为空 |
| `ID Number` | 前端传入,第一版可为空 |
中文说明:
-`Line``Name``First Name``Number of Rooms``Accompanying Guests` 外,第一版都由前端输入或选择。
- 如果前端希望 `Adults` 自动等于当前房间人数,后端可以提供默认规则;但请求体仍建议显式保留覆盖字段,方便后续处理儿童、领队或特殊房间。
## 7. 接口契约
第一版已新增业务接口,不复用 M008 文件转换接口:
```text
POST /api/reservation/rooming-lists/generations
Authorization: Bearer <access_token>
Content-Type: multipart/form-data
```
表单字段:
| 字段 | 类型 | 必填 | 中文说明 |
| --- | --- | --- | --- |
| `file` | file | 是 | 来源 Excel 文件,仅支持 `.xls` / `.xlsx` |
| `hotel_id` | string | 否 | 酒店 ID不传时按当前登录用户默认酒店或单酒店上下文解析 |
| `people_per_room` | integer | 是 | 每间房人数,必须大于 0 |
| `arrival` | string | 是 | 入住日期,酒店本地业务日期,格式 `yyyy-MM-dd` |
| `departure` | string | 是 | 离店日期,酒店本地业务日期,格式 `yyyy-MM-dd` |
| `room_type` | string | 是 | 目标 Excel 的 `Room Type` |
| `rate_code` | string | 否 | 目标 Excel 的 `Rate Code` |
| `adults` | integer | 否 | 目标 Excel 的 `Adults`;为空时可由后端按分组人数派生 |
| `children` | integer | 否 | 目标 Excel 的 `Children`;为空时默认 `0` |
| `payment_type` | string | 否 | 目标 Excel 的 `Payment Type` |
| `vip` | string | 否 | 目标 Excel 的 `VIP` |
| `nationality` | string | 否 | 目标 Excel 的 `Nationality` |
| `email` | string | 否 | 目标 Excel 的 `Email` |
| `id_type` | string | 否 | 目标 Excel 的 `ID Type` |
| `id_number` | string | 否 | 目标 Excel 的 `ID Number` |
成功响应:
```text
HTTP 200
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="rooming-list-<timestamp>.xlsx"
```
错误响应沿用后端统一错误模型,例如:
```json
{
"error_code": "ROOMING_LIST_SOURCE_FILE_INVALID",
"message": "来源名单文件不合法。",
"details": [
"未找到表头:护照全名"
]
}
```
已收口的第一版错误场景:
- multipart 必填字段缺失,返回 `ROOMING_LIST_VALIDATION_FAILED`
- `arrival` / `departure` 日期格式错误,返回 `ROOMING_LIST_VALIDATION_FAILED`
- `people_per_room``adults``children` 数字格式错误,返回 `ROOMING_LIST_VALIDATION_FAILED`
- 来源文件缺失、非 `.xls` / `.xlsx`、无法读取或缺少 `护照全名` 表头,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`
- 错误详情只返回字段名或行号,不回显完整名单、证件号或源文件内容。
## 8. 权限、安全和审计
接口分类:`FRONTEND_USER`
已新增权限码:
```text
RESERVATION_ROOMING_LIST_GENERATE
```
管控要求:
- 必须登录。
- 必须拥有 `RESERVATION_ROOMING_LIST_GENERATE` 权限。
- 必须校验用户对 `hotel_id` 的访问权。
- 第一版直接下载、不落库,因此不写生成记录表。
- 当前 CP1 不新增持久化审计表;后续如增加生成记录或历史下载,再补业务审计落库。
- 日志和错误响应不得输出完整名单、证件号、源文件内容或生成文件内容。
## 9. 后端实现拆分
已实现类职责:
| 类 / 能力 | 中文职责 |
| --- | --- |
| `ReservationRoomingListGenerationController` | 接收前端上传请求,做登录、权限和酒店上下文校验 |
| `ReservationRoomingListGenerationService` | 编排来源解析、分房、目标 Excel 渲染和文件响应 |
| `ReservationRoomingListGenerationServiceImpl` | 具体业务实现 |
| `RoomingListSourceExcelParser` | 解析来源 Excel提取护照姓名 |
| `RoomingListNameParser` | 拆分 `/` 或空格格式的护照姓名 |
| `RoomingListGroupingService` | 按每房人数分组并生成目标行模型 |
| `RoomingListExcelRenderer` | 生成目标格式 `.xlsx` |
| `ReservationRoomingListGenerationRequest` | multipart 外的业务参数请求模型 |
| `ReservationRoomingListGeneratedFile` | 生成后的下载文件字节和文件名 |
| `ReservationRoomingListGuestDto` | 解析后的旅客姓名结构 |
| `ReservationRoomingListRoomDto` | 分房后的目标行结构 |
中文说明:
- 第一版不需要新增 Entity、Mapper 和 Repository。
- 如果后续要生成记录、OSS 上传、历史查询或重下载,再新增 `workflow_reservation_rooming_list_generation` 表。
- 解析和渲染能力应和 Controller 解耦,便于单元测试。
- 目标 Excel 固定列宽,不调用 POI `autoSizeColumn`,避免后端运行环境依赖本机字体或图形环境。
## 10. 前端实现建议
第一版页面可以放在 Reservation 工具入口,例如:
```text
/reservation/rooming-lists/new
```
页面能力:
- 上传来源 Excel。
- 填写每间房人数。
- 填写或选择 Arrival、Departure、Room Type、Rate Code、Payment Type、Nationality 等字段。
- 展示解析预览:总人数、预计房间数、前几行分房结果。
- 点击生成后下载 `.xlsx`
前端注意:
- 不要在前端解析完整 Excel 作为权威结果;前端预览可以后置为后端 preview 接口。
- 第一版如果没有 preview 接口,可以只展示用户输入和文件名,生成成功后直接下载。
- 不要把上传文件内容、客人名单或生成文件内容写进浏览器日志、错误上报、URL 或 localStorage。
## 11. 测试范围
后端测试建议覆盖:
- 能识别 `护照全名` 表头。
- `LI/CHUNHONG` 能拆成 `Name=LI``First Name=CHUNHONG`
- `LI CHUNHONG` 能拆成 `Name=LI``First Name=CHUNHONG`
- 姓名首尾空格会 trim。
- `people_per_room=3` 时 32 人生成 11 行。
- 尾房人数不足时仍生成一行。
- `Accompanying Guests` 使用英文逗号拼接。
- 未找到表头时返回明确错误。
- 非 Excel 文件、空文件、超大文件返回明确错误。
- 无 token、无权限、无酒店访问权被拦截。
## 12. 后续版本候选
后续可以继续扩展:
- 生成记录表和历史下载。
- 生成文件上传 OSS。
- 后端预览接口。
- 支持前端手工调整分房顺序。
- 支持不同房间不同人数。
- 支持字段映射配置,允许用户选择来源 Excel 中的姓名列。
- 支持从订单、任务或 SourceMessage 附件自动带入名单。
- 支持从任务详情或订单详情预填 Arrival、Departure、Room Type、Rate Code 等字段。

View File

@@ -55,6 +55,7 @@
| `POST /api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry` | `FRONTEND_USER` | 当前为 OPERA 模拟 | 登录 + `RESERVATION_OPERA_SIM_EXECUTE` + 酒店访问权 | 必须写业务审计和 attempt |
| `GET /api/reservation/tasks/{taskId}/audits` | `FRONTEND_USER` | 已强制 Bearer 登录 + `RESERVATION_AUDIT_READ`;按任务实际所属酒店校验访问权 | 保持登录 + `RESERVATION_AUDIT_READ` + 酒店访问权 | 查询审计不再写审计 |
| `POST /api/reservation/invoices/manual-generations` | `FRONTEND_USER` | 已实现 M009 CP2强制 Bearer 登录 + `RESERVATION_INVOICE_GENERATE` + 酒店访问权;`task_id` / `order_id` 可为空,传入时反查对象所属酒店 | 保持登录 + `RESERVATION_INVOICE_GENERATE` + 酒店访问权;后续如增加历史列表或预填接口需单独登记权限 | 写业务审计,记录来源类型、模板版本、生成结果摘要;生成失败写入生成记录安全错误摘要 |
| `POST /api/reservation/rooming-lists/generations` | `FRONTEND_USER` | 已实现 M010 CP1multipart 上传来源名单并直接下载 `.xlsx` | 登录 + `RESERVATION_ROOMING_LIST_GENERATE` + 酒店访问权;第一版不落库、不上传 OSS | CP1 不落生成记录表;错误响应不得记录完整名单和证件信息,后续若增加历史记录再补业务审计 |
### 3.3 来源邮件接口
@@ -124,6 +125,7 @@
| `RESERVATION_OPERA_SIM_EXECUTE` | 执行或重试 OPERA 模拟 / 未来真实操作 | OPERA execute / retry |
| `RESERVATION_AUDIT_READ` | 查看业务审计流水 | 任务审计列表 |
| `RESERVATION_INVOICE_GENERATE` | 生成 Reservation Proforma Invoice | Manual Invoice 生成、未来任务 / 订单预填生成 |
| `RESERVATION_ROOMING_LIST_GENERATE` | 生成 Reservation Rooming List Excel | Rooming List 上传名单并生成 `.xlsx` 下载 |
| `SOURCE_MESSAGE_READ` | 查看来源邮件安全摘要 | SourceMessage 列表、详情、会话摘要 |
| `SOURCE_MESSAGE_ORIGINAL_READ` | 查看邮件正文、HTML 和附件外链 | original / conversation 完整正文;必须叠加 `SOURCE_MESSAGE_READ` 使用 |
| `SYSTEM_DEBUG_EML_RUN` | 使用 Debug EML 调试链路 | Debug EML 上传、查询、stream |

View File

@@ -13,6 +13,7 @@ public enum PlatformPermissionCode {
RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_AUDIT_READ,
RESERVATION_INVOICE_GENERATE,
RESERVATION_ROOMING_LIST_GENERATE,
HOTEL_SWITCH,
SYSTEM_AUTH_READ,
SYSTEM_USER_MANAGE,

View File

@@ -291,6 +291,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
PlatformPermissionCode.RESERVATION_OPERA_SIM_EXECUTE,
PlatformPermissionCode.RESERVATION_AUDIT_READ,
PlatformPermissionCode.RESERVATION_INVOICE_GENERATE,
PlatformPermissionCode.RESERVATION_ROOMING_LIST_GENERATE,
PlatformPermissionCode.SOURCE_MESSAGE_READ,
PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ));
matrix.put(PlatformRoleCode.RESERVATION_VIEWER, List.of(
@@ -317,6 +318,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
case RESERVATION_OPERA_SIM_EXECUTE -> "执行 OPERA 模拟";
case RESERVATION_AUDIT_READ -> "读取任务审计";
case RESERVATION_INVOICE_GENERATE -> "生成预订发票";
case RESERVATION_ROOMING_LIST_GENERATE -> "生成 Rooming List";
case HOTEL_SWITCH -> "切换酒店";
case SYSTEM_AUTH_READ -> "读取当前登录上下文";
case SYSTEM_USER_MANAGE -> "管理用户";
@@ -336,7 +338,8 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
case SOURCE_MESSAGE_READ, SOURCE_MESSAGE_ORIGINAL_READ -> "SOURCE_MESSAGE";
case RESERVATION_ORDER_READ, RESERVATION_TASK_READ, RESERVATION_TASK_EDIT,
RESERVATION_TASK_CONFIRM, RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE -> "RESERVATION";
RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE,
RESERVATION_ROOMING_LIST_GENERATE -> "RESERVATION";
case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL";
default -> "SYSTEM";
};

View File

@@ -0,0 +1,14 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* Rooming List 生成后的下载文件。
*
* @param fileName 下载文件名
* @param contentType 文件 MIME 类型
* @param content 文件字节内容
*/
public record ReservationRoomingListGeneratedFile(
String fileName,
String contentType,
byte[] content) {
}

View File

@@ -0,0 +1,16 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* 来源名单中解析出的旅客姓名。
*
* @param rowNumber 来源 Excel 中的 1 基行号
* @param lastName 目标 Excel 的 Name 字段,通常为护照姓氏
* @param firstName 目标 Excel 的 First Name 字段,通常为护照名字
* @param displayName 同房陪同人展示姓名,按目标模板写入 Accompanying Guests
*/
public record ReservationRoomingListGuestDto(
int rowNumber,
String lastName,
String firstName,
String displayName) {
}

View File

@@ -0,0 +1,18 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.util.List;
/**
* Rooming List 按每房人数分组后的目标行。
*
* @param lineNumber 目标 Excel 的 Line 行号
* @param primaryGuest 每间房第一位旅客,用于写入 Name / First Name
* @param accompanyingGuests 同房陪同旅客列表
* @param adultCount 当前房间成人数
*/
public record ReservationRoomingListRoomDto(
int lineNumber,
ReservationRoomingListGuestDto primaryGuest,
List<ReservationRoomingListGuestDto> accompanyingGuests,
int adultCount) {
}

View File

@@ -0,0 +1,38 @@
package cn.nianxx.thhotel.workflows.reservation.common.request;
import java.time.LocalDate;
/**
* Rooming List Excel 生成请求参数。来源文件通过 multipart file 单独传入。
*
* @param hotelId 酒店 ID为空时按当前登录用户默认酒店解析
* @param peoplePerRoom 每间房人数
* @param arrival 入住日期,酒店本地业务日期
* @param departure 离店日期,酒店本地业务日期
* @param roomType 目标 Excel 的 Room Type
* @param rateCode 目标 Excel 的 Rate Code
* @param adults 目标 Excel 的 Adults为空时按当前分房人数派生
* @param children 目标 Excel 的 Children为空时默认 0
* @param paymentType 目标 Excel 的 Payment Type
* @param vip 目标 Excel 的 VIP
* @param nationality 目标 Excel 的 Nationality
* @param email 目标 Excel 的 Email
* @param idType 目标 Excel 的 ID Type
* @param idNumber 目标 Excel 的 ID Number
*/
public record ReservationRoomingListGenerationRequest(
String hotelId,
Integer peoplePerRoom,
LocalDate arrival,
LocalDate departure,
String roomType,
String rateCode,
Integer adults,
Integer children,
String paymentType,
String vip,
String nationality,
String email,
String idType,
String idNumber) {
}

View File

@@ -0,0 +1,90 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGeneratedFile;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationRoomingListGenerationService;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* Reservation Rooming List 生成接口。第一版接收来源名单并直接返回 `.xlsx` 下载。
*/
@RestController
@RequestMapping("/api/reservation/rooming-lists")
public class ReservationRoomingListGenerationController {
private final FrontendAuthorizationService authorizationService;
private final ReservationRoomingListGenerationService roomingListGenerationService;
/**
* 注入前端鉴权服务和 Rooming List 生成服务。
*/
public ReservationRoomingListGenerationController(
FrontendAuthorizationService authorizationService,
ReservationRoomingListGenerationService roomingListGenerationService) {
this.authorizationService = authorizationService;
this.roomingListGenerationService = roomingListGenerationService;
}
/**
* 生成 Rooming List Excel。需要登录、Rooming List 生成权限和酒店访问权。
*/
@PostMapping(value = "/generations", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> generateRoomingList(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "hotel_id", required = false) String hotelId,
@RequestParam("people_per_room") Integer peoplePerRoom,
@RequestParam("arrival") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate arrival,
@RequestParam("departure") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate departure,
@RequestParam("room_type") String roomType,
@RequestParam(value = "rate_code", required = false) String rateCode,
@RequestParam(value = "adults", required = false) Integer adults,
@RequestParam(value = "children", required = false) Integer children,
@RequestParam(value = "payment_type", required = false) String paymentType,
@RequestParam(value = "vip", required = false) String vip,
@RequestParam(value = "nationality", required = false) String nationality,
@RequestParam(value = "email", required = false) String email,
@RequestParam(value = "id_type", required = false) String idType,
@RequestParam(value = "id_number", required = false) String idNumber) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_ROOMING_LIST_GENERATE.name());
ReservationRoomingListGeneratedFile generatedFile = roomingListGenerationService.generate(
file,
new ReservationRoomingListGenerationRequest(
hotelId,
peoplePerRoom,
arrival,
departure,
roomType,
rateCode,
adults,
children,
paymentType,
vip,
nationality,
email,
idType,
idNumber),
actor);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(generatedFile.contentType()))
.contentLength(generatedFile.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(generatedFile.fileName())
.build()
.toString())
.body(generatedFile.content());
}
}

View File

@@ -0,0 +1,76 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse;
import java.util.List;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
/**
* Rooming List 生成接口参数绑定异常处理。用于把 multipart 缺参和类型错误收口为稳定业务错误。
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(assignableTypes = ReservationRoomingListGenerationController.class)
public class ReservationRoomingListGenerationControllerAdvice {
private static final String ERROR_CODE = "ROOMING_LIST_VALIDATION_FAILED";
private static final String ERROR_MESSAGE = "Rooming List 字段校验失败。";
/**
* 处理 multipart 表单普通字段缺失。
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMissingRequestParameter(
MissingServletRequestParameterException exception) {
return badRequest(List.of(exception.getParameterName() + ": 必填字段缺失。"));
}
/**
* 处理 multipart 文件字段缺失。
*/
@ExceptionHandler(MissingServletRequestPartException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMissingRequestPart(
MissingServletRequestPartException exception) {
return badRequest(List.of(exception.getRequestPartName() + ": 文件字段缺失。"));
}
/**
* 处理日期、数字等请求参数格式错误。
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleTypeMismatch(
MethodArgumentTypeMismatchException exception) {
return badRequest(List.of(exception.getName() + ": 参数格式不合法。"));
}
/**
* 处理上传文件超过系统 multipart 限制。
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMaxUploadSizeExceeded(
MaxUploadSizeExceededException exception) {
return badRequest(List.of("file: 上传文件超过系统允许大小。"));
}
/**
* 处理 multipart 请求整体格式错误。
*/
@ExceptionHandler(MultipartException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMultipartException(
MultipartException exception) {
return badRequest(List.of("file: multipart 请求不合法。"));
}
private ResponseEntity<ReservationWorkflowErrorResponse> badRequest(List<String> details) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ReservationWorkflowErrorResponse(ERROR_CODE, ERROR_MESSAGE, details));
}
}

View File

@@ -3,6 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.security.service.impl.FrontendAuthorizationException;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationInvoiceGenerationException;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationRoomingListGenerationException;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException;
import java.util.List;
import org.springframework.http.ResponseEntity;
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
ReservationTaskController.class,
ReservationFrontendQueryController.class,
ReservationDemoDataController.class,
ReservationInvoiceGenerationController.class
ReservationInvoiceGenerationController.class,
ReservationRoomingListGenerationController.class
})
public class ReservationTaskControllerAdvice {
@@ -46,6 +48,19 @@ public class ReservationTaskControllerAdvice {
exception.getDetails()));
}
/**
* 处理 Rooming List 生成阶段的受控业务异常。
*/
@ExceptionHandler(ReservationRoomingListGenerationException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleRoomingListGenerationException(
ReservationRoomingListGenerationException exception) {
return ResponseEntity.status(exception.getStatus())
.body(new ReservationWorkflowErrorResponse(
exception.getErrorCode(),
exception.getMessage(),
exception.getDetails()));
}
/**
* 处理前端业务接口登录或权限不足异常,保持 Reservation 错误响应结构稳定。
*/

View File

@@ -0,0 +1,20 @@
package cn.nianxx.thhotel.workflows.reservation.service;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGeneratedFile;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
import org.springframework.web.multipart.MultipartFile;
/**
* Reservation Rooming List Excel 生成服务。
*/
public interface ReservationRoomingListGenerationService {
/**
* 根据来源名单和前端手工字段生成目标 Rooming List Excel。第一版同步返回文件字节不落库。
*/
ReservationRoomingListGeneratedFile generate(
MultipartFile sourceFile,
ReservationRoomingListGenerationRequest request,
AuthenticatedUserContext actor);
}

View File

@@ -0,0 +1,41 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import java.util.List;
import org.springframework.http.HttpStatus;
/**
* Rooming List 生成受控异常。错误响应只返回安全摘要,不回显完整客人名单或源文件内容。
*/
public class ReservationRoomingListGenerationException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
private final List<String> details;
public ReservationRoomingListGenerationException(HttpStatus status, String errorCode, String message) {
this(status, errorCode, message, List.of());
}
public ReservationRoomingListGenerationException(
HttpStatus status,
String errorCode,
String message,
List<String> details) {
super(message);
this.status = status;
this.errorCode = errorCode;
this.details = details == null ? List.of() : List.copyOf(details);
}
public HttpStatus getStatus() {
return status;
}
public String getErrorCode() {
return errorCode;
}
public List<String> getDetails() {
return details;
}
}

View File

@@ -0,0 +1,184 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGeneratedFile;
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.request.ReservationRoomingListGenerationRequest;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationRoomingListGenerationService;
import java.time.Clock;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* Reservation Rooming List Excel 生成服务实现。第一版同步生成下载文件,不落库、不上传 OSS。
*/
@Service
public class ReservationRoomingListGenerationServiceImpl implements ReservationRoomingListGenerationService {
private static final String EXCEL_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final long MAX_SOURCE_FILE_BYTES = 20L * 1024L * 1024L;
private static final DateTimeFormatter FILE_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final HotelContextService hotelContextService;
private final RoomingListSourceExcelParser sourceExcelParser;
private final RoomingListGroupingService groupingService;
private final RoomingListExcelRenderer excelRenderer;
/**
* 注入酒店上下文、来源解析、分房和 Excel 渲染组件。
*/
public ReservationRoomingListGenerationServiceImpl(
HotelContextService hotelContextService,
RoomingListSourceExcelParser sourceExcelParser,
RoomingListGroupingService groupingService,
RoomingListExcelRenderer excelRenderer) {
this.hotelContextService = hotelContextService;
this.sourceExcelParser = sourceExcelParser;
this.groupingService = groupingService;
this.excelRenderer = excelRenderer;
}
/**
* 生成 Rooming List Excel。只返回安全文件字节不保存完整名单。
*/
@Override
public ReservationRoomingListGeneratedFile generate(
MultipartFile sourceFile,
ReservationRoomingListGenerationRequest request,
AuthenticatedUserContext actor) {
ReservationRoomingListGenerationRequest normalizedRequest = normalizeAndValidate(request, sourceFile);
String hotelId = resolveAccessibleHotel(normalizedRequest.hotelId());
List<ReservationRoomingListGuestDto> guests = sourceExcelParser.parse(sourceFile);
List<ReservationRoomingListRoomDto> rooms = groupingService.group(
guests,
normalizedRequest.peoplePerRoom(),
normalizedRequest.adults());
byte[] content = excelRenderer.render(rooms, normalizedRequest);
return new ReservationRoomingListGeneratedFile(
buildFileName(hotelId),
EXCEL_CONTENT_TYPE,
content);
}
/**
* 归一化请求并校验手工字段和来源文件基本大小。
*/
private ReservationRoomingListGenerationRequest normalizeAndValidate(
ReservationRoomingListGenerationRequest request,
MultipartFile sourceFile) {
if (request == null) {
throw validationError(List.of("request: 请求参数不能为空。"));
}
List<String> errors = new ArrayList<>();
if (sourceFile == null || sourceFile.isEmpty()) {
errors.add("file: 来源 Excel 文件不能为空。");
} else if (sourceFile.getSize() > MAX_SOURCE_FILE_BYTES) {
errors.add("file: 来源 Excel 文件不能超过 20MB。");
}
if (request.peoplePerRoom() == null) {
errors.add("people_per_room: 必填字段缺失。");
} else if (request.peoplePerRoom() <= 0) {
errors.add("people_per_room: 必须大于 0。");
}
if (request.arrival() == null) {
errors.add("arrival: 必填字段缺失。");
}
if (request.departure() == null) {
errors.add("departure: 必填字段缺失。");
}
if (request.arrival() != null && request.departure() != null
&& !request.departure().isAfter(request.arrival())) {
errors.add("departure: 离店日期必须晚于入住日期。");
}
if (isBlank(request.roomType())) {
errors.add("room_type: 必填字段缺失。");
}
validateNonNegative(request.adults(), "adults", errors);
validateNonNegative(request.children(), "children", errors);
if (!errors.isEmpty()) {
throw validationError(errors);
}
return new ReservationRoomingListGenerationRequest(
trimToNull(request.hotelId()),
request.peoplePerRoom(),
request.arrival(),
request.departure(),
trimToEmpty(request.roomType()),
trimToEmpty(request.rateCode()),
request.adults(),
request.children(),
trimToEmpty(request.paymentType()),
trimToEmpty(request.vip()),
trimToEmpty(request.nationality()),
trimToEmpty(request.email()),
trimToEmpty(request.idType()),
trimToEmpty(request.idNumber()));
}
/**
* 校验当前登录用户可访问目标酒店。
*/
private String resolveAccessibleHotel(String requestedHotelId) {
try {
return hotelContextService.requireAccessibleHotel(requestedHotelId);
} catch (HotelContextException exception) {
throw new ReservationRoomingListGenerationException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
private void validateNonNegative(Integer value, String fieldPath, List<String> errors) {
if (value != null && value < 0) {
errors.add(fieldPath + ": 不能小于 0。");
}
}
private ReservationRoomingListGenerationException validationError(List<String> details) {
return new ReservationRoomingListGenerationException(
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_VALIDATION_FAILED",
"Rooming List 字段校验失败。",
details);
}
private String buildFileName(String hotelId) {
String timestamp = java.time.LocalDateTime.now(Clock.systemUTC())
.format(FILE_TIMESTAMP_FORMATTER);
return "rooming-list-" + safeFileNamePart(hotelId) + "-" + timestamp + ".xlsx";
}
private String safeFileNamePart(String value) {
String normalized = trimToNull(value);
if (normalized == null) {
return "hotel";
}
return normalized.replaceAll("[^A-Za-z0-9._-]", "_");
}
private boolean isBlank(String value) {
return trimToNull(value) == null;
}
private String trimToEmpty(String value) {
String normalized = trimToNull(value);
return normalized == null ? "" : normalized;
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,132 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListRoomDto;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* Rooming List 目标 Excel 渲染器。输出当前 PMS 导入样例需要的固定列结构。
*/
@Component
public class RoomingListExcelRenderer {
private static final String[] HEADERS = {
"Line",
"Name",
"First Name",
"Title",
"Arrival",
"Departure",
"Room Type",
"Rate Code",
"Number of Rooms",
"Adults",
"Children",
"Payment Type",
"VIP",
"Accompanying Guests",
"Nationality",
"Email",
"ID Type",
"ID Number"
};
/**
* 将分房结果渲染为 `.xlsx` 字节。
*/
public byte[] render(List<ReservationRoomingListRoomDto> rooms, ReservationRoomingListGenerationRequest request) {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
writeHeader(workbook, sheet.createRow(0));
for (ReservationRoomingListRoomDto room : rooms) {
writeRoomRow(sheet.createRow(room.lineNumber()), room, request);
}
for (int index = 0; index < HEADERS.length; index++) {
sheet.setColumnWidth(index, defaultColumnWidth(index));
}
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException exception) {
throw new ReservationRoomingListGenerationException(
HttpStatus.INTERNAL_SERVER_ERROR,
"ROOMING_LIST_RENDER_FAILED",
"Rooming List Excel 生成失败。");
}
}
/**
* 写入目标模板表头。
*/
private void writeHeader(XSSFWorkbook workbook, Row headerRow) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
for (int index = 0; index < HEADERS.length; index++) {
var cell = headerRow.createCell(index);
cell.setCellValue(HEADERS[index]);
cell.setCellStyle(style);
}
}
/**
* 写入单个房间对应的目标 Excel 行。
*/
private void writeRoomRow(
Row row,
ReservationRoomingListRoomDto room,
ReservationRoomingListGenerationRequest request) {
row.createCell(0).setCellValue(room.lineNumber());
row.createCell(1).setCellValue(room.primaryGuest().lastName());
row.createCell(2).setCellValue(room.primaryGuest().firstName());
row.createCell(3).setCellValue("");
row.createCell(4).setCellValue(request.arrival().format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(5).setCellValue(request.departure().format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(6).setCellValue(defaultString(request.roomType()));
row.createCell(7).setCellValue(defaultString(request.rateCode()));
row.createCell(8).setCellValue(1);
row.createCell(9).setCellValue(room.adultCount());
row.createCell(10).setCellValue(request.children() == null ? 0 : request.children());
row.createCell(11).setCellValue(defaultString(request.paymentType()));
row.createCell(12).setCellValue(defaultString(request.vip()));
row.createCell(13).setCellValue(accompanyingGuests(room));
row.createCell(14).setCellValue(defaultString(request.nationality()));
row.createCell(15).setCellValue(defaultString(request.email()));
row.createCell(16).setCellValue(defaultString(request.idType()));
row.createCell(17).setCellValue(defaultString(request.idNumber()));
}
private String accompanyingGuests(ReservationRoomingListRoomDto room) {
return room.accompanyingGuests().stream()
.map(guest -> guest.displayName())
.collect(Collectors.joining(", "));
}
/**
* 使用固定列宽,避免 POI 自动列宽依赖本机字体图形环境。
*/
private int defaultColumnWidth(int index) {
if (index == 13) {
return 36 * 256;
}
if (index == 4 || index == 5) {
return 14 * 256;
}
return 16 * 256;
}
private String defaultString(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,36 @@
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 java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* Rooming List 分房服务。第一版只按来源名单顺序和固定每房人数分组。
*/
@Component
public class RoomingListGroupingService {
/**
* 按每房人数将旅客顺序切分为目标 Excel 行。
*/
public List<ReservationRoomingListRoomDto> group(
List<ReservationRoomingListGuestDto> guests,
int peoplePerRoom,
Integer requestedAdults) {
List<ReservationRoomingListRoomDto> rooms = new ArrayList<>();
for (int start = 0; start < guests.size(); start += peoplePerRoom) {
int end = Math.min(start + peoplePerRoom, guests.size());
List<ReservationRoomingListGuestDto> roomGuests = guests.subList(start, end);
int lineNumber = rooms.size() + 1;
int adultCount = requestedAdults == null ? roomGuests.size() : requestedAdults;
rooms.add(new ReservationRoomingListRoomDto(
lineNumber,
roomGuests.get(0),
List.copyOf(roomGuests.subList(1, roomGuests.size())),
adultCount));
}
return rooms;
}
}

View File

@@ -0,0 +1,97 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGuestDto;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* Rooming List 护照姓名解析器。第一版只处理斜杠或空格分隔的英文护照姓名。
*/
@Component
public class RoomingListNameParser {
/**
* 将来源 Excel 单元格中的护照全名拆分为目标模板 Name / First Name。
*/
public ReservationRoomingListGuestDto parse(int rowNumber, String rawPassportName) {
String normalized = trimToNull(rawPassportName);
if (normalized == null) {
throw invalidName(rowNumber);
}
ParsedName parsedName = normalized.contains("/")
? parseSlashName(normalized)
: parseWhitespaceName(normalized);
if (parsedName == null || isBlank(parsedName.lastName()) || isBlank(parsedName.firstName())) {
throw invalidName(rowNumber);
}
return new ReservationRoomingListGuestDto(
rowNumber,
parsedName.lastName(),
parsedName.firstName(),
parsedName.lastName() + " " + parsedName.firstName());
}
/**
* 按 `/` 拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。
*/
private ParsedName parseSlashName(String value) {
List<String> parts = Arrays.stream(value.split("/"))
.map(this::trimToNull)
.filter(part -> part != null)
.toList();
if (parts.size() < 2) {
return null;
}
return new ParsedName(parts.get(0), parts.subList(1, parts.size()).stream()
.collect(Collectors.joining(" ")));
}
/**
* 按连续空白拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。
*/
private ParsedName parseWhitespaceName(String value) {
List<String> parts = Arrays.stream(value.split("\\s+"))
.map(this::trimToNull)
.filter(part -> part != null)
.toList();
if (parts.size() < 2) {
return null;
}
return new ParsedName(parts.get(0), parts.subList(1, parts.size()).stream()
.collect(Collectors.joining(" ")));
}
/**
* 构造姓名格式错误,错误详情只包含行号,不回显原始姓名。
*/
private ReservationRoomingListGenerationException invalidName(int rowNumber) {
return new ReservationRoomingListGenerationException(
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_SOURCE_FILE_INVALID",
"来源名单文件不合法。",
List.of("" + rowNumber + " 行护照全名无法拆分为 Name / First Name。"));
}
private boolean isBlank(String value) {
return trimToNull(value) == null;
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
/**
* 护照姓名拆分结果。
*
* @param lastName 姓氏
* @param firstName 名字
*/
private record ParsedName(String lastName, String firstName) {
}
}

View File

@@ -0,0 +1,144 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGuestDto;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
/**
* Rooming List 来源 Excel 解析器。第一版扫描 `护照全名` 表头并按行提取名单。
*/
@Component
public class RoomingListSourceExcelParser {
private static final String PASSPORT_NAME_HEADER = "护照全名";
private static final int MAX_HEADER_SCAN_ROWS = 20;
private final RoomingListNameParser nameParser;
/**
* 注入护照姓名解析器。
*/
public RoomingListSourceExcelParser(RoomingListNameParser nameParser) {
this.nameParser = nameParser;
}
/**
* 解析来源 Excel返回按原表顺序排列的旅客名单。
*/
public List<ReservationRoomingListGuestDto> parse(MultipartFile sourceFile) {
validateFile(sourceFile);
DataFormatter formatter = new DataFormatter();
try (InputStream inputStream = sourceFile.getInputStream();
Workbook workbook = WorkbookFactory.create(inputStream)) {
if (workbook.getNumberOfSheets() == 0) {
throw invalidFile(List.of("来源 Excel 至少需要一个 Sheet。"));
}
Sheet sheet = workbook.getSheetAt(0);
HeaderLocation header = findPassportNameHeader(sheet, formatter);
List<ReservationRoomingListGuestDto> guests = parseGuests(sheet, header, formatter);
if (guests.isEmpty()) {
throw invalidFile(List.of("护照全名列没有可用旅客姓名。"));
}
return guests;
} catch (ReservationRoomingListGenerationException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
throw invalidFile(List.of("来源 Excel 无法读取或格式不受支持。"));
}
}
/**
* 校验来源文件基本信息,避免非 Excel 或空文件进入解析。
*/
private void validateFile(MultipartFile sourceFile) {
if (sourceFile == null || sourceFile.isEmpty()) {
throw invalidFile(List.of("file: 来源 Excel 文件不能为空。"));
}
String fileName = sourceFile.getOriginalFilename();
if (isBlank(fileName) || !(fileName.toLowerCase().endsWith(".xlsx")
|| fileName.toLowerCase().endsWith(".xls"))) {
throw invalidFile(List.of("file: 仅支持 .xls / .xlsx 文件。"));
}
}
/**
* 在前若干行内查找 `护照全名` 表头位置。
*/
private HeaderLocation findPassportNameHeader(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;
}
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
Cell cell = row.getCell(cellIndex);
if (PASSPORT_NAME_HEADER.equals(cellText(cell, formatter))) {
return new HeaderLocation(rowIndex, cellIndex);
}
}
}
throw invalidFile(List.of("未找到表头:护照全名"));
}
/**
* 从表头下一行开始提取护照姓名,空白行自动跳过。
*/
private List<ReservationRoomingListGuestDto> parseGuests(
Sheet sheet,
HeaderLocation header,
DataFormatter formatter) {
List<ReservationRoomingListGuestDto> guests = new ArrayList<>();
for (int rowIndex = header.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
String passportName = cellText(row.getCell(header.cellIndex()), formatter);
if (isBlank(passportName)) {
continue;
}
guests.add(nameParser.parse(rowIndex + 1, passportName));
}
return guests;
}
private String cellText(Cell cell, DataFormatter formatter) {
if (cell == null) {
return "";
}
return formatter.formatCellValue(cell).trim();
}
private ReservationRoomingListGenerationException invalidFile(List<String> details) {
return new ReservationRoomingListGenerationException(
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_SOURCE_FILE_INVALID",
"来源名单文件不合法。",
details);
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
/**
* 来源表头坐标。
*
* @param rowIndex 0 基行号
* @param cellIndex 0 基列号
*/
private record HeaderLocation(int rowIndex, int cellIndex) {
}
}

View File

@@ -76,6 +76,7 @@ class AuthControllerTest {
"RESERVATION_OPERA_SIM_EXECUTE",
"RESERVATION_AUDIT_READ",
"RESERVATION_INVOICE_GENERATE",
"RESERVATION_ROOMING_LIST_GENERATE",
"HOTEL_SWITCH",
"SYSTEM_AUTH_READ",
"SYSTEM_USER_MANAGE",

View File

@@ -0,0 +1,202 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
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.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
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.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:reservation_rooming_list_generation;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=rooming-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=系统管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"superagent.task-result.hmac-secret=test-superagent-secret",
"mcp.enabled=true",
"mcp.auth-token=test-mcp-token"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationRoomingListGenerationControllerTest {
private static final String ENDPOINT = "/api/reservation/rooming-lists/generations";
@Autowired
private MockMvc mockMvc;
@Autowired
private PlatformIdentityRepository identityRepository;
@Autowired
private PlatformHotelRepository hotelRepository;
@Autowired
private AuthPasswordService passwordService;
@BeforeEach
void setUpNoPermissionUser() {
PlatformUserEntity user = identityRepository.findUserByUsername("rooming-no-permission")
.orElseGet(() -> {
LocalDateTime now = LocalDateTime.now();
PlatformUserEntity created = new PlatformUserEntity();
created.setUsername("rooming-no-permission");
created.setPasswordHash(passwordService.hash("NoPerm@123456"));
created.setDisplayName("无权限用户");
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
created.setSuperAdmin(false);
created.setPasswordChangedAt(now);
created.setCreatedAt(now);
created.setUpdatedAt(now);
identityRepository.insertUser(created);
return created;
});
hotelRepository.ensureUserHotel(user.getId(), "HOTEL-TEST", true);
}
@Test
void shouldDownloadGeneratedRoomingListWorkbook() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1")
.param("rate_code", "RACK")
.param("payment_type", "CA")
.param("nationality", "CN"))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andExpect(header().string("Content-Disposition", containsString("attachment;")))
.andExpect(header().string("Content-Disposition", containsString("rooming-list-HOTEL-TEST-")))
.andExpect(result -> assertThat(result.getResponse().getContentAsByteArray())
.startsWith(new byte[]{0x50, 0x4B}))
.andExpect(content().string(not(containsString("P123456"))));
}
@Test
void shouldRejectRoomingListGenerationWhenTokenMissing() throws Exception {
mockMvc.perform(multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
}
@Test
void shouldRejectRoomingListGenerationWhenPermissionMissing() throws Exception {
String token = loginToken(mockMvc, "rooming-no-permission", "NoPerm@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
void shouldRejectRoomingListGenerationWhenHotelAccessDenied() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "OTHER-HOTEL")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@Test
void shouldRejectRoomingListGenerationWhenRequiredFieldMissing() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("room_type: 必填字段缺失。"));
}
@Test
void shouldRejectRoomingListGenerationWhenDateFormatInvalid() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026/07/26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("arrival: 参数格式不合法。"));
}
private MockMultipartFile sourceFile() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
Row headerRow = sheet.createRow(0);
headerRow.createCell(0).setCellValue("姓名");
headerRow.createCell(1).setCellValue("护照全名");
Row row1 = sheet.createRow(1);
row1.createCell(0).setCellValue("游客1");
row1.createCell(1).setCellValue("LI/CHUNHONG");
Row row2 = sheet.createRow(2);
row2.createCell(0).setCellValue("游客2");
row2.createCell(1).setCellValue("ZHANG GAILI");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"LLT260509FA203.xlsx",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
outputStream.toByteArray());
}
}
}

View File

@@ -0,0 +1,245 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.Mockito.when;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGeneratedFile;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.time.LocalDate;
import java.util.List;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
@ExtendWith(MockitoExtension.class)
class ReservationRoomingListGenerationServiceImplTest {
@Mock
private HotelContextService hotelContextService;
private ReservationRoomingListGenerationServiceImpl service;
@BeforeEach
void setUp() {
service = new ReservationRoomingListGenerationServiceImpl(
hotelContextService,
new RoomingListSourceExcelParser(new RoomingListNameParser()),
new RoomingListGroupingService(),
new RoomingListExcelRenderer());
}
@Test
void shouldGenerateRoomingListWorkbookByPassportNamesAndPeoplePerRoom() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGeneratedFile generatedFile = service.generate(
sourceFile(passportNames32()),
request(3),
actor());
assertThat(generatedFile.fileName()).startsWith("rooming-list-HOTEL-TEST-");
assertThat(generatedFile.contentType())
.isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
assertThat(generatedFile.content()).startsWith(new byte[]{0x50, 0x4B});
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
assertThat(workbook.getSheetAt(0).getPhysicalNumberOfRows()).isEqualTo(12);
Row firstRoom = workbook.getSheetAt(0).getRow(1);
assertThat(cellText(firstRoom.getCell(0))).isEqualTo("1");
assertThat(cellText(firstRoom.getCell(1))).isEqualTo("LI");
assertThat(cellText(firstRoom.getCell(2))).isEqualTo("CHUNHONG");
assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-07-29");
assertThat(cellText(firstRoom.getCell(6))).isEqualTo("UG1");
assertThat(cellText(firstRoom.getCell(8))).isEqualTo("1");
assertThat(cellText(firstRoom.getCell(9))).isEqualTo("3");
assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG GAILI, LI HONG");
Row secondRoom = workbook.getSheetAt(0).getRow(2);
assertThat(cellText(secondRoom.getCell(1))).isEqualTo("LIU");
assertThat(cellText(secondRoom.getCell(2))).isEqualTo("JIAYI");
assertThat(cellText(secondRoom.getCell(13))).isEqualTo("ZHU XINGTONG, CHEN YUCHAI");
Row lastRoom = workbook.getSheetAt(0).getRow(11);
assertThat(cellText(lastRoom.getCell(0))).isEqualTo("11");
assertThat(cellText(lastRoom.getCell(1))).isEqualTo("ZHOU");
assertThat(cellText(lastRoom.getCell(2))).isEqualTo("XIAOYAN");
assertThat(cellText(lastRoom.getCell(9))).isEqualTo("2");
assertThat(cellText(lastRoom.getCell(13))).isEqualTo("GUO YAN");
}
}
@Test
void shouldRejectSourceWorkbookWithoutPassportNameHeader() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFileWithoutPassportHeader(), request(2), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
assertThat(exception.getDetails()).contains("未找到表头:护照全名");
}
@Test
void shouldRejectNonExcelSourceFile() {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(nonExcelFile(), request(2), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
assertThat(exception.getDetails()).contains("file: 仅支持 .xls / .xlsx 文件。");
}
@Test
void shouldRejectInvalidPeoplePerRoom() throws Exception {
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFile(passportNames32()), request(0), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
assertThat(exception.getDetails()).contains("people_per_room: 必须大于 0。");
}
private ReservationRoomingListGenerationRequest request(int peoplePerRoom) {
return new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
peoplePerRoom,
LocalDate.of(2026, 7, 26),
LocalDate.of(2026, 7, 29),
"UG1",
"RACK",
null,
0,
"CA",
"",
"CN",
"",
"",
"");
}
private AuthenticatedUserContext actor() {
return new AuthenticatedUserContext(
1L,
"rooming-admin",
"系统管理员",
true,
"HOTEL-TEST",
List.of("HOTEL-TEST"),
List.of("RESERVATION_ROOMING_LIST_GENERATE"));
}
private MockMultipartFile sourceFile(List<String> passportNames) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
Row titleRow = sheet.createRow(0);
titleRow.createCell(0).setCellValue("旅游批次");
Row headerRow = sheet.createRow(1);
headerRow.createCell(0).setCellValue("姓名");
headerRow.createCell(1).setCellValue("护照全名");
for (int index = 0; index < passportNames.size(); index++) {
Row row = sheet.createRow(index + 2);
row.createCell(0).setCellValue("游客" + (index + 1));
row.createCell(1).setCellValue(passportNames.get(index));
}
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"LLT260509FA203.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private MockMultipartFile sourceFileWithoutPassportHeader() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
Row headerRow = sheet.createRow(0);
headerRow.createCell(0).setCellValue("姓名");
headerRow.createCell(1).setCellValue("证件号");
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("游客1");
row.createCell(1).setCellValue("P123456");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"invalid.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private MockMultipartFile nonExcelFile() {
return new MockMultipartFile(
"file",
"LLT260509FA203.txt",
"text/plain",
"LI/CHUNHONG".getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
private List<String> passportNames32() {
return List.of(
"LI/CHUNHONG",
"ZHANG/GAILI",
"LI HONG",
"LIU/JIAYI ",
"ZHU/XINGTONG",
"CHEN/YUCHAI",
"WANG/XIAOLI",
"SUN/JIE",
"MA/LING",
"WU/YUAN",
"HUANG/XIAOMEI",
"ZHAO/MING",
"YANG/LILI",
"XU/JUN",
"LIN/FANG",
"LUO/HUA",
"HE/MEI",
"GAO/LEI",
"PAN/XIN",
"DENG/NA",
"TANG/YAN",
"CAO/YING",
"FAN/QIANG",
"JIANG/MIN",
"SHEN/YUE",
"YU/PING",
"FENG/QIU",
"XIE/RUI",
"SONG/LAN",
"LU/JIA",
"ZHOU/XIAOYAN",
"GUO/YAN");
}
private String cellText(Cell cell) {
if (cell == null) {
return "";
}
if (cell.getCellType() == CellType.NUMERIC) {
double value = cell.getNumericCellValue();
if (value == Math.rint(value)) {
return String.valueOf((long) value);
}
return String.valueOf(value);
}
return cell.getStringCellValue();
}
}