兼容第三种房表名单格式

This commit is contained in:
andy
2026-08-04 09:41:16 +08:00
parent 99fb919158
commit d56587a078
18 changed files with 427 additions and 98 deletions

View File

@@ -7,11 +7,11 @@ import java.time.LocalDate;
*
* @param hotelId 酒店 ID为空时按当前登录用户默认酒店解析
* @param peoplePerRoom 每间房人数
* @param arrival 第二种来源名单样式使用的入住酒店本地日期;第一种样式可为空并由来源旅游日期派生
* @param departure 第二种来源名单样式使用的离店酒店本地日期;第一种样式可为空并由来源旅游日期派生
* @param arrival 无旅游日期来源名单样式使用的入住酒店本地日期;第一种样式可为空并由来源旅游日期派生
* @param departure 无旅游日期来源名单样式使用的离店酒店本地日期;第一种样式可为空并由来源旅游日期派生
* @param roomType 目标 Excel 的 Room Type
* @param paymentType 目标 Excel 的 Payment Type默认 BTQR当前允许 BTQR 或 CA
* @param nationality 目标 Excel 的 Nationality当前只允许 KR 或 CHN
* @param nationality 目标 Excel 的 Nationality当前只允许 KR、CN、TH、MM、RS 或 TW
*/
public record ReservationRoomingListGenerationRequest(
String hotelId,

View File

@@ -30,8 +30,8 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
private static final String PAYMENT_TYPE_BTQR = "BTQR";
private static final String PAYMENT_TYPE_CA = "CA";
private static final String DEFAULT_PAYMENT_TYPE = PAYMENT_TYPE_BTQR;
private static final String NATIONALITY_KR = "KR";
private static final String NATIONALITY_CHN = "CHN";
private static final List<String> ALLOWED_NATIONALITIES =
List.of("KR", "CN", "TH", "MM", "RS", "TW");
private final HotelContextService hotelContextService;
private final RoomingListSourceExcelParser sourceExcelParser;
@@ -152,8 +152,8 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
return "";
}
String code = normalized.toUpperCase(java.util.Locale.ROOT);
if (!NATIONALITY_KR.equals(code) && !NATIONALITY_CHN.equals(code)) {
errors.add("nationality: 当前只允许 KR 或 CHN");
if (!ALLOWED_NATIONALITIES.contains(code)) {
errors.add("nationality: 当前只允许 KR、CN、TH、MM、RS 或 TW");
}
return code;
}

View File

