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

This commit is contained in:
andy committed 2026-07-24 11:55:29 +07:00
1 parent 14eda89b0b
commit eaea8787d9
22 files changed
+645 -64

No files matched your search

@@ -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) {
}
/**
* 来源名单行和统一旅游日期区间。
*