From 1236df86cf160411b840c0871acb4178c3cb37ae Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 22 Jul 2026 15:29:33 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Rooming=20List=20CP2=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 1 + PROJECT_STATE.md | 4 +- client/src/i18n/locales/en-US.ts | 11 +- client/src/i18n/locales/th-TH.ts | 11 +- client/src/i18n/locales/zh-CN.ts | 10 +- client/src/services/roomingListService.ts | 16 -- client/src/tests/roomingListService.spec.ts | 46 ++-- client/src/tests/roomingListView.spec.ts | 51 ++-- client/src/types/roomingList.ts | 12 +- .../ReservationRoomingListGenerationView.vue | 232 +++++------------- docs/project/README.md | 2 +- docs/project/frontend-backend/README.md | 4 +- .../backend-to-frontend-notes.md | 16 +- .../frontend-to-backend-api-requests.md | 58 ++++- .../M010-rooming-list-excel-generation-v1.md | 136 +++++++--- .../security-access-control-boundary.md | 2 +- .../ReservationRoomingListSourceDataDto.java | 17 ++ ...servationRoomingListGenerationRequest.java | 28 +-- ...vationRoomingListGenerationController.java | 28 +-- ...ationRoomingListGenerationServiceImpl.java | 70 +++--- .../impl/RoomingListExcelRenderer.java | 31 ++- .../impl/RoomingListSourceExcelParser.java | 148 +++++++++-- ...onRoomingListGenerationControllerTest.java | 106 +++++--- ...nRoomingListGenerationServiceImplTest.java | 145 +++++++++-- 24 files changed, 704 insertions(+), 481 deletions(-) create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/ReservationRoomingListSourceDataDto.java diff --git a/CONTEXT.md b/CONTEXT.md index b3d7074..e010aa1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -50,6 +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 已实现从来源名单 `旅游日期` 派生入住 / 离店日期,由系统按分房结果计算成人数,前端目标默认值区域只保留付款方式和国籍。 - 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 d6c5c34..23e55ad 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -34,7 +34,7 @@ - 现有历史文档暂不按 AI-NSES 目录大搬迁,先通过索引和采用说明建立映射关系。 - `docs/import/` 下按日期导入的资料是输入材料,不等同于当前权威开发契约;当前开发应优先看 `docs/project/README.md` 标记为当前有效或权威契约的文档。 - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 -- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单和手工字段,后端同步生成 `.xlsx` 直接下载,第一版不落库、不上传 OSS。 +- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单并下载后端同步生成的 `.xlsx`,第一版不落库、不上传 OSS。CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality,Payment Type 默认 / 当前唯一允许值为 `CA`,Nationality 只允许 `KR` / `CHN`。 - 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`;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 任务详情和草稿确认接口后续可逐步废弃。 @@ -43,7 +43,7 @@ - 后续如继续做 M002 V4,可优先进行测试机联调,或推进真实 PMS / OPERA / OHIP 目录同步、`workflow_reservation_catalog_sync_run` checkpoint 和 SuperAgent 目录供给方案。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 -- M010 后续如需预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化,再单独开前后端 checkpoint。 +- M010 CP2 字段收口已完成;预览、历史记录、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 e9a82cc..9b34771 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -398,7 +398,8 @@ export default { roomingList: { eyebrow: 'Reservation tool', title: 'Generate Rooming List Excel', - subtitle: 'Upload the travel group list, enter rooming defaults, and download the generated hotel import workbook.', + subtitle: + 'Upload the travel group list, enter people per room, room type, payment type and nationality. Arrival and departure are derived from the source travel dates.', currentHotel: 'Current hotel', sourceSection: 'Source list and required fields', targetSection: 'Target Excel defaults', @@ -415,8 +416,12 @@ export default { adults: 'Adults', children: 'Children', paymentType: 'Payment Type', + paymentTypeCa: 'CA', vip: 'VIP', nationality: 'Nationality', + nationalityPlaceholder: 'Select nationality', + nationalityKr: 'KR', + nationalityChn: 'CHN', email: 'Email', idType: 'ID Type', idNumber: 'ID Number', @@ -436,6 +441,10 @@ export default { dateFormat: 'Date format must be yyyy-MM-dd.', departureAfterArrival: 'Departure must be later than arrival.', roomTypeRequired: 'Enter room type.', + paymentTypeRequired: 'Select payment type.', + paymentTypeAllowed: 'Only CA is currently allowed.', + nationalityRequired: 'Select nationality.', + nationalityAllowed: 'Only KR or CHN are currently allowed.', invalidEmail: 'Invalid email.', }, errors: { diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 4adeb48..5051fde 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -398,7 +398,8 @@ export default { roomingList: { eyebrow: 'เครื่องมือ Reservation', title: 'สร้าง Rooming List Excel', - subtitle: 'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้องและค่าเริ่มต้น แล้วดาวน์โหลดไฟล์ Excel ที่สร้างแล้ว', + subtitle: + 'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality โดย Arrival/Departure จะมาจาก Travel Date ในไฟล์ต้นทาง', currentHotel: 'โรงแรมปัจจุบัน', sourceSection: 'ไฟล์รายชื่อและข้อมูลจำเป็น', targetSection: 'ค่าเริ่มต้นของ Excel ปลายทาง', @@ -415,8 +416,12 @@ export default { adults: 'Adults', children: 'Children', paymentType: 'Payment Type', + paymentTypeCa: 'CA', vip: 'VIP', nationality: 'Nationality', + nationalityPlaceholder: 'เลือก Nationality', + nationalityKr: 'KR', + nationalityChn: 'CHN', email: 'Email', idType: 'ID Type', idNumber: 'ID Number', @@ -436,6 +441,10 @@ export default { dateFormat: 'รูปแบบวันที่ต้องเป็น yyyy-MM-dd', departureAfterArrival: 'Departure ต้องอยู่หลัง Arrival', roomTypeRequired: 'กรุณากรอก Room Type', + paymentTypeRequired: 'กรุณาเลือก Payment Type', + paymentTypeAllowed: 'รองรับเฉพาะ CA ในตอนนี้', + nationalityRequired: 'กรุณาเลือก Nationality', + nationalityAllowed: 'รองรับเฉพาะ KR หรือ CHN ในตอนนี้', invalidEmail: 'รูปแบบ Email ไม่ถูกต้อง', }, errors: { diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 70510bc..b6e29ff 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -398,7 +398,7 @@ export default { roomingList: { eyebrow: '预订工具', title: '生成 Rooming List Excel', - subtitle: '上传旅行团名单 Excel,填写每房人数和目标列默认值,生成酒店可导入的 Rooming List。', + subtitle: '上传旅行团名单 Excel,填写每房人数、房型、付款方式和国籍;入住/离店日期由来源表的旅游日期生成。', currentHotel: '当前酒店', sourceSection: '来源名单与必填信息', targetSection: '目标 Excel 默认值', @@ -415,8 +415,12 @@ export default { adults: '成人数(Adults)', children: '儿童数(Children)', paymentType: '付款方式(Payment Type)', + paymentTypeCa: 'CA', vip: 'VIP', nationality: '国籍(Nationality)', + nationalityPlaceholder: '请选择国籍', + nationalityKr: 'KR', + nationalityChn: 'CHN', email: '邮箱(Email)', idType: '证件类型(ID Type)', idNumber: '证件号码(ID Number)', @@ -436,6 +440,10 @@ export default { dateFormat: '日期格式必须为 yyyy-MM-dd。', departureAfterArrival: '离店日期必须晚于入住日期。', roomTypeRequired: '请填写房型。', + paymentTypeRequired: '请选择付款方式。', + paymentTypeAllowed: '付款方式当前只允许 CA。', + nationalityRequired: '请选择国籍。', + nationalityAllowed: '国籍当前只允许 KR 或 CHN。', invalidEmail: 'Email 格式不正确。', }, errors: { diff --git a/client/src/services/roomingListService.ts b/client/src/services/roomingListService.ts index 6f158fc..4f7241d 100644 --- a/client/src/services/roomingListService.ts +++ b/client/src/services/roomingListService.ts @@ -72,19 +72,9 @@ function buildRoomingListForm(input: RoomingListGenerationInput): FormData { form.append('file', input.file) appendOptionalText(form, 'hotel_id', input.hotelId) form.append('people_per_room', String(input.peoplePerRoom)) - form.append('arrival', input.arrival) - form.append('departure', input.departure) form.append('room_type', input.roomType) - appendOptionalText(form, 'title', input.title) - appendOptionalText(form, 'rate_code', input.rateCode) - appendOptionalNumber(form, 'adults', input.adults) - appendOptionalNumber(form, 'children', input.children) appendOptionalText(form, 'payment_type', input.paymentType) - appendOptionalText(form, 'vip', input.vip) appendOptionalText(form, 'nationality', input.nationality) - appendOptionalText(form, 'email', input.email) - appendOptionalText(form, 'id_type', input.idType) - appendOptionalText(form, 'id_number', input.idNumber) return form } @@ -95,12 +85,6 @@ function appendOptionalText(form: FormData, key: string, value: string | null | } } -function appendOptionalNumber(form: FormData, key: string, value: number | null | undefined): void { - if (typeof value === 'number' && Number.isFinite(value)) { - form.append(key, String(value)) - } -} - async function readErrorPayload(response: Response): Promise { const contentType = response.headers.get('content-type') ?? '' if (!contentType.includes('application/json')) { diff --git a/client/src/tests/roomingListService.spec.ts b/client/src/tests/roomingListService.spec.ts index b8f618f..e32dd1c 100644 --- a/client/src/tests/roomingListService.spec.ts +++ b/client/src/tests/roomingListService.spec.ts @@ -15,19 +15,9 @@ function createInput(): RoomingListGenerationInput { }), hotelId: 'HOTEL-TEST', peoplePerRoom: 2, - arrival: '2026-07-20', - departure: '2026-07-22', roomType: 'DLX', - title: 'MR', - rateCode: 'BAR', - adults: 2, - children: 0, - paymentType: 'BT', - vip: 'VIP1', - nationality: 'TH', - email: 'guest@example.test', - idType: 'Passport', - idNumber: 'A1234567', + paymentType: 'CA', + nationality: 'CHN', } } @@ -84,19 +74,19 @@ describe('roomingListService', () => { expect(formData.get('file')).toBe(input.file) expect(formData.get('hotel_id')).toBe('HOTEL-TEST') expect(formData.get('people_per_room')).toBe('2') - expect(formData.get('arrival')).toBe('2026-07-20') - expect(formData.get('departure')).toBe('2026-07-22') expect(formData.get('room_type')).toBe('DLX') - expect(formData.get('title')).toBe('MR') - expect(formData.get('rate_code')).toBe('BAR') - expect(formData.get('adults')).toBe('2') - expect(formData.get('children')).toBe('0') - expect(formData.get('payment_type')).toBe('BT') - expect(formData.get('vip')).toBe('VIP1') - expect(formData.get('nationality')).toBe('TH') - expect(formData.get('email')).toBe('guest@example.test') - expect(formData.get('id_type')).toBe('Passport') - expect(formData.get('id_number')).toBe('A1234567') + expect(formData.get('payment_type')).toBe('CA') + expect(formData.get('nationality')).toBe('CHN') + expect(formData.get('arrival')).toBeNull() + expect(formData.get('departure')).toBeNull() + expect(formData.get('title')).toBeNull() + expect(formData.get('rate_code')).toBeNull() + expect(formData.get('adults')).toBeNull() + expect(formData.get('children')).toBeNull() + expect(formData.get('vip')).toBeNull() + expect(formData.get('email')).toBeNull() + expect(formData.get('id_type')).toBeNull() + expect(formData.get('id_number')).toBeNull() expect(result.fileName).toBe('rooming-list-HOTEL-TEST-20260718.xlsx') expect(result.blob.type).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') }) @@ -107,16 +97,8 @@ describe('roomingListService', () => { const result = await generateReservationRoomingListExcel({ ...createInput(), hotelId: undefined, - title: '', - rateCode: '', - adults: null, - children: null, paymentType: '', - vip: '', nationality: '', - email: '', - idType: '', - idNumber: '', }) expect(result.fileName).toBe('rooming-list.xlsx') diff --git a/client/src/tests/roomingListView.spec.ts b/client/src/tests/roomingListView.spec.ts index c945252..3cfca07 100644 --- a/client/src/tests/roomingListView.spec.ts +++ b/client/src/tests/roomingListView.spec.ts @@ -84,9 +84,8 @@ async function fillRequiredFields(wrapper: ReturnType) { }), ) await wrapper.find('[data-testid="rooming-list-people-per-room"]').setValue('2') - await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-07-20') - await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-22') await wrapper.find('[data-testid="rooming-list-room-type"]').setValue('DLX') + await wrapper.find('[data-testid="rooming-list-nationality"]').setValue('CHN') } describe('ReservationRoomingListGenerationView', () => { @@ -132,24 +131,26 @@ describe('ReservationRoomingListGenerationView', () => { expect(generateReservationRoomingListExcel).not.toHaveBeenCalled() expect(wrapper.text()).toContain('请选择来源 Excel 文件。') expect(wrapper.text()).toContain('每间房人数必须大于 0。') - expect(wrapper.text()).toContain('请填写入住日期。') - expect(wrapper.text()).toContain('请填写离店日期。') expect(wrapper.text()).toContain('请填写房型。') + expect(wrapper.text()).toContain('请选择国籍。') }) - it('blocks departure dates that are not later than arrival', async () => { + it('does not render manually entered dates or removed target defaults', () => { const wrapper = mountView() - await fillRequiredFields(wrapper) - await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-07-20') - await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') - await flushPromises() - - expect(generateReservationRoomingListExcel).not.toHaveBeenCalled() - expect(wrapper.text()).toContain('离店日期必须晚于入住日期。') + expect(wrapper.find('[data-testid="rooming-list-arrival"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-departure"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-title"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-rate-code"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-adults"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-children"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-vip"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-email"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-id-type"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="rooming-list-id-number"]').exists()).toBe(false) }) - it('submits required and optional fields, then downloads the returned workbook', async () => { + it('submits required fields and allowed defaults, then downloads the returned workbook', async () => { vi.mocked(generateReservationRoomingListExcel).mockResolvedValue({ blob: new Blob(['xlsx-binary'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', @@ -158,16 +159,6 @@ describe('ReservationRoomingListGenerationView', () => { }) const wrapper = mountView() await fillRequiredFields(wrapper) - await wrapper.find('[data-testid="rooming-list-title"]').setValue('MR') - await wrapper.find('[data-testid="rooming-list-rate-code"]').setValue('BAR') - await wrapper.find('[data-testid="rooming-list-adults"]').setValue('2') - await wrapper.find('[data-testid="rooming-list-children"]').setValue('0') - await wrapper.find('[data-testid="rooming-list-payment-type"]').setValue('BT') - await wrapper.find('[data-testid="rooming-list-vip"]').setValue('VIP1') - await wrapper.find('[data-testid="rooming-list-nationality"]').setValue('TH') - await wrapper.find('[data-testid="rooming-list-email"]').setValue('guest@example.test') - await wrapper.find('[data-testid="rooming-list-id-type"]').setValue('Passport') - await wrapper.find('[data-testid="rooming-list-id-number"]').setValue('A1234567') await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click') await flushPromises() @@ -176,19 +167,9 @@ describe('ReservationRoomingListGenerationView', () => { file: expect.any(File), hotelId: 'HOTEL-TEST', peoplePerRoom: 2, - arrival: '2026-07-20', - departure: '2026-07-22', roomType: 'DLX', - title: 'MR', - rateCode: 'BAR', - adults: 2, - children: 0, - paymentType: 'BT', - vip: 'VIP1', - nationality: 'TH', - email: 'guest@example.test', - idType: 'Passport', - idNumber: 'A1234567', + paymentType: 'CA', + nationality: 'CHN', }) expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce() diff --git a/client/src/types/roomingList.ts b/client/src/types/roomingList.ts index 2faa954..52e871a 100644 --- a/client/src/types/roomingList.ts +++ b/client/src/types/roomingList.ts @@ -12,19 +12,9 @@ export interface RoomingListGenerationInput { file: File hotelId?: string | null peoplePerRoom: number - arrival: string - departure: string roomType: string - title?: string | null - rateCode?: string | null - adults?: number | null - children?: number | null paymentType?: string | null - vip?: string | null - nationality?: string | null - email?: string | null - idType?: string | null - idNumber?: string | null + nationality: string } export interface RoomingListGenerationDownload { diff --git a/client/src/views/reservation/ReservationRoomingListGenerationView.vue b/client/src/views/reservation/ReservationRoomingListGenerationView.vue index bfbed29..1845ed8 100644 --- a/client/src/views/reservation/ReservationRoomingListGenerationView.vue +++ b/client/src/views/reservation/ReservationRoomingListGenerationView.vue @@ -116,46 +116,6 @@ {{ fieldError('roomType') }} - - - - @@ -173,28 +133,55 @@
- + +
@@ -274,48 +261,22 @@ type AlertTone = 'success' | 'error' type RoomingListFieldKey = | 'file' | 'peoplePerRoom' - | 'arrival' - | 'departure' | 'roomType' - | 'title' - | 'rateCode' - | 'adults' - | 'children' | 'paymentType' - | 'vip' | 'nationality' - | 'email' - | 'idType' - | 'idNumber' -interface OptionalFieldDefinition { - key: Exclude - labelKey: string - testId: string - type: 'text' | 'email' | 'number' - min?: string - step?: string -} +const allowedPaymentTypes = new Set(['CA']) +const allowedNationalities = new Set(['KR', 'CHN']) -const { t, te, locale } = useI18n() +const { t, te } = useI18n() const authStore = useAuthStore() const form = reactive({ file: null as File | null, peoplePerRoom: '', - arrival: '', - departure: '', roomType: '', - title: '', - rateCode: '', - adults: '', - children: '', - paymentType: '', - vip: '', + paymentType: 'CA', nationality: '', - email: '', - idType: '', - idNumber: '', }) const submitting = ref(false) @@ -325,26 +286,6 @@ const detailMessages = ref([]) const generatedFileName = ref('') const alertTone = ref('error') -const optionalFields: OptionalFieldDefinition[] = [ - { key: 'title', labelKey: 'roomingList.titleColumn', testId: 'rooming-list-title', type: 'text' }, - { key: 'rateCode', labelKey: 'roomingList.rateCode', testId: 'rooming-list-rate-code', type: 'text' }, - { key: 'adults', labelKey: 'roomingList.adults', testId: 'rooming-list-adults', type: 'number', min: '0', step: '1' }, - { - key: 'children', - labelKey: 'roomingList.children', - testId: 'rooming-list-children', - type: 'number', - min: '0', - step: '1', - }, - { key: 'paymentType', labelKey: 'roomingList.paymentType', testId: 'rooming-list-payment-type', type: 'text' }, - { key: 'vip', labelKey: 'roomingList.vip', testId: 'rooming-list-vip', type: 'text' }, - { key: 'nationality', labelKey: 'roomingList.nationality', testId: 'rooming-list-nationality', type: 'text' }, - { key: 'email', labelKey: 'roomingList.email', testId: 'rooming-list-email', type: 'email' }, - { key: 'idType', labelKey: 'roomingList.idType', testId: 'rooming-list-id-type', type: 'text' }, - { key: 'idNumber', labelKey: 'roomingList.idNumber', testId: 'rooming-list-id-number', type: 'text' }, -] - const currentHotelLabel = computed(() => authStore.selectedHotel?.hotel_name || authStore.selectedHotelId || '-') const sourceFileLabel = computed(() => form.file?.name || t('roomingList.noFile')) const alertTitle = computed(() => @@ -396,19 +337,9 @@ function buildGenerationInput(file: File): RoomingListGenerationInput { file, hotelId: authStore.selectedHotelId, peoplePerRoom: Number(form.peoplePerRoom), - arrival: form.arrival, - departure: form.departure, roomType: form.roomType, - title: form.title, - rateCode: form.rateCode, - adults: optionalIntegerValue(form.adults), - children: optionalIntegerValue(form.children), paymentType: form.paymentType, - vip: form.vip, nationality: form.nationality, - email: form.email, - idType: form.idType, - idNumber: form.idNumber, } } @@ -453,28 +384,18 @@ function collectFieldErrors(): FieldErrors { if (!isPositiveInteger(form.peoplePerRoom)) { errors.peoplePerRoom = t('roomingList.fieldErrors.positiveInteger') } - if (!isValidDateValue(form.arrival)) { - errors.arrival = form.arrival ? t('roomingList.fieldErrors.dateFormat') : t('roomingList.fieldErrors.arrivalRequired') - } - if (!isValidDateValue(form.departure)) { - errors.departure = form.departure - ? t('roomingList.fieldErrors.dateFormat') - : t('roomingList.fieldErrors.departureRequired') - } - if (isValidDateValue(form.arrival) && isValidDateValue(form.departure) && form.departure <= form.arrival) { - errors.departure = t('roomingList.fieldErrors.departureAfterArrival') - } if (!form.roomType.trim()) { errors.roomType = t('roomingList.fieldErrors.roomTypeRequired') } - if (form.adults && !isNonNegativeInteger(form.adults)) { - errors.adults = t('roomingList.fieldErrors.nonNegativeInteger') + if (!form.paymentType.trim()) { + errors.paymentType = t('roomingList.fieldErrors.paymentTypeRequired') + } else if (!allowedPaymentTypes.has(form.paymentType.trim())) { + errors.paymentType = t('roomingList.fieldErrors.paymentTypeAllowed') } - if (form.children && !isNonNegativeInteger(form.children)) { - errors.children = t('roomingList.fieldErrors.nonNegativeInteger') - } - if (form.email && !isValidEmail(form.email)) { - errors.email = t('roomingList.fieldErrors.invalidEmail') + if (!form.nationality.trim()) { + errors.nationality = t('roomingList.fieldErrors.nationalityRequired') + } else if (!allowedNationalities.has(form.nationality.trim())) { + errors.nationality = t('roomingList.fieldErrors.nationalityAllowed') } return errors } @@ -506,14 +427,6 @@ function validationAria(fieldKey: RoomingListFieldKey): Record guests, + LocalDate arrival, + LocalDate departure) { +} 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 cb58ca2..94a33b4 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 @@ -1,40 +1,18 @@ 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 title 目标 Excel 的 Title - * @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 + * @param paymentType 目标 Excel 的 Payment Type;当前默认和唯一允许值为 CA + * @param nationality 目标 Excel 的 Nationality;当前只允许 KR 或 CHN */ public record ReservationRoomingListGenerationRequest( String hotelId, Integer peoplePerRoom, - LocalDate arrival, - LocalDate departure, String roomType, - String title, - String rateCode, - Integer adults, - Integer children, String paymentType, - String vip, - String nationality, - String email, - String idType, - String idNumber) { + String nationality) { } 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 index ffe6772..b5f54af 100644 --- 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 @@ -6,8 +6,6 @@ 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; @@ -46,19 +44,9 @@ public class ReservationRoomingListGenerationController { @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 = "title", required = false) String title, - @RequestParam(value = "rate_code", required = false) String rateCode, - @RequestParam(value = "adults", required = false) Integer adults, - @RequestParam(value = "children", required = false) Integer children, + @RequestParam(value = "room_type", required = false) String roomType, @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) { + @RequestParam(value = "nationality", required = false) String nationality) { AuthenticatedUserContext actor = authorizationService.requirePermission( PlatformPermissionCode.RESERVATION_ROOMING_LIST_GENERATE.name()); ReservationRoomingListGeneratedFile generatedFile = roomingListGenerationService.generate( @@ -66,19 +54,9 @@ public class ReservationRoomingListGenerationController { new ReservationRoomingListGenerationRequest( hotelId, peoplePerRoom, - arrival, - departure, roomType, - title, - rateCode, - adults, - children, paymentType, - vip, - nationality, - email, - idType, - idNumber), + nationality), actor); return ResponseEntity.ok() .contentType(MediaType.parseMediaType(generatedFile.contentType())) 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 c9cbea7..98d47f9 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 @@ -4,8 +4,8 @@ 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.dto.ReservationRoomingListSourceDataDto; import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest; import cn.nianxx.thhotel.workflows.reservation.service.ReservationRoomingListGenerationService; import java.time.Clock; @@ -27,6 +27,9 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR private static final long MAX_SOURCE_FILE_BYTES = 20L * 1024L * 1024L; private static final DateTimeFormatter FILE_TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private static final String DEFAULT_PAYMENT_TYPE = "CA"; + private static final String NATIONALITY_KR = "KR"; + private static final String NATIONALITY_CHN = "CHN"; private final HotelContextService hotelContextService; private final RoomingListSourceExcelParser sourceExcelParser; @@ -57,12 +60,16 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR AuthenticatedUserContext actor) { ReservationRoomingListGenerationRequest normalizedRequest = normalizeAndValidate(request, sourceFile); String hotelId = resolveAccessibleHotel(normalizedRequest.hotelId()); - List guests = sourceExcelParser.parse(sourceFile); + ReservationRoomingListSourceDataDto sourceData = sourceExcelParser.parse(sourceFile); List rooms = groupingService.group( - guests, + sourceData.guests(), normalizedRequest.peoplePerRoom(), - normalizedRequest.adults()); - byte[] content = excelRenderer.render(rooms, normalizedRequest); + null); + byte[] content = excelRenderer.render( + rooms, + normalizedRequest, + sourceData.arrival(), + sourceData.departure()); return new ReservationRoomingListGeneratedFile( buildFileName(hotelId), EXCEL_CONTENT_TYPE, @@ -89,40 +96,20 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR } 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); + String paymentType = normalizePaymentType(request.paymentType(), errors); + String nationality = normalizeNationality(request.nationality(), errors); if (!errors.isEmpty()) { throw validationError(errors); } return new ReservationRoomingListGenerationRequest( trimToNull(request.hotelId()), request.peoplePerRoom(), - request.arrival(), - request.departure(), trimToEmpty(request.roomType()), - trimToEmpty(request.title()), - 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())); + paymentType, + nationality); } /** @@ -139,10 +126,29 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR } } - private void validateNonNegative(Integer value, String fieldPath, List errors) { - if (value != null && value < 0) { - errors.add(fieldPath + ": 不能小于 0。"); + private String normalizePaymentType(String value, List errors) { + String normalized = trimToNull(value); + if (normalized == null) { + return DEFAULT_PAYMENT_TYPE; } + String code = normalized.toUpperCase(java.util.Locale.ROOT); + if (!DEFAULT_PAYMENT_TYPE.equals(code)) { + errors.add("payment_type: 当前只允许 CA。"); + } + return code; + } + + private String normalizeNationality(String value, List errors) { + String normalized = trimToNull(value); + if (normalized == null) { + errors.add("nationality: 必填字段缺失。"); + return ""; + } + String code = normalized.toUpperCase(java.util.Locale.ROOT); + if (!NATIONALITY_KR.equals(code) && !NATIONALITY_CHN.equals(code)) { + errors.add("nationality: 当前只允许 KR 或 CHN。"); + } + return code; } private ReservationRoomingListGenerationException validationError(List details) { 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 index c69c4a3..b4fa20f 100644 --- 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 @@ -4,6 +4,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingList import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.stream.Collectors; @@ -44,13 +45,17 @@ public class RoomingListExcelRenderer { /** * 将分房结果渲染为 `.xlsx` 字节。 */ - public byte[] render(List rooms, ReservationRoomingListGenerationRequest request) { + public byte[] render( + List rooms, + ReservationRoomingListGenerationRequest request, + LocalDate arrival, + LocalDate departure) { 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); + writeRoomRow(sheet.createRow(room.lineNumber()), room, request, arrival, departure); } for (int index = 0; index < HEADERS.length; index++) { sheet.setColumnWidth(index, defaultColumnWidth(index)); @@ -86,25 +91,27 @@ public class RoomingListExcelRenderer { private void writeRoomRow( Row row, ReservationRoomingListRoomDto room, - ReservationRoomingListGenerationRequest request) { + ReservationRoomingListGenerationRequest request, + LocalDate arrival, + LocalDate departure) { row.createCell(0).setCellValue(room.lineNumber()); row.createCell(1).setCellValue(room.primaryGuest().lastName()); row.createCell(2).setCellValue(room.primaryGuest().firstName()); - row.createCell(3).setCellValue(defaultString(request.title())); - row.createCell(4).setCellValue(request.arrival().format(DateTimeFormatter.ISO_LOCAL_DATE)); - row.createCell(5).setCellValue(request.departure().format(DateTimeFormatter.ISO_LOCAL_DATE)); + row.createCell(3).setCellValue(""); + row.createCell(4).setCellValue(arrival.format(DateTimeFormatter.ISO_LOCAL_DATE)); + row.createCell(5).setCellValue(departure.format(DateTimeFormatter.ISO_LOCAL_DATE)); row.createCell(6).setCellValue(defaultString(request.roomType())); - row.createCell(7).setCellValue(defaultString(request.rateCode())); + row.createCell(7).setCellValue(""); row.createCell(8).setCellValue(1); row.createCell(9).setCellValue(room.adultCount()); - row.createCell(10).setCellValue(request.children() == null ? 0 : request.children()); + row.createCell(10).setCellValue(0); row.createCell(11).setCellValue(defaultString(request.paymentType())); - row.createCell(12).setCellValue(defaultString(request.vip())); + row.createCell(12).setCellValue(""); 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())); + row.createCell(15).setCellValue(""); + row.createCell(16).setCellValue(""); + row.createCell(17).setCellValue(""); } private String accompanyingGuests(ReservationRoomingListRoomDto room) { 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 268de42..2405c7c 100644 --- a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/service/impl/RoomingListSourceExcelParser.java @@ -1,10 +1,16 @@ 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.ReservationRoomingListSourceDataDto; import java.io.IOException; import java.io.InputStream; +import java.time.DateTimeException; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Row; @@ -22,7 +28,12 @@ import org.springframework.web.multipart.MultipartFile; public class RoomingListSourceExcelParser { private static final String PASSPORT_NAME_HEADER = "护照全名"; + private static final String TRAVEL_DATE_HEADER = "旅游日期"; private static final int MAX_HEADER_SCAN_ROWS = 20; + private static final Pattern FULL_DATE_PATTERN = Pattern.compile( + "^(\\d{4})\\s*(?:年|[-/.])\\s*(\\d{1,2})\\s*(?:月|[-/.])\\s*(\\d{1,2})\\s*(?:日)?$"); + private static final Pattern PARTIAL_DATE_PATTERN = Pattern.compile( + "^(?:(\\d{4})\\s*(?:年|[-/.]))?\\s*(?:(\\d{1,2})\\s*(?:月|[-/.]))?\\s*(\\d{1,2})\\s*(?:日)?$"); private final RoomingListNameParser nameParser; @@ -36,7 +47,7 @@ public class RoomingListSourceExcelParser { /** * 解析来源 Excel,返回按原表顺序排列的旅客名单。 */ - public List parse(MultipartFile sourceFile) { + public ReservationRoomingListSourceDataDto parse(MultipartFile sourceFile) { validateFile(sourceFile); DataFormatter formatter = new DataFormatter(); try (InputStream inputStream = sourceFile.getInputStream(); @@ -45,12 +56,16 @@ public class RoomingListSourceExcelParser { 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()) { + HeaderLocation passportNameHeader = findHeader(sheet, formatter, PASSPORT_NAME_HEADER); + HeaderLocation travelDateHeader = findHeader(sheet, formatter, TRAVEL_DATE_HEADER); + SourceRows sourceRows = parseRows(sheet, passportNameHeader, travelDateHeader, formatter); + if (sourceRows.guests().isEmpty()) { throw invalidFile(List.of("护照全名列没有可用旅客姓名。")); } - return guests; + return new ReservationRoomingListSourceDataDto( + sourceRows.guests(), + sourceRows.travelDateRange().arrival(), + sourceRows.travelDateRange().departure()); } catch (ReservationRoomingListGenerationException exception) { throw exception; } catch (IOException | RuntimeException exception) { @@ -73,9 +88,9 @@ public class RoomingListSourceExcelParser { } /** - * 在前若干行内查找 `护照全名` 表头位置。 + * 在前若干行内查找指定表头位置。 */ - private HeaderLocation findPassportNameHeader(Sheet sheet, DataFormatter formatter) { + private HeaderLocation findHeader(Sheet sheet, DataFormatter formatter, String headerText) { int lastRowNum = Math.min(sheet.getLastRowNum(), MAX_HEADER_SCAN_ROWS - 1); for (int rowIndex = 0; rowIndex <= lastRowNum; rowIndex++) { Row row = sheet.getRow(rowIndex); @@ -84,34 +99,119 @@ public class RoomingListSourceExcelParser { } for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) { Cell cell = row.getCell(cellIndex); - if (PASSPORT_NAME_HEADER.equals(cellText(cell, formatter))) { + if (headerText.equals(cellText(cell, formatter))) { return new HeaderLocation(rowIndex, cellIndex); } } } - throw invalidFile(List.of("未找到表头:护照全名")); + throw invalidFile(List.of("未找到表头:" + headerText)); } /** - * 从表头下一行开始提取护照姓名,空白行自动跳过。 + * 从表头下一行开始提取护照姓名和旅游日期,空白姓名行自动跳过。 */ - private List parseGuests( + private SourceRows parseRows( Sheet sheet, - HeaderLocation header, + HeaderLocation passportNameHeader, + HeaderLocation travelDateHeader, DataFormatter formatter) { List guests = new ArrayList<>(); - for (int rowIndex = header.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) { + TravelDateRange expectedRange = null; + int firstDataRowIndex = Math.max(passportNameHeader.rowIndex(), travelDateHeader.rowIndex()) + 1; + for (int rowIndex = firstDataRowIndex; rowIndex <= sheet.getLastRowNum(); rowIndex++) { Row row = sheet.getRow(rowIndex); if (row == null) { continue; } - String passportName = cellText(row.getCell(header.cellIndex()), formatter); + String passportName = cellText(row.getCell(passportNameHeader.cellIndex()), formatter); if (isBlank(passportName)) { continue; } + String travelDateText = cellText(row.getCell(travelDateHeader.cellIndex()), formatter); + TravelDateRange currentRange = parseTravelDateRange(travelDateText); + if (expectedRange == null) { + expectedRange = currentRange; + } else if (!Objects.equals(expectedRange, currentRange)) { + throw invalidFile(List.of("旅游日期: 同一来源文件不能包含多个不同旅游日期区间。")); + } guests.add(nameParser.parse(rowIndex + 1, passportName)); } - return guests; + if (expectedRange == null) { + throw invalidFile(List.of("旅游日期: 没有可用旅游日期。")); + } + return new SourceRows(List.copyOf(guests), expectedRange); + } + + /** + * 解析样表中的旅游日期区间,输出酒店本地入住 / 离店日期。 + */ + private TravelDateRange parseTravelDateRange(String value) { + if (isBlank(value)) { + throw invalidFile(List.of("旅游日期: 不能为空。")); + } + String[] parts = splitTravelDateRange(value.trim()); + if (parts.length != 2) { + throw invalidFile(List.of("旅游日期: 格式无法解析。")); + } + LocalDate arrival = parseDatePart(parts[0], null, null, true); + LocalDate departure = parseDatePart(parts[1], arrival.getYear(), arrival.getMonthValue(), false); + if (!departure.isAfter(arrival)) { + throw invalidFile(List.of("旅游日期: 结束日期必须晚于起始日期。")); + } + return new TravelDateRange(arrival, departure); + } + + private String[] splitTravelDateRange(String value) { + String[] parts = value.split("\\s*(?:-|–|—|至|到|~|~)\\s*", 2); + if (parts.length == 2) { + return parts; + } + parts = value.split("\\s+-\\s+", 2); + if (parts.length == 2) { + return parts; + } + parts = value.split("(?<=日)\\s*-\\s*", 2); + return parts; + } + + private LocalDate parseDatePart( + String value, + Integer defaultYear, + Integer defaultMonth, + boolean requireFullDate) { + String normalized = value.trim(); + Matcher fullMatcher = FULL_DATE_PATTERN.matcher(normalized); + if (fullMatcher.matches()) { + return localDate(fullMatcher.group(1), fullMatcher.group(2), fullMatcher.group(3)); + } + Matcher partialMatcher = PARTIAL_DATE_PATTERN.matcher(normalized); + if (!partialMatcher.matches()) { + throw invalidFile(List.of("旅游日期: 格式无法解析。")); + } + String yearText = partialMatcher.group(1); + String monthText = partialMatcher.group(2); + String dayText = partialMatcher.group(3); + if (requireFullDate && (isBlank(yearText) || isBlank(monthText))) { + throw invalidFile(List.of("旅游日期: 格式无法解析。")); + } + Integer year = yearText == null ? defaultYear : Integer.valueOf(yearText); + Integer month = monthText == null ? defaultMonth : Integer.valueOf(monthText); + if (year == null || month == null) { + throw invalidFile(List.of("旅游日期: 格式无法解析。")); + } + return localDate(year, month, Integer.parseInt(dayText)); + } + + private LocalDate localDate(String yearText, String monthText, String dayText) { + return localDate(Integer.parseInt(yearText), Integer.parseInt(monthText), Integer.parseInt(dayText)); + } + + private LocalDate localDate(int year, int month, int day) { + try { + return LocalDate.of(year, month, day); + } catch (DateTimeException exception) { + throw invalidFile(List.of("旅游日期: 格式无法解析。")); + } } private String cellText(Cell cell, DataFormatter formatter) { @@ -141,4 +241,22 @@ public class RoomingListSourceExcelParser { */ private record HeaderLocation(int rowIndex, int cellIndex) { } + + /** + * 来源名单行和统一旅游日期区间。 + * + * @param guests 来源旅客名单 + * @param travelDateRange 统一旅游日期区间 + */ + private record SourceRows(List guests, TravelDateRange travelDateRange) { + } + + /** + * 旅游日期派生出的酒店本地业务日期区间。 + * + * @param arrival 入住日期 + * @param departure 离店日期 + */ + private record TravelDateRange(LocalDate arrival, LocalDate departure) { + } } 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 73f5eba..033cfef 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 @@ -88,13 +88,9 @@ class ReservationRoomingListGenerationControllerTest { .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("title", "MR") - .param("rate_code", "RACK") .param("payment_type", "CA") - .param("nationality", "CN")) + .param("nationality", "CHN")) .andExpect(status().isOk()) .andExpect(content().contentType( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) @@ -102,8 +98,20 @@ class ReservationRoomingListGenerationControllerTest { .andExpect(header().string("Content-Disposition", containsString("rooming-list-HOTEL-TEST-"))) .andExpect(result -> assertThat(result.getResponse().getContentAsByteArray()) .startsWith(new byte[]{0x50, 0x4B})) - .andExpect(result -> assertThat(titleCell(result.getResponse().getContentAsByteArray())) - .isEqualTo("MR")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 3)) + .isEmpty()) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 4)) + .isEqualTo("2026-05-09")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 5)) + .isEqualTo("2026-05-14")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 9)) + .isEqualTo("2")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 10)) + .isEqualTo("0")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 11)) + .isEqualTo("CA")) + .andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 14)) + .isEqualTo("CHN")) .andExpect(content().string(not(containsString("P123456")))); } @@ -113,8 +121,6 @@ class ReservationRoomingListGenerationControllerTest { .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")); @@ -125,9 +131,7 @@ class ReservationRoomingListGenerationControllerTest { 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("people_per_room", "2")) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED")); } @@ -140,8 +144,6 @@ class ReservationRoomingListGenerationControllerTest { .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")); @@ -155,9 +157,9 @@ class ReservationRoomingListGenerationControllerTest { .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")) + .param("room_type", "UG1") + .param("payment_type", "CA") + .param("nationality", "CHN")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED")); } @@ -169,28 +171,26 @@ class ReservationRoomingListGenerationControllerTest { 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("people_per_room", "2")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED")) .andExpect(jsonPath("$.details[0]").value("room_type: 必填字段缺失。")); } @Test - void shouldRejectRoomingListGenerationWhenDateFormatInvalid() throws Exception { + void shouldRejectRoomingListGenerationWhenTravelDateFormatInvalid() throws Exception { String token = loginToken(mockMvc, "rooming-admin", "Admin@123456"); performAuthorized(mockMvc, token, multipart(ENDPOINT) - .file(sourceFile()) + .file(sourceFileWithInvalidTravelDate()) .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("room_type", "UG1") + .param("payment_type", "CA") + .param("nationality", "CHN")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED")) - .andExpect(jsonPath("$.details[0]").value("arrival: 参数格式不合法。")); + .andExpect(jsonPath("$.error_code").value("ROOMING_LIST_SOURCE_FILE_INVALID")) + .andExpect(jsonPath("$.details[0]").value("旅游日期: 格式无法解析。")); } private MockMultipartFile sourceFile() throws Exception { @@ -198,14 +198,17 @@ class ReservationRoomingListGenerationControllerTest { ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { var sheet = workbook.createSheet("Sheet1"); Row headerRow = sheet.createRow(0); - headerRow.createCell(0).setCellValue("姓名"); - headerRow.createCell(1).setCellValue("护照全名"); + headerRow.createCell(0).setCellValue("旅游日期"); + headerRow.createCell(1).setCellValue("姓名"); + headerRow.createCell(2).setCellValue("护照全名"); Row row1 = sheet.createRow(1); - row1.createCell(0).setCellValue("游客1"); - row1.createCell(1).setCellValue("LI/CHUNHONG"); + row1.createCell(0).setCellValue("2026年5月9日-5月14日"); + row1.createCell(1).setCellValue("游客1"); + row1.createCell(2).setCellValue("LI/CHUNHONG"); Row row2 = sheet.createRow(2); - row2.createCell(0).setCellValue("游客2"); - row2.createCell(1).setCellValue("ZHANG GAILI"); + row2.createCell(0).setCellValue("2026年5月9日-5月14日"); + row2.createCell(1).setCellValue("游客2"); + row2.createCell(2).setCellValue("ZHANG GAILI"); workbook.write(outputStream); return new MockMultipartFile( "file", @@ -215,9 +218,42 @@ class ReservationRoomingListGenerationControllerTest { } } - private String titleCell(byte[] workbookBytes) throws Exception { + private MockMultipartFile sourceFileWithInvalidTravelDate() 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("五月九日到五月十四日"); + row1.createCell(1).setCellValue("LI/CHUNHONG"); + workbook.write(outputStream); + return new MockMultipartFile( + "file", + "LLT260509FA203.xlsx", + MediaType.APPLICATION_OCTET_STREAM_VALUE, + outputStream.toByteArray()); + } + } + + private String workbookCell(byte[] workbookBytes, int rowIndex, int cellIndex) throws Exception { try (XSSFWorkbook workbook = new XSSFWorkbook(new java.io.ByteArrayInputStream(workbookBytes))) { - return workbook.getSheetAt(0).getRow(1).getCell(3).getStringCellValue(); + var cell = workbook.getSheetAt(0).getRow(rowIndex).getCell(cellIndex); + if (cell == null) { + return ""; + } + return switch (cell.getCellType()) { + case NUMERIC -> { + double value = cell.getNumericCellValue(); + if (value == Math.rint(value)) { + yield String.valueOf((long) value); + } + yield String.valueOf(value); + } + case STRING -> cell.getStringCellValue(); + default -> ""; + }; } } } 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 c4ce625..cbdcb79 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 @@ -10,7 +10,6 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingList 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; @@ -60,12 +59,20 @@ class ReservationRoomingListGenerationServiceImplTest { 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(3))).isEqualTo("MR"); - assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-07-29"); + assertThat(cellText(firstRoom.getCell(3))).isEmpty(); + assertThat(cellText(firstRoom.getCell(4))).isEqualTo("2026-05-09"); + assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-05-14"); assertThat(cellText(firstRoom.getCell(6))).isEqualTo("UG1"); + assertThat(cellText(firstRoom.getCell(7))).isEmpty(); assertThat(cellText(firstRoom.getCell(8))).isEqualTo("1"); assertThat(cellText(firstRoom.getCell(9))).isEqualTo("3"); + assertThat(cellText(firstRoom.getCell(10))).isEqualTo("0"); + assertThat(cellText(firstRoom.getCell(11))).isEqualTo("CA"); assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG GAILI, LI HONG"); + assertThat(cellText(firstRoom.getCell(14))).isEqualTo("CHN"); + assertThat(cellText(firstRoom.getCell(15))).isEmpty(); + assertThat(cellText(firstRoom.getCell(16))).isEmpty(); + assertThat(cellText(firstRoom.getCell(17))).isEmpty(); Row secondRoom = workbook.getSheetAt(0).getRow(2); assertThat(cellText(secondRoom.getCell(1))).isEqualTo("LIU"); @@ -81,6 +88,22 @@ class ReservationRoomingListGenerationServiceImplTest { } } + @Test + void shouldParseTravelDateWithNumericHyphenRange() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + ReservationRoomingListGeneratedFile generatedFile = service.generate( + sourceFileWithTravelDate("2026-05-09 - 05-14", List.of("LI/CHUNHONG")), + request(1), + actor()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) { + Row firstRoom = workbook.getSheetAt(0).getRow(1); + assertThat(cellText(firstRoom.getCell(4))).isEqualTo("2026-05-09"); + assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-05-14"); + } + } + @Test void shouldRejectSourceWorkbookWithoutPassportNameHeader() throws Exception { when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); @@ -93,6 +116,49 @@ class ReservationRoomingListGenerationServiceImplTest { assertThat(exception.getDetails()).contains("未找到表头:护照全名"); } + @Test + void shouldRejectSourceWorkbookWithoutTravelDateHeader() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + ReservationRoomingListGenerationException exception = catchThrowableOfType( + () -> service.generate(sourceFileWithoutTravelDateHeader(), request(2), actor()), + ReservationRoomingListGenerationException.class); + + assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID"); + assertThat(exception.getDetails()).contains("未找到表头:旅游日期"); + } + + @Test + void shouldRejectSourceWorkbookWhenTravelDateRangesDiffer() throws Exception { + when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); + + ReservationRoomingListGenerationException exception = catchThrowableOfType( + () -> service.generate(sourceFileWithMultipleTravelDates(), request(2), actor()), + ReservationRoomingListGenerationException.class); + + assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID"); + assertThat(exception.getDetails()).contains("旅游日期: 同一来源文件不能包含多个不同旅游日期区间。"); + } + + @Test + void shouldRejectInvalidRoomingListDefaults() throws Exception { + ReservationRoomingListGenerationException exception = catchThrowableOfType( + () -> service.generate( + sourceFile(passportNames32()), + new ReservationRoomingListGenerationRequest( + "HOTEL-TEST", + 2, + "UG1", + "BT", + "TH"), + actor()), + ReservationRoomingListGenerationException.class); + + assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED"); + assertThat(exception.getDetails()) + .contains("payment_type: 当前只允许 CA。", "nationality: 当前只允许 KR 或 CHN。"); + } + @Test void shouldRejectNonExcelSourceFile() { when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST"); @@ -119,19 +185,9 @@ class ReservationRoomingListGenerationServiceImplTest { return new ReservationRoomingListGenerationRequest( "HOTEL-TEST", peoplePerRoom, - LocalDate.of(2026, 7, 26), - LocalDate.of(2026, 7, 29), "UG1", - "MR", - "RACK", null, - 0, - "CA", - "", - "CN", - "", - "", - ""); + "CHN"); } private AuthenticatedUserContext actor() { @@ -146,18 +202,24 @@ class ReservationRoomingListGenerationServiceImplTest { } private MockMultipartFile sourceFile(List passportNames) throws Exception { + return sourceFileWithTravelDate("2026年5月9日-5月14日", passportNames); + } + + private MockMultipartFile sourceFileWithTravelDate(String travelDate, 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("护照全名"); + headerRow.createCell(0).setCellValue("旅游日期"); + headerRow.createCell(1).setCellValue("姓名"); + headerRow.createCell(2).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)); + row.createCell(0).setCellValue(travelDate); + row.createCell(1).setCellValue("游客" + (index + 1)); + row.createCell(2).setCellValue(passportNames.get(index)); } workbook.write(outputStream); return new MockMultipartFile( @@ -169,15 +231,58 @@ class ReservationRoomingListGenerationServiceImplTest { } 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("姓名"); + headerRow.createCell(2).setCellValue("证件号"); + Row row = sheet.createRow(1); + row.createCell(0).setCellValue("2026年5月9日-5月14日"); + row.createCell(1).setCellValue("游客1"); + row.createCell(2).setCellValue("P123456"); + workbook.write(outputStream); + return new MockMultipartFile( + "file", + "invalid.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + outputStream.toByteArray()); + } + } + + private MockMultipartFile sourceFileWithoutTravelDateHeader() 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("证件号"); + headerRow.createCell(1).setCellValue("护照全名"); Row row = sheet.createRow(1); row.createCell(0).setCellValue("游客1"); - row.createCell(1).setCellValue("P123456"); + row.createCell(1).setCellValue("LI/CHUNHONG"); + workbook.write(outputStream); + return new MockMultipartFile( + "file", + "invalid.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + outputStream.toByteArray()); + } + } + + private MockMultipartFile sourceFileWithMultipleTravelDates() 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("2026年5月9日-5月14日"); + row1.createCell(1).setCellValue("LI/CHUNHONG"); + Row row2 = sheet.createRow(2); + row2.createCell(0).setCellValue("2026年5月10日-5月15日"); + row2.createCell(1).setCellValue("ZHANG/GAILI"); workbook.write(outputStream); return new MockMultipartFile( "file",