更新第四种复杂的rooming list
This commit is contained in:
1 parent
e23131f4d0
commit
3bce336b6c
7 files changed
+315
-23
No files matched your search
+10
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+167
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+73
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user