实现 Rooming List CP2 字段收口

This commit is contained in:
andy
2026-07-22 15:29:33 +07:00
parent 71191a10b0
commit 1236df86cf
24 changed files with 704 additions and 481 deletions

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.time.LocalDate;
import java.util.List;
/**
* Rooming List 来源 Excel 解析结果。
*
* @param guests 来源 Excel 中按原始顺序解析出的旅客名单
* @param arrival 从来源 `旅游日期` 派生的入住酒店本地日期
* @param departure 从来源 `旅游日期` 派生的离店酒店本地日期
*/
public record ReservationRoomingListSourceDataDto(
List<ReservationRoomingListGuestDto> guests,
LocalDate arrival,
LocalDate departure) {
}

View File

@@ -1,40 +1,18 @@
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 title 目标 Excel 的 Title
* @param rateCode 目标 Excel 的 Rate Code
* @param adults 目标 Excel 的 Adults为空时按当前分房人数派生
* @param children 目标 Excel 的 Children为空时默认 0
* @param paymentType 目标 Excel 的 Payment Type
* @param vip 目标 Excel 的 VIP
* @param nationality 目标 Excel 的 Nationality
* @param email 目标 Excel 的 Email
* @param idType 目标 Excel 的 ID Type
* @param idNumber 目标 Excel 的 ID Number
* @param paymentType 目标 Excel 的 Payment Type当前默认和唯一允许值为 CA
* @param nationality 目标 Excel 的 Nationality当前只允许 KR 或 CHN
*/
public record ReservationRoomingListGenerationRequest(
String hotelId,
Integer peoplePerRoom,
LocalDate arrival,
LocalDate departure,
String roomType,
String title,
String rateCode,
Integer adults,
Integer children,
String paymentType,
String vip,
String nationality,
String email,
String idType,
String idNumber) {
String nationality) {
}

View File

@@ -6,8 +6,6 @@ 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;
@@ -46,19 +44,9 @@ public class ReservationRoomingListGenerationController {
@RequestParam("file") MultipartFile file,
@RequestParam(value = "hotel_id", required = false) String hotelId,
@RequestParam("people_per_room") Integer peoplePerRoom,
@RequestParam("arrival") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate arrival,
@RequestParam("departure") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate departure,
@RequestParam("room_type") String roomType,
@RequestParam(value = "title", required = false) String title,
@RequestParam(value = "rate_code", required = false) String rateCode,
@RequestParam(value = "adults", required = false) Integer adults,
@RequestParam(value = "children", required = false) Integer children,
@RequestParam(value = "room_type", required = false) String roomType,
@RequestParam(value = "payment_type", required = false) String paymentType,
@RequestParam(value = "vip", required = false) String vip,
@RequestParam(value = "nationality", required = false) String nationality,
@RequestParam(value = "email", required = false) String email,
@RequestParam(value = "id_type", required = false) String idType,
@RequestParam(value = "id_number", required = false) String idNumber) {
@RequestParam(value = "nationality", required = false) String nationality) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_ROOMING_LIST_GENERATE.name());
ReservationRoomingListGeneratedFile generatedFile = roomingListGenerationService.generate(
@@ -66,19 +54,9 @@ public class ReservationRoomingListGenerationController {
new ReservationRoomingListGenerationRequest(
hotelId,
peoplePerRoom,
arrival,
departure,
roomType,
title,
rateCode,
adults,
children,
paymentType,
vip,
nationality,
email,
idType,
idNumber),
nationality),
actor);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(generatedFile.contentType()))

View File

