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

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

@@ -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",