实现房表生成第二种名单样式
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源名单行和统一旅游日期区间。
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user