更新第四种复杂的rooming list

This commit is contained in:
andy committed 2026-08-20 11:39:20 +08:00
1 parent e23131f4d0
commit 3bce336b6c
7 files changed
+315 -23

No files matched your search

@@ -9,9 +9,18 @@ import java.util.List;
* @param guests 来源 Excel 中按原始顺序解析出的旅客名单
* @param arrival 从来源 `旅游日期` 派生的入住酒店本地日期
* @param departure 从来源 `旅游日期` 派生的离店酒店本地日期
* @param preGroupedRooms 来源 Excel 已明确给出的分房结果;为空时调用方按每房人数自动分房
*/
public record ReservationRoomingListSourceDataDto(
List<ReservationRoomingListGuestDto> guests,
LocalDate arrival,
LocalDate departure) {
LocalDate departure,
List<ReservationRoomingListRoomDto> preGroupedRooms) {
public ReservationRoomingListSourceDataDto(
List<ReservationRoomingListGuestDto> guests,
LocalDate arrival,
LocalDate departure) {
this(guests, arrival, departure, null);
}
}
@@ -66,10 +66,9 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
sourceFile,
normalizedRequest.arrival(),
normalizedRequest.departure());
List<ReservationRoomingListRoomDto> rooms = groupingService.group(
sourceData.guests(),
normalizedRequest.peoplePerRoom(),
null);
List<ReservationRoomingListRoomDto> rooms = sourceData.preGroupedRooms() == null
? groupingService.group(sourceData.guests(), normalizedRequest.peoplePerRoom(), null)
: sourceData.preGroupedRooms();
byte[] content = excelRenderer.render(
rooms,
normalizedRequest,
@@ -1,6 +1,7 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGuestDto;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListRoomDto;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListSourceDataDto;
import java.io.IOException;
import java.io.InputStream;
@@ -31,6 +32,9 @@ public class RoomingListSourceExcelParser {
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 String PINYIN_NAME_HEADER = "拼音";
private static final String CHILD_BED_HEADER = "儿童情况";
private static final String SOURCE_ROOM_GROUP_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*(?:日)?$");
@@ -95,14 +99,24 @@ public class RoomingListSourceExcelParser {
formatter,
requireManualStayDateRange(manualArrival, manualDeparture));
} else {
throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期、英文姓/英文名 或 英文名。"));
PinyinRoomHeaders pinyinRoomHeaders = findPinyinRoomHeaders(sheet, formatter);
if (pinyinRoomHeaders != null) {
sourceRows = parsePinyinRoomRows(
sheet,
pinyinRoomHeaders,
formatter,
requireManualStayDateRange(manualArrival, manualDeparture));
} else {
throw invalidFile(List.of("未找到可识别的名单表头:护照全名/旅游日期、英文姓/英文名、英文名 或 拼音/分房。"));
}
}
}
}
return new ReservationRoomingListSourceDataDto(
sourceRows.guests(),
sourceRows.travelDateRange().arrival(),
sourceRows.travelDateRange().departure());
sourceRows.travelDateRange().departure(),
sourceRows.preGroupedRooms());
} catch (ReservationRoomingListGenerationException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
@@ -226,6 +240,46 @@ public class RoomingListSourceExcelParser {
return findHeaderIfPresent(sheet, formatter, ENGLISH_FIRST_NAME_HEADER);
}
/**
* 查找第四种来源样式的拼音分房表头;要求 `拼音` 和 `分房` 位于同一表头行,`儿童情况` 可选。
*/
private PinyinRoomHeaders findPinyinRoomHeaders(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 pinyinHeader = null;
HeaderLocation childBedHeader = null;
HeaderLocation roomGroupHeader = 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 (PINYIN_NAME_HEADER.equals(text)) {
pinyinHeader = new HeaderLocation(rowIndex, cellIndex);
} else if (CHILD_BED_HEADER.equals(text)) {
childBedHeader = new HeaderLocation(rowIndex, cellIndex);
} else if (SOURCE_ROOM_GROUP_HEADER.equals(text)) {
roomGroupHeader = new HeaderLocation(rowIndex, cellIndex);
}
}
if (pinyinHeader != null && roomGroupHeader != null) {
Integer childBedCellIndex = childBedHeader == null ? null : childBedHeader.cellIndex();
return new PinyinRoomHeaders(
rowIndex,
pinyinHeader.cellIndex(),
childBedCellIndex,
roomGroupHeader.cellIndex());
}
}
return null;
}
/**
* 第二种来源样式直接使用英文姓 / 英文名,不读取证件号、中文名或生日等敏感字段。
*/
@@ -285,6 +339,79 @@ public class RoomingListSourceExcelParser {
return new SourceRows(List.copyOf(guests), manualStayDateRange);
}
/**
* 第四种来源样式读取 `拼音` 和 `分房`,按原表 `用房1间` 边界生成房间,`外住` 行排除。
*/
private SourceRows parsePinyinRoomRows(
Sheet sheet,
PinyinRoomHeaders headers,
DataFormatter formatter,
TravelDateRange manualStayDateRange) {
List<RoomGuest> currentRoomGuests = new ArrayList<>();
List<List<RoomGuest>> sourceRooms = new ArrayList<>();
for (int rowIndex = headers.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
String pinyinName = cellText(row.getCell(headers.pinyinCellIndex()), formatter);
if (isBlank(pinyinName)) {
continue;
}
String roomGroup = cellText(row.getCell(headers.roomGroupCellIndex()), formatter);
if (contains(roomGroup, "外住")) {
finishCurrentRoom(sourceRooms, currentRoomGuests);
continue;
}
if (contains(roomGroup, "用房")) {
finishCurrentRoom(sourceRooms, currentRoomGuests);
} else if (currentRoomGuests.isEmpty()) {
continue;
}
ReservationRoomingListGuestDto guest = nameParser.parse(rowIndex + 1, pinyinName);
String childBed = headers.childBedCellIndex() == null
? ""
: cellText(row.getCell(headers.childBedCellIndex()), formatter);
currentRoomGuests.add(new RoomGuest(guest, !contains(childBed, "不占床")));
}
finishCurrentRoom(sourceRooms, currentRoomGuests);
if (sourceRooms.isEmpty()) {
throw invalidFile(List.of("拼音/分房列没有可用房间。"));
}
List<ReservationRoomingListGuestDto> guests = sourceRooms.stream()
.flatMap(room -> room.stream())
.map(RoomGuest::guest)
.toList();
List<ReservationRoomingListRoomDto> rooms = new ArrayList<>();
for (List<RoomGuest> roomGuests : sourceRooms) {
RoomGuest primaryRoomGuest = roomGuests.stream()
.filter(RoomGuest::occupiesBed)
.findFirst()
.orElseThrow(() -> invalidFile(List.of("拼音/分房: 每间房至少需要一位占床旅客。")));
ReservationRoomingListGuestDto primaryGuest = primaryRoomGuest.guest();
List<ReservationRoomingListGuestDto> accompanyingGuests = roomGuests.stream()
.filter(roomGuest -> roomGuest != primaryRoomGuest)
.map(RoomGuest::guest)
.toList();
int adultCount = (int) roomGuests.stream()
.filter(RoomGuest::occupiesBed)
.count();
rooms.add(new ReservationRoomingListRoomDto(
rooms.size() + 1,
primaryGuest,
accompanyingGuests,
adultCount));
}
return new SourceRows(List.copyOf(guests), manualStayDateRange, List.copyOf(rooms));
}
private void finishCurrentRoom(List<List<RoomGuest>> sourceRooms, List<RoomGuest> currentRoomGuests) {
if (!currentRoomGuests.isEmpty()) {
sourceRooms.add(List.copyOf(currentRoomGuests));
currentRoomGuests.clear();
}
}
/**
* 无旅游日期来源样式没有可靠旅游日期,必须使用用户提交的入住 / 离店日期。
*/
@@ -404,6 +531,10 @@ public class RoomingListSourceExcelParser {
return value == null || value.trim().isEmpty();
}
private boolean contains(String value, String keyword) {
return value != null && value.contains(keyword);
}
/**
* 来源表头坐标。
*
@@ -424,12 +555,44 @@ public class RoomingListSourceExcelParser {
}
/**
* 来源名单行和统一旅游日期区间。
* 第四种来源样式的拼音姓名、儿童占床和原表分房表头坐标。
*
* @param rowIndex 0 基表头行号
* @param pinyinCellIndex `拼音` 列号
* @param childBedCellIndex `儿童情况` 列号;来源没有该列时为空
* @param roomGroupCellIndex `分房` 列号
*/
private record PinyinRoomHeaders(
int rowIndex,
int pinyinCellIndex,
Integer childBedCellIndex,
int roomGroupCellIndex) {
}
/**
* 来源原表分房中的单个旅客。
*
* @param guest 目标房表可输出的旅客姓名
* @param occupiesBed 是否占床;`儿童情况=不占床` 不计入 Adults,但仍进入 Accompanying Guest
*/
private record RoomGuest(ReservationRoomingListGuestDto guest, boolean occupiesBed) {
}
/**
* 来源名单行、统一旅游日期区间和可选原表分房结果。
*
* @param guests 来源旅客名单
* @param travelDateRange 统一旅游日期区间
* @param preGroupedRooms 来源已明确给出的分房结果;为空时按每间房人数自动分房
*/
private record SourceRows(List<ReservationRoomingListGuestDto> guests, TravelDateRange travelDateRange) {
private record SourceRows(
List<ReservationRoomingListGuestDto> guests,
TravelDateRange travelDateRange,
List<ReservationRoomingListRoomDto> preGroupedRooms) {
private SourceRows(List<ReservationRoomingListGuestDto> guests, TravelDateRange travelDateRange) {
this(guests, travelDateRange, null);
}
}
/**
@@ -206,6 +206,35 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
@Test
void shouldGenerateRoomingListWorkbookByPinyinColumnAndSourceRoomGroups() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGeneratedFile generatedFile = service.generate(
sourceFileWithPinyinRoomGroups(),
request(99, LocalDate.of(2026, 8, 23), LocalDate.of(2026, 8, 26)),
actor());
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
assertThat(workbook.getSheetAt(0).getPhysicalNumberOfRows()).isEqualTo(3);
Row firstRoom = workbook.getSheetAt(0).getRow(1);
assertThat(cellText(firstRoom.getCell(0))).isEqualTo("1");
assertThat(cellText(firstRoom.getCell(1))).isEqualTo("ZHANG");
assertThat(cellText(firstRoom.getCell(2))).isEqualTo("JIANWEI");
assertThat(cellText(firstRoom.getCell(4))).isEqualTo("23/08/2026");
assertThat(cellText(firstRoom.getCell(5))).isEqualTo("26/08/2026");
assertThat(cellText(firstRoom.getCell(9))).isEqualTo("2");
assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG QIWEI, ZHOU YANLI");
Row secondRoom = workbook.getSheetAt(0).getRow(2);
assertThat(cellText(secondRoom.getCell(0))).isEqualTo("2");
assertThat(cellText(secondRoom.getCell(1))).isEqualTo("ZHANG");
assertThat(cellText(secondRoom.getCell(2))).isEqualTo("TONGFANG");
assertThat(cellText(secondRoom.getCell(9))).isEqualTo("2");
assertThat(cellText(secondRoom.getCell(13))).isEqualTo("CHEN WENXIAN");
}
}
@Test
void shouldRejectEnglishNameSourceWorkbookWithoutManualStayDates() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
@@ -561,6 +590,34 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
private MockMultipartFile sourceFileWithPinyinRoomGroups() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("团队名单表");
Row titleRow = sheet.createRow(0);
titleRow.createCell(0).setCellValue("TJT260821A 2026-08-23 - 2026-08-26");
Row headerRow = sheet.createRow(4);
headerRow.createCell(0).setCellValue("序号");
headerRow.createCell(1).setCellValue("姓名");
headerRow.createCell(2).setCellValue("拼音");
headerRow.createCell(11).setCellValue("儿童情况");
headerRow.createCell(12).setCellValue("分房");
headerRow.createCell(13).setCellValue("备注");
pinyinRoomRow(sheet.createRow(5), "1L", "领队", "CHEN ZHAOWEN", "", "外住", "领队");
pinyinRoomRow(sheet.createRow(6), "1", "儿童A", "ZHANG QIWEI", "不占床", "用房1间", "一家");
pinyinRoomRow(sheet.createRow(7), "2", "旅客A", "ZHANG JIANWEI", "", "", "");
pinyinRoomRow(sheet.createRow(8), "3", "旅客B", "ZHOU YANLI", "", "", "");
pinyinRoomRow(sheet.createRow(9), "4", "旅客C", "ZHANG TONGFANG", "", "用房1间", "夫妻");
pinyinRoomRow(sheet.createRow(10), "5", "旅客D", "CHEN WENXIAN", "", "", "");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"synthetic-pinyin-room-groups.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private void singleEnglishNameRow(
Row row,
String index,
@@ -574,6 +631,22 @@ class ReservationRoomingListGenerationServiceImplTest {
row.createCell(4).setCellValue(passportNo);
}
private void pinyinRoomRow(
Row row,
String index,
String chineseName,
String pinyin,
String childBed,
String rooming,
String remark) {
row.createCell(0).setCellValue(index);
row.createCell(1).setCellValue(chineseName);
row.createCell(2).setCellValue(pinyin);
row.createCell(11).setCellValue(childBed);
row.createCell(12).setCellValue(rooming);
row.createCell(13).setCellValue(remark);
}
private void englishNameRow(
Row row,
int index,