@@ -8,16 +8,16 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* Rooming List 护照姓名解析器。第一版只处理斜杠或空格分隔的英文护照姓名。
* Rooming List 英文姓名解析器。第一版只处理斜杠或空格分隔的英文姓名。
*/
@Component
public class RoomingListNameParser {
/**
* 将来源 Excel 单元格中的护照全名拆分为目标模板 Name / First Name。
* 将来源 Excel 单元格中的英文姓名拆分为目标模板 Name / First Name。
*/
public ReservationRoomingListGuestDto parse(int rowNumber, String rawPassportName) {
String normalized = trimToNull(rawPassportName);
public ReservationRoomingListGuestDto parse(int rowNumber, String rawEnglishName) {
String normalized = trimToNull(rawEnglishName);
if (normalized == null) {
throw invalidName(rowNumber);
}
@@ -35,7 +35,7 @@ public class RoomingListNameParser {
}
/**
* 按 `/` 拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。
* 按 `/` 拆分英文姓名,第一段作为姓氏,剩余部分合并作为名字。
*/
private ParsedName parseSlashName(String value) {
List<String> parts = Arrays.stream(value.split("/"))
@@ -50,7 +50,7 @@ public class RoomingListNameParser {
}
/**
* 按连续空白拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。
* 按连续空白拆分英文姓名,第一段作为姓氏,剩余部分合并作为名字。
*/
private ParsedName parseWhitespaceName(String value) {
List<String> parts = Arrays.stream(value.split("\\s+"))
@@ -72,7 +72,7 @@ public class RoomingListNameParser {
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_SOURCE_FILE_INVALID",
"来源名单文件不合法。",
List.of("" + rowNumber + "护照全名无法拆分为 Name / First Name。"));
List.of("" + rowNumber + "英文姓名无法拆分为 Name / First Name。"));
}
private boolean isBlank(String value) {
@@ -87,7 +87,7 @@ public class RoomingListNameParser {
}
/**
* 护照姓名拆分结果。
* 英文姓名拆分结果。
*
* @param lastName 姓氏
* @param firstName 名字

View File

@@ -54,7 +54,7 @@ public class RoomingListSourceExcelParser {
}
/**
* 解析来源 Excel。第二种英文姓名来源样式需要调用方提供入住 / 离店酒店本地日期。
* 解析来源 Excel。无旅游日期来源样式需要调用方提供入住 / 离店酒店本地日期。
*/
public ReservationRoomingListSourceDataDto parse(
MultipartFile sourceFile,
@@ -86,7 +86,17 @@ public class RoomingListSourceExcelParser {
formatter,
requireManualStayDateRange(manualArrival, manualDeparture));
} else {
throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期 或 英文姓/英文名。"));
HeaderLocation singleEnglishNameHeader =
findSingleEnglishNameHeaderIfPresent(sheet, formatter);
if (singleEnglishNameHeader != null) {
sourceRows = parseSingleEnglishNameRows(
sheet,
singleEnglishNameHeader,
formatter,
requireManualStayDateRange(manualArrival, manualDeparture));
} else {
throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期、英文姓/英文名 或 英文名。"));
}
}
}
return new ReservationRoomingListSourceDataDto(
@@ -209,6 +219,13 @@ public class RoomingListSourceExcelParser {
return null;
}
/**
* 查找第三种来源样式的单列英文名表头;该列值形如 `CAI/HAIYUN`。
*/
private HeaderLocation findSingleEnglishNameHeaderIfPresent(Sheet sheet, DataFormatter formatter) {
return findHeaderIfPresent(sheet, formatter, ENGLISH_FIRST_NAME_HEADER);
}
/**
* 第二种来源样式直接使用英文姓 / 英文名,不读取证件号、中文名或生日等敏感字段。
*/
@@ -243,15 +260,41 @@ public class RoomingListSourceExcelParser {
}
/**
* 第种来源样式没有可靠旅游日期,必须使用用户提交的入住 / 离店日期
* 第种来源样式直接读取单列英文名并复用护照英文名拆分,不读取证件号、生日或联系方式等敏感字段
*/
private SourceRows parseSingleEnglishNameRows(
Sheet sheet,
HeaderLocation englishNameHeader,
DataFormatter formatter,
TravelDateRange manualStayDateRange) {
List<ReservationRoomingListGuestDto> guests = new ArrayList<>();
for (int rowIndex = englishNameHeader.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
String englishName = cellText(row.getCell(englishNameHeader.cellIndex()), formatter);
if (isBlank(englishName)) {
continue;
}
guests.add(nameParser.parse(rowIndex + 1, englishName));
}
if (guests.isEmpty()) {
throw invalidFile(List.of("英文名列没有可用旅客姓名。"));
}
return new SourceRows(List.copyOf(guests), manualStayDateRange);
}
/**
* 无旅游日期来源样式没有可靠旅游日期,必须使用用户提交的入住 / 离店日期。
*/
private TravelDateRange requireManualStayDateRange(LocalDate manualArrival, LocalDate manualDeparture) {
List<String> errors = new ArrayList<>();
if (manualArrival == null) {
errors.add("arrival: 第二种来源名单样式必须填写入住日期。");
errors.add("arrival: 无旅游日期来源名单样式必须填写入住日期。");
}
if (manualDeparture == null) {
errors.add("departure: 第二种来源名单样式必须填写离店日期。");
errors.add("departure: 无旅游日期来源名单样式必须填写离店日期。");
}
if (!errors.isEmpty()) {
throw validationError(errors);

View File

@@ -4,7 +4,6 @@ import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@@ -17,9 +16,13 @@ import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -90,7 +93,7 @@ class ReservationRoomingListGenerationControllerTest {
.param("people_per_room", "2")
.param("room_type", "UG1")
.param("payment_type", "BTQR")
.param("nationality", "CHN"))
.param("nationality", "CN"))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
@@ -111,8 +114,10 @@ class ReservationRoomingListGenerationControllerTest {
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 11))
.isEqualTo("BTQR"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 14))
.isEqualTo("CHN"))
.andExpect(content().string(not(containsString("SYNTHETIC_DOC_001"))));
.isEqualTo("CN"))
.andExpect(result -> assertWorkbookDoesNotContain(
result.getResponse().getContentAsByteArray(),
"SYNTHETIC_DOC_001"));
}
@Test
@@ -127,7 +132,7 @@ class ReservationRoomingListGenerationControllerTest {
.param("departure", "2026-05-16")
.param("room_type", "UG1")
.param("payment_type", "BTQR")
.param("nationality", "CHN"))
.param("nationality", "CN"))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
@@ -147,8 +152,49 @@ class ReservationRoomingListGenerationControllerTest {
.isEqualTo("LEADER"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 2, 9))
.isEqualTo("1"))
.andExpect(content().string(not(containsString("PX000101"))))
.andExpect(content().string(not(containsString("SYNTHETIC_CN_A"))));
.andExpect(result -> assertWorkbookDoesNotContain(
result.getResponse().getContentAsByteArray(),
"PX000101",
"SYNTHETIC_CN_A"));
}
@Test
void shouldDownloadGeneratedRoomingListWorkbookForSingleEnglishNameColumnSourceWithManualStayDates()
throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFileWithSingleEnglishNameColumn())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "3")
.param("arrival", "2026-08-03")
.param("departure", "2026-08-08")
.param("room_type", "UG1")
.param("payment_type", "BTQR")
.param("nationality", "CN"))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 1))
.isEqualTo("CAI"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 2))
.isEqualTo("HAIYUN"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 4))
.isEqualTo("2026/08/03"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 5))
.isEqualTo("2026/08/08"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 9))
.isEqualTo("3"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 13))
.isEqualTo("HUANG YUHENG, MA XIUHAN"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 2, 1))
.isEqualTo("HE"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 2, 9))
.isEqualTo("1"))
.andExpect(result -> assertWorkbookDoesNotContain(
result.getResponse().getContentAsByteArray(),
"PX000201",
"SYNTHETIC_CN_LEADER"));
}
@Test
@@ -195,7 +241,7 @@ class ReservationRoomingListGenerationControllerTest {
.param("people_per_room", "2")
.param("room_type", "UG1")
.param("payment_type", "CA")
.param("nationality", "CHN"))
.param("nationality", "CN"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@@ -223,11 +269,11 @@ class ReservationRoomingListGenerationControllerTest {
.param("people_per_room", "2")
.param("room_type", "UG1")
.param("payment_type", "BTQR")
.param("nationality", "CHN"))
.param("nationality", "CN"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("arrival: 第二种来源名单样式必须填写入住日期。"))
.andExpect(jsonPath("$.details[1]").value("departure: 第二种来源名单样式必须填写离店日期。"));
.andExpect(jsonPath("$.details[0]").value("arrival: 无旅游日期来源名单样式必须填写入住日期。"))
.andExpect(jsonPath("$.details[1]").value("departure: 无旅游日期来源名单样式必须填写离店日期。"));
}
@Test
@@ -240,7 +286,7 @@ class ReservationRoomingListGenerationControllerTest {
.param("people_per_room", "2")
.param("room_type", "UG1")
.param("payment_type", "CA")
.param("nationality", "CHN"))
.param("nationality", "CN"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_SOURCE_FILE_INVALID"))
.andExpect(jsonPath("$.details[0]").value("旅游日期: 格式无法解析。"));
@@ -318,6 +364,45 @@ class ReservationRoomingListGenerationControllerTest {
}
}
private MockMultipartFile sourceFileWithSingleEnglishNameColumn() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
Row titleRow = sheet.createRow(0);
titleRow.createCell(0).setCellValue("客人名单表");
titleRow.createCell(1).setCellValue("0803杭州-曼谷/0808曼谷-杭州");
Row headerRow = sheet.createRow(1);
headerRow.createCell(0).setCellValue("序号");
headerRow.createCell(1).setCellValue("姓名");
headerRow.createCell(2).setCellValue("性别");
headerRow.createCell(3).setCellValue("英文名");
headerRow.createCell(4).setCellValue("证件号");
singleEnglishNameRow(sheet.createRow(2), "领队", "SYNTHETIC_CN_LEADER", "CAI/HAIYUN", "PX000201");
singleEnglishNameRow(sheet.createRow(3), "1", "SYNTHETIC_CN_A", "HUANG/YUHENG", "PX000202");
singleEnglishNameRow(sheet.createRow(4), "2", "SYNTHETIC_CN_B", "MA/XIUHAN", "PX000203");
singleEnglishNameRow(sheet.createRow(5), "3", "SYNTHETIC_CN_C", "HE/XIANG", "PX000204");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"synthetic-third-style.xlsx",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
outputStream.toByteArray());
}
}
private void singleEnglishNameRow(
Row row,
String index,
String chineseName,
String englishName,
String passportNo) {
row.createCell(0).setCellValue(index);
row.createCell(1).setCellValue(chineseName);
row.createCell(2).setCellValue("F");
row.createCell(3).setCellValue(englishName);
row.createCell(4).setCellValue(passportNo);
}
private void englishNameRow(
Row row,
int index,
@@ -333,7 +418,7 @@ class ReservationRoomingListGenerationControllerTest {
}
private String workbookCell(byte[] workbookBytes, int rowIndex, int cellIndex) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new java.io.ByteArrayInputStream(workbookBytes))) {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(workbookBytes))) {
var cell = workbook.getSheetAt(0).getRow(rowIndex).getCell(cellIndex);
if (cell == null) {
return "";
@@ -351,4 +436,21 @@ class ReservationRoomingListGenerationControllerTest {
};
}
}
private void assertWorkbookDoesNotContain(byte[] workbookBytes, String... unexpectedValues) throws Exception {
DataFormatter formatter = new DataFormatter();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(workbookBytes))) {
for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) {
Sheet sheet = workbook.getSheetAt(sheetIndex);
for (Row row : sheet) {
for (Cell cell : row) {
String cellText = formatter.formatCellValue(cell);
for (String unexpectedValue : unexpectedValues) {
assertThat(cellText).doesNotContain(unexpectedValue);
}
}
}
}
}
}
}

