实现房表生成第二种名单样式

This commit is contained in:
andy
2026-07-24 11:55:29 +07:00
parent 14eda89b0b
commit eaea8787d9
22 changed files with 645 additions and 64 deletions

View File

@@ -50,7 +50,7 @@ TH Hotel Simple 是一个前后端分离的酒店业务协同项目。
- Room Type Catalog预订业务可选房型代码目录第一阶段已确认稳定业务集合为 `RM2``RM3``RM4``SU1``SU2``SU3`,暂不按 Account 限制可用房型。
- Rate Code ApplicabilityRate Code 的业务适用范围;第一阶段暂不按 Reservation Account 或 booking type 限制候选Rate Code 先作为当前酒店级目录校验。未来如需求方明确 Account 专属候选,再单独建立适用关系。
- Rooming List Task CardV4 订单任务中的 Rooming List 事项确认卡,表示当前来源消息包含需要人工处理的房表事项;它不同于独立的 Rooming List Excel 生成工具。
- Rooming List Excel Generation独立的 Reservation 运营工具,用于把旅行团名单 Excel 转成酒店 / PMS 可导入的 Rooming List ExcelCP2 已实现从来源名单 `旅游日期` 派生入住 / 离店日期,由系统按分房结果计算成人数,前端目标默认值区域只保留付款方式和国籍。
- Rooming List Excel Generation独立的 Reservation 运营工具,用于把旅行团名单 Excel 转成酒店 / PMS 可导入的 Rooming List ExcelCP2 已实现从来源名单 `旅游日期` 派生入住 / 离店日期,由系统按分房结果计算成人数,前端目标默认值区域只保留付款方式和国籍CP3 已实现第二种 `英文姓` + `英文名` 名单样式,并由用户补充入住 / 离店日期
- Payment Attachment PreviewPayment 卡中的付款凭证附件展示能力;业务事实仍是 `attachment_ids[]` 关联,图片可缩略图和大图预览,非图片统一文件列表和下载,附件外链必须走 SourceMessage 原文权限链路。
- Source Message NotificationM002 V4 的 S10 纯通知模型;只表示来源邮件需要被查看和确认已处理,不形成订单任务或业务卡。
- Identity / Access / Hotel / Menu登录、用户、角色、权限、酒店授权和动态菜单底座。

View File

@@ -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 / DepartureAdults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / NationalityPayment Type 默认 `BTQR` 且当前允许 `BTQR` / `CA`Nationality 只允许 `KR` / `CHN`
- M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单并下载后端同步生成的 `.xlsx`,第一版不落库、不上传 OSS。CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / DepartureAdults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / NationalityPayment Type 默认 `BTQR` 且当前允许 `BTQR` / `CA`Nationality 只允许 `KR` / `CHN`CP3 已实现:兼容第二种 `英文姓` + `英文名` 名单样式,用户补充 Arrival / Departure`23+1` 中领队也进入房表。
- M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSONDebug 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 来源通知 ackM002 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 被拒也会入本诊断表但不会进入业务写入 ServiceV4 `source_message.conversation_id` 可缺省;该诊断表不作为业务事实来源,不进入普通前端接口。
- 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec再实现代码V4 相关需求必须按 `docs/project/ai-nses-project-overlay.md` 补核心概念守门、需求追踪表和 agent 交接边界。
- 单卡可操作态测试数据已回填;后续演示或回归如果需要重新造数,应继续使用 fresh runId避免复用旧 SourceMessage 时间线造成阻塞误判。
- M010 CP2 字段收口已完成预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。
- M010 CP3 第二种名单样式兼容已完成预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。
- M011 CP4 暂不推进;当前停留在 CP3 边界,只增强 SuperAgent 输入,不直接落订单、任务或长期解析历史。后续如确实需要运营查询或长期追踪,再单独设计 Excel 解析批次 / 行级持久化表。
## 6. 文档同步提醒

View File

