diff --git a/CONTEXT.md b/CONTEXT.md index d615305..2a21dfe 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -50,7 +50,7 @@ TH Hotel Simple 是一个前后端分离的酒店业务协同项目。 - Room Type Catalog:预订业务可选房型代码目录;第一阶段已确认稳定业务集合为 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3`,暂不按 Account 限制可用房型。 - Rate Code Applicability:Rate Code 的业务适用范围;第一阶段暂不按 Reservation Account 或 booking type 限制候选,Rate Code 先作为当前酒店级目录校验。未来如需求方明确 Account 专属候选,再单独建立适用关系。 - Rooming List Task Card:V4 订单任务中的 Rooming List 事项确认卡,表示当前来源消息包含需要人工处理的房表事项;它不同于独立的 Rooming List Excel 生成工具。 -- Rooming List Excel Generation:独立的 Reservation 运营工具,用于把旅行团名单 Excel 转成酒店 / PMS 可导入的 Rooming List Excel;CP2 已实现从来源名单 `旅游日期` 派生入住 / 离店日期,由系统按分房结果计算成人数,前端目标默认值区域只保留付款方式和国籍;CP3 已实现第二种 `英文姓` + `英文名` 名单样式,并由用户补充入住 / 离店日期。 +- Rooming List Excel Generation:独立的 Reservation 运营工具,用于把旅行团名单 Excel 转成酒店 / PMS 可导入的 Rooming List Excel;CP2 已实现从来源名单 `旅游日期` 派生入住 / 离店日期,由系统按分房结果计算成人数,前端目标默认值区域只保留付款方式和国籍;CP3 已实现第二种 `英文姓` + `英文名` 名单样式,CP4 已实现第三种单列 `英文名` 名单样式,无旅游日期来源样式均由用户补充入住 / 离店日期。 - Payment Attachment Preview:Payment 卡中的付款凭证附件展示能力;业务事实仍是 `attachment_ids[]` 关联,图片可缩略图和大图预览,非图片统一文件列表和下载,附件外链必须走 SourceMessage 原文权限链路。 - Source Message Notification:M002 V4 的 S10 纯通知模型;只表示来源邮件需要被查看和确认已处理,不形成订单任务或业务卡。 - Identity / Access / Hotel / Menu:登录、用户、角色、权限、酒店授权和动态菜单底座。 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d5f3c95..69c9ee1 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -36,7 +36,7 @@ - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - AI-NSES v0.2 和 TH Hotel 项目级 Overlay 已落地;近期 M002 V4 新增需求已新增模板化 Spec 入口。后续新增或变更 V4 需求时必须继续维护该 Spec、后续 Change Request 或新 Spec,避免只散落在当前有效大文档中。 -- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单并下载后端同步生成的 `.xlsx`,第一版不落库、不上传 OSS。CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality,Payment Type 默认 `BTQR` 且当前允许 `BTQR` / `CA`,Nationality 只允许 `KR` / `CHN`。CP3 已实现:兼容第二种 `英文姓` + `英文名` 名单样式,用户补充 Arrival / Departure,且 `23+1` 中领队也进入房表。 +- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单并下载后端同步生成的 `.xlsx`,第一版不落库、不上传 OSS。CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality,Payment Type 默认 `BTQR` 且当前允许 `BTQR` / `CA`,Nationality 只允许 `KR` / `CN` / `TH` / `MM` / `RS` / `TW`。CP3 已实现:兼容第二种 `英文姓` + `英文名` 名单样式;CP4 已实现:兼容第三种单列 `英文名` 名单样式。无旅游日期来源样式由用户补充 Arrival / Departure,且 `23+1`、`19+1` 中领队也进入房表。 - M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel,按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSON;Debug EML 和 AgentBus dispatch 在各自 include 开关与总开关同时启用时,会在调用 SuperAgent 前追加 `attachment_extractions[]`。测试机 AgentBus 增强已开启;生产链路仍默认关闭,生产开启需单独确认。 - M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;MCP `th_hotel_submit_task_results` 已收口为 M002 V4-only,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不再影响 SuperAgent 输出契约;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成,前端轻量事项确认卡也已完成;Payment 附件安全摘要后端和前端预览接入均已完成。OWNER RATE Room Type / Rate Code 目录口径已落文档;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Rooming List 卡第一版只做事项确认;Account 通过数据库目录选择,Market / Source 可默认来自目录并允许前端人工覆盖提交;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 @@ -47,7 +47,7 @@ - SuperAgent 通过 MCP 提交时,排障优先查询 `platform_superagent_mcp_call_diagnostic`,对比 `arguments_json`、`adapted_payload_json`、`mapping_diagnostics_json` 和业务 batch / transition,判断问题来自 SuperAgent 原始参数、MCP adapter 还是业务入站层;V4-only 模式下 `mapping_diagnostics_json` 通常为空对象,若错误码为 `MCP_SUBMIT_V4_REQUIRED`,说明 SuperAgent 仍按旧 V2/V3 schema 输出;旧 V2/V3 被拒也会入本诊断表但不会进入业务写入 Service;V4 `source_message.conversation_id` 可缺省;该诊断表不作为业务事实来源,不进入普通前端接口。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码;V4 相关需求必须按 `docs/project/ai-nses-project-overlay.md` 补核心概念守门、需求追踪表和 agent 交接边界。 - 单卡可操作态测试数据已回填;后续演示或回归如果需要重新造数,应继续使用 fresh runId,避免复用旧 SourceMessage 时间线造成阻塞误判。 -- M010 CP3 第二种名单样式兼容已完成;预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。 +- M010 CP4 第三种单列 `英文名` 名单样式兼容已完成;预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。 - M011 CP4 暂不推进;当前停留在 CP3 边界,只增强 SuperAgent 输入,不直接落订单、任务或长期解析历史。后续如确实需要运营查询或长期追踪,再单独设计 Excel 解析批次 / 行级持久化表。 ## 6. 文档同步提醒 diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index b20c867..cdb2eef 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -402,7 +402,7 @@ export default { eyebrow: 'Reservation tool', title: 'Generate Rooming List Excel', subtitle: - 'Upload the travel group list, enter people per room, room type, payment type and nationality. Lists with travel dates derive stay dates automatically; English-name lists require manual dates.', + 'Upload the travel group list, enter people per room, room type, payment type and nationality. Lists with travel dates derive stay dates automatically; lists without travel dates require manual dates.', currentHotel: 'Current hotel', sourceSection: 'Source list and required fields', targetSection: 'Target Excel defaults', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index fbba17a..efd5f7b 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -402,7 +402,7 @@ export default { eyebrow: 'เครื่องมือ Reservation', title: 'สร้าง Rooming List Excel', subtitle: - 'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality ไฟล์ที่มี Travel Date จะสร้างวันที่ให้อัตโนมัติ ส่วนไฟล์ English name ต้องกรอกวันที่เอง', + 'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality ไฟล์ที่มี Travel Date จะสร้างวันที่ให้อัตโนมัติ ส่วนไฟล์ที่ไม่มี Travel Date ต้องกรอกวันที่เอง', currentHotel: 'โรงแรมปัจจุบัน', sourceSection: 'ไฟล์รายชื่อและข้อมูลจำเป็น', targetSection: 'ค่าเริ่มต้นของ Excel ปลายทาง', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 56a7d2f..011d63a 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -401,7 +401,7 @@ export default { roomingList: { eyebrow: '预订工具', title: '生成 Rooming List Excel', - subtitle: '上传旅行团名单 Excel,填写每房人数、房型、付款方式和国籍;有旅游日期的名单自动生成入住/离店日期,英文姓/英文名名单需手填日期。', + subtitle: '上传旅行团名单 Excel,填写每房人数、房型、付款方式和国籍;有旅游日期的名单自动生成入住/离店日期,没有旅游日期的名单需手填日期。', currentHotel: '当前酒店', sourceSection: '来源名单与必填信息', targetSection: '目标 Excel 默认值', diff --git a/client/src/tests/roomingListView.spec.ts b/client/src/tests/roomingListView.spec.ts index bff9e45..7271fa9 100644 --- a/client/src/tests/roomingListView.spec.ts +++ b/client/src/tests/roomingListView.spec.ts @@ -325,8 +325,8 @@ describe('ReservationRoomingListGenerationView', () => { it('maps backend manual stay date validation details to the active locale', async () => { vi.mocked(generateReservationRoomingListExcel).mockRejectedValue( new RoomingListGenerationError('Rooming List 字段校验失败。', 400, 'ROOMING_LIST_VALIDATION_FAILED', [ - 'arrival: 第二种来源名单样式必须填写入住日期(前端提交为空)。', - 'departure: 第二种来源名单样式必须填写离店日期', + 'arrival: 无旅游日期来源名单样式必须填写入住日期(前端提交为空)。', + 'departure: 无旅游日期来源名单样式必须填写离店日期', ]), ) const wrapper = mountView({ locale: 'en-US' }) @@ -339,7 +339,7 @@ describe('ReservationRoomingListGenerationView', () => { expect(wrapper.text()).toContain('Rooming List field validation failed. Please check the form.') expect(wrapper.text()).toContain('Enter arrival date.') expect(wrapper.text()).toContain('Enter departure date.') - expect(wrapper.text()).not.toContain('第二种来源名单样式必须填写') + expect(wrapper.text()).not.toContain('无旅游日期来源名单样式必须填写') expect(wrapper.find('[data-testid="rooming-list-arrival"]').classes()).toContain('is-invalid') expect(wrapper.find('[data-testid="rooming-list-departure"]').classes()).toContain('is-invalid') }) @@ -347,8 +347,8 @@ describe('ReservationRoomingListGenerationView', () => { it('clears mapped backend stay date field errors when the user edits those fields', async () => { vi.mocked(generateReservationRoomingListExcel).mockRejectedValue( new RoomingListGenerationError('Rooming List 字段校验失败。', 400, 'ROOMING_LIST_VALIDATION_FAILED', [ - 'arrival: 第二种来源名单样式必须填写入住日期。', - 'departure: 第二种来源名单样式必须填写离店日期。', + 'arrival: 无旅游日期来源名单样式必须填写入住日期。', + 'departure: 无旅游日期来源名单样式必须填写离店日期。', ]), ) const wrapper = mountView({ locale: 'en-US' }) diff --git a/docs/project/README.md b/docs/project/README.md index 3ae31eb..919be1d 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -62,7 +62,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;CP2 已实现来源 `旅游日期` 派生 Arrival / Departure、Adults 系统计算,以及目标默认值区域只保留 Payment Type / Nationality;CP3 已实现第二种 `英文姓` + `英文名` 名单样式,并由用户补充 Arrival / Departure。 | +| `requirements/M010-rooming-list-excel-generation-v1.md` | 当前有效 | M010 Rooming List Excel 生成方案;后端 CP1 已支持前端上传来源名单和手工字段,同步生成 `.xlsx` 直接下载,不落库、不上传 OSS;CP2 已实现来源 `旅游日期` 派生 Arrival / Departure、Adults 系统计算,以及目标默认值区域只保留 Payment Type / Nationality;CP3 已实现第二种 `英文姓` + `英文名` 名单样式;CP4 已实现第三种单列 `英文名` 名单样式;无旅游日期来源样式由用户补充 Arrival / Departure。 | | `requirements/M011-booking-excel-pre-superagent-enrichment-v1.md` | 当前有效 | M011 Booking Excel 附件预处理方案;CP1/CP2/CP3 已支持 Debug EML 和 AgentBus dispatch 调 SuperAgent 前排除人员名单类 Excel、抽取 Booking / 附加费类 Excel 高亮行并追加 `attachment_extractions[]`;测试机 AgentBus 增强已开启,生产默认关闭;CP4 暂不推进。 | ## 集成契约 diff --git a/docs/project/frontend-backend/README.md b/docs/project/frontend-backend/README.md index b90b6ed..07181fc 100644 --- a/docs/project/frontend-backend/README.md +++ b/docs/project/frontend-backend/README.md @@ -47,7 +47,7 @@ | 任务卡字段控件契约 V1 | `docs/project/requirements/M002-task-field-control-contract-v1.md` | 后端已返回 `fields[]` 控件元数据,规定人工复核控件复用和前后端边界;前端待接入。 | | 订单任务多卡模型 V4 | `docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md` | 当前 V4 主入口;后端已开放工作台统一列表、V4 订单任务列表 / 详情、卡片确认、复核解阻、S10/S99 来源通知详情和 ack;前端已完成 V4 页面第一版、目录 lookup 接入、订单详情 V4 时间线消费、系统设置目录管理 CP1,以及 Room Information 业务展示模型接入;Payment 附件预览和 Rooming List 轻量卡展示仍待后续实现。 | | 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` 直接下载;CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality;CP3 已实现:兼容 `英文姓` + `英文名` 第二种名单样式,并由用户补充 Arrival / Departure;前端 V1 已新增 `/reservation/rooming-lists/new`,按 Blob 下载处理,不落库、不上传 OSS。 | +| Rooming List Excel 生成 | `docs/project/requirements/M010-rooming-list-excel-generation-v1.md` | 当前有效;后端 CP1 已支持前端上传来源名单并填写目标字段,同步生成 `.xlsx` 直接下载;CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality;CP3 已实现:兼容 `英文姓` + `英文名` 第二种名单样式;CP4 已实现:兼容单列 `英文名` 第三种名单样式;无旅游日期来源样式由用户补充 Arrival / Departure;前端 V1 已新增 `/reservation/rooming-lists/new`,按 Blob 下载处理,不落库、不上传 OSS。 | | Booking Excel 附件预处理 | `docs/project/requirements/M011-booking-excel-pre-superagent-enrichment-v1.md` | 当前有效;Debug EML 和 AgentBus dispatch 已支持调 SuperAgent 前排除人员名单类 Excel、抽取 Booking / 附加费类 Excel 高亮行并生成 `attachment_extractions[]`;测试机 AgentBus 增强已开启,生产默认关闭;CP4 暂不推进。 | | 订单任务主流程 V2 | `docs/project/requirements/M002-order-task-workflow-v2.md` | 已实现阶段记录,保留用于理解当前代码中的 S000/S999、订单任务流转和 OPERA 模拟骨架。 | | 后端 checkpoint | `docs/project/requirements/M002-backend-checkpoint-plan.md` | 阶段记录,用于理解后端拆分和验收。 | @@ -64,7 +64,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 和前端 V1 已按 M010 落地:接口为 `POST /api/reservation/rooming-lists/generations`,前端页面为 `/reservation/rooming-lists/new`,上传来源名单、填写每房人数和目标列字段,后端同步返回 `.xlsx` 下载;该能力不依赖订单或任务,第一版不落库、不上传 OSS,权限码为 `RESERVATION_ROOMING_LIST_GENERATE`。M010 CP2 已实现:前端保留上传文件、每房人数、Room Type,目标默认值区域只保留 Payment Type 和 Nationality;后端从来源 Excel `旅游日期` 派生 Arrival / Departure,并按分房结果派生 Adults。M010 CP3 已实现:兼容 `英文姓` + `英文名` 第二种名单样式,页面恢复 Arrival / Departure 两个日期输入;第一种 `旅游日期` 样式可不填,第二种样式必须由用户填写。 +- Rooming List Excel 后端 CP1 和前端 V1 已按 M010 落地:接口为 `POST /api/reservation/rooming-lists/generations`,前端页面为 `/reservation/rooming-lists/new`,上传来源名单、填写每房人数和目标列字段,后端同步返回 `.xlsx` 下载;该能力不依赖订单或任务,第一版不落库、不上传 OSS,权限码为 `RESERVATION_ROOMING_LIST_GENERATE`。M010 CP2 已实现:前端保留上传文件、每房人数、Room Type,目标默认值区域只保留 Payment Type 和 Nationality;后端从来源 Excel `旅游日期` 派生 Arrival / Departure,并按分房结果派生 Adults。M010 CP3 已实现:兼容 `英文姓` + `英文名` 第二种名单样式;M010 CP4 已实现:兼容单列 `英文名` 第三种名单样式。页面恢复 Arrival / Departure 两个日期输入;第一种 `旅游日期` 样式可不填,无旅游日期来源样式必须由用户填写。 - Booking Excel 附件预处理已按 M011 落地 CP1/CP2/CP3:Debug EML 和 AgentBus dispatch 调用 SuperAgent 前由后端解析 Excel 附件,排除人员名单类文件,只把 Booking Update / 附加费表的高亮行业务摘要追加为 `attachment_extractions[]`;第一版不新增前端普通业务入口,测试机 AgentBus 增强已开启,生产默认关闭,CP4 暂不推进。 - Reservation V4 目录管理后台 CP1 已前后端接入:系统设置下新增 `/system/reservation-catalogs`,需要登录用户具备 `RESERVATION_CATALOG_MANAGE`;前端可维护 Account、Room Type、Rate Code 的列表、新增、启用 / 停用,并明确提示停用目录不再进入普通 V4 任务卡 lookup。 - V4 任务卡复核态已确认:`REVIEW_REQUIRED` 仍在原业务卡内编辑和确认,页面主按钮文案统一为“确认卡片”;前端必须按 `card_status` 调用普通确认或复核解阻接口,且只提交后端 `fields[]` 白名单内业务字段。 diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index 244572a..f279ad4 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -96,7 +96,7 @@ | `GET/POST/PUT /api/admin/menus...` | 系统管理菜单维护 | 需要 `SYSTEM_MENU_MANAGE`;允许保存未知路由,前端必须有未知路由兜底页;已提供完整菜单树查询和批量树排序保存。 | | `GET/POST/PUT /api/admin/hotels...` | 系统管理酒店维护 | 需要 `HOTEL_MANAGE`;新增酒店默认 `DISABLED`,单酒店阶段不能启用第二家 `ACTIVE`。 | | `GET /api/admin/audits` | 系统管理操作审计 | 需要 `SYSTEM_ADMIN_CONSOLE_ACCESS`;用于查看管理后台写操作审计,不包含密码、token、secret。 | -| `POST /api/reservation/rooming-lists/generations` | 生成 Rooming List Excel | 必须带 Bearer token,需要 `RESERVATION_ROOMING_LIST_GENERATE`;请求为 multipart/form-data,成功后直接返回 `.xlsx` 文件流,前端按 Blob 下载处理。CP2 已实现:后端从来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源样式,并在该样式下使用前端提交的 `arrival` / `departure`。 | +| `POST /api/reservation/rooming-lists/generations` | 生成 Rooming List Excel | 必须带 Bearer token,需要 `RESERVATION_ROOMING_LIST_GENERATE`;请求为 multipart/form-data,成功后直接返回 `.xlsx` 文件流,前端按 Blob 下载处理。CP2 已实现:后端从来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源样式。CP4 已实现:兼容单列 `英文名` 第三种来源样式。无旅游日期来源样式使用前端提交的 `arrival` / `departure`。 | ### 5.1 本轮新增 / 修改接口说明 @@ -463,7 +463,7 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填 ### 5.12 Rooming List Excel 生成页面接入方向 -M010 后端 CP1 已实现。第一版生成结果直接下载 `.xlsx`,不落库、不上传 OSS,不依赖订单或任务。CP2 已实现,前后端按本节新口径接入。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源名单样式,并由用户补充入住 / 离店日期。 +M010 后端 CP1 已实现。第一版生成结果直接下载 `.xlsx`,不落库、不上传 OSS,不依赖订单或任务。CP2 已实现,前后端按本节新口径接入。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源名单样式。CP4 已实现:兼容单列 `英文名` 第三种来源名单样式。无旅游日期来源样式均由用户补充入住 / 离店日期。 接口: @@ -483,20 +483,20 @@ RESERVATION_ROOMING_LIST_GENERATE - 前端 V1 已新增 `/reservation/rooming-lists/new` 页面,按 `RESERVATION_ROOMING_LIST_GENERATE` 路由权限保护,并使用登录态 Bearer token 调用正式业务接口。 - 侧边栏入口不由前端硬编码公开,仍依赖后端登录态 `menus[]`。如需菜单中展示,建议菜单管理配置 `menu_code=RESERVATION_ROOMING_LIST`、`route_path=/reservation/rooming-lists/new`、`permission_code=RESERVATION_ROOMING_LIST_GENERATE`。 -- 该页面上传来源名单 Excel;第一种来源样式后端识别 `护照全名` 和 `旅游日期`,第二种来源样式后端识别 `英文姓` 和 `英文名`。 -- 第一种来源样式姓名支持 `LI/CHUNHONG` 和 `LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`。第二种来源样式直接使用 `英文姓` 写入 `Name`,使用 `英文名` 写入 `First Name`。 +- 该页面上传来源名单 Excel;第一种来源样式后端识别 `护照全名` 和 `旅游日期`,第二种来源样式后端识别 `英文姓` 和 `英文名`,第三种来源样式后端识别单列 `英文名`。 +- 第一种来源样式姓名支持 `LI/CHUNHONG` 和 `LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`。第二种来源样式直接使用 `英文姓` 写入 `Name`,使用 `英文名` 写入 `First Name`。第三种来源样式复用第一种姓名拆分规则,支持 `CAI/HAIYUN` 或 `CAI HAIYUN`。 - 前端必须让用户输入 `people_per_room` 和目标 `Room Type`,后端按名单顺序分组,并用 `ceil(total_people / people_per_room)` 生成房间行。 - 每组第一位旅客写入 `Name` / `First Name`,同组剩余旅客写入 `Accompanying Guests`,多人用英文逗号分隔。 -- CP3 起前端恢复 `Arrival` / `Departure` 日期输入:第一种来源样式可不填,后端优先从来源 Excel 的 `旅游日期` 派生;第二种来源样式必须填写,后端使用用户提交日期写入目标 Excel。样例 `2026年5月9日-5月14日` 仍应生成 `Arrival=2026/05/09`、`Departure=2026/05/14`。 -- 第二种来源样式中领队也进入房表;例如 `23+1` 表示 23 位客人 + 1 位领队全部参与分房,实际人数以后端读取到的有效名单行数为准。 +- CP3 起前端恢复 `Arrival` / `Departure` 日期输入:第一种来源样式可不填,后端优先从来源 Excel 的 `旅游日期` 派生;无旅游日期来源样式必须填写,后端使用用户提交日期写入目标 Excel。样例 `2026年5月9日-5月14日` 仍应生成 `Arrival=2026/05/09`、`Departure=2026/05/14`。 +- 第二种和第三种来源样式中领队也进入房表;例如 `23+1` 或 `19+1` 表示客人 + 领队全部参与分房,实际人数以后端读取到的有效名单行数为准。 - CP2 起前端不再展示或提交 `Adults`,后端按每个分房行的实际人数派生;尾房不足满房人数时按尾房实际人数写入。`Children` 第一版固定为 `0`。 -- CP2 起目标默认值区域只保留 `Payment Type` 和 `Nationality`:`Payment Type` 默认 `BTQR`,当前允许 `BTQR`、`CA`;`Nationality` 只允许 `KR`、`CHN`。 +- CP2 起目标默认值区域只保留 `Payment Type` 和 `Nationality`:`Payment Type` 默认 `BTQR`,当前允许 `BTQR`、`CA`;`Nationality` 只允许 `KR`、`CN`、`TH`、`MM`、`RS`、`TW`。 - CP2 页面不再展示或提交 `Title`、`Rate Code`、`VIP`、`Email`、`ID Type`、`ID Number`,这些目标列第一版固定为空。 - 第一版接口成功后直接返回 Excel 文件流,前端应按 Blob 下载处理,不要期待 JSON 里的 URL。 - 失败时返回统一 JSON 错误,例如 `ROOMING_LIST_VALIDATION_FAILED`、`ROOMING_LIST_SOURCE_FILE_INVALID`、`HOTEL_ACCESS_DENIED` 或 `FRONTEND_PERMISSION_DENIED`。 - 该上传接口会在 multipart 参数绑定前先校验 Bearer token 和 `RESERVATION_ROOMING_LIST_GENERATE` 权限;未登录时优先返回 401,不会因为缺少业务字段先返回 400。 -- `ROOMING_LIST_VALIDATION_FAILED` 应覆盖 multipart 必填字段缺失、`people_per_room` 数字格式错误、第二种来源样式下缺少或错误的 `arrival` / `departure`、`payment_type` / `nationality` 枚举错误,`details[]` 返回字段级提示。 -- `ROOMING_LIST_SOURCE_FILE_INVALID` 应覆盖来源文件缺失、非 Excel、无法读取、既不能识别第一种 `护照全名` + `旅游日期`、也不能识别第二种 `英文姓` + `英文名`、第一种来源样式旅游日期格式无法解析或同一文件出现多个不同旅游日期区间。 +- `ROOMING_LIST_VALIDATION_FAILED` 应覆盖 multipart 必填字段缺失、`people_per_room` 数字格式错误、无旅游日期来源样式下缺少或错误的 `arrival` / `departure`、`payment_type` / `nationality` 枚举错误,`details[]` 返回字段级提示。 +- `ROOMING_LIST_SOURCE_FILE_INVALID` 应覆盖来源文件缺失、非 Excel、无法读取、既不能识别第一种 `护照全名` + `旅游日期`、也不能识别第二种 `英文姓` + `英文名` 或第三种单列 `英文名`、第一种来源样式旅游日期格式无法解析或同一文件出现多个不同旅游日期区间。 - 页面不要把上传文件内容、客人名单、生成文件内容写入浏览器日志、埋点、错误上报、URL 或 localStorage。 - 第一版没有 preview 接口、生成记录接口、OSS URL、历史下载和订单 / 任务预填;前端不要在页面上承诺这些能力。 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index b9c9e0b..f688292 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -17,7 +17,7 @@ | P0 | 邮件会话详情接口 `GET /api/source-messages/{sourceMessageId}/conversation` | 邮件会话详情页 | 已完成第一版 | | 联调 | 演示数据 seed 接口 `POST /api/system/reservation/demo-data` | 本地 / test 前端页面看效果 | 已完成;仅 dev/test 受控使用 | | 联调 | Debug EML 上传接口 `POST /api/system/debug/eml-superagent-runs` | Debug 页面上传 `.eml` 看 SuperAgent 结果 | 已完成第一版;仅 dev/test 受控使用 | -| Done | Rooming List Excel 生成接口 `POST /api/reservation/rooming-lists/generations` | 房表生成页面 | CP1 已完成;CP2 已实现旅游日期派生、Adults 自动计算和目标默认值字段收口;CP3 已实现第二种 `英文姓` + `英文名` 名单样式和用户补 Arrival / Departure | +| Done | Rooming List Excel 生成接口 `POST /api/reservation/rooming-lists/generations` | 房表生成页面 | CP1 已完成;CP2 已实现旅游日期派生、Adults 自动计算和目标默认值字段收口;CP3 已实现第二种 `英文姓` + `英文名` 名单样式;CP4 已实现第三种单列 `英文名` 名单样式;无旅游日期名单样式由用户补 Arrival / Departure | | P1 | S10/S99 源邮件只读通知卡与旧 S000/S999 兼容 | 任务列表、任务详情来源邮件查看 | 已完成第一版:旧 S000/S999 兼容,新结构化 S10/S99 可入站并在任务列表 / 详情只读展示 | | P1 | type-known manual review 同卡复核解阻 | 任务详情复核 | 已完成第一版:原业务任务卡复核、字段修正、订单归属确认、READY 流转 | | P1 | 任务卡前端字段白名单元数据接口 | 字段白名单调试、版本对齐 | 未完成;若任务详情已透出完整元数据,可后置 | @@ -1184,9 +1184,9 @@ POST /api/reservation/invoices/manual-generations - 第一版已写入 `workflow_reservation_invoice_generation` 生成记录和业务审计,但暂不提供前端查询历史列表 / 详情接口。 - 错误响应中的 `error_code` 用于前端主错误文案映射;`message` / `details[]` 只作为折叠技术详情展示,不直接铺给普通用户。 -## 13. Rooming List Excel 生成接口 CP2 / CP3 契约 +## 13. Rooming List Excel 生成接口 CP2 / CP3 / CP4 契约 -M010 Rooming List Excel 生成接口 CP1 已实现,前端 V1 已接入 `/reservation/rooming-lists/new`。CP2 不新增路径,继续收口同一个正式业务接口。CP3 已实现兼容第二种来源名单样式,仍复用本接口。 +M010 Rooming List Excel 生成接口 CP1 已实现,前端 V1 已接入 `/reservation/rooming-lists/new`。CP2 不新增路径,继续收口同一个正式业务接口。CP3 已实现兼容第二种来源名单样式,CP4 已实现兼容第三种单列 `英文名` 来源名单样式,仍复用本接口。 路径: @@ -1202,12 +1202,12 @@ CP2 前端提交字段: | `hotel_id` | 否 | 当前选择酒店;不传时由后端按登录用户酒店上下文解析。 | | `people_per_room` | 是 | 每间房人数,正整数。 | | `room_type` | 是 | 目标 Excel 的 Room Type。 | -| `arrival` | 条件必填 | CP3 第二种来源名单样式必填,格式 `yyyy-MM-dd`;第一种 `旅游日期` 样式可不填。 | -| `departure` | 条件必填 | CP3 第二种来源名单样式必填,格式 `yyyy-MM-dd`;必须晚于 `arrival`。 | +| `arrival` | 条件必填 | 无旅游日期来源名单样式必填,格式 `yyyy-MM-dd`;第一种 `旅游日期` 样式可不填。 | +| `departure` | 条件必填 | 无旅游日期来源名单样式必填,格式 `yyyy-MM-dd`;必须晚于 `arrival`。 | | `payment_type` | 否 | 目标 Excel 的 Payment Type;默认 `BTQR`,当前允许 `BTQR`、`CA`。 | -| `nationality` | 是 | 目标 Excel 的 Nationality;只允许 `KR`、`CHN`。 | +| `nationality` | 是 | 目标 Excel 的 Nationality;只允许 `KR`、`CN`、`TH`、`MM`、`RS`、`TW`。 | -CP3 前端仍不提交: +CP3 / CP4 前端仍不提交: ```text title @@ -1225,21 +1225,23 @@ id_number - 第一种来源样式:从来源 Excel `旅游日期` 列解析入住和离店日期;样例 `2026年5月9日-5月14日` 派生 `Arrival=2026/05/09`、`Departure=2026/05/14`。 - 第二种来源样式:来源 Excel 表头包含 `英文姓`、`英文名`,没有可靠入住 / 离店日期;后端使用前端提交的 `arrival` / `departure` 写入目标 Excel。 - 第二种来源样式姓名生成规则:`Name=英文姓`,`First Name=英文名`,不再拆分字符串。 -- 第二种来源样式中领队也进入房表;例如 `23+1` 表示 23 位客人 + 1 位领队全部参与分房,实际人数以后端读取到的有效名单行数为准。 +- 第三种来源样式:来源 Excel 表头包含单列 `英文名`,没有可靠入住 / 离店日期;后端使用前端提交的 `arrival` / `departure` 写入目标 Excel。 +- 第三种来源样式姓名生成规则:`英文名=CAI/HAIYUN` 或 `CAI HAIYUN` 时,生成 `Name=CAI`、`First Name=HAIYUN`。 +- 第二种和第三种来源样式中领队也进入房表;例如 `23+1` 或 `19+1` 表示客人 + 领队全部参与分房,实际人数以后端读取到的有效名单行数为准。 - `Adults` 按当前分房行实际人数计算;尾房人数不足时按实际人数写入。 - `Children` 固定为 `0`。 - `Title`、`Rate Code`、`VIP`、`Email`、`ID Type`、`ID Number` 第一版固定为空。 -- 如果来源 Excel 既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名`,后端返回受控错误,不生成 Excel。 +- 如果来源 Excel 既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名` 或第三种单列 `英文名`,后端返回受控错误,不生成 Excel。 - 第一种来源样式缺少 `旅游日期`、日期格式无法解析、结束日期不晚于起始日期,或同一文件出现多个不同旅游日期区间,后端返回受控错误,不生成 Excel。 -- 第二种来源样式缺少 `arrival` / `departure`、日期格式无法解析,或 `departure` 不晚于 `arrival`,后端返回受控错误,不生成 Excel。 +- 无旅游日期来源样式缺少 `arrival` / `departure`、日期格式无法解析,或 `departure` 不晚于 `arrival`,后端返回受控错误,不生成 Excel。 前端诉求: - 页面第一部分保留上传来源 Excel、每房人数、Room Type,并恢复 `Arrival` / `Departure` 日期输入。 -- `Arrival` / `Departure` 在第一种来源样式下可不填;当用户上传第二种没有 `旅游日期` 的名单时必须填写。页面文案建议说明“来源名单没有旅游日期时请填写入住 / 离店日期”。 +- `Arrival` / `Departure` 在第一种来源样式下可不填;当用户上传没有 `旅游日期` 的名单时必须填写。页面文案建议说明“来源名单没有旅游日期时请填写入住 / 离店日期”。 - 目标默认值区域只保留 Payment Type 和 Nationality 两个下拉。 - Payment Type 默认选中 `BTQR`,当前提供 `BTQR`、`CA` 两个选项。 -- Nationality 只提供 `KR` 和 `CHN`。 +- Nationality 只提供 `KR`、`CN`、`TH`、`MM`、`RS`、`TW`。 - 前端不读取完整 Excel 作为权威解析结果;来源样式识别、旅客名单和成人数以后端生成结果为准。 - 页面不要把上传文件内容、客人名单、生成文件内容写入浏览器日志、埋点、错误上报、URL 或 localStorage。 diff --git a/docs/project/requirements/M010-rooming-list-excel-generation-v1.md b/docs/project/requirements/M010-rooming-list-excel-generation-v1.md index 2ad2a23..a1ae322 100644 --- a/docs/project/requirements/M010-rooming-list-excel-generation-v1.md +++ b/docs/project/requirements/M010-rooming-list-excel-generation-v1.md @@ -2,9 +2,9 @@ | 项目 | 内容 | | --- | --- | -| 文档状态 | 后端 CP1 已实现;CP2 已实现旅游日期派生、Adults 系统计算和目标默认值字段收口;CP3 第二种来源名单样式已实现 | +| 文档状态 | 后端 CP1 已实现;CP2 已实现旅游日期派生、Adults 系统计算和目标默认值字段收口;CP3 第二种来源名单样式已实现;CP4 第三种单列 `英文名` 来源名单样式已实现 | | 适用范围 | 从旅行团名单 Excel 解析护照姓名,并生成酒店 / PMS 可导入的 Rooming List Excel | -| 当前目标 | CP1 已支持前端上传来源 Excel、填写分房和目标列默认值,后端同步生成目标格式 `.xlsx`;CP2 收口为后端从来源 `旅游日期` 派生入住 / 离店日期,成人数由系统按分房结果计算,目标默认值区域只保留付款方式和国籍;CP3 兼容 `英文姓` + `英文名` 第二种名单样式,并由用户补充入住 / 离店日期 | +| 当前目标 | CP1 已支持前端上传来源 Excel、填写分房和目标列默认值,后端同步生成目标格式 `.xlsx`;CP2 收口为后端从来源 `旅游日期` 派生入住 / 离店日期,成人数由系统按分房结果计算,目标默认值区域只保留付款方式和国籍;CP3 兼容 `英文姓` + `英文名` 第二种名单样式;CP4 兼容单列 `英文名` 第三种名单样式;无旅游日期来源样式均由用户补充入住 / 离店日期 | | 依赖能力 | 登录权限、酒店上下文、Apache POI 或等价 Excel 读写能力 | ## 1. 背景 @@ -34,7 +34,7 @@ CP2 目标调整: - 后端按分房结果计算目标 `Adults`,不再要求前端手填 `adults`。 - 目标默认值区域只保留 `Payment Type` 和 `Nationality` 两个字段。 - `Payment Type` 前端默认为 `BTQR`,当前可选择 `BTQR` 或 `CA`,后端按同一枚举校验。 -- `Nationality` 前端只允许选择 `KR` 或 `CHN`,后端按同一枚举校验。 +- `Nationality` 前端只允许选择 `KR`、`CN`、`TH`、`MM`、`RS`、`TW`,后端按同一枚举校验。 CP3 目标调整: @@ -45,6 +45,14 @@ CP3 目标调整: - 第二种样式中的领队也进入房表。例如标题行 `23+1` 应理解为 23 位客人 + 1 位领队,合计 24 位入住人全部参与分房。 - 来源表中的中文名、性别、出生日期、护照号码、出生地、签发地、签发日期、有效期等字段第一版仍不写入目标 Excel,也不在错误响应或日志中回显。 +CP4 目标调整: + +- 兼容第三种来源名单样式:表头包含单列 `英文名`,没有 `护照全名`、`旅游日期` 和 `英文姓`。 +- 第三种样式的 `英文名` 单元格值形如 `CAI/HAIYUN`、`HUANG/YUHENG`,后端复用 `/` 或空格拆分规则,第一段写入目标 `Name`,剩余部分写入目标 `First Name`。 +- 第三种样式没有可靠入住 / 离店日期来源,后端不得从标题中的航班日期猜测酒店入住 / 离店日期;必须使用前端提交的 `arrival` / `departure`。 +- 第三种样式中的 `领队` 行也进入房表。例如 `19+1` 应理解为 19 位客人 + 1 位领队,合计 20 位入住人全部参与分房。 +- 来源表中的中文名、性别、证件号、生日、出生地、签发地、联系方式、小费、住宿说明等字段第一版仍不写入目标 Excel,也不在错误响应或日志中回显。 + 第一阶段不做: - 不落业务订单。 @@ -70,7 +78,7 @@ CP3 目标调整: ## 4. 来源 Excel 解析规则 -基于当前样例来源文件,当前支持两类来源名单格式。后端应先在来源 Excel 中识别表头组合,再选择对应解析器。 +基于当前样例来源文件,当前支持三类来源名单格式。后端应先在来源 Excel 中识别表头组合,再选择对应解析器。 ### 4.1 第一种来源样式:护照全名 + 旅游日期 @@ -164,6 +172,33 @@ Sheet1 第 3 行起:名单数据 如果来源文件同时满足第一种和第二种表头组合,后端应优先使用第一种 `护照全名` + `旅游日期`,保持现有已上线行为稳定。 +### 4.3 第三种来源样式:单列英文名 + +第三种样式识别以下表头: + +| 来源字段 | 中文说明 | 第一版用途 | +| --- | --- | --- | +| `英文名` | 护照英文姓名,通常为 `姓/名` 格式 | 拆分后写入目标 Excel 的 `Name` 和 `First Name` | + +已确认样例: + +```text +Sheet1 第 1 行:客人名单表,包含航班说明,例如 0803 杭州-曼谷 / 0808 曼谷-杭州 +Sheet1 第 2 行:序号 / 姓名 / 性别 / 英文名 / 证件号 / 出生年月日 / ... +Sheet1 第 3 行:领队 +Sheet1 第 4 行起:名单数据 +``` + +解析规则: + +- 在前若干行内查找 `英文名` 表头;当同一文件不存在 `英文姓` 表头时,按第三种样式处理。 +- 从表头下一行开始读取名单行。 +- `英文名` 为空的行跳过;如后续需要强校验可单独确认。 +- `英文名` 值继续复用第一种样式的姓名拆分规则,支持 `CAI/HAIYUN`、`CAI HAIYUN`。 +- 标记为 `领队` 的行也进入名单;实际入住人数以后端读取到的有效 `英文名` 行数为准。 +- 不读取也不返回中文名、性别、证件号、生日、出生地、签发地、联系方式、小费、住宿说明。 +- 第三种样式没有 `旅游日期`,后端不得从航班行猜测入住 / 离店日期;必须使用前端提交的 `arrival` / `departure`。 + ## 5. 分房生成规则 前端需要传入 `people_per_room`,表示每间房人数。 @@ -205,8 +240,8 @@ people_per_room = 3 | `Name` | 每间房第一位旅客的姓氏 / 第一段姓名 | | `First Name` | 每间房第一位旅客的名字 / 剩余姓名 | | `Title` | CP2 固定为空,后续如有稳定默认值再单独确认 | -| `Arrival` | 第一种样式从来源 Excel `旅游日期` 起始日派生;第二种样式使用前端提交的 `arrival` | -| `Departure` | 第一种样式从来源 Excel `旅游日期` 结束日派生;第二种样式使用前端提交的 `departure` | +| `Arrival` | 第一种样式从来源 Excel `旅游日期` 起始日派生;无旅游日期来源样式使用前端提交的 `arrival` | +| `Departure` | 第一种样式从来源 Excel `旅游日期` 结束日派生;无旅游日期来源样式使用前端提交的 `departure` | | `Room Type` | 前端传入 | | `Rate Code` | CP2 固定为空,后续如需 Rate Code 选择再单独确认 | | `Number of Rooms` | 第一版每行固定为 `1`,后续如目标模板变化再调整 | @@ -215,7 +250,7 @@ people_per_room = 3 | `Payment Type` | 前端选择;当前默认值为 `BTQR`,允许 `BTQR`、`CA` | | `VIP` | CP2 固定为空,后续如有稳定默认值再单独确认 | | `Accompanying Guests` | 同房其他旅客姓名,英文逗号分隔 | -| `Nationality` | 前端选择;当前只允许 `KR`、`CHN` | +| `Nationality` | 前端选择;当前只允许 `KR`、`CN`、`TH`、`MM`、`RS`、`TW` | | `Email` | CP2 固定为空,后续如有稳定来源再单独确认 | | `ID Type` | CP2 固定为空,后续如有稳定来源再单独确认 | | `ID Number` | CP2 固定为空,后续如有稳定来源再单独确认 | @@ -223,7 +258,7 @@ people_per_room = 3 中文说明: - CP2 目标默认值区域只保留 `Payment Type` 和 `Nationality`,不再展示 `Title`、`Rate Code`、`Adults`、`Children`、`VIP`、`Email`、`ID Type`、`ID Number`。 -- `Arrival` / `Departure` 的业务来源按来源样式区分:第一种样式来自来源名单 Excel 的 `旅游日期`,第二种样式来自前端用户填写。 +- `Arrival` / `Departure` 的业务来源按来源样式区分:第一种样式来自来源名单 Excel 的 `旅游日期`,无旅游日期来源样式来自前端用户填写。 - `Adults` 的业务来源是系统分房结果,前端不再手填。 - 如后续需要恢复 Rate Code、Title、Email 或证件字段,需要单独确认字段来源、默认值和安全边界,不能直接把 CP1 自由文本框恢复为生产能力。 @@ -245,12 +280,12 @@ Content-Type: multipart/form-data | `hotel_id` | string | 否 | 酒店 ID;不传时按当前登录用户默认酒店或单酒店上下文解析 | | `people_per_room` | integer | 是 | 每间房人数,必须大于 0 | | `room_type` | string | 是 | 目标 Excel 的 `Room Type` | -| `arrival` | date string | 条件必填 | CP3 第二种来源样式必填,格式 `yyyy-MM-dd`;第一种来源样式可不传,后端优先从 `旅游日期` 派生 | -| `departure` | date string | 条件必填 | CP3 第二种来源样式必填,格式 `yyyy-MM-dd`;必须晚于 `arrival` | +| `arrival` | date string | 条件必填 | 无旅游日期来源样式必填,格式 `yyyy-MM-dd`;第一种来源样式可不传,后端优先从 `旅游日期` 派生 | +| `departure` | date string | 条件必填 | 无旅游日期来源样式必填,格式 `yyyy-MM-dd`;必须晚于 `arrival` | | `payment_type` | string | 否 | CP2 目标 Excel 的 `Payment Type`;为空时后端按 `BTQR` 处理;当前只允许 `BTQR`、`CA` | -| `nationality` | string | 是 | CP2 目标 Excel 的 `Nationality`;当前只允许 `KR`、`CHN` | +| `nationality` | string | 是 | CP2 目标 Excel 的 `Nationality`;当前只允许 `KR`、`CN`、`TH`、`MM`、`RS`、`TW` | -CP3 后仍不接收以下前端业务字段作为生成依据: +CP3 / CP4 后仍不接收以下前端业务字段作为生成依据: | 字段 | 处理口径 | | --- | --- | @@ -288,9 +323,9 @@ Content-Disposition: attachment; filename="rooming-list-.xlsx" - multipart 必填字段缺失,返回 `ROOMING_LIST_VALIDATION_FAILED`。 - `people_per_room` 数字格式错误,返回 `ROOMING_LIST_VALIDATION_FAILED`。 - `payment_type` 非 `BTQR` / `CA`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 -- `nationality` 非 `KR` / `CHN`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 -- 第二种来源样式缺少 `arrival` / `departure`、日期格式错误或 `departure` 不晚于 `arrival`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 -- 来源文件缺失、非 `.xls` / `.xlsx`、无法读取、既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名`,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`。 +- `nationality` 非 `KR` / `CN` / `TH` / `MM` / `RS` / `TW`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 +- 无旅游日期来源样式缺少 `arrival` / `departure`、日期格式错误或 `departure` 不晚于 `arrival`,返回 `ROOMING_LIST_VALIDATION_FAILED`。 +- 来源文件缺失、非 `.xls` / `.xlsx`、无法读取、既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名` 或第三种单列 `英文名`,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`。 - 第一种来源样式缺少 / 无法解析 `旅游日期` 表头,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`。 - 同一来源文件出现多个不同旅游日期区间,CP2 返回受控错误,不生成 Excel。 - 错误详情只返回字段名或行号,不回显完整名单、证件号或源文件内容。 @@ -324,7 +359,7 @@ RESERVATION_ROOMING_LIST_GENERATE | `ReservationRoomingListGenerationController` | 接收前端上传请求,做登录、权限和酒店上下文校验 | | `ReservationRoomingListGenerationService` | 编排来源解析、分房、目标 Excel 渲染和文件响应 | | `ReservationRoomingListGenerationServiceImpl` | 具体业务实现 | -| `RoomingListSourceExcelParser` | CP1 解析来源 Excel,提取护照姓名;CP2 已扩展为同时提取并校验统一旅游日期区间;CP3 已扩展第二种 `英文姓` + `英文名` 样式,并在该样式下要求用户补日期 | +| `RoomingListSourceExcelParser` | CP1 解析来源 Excel,提取护照姓名;CP2 已扩展为同时提取并校验统一旅游日期区间;CP3 已扩展第二种 `英文姓` + `英文名` 样式;CP4 已扩展第三种单列 `英文名` 样式;无旅游日期来源样式要求用户补日期 | | `RoomingListNameParser` | 拆分 `/` 或空格格式的护照姓名 | | `RoomingListGroupingService` | 按每房人数分组并生成目标行模型 | | `RoomingListExcelRenderer` | 生成目标格式 `.xlsx` | @@ -353,10 +388,10 @@ RESERVATION_ROOMING_LIST_GENERATE - 上传来源 Excel。 - 填写每间房人数。 - 填写目标 `Room Type`。 -- 填写 `Arrival` / `Departure`:第一种来源样式可不填,第二种来源样式必填;页面文案需说明“当来源名单没有旅游日期时请填写”。 +- 填写 `Arrival` / `Departure`:第一种来源样式可不填,无旅游日期来源样式必填;页面文案需说明“当来源名单没有旅游日期时请填写”。 - 目标默认值区域只保留 `Payment Type` 和 `Nationality`。 - `Payment Type` 使用下拉选择,默认 `BTQR`,当前可选择 `BTQR` 或 `CA`。 -- `Nationality` 使用下拉,只允许 `KR`、`CHN`。 +- `Nationality` 使用下拉,只允许 `KR`、`CN`、`TH`、`MM`、`RS`、`TW`。 - 展示解析预览:总人数、预计房间数、前几行分房结果。 - 点击生成后下载 `.xlsx`。 @@ -364,8 +399,8 @@ RESERVATION_ROOMING_LIST_GENERATE - 不要在前端解析完整 Excel 作为权威结果;前端预览可以后置为后端 preview 接口。 - 第一版如果没有 preview 接口,可以只展示用户输入和文件名,生成成功后直接下载。 -- CP3 页面重新展示并提交可选 `arrival`、`departure`;后端仅在第二种来源样式下要求这两个字段必填,第一种来源样式仍优先使用来源 Excel `旅游日期`。 -- 前端不要自行读取 Excel 并推导日期;如果用户上传第二种没有 `旅游日期` 的名单,由用户手工填写 `arrival` / `departure`。 +- CP3 起页面重新展示并提交可选 `arrival`、`departure`;后端仅在无旅游日期来源样式下要求这两个字段必填,第一种来源样式仍优先使用来源 Excel `旅游日期`。 +- 前端不要自行读取 Excel 并推导日期;如果用户上传没有 `旅游日期` 的名单,由用户手工填写 `arrival` / `departure`。 - `Adults` 由后端按分房结果计算;前端不要让用户覆盖。 - 不要把上传文件内容、客人名单或生成文件内容写进浏览器日志、错误上报、URL 或 localStorage。 @@ -379,7 +414,11 @@ RESERVATION_ROOMING_LIST_GENERATE - 第二种来源样式下 `英文姓=FENG`、`英文名=MINNA` 能生成 `Name=FENG`、`First Name=MINNA`。 - 第二种来源样式下领队也进入房表,实际人数以后端读取到的有效名单行数为准。 - 第二种来源样式使用前端提交的 `arrival` / `departure` 写入目标 Excel。 -- 第二种来源样式缺少 `arrival` / `departure` 返回受控错误。 +- 能识别第三种来源样式单列 `英文名` 表头。 +- 第三种来源样式下 `英文名=CAI/HAIYUN` 能生成 `Name=CAI`、`First Name=HAIYUN`。 +- 第三种来源样式下领队也进入房表,实际人数以后端读取到的有效名单行数为准。 +- 无旅游日期来源样式使用前端提交的 `arrival` / `departure` 写入目标 Excel。 +- 无旅游日期来源样式缺少 `arrival` / `departure` 返回受控错误。 - `LI/CHUNHONG` 能拆成 `Name=LI`、`First Name=CHUNHONG`。 - `LI CHUNHONG` 能拆成 `Name=LI`、`First Name=CHUNHONG`。 - 姓名首尾空格会 trim。 @@ -388,7 +427,7 @@ RESERVATION_ROOMING_LIST_GENERATE - `Adults` 按每个房间实际人数派生,尾房人数不足时不使用满房人数。 - `Children` 默认为 `0`。 - `payment_type` 为空时按 `BTQR` 处理,非 `BTQR` / `CA` 返回受控错误。 -- `nationality=KR` / `CHN` 成功,其他值返回受控错误。 +- `nationality=KR` / `CN` / `TH` / `MM` / `RS` / `TW` 成功,其他值返回受控错误。 - 来源文件中多个不同旅游日期区间返回受控错误。 - 缺少 `旅游日期` 表头或旅游日期格式无法解析时返回受控错误。 - `Accompanying Guests` 使用英文逗号拼接。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 1f0dd11..4f35cf2 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -67,7 +67,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 / CP2 / CP3;CP3 兼容第二种 `英文姓` + `英文名` 名单样式;multipart 上传来源名单并直接下载 `.xlsx`;已在 multipart 参数绑定前前置校验登录和生成权限 | 登录 + `RESERVATION_ROOMING_LIST_GENERATE` + 酒店访问权;第一种来源样式后端从来源 Excel `旅游日期` 派生 Arrival / Departure,第二种来源样式由用户提交 Arrival / Departure;Adults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality;Payment Type 默认 `BTQR` 且仅允许 `BTQR` / `CA`;第一版仍不落库、不上传 OSS | CP1 / CP2 / CP3 不落生成记录表;错误响应不得记录完整名单、证件信息、源文件内容或生成文件内容;第二种样式不得回显中文名、护照号、生日、签发地等来源证件字段;后续若增加历史记录再补业务审计 | +| `POST /api/reservation/rooming-lists/generations` | `FRONTEND_USER` | 已实现 M010 CP1 / CP2 / CP3 / CP4;CP3 兼容第二种 `英文姓` + `英文名` 名单样式,CP4 兼容第三种单列 `英文名` 名单样式;multipart 上传来源名单并直接下载 `.xlsx`;已在 multipart 参数绑定前前置校验登录和生成权限 | 登录 + `RESERVATION_ROOMING_LIST_GENERATE` + 酒店访问权;第一种来源样式后端从来源 Excel `旅游日期` 派生 Arrival / Departure,无旅游日期来源样式由用户提交 Arrival / Departure;Adults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality;Payment Type 默认 `BTQR` 且仅允许 `BTQR` / `CA`;Nationality 仅允许 `KR` / `CN` / `TH` / `MM` / `RS` / `TW`;第一版仍不落库、不上传 OSS | CP1 / CP2 / CP3 / CP4 不落生成记录表;错误响应不得记录完整名单、证件信息、源文件内容或生成文件内容;无旅游日期来源样式不得回显中文名、护照号、生日、出生地、签发地、联系方式、小费、住宿说明等来源敏感字段;后续若增加历史记录再补业务审计 | ### 3.2.1 V4 工作台、订单详情和任务详情页展示层技术信息边界 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 index 4872e4b..884ffb1 100644 --- 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 @@ -7,11 +7,11 @@ import java.time.LocalDate; * * @param hotelId 酒店 ID;为空时按当前登录用户默认酒店解析 * @param peoplePerRoom 每间房人数 - * @param arrival 第二种来源名单样式使用的入住酒店本地日期;第一种样式可为空并由来源旅游日期派生 - * @param departure 第二种来源名单样式使用的离店酒店本地日期;第一种样式可为空并由来源旅游日期派生 + * @param arrival 无旅游日期来源名单样式使用的入住酒店本地日期;第一种样式可为空并由来源旅游日期派生 + * @param departure 无旅游日期来源名单样式使用的离店酒店本地日期;第一种样式可为空并由来源旅游日期派生 * @param roomType 目标 Excel 的 Room Type * @param paymentType 目标 Excel 的 Payment Type;默认 BTQR,当前允许 BTQR 或 CA - * @param nationality 目标 Excel 的 Nationality;当前只允许 KR 或 CHN + * @param nationality 目标 Excel 的 Nationality;当前只允许 KR、CN、TH、MM、RS 或 TW */ public record ReservationRoomingListGenerationRequest( String hotelId, diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java index 80878e1..348b86d 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImpl.java @@ -30,8 +30,8 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR private static final String PAYMENT_TYPE_BTQR = "BTQR"; private static final String PAYMENT_TYPE_CA = "CA"; private static final String DEFAULT_PAYMENT_TYPE = PAYMENT_TYPE_BTQR; - private static final String NATIONALITY_KR = "KR"; - private static final String NATIONALITY_CHN = "CHN"; + private static final List ALLOWED_NATIONALITIES = + List.of("KR", "CN", "TH", "MM", "RS", "TW"); private final HotelContextService hotelContextService; private final RoomingListSourceExcelParser sourceExcelParser; @@ -152,8 +152,8 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR return ""; } String code = normalized.toUpperCase(java.util.Locale.ROOT); - if (!NATIONALITY_KR.equals(code) && !NATIONALITY_CHN.equals(code)) { - errors.add("nationality: 当前只允许 KR 或 CHN。"); + if (!ALLOWED_NATIONALITIES.contains(code)) { + errors.add("nationality: 当前只允许 KR、CN、TH、MM、RS 或 TW。"); } return code; } 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 index 422f326..e4e8286 100644 --- 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 @@ -8,16 +8,16 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; /** - * Rooming List 护照姓名解析器。第一版只处理斜杠或空格分隔的英文护照姓名。 + * Rooming List 英文姓名解析器。第一版只处理斜杠或空格分隔的英文姓名。 */ @Component public class RoomingListNameParser { /** - * 将来源 Excel 单元格中的护照全名拆分为目标模板 Name / First Name。 + * 将来源 Excel 单元格中的英文姓名拆分为目标模板 Name / First Name。 */ - public ReservationRoomingListGuestDto parse(int rowNumber, String rawPassportName) { - String normalized = trimToNull(rawPassportName); + public ReservationRoomingListGuestDto parse(int rowNumber, String rawEnglishName) { + String normalized = trimToNull(rawEnglishName); if (normalized == null) { throw invalidName(rowNumber); } @@ -35,7 +35,7 @@ public class RoomingListNameParser { } /** - * 按 `/` 拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。 + * 按 `/` 拆分英文姓名,第一段作为姓氏,剩余部分合并作为名字。 */ private ParsedName parseSlashName(String value) { List parts = Arrays.stream(value.split("/")) @@ -50,7 +50,7 @@ public class RoomingListNameParser { } /** - * 按连续空白拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。 + * 按连续空白拆分英文姓名,第一段作为姓氏,剩余部分合并作为名字。 */ private ParsedName parseWhitespaceName(String value) { List parts = Arrays.stream(value.split("\\s+")) @@ -72,7 +72,7 @@ public class RoomingListNameParser { HttpStatus.BAD_REQUEST, "ROOMING_LIST_SOURCE_FILE_INVALID", "来源名单文件不合法。", - List.of("第 " + rowNumber + " 行护照全名无法拆分为 Name / First Name。")); + List.of("第 " + rowNumber + " 行英文姓名无法拆分为 Name / First Name。")); } private boolean isBlank(String value) { @@ -87,7 +87,7 @@ public class RoomingListNameParser { } /** - * 护照姓名拆分结果。 + * 英文姓名拆分结果。 * * @param lastName 姓氏 * @param 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 index aa2e979..b575c57 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java @@ -54,7 +54,7 @@ public class RoomingListSourceExcelParser { } /** - * 解析来源 Excel。第二种英文姓名来源样式需要调用方提供入住 / 离店酒店本地日期。 + * 解析来源 Excel。无旅游日期来源样式需要调用方提供入住 / 离店酒店本地日期。 */ public ReservationRoomingListSourceDataDto parse( MultipartFile sourceFile, @@ -86,7 +86,17 @@ public class RoomingListSourceExcelParser { formatter, requireManualStayDateRange(manualArrival, manualDeparture)); } else { - throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期 或 英文姓/英文名。")); + HeaderLocation singleEnglishNameHeader = + findSingleEnglishNameHeaderIfPresent(sheet, formatter); + if (singleEnglishNameHeader != null) { + sourceRows = parseSingleEnglishNameRows( + sheet, + singleEnglishNameHeader, + formatter, + requireManualStayDateRange(manualArrival, manualDeparture)); + } else { + throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期、英文姓/英文名 或 英文名。")); + } } } return new ReservationRoomingListSourceDataDto( @@ -209,6 +219,13 @@ public class RoomingListSourceExcelParser { return null; } + /** + * 查找第三种来源样式的单列英文名表头;该列值形如 `CAI/HAIYUN`。 + */ + private HeaderLocation findSingleEnglishNameHeaderIfPresent(Sheet sheet, DataFormatter formatter) { + return findHeaderIfPresent(sheet, formatter, ENGLISH_FIRST_NAME_HEADER); + } + /** * 第二种来源样式直接使用英文姓 / 英文名,不读取证件号、中文名或生日等敏感字段。 */ @@ -243,15 +260,41 @@ public class RoomingListSourceExcelParser { } /** - * 第二种来源样式没有可靠旅游日期,必须使用用户提交的入住 / 离店日期。 + * 第三种来源样式直接读取单列英文名并复用护照英文名拆分,不读取证件号、生日或联系方式等敏感字段。 + */ + private SourceRows parseSingleEnglishNameRows( + Sheet sheet, + HeaderLocation englishNameHeader, + DataFormatter formatter, + TravelDateRange manualStayDateRange) { + List guests = new ArrayList<>(); + for (int rowIndex = englishNameHeader.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + continue; + } + String englishName = cellText(row.getCell(englishNameHeader.cellIndex()), formatter); + if (isBlank(englishName)) { + continue; + } + guests.add(nameParser.parse(rowIndex + 1, englishName)); + } + if (guests.isEmpty()) { + throw invalidFile(List.of("英文名列没有可用旅客姓名。")); + } + return new SourceRows(List.copyOf(guests), manualStayDateRange); + } + + /** + * 无旅游日期来源样式没有可靠旅游日期,必须使用用户提交的入住 / 离店日期。 */ private TravelDateRange requireManualStayDateRange(LocalDate manualArrival, LocalDate manualDeparture) { List errors = new ArrayList<>(); if (manualArrival == null) { - errors.add("arrival: 第二种来源名单样式必须填写入住日期。"); + errors.add("arrival: 无旅游日期来源名单样式必须填写入住日期。"); } if (manualDeparture == null) { - errors.add("departure: 第二种来源名单样式必须填写离店日期。"); + errors.add("departure: 无旅游日期来源名单样式必须填写离店日期。"); } if (!errors.isEmpty()) { throw validationError(errors); 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 index 35672a5..c2056b8 100644 --- 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 @@ -4,7 +4,6 @@ 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; @@ -17,9 +16,13 @@ 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.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.time.LocalDateTime; +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.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -90,7 +93,7 @@ class ReservationRoomingListGenerationControllerTest { .param("people_per_room", "2") .param("room_type", "UG1") .param("payment_type", "BTQR") - .param("nationality", "CHN")) + .param("nationality", "CN")) .andExpect(status().isOk()) .andExpect(content().contentType( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) @@ -111,8 +114,10 @@ class ReservationRoomingListGenerationControllerTest { .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 11)) .isEqualTo("BTQR")) .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 14)) - .isEqualTo("CHN")) - .andExpect(content().string(not(containsString("SYNTHETIC_DOC_001")))); + .isEqualTo("CN")) + .andExpect(result -> assertWorkbookDoesNotContain( + result.getResponse().getContentAsByteArray(), + "SYNTHETIC_DOC_001")); } @Test @@ -127,7 +132,7 @@ class ReservationRoomingListGenerationControllerTest { .param("departure", "2026-05-16") .param("room_type", "UG1") .param("payment_type", "BTQR") - .param("nationality", "CHN")) + .param("nationality", "CN")) .andExpect(status().isOk()) .andExpect(content().contentType( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) @@ -147,8 +152,49 @@ class ReservationRoomingListGenerationControllerTest { .isEqualTo("LEADER")) .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 2, 9)) .isEqualTo("1")) - .andExpect(content().string(not(containsString("PX000101")))) - .andExpect(content().string(not(containsString("SYNTHETIC_CN_A")))); + .andExpect(result -> assertWorkbookDoesNotContain( + result.getResponse().getContentAsByteArray(), + "PX000101", + "SYNTHETIC_CN_A")); + } + + @Test + void shouldDownloadGeneratedRoomingListWorkbookForSingleEnglishNameColumnSourceWithManualStayDates() + throws Exception { + String token = loginToken(mockMvc, "rooming-admin", "Admin@123456"); + + performAuthorized(mockMvc, token, multipart(ENDPOINT) + .file(sourceFileWithSingleEnglishNameColumn()) + .param("hotel_id", "HOTEL-TEST") + .param("people_per_room", "3") + .param("arrival", "2026-08-03") + .param("departure", "2026-08-08") + .param("room_type", "UG1") + .param("payment_type", "BTQR") + .param("nationality", "CN")) + .andExpect(status().isOk()) + .andExpect(content().contentType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 1)) + .isEqualTo("CAI")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 2)) + .isEqualTo("HAIYUN")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 4)) + .isEqualTo("2026/08/03")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 5)) + .isEqualTo("2026/08/08")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 9)) + .isEqualTo("3")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 13)) + .isEqualTo("HUANG YUHENG, MA XIUHAN")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 2, 1)) + .isEqualTo("HE")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 2, 9)) + .isEqualTo("1")) + .andExpect(result -> assertWorkbookDoesNotContain( + result.getResponse().getContentAsByteArray(), + "PX000201", + "SYNTHETIC_CN_LEADER")); } @Test @@ -195,7 +241,7 @@ class ReservationRoomingListGenerationControllerTest { .param("people_per_room", "2") .param("room_type", "UG1") .param("payment_type", "CA") - .param("nationality", "CHN")) + .param("nationality", "CN")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); } @@ -223,11 +269,11 @@ class ReservationRoomingListGenerationControllerTest { .param("people_per_room", "2") .param("room_type", "UG1") .param("payment_type", "BTQR") - .param("nationality", "CHN")) + .param("nationality", "CN")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED")) - .andExpect(jsonPath("$.details[0]").value("arrival: 第二种来源名单样式必须填写入住日期。")) - .andExpect(jsonPath("$.details[1]").value("departure: 第二种来源名单样式必须填写离店日期。")); + .andExpect(jsonPath("$.details[0]").value("arrival: 无旅游日期来源名单样式必须填写入住日期。")) + .andExpect(jsonPath("$.details[1]").value("departure: 无旅游日期来源名单样式必须填写离店日期。")); } @Test @@ -240,7 +286,7 @@ class ReservationRoomingListGenerationControllerTest { .param("people_per_room", "2") .param("room_type", "UG1") .param("payment_type", "CA") - .param("nationality", "CHN")) + .param("nationality", "CN")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_code").value("ROOMING_LIST_SOURCE_FILE_INVALID")) .andExpect(jsonPath("$.details[0]").value("旅游日期: 格式无法解析。")); @@ -318,6 +364,45 @@ class ReservationRoomingListGenerationControllerTest { } } + private MockMultipartFile sourceFileWithSingleEnglishNameColumn() 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("客人名单表"); + titleRow.createCell(1).setCellValue("0803杭州-曼谷/0808曼谷-杭州"); + Row headerRow = sheet.createRow(1); + headerRow.createCell(0).setCellValue("序号"); + headerRow.createCell(1).setCellValue("姓名"); + headerRow.createCell(2).setCellValue("性别"); + headerRow.createCell(3).setCellValue("英文名"); + headerRow.createCell(4).setCellValue("证件号"); + singleEnglishNameRow(sheet.createRow(2), "领队", "SYNTHETIC_CN_LEADER", "CAI/HAIYUN", "PX000201"); + singleEnglishNameRow(sheet.createRow(3), "1", "SYNTHETIC_CN_A", "HUANG/YUHENG", "PX000202"); + singleEnglishNameRow(sheet.createRow(4), "2", "SYNTHETIC_CN_B", "MA/XIUHAN", "PX000203"); + singleEnglishNameRow(sheet.createRow(5), "3", "SYNTHETIC_CN_C", "HE/XIANG", "PX000204"); + workbook.write(outputStream); + return new MockMultipartFile( + "file", + "synthetic-third-style.xlsx", + MediaType.APPLICATION_OCTET_STREAM_VALUE, + outputStream.toByteArray()); + } + } + + private void singleEnglishNameRow( + Row row, + String index, + String chineseName, + String englishName, + String passportNo) { + row.createCell(0).setCellValue(index); + row.createCell(1).setCellValue(chineseName); + row.createCell(2).setCellValue("F"); + row.createCell(3).setCellValue(englishName); + row.createCell(4).setCellValue(passportNo); + } + private void englishNameRow( Row row, int index, @@ -333,7 +418,7 @@ class ReservationRoomingListGenerationControllerTest { } private String workbookCell(byte[] workbookBytes, int rowIndex, int cellIndex) throws Exception { - try (XSSFWorkbook workbook = new XSSFWorkbook(new java.io.ByteArrayInputStream(workbookBytes))) { + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(workbookBytes))) { var cell = workbook.getSheetAt(0).getRow(rowIndex).getCell(cellIndex); if (cell == null) { return ""; @@ -351,4 +436,21 @@ class ReservationRoomingListGenerationControllerTest { }; } } + + private void assertWorkbookDoesNotContain(byte[] workbookBytes, String... unexpectedValues) throws Exception { + DataFormatter formatter = new DataFormatter(); + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(workbookBytes))) { + for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) { + Sheet sheet = workbook.getSheetAt(sheetIndex); + for (Row row : sheet) { + for (Cell cell : row) { + String cellText = formatter.formatCellValue(cell); + for (String unexpectedValue : unexpectedValues) { + assertThat(cellText).doesNotContain(unexpectedValue); + } + } + } + } + } + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java index 55b9057..c810173 100644 --- a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/service/impl/ReservationRoomingListGenerationServiceImplTest.java @@ -70,7 +70,7 @@ class ReservationRoomingListGenerationServiceImplTest { assertThat(cellText(firstRoom.getCell(10))).isEqualTo("0"); assertThat(cellText(firstRoom.getCell(11))).isEqualTo("BTQR"); assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG GAILI, LI HONG"); - assertThat(cellText(firstRoom.getCell(14))).isEqualTo("CHN"); + assertThat(cellText(firstRoom.getCell(14))).isEqualTo("CN"); assertThat(cellText(firstRoom.getCell(15))).isEmpty(); assertThat(cellText(firstRoom.getCell(16))).isEmpty(); assertThat(cellText(firstRoom.getCell(17))).isEmpty(); @@ -102,7 +102,7 @@ class ReservationRoomingListGenerationServiceImplTest { null, "UG1", "ca", - "CHN"), + "CN"), actor()); try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) { @@ -111,6 +111,30 @@ class ReservationRoomingListGenerationServiceImplTest { } } + @Test + void shouldAcceptExpandedNationalityCodesForRoomingListWorkbook() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + for (String nationality : List.of("KR", "CN", "TH", "MM", "RS", "TW")) { + ReservationRoomingListGeneratedFile generatedFile = service.generate( + sourceFileWithTravelDate("2026-05-09 - 05-14", List.of("LI/CHUNHONG")), + new ReservationRoomingListGenerationRequest( + "HOTEL-TEST", + 1, + null, + null, + "UG1", + "BTQR", + nationality), + actor()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) { + Row firstRoom = workbook.getSheetAt(0).getRow(1); + assertThat(cellText(firstRoom.getCell(14))).isEqualTo(nationality); + } + } + } + @Test void shouldParseTravelDateWithNumericHyphenRange() throws Exception { when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); @@ -153,6 +177,35 @@ class ReservationRoomingListGenerationServiceImplTest { } } + @Test + void shouldGenerateRoomingListWorkbookBySingleEnglishNameColumnAndManualStayDates() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + ReservationRoomingListGeneratedFile generatedFile = service.generate( + sourceFileWithSingleEnglishNameColumn(), + request(3, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 8)), + actor()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) { + assertThat(workbook.getSheetAt(0).getPhysicalNumberOfRows()).isEqualTo(3); + Row leaderRoom = workbook.getSheetAt(0).getRow(1); + assertThat(cellText(leaderRoom.getCell(0))).isEqualTo("1"); + assertThat(cellText(leaderRoom.getCell(1))).isEqualTo("CAI"); + assertThat(cellText(leaderRoom.getCell(2))).isEqualTo("HAIYUN"); + assertThat(cellText(leaderRoom.getCell(4))).isEqualTo("2026/08/03"); + assertThat(cellText(leaderRoom.getCell(5))).isEqualTo("2026/08/08"); + assertThat(cellText(leaderRoom.getCell(9))).isEqualTo("3"); + assertThat(cellText(leaderRoom.getCell(13))).isEqualTo("HUANG YUHENG, MA XIUHAN"); + + Row tailRoom = workbook.getSheetAt(0).getRow(2); + assertThat(cellText(tailRoom.getCell(0))).isEqualTo("2"); + assertThat(cellText(tailRoom.getCell(1))).isEqualTo("HE"); + assertThat(cellText(tailRoom.getCell(2))).isEqualTo("XIANG"); + assertThat(cellText(tailRoom.getCell(9))).isEqualTo("1"); + assertThat(cellText(tailRoom.getCell(13))).isEmpty(); + } + } + @Test void shouldRejectEnglishNameSourceWorkbookWithoutManualStayDates() throws Exception { when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); @@ -164,8 +217,40 @@ class ReservationRoomingListGenerationServiceImplTest { assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED"); assertThat(exception.getDetails()) .contains( - "arrival: 第二种来源名单样式必须填写入住日期。", - "departure: 第二种来源名单样式必须填写离店日期。"); + "arrival: 无旅游日期来源名单样式必须填写入住日期。", + "departure: 无旅游日期来源名单样式必须填写离店日期。"); + } + + @Test + void shouldRejectSingleEnglishNameColumnSourceWorkbookWithoutManualStayDates() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + ReservationRoomingListGenerationException exception = catchThrowableOfType( + () -> service.generate(sourceFileWithSingleEnglishNameColumn(), request(2), actor()), + ReservationRoomingListGenerationException.class); + + assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED"); + assertThat(exception.getDetails()) + .contains( + "arrival: 无旅游日期来源名单样式必须填写入住日期。", + "departure: 无旅游日期来源名单样式必须填写离店日期。"); + } + + @Test + void shouldReportGenericEnglishNameWhenSingleEnglishNameColumnCannotBeSplit() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + ReservationRoomingListGenerationException exception = catchThrowableOfType( + () -> service.generate( + sourceFileWithInvalidSingleEnglishNameColumn(), + request(2, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 8)), + actor()), + ReservationRoomingListGenerationException.class); + + assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID"); + assertThat(exception.getDetails()) + .contains("第 3 行英文姓名无法拆分为 Name / First Name。") + .noneMatch(detail -> detail.contains("护照全名")); } @Test @@ -232,13 +317,13 @@ class ReservationRoomingListGenerationServiceImplTest { null, "UG1", "BT", - "TH"), + "JP"), actor()), ReservationRoomingListGenerationException.class); assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED"); assertThat(exception.getDetails()) - .contains("payment_type: 当前只允许 BTQR 或 CA。", "nationality: 当前只允许 KR 或 CHN。"); + .contains("payment_type: 当前只允许 BTQR 或 CA。", "nationality: 当前只允许 KR、CN、TH、MM、RS 或 TW。"); } @Test @@ -278,7 +363,7 @@ class ReservationRoomingListGenerationServiceImplTest { departure, "UG1", null, - "CHN"); + "CN"); } private AuthenticatedUserContext actor() { @@ -431,6 +516,64 @@ class ReservationRoomingListGenerationServiceImplTest { } } + private MockMultipartFile sourceFileWithSingleEnglishNameColumn() 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("客人名单表"); + titleRow.createCell(1).setCellValue("0803杭州-曼谷/0808曼谷-杭州"); + Row headerRow = sheet.createRow(1); + headerRow.createCell(0).setCellValue("序号"); + headerRow.createCell(1).setCellValue("姓名"); + headerRow.createCell(2).setCellValue("性别"); + headerRow.createCell(3).setCellValue("英文名"); + headerRow.createCell(4).setCellValue("证件号"); + singleEnglishNameRow(sheet.createRow(2), "领队", "SYNTHETIC_CN_LEADER", "CAI/HAIYUN", "PX000101"); + singleEnglishNameRow(sheet.createRow(3), "1", "SYNTHETIC_CN_A", "HUANG/YUHENG", "PX000102"); + singleEnglishNameRow(sheet.createRow(4), "2", "SYNTHETIC_CN_B", "MA/XIUHAN", "PX000103"); + singleEnglishNameRow(sheet.createRow(5), "3", "SYNTHETIC_CN_C", "HE/XIANG", "PX000104"); + workbook.write(outputStream); + return new MockMultipartFile( + "file", + "synthetic-third-style.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + outputStream.toByteArray()); + } + } + + private MockMultipartFile sourceFileWithInvalidSingleEnglishNameColumn() throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("Sheet1"); + Row headerRow = sheet.createRow(1); + headerRow.createCell(0).setCellValue("序号"); + headerRow.createCell(1).setCellValue("姓名"); + headerRow.createCell(2).setCellValue("性别"); + headerRow.createCell(3).setCellValue("英文名"); + singleEnglishNameRow(sheet.createRow(2), "领队", "SYNTHETIC_CN_LEADER", "CAI", "PX000105"); + workbook.write(outputStream); + return new MockMultipartFile( + "file", + "synthetic-invalid-third-style.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + outputStream.toByteArray()); + } + } + + private void singleEnglishNameRow( + Row row, + String index, + String chineseName, + String englishName, + String passportNo) { + row.createCell(0).setCellValue(index); + row.createCell(1).setCellValue(chineseName); + row.createCell(2).setCellValue("F"); + row.createCell(3).setCellValue(englishName); + row.createCell(4).setCellValue(passportNo); + } + private void englishNameRow( Row row, int index,