实现 Rooming List CP2 字段收口
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -> "";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user