@@ -4,8 +4,8 @@ import cn.nianxx.thhotel.platform.hotel.service.HotelContextException;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGeneratedFile;
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 cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationRoomingListGenerationService;
import java.time.Clock;
@@ -27,6 +27,9 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
private static final long MAX_SOURCE_FILE_BYTES = 20L * 1024L * 1024L;
private static final DateTimeFormatter FILE_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private static final String DEFAULT_PAYMENT_TYPE = "CA";
private static final String NATIONALITY_KR = "KR";
private static final String NATIONALITY_CHN = "CHN";
private final HotelContextService hotelContextService;
private final RoomingListSourceExcelParser sourceExcelParser;
@@ -57,12 +60,16 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
AuthenticatedUserContext actor) {
ReservationRoomingListGenerationRequest normalizedRequest = normalizeAndValidate(request, sourceFile);
String hotelId = resolveAccessibleHotel(normalizedRequest.hotelId());
List<ReservationRoomingListGuestDto> guests = sourceExcelParser.parse(sourceFile);
ReservationRoomingListSourceDataDto sourceData = sourceExcelParser.parse(sourceFile);
List<ReservationRoomingListRoomDto> rooms = groupingService.group(
guests,
sourceData.guests(),
normalizedRequest.peoplePerRoom(),
normalizedRequest.adults());
byte[] content = excelRenderer.render(rooms, normalizedRequest);
null);
byte[] content = excelRenderer.render(
rooms,
normalizedRequest,
sourceData.arrival(),
sourceData.departure());
return new ReservationRoomingListGeneratedFile(
buildFileName(hotelId),
EXCEL_CONTENT_TYPE,
@@ -89,40 +96,20 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
} else if (request.peoplePerRoom() <= 0) {
errors.add("people_per_room: 必须大于 0。");
}
if (request.arrival() == null) {
errors.add("arrival: 必填字段缺失。");
}
if (request.departure() == null) {
errors.add("departure: 必填字段缺失。");
}
if (request.arrival() != null && request.departure() != null
&& !request.departure().isAfter(request.arrival())) {
errors.add("departure: 离店日期必须晚于入住日期。");
}
if (isBlank(request.roomType())) {
errors.add("room_type: 必填字段缺失。");
}
validateNonNegative(request.adults(), "adults", errors);
validateNonNegative(request.children(), "children", errors);
String paymentType = normalizePaymentType(request.paymentType(), errors);
String nationality = normalizeNationality(request.nationality(), errors);
if (!errors.isEmpty()) {
throw validationError(errors);
}
return new ReservationRoomingListGenerationRequest(
trimToNull(request.hotelId()),
request.peoplePerRoom(),
request.arrival(),
request.departure(),
trimToEmpty(request.roomType()),
trimToEmpty(request.title()),
trimToEmpty(request.rateCode()),
request.adults(),
request.children(),
trimToEmpty(request.paymentType()),
trimToEmpty(request.vip()),
trimToEmpty(request.nationality()),
trimToEmpty(request.email()),
trimToEmpty(request.idType()),
trimToEmpty(request.idNumber()));
paymentType,
nationality);
}
/**
@@ -139,10 +126,29 @@ public class ReservationRoomingListGenerationServiceImpl implements ReservationR
}
}
private void validateNonNegative(Integer value, String fieldPath, List<String> errors) {
if (value != null && value < 0) {
errors.add(fieldPath + ": 不能小于 0。");
private String normalizePaymentType(String value, List<String> errors) {
String normalized = trimToNull(value);
if (normalized == null) {
return DEFAULT_PAYMENT_TYPE;
}
String code = normalized.toUpperCase(java.util.Locale.ROOT);
if (!DEFAULT_PAYMENT_TYPE.equals(code)) {
errors.add("payment_type: 当前只允许 CA。");
}
return code;
}
private String normalizeNationality(String value, List<String> errors) {
String normalized = trimToNull(value);
if (normalized == null) {
errors.add("nationality: 必填字段缺失。");
return "";
}
String code = normalized.toUpperCase(java.util.Locale.ROOT);
if (!NATIONALITY_KR.equals(code) && !NATIONALITY_CHN.equals(code)) {
errors.add("nationality: 当前只允许 KR 或 CHN。");
}
return code;
}
private ReservationRoomingListGenerationException validationError(List<String> details) {

View File

@@ -4,6 +4,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingList
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
@@ -44,13 +45,17 @@ public class RoomingListExcelRenderer {
/**
* 将分房结果渲染为 `.xlsx` 字节。
*/
public byte[] render(List<ReservationRoomingListRoomDto> rooms, ReservationRoomingListGenerationRequest request) {
public byte[] render(
List<ReservationRoomingListRoomDto> rooms,
ReservationRoomingListGenerationRequest request,
LocalDate arrival,
LocalDate departure) {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
writeHeader(workbook, sheet.createRow(0));
for (ReservationRoomingListRoomDto room : rooms) {
writeRoomRow(sheet.createRow(room.lineNumber()), room, request);
writeRoomRow(sheet.createRow(room.lineNumber()), room, request, arrival, departure);
}
for (int index = 0; index < HEADERS.length; index++) {
sheet.setColumnWidth(index, defaultColumnWidth(index));
@@ -86,25 +91,27 @@ public class RoomingListExcelRenderer {
private void writeRoomRow(
Row row,
ReservationRoomingListRoomDto room,
ReservationRoomingListGenerationRequest request) {
ReservationRoomingListGenerationRequest request,
LocalDate arrival,
LocalDate departure) {
row.createCell(0).setCellValue(room.lineNumber());
row.createCell(1).setCellValue(room.primaryGuest().lastName());
row.createCell(2).setCellValue(room.primaryGuest().firstName());
row.createCell(3).setCellValue(defaultString(request.title()));
row.createCell(4).setCellValue(request.arrival().format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(5).setCellValue(request.departure().format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(3).setCellValue("");
row.createCell(4).setCellValue(arrival.format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(5).setCellValue(departure.format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(6).setCellValue(defaultString(request.roomType()));
row.createCell(7).setCellValue(defaultString(request.rateCode()));
row.createCell(7).setCellValue("");
row.createCell(8).setCellValue(1);
row.createCell(9).setCellValue(room.adultCount());
row.createCell(10).setCellValue(request.children() == null ? 0 : request.children());
row.createCell(10).setCellValue(0);
row.createCell(11).setCellValue(defaultString(request.paymentType()));
row.createCell(12).setCellValue(defaultString(request.vip()));
row.createCell(12).setCellValue("");
row.createCell(13).setCellValue(accompanyingGuests(room));
row.createCell(14).setCellValue(defaultString(request.nationality()));
row.createCell(15).setCellValue(defaultString(request.email()));
row.createCell(16).setCellValue(defaultString(request.idType()));
row.createCell(17).setCellValue(defaultString(request.idNumber()));
row.createCell(15).setCellValue("");
row.createCell(16).setCellValue("");
row.createCell(17).setCellValue("");
}
private String accompanyingGuests(ReservationRoomingListRoomDto room) {

View File

@@ -1,10 +1,16 @@
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.ReservationRoomingListSourceDataDto;
import java.io.IOException;
import java.io.InputStream;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
@@ -22,7 +28,12 @@ import org.springframework.web.multipart.MultipartFile;
public class RoomingListSourceExcelParser {
private static final String PASSPORT_NAME_HEADER = "护照全名";
private static final String TRAVEL_DATE_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*(?:日)?$");
private static final Pattern PARTIAL_DATE_PATTERN = Pattern.compile(
"^(?:(\\d{4})\\s*(?:年|[-/.]))?\\s*(?:(\\d{1,2})\\s*(?:月|[-/.]))?\\s*(\\d{1,2})\\s*(?:日)?$");
private final RoomingListNameParser nameParser;
@@ -36,7 +47,7 @@ public class RoomingListSourceExcelParser {
/**
* 解析来源 Excel返回按原表顺序排列的旅客名单。
*/
public List<ReservationRoomingListGuestDto> parse(MultipartFile sourceFile) {
public ReservationRoomingListSourceDataDto parse(MultipartFile sourceFile) {
validateFile(sourceFile);
DataFormatter formatter = new DataFormatter();
try (InputStream inputStream = sourceFile.getInputStream();
@@ -45,12 +56,16 @@ public class RoomingListSourceExcelParser {
throw invalidFile(List.of("来源 Excel 至少需要一个 Sheet。"));
}
Sheet sheet = workbook.getSheetAt(0);
HeaderLocation header = findPassportNameHeader(sheet, formatter);
List<ReservationRoomingListGuestDto> guests = parseGuests(sheet, header, formatter);
if (guests.isEmpty()) {
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("护照全名列没有可用旅客姓名。"));
}
return guests;
return new ReservationRoomingListSourceDataDto(
sourceRows.guests(),
sourceRows.travelDateRange().arrival(),
sourceRows.travelDateRange().departure());
} catch (ReservationRoomingListGenerationException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
@@ -73,9 +88,9 @@ public class RoomingListSourceExcelParser {
}
/**
* 在前若干行内查找 `护照全名` 表头位置。
* 在前若干行内查找指定表头位置。
*/
private HeaderLocation findPassportNameHeader(Sheet sheet, DataFormatter formatter) {
private HeaderLocation findHeader(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);
@@ -84,34 +99,119 @@ public class RoomingListSourceExcelParser {
}
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
Cell cell = row.getCell(cellIndex);
if (PASSPORT_NAME_HEADER.equals(cellText(cell, formatter))) {
if (headerText.equals(cellText(cell, formatter))) {
return new HeaderLocation(rowIndex, cellIndex);
}
}
}
throw invalidFile(List.of("未找到表头:护照全名"));
throw invalidFile(List.of("未找到表头:" + headerText));
}
/**
* 从表头下一行开始提取护照姓名,空白行自动跳过。
* 从表头下一行开始提取护照姓名和旅游日期,空白姓名行自动跳过。
*/
private List<ReservationRoomingListGuestDto> parseGuests(
private SourceRows parseRows(
Sheet sheet,
HeaderLocation header,
HeaderLocation passportNameHeader,
HeaderLocation travelDateHeader,
DataFormatter formatter) {
List<ReservationRoomingListGuestDto> guests = new ArrayList<>();
for (int rowIndex = header.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
TravelDateRange expectedRange = null;
int firstDataRowIndex = Math.max(passportNameHeader.rowIndex(), travelDateHeader.rowIndex()) + 1;
for (int rowIndex = firstDataRowIndex; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
String passportName = cellText(row.getCell(header.cellIndex()), formatter);
String passportName = cellText(row.getCell(passportNameHeader.cellIndex()), formatter);
if (isBlank(passportName)) {
continue;
}
String travelDateText = cellText(row.getCell(travelDateHeader.cellIndex()), formatter);
TravelDateRange currentRange = parseTravelDateRange(travelDateText);
if (expectedRange == null) {
expectedRange = currentRange;
} else if (!Objects.equals(expectedRange, currentRange)) {
throw invalidFile(List.of("旅游日期: 同一来源文件不能包含多个不同旅游日期区间。"));
}
guests.add(nameParser.parse(rowIndex + 1, passportName));
}
return guests;
if (expectedRange == null) {
throw invalidFile(List.of("旅游日期: 没有可用旅游日期。"));
}
return new SourceRows(List.copyOf(guests), expectedRange);
}
/**
* 解析样表中的旅游日期区间,输出酒店本地入住 / 离店日期。
*/
private TravelDateRange parseTravelDateRange(String value) {
if (isBlank(value)) {
throw invalidFile(List.of("旅游日期: 不能为空。"));
}
String[] parts = splitTravelDateRange(value.trim());
if (parts.length != 2) {
throw invalidFile(List.of("旅游日期: 格式无法解析。"));
}
LocalDate arrival = parseDatePart(parts[0], null, null, true);
LocalDate departure = parseDatePart(parts[1], arrival.getYear(), arrival.getMonthValue(), false);
if (!departure.isAfter(arrival)) {
throw invalidFile(List.of("旅游日期: 结束日期必须晚于起始日期。"));
}
return new TravelDateRange(arrival, departure);
}
private String[] splitTravelDateRange(String value) {
String[] parts = value.split("\\s*(?:||—|至|到|~|)\\s*", 2);
if (parts.length == 2) {
return parts;
}
parts = value.split("\\s+-\\s+", 2);
if (parts.length == 2) {
return parts;
}
parts = value.split("(?<=日)\\s*-\\s*", 2);
return parts;
}
private LocalDate parseDatePart(
String value,
Integer defaultYear,
Integer defaultMonth,
boolean requireFullDate) {
String normalized = value.trim();
Matcher fullMatcher = FULL_DATE_PATTERN.matcher(normalized);
if (fullMatcher.matches()) {
return localDate(fullMatcher.group(1), fullMatcher.group(2), fullMatcher.group(3));
}
Matcher partialMatcher = PARTIAL_DATE_PATTERN.matcher(normalized);
if (!partialMatcher.matches()) {
throw invalidFile(List.of("旅游日期: 格式无法解析。"));
}
String yearText = partialMatcher.group(1);
String monthText = partialMatcher.group(2);
String dayText = partialMatcher.group(3);
if (requireFullDate && (isBlank(yearText) || isBlank(monthText))) {
throw invalidFile(List.of("旅游日期: 格式无法解析。"));
}
Integer year = yearText == null ? defaultYear : Integer.valueOf(yearText);
Integer month = monthText == null ? defaultMonth : Integer.valueOf(monthText);
if (year == null || month == null) {
throw invalidFile(List.of("旅游日期: 格式无法解析。"));
}
return localDate(year, month, Integer.parseInt(dayText));
}
private LocalDate localDate(String yearText, String monthText, String dayText) {
return localDate(Integer.parseInt(yearText), Integer.parseInt(monthText), Integer.parseInt(dayText));
}
private LocalDate localDate(int year, int month, int day) {
try {
return LocalDate.of(year, month, day);
} catch (DateTimeException exception) {
throw invalidFile(List.of("旅游日期: 格式无法解析。"));
}
}
private String cellText(Cell cell, DataFormatter formatter) {
@@ -141,4 +241,22 @@ public class RoomingListSourceExcelParser {
*/
private record HeaderLocation(int rowIndex, int cellIndex) {
}
/**
* 来源名单行和统一旅游日期区间。
*
* @param guests 来源旅客名单
* @param travelDateRange 统一旅游日期区间
*/
private record SourceRows(List<ReservationRoomingListGuestDto> guests, TravelDateRange travelDateRange) {
}
/**
* 旅游日期派生出的酒店本地业务日期区间。
*
* @param arrival 入住日期
* @param departure 离店日期
*/
private record TravelDateRange(LocalDate arrival, LocalDate departure) {
}
}

View File

@@ -88,13 +88,9 @@ class ReservationRoomingListGenerationControllerTest {
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1")
.param("title", "MR")
.param("rate_code", "RACK")
.param("payment_type", "CA")
.param("nationality", "CN"))
.param("nationality", "CHN"))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
@@ -102,8 +98,20 @@ class ReservationRoomingListGenerationControllerTest {
.andExpect(header().string("Content-Disposition", containsString("rooming-list-HOTEL-TEST-")))
.andExpect(result -> assertThat(result.getResponse().getContentAsByteArray())
.startsWith(new byte[]{0x50, 0x4B}))
.andExpect(result -> assertThat(titleCell(result.getResponse().getContentAsByteArray()))
.isEqualTo("MR"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 3))
.isEmpty())
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 4))
.isEqualTo("2026-05-09"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 5))
.isEqualTo("2026-05-14"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 9))
.isEqualTo("2"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 10))
.isEqualTo("0"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 11))
.isEqualTo("CA"))
.andExpect(result -> assertThat(workbookCell(result.getResponse().getContentAsByteArray(), 1, 14))
.isEqualTo("CHN"))
.andExpect(content().string(not(containsString("P123456"))));
}
@@ -113,8 +121,6 @@ class ReservationRoomingListGenerationControllerTest {
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
@@ -125,9 +131,7 @@ class ReservationRoomingListGenerationControllerTest {
mockMvc.perform(multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29"))
.param("people_per_room", "2"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
}
@@ -140,8 +144,6 @@ class ReservationRoomingListGenerationControllerTest {
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
@@ -155,9 +157,9 @@ class ReservationRoomingListGenerationControllerTest {
.file(sourceFile())
.param("hotel_id", "OTHER-HOTEL")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.param("room_type", "UG1")
.param("payment_type", "CA")
.param("nationality", "CHN"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@@ -169,28 +171,26 @@ class ReservationRoomingListGenerationControllerTest {
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFile())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026-07-26")
.param("departure", "2026-07-29"))
.param("people_per_room", "2"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("room_type: 必填字段缺失。"));
}
@Test
void shouldRejectRoomingListGenerationWhenDateFormatInvalid() throws Exception {
void shouldRejectRoomingListGenerationWhenTravelDateFormatInvalid() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.file(sourceFile())
.file(sourceFileWithInvalidTravelDate())
.param("hotel_id", "HOTEL-TEST")
.param("people_per_room", "2")
.param("arrival", "2026/07/26")
.param("departure", "2026-07-29")
.param("room_type", "UG1"))
.param("room_type", "UG1")
.param("payment_type", "CA")
.param("nationality", "CHN"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("arrival: 参数格式不合法"));
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_SOURCE_FILE_INVALID"))
.andExpect(jsonPath("$.details[0]").value("旅游日期: 格式无法解析"));
}
private MockMultipartFile sourceFile() throws Exception {
@@ -198,14 +198,17 @@ class ReservationRoomingListGenerationControllerTest {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
var sheet = workbook.createSheet("Sheet1");
Row headerRow = sheet.createRow(0);
headerRow.createCell(0).setCellValue("姓名");
headerRow.createCell(1).setCellValue("护照全");
headerRow.createCell(0).setCellValue("旅游日期");
headerRow.createCell(1).setCellValue("");
headerRow.createCell(2).setCellValue("护照全名");
Row row1 = sheet.createRow(1);
row1.createCell(0).setCellValue("游客1");
row1.createCell(1).setCellValue("LI/CHUNHONG");
row1.createCell(0).setCellValue("2026年5月9日5月14日");
row1.createCell(1).setCellValue("游客1");
row1.createCell(2).setCellValue("LI/CHUNHONG");
Row row2 = sheet.createRow(2);
row2.createCell(0).setCellValue("游客2");
row2.createCell(1).setCellValue("ZHANG GAILI");
row2.createCell(0).setCellValue("2026年5月9日5月14日");
row2.createCell(1).setCellValue("游客2");
row2.createCell(2).setCellValue("ZHANG GAILI");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
@@ -215,9 +218,42 @@ class ReservationRoomingListGenerationControllerTest {
}
}
private String titleCell(byte[] workbookBytes) throws Exception {
private MockMultipartFile sourceFileWithInvalidTravelDate() 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("护照全名");
Row row1 = sheet.createRow(1);
row1.createCell(0).setCellValue("五月九日到五月十四日");
row1.createCell(1).setCellValue("LI/CHUNHONG");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"LLT260509FA203.xlsx",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
outputStream.toByteArray());
}
}
private String workbookCell(byte[] workbookBytes, int rowIndex, int cellIndex) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new java.io.ByteArrayInputStream(workbookBytes))) {
return workbook.getSheetAt(0).getRow(1).getCell(3).getStringCellValue();
var cell = workbook.getSheetAt(0).getRow(rowIndex).getCell(cellIndex);
if (cell == null) {
return "";
}
return switch (cell.getCellType()) {
case NUMERIC -> {
double value = cell.getNumericCellValue();
if (value == Math.rint(value)) {
yield String.valueOf((long) value);
}
yield String.valueOf(value);
}
case STRING -> cell.getStringCellValue();
default -> "";
};
}
}
}

View File

@@ -10,7 +10,6 @@ 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;
@@ -60,12 +59,20 @@ class ReservationRoomingListGenerationServiceImplTest {
assertThat(cellText(firstRoom.getCell(0))).isEqualTo("1");
assertThat(cellText(firstRoom.getCell(1))).isEqualTo("LI");
assertThat(cellText(firstRoom.getCell(2))).isEqualTo("CHUNHONG");
assertThat(cellText(firstRoom.getCell(3))).isEqualTo("MR");
assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-07-29");
assertThat(cellText(firstRoom.getCell(3))).isEmpty();
assertThat(cellText(firstRoom.getCell(4))).isEqualTo("2026-05-09");
assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-05-14");
assertThat(cellText(firstRoom.getCell(6))).isEqualTo("UG1");
assertThat(cellText(firstRoom.getCell(7))).isEmpty();
assertThat(cellText(firstRoom.getCell(8))).isEqualTo("1");
assertThat(cellText(firstRoom.getCell(9))).isEqualTo("3");
assertThat(cellText(firstRoom.getCell(10))).isEqualTo("0");
assertThat(cellText(firstRoom.getCell(11))).isEqualTo("CA");
assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG GAILI, LI HONG");
assertThat(cellText(firstRoom.getCell(14))).isEqualTo("CHN");
assertThat(cellText(firstRoom.getCell(15))).isEmpty();
assertThat(cellText(firstRoom.getCell(16))).isEmpty();
assertThat(cellText(firstRoom.getCell(17))).isEmpty();
Row secondRoom = workbook.getSheetAt(0).getRow(2);
assertThat(cellText(secondRoom.getCell(1))).isEqualTo("LIU");
@@ -81,6 +88,22 @@ class ReservationRoomingListGenerationServiceImplTest {
}
}
@Test
void shouldParseTravelDateWithNumericHyphenRange() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGeneratedFile generatedFile = service.generate(
sourceFileWithTravelDate("2026-05-09 - 05-14", List.of("LI/CHUNHONG")),
request(1),
actor());
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
Row firstRoom = workbook.getSheetAt(0).getRow(1);
assertThat(cellText(firstRoom.getCell(4))).isEqualTo("2026-05-09");
assertThat(cellText(firstRoom.getCell(5))).isEqualTo("2026-05-14");
}
}
@Test
void shouldRejectSourceWorkbookWithoutPassportNameHeader() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
@@ -93,6 +116,49 @@ class ReservationRoomingListGenerationServiceImplTest {
assertThat(exception.getDetails()).contains("未找到表头:护照全名");
}
@Test
void shouldRejectSourceWorkbookWithoutTravelDateHeader() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFileWithoutTravelDateHeader(), request(2), actor()),
ReservationRoomingListGenerationException.class);
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");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFileWithMultipleTravelDates(), request(2), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
assertThat(exception.getDetails()).contains("旅游日期: 同一来源文件不能包含多个不同旅游日期区间。");
}
@Test
void shouldRejectInvalidRoomingListDefaults() throws Exception {
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(
sourceFile(passportNames32()),
new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
2,
"UG1",
"BT",
"TH"),
actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
assertThat(exception.getDetails())
.contains("payment_type: 当前只允许 CA。", "nationality: 当前只允许 KR 或 CHN。");
}
@Test
void shouldRejectNonExcelSourceFile() {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
@@ -119,19 +185,9 @@ class ReservationRoomingListGenerationServiceImplTest {
return new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
peoplePerRoom,
LocalDate.of(2026, 7, 26),
LocalDate.of(2026, 7, 29),
"UG1",
"MR",
"RACK",
null,
0,
"CA",
"",
"CN",
"",
"",
"");
"CHN");
}
private AuthenticatedUserContext actor() {
@@ -146,18 +202,24 @@ class ReservationRoomingListGenerationServiceImplTest {
}
private MockMultipartFile sourceFile(List<String> passportNames) throws Exception {
return sourceFileWithTravelDate("2026年5月9日5月14日", passportNames);
}
private MockMultipartFile sourceFileWithTravelDate(String travelDate, List<String> passportNames) 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("旅游批次");
Row headerRow = sheet.createRow(1);
headerRow.createCell(0).setCellValue("姓名");
headerRow.createCell(1).setCellValue("护照全");
headerRow.createCell(0).setCellValue("旅游日期");
headerRow.createCell(1).setCellValue("");
headerRow.createCell(2).setCellValue("护照全名");
for (int index = 0; index < passportNames.size(); index++) {
Row row = sheet.createRow(index + 2);
row.createCell(0).setCellValue("游客" + (index + 1));
row.createCell(1).setCellValue(passportNames.get(index));
row.createCell(0).setCellValue(travelDate);
row.createCell(1).setCellValue("游客" + (index + 1));
row.createCell(2).setCellValue(passportNames.get(index));
}
workbook.write(outputStream);
return new MockMultipartFile(
@@ -169,15 +231,58 @@ class ReservationRoomingListGenerationServiceImplTest {
}
private MockMultipartFile sourceFileWithoutPassportHeader() 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("2026年5月9日5月14日");
row.createCell(1).setCellValue("游客1");
row.createCell(2).setCellValue("P123456");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"invalid.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private MockMultipartFile sourceFileWithoutTravelDateHeader() 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(1).setCellValue("护照全名");
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("游客1");
row.createCell(1).setCellValue("P123456");
row.createCell(1).setCellValue("LI/CHUNHONG");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"invalid.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private MockMultipartFile sourceFileWithMultipleTravelDates() 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("护照全名");
Row row1 = sheet.createRow(1);
row1.createCell(0).setCellValue("2026年5月9日5月14日");
row1.createCell(1).setCellValue("LI/CHUNHONG");
Row row2 = sheet.createRow(2);
row2.createCell(0).setCellValue("2026年5月10日5月15日");
row2.createCell(1).setCellValue("ZHANG/GAILI");
workbook.write(outputStream);
return new MockMultipartFile(
"file",