@@ -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. Arrival and departure are derived from the source travel dates.',
'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.',
currentHotel: 'Current hotel',
sourceSection: 'Source list and required fields',
targetSection: 'Target Excel defaults',
@@ -412,6 +412,7 @@ export default {
peoplePerRoom: 'People per room',
arrival: 'Arrival',
departure: 'Departure',
datePlaceholder: 'yyyy-MM-dd',
roomType: 'Room type',
roomTypePlaceholder: 'e.g. DLX / TWN',
titleColumn: 'Title',

View File

@@ -402,7 +402,7 @@ export default {
eyebrow: 'เครื่องมือ Reservation',
title: 'สร้าง Rooming List Excel',
subtitle:
'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality โดย Arrival/Departure จะมาจาก Travel Date ในไฟล์ต้นทาง',
'อัปโหลดรายชื่อกรุ๊ป กรอกจำนวนคนต่อห้อง Room Type, Payment Type และ Nationality ไฟล์ที่มี Travel Date จะสร้างวันที่ให้อัตโนมัติ ส่วนไฟล์ English name ต้องกรอกวันที่เอง',
currentHotel: 'โรงแรมปัจจุบัน',
sourceSection: 'ไฟล์รายชื่อและข้อมูลจำเป็น',
targetSection: 'ค่าเริ่มต้นของ Excel ปลายทาง',
@@ -412,6 +412,7 @@ export default {
peoplePerRoom: 'จำนวนคนต่อห้อง',
arrival: 'Arrival',
departure: 'Departure',
datePlaceholder: 'yyyy-MM-dd',
roomType: 'Room Type',
roomTypePlaceholder: 'เช่น DLX / TWN',
titleColumn: 'Title',

View File

@@ -401,7 +401,7 @@ export default {
roomingList: {
eyebrow: '预订工具',
title: '生成 Rooming List Excel',
subtitle: '上传旅行团名单 Excel填写每房人数、房型、付款方式和国籍入住/离店日期由来源表的旅游日期生成。',
subtitle: '上传旅行团名单 Excel填写每房人数、房型、付款方式和国籍有旅游日期的名单自动生成入住/离店日期,英文姓/英文名名单需手填日期。',
currentHotel: '当前酒店',
sourceSection: '来源名单与必填信息',
targetSection: '目标 Excel 默认值',
@@ -411,6 +411,7 @@ export default {
peoplePerRoom: '每间房人数',
arrival: '入住日期',
departure: '离店日期',
datePlaceholder: 'yyyy-MM-dd',
roomType: '房型',
roomTypePlaceholder: '例如 DLX / TWN',
titleColumn: '称谓Title',

View File

@@ -72,6 +72,8 @@ function buildRoomingListForm(input: RoomingListGenerationInput): FormData {
form.append('file', input.file)
appendOptionalText(form, 'hotel_id', input.hotelId)
form.append('people_per_room', String(input.peoplePerRoom))
appendOptionalText(form, 'arrival', input.arrival)
appendOptionalText(form, 'departure', input.departure)
form.append('room_type', input.roomType)
appendOptionalText(form, 'payment_type', input.paymentType)
appendOptionalText(form, 'nationality', input.nationality)

View File

@@ -15,6 +15,8 @@ function createInput(): RoomingListGenerationInput {
}),
hotelId: 'HOTEL-TEST',
peoplePerRoom: 2,
arrival: '2026-05-10',
departure: '2026-05-16',
roomType: 'DLX',
paymentType: 'CA',
nationality: 'CHN',
@@ -74,11 +76,11 @@ 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-05-10')
expect(formData.get('departure')).toBe('2026-05-16')
expect(formData.get('room_type')).toBe('DLX')
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()

View File

@@ -1,5 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -62,7 +63,7 @@ function mountView(options: { permissions?: string[] } = {}) {
return mount(ReservationRoomingListGenerationView, {
global: {
plugins: [i18n, pinia],
plugins: [i18n, pinia, PrimeVue],
},
})
}
@@ -91,6 +92,20 @@ async function fillRequiredFields(wrapper: ReturnType<typeof mountView>) {
describe('ReservationRoomingListGenerationView', () => {
beforeEach(() => {
vi.mocked(generateReservationRoomingListExcel).mockReset()
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn(() => 'blob:rooming-list'),
@@ -129,17 +144,19 @@ describe('ReservationRoomingListGenerationView', () => {
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请先修正表单')
expect(wrapper.text()).toContain('表单校验未通过,请检查必填字段。')
expect(wrapper.text()).toContain('请选择来源 Excel 文件。')
expect(wrapper.text()).toContain('每间房人数必须大于 0。')
expect(wrapper.text()).toContain('请填写房型。')
expect(wrapper.text()).toContain('请选择国籍。')
})
it('does not render manually entered dates or removed target defaults', () => {
it('renders optional manual stay dates but keeps removed target defaults hidden', () => {
const wrapper = mountView()
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-arrival"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="rooming-list-departure"]').exists()).toBe(true)
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)
@@ -167,6 +184,8 @@ describe('ReservationRoomingListGenerationView', () => {
file: expect.any(File),
hotelId: 'HOTEL-TEST',
peoplePerRoom: 2,
arrival: '',
departure: '',
roomType: 'DLX',
paymentType: 'BTQR',
nationality: 'CHN',
@@ -197,6 +216,49 @@ describe('ReservationRoomingListGenerationView', () => {
)
})
it('submits optional manual stay dates when they are provided', async () => {
vi.mocked(generateReservationRoomingListExcel).mockResolvedValue({
blob: new Blob(['xlsx-binary'], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}),
fileName: 'rooming-list-HOTEL-TEST.xlsx',
})
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-05-10')
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-05-16')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).toHaveBeenCalledWith(
expect.objectContaining({
arrival: '2026-05-10',
departure: '2026-05-16',
}),
)
})
it('blocks submit when optional manual stay dates are incomplete or out of order', async () => {
const wrapper = mountView()
await fillRequiredFields(wrapper)
await wrapper.find('[data-testid="rooming-list-arrival"]').setValue('2026-05-16')
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('2026-05-10')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('离店日期必须晚于入住日期。')
await wrapper.find('[data-testid="rooming-list-departure"]').setValue('')
await wrapper.find('[data-testid="rooming-list-submit"]').trigger('click')
await flushPromises()
expect(generateReservationRoomingListExcel).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请填写离店日期。')
})
it('does not submit the same source file again while generation is pending', async () => {
let resolveGeneration!: (value: { blob: Blob; fileName: string }) => void
vi.mocked(generateReservationRoomingListExcel).mockImplementation(

View File

@@ -12,6 +12,8 @@ export interface RoomingListGenerationInput {
file: File
hotelId?: string | null
peoplePerRoom: number
arrival?: string | null
departure?: string | null
roomType: string
paymentType?: string | null
nationality: string

View File

@@ -116,6 +116,56 @@
{{ fieldError('roomType') }}
</span>
</label>
<div class="field">
<label
for="rooming-list-arrival-input"
class="field-label"
>
{{ t('roomingList.arrival') }}
</label>
<LocalizedDatePicker
v-model="form.arrival"
input-id="rooming-list-arrival-input"
test-id="rooming-list-arrival"
:placeholder="t('roomingList.datePlaceholder')"
:input-class="validationClass('arrival')"
:invalid="Boolean(fieldError('arrival'))"
:described-by="fieldError('arrival') ? fieldErrorId('arrival') : undefined"
/>
<span
v-if="fieldError('arrival')"
:id="fieldErrorId('arrival')"
class="field-error"
>
{{ fieldError('arrival') }}
</span>
</div>
<div class="field">
<label
for="rooming-list-departure-input"
class="field-label"
>
{{ t('roomingList.departure') }}
</label>
<LocalizedDatePicker
v-model="form.departure"
input-id="rooming-list-departure-input"
test-id="rooming-list-departure"
:placeholder="t('roomingList.datePlaceholder')"
:input-class="validationClass('departure')"
:invalid="Boolean(fieldError('departure'))"
:described-by="fieldError('departure') ? fieldErrorId('departure') : undefined"
/>
<span
v-if="fieldError('departure')"
:id="fieldErrorId('departure')"
class="field-error"
>
{{ fieldError('departure') }}
</span>
</div>
</div>
</section>
@@ -252,6 +302,7 @@
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import LocalizedDatePicker from '@/components/common/LocalizedDatePicker.vue'
import {
generateReservationRoomingListExcel,
RoomingListGenerationError,
@@ -264,6 +315,8 @@ type AlertTone = 'success' | 'error'
type RoomingListFieldKey =
| 'file'
| 'peoplePerRoom'
| 'arrival'
| 'departure'
| 'roomType'
| 'paymentType'
| 'nationality'
@@ -277,6 +330,8 @@ const authStore = useAuthStore()
const form = reactive({
file: null as File | null,
peoplePerRoom: '',
arrival: '',
departure: '',
roomType: '',
paymentType: 'BTQR',
nationality: '',
@@ -340,6 +395,8 @@ function buildGenerationInput(file: File): RoomingListGenerationInput {
file,
hotelId: authStore.selectedHotelId,
peoplePerRoom: Number(form.peoplePerRoom),
arrival: form.arrival,
departure: form.departure,
roomType: form.roomType,
paymentType: form.paymentType,
nationality: form.nationality,
@@ -387,6 +444,21 @@ function collectFieldErrors(): FieldErrors {
if (!isPositiveInteger(form.peoplePerRoom)) {
errors.peoplePerRoom = t('roomingList.fieldErrors.positiveInteger')
}
const arrival = form.arrival.trim()
const departure = form.departure.trim()
if (arrival && !isDateInputValue(arrival)) {
errors.arrival = t('roomingList.fieldErrors.dateFormat')
}
if (departure && !isDateInputValue(departure)) {
errors.departure = t('roomingList.fieldErrors.dateFormat')
}
if (arrival && !departure) {
errors.departure = t('roomingList.fieldErrors.departureRequired')
} else if (!arrival && departure) {
errors.arrival = t('roomingList.fieldErrors.arrivalRequired')
} else if (arrival && departure && isDateInputValue(arrival) && isDateInputValue(departure) && departure <= arrival) {
errors.departure = t('roomingList.fieldErrors.departureAfterArrival')
}
if (!form.roomType.trim()) {
errors.roomType = t('roomingList.fieldErrors.roomTypeRequired')
}
@@ -438,6 +510,14 @@ function isPositiveInteger(value: unknown): boolean {
return /^[1-9]\d*$/.test(normalizeInputText(value))
}
function isDateInputValue(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false
}
const date = new Date(`${value}T00:00:00Z`)
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
}
function normalizeInputText(value: unknown): string {
if (typeof value === 'string') {
return value.trim()

View File

@@ -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` 直接下载,不落库、不上传 OSSCP2 已实现来源 `旅游日期` 派生 Arrival / Departure、Adults 系统计算,以及目标默认值区域只保留 Payment Type / Nationality。 |
| `requirements/M010-rooming-list-excel-generation-v1.md` | 当前有效 | M010 Rooming List Excel 生成方案;后端 CP1 已支持前端上传来源名单和手工字段,同步生成 `.xlsx` 直接下载,不落库、不上传 OSSCP2 已实现来源 `旅游日期` 派生 Arrival / Departure、Adults 系统计算,以及目标默认值区域只保留 Payment Type / NationalityCP3 已实现第二种 `英文姓` + `英文名` 名单样式,并由用户补充 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 暂不推进。 |
## 集成契约

View File

@@ -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 / DepartureAdults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality前端 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 / DepartureAdults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / NationalityCP3 已实现:兼容 `英文姓` + `英文名` 第二种名单样式,并由用户补充 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。
- 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 两个日期输入;第一种 `旅游日期` 样式可不填,第二种样式必须由用户填写。
- Booking Excel 附件预处理已按 M011 落地 CP1/CP2/CP3Debug 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[]` 白名单内业务字段。

View File

@@ -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 / DepartureAdults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality。 |
| `POST /api/reservation/rooming-lists/generations` | 生成 Rooming List Excel | 必须带 Bearer token需要 `RESERVATION_ROOMING_LIST_GENERATE`;请求为 multipart/form-data成功后直接返回 `.xlsx` 文件流,前端按 Blob 下载处理。CP2 已实现:后端从来源 Excel `旅游日期` 派生 Arrival / DepartureAdults 按分房结果计算,目标默认值区域只保留 Payment Type / Nationality。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源样式,并在该样式下使用前端提交的 `arrival` / `departure` |
### 5.1 本轮新增 / 修改接口说明
@@ -463,7 +463,7 @@ M009 后端 CP2 已实现:页面可不依赖订单或任务,用户手工填
### 5.12 Rooming List Excel 生成页面接入方向
M010 后端 CP1 已实现。第一版生成结果直接下载 `.xlsx`,不落库、不上传 OSS不依赖订单或任务。CP2 已实现,前后端按本节新口径接入。
M010 后端 CP1 已实现。第一版生成结果直接下载 `.xlsx`,不落库、不上传 OSS不依赖订单或任务。CP2 已实现,前后端按本节新口径接入。CP3 已实现:兼容 `英文姓` + `英文名` 第二种来源名单样式,并由用户补充入住 / 离店日期。
接口:
@@ -483,19 +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`
- 该页面上传来源名单 ExcelCP1 后端默认识别 `护照全名` CP2 还需要识别 `旅游日期`
- 姓名支持 `LI/CHUNHONG``LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`
- 该页面上传来源名单 Excel第一种来源样式后端识别 `护照全名` `旅游日期`,第二种来源样式后端识别 `英文姓``英文名`
- 第一种来源样式姓名支持 `LI/CHUNHONG``LI CHUNHONG` 两类格式;后端会把第一段写入目标 `Name`,剩余部分写入目标 `First Name`第二种来源样式直接使用 `英文姓` 写入 `Name`,使用 `英文名` 写入 `First Name`
- 前端必须让用户输入 `people_per_room` 和目标 `Room Type`,后端按名单顺序分组,并用 `ceil(total_people / people_per_room)` 生成房间行。
- 每组第一位旅客写入 `Name` / `First Name`,同组剩余旅客写入 `Accompanying Guests`,多人用英文逗号分隔。
- CP2 起前端不再展示或提交 `Arrival` / `Departure`,后端从来源 Excel 的 `旅游日期` 派生。样例 `2026年5月9日5月14日` 应生成 `Arrival=2026/05/09``Departure=2026/05/14`
- CP3 起前端恢复 `Arrival` / `Departure` 日期输入:第一种来源样式可不填,后端优先从来源 Excel 的 `旅游日期` 派生;第二种来源样式必须填写,后端使用用户提交日期写入目标 Excel。样例 `2026年5月9日5月14日` 应生成 `Arrival=2026/05/09``Departure=2026/05/14`
- 第二种来源样式中领队也进入房表;例如 `23+1` 表示 23 位客人 + 1 位领队全部参与分房,实际人数以后端读取到的有效名单行数为准。
- CP2 起前端不再展示或提交 `Adults`,后端按每个分房行的实际人数派生;尾房不足满房人数时按尾房实际人数写入。`Children` 第一版固定为 `0`
- CP2 起目标默认值区域只保留 `Payment Type``Nationality``Payment Type` 默认 `BTQR`,当前允许 `BTQR``CA``Nationality` 只允许 `KR``CHN`
- 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` 数字格式错误、`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、历史下载和订单 / 任务预填;前端不要在页面上承诺这些能力。

View File

@@ -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 自动计算和目标默认值字段收口 |
| Done | Rooming List Excel 生成接口 `POST /api/reservation/rooming-lists/generations` | 房表生成页面 | CP1 已完成CP2 已实现旅游日期派生、Adults 自动计算和目标默认值字段收口CP3 已实现第二种 `英文姓` + `英文名` 名单样式和用户补 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 契约
## 13. Rooming List Excel 生成接口 CP2 / CP3 契约
M010 Rooming List Excel 生成接口 CP1 已实现,前端 V1 已接入 `/reservation/rooming-lists/new`。CP2 不新增路径,继续收口同一个正式业务接口。
M010 Rooming List Excel 生成接口 CP1 已实现,前端 V1 已接入 `/reservation/rooming-lists/new`。CP2 不新增路径,继续收口同一个正式业务接口。CP3 已实现兼容第二种来源名单样式,仍复用本接口。
路径:
@@ -1202,14 +1202,14 @@ CP2 前端提交字段:
| `hotel_id` | 否 | 当前选择酒店;不传时由后端按登录用户酒店上下文解析。 |
| `people_per_room` | 是 | 每间房人数,正整数。 |
| `room_type` | 是 | 目标 Excel 的 Room Type。 |
| `arrival` | 条件必填 | CP3 第二种来源名单样式必填,格式 `yyyy-MM-dd`;第一种 `旅游日期` 样式可不填。 |
| `departure` | 条件必填 | CP3 第二种来源名单样式必填,格式 `yyyy-MM-dd`;必须晚于 `arrival`。 |
| `payment_type` | 否 | 目标 Excel 的 Payment Type默认 `BTQR`,当前允许 `BTQR``CA`。 |
| `nationality` | 是 | 目标 Excel 的 Nationality只允许 `KR``CHN`。 |
CP2 前端不提交:
CP3 前端不提交:
```text
arrival
departure
title
rate_code
adults
@@ -1222,19 +1222,25 @@ id_number
后端派生规则:
- 从来源 Excel `旅游日期` 列解析入住和离店日期;样例 `2026年5月9日5月14日` 派生 `Arrival=2026/05/09``Departure=2026/05/14`
- 第一种来源样式:从来源 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 位领队全部参与分房,实际人数以后端读取到的有效名单行数为准。
- `Adults` 按当前分房行实际人数计算;尾房人数不足时按实际人数写入。
- `Children` 固定为 `0`
- `Title``Rate Code``VIP``Email``ID Type``ID Number` 第一版固定为空。
- 如果来源 Excel 缺少 `旅游日期`、日期格式无法解析、结束日期不晚于起始日期,或同一文件出现多个不同旅游日期区间,后端返回受控错误,不生成 Excel。
- 如果来源 Excel 既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名`,后端返回受控错误,不生成 Excel。
- 第一种来源样式缺少 `旅游日期`、日期格式无法解析、结束日期不晚于起始日期,或同一文件出现多个不同旅游日期区间,后端返回受控错误,不生成 Excel。
- 第二种来源样式缺少 `arrival` / `departure`、日期格式无法解析,或 `departure` 不晚于 `arrival`,后端返回受控错误,不生成 Excel。
前端诉求:
- 页面第一部分保留上传来源 Excel、每房人数、Room Type。
- 页面第一部分保留上传来源 Excel、每房人数、Room Type,并恢复 `Arrival` / `Departure` 日期输入
- `Arrival` / `Departure` 在第一种来源样式下可不填;当用户上传第二种没有 `旅游日期` 的名单时必须填写。页面文案建议说明“来源名单没有旅游日期时请填写入住 / 离店日期”。
- 目标默认值区域只保留 Payment Type 和 Nationality 两个下拉。
- Payment Type 默认选中 `BTQR`,当前提供 `BTQR``CA` 两个选项。
- Nationality 只提供 `KR``CHN`
- 前端不读取完整 Excel 作为权威解析结果;旅游日期和成人数以后端生成结果为准。
- 前端不读取完整 Excel 作为权威解析结果;来源样式识别、旅客名单和成人数以后端生成结果为准。
- 页面不要把上传文件内容、客人名单、生成文件内容写入浏览器日志、埋点、错误上报、URL 或 localStorage。
## 14. 已确认后置接口

View File

@@ -2,9 +2,9 @@
| 项目 | 内容 |
| --- | --- |
| 文档状态 | 后端 CP1 已实现CP2 已实现旅游日期派生、Adults 系统计算和目标默认值字段收口 |
| 文档状态 | 后端 CP1 已实现CP2 已实现旅游日期派生、Adults 系统计算和目标默认值字段收口CP3 第二种来源名单样式已实现 |
| 适用范围 | 从旅行团名单 Excel 解析护照姓名,并生成酒店 / PMS 可导入的 Rooming List Excel |
| 当前目标 | CP1 已支持前端上传来源 Excel、填写分房和目标列默认值后端同步生成目标格式 `.xlsx`CP2 收口为后端从来源 `旅游日期` 派生入住 / 离店日期,成人数由系统按分房结果计算,目标默认值区域只保留付款方式和国籍 |
| 当前目标 | CP1 已支持前端上传来源 Excel、填写分房和目标列默认值后端同步生成目标格式 `.xlsx`CP2 收口为后端从来源 `旅游日期` 派生入住 / 离店日期,成人数由系统按分房结果计算,目标默认值区域只保留付款方式和国籍CP3 兼容 `英文姓` + `英文名` 第二种名单样式,并由用户补充入住 / 离店日期 |
| 依赖能力 | 登录权限、酒店上下文、Apache POI 或等价 Excel 读写能力 |
## 1. 背景
@@ -36,6 +36,15 @@ CP2 目标调整:
- `Payment Type` 前端默认为 `BTQR`,当前可选择 `BTQR``CA`,后端按同一枚举校验。
- `Nationality` 前端只允许选择 `KR``CHN`,后端按同一枚举校验。
CP3 目标调整:
- 继续复用同一个生成接口和同一个前端页面,不新增上传入口。
- 兼容第二种来源名单样式:表头包含 `英文姓``英文名`,没有 `护照全名``旅游日期`
- 第二种样式的姓名不再拆分字符串,直接使用 `英文姓` 写入目标 `Name`,使用 `英文名` 写入目标 `First Name`
- 第二种样式没有可靠入住 / 离店日期来源,前端需要恢复并提交 `arrival``departure` 两个日期字段;第一种样式仍可不填,由后端从来源 `旅游日期` 派生。
- 第二种样式中的领队也进入房表。例如标题行 `23+1` 应理解为 23 位客人 + 1 位领队,合计 24 位入住人全部参与分房。
- 来源表中的中文名、性别、出生日期、护照号码、出生地、签发地、签发日期、有效期等字段第一版仍不写入目标 Excel也不在错误响应或日志中回显。
第一阶段不做:
- 不落业务订单。
@@ -61,7 +70,11 @@ CP2 目标调整:
## 4. 来源 Excel 解析规则
基于当前样例来源文件,第一版默认识别以下表头:
基于当前样例来源文件,当前支持两类来源名单格式。后端应先在来源 Excel 中识别表头组合,再选择对应解析器。
### 4.1 第一种来源样式:护照全名 + 旅游日期
第一种样式识别以下表头:
| 来源字段 | 中文说明 | 第一版用途 |
| --- | --- | --- |
@@ -70,7 +83,7 @@ CP2 目标调整:
CP1 只使用 `护照全名` 生成目标姓名字段。CP2 额外使用 `旅游日期` 派生目标 `Arrival` / `Departure`。来源文件中的证件号、生日、性别、年龄等字段暂不自动写入目标 Excel除非后续单独确认。
### 4.1 姓名拆分
#### 4.1.1 姓名拆分
支持的输入格式:
@@ -94,7 +107,7 @@ LIU/JIAYI
- 当前目标模板中 `Name` 更接近姓氏字段,`First Name` 更接近名字段。
- 第一版不尝试判断中文、韩文、英文姓名顺序,也不做大小写改写。
### 4.2 旅游日期解析
#### 4.1.2 旅游日期解析
CP2 起后端应识别来源 Excel 的 `旅游日期` 表头,并从有名单数据的行中读取日期区间。
@@ -121,6 +134,36 @@ CP2 起后端应识别来源 Excel 的 `旅游日期` 表头,并从有名单
- 当前样表第 2 行表头中包含 `旅游日期`,第 3 行起数据为 `2026年5月9日5月14日`,可派生 `Arrival=2026/05/09``Departure=2026/05/14`
- CP2 暂不支持同一个文件中多批次、多旅游日期混合分房;如后续出现该真实场景,需要先设计按批次拆文件或按旅游日期分组生成多个 Sheet / 多个文件。
### 4.2 第二种来源样式:英文姓 + 英文名
第二种样式识别以下表头:
| 来源字段 | 中文说明 | 第一版用途 |
| --- | --- | --- |
| `英文姓` | 护照英文姓氏 | 直接写入目标 Excel 的 `Name` |
| `英文名` | 护照英文名字 | 直接写入目标 Excel 的 `First Name` |
已确认样例:
```text
Sheet1 第 1 行SYNTHETIC-GROUP-001 23+1 12XX 领队...
Sheet1 第 2 行:序号 / 中文名 / 英文姓 / 英文名 / 性别 / 出生日期 / 护照号码 / ...
Sheet1 第 3 行起:名单数据
```
解析规则:
- 在前若干行内查找同一行同时包含 `英文姓``英文名` 的表头。
- 从表头下一行开始读取名单行。
- `英文姓``英文名` 为空的行跳过;如后续需要强校验可单独确认。
- 底部航班说明、空白行、合计说明等不进入名单。
- 不读取也不返回中文名、性别、出生日期、护照号码、出生地、签发地、签发日期、有效期。
- 标题行中的 `23+1` 只作为人工可读说明,不作为系统人数校验;实际入住人数以后端读取到的有效 `英文姓` + `英文名` 行数为准。
- `23+1` 业务含义已确认23 位客人 + 1 位领队,领队也要进入目标 Rooming List。
- 第二种样式没有 `旅游日期`,后端不得从航班行猜测入住 / 离店日期;必须使用前端提交的 `arrival` / `departure`
如果来源文件同时满足第一种和第二种表头组合,后端应优先使用第一种 `护照全名` + `旅游日期`,保持现有已上线行为稳定。
## 5. 分房生成规则
前端需要传入 `people_per_room`,表示每间房人数。
@@ -162,8 +205,8 @@ people_per_room = 3
| `Name` | 每间房第一位旅客的姓氏 / 第一段姓名 |
| `First Name` | 每间房第一位旅客的名字 / 剩余姓名 |
| `Title` | CP2 固定为空,后续如有稳定默认值再单独确认 |
| `Arrival` | CP2 从来源 Excel `旅游日期` 起始日派生 |
| `Departure` | CP2 从来源 Excel `旅游日期` 结束日派生 |
| `Arrival` | 第一种样式从来源 Excel `旅游日期` 起始日派生;第二种样式使用前端提交的 `arrival` |
| `Departure` | 第一种样式从来源 Excel `旅游日期` 结束日派生;第二种样式使用前端提交的 `departure` |
| `Room Type` | 前端传入 |
| `Rate Code` | CP2 固定为空,后续如需 Rate Code 选择再单独确认 |
| `Number of Rooms` | 第一版每行固定为 `1`,后续如目标模板变化再调整 |
@@ -180,7 +223,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 自由文本框恢复为生产能力。
@@ -202,15 +245,15 @@ 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` |
| `payment_type` | string | 否 | CP2 目标 Excel 的 `Payment Type`;为空时后端按 `BTQR` 处理;当前只允许 `BTQR``CA` |
| `nationality` | string | 是 | CP2 目标 Excel 的 `Nationality`;当前只允许 `KR``CHN` |
CP2 不再接收以下前端业务字段作为生成依据:
CP3 后仍不接收以下前端业务字段作为生成依据:
| 字段 | CP2 处理口径 |
| 字段 | 处理口径 |
| --- | --- |
| `arrival` | 不由前端提交;后端从来源 Excel `旅游日期` 派生 |
| `departure` | 不由前端提交;后端从来源 Excel `旅游日期` 派生 |
| `title` | 固定为空 |
| `rate_code` | 固定为空 |
| `adults` | 后端按当前房间实际人数派生 |
@@ -246,7 +289,9 @@ Content-Disposition: attachment; filename="rooming-list-<timestamp>.xlsx"
- `people_per_room` 数字格式错误,返回 `ROOMING_LIST_VALIDATION_FAILED`
- `payment_type``BTQR` / `CA`,返回 `ROOMING_LIST_VALIDATION_FAILED`
- `nationality``KR` / `CHN`,返回 `ROOMING_LIST_VALIDATION_FAILED`
- 来源文件缺失、非 `.xls` / `.xlsx`、无法读取、缺少 `护照全名` 表头或缺少 / 无法解析 `旅游日期` 表头,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`
- 第二种来源样式缺少 `arrival` / `departure`、日期格式错误或 `departure` 不晚于 `arrival`,返回 `ROOMING_LIST_VALIDATION_FAILED`
- 来源文件缺失、非 `.xls` / `.xlsx`、无法读取、既不能识别第一种 `护照全名` + `旅游日期`,也不能识别第二种 `英文姓` + `英文名`,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`
- 第一种来源样式缺少 / 无法解析 `旅游日期` 表头,返回 `ROOMING_LIST_SOURCE_FILE_INVALID`
- 同一来源文件出现多个不同旅游日期区间CP2 返回受控错误,不生成 Excel。
- 错误详情只返回字段名或行号,不回显完整名单、证件号或源文件内容。
@@ -279,7 +324,7 @@ RESERVATION_ROOMING_LIST_GENERATE
| `ReservationRoomingListGenerationController` | 接收前端上传请求,做登录、权限和酒店上下文校验 |
| `ReservationRoomingListGenerationService` | 编排来源解析、分房、目标 Excel 渲染和文件响应 |
| `ReservationRoomingListGenerationServiceImpl` | 具体业务实现 |
| `RoomingListSourceExcelParser` | CP1 解析来源 Excel提取护照姓名CP2 扩展为同时提取并校验统一旅游日期区间 |
| `RoomingListSourceExcelParser` | CP1 解析来源 Excel提取护照姓名CP2 扩展为同时提取并校验统一旅游日期区间CP3 已扩展第二种 `英文姓` + `英文名` 样式,并在该样式下要求用户补日期 |
| `RoomingListNameParser` | 拆分 `/` 或空格格式的护照姓名 |
| `RoomingListGroupingService` | 按每房人数分组并生成目标行模型 |
| `RoomingListExcelRenderer` | 生成目标格式 `.xlsx` |
@@ -308,6 +353,7 @@ RESERVATION_ROOMING_LIST_GENERATE
- 上传来源 Excel。
- 填写每间房人数。
- 填写目标 `Room Type`
- 填写 `Arrival` / `Departure`:第一种来源样式可不填,第二种来源样式必填;页面文案需说明“当来源名单没有旅游日期时请填写”。
- 目标默认值区域只保留 `Payment Type``Nationality`
- `Payment Type` 使用下拉选择,默认 `BTQR`,当前可选择 `BTQR``CA`
- `Nationality` 使用下拉,只允许 `KR``CHN`
@@ -318,8 +364,8 @@ RESERVATION_ROOMING_LIST_GENERATE
- 不要在前端解析完整 Excel 作为权威结果;前端预览可以后置为后端 preview 接口。
- 第一版如果没有 preview 接口,可以只展示用户输入和文件名,生成成功后直接下载。
- CP2 页面不再展示提交 `arrival``departure``title``rate_code``adults``children``vip``email``id_type``id_number`
- `arrival` / `departure` 由后端从来源 Excel `旅游日期` 解析后写入目标 Excel前端不要自行读取 Excel 并推导日期。
- CP3 页面重新展示提交可选 `arrival``departure`;后端仅在第二种来源样式下要求这两个字段必填,第一种来源样式仍优先使用来源 Excel `旅游日期`
- 前端不要自行读取 Excel 并推导日期;如果用户上传第二种没有 `旅游日期` 的名单,由用户手工填写 `arrival` / `departure`
- `Adults` 由后端按分房结果计算;前端不要让用户覆盖。
- 不要把上传文件内容、客人名单或生成文件内容写进浏览器日志、错误上报、URL 或 localStorage。
@@ -329,6 +375,11 @@ RESERVATION_ROOMING_LIST_GENERATE
- 能识别 `护照全名` 表头。
- 能识别 `旅游日期` 表头,并把 `2026年5月9日5月14日` 解析为 `Arrival=2026/05/09``Departure=2026/05/14`
- 能识别第二种来源样式 `英文姓` / `英文名` 表头。
- 第二种来源样式下 `英文姓=FENG``英文名=MINNA` 能生成 `Name=FENG``First Name=MINNA`
- 第二种来源样式下领队也进入房表,实际人数以后端读取到的有效名单行数为准。
- 第二种来源样式使用前端提交的 `arrival` / `departure` 写入目标 Excel。
- 第二种来源样式缺少 `arrival` / `departure` 返回受控错误。
- `LI/CHUNHONG` 能拆成 `Name=LI``First Name=CHUNHONG`
- `LI CHUNHONG` 能拆成 `Name=LI``First Name=CHUNHONG`
- 姓名首尾空格会 trim。

View File

@@ -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 / CP2multipart 上传来源名单并直接下载 `.xlsx`;已在 multipart 参数绑定前前置校验登录和生成权限 | 登录 + `RESERVATION_ROOMING_LIST_GENERATE` + 酒店访问权;后端从来源 Excel `旅游日期` 派生 Arrival / DepartureAdults 按分房结果计算,目标默认值区域只保留 Payment Type / NationalityPayment Type 默认 `BTQR` 且仅允许 `BTQR` / `CA`;第一版仍不落库、不上传 OSS | CP1 / CP2 不落生成记录表;错误响应不得记录完整名单、证件信息、源文件内容或生成文件内容;后续若增加历史记录再补业务审计 |
| `POST /api/reservation/rooming-lists/generations` | `FRONTEND_USER` | 已实现 M010 CP1 / CP2 / CP3CP3 兼容第二种 `英文姓` + `英文名` 名单样式multipart 上传来源名单并直接下载 `.xlsx`;已在 multipart 参数绑定前前置校验登录和生成权限 | 登录 + `RESERVATION_ROOMING_LIST_GENERATE` + 酒店访问权;第一种来源样式后端从来源 Excel `旅游日期` 派生 Arrival / Departure第二种来源样式由用户提交 Arrival / DepartureAdults 按分房结果计算,目标默认值区域只保留 Payment Type / NationalityPayment Type 默认 `BTQR` 且仅允许 `BTQR` / `CA`;第一版仍不落库、不上传 OSS | CP1 / CP2 / CP3 不落生成记录表;错误响应不得记录完整名单、证件信息、源文件内容或生成文件内容;第二种样式不得回显中文名、护照号、生日、签发地等来源证件字段;后续若增加历史记录再补业务审计 |
### 3.2.1 V4 工作台、订单详情和任务详情页展示层技术信息边界

View File

@@ -1,10 +1,14 @@
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 paymentType 目标 Excel 的 Payment Type默认 BTQR当前允许 BTQR 或 CA
* @param nationality 目标 Excel 的 Nationality当前只允许 KR 或 CHN
@@ -12,6 +16,8 @@ package cn.nianxx.thhotel.workflows.reservation.common.request;
public record ReservationRoomingListGenerationRequest(
String hotelId,
Integer peoplePerRoom,
LocalDate arrival,
LocalDate departure,
String roomType,
String paymentType,
String nationality) {

View File

@@ -6,6 +6,8 @@ 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;
@@ -44,6 +46,12 @@ public class ReservationRoomingListGenerationController {
@RequestParam("file") MultipartFile file,
@RequestParam(value = "hotel_id", required = false) String hotelId,
@RequestParam("people_per_room") Integer peoplePerRoom,
@RequestParam(value = "arrival", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate arrival,
@RequestParam(value = "departure", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate departure,
@RequestParam(value = "room_type", required = false) String roomType,
@RequestParam(value = "payment_type", required = false) String paymentType,
@RequestParam(value = "nationality", required = false) String nationality) {
@@ -54,6 +62,8 @@ public class ReservationRoomingListGenerationController {
new ReservationRoomingListGenerationRequest(
hotelId,
peoplePerRoom,
arrival,
departure,
roomType,
paymentType,
nationality),

View File

@@ -62,7 +62,10 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
AuthenticatedUserContext actor) {
ReservationRoomingListGenerationRequest normalizedRequest = normalizeAndValidate(request, sourceFile);
String hotelId = resolveAccessibleHotel(normalizedRequest.hotelId());
ReservationRoomingListSourceDataDto sourceData = sourceExcelParser.parse(sourceFile);
ReservationRoomingListSourceDataDto sourceData = sourceExcelParser.parse(
sourceFile,
normalizedRequest.arrival(),
normalizedRequest.departure());
List<ReservationRoomingListRoomDto> rooms = groupingService.group(
sourceData.guests(),
normalizedRequest.peoplePerRoom(),
@@ -109,6 +112,8 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
return new ReservationRoomingListGenerationRequest(
trimToNull(request.hotelId()),
request.peoplePerRoom(),
request.arrival(),
request.departure(),
trimToEmpty(request.roomType()),
paymentType,
nationality);

View File

@@ -22,13 +22,15 @@ import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
/**
* Rooming List 来源 Excel 解析器。第一版扫描 `护照全名` 表头并按行提取名单
* Rooming List 来源 Excel 解析器。按表头组合识别来源样式并提取生成目标 Excel 所需的安全字段
*/
@Component
public class RoomingListSourceExcelParser {
private static final String PASSPORT_NAME_HEADER = "护照全名";
private static final String TRAVEL_DATE_HEADER = "旅游日期";
private static final String ENGLISH_LAST_NAME_HEADER = "英文姓";
private static final String ENGLISH_FIRST_NAME_HEADER = "英文名";
private static final 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*(?:日)?$");
@@ -48,6 +50,16 @@ public class RoomingListSourceExcelParser {
* 解析来源 Excel返回按原表顺序排列的旅客名单。
*/
public ReservationRoomingListSourceDataDto parse(MultipartFile sourceFile) {
return parse(sourceFile, null, null);
}
/**
* 解析来源 Excel。第二种英文姓名来源样式需要调用方提供入住 / 离店酒店本地日期。
*/
public ReservationRoomingListSourceDataDto parse(
MultipartFile sourceFile,
LocalDate manualArrival,
LocalDate manualDeparture) {
validateFile(sourceFile);
DataFormatter formatter = new DataFormatter();
try (InputStream inputStream = sourceFile.getInputStream();
@@ -56,11 +68,26 @@ public class RoomingListSourceExcelParser {
throw invalidFile(List.of("来源 Excel 至少需要一个 Sheet。"));
}
Sheet sheet = workbook.getSheetAt(0);
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("护照全名列没有可用旅客姓名。"));
HeaderLocation passportNameHeader = findHeaderIfPresent(sheet, formatter, PASSPORT_NAME_HEADER);
HeaderLocation travelDateHeader = findHeaderIfPresent(sheet, formatter, TRAVEL_DATE_HEADER);
SourceRows sourceRows;
if (passportNameHeader != null && travelDateHeader != null) {
sourceRows = parsePassportRows(sheet, passportNameHeader, travelDateHeader, formatter);
} else if (passportNameHeader == null && travelDateHeader != null) {
throw invalidFile(List.of("未找到表头:" + PASSPORT_NAME_HEADER));
} else if (passportNameHeader != null) {
throw invalidFile(List.of("未找到表头:" + TRAVEL_DATE_HEADER));
} else {
EnglishNameHeaders englishNameHeaders = findEnglishNameHeaders(sheet, formatter);
if (englishNameHeaders != null) {
sourceRows = parseEnglishNameRows(
sheet,
englishNameHeaders,
formatter,
requireManualStayDateRange(manualArrival, manualDeparture));
} else {
throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期 或 英文姓/英文名。"));
}
}
return new ReservationRoomingListSourceDataDto(
sourceRows.guests(),
@@ -90,27 +117,32 @@ public class RoomingListSourceExcelParser {
/**
* 在前若干行内查找指定表头位置。
*/
private HeaderLocation findHeader(Sheet sheet, DataFormatter formatter, String headerText) {
private HeaderLocation findHeaderIfPresent(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);
if (row == null) {
continue;
}
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
int firstCellIndex = row.getFirstCellNum();
int lastCellIndex = row.getLastCellNum();
if (firstCellIndex < 0 || lastCellIndex < 0) {
continue;
}
for (int cellIndex = firstCellIndex; cellIndex < lastCellIndex; cellIndex++) {
Cell cell = row.getCell(cellIndex);
if (headerText.equals(cellText(cell, formatter))) {
return new HeaderLocation(rowIndex, cellIndex);
}
}
}
throw invalidFile(List.of("未找到表头:" + headerText));
return null;
}
/**
* 从表头下一行开始提取护照姓名和旅游日期,空白姓名行自动跳过。
*/
private SourceRows parseRows(
private SourceRows parsePassportRows(
Sheet sheet,
HeaderLocation passportNameHeader,
HeaderLocation travelDateHeader,
@@ -139,9 +171,97 @@ public class RoomingListSourceExcelParser {
if (expectedRange == null) {
throw invalidFile(List.of("旅游日期: 没有可用旅游日期。"));
}
if (guests.isEmpty()) {
throw invalidFile(List.of("护照全名列没有可用旅客姓名。"));
}
return new SourceRows(List.copyOf(guests), expectedRange);
}
/**
* 查找第二种来源样式的英文姓名表头;要求 `英文姓` 和 `英文名` 位于同一表头行。
*/
private EnglishNameHeaders findEnglishNameHeaders(Sheet sheet, DataFormatter formatter) {
int lastRowNum = Math.min(sheet.getLastRowNum(), MAX_HEADER_SCAN_ROWS - 1);
for (int rowIndex = 0; rowIndex <= lastRowNum; rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
HeaderLocation lastNameHeader = null;
HeaderLocation firstNameHeader = null;
int firstCellIndex = row.getFirstCellNum();
int lastCellIndex = row.getLastCellNum();
if (firstCellIndex < 0 || lastCellIndex < 0) {
continue;
}
for (int cellIndex = firstCellIndex; cellIndex < lastCellIndex; cellIndex++) {
String text = cellText(row.getCell(cellIndex), formatter);
if (ENGLISH_LAST_NAME_HEADER.equals(text)) {
lastNameHeader = new HeaderLocation(rowIndex, cellIndex);
} else if (ENGLISH_FIRST_NAME_HEADER.equals(text)) {
firstNameHeader = new HeaderLocation(rowIndex, cellIndex);
}
}
if (lastNameHeader != null && firstNameHeader != null) {
return new EnglishNameHeaders(rowIndex, lastNameHeader.cellIndex(), firstNameHeader.cellIndex());
}
}
return null;
}
/**
* 第二种来源样式直接使用英文姓 / 英文名,不读取证件号、中文名或生日等敏感字段。
*/
private SourceRows parseEnglishNameRows(
Sheet sheet,
EnglishNameHeaders headers,
DataFormatter formatter,
TravelDateRange manualStayDateRange) {
List<ReservationRoomingListGuestDto> guests = new ArrayList<>();
for (int rowIndex = headers.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
String lastName = cellText(row.getCell(headers.lastNameCellIndex()), formatter);
String firstName = cellText(row.getCell(headers.firstNameCellIndex()), formatter);
if (isBlank(lastName) || isBlank(firstName)) {
continue;
}
String normalizedLastName = lastName.trim();
String normalizedFirstName = firstName.trim();
guests.add(new ReservationRoomingListGuestDto(
rowIndex + 1,
normalizedLastName,
normalizedFirstName,
normalizedLastName + " " + normalizedFirstName));
}
if (guests.isEmpty()) {
throw invalidFile(List.of("英文姓/英文名列没有可用旅客姓名。"));
}
return new SourceRows(List.copyOf(guests), manualStayDateRange);
}
/**
* 第二种来源样式没有可靠旅游日期,必须使用用户提交的入住 / 离店日期。
*/
private TravelDateRange requireManualStayDateRange(LocalDate manualArrival, LocalDate manualDeparture) {
List<String> errors = new ArrayList<>();
if (manualArrival == null) {
errors.add("arrival: 第二种来源名单样式必须填写入住日期。");
}
if (manualDeparture == null) {
errors.add("departure: 第二种来源名单样式必须填写离店日期。");
}
if (!errors.isEmpty()) {
throw validationError(errors);
}
if (!manualDeparture.isAfter(manualArrival)) {
throw validationError(List.of("departure: 离店日期必须晚于入住日期。"));
}
return new TravelDateRange(manualArrival, manualDeparture);
}
/**
* 解析样表中的旅游日期区间,输出酒店本地入住 / 离店日期。
*/
@@ -229,6 +349,14 @@ public class RoomingListSourceExcelParser {
details);
}
private ReservationRoomingListGenerationException validationError(List<String> details) {
return new ReservationRoomingListGenerationException(
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_VALIDATION_FAILED",
"Rooming List 字段校验失败。",
details);
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
@@ -242,6 +370,16 @@ public class RoomingListSourceExcelParser {
private record HeaderLocation(int rowIndex, int cellIndex) {
}
/**
* 第二种来源样式的英文姓名表头坐标。
*
* @param rowIndex 0 基表头行号
* @param lastNameCellIndex `英文姓` 列号
* @param firstNameCellIndex `英文名` 列号
*/
private record EnglishNameHeaders(int rowIndex, int lastNameCellIndex, int firstNameCellIndex) {
}
/**
* 来源名单行和统一旅游日期区间。
*

View File

@@ -112,7 +112,43 @@ class ReservationRoomingListGenerationControllerTest {
.isEqualTo("BTQR"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 14))
.isEqualTo("CHN"))
.andExpect(content().string(not(containsString("P123456"))));
.andExpect(content().string(not(containsString("SYNTHETIC_DOC_001"))));
}
@Test
void shouldDownloadGeneratedRoomingListWorkbookForEnglishNameSourceWithManualStayDates() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFileWithEnglishNames())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-05-10")
.param("departure", "2026-05-16")
.param("room_type", "UG1")
.param("payment_type", "BTQR")
.param("nationality", "CHN"))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 1))
.isEqualTo("ALPHA"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 2))
.isEqualTo("ONE"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 4))
.isEqualTo("2026/05/10"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 5))
.isEqualTo("2026/05/16"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 9))
.isEqualTo("2"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 13))
.isEqualTo("BRAVO TWO"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 2, 1))
.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"))));
}
@Test
@@ -201,14 +237,17 @@ class ReservationRoomingListGenerationControllerTest {
headerRow.createCell(0).setCellValue("旅游日期");
headerRow.createCell(1).setCellValue("姓名");
headerRow.createCell(2).setCellValue("护照全名");
headerRow.createCell(3).setCellValue("证件号");
Row row1 = sheet.createRow(1);
row1.createCell(0).setCellValue("2026年5月9日5月14日");
row1.createCell(1).setCellValue("游客1");
row1.createCell(2).setCellValue("LI/CHUNHONG");
row1.createCell(2).setCellValue("ALPHA/ONE");
row1.createCell(3).setCellValue("SYNTHETIC_DOC_001");
Row row2 = sheet.createRow(2);
row2.createCell(0).setCellValue("2026年5月9日5月14日");
row2.createCell(1).setCellValue("游客2");
row2.createCell(2).setCellValue("ZHANG GAILI");
row2.createCell(2).setCellValue("BRAVO TWO");
row2.createCell(3).setCellValue("SYNTHETIC_DOC_002");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
@@ -237,6 +276,45 @@ class ReservationRoomingListGenerationControllerTest {
}
}
private MockMultipartFile sourceFileWithEnglishNames() 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("SYNTHETIC-GROUP-001");
titleRow.createCell(1).setCellValue("2+1");
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("护照号码");
englishNameRow(sheet.createRow(2), 1, "SYNTHETIC_CN_A", "ALPHA", "ONE", "PX000101");
englishNameRow(sheet.createRow(3), 2, "SYNTHETIC_CN_B", "BRAVO", "TWO", "PX000102");
englishNameRow(sheet.createRow(4), 3, "SYNTHETIC_CN_LEADER", "LEADER", "GUIDE", "PX000103");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"synthetic-second-style.xlsx",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
outputStream.toByteArray());
}
}
private void englishNameRow(
Row row,
int index,
String chineseName,
String lastName,
String firstName,
String passportNo) {
row.createCell(0).setCellValue(index);
row.createCell(1).setCellValue(chineseName);
row.createCell(2).setCellValue(lastName);
row.createCell(3).setCellValue(firstName);
row.createCell(4).setCellValue(passportNo);
}
private String workbookCell(byte[] workbookBytes, int rowIndex, int cellIndex) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new java.io.ByteArrayInputStream(workbookBytes))) {
var cell = workbook.getSheetAt(0).getRow(rowIndex).getCell(cellIndex);

View File

@@ -10,6 +10,7 @@ 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;
@@ -97,6 +98,8 @@ class ReservationRoomingListGenerationServiceImplTest {
new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
1,
null,
null,
"UG1",
"ca",
"CHN"),
@@ -124,6 +127,47 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
@Test
void shouldGenerateRoomingListWorkbookByEnglishNameColumnsAndManualStayDates() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGeneratedFile generatedFile = service.generate(
sourceFileWithEnglishNameColumns(),
request(2, LocalDate.of(2026, 5, 10), LocalDate.of(2026, 5, 16)),
actor());
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
Row firstRoom = workbook.getSheetAt(0).getRow(1);
assertThat(cellText(firstRoom.getCell(1))).isEqualTo("ALPHA");
assertThat(cellText(firstRoom.getCell(2))).isEqualTo("ONE");
assertThat(cellText(firstRoom.getCell(4))).isEqualTo("2026/05/10");
assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026/05/16");
assertThat(cellText(firstRoom.getCell(9))).isEqualTo("2");
assertThat(cellText(firstRoom.getCell(13))).isEqualTo("BRAVO TWO");
Row leaderRoom = workbook.getSheetAt(0).getRow(2);
assertThat(cellText(leaderRoom.getCell(0))).isEqualTo("2");
assertThat(cellText(leaderRoom.getCell(1))).isEqualTo("LEADER");
assertThat(cellText(leaderRoom.getCell(2))).isEqualTo("GUIDE");
assertThat(cellText(leaderRoom.getCell(9))).isEqualTo("1");
}
}
@Test
void shouldRejectEnglishNameSourceWorkbookWithoutManualStayDates() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFileWithEnglishNameColumns(), request(2), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
assertThat(exception.getDetails())
.contains(
"arrival: 第二种来源名单样式必须填写入住日期。",
"departure: 第二种来源名单样式必须填写离店日期。");
}
@Test
void shouldRejectSourceWorkbookWithoutPassportNameHeader() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
@@ -148,6 +192,22 @@ class ReservationRoomingListGenerationServiceImplTest {
assertThat(exception.getDetails()).contains("未找到表头:旅游日期");
}
@Test
void shouldRejectPartialPassportStyleEvenWhenEnglishNameHeadersExist() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(
sourceFileWithPassportHeaderAndEnglishNameColumnsWithoutTravelDate(),
request(2, LocalDate.of(2026, 5, 10), LocalDate.of(2026, 5, 16)),
actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception).isNotNull();
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");
@@ -168,6 +228,8 @@ class ReservationRoomingListGenerationServiceImplTest {
new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
2,
null,
null,
"UG1",
"BT",
"TH"),
@@ -202,9 +264,18 @@ class ReservationRoomingListGenerationServiceImplTest {
}
private ReservationRoomingListGenerationRequest request(int peoplePerRoom) {
return request(peoplePerRoom, null, null);
}
private ReservationRoomingListGenerationRequest request(
int peoplePerRoom,
LocalDate arrival,
LocalDate departure) {
return new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
peoplePerRoom,
arrival,
departure,
"UG1",
null,
"CHN");
@@ -261,7 +332,7 @@ class ReservationRoomingListGenerationServiceImplTest {
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("2026年5月9日5月14日");
row.createCell(1).setCellValue("游客1");
row.createCell(2).setCellValue("P123456");
row.createCell(2).setCellValue("SYNTHETIC_DOC_001");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
@@ -290,6 +361,27 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
private MockMultipartFile sourceFileWithPassportHeaderAndEnglishNameColumnsWithoutTravelDate() 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("ALPHA/ONE");
row.createCell(1).setCellValue("ALPHA");
row.createCell(2).setCellValue("ONE");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"invalid-partial-passport-style.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private MockMultipartFile sourceFileWithMultipleTravelDates() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
@@ -312,6 +404,49 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
private MockMultipartFile sourceFileWithEnglishNameColumns() 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("SYNTHETIC-GROUP-001");
titleRow.createCell(1).setCellValue("2+1");
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("性别");
headerRow.createCell(5).setCellValue("出生日期");
headerRow.createCell(6).setCellValue("护照号码");
englishNameRow(sheet.createRow(2), 1, "SYNTHETIC_CN_A", "ALPHA", "ONE", "PX000001");
englishNameRow(sheet.createRow(3), 2, "SYNTHETIC_CN_B", "BRAVO", "TWO", "PX000002");
englishNameRow(sheet.createRow(4), 3, "SYNTHETIC_CN_LEADER", "LEADER", "GUIDE", "PX000003");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"synthetic-second-style.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private void englishNameRow(
Row row,
int index,
String chineseName,
String lastName,
String firstName,
String passportNo) {
row.createCell(0).setCellValue(index);
row.createCell(1).setCellValue(chineseName);
row.createCell(2).setCellValue(lastName);
row.createCell(3).setCellValue(firstName);
row.createCell(4).setCellValue("F");
row.createCell(5).setCellValue("1990-01-01");
row.createCell(6).setCellValue(passportNo);
}
private MockMultipartFile nonExcelFile() {
return new MockMultipartFile(
"file",