兼容第三种房表名单格式
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 名字
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user