diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 326abfc..2aade8d 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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. 文档同步提醒 diff --git a/docs/project/README.md b/docs/project/README.md index 48b432b..3aafcd7 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -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。 | ## 集成契约 diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index 34b620c..77780a0 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -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. 前端开发注意事项 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 011ef89..1d4cea8 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -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 +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 联调造数,不是生产业务页面接口;访问口令不能进入前端代码。 diff --git a/docs/project/requirements/M010-rooming-list-excel-generation-v1.md b/docs/project/requirements/M010-rooming-list-excel-generation-v1.md new file mode 100644 index 0000000..664e570 --- /dev/null +++ b/docs/project/requirements/M010-rooming-list-excel-generation-v1.md @@ -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 +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-.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 等字段。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 52f5f3c..72b99e8 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -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 CP1;multipart 上传来源名单并直接下载 `.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 | diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java index 9400b2d..9ac5e7f 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/access/common/enums/PlatformPermissionCode.java @@ -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, diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java index 9788703..251ae9c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/identity/service/impl/PlatformIdentityBootstrapRunner.java @@ -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"; }; diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListGeneratedFile.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListGeneratedFile.java new file mode 100644 index 0000000..41aea45 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListGeneratedFile.java @@ -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) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListGuestDto.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListGuestDto.java new file mode 100644 index 0000000..ed9e7d7 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListGuestDto.java @@ -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) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListRoomDto.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListRoomDto.java new file mode 100644 index 0000000..7b36441 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListRoomDto.java @@ -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 accompanyingGuests, + int adultCount) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationRoomingListGenerationRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationRoomingListGenerationRequest.java new file mode 100644 index 0000000..3642263 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/request/ReservationRoomingListGenerationRequest.java @@ -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) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationController.java new file mode 100644 index 0000000..7d16791 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationController.java @@ -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 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()); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerAdvice.java new file mode 100644 index 0000000..6bc0fa6 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerAdvice.java @@ -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 handleMissingRequestParameter( + MissingServletRequestParameterException exception) { + return badRequest(List.of(exception.getParameterName() + ": 必填字段缺失。")); + } + + /** + * 处理 multipart 文件字段缺失。 + */ + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity handleMissingRequestPart( + MissingServletRequestPartException exception) { + return badRequest(List.of(exception.getRequestPartName() + ": 文件字段缺失。")); + } + + /** + * 处理日期、数字等请求参数格式错误。 + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch( + MethodArgumentTypeMismatchException exception) { + return badRequest(List.of(exception.getName() + ": 参数格式不合法。")); + } + + /** + * 处理上传文件超过系统 multipart 限制。 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceeded( + MaxUploadSizeExceededException exception) { + return badRequest(List.of("file: 上传文件超过系统允许大小。")); + } + + /** + * 处理 multipart 请求整体格式错误。 + */ + @ExceptionHandler(MultipartException.class) + public ResponseEntity handleMultipartException( + MultipartException exception) { + return badRequest(List.of("file: multipart 请求不合法。")); + } + + private ResponseEntity badRequest(List details) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ReservationWorkflowErrorResponse(ERROR_CODE, ERROR_MESSAGE, details)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java index 34c5a8b..f2c4352 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationTaskControllerAdvice.java @@ -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 handleRoomingListGenerationException( + ReservationRoomingListGenerationException exception) { + return ResponseEntity.status(exception.getStatus()) + .body(new ReservationWorkflowErrorResponse( + exception.getErrorCode(), + exception.getMessage(), + exception.getDetails())); + } + /** * 处理前端业务接口登录或权限不足异常,保持 Reservation 错误响应结构稳定。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationRoomingListGenerationService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationRoomingListGenerationService.java new file mode 100644 index 0000000..d767888 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/ReservationRoomingListGenerationService.java @@ -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); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationException.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationException.java new file mode 100644 index 0000000..63db070 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationException.java @@ -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 details; + + public ReservationRoomingListGenerationException(HttpStatus status, String errorCode, String message) { + this(status, errorCode, message, List.of()); + } + + public ReservationRoomingListGenerationException( + HttpStatus status, + String errorCode, + String message, + List 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 getDetails() { + return details; + } +} 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 new file mode 100644 index 0000000..f328607 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java @@ -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 guests = sourceExcelParser.parse(sourceFile); + List 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 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 errors) { + if (value != null && value < 0) { + errors.add(fieldPath + ": 不能小于 0。"); + } + } + + private ReservationRoomingListGenerationException validationError(List 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(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListExcelRenderer.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListExcelRenderer.java new file mode 100644 index 0000000..b8cdcf9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListExcelRenderer.java @@ -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 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; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListGroupingService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListGroupingService.java new file mode 100644 index 0000000..e5fcef1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListGroupingService.java @@ -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 group( + List guests, + int peoplePerRoom, + Integer requestedAdults) { + List rooms = new ArrayList<>(); + for (int start = 0; start < guests.size(); start += peoplePerRoom) { + int end = Math.min(start + peoplePerRoom, guests.size()); + List 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; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListNameParser.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListNameParser.java new file mode 100644 index 0000000..422f326 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListNameParser.java @@ -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 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 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) { + } +} 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 new file mode 100644 index 0000000..268de42 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java @@ -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 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 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 parseGuests( + Sheet sheet, + HeaderLocation header, + DataFormatter formatter) { + List 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 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) { + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java index 70e7a8c..35410ba 100644 --- a/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/platform/identity/control/AuthControllerTest.java @@ -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", diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerTest.java new file mode 100644 index 0000000..350a5d1 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationRoomingListGenerationControllerTest.java @@ -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()); + } + } +} 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 new file mode 100644 index 0000000..ea95267 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java @@ -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 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 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(); + } +}