View File

@@ -70,7 +70,7 @@ class ReservationRoomingListGenerationServiceImplTest {
assertThat(cellText(firstRoom.getCell(10))).isEqualTo("0");
assertThat(cellText(firstRoom.getCell(11))).isEqualTo("BTQR");
assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG GAILI, LI HONG");
assertThat(cellText(firstRoom.getCell(14))).isEqualTo("CHN");
assertThat(cellText(firstRoom.getCell(14))).isEqualTo("CN");
assertThat(cellText(firstRoom.getCell(15))).isEmpty();
assertThat(cellText(firstRoom.getCell(16))).isEmpty();
assertThat(cellText(firstRoom.getCell(17))).isEmpty();
@@ -102,7 +102,7 @@ class ReservationRoomingListGenerationServiceImplTest {
null,
"UG1",
"ca",
"CHN"),
"CN"),
actor());
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
@@ -111,6 +111,30 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
@Test
void shouldAcceptExpandedNationalityCodesForRoomingListWorkbook() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
for (String nationality : List.of("KR", "CN", "TH", "MM", "RS", "TW")) {
ReservationRoomingListGeneratedFile generatedFile = service.generate(
sourceFileWithTravelDate("2026-05-09 - 05-14", List.of("LI/CHUNHONG")),
new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
1,
null,
null,
"UG1",
"BTQR",
nationality),
actor());
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
Row firstRoom = workbook.getSheetAt(0).getRow(1);
assertThat(cellText(firstRoom.getCell(14))).isEqualTo(nationality);
}
}
}
@Test
void shouldParseTravelDateWithNumericHyphenRange() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
@@ -153,6 +177,35 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
@Test
void shouldGenerateRoomingListWorkbookBySingleEnglishNameColumnAndManualStayDates() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGeneratedFile generatedFile = service.generate(
sourceFileWithSingleEnglishNameColumn(),
request(3, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 8)),
actor());
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
assertThat(workbook.getSheetAt(0).getPhysicalNumberOfRows()).isEqualTo(3);
Row leaderRoom = workbook.getSheetAt(0).getRow(1);
assertThat(cellText(leaderRoom.getCell(0))).isEqualTo("1");
assertThat(cellText(leaderRoom.getCell(1))).isEqualTo("CAI");
assertThat(cellText(leaderRoom.getCell(2))).isEqualTo("HAIYUN");
assertThat(cellText(leaderRoom.getCell(4))).isEqualTo("2026/08/03");
assertThat(cellText(leaderRoom.getCell(5))).isEqualTo("2026/08/08");
assertThat(cellText(leaderRoom.getCell(9))).isEqualTo("3");
assertThat(cellText(leaderRoom.getCell(13))).isEqualTo("HUANG YUHENG, MA XIUHAN");
Row tailRoom = workbook.getSheetAt(0).getRow(2);
assertThat(cellText(tailRoom.getCell(0))).isEqualTo("2");
assertThat(cellText(tailRoom.getCell(1))).isEqualTo("HE");
assertThat(cellText(tailRoom.getCell(2))).isEqualTo("XIANG");
assertThat(cellText(tailRoom.getCell(9))).isEqualTo("1");
assertThat(cellText(tailRoom.getCell(13))).isEmpty();
}
}
@Test
void shouldRejectEnglishNameSourceWorkbookWithoutManualStayDates() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
@@ -164,8 +217,40 @@ class ReservationRoomingListGenerationServiceImplTest {
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
assertThat(exception.getDetails())
.contains(
"arrival: 第二种来源名单样式必须填写入住日期。",
"departure: 第二种来源名单样式必须填写离店日期。");
"arrival: 无旅游日期来源名单样式必须填写入住日期。",
"departure: 无旅游日期来源名单样式必须填写离店日期。");
}
@Test
void shouldRejectSingleEnglishNameColumnSourceWorkbookWithoutManualStayDates() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFileWithSingleEnglishNameColumn(), request(2), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
assertThat(exception.getDetails())
.contains(
"arrival: 无旅游日期来源名单样式必须填写入住日期。",
"departure: 无旅游日期来源名单样式必须填写离店日期。");
}
@Test
void shouldReportGenericEnglishNameWhenSingleEnglishNameColumnCannotBeSplit() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(
sourceFileWithInvalidSingleEnglishNameColumn(),
request(2, LocalDate.of(2026, 8, 3), LocalDate.of(2026, 8, 8)),
actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
assertThat(exception.getDetails())
.contains("第 3 行英文姓名无法拆分为 Name / First Name。")
.noneMatch(detail -> detail.contains("护照全名"));
}
@Test
@@ -232,13 +317,13 @@ class ReservationRoomingListGenerationServiceImplTest {
null,
"UG1",
"BT",
"TH"),
"JP"),
actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
assertThat(exception.getDetails())
.contains("payment_type: 当前只允许 BTQR 或 CA。", "nationality: 当前只允许 KR 或 CHN");
.contains("payment_type: 当前只允许 BTQR 或 CA。", "nationality: 当前只允许 KR、CN、TH、MM、RS 或 TW");
}
@Test
@@ -278,7 +363,7 @@ class ReservationRoomingListGenerationServiceImplTest {
departure,
"UG1",
null,
"CHN");
"CN");
}
private AuthenticatedUserContext actor() {
@@ -431,6 +516,64 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
private MockMultipartFile sourceFileWithSingleEnglishNameColumn() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
Row titleRow = sheet.createRow(0);
titleRow.createCell(0).setCellValue("客人名单表");
titleRow.createCell(1).setCellValue("0803杭州-曼谷/0808曼谷-杭州");
Row headerRow = sheet.createRow(1);
headerRow.createCell(0).setCellValue("序号");
headerRow.createCell(1).setCellValue("姓名");
headerRow.createCell(2).setCellValue("性别");
headerRow.createCell(3).setCellValue("英文名");
headerRow.createCell(4).setCellValue("证件号");
singleEnglishNameRow(sheet.createRow(2), "领队", "SYNTHETIC_CN_LEADER", "CAI/HAIYUN", "PX000101");
singleEnglishNameRow(sheet.createRow(3), "1", "SYNTHETIC_CN_A", "HUANG/YUHENG", "PX000102");
singleEnglishNameRow(sheet.createRow(4), "2", "SYNTHETIC_CN_B", "MA/XIUHAN", "PX000103");
singleEnglishNameRow(sheet.createRow(5), "3", "SYNTHETIC_CN_C", "HE/XIANG", "PX000104");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"synthetic-third-style.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private MockMultipartFile sourceFileWithInvalidSingleEnglishNameColumn() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
Row headerRow = sheet.createRow(1);
headerRow.createCell(0).setCellValue("序号");
headerRow.createCell(1).setCellValue("姓名");
headerRow.createCell(2).setCellValue("性别");
headerRow.createCell(3).setCellValue("英文名");
singleEnglishNameRow(sheet.createRow(2), "领队", "SYNTHETIC_CN_LEADER", "CAI", "PX000105");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"synthetic-invalid-third-style.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private void singleEnglishNameRow(
Row row,
String index,
String chineseName,
String englishName,
String passportNo) {
row.createCell(0).setCellValue(index);
row.createCell(1).setCellValue(chineseName);
row.createCell(2).setCellValue("F");
row.createCell(3).setCellValue(englishName);
row.createCell(4).setCellValue(passportNo);
}
private void englishNameRow(
Row row,
int index,