实现房表生成第二种名单样式
This commit is contained in:
1 parent
14eda89b0b
commit
eaea8787d9
22 files changed
+645
-64
No files matched your search
+6
@@ -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) {
|
||||
|
||||
+10
@@ -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),
|
||||
|
||||
+6
-1
@@ -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);
|
||||
|
||||
+148
-10
@@ -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) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源名单行和统一旅游日期区间。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user