实现Rooming List Excel生成接口

This commit is contained in:
andy
2026-07-18 11:36:16 +07:00
parent 75d860129c
commit d3a52e7605
25 changed files with 1709 additions and 3 deletions

View File

@@ -13,6 +13,7 @@ public enum PlatformPermissionCode {
RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_AUDIT_READ,
RESERVATION_INVOICE_GENERATE,
RESERVATION_ROOMING_LIST_GENERATE,
HOTEL_SWITCH,
SYSTEM_AUTH_READ,
SYSTEM_USER_MANAGE,

View File

@@ -291,6 +291,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
PlatformPermissionCode.RESERVATION_OPERA_SIM_EXECUTE,
PlatformPermissionCode.RESERVATION_AUDIT_READ,
PlatformPermissionCode.RESERVATION_INVOICE_GENERATE,
PlatformPermissionCode.RESERVATION_ROOMING_LIST_GENERATE,
PlatformPermissionCode.SOURCE_MESSAGE_READ,
PlatformPermissionCode.SOURCE_MESSAGE_ORIGINAL_READ));
matrix.put(PlatformRoleCode.RESERVATION_VIEWER, List.of(
@@ -317,6 +318,7 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
case RESERVATION_OPERA_SIM_EXECUTE -> "执行 OPERA 模拟";
case RESERVATION_AUDIT_READ -> "读取任务审计";
case RESERVATION_INVOICE_GENERATE -> "生成预订发票";
case RESERVATION_ROOMING_LIST_GENERATE -> "生成 Rooming List";
case HOTEL_SWITCH -> "切换酒店";
case SYSTEM_AUTH_READ -> "读取当前登录上下文";
case SYSTEM_USER_MANAGE -> "管理用户";
@@ -336,7 +338,8 @@ public class PlatformIdentityBootstrapRunner implements ApplicationRunner {
case SOURCE_MESSAGE_READ, SOURCE_MESSAGE_ORIGINAL_READ -> "SOURCE_MESSAGE";
case RESERVATION_ORDER_READ, RESERVATION_TASK_READ, RESERVATION_TASK_EDIT,
RESERVATION_TASK_CONFIRM, RESERVATION_OPERA_SIM_EXECUTE,
RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE -> "RESERVATION";
RESERVATION_AUDIT_READ, RESERVATION_INVOICE_GENERATE,
RESERVATION_ROOMING_LIST_GENERATE -> "RESERVATION";
case HOTEL_SWITCH, HOTEL_MANAGE -> "HOTEL";
default -> "SYSTEM";
};

View File

@@ -0,0 +1,14 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* Rooming List 生成后的下载文件。
*
* @param fileName 下载文件名
* @param contentType 文件 MIME 类型
* @param content 文件字节内容
*/
public record ReservationRoomingListGeneratedFile(
String fileName,
String contentType,
byte[] content) {
}

View File

@@ -0,0 +1,16 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
/**
* 来源名单中解析出的旅客姓名。
*
* @param rowNumber 来源 Excel 中的 1 基行号
* @param lastName 目标 Excel 的 Name 字段,通常为护照姓氏
* @param firstName 目标 Excel 的 First Name 字段,通常为护照名字
* @param displayName 同房陪同人展示姓名,按目标模板写入 Accompanying Guests
*/
public record ReservationRoomingListGuestDto(
int rowNumber,
String lastName,
String firstName,
String displayName) {
}

View File

@@ -0,0 +1,18 @@
package cn.nianxx.thhotel.workflows.reservation.common.dto;
import java.util.List;
/**
* Rooming List 按每房人数分组后的目标行。
*
* @param lineNumber 目标 Excel 的 Line 行号
* @param primaryGuest 每间房第一位旅客,用于写入 Name / First Name
* @param accompanyingGuests 同房陪同旅客列表
* @param adultCount 当前房间成人数
*/
public record ReservationRoomingListRoomDto(
int lineNumber,
ReservationRoomingListGuestDto primaryGuest,
List<ReservationRoomingListGuestDto> accompanyingGuests,
int adultCount) {
}

View File

@@ -0,0 +1,38 @@
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 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
*/
public record ReservationRoomingListGenerationRequest(
String hotelId,
Integer peoplePerRoom,
LocalDate arrival,
LocalDate departure,
String roomType,
String rateCode,
Integer adults,
Integer children,
String paymentType,
String vip,
String nationality,
String email,
String idType,
String idNumber) {
}

View File

@@ -0,0 +1,90 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
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;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* Reservation Rooming List 生成接口。第一版接收来源名单并直接返回 `.xlsx` 下载。
*/
@RestController
@RequestMapping("/api/reservation/rooming-lists")
public class ReservationRoomingListGenerationController {
private final FrontendAuthorizationService authorizationService;
private final ReservationRoomingListGenerationService roomingListGenerationService;
/**
* 注入前端鉴权服务和 Rooming List 生成服务。
*/
public ReservationRoomingListGenerationController(
FrontendAuthorizationService authorizationService,
ReservationRoomingListGenerationService roomingListGenerationService) {
this.authorizationService = authorizationService;
this.roomingListGenerationService = roomingListGenerationService;
}
/**
* 生成 Rooming List Excel。需要登录、Rooming List 生成权限和酒店访问权。
*/
@PostMapping(value = "/generations", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<byte[]> generateRoomingList(
@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 = "rate_code", required = false) String rateCode,
@RequestParam(value = "adults", required = false) Integer adults,
@RequestParam(value = "children", required = false) Integer children,
@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) {
AuthenticatedUserContext actor = authorizationService.requirePermission(
PlatformPermissionCode.RESERVATION_ROOMING_LIST_GENERATE.name());
ReservationRoomingListGeneratedFile generatedFile = roomingListGenerationService.generate(
file,
new ReservationRoomingListGenerationRequest(
hotelId,
peoplePerRoom,
arrival,
departure,
roomType,
rateCode,
adults,
children,
paymentType,
vip,
nationality,
email,
idType,
idNumber),
actor);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(generatedFile.contentType()))
.contentLength(generatedFile.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(generatedFile.fileName())
.build()
.toString())
.body(generatedFile.content());
}
}

View File

@@ -0,0 +1,76 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse;
import java.util.List;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
/**
* Rooming List 生成接口参数绑定异常处理。用于把 multipart 缺参和类型错误收口为稳定业务错误。
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(assignableTypes = ReservationRoomingListGenerationController.class)
public class ReservationRoomingListGenerationControllerAdvice {
private static final String ERROR_CODE = "ROOMING_LIST_VALIDATION_FAILED";
private static final String ERROR_MESSAGE = "Rooming List 字段校验失败。";
/**
* 处理 multipart 表单普通字段缺失。
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMissingRequestParameter(
MissingServletRequestParameterException exception) {
return badRequest(List.of(exception.getParameterName() + ": 必填字段缺失。"));
}
/**
* 处理 multipart 文件字段缺失。
*/
@ExceptionHandler(MissingServletRequestPartException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMissingRequestPart(
MissingServletRequestPartException exception) {
return badRequest(List.of(exception.getRequestPartName() + ": 文件字段缺失。"));
}
/**
* 处理日期、数字等请求参数格式错误。
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleTypeMismatch(
MethodArgumentTypeMismatchException exception) {
return badRequest(List.of(exception.getName() + ": 参数格式不合法。"));
}
/**
* 处理上传文件超过系统 multipart 限制。
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMaxUploadSizeExceeded(
MaxUploadSizeExceededException exception) {
return badRequest(List.of("file: 上传文件超过系统允许大小。"));
}
/**
* 处理 multipart 请求整体格式错误。
*/
@ExceptionHandler(MultipartException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleMultipartException(
MultipartException exception) {
return badRequest(List.of("file: multipart 请求不合法。"));
}
private ResponseEntity<ReservationWorkflowErrorResponse> badRequest(List<String> details) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ReservationWorkflowErrorResponse(ERROR_CODE, ERROR_MESSAGE, details));
}
}

View File

@@ -3,6 +3,7 @@ package cn.nianxx.thhotel.workflows.reservation.control;
import cn.nianxx.thhotel.platform.security.service.impl.FrontendAuthorizationException;
import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationWorkflowErrorResponse;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationInvoiceGenerationException;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationRoomingListGenerationException;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException;
import java.util.List;
import org.springframework.http.ResponseEntity;
@@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
ReservationTaskController.class,
ReservationFrontendQueryController.class,
ReservationDemoDataController.class,
ReservationInvoiceGenerationController.class
ReservationInvoiceGenerationController.class,
ReservationRoomingListGenerationController.class
})
public class ReservationTaskControllerAdvice {
@@ -46,6 +48,19 @@ public class ReservationTaskControllerAdvice {
exception.getDetails()));
}
/**
* 处理 Rooming List 生成阶段的受控业务异常。
*/
@ExceptionHandler(ReservationRoomingListGenerationException.class)
public ResponseEntity<ReservationWorkflowErrorResponse> handleRoomingListGenerationException(
ReservationRoomingListGenerationException exception) {
return ResponseEntity.status(exception.getStatus())
.body(new ReservationWorkflowErrorResponse(
exception.getErrorCode(),
exception.getMessage(),
exception.getDetails()));
}
/**
* 处理前端业务接口登录或权限不足异常,保持 Reservation 错误响应结构稳定。
*/

View File

@@ -0,0 +1,20 @@
package cn.nianxx.thhotel.workflows.reservation.service;
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.request.ReservationRoomingListGenerationRequest;
import org.springframework.web.multipart.MultipartFile;
/**
* Reservation Rooming List Excel 生成服务。
*/
public interface ReservationRoomingListGenerationService {
/**
* 根据来源名单和前端手工字段生成目标 Rooming List Excel。第一版同步返回文件字节不落库。
*/
ReservationRoomingListGeneratedFile generate(
MultipartFile sourceFile,
ReservationRoomingListGenerationRequest request,
AuthenticatedUserContext actor);
}

View File

@@ -0,0 +1,41 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import java.util.List;
import org.springframework.http.HttpStatus;
/**
* Rooming List 生成受控异常。错误响应只返回安全摘要,不回显完整客人名单或源文件内容。
*/
public class ReservationRoomingListGenerationException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
private final List<String> details;
public ReservationRoomingListGenerationException(HttpStatus status, String errorCode, String message) {
this(status, errorCode, message, List.of());
}
public ReservationRoomingListGenerationException(
HttpStatus status,
String errorCode,
String message,
List<String> details) {
super(message);
this.status = status;
this.errorCode = errorCode;
this.details = details == null ? List.of() : List.copyOf(details);
}
public HttpStatus getStatus() {
return status;
}
public String getErrorCode() {
return errorCode;
}
public List<String> getDetails() {
return details;
}
}

View File

@@ -0,0 +1,184 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
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.request.ReservationRoomingListGenerationRequest;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationRoomingListGenerationService;
import java.time.Clock;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* Reservation Rooming List Excel 生成服务实现。第一版同步生成下载文件,不落库、不上传 OSS。
*/
@Service
public class ReservationRoomingListGenerationServiceImpl implements ReservationRoomingListGenerationService {
private static final String EXCEL_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final long MAX_SOURCE_FILE_BYTES = 20L * 1024L * 1024L;
private static final DateTimeFormatter FILE_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
private final HotelContextService hotelContextService;
private final RoomingListSourceExcelParser sourceExcelParser;
private final RoomingListGroupingService groupingService;
private final RoomingListExcelRenderer excelRenderer;
/**
* 注入酒店上下文、来源解析、分房和 Excel 渲染组件。
*/
public ReservationRoomingListGenerationServiceImpl(
HotelContextService hotelContextService,
RoomingListSourceExcelParser sourceExcelParser,
RoomingListGroupingService groupingService,
RoomingListExcelRenderer excelRenderer) {
this.hotelContextService = hotelContextService;
this.sourceExcelParser = sourceExcelParser;
this.groupingService = groupingService;
this.excelRenderer = excelRenderer;
}
/**
* 生成 Rooming List Excel。只返回安全文件字节不保存完整名单。
*/
@Override
public ReservationRoomingListGeneratedFile generate(
MultipartFile sourceFile,
ReservationRoomingListGenerationRequest request,
AuthenticatedUserContext actor) {
ReservationRoomingListGenerationRequest normalizedRequest = normalizeAndValidate(request, sourceFile);
String hotelId = resolveAccessibleHotel(normalizedRequest.hotelId());
List<ReservationRoomingListGuestDto> guests = sourceExcelParser.parse(sourceFile);
List<ReservationRoomingListRoomDto> rooms = groupingService.group(
guests,
normalizedRequest.peoplePerRoom(),
normalizedRequest.adults());
byte[] content = excelRenderer.render(rooms, normalizedRequest);
return new ReservationRoomingListGeneratedFile(
buildFileName(hotelId),
EXCEL_CONTENT_TYPE,
content);
}
/**
* 归一化请求并校验手工字段和来源文件基本大小。
*/
private ReservationRoomingListGenerationRequest normalizeAndValidate(
ReservationRoomingListGenerationRequest request,
MultipartFile sourceFile) {
if (request == null) {
throw validationError(List.of("request: 请求参数不能为空。"));
}
List<String> errors = new ArrayList<>();
if (sourceFile == null || sourceFile.isEmpty()) {
errors.add("file: 来源 Excel 文件不能为空。");
} else if (sourceFile.getSize() > MAX_SOURCE_FILE_BYTES) {
errors.add("file: 来源 Excel 文件不能超过 20MB。");
}
if (request.peoplePerRoom() == null) {
errors.add("people_per_room: 必填字段缺失。");
} 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);
if (!errors.isEmpty()) {
throw validationError(errors);
}
return new ReservationRoomingListGenerationRequest(
trimToNull(request.hotelId()),
request.peoplePerRoom(),
request.arrival(),
request.departure(),
trimToEmpty(request.roomType()),
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()));
}
/**
* 校验当前登录用户可访问目标酒店。
*/
private String resolveAccessibleHotel(String requestedHotelId) {
try {
return hotelContextService.requireAccessibleHotel(requestedHotelId);
} catch (HotelContextException exception) {
throw new ReservationRoomingListGenerationException(
exception.getStatus(),
exception.getErrorCode(),
exception.getMessage());
}
}
private void validateNonNegative(Integer value, String fieldPath, List<String> errors) {
if (value != null && value < 0) {
errors.add(fieldPath + ": 不能小于 0。");
}
}
private ReservationRoomingListGenerationException validationError(List<String> details) {
return new ReservationRoomingListGenerationException(
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_VALIDATION_FAILED",
"Rooming List 字段校验失败。",
details);
}
private String buildFileName(String hotelId) {
String timestamp = java.time.LocalDateTime.now(Clock.systemUTC())
.format(FILE_TIMESTAMP_FORMATTER);
return "rooming-list-" + safeFileNamePart(hotelId) + "-" + timestamp + ".xlsx";
}
private String safeFileNamePart(String value) {
String normalized = trimToNull(value);
if (normalized == null) {
return "hotel";
}
return normalized.replaceAll("[^A-Za-z0-9._-]", "_");
}
private boolean isBlank(String value) {
return trimToNull(value) == null;
}
private String trimToEmpty(String value) {
String normalized = trimToNull(value);
return normalized == null ? "" : normalized;
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,132 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListRoomDto;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationRoomingListGenerationRequest;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* Rooming List 目标 Excel 渲染器。输出当前 PMS 导入样例需要的固定列结构。
*/
@Component
public class RoomingListExcelRenderer {
private static final String[] HEADERS = {
"Line",
"Name",
"First Name",
"Title",
"Arrival",
"Departure",
"Room Type",
"Rate Code",
"Number of Rooms",
"Adults",
"Children",
"Payment Type",
"VIP",
"Accompanying Guests",
"Nationality",
"Email",
"ID Type",
"ID Number"
};
/**
* 将分房结果渲染为 `.xlsx` 字节。
*/
public byte[] render(List<ReservationRoomingListRoomDto> rooms, ReservationRoomingListGenerationRequest request) {
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);
}
for (int index = 0; index < HEADERS.length; index++) {
sheet.setColumnWidth(index, defaultColumnWidth(index));
}
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException exception) {
throw new ReservationRoomingListGenerationException(
HttpStatus.INTERNAL_SERVER_ERROR,
"ROOMING_LIST_RENDER_FAILED",
"Rooming List Excel 生成失败。");
}
}
/**
* 写入目标模板表头。
*/
private void writeHeader(XSSFWorkbook workbook, Row headerRow) {
CellStyle style = workbook.createCellStyle();
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
for (int index = 0; index < HEADERS.length; index++) {
var cell = headerRow.createCell(index);
cell.setCellValue(HEADERS[index]);
cell.setCellStyle(style);
}
}
/**
* 写入单个房间对应的目标 Excel 行。
*/
private void writeRoomRow(
Row row,
ReservationRoomingListRoomDto room,
ReservationRoomingListGenerationRequest request) {
row.createCell(0).setCellValue(room.lineNumber());
row.createCell(1).setCellValue(room.primaryGuest().lastName());
row.createCell(2).setCellValue(room.primaryGuest().firstName());
row.createCell(3).setCellValue("");
row.createCell(4).setCellValue(request.arrival().format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(5).setCellValue(request.departure().format(DateTimeFormatter.ISO_LOCAL_DATE));
row.createCell(6).setCellValue(defaultString(request.roomType()));
row.createCell(7).setCellValue(defaultString(request.rateCode()));
row.createCell(8).setCellValue(1);
row.createCell(9).setCellValue(room.adultCount());
row.createCell(10).setCellValue(request.children() == null ? 0 : request.children());
row.createCell(11).setCellValue(defaultString(request.paymentType()));
row.createCell(12).setCellValue(defaultString(request.vip()));
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()));
}
private String accompanyingGuests(ReservationRoomingListRoomDto room) {
return room.accompanyingGuests().stream()
.map(guest -> guest.displayName())
.collect(Collectors.joining(", "));
}
/**
* 使用固定列宽,避免 POI 自动列宽依赖本机字体图形环境。
*/
private int defaultColumnWidth(int index) {
if (index == 13) {
return 36 * 256;
}
if (index == 4 || index == 5) {
return 14 * 256;
}
return 16 * 256;
}
private String defaultString(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,36 @@
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 java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Component;
/**
* Rooming List 分房服务。第一版只按来源名单顺序和固定每房人数分组。
*/
@Component
public class RoomingListGroupingService {
/**
* 按每房人数将旅客顺序切分为目标 Excel 行。
*/
public List<ReservationRoomingListRoomDto> group(
List<ReservationRoomingListGuestDto> guests,
int peoplePerRoom,
Integer requestedAdults) {
List<ReservationRoomingListRoomDto> rooms = new ArrayList<>();
for (int start = 0; start < guests.size(); start += peoplePerRoom) {
int end = Math.min(start + peoplePerRoom, guests.size());
List<ReservationRoomingListGuestDto> roomGuests = guests.subList(start, end);
int lineNumber = rooms.size() + 1;
int adultCount = requestedAdults == null ? roomGuests.size() : requestedAdults;
rooms.add(new ReservationRoomingListRoomDto(
lineNumber,
roomGuests.get(0),
List.copyOf(roomGuests.subList(1, roomGuests.size())),
adultCount));
}
return rooms;
}
}

View File

@@ -0,0 +1,97 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGuestDto;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* Rooming List 护照姓名解析器。第一版只处理斜杠或空格分隔的英文护照姓名。
*/
@Component
public class RoomingListNameParser {
/**
* 将来源 Excel 单元格中的护照全名拆分为目标模板 Name / First Name。
*/
public ReservationRoomingListGuestDto parse(int rowNumber, String rawPassportName) {
String normalized = trimToNull(rawPassportName);
if (normalized == null) {
throw invalidName(rowNumber);
}
ParsedName parsedName = normalized.contains("/")
? parseSlashName(normalized)
: parseWhitespaceName(normalized);
if (parsedName == null || isBlank(parsedName.lastName()) || isBlank(parsedName.firstName())) {
throw invalidName(rowNumber);
}
return new ReservationRoomingListGuestDto(
rowNumber,
parsedName.lastName(),
parsedName.firstName(),
parsedName.lastName() + " " + parsedName.firstName());
}
/**
* 按 `/` 拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。
*/
private ParsedName parseSlashName(String value) {
List<String> parts = Arrays.stream(value.split("/"))
.map(this::trimToNull)
.filter(part -> part != null)
.toList();
if (parts.size() < 2) {
return null;
}
return new ParsedName(parts.get(0), parts.subList(1, parts.size()).stream()
.collect(Collectors.joining(" ")));
}
/**
* 按连续空白拆分护照姓名,第一段作为姓氏,剩余部分合并作为名字。
*/
private ParsedName parseWhitespaceName(String value) {
List<String> parts = Arrays.stream(value.split("\\s+"))
.map(this::trimToNull)
.filter(part -> part != null)
.toList();
if (parts.size() < 2) {
return null;
}
return new ParsedName(parts.get(0), parts.subList(1, parts.size()).stream()
.collect(Collectors.joining(" ")));
}
/**
* 构造姓名格式错误,错误详情只包含行号,不回显原始姓名。
*/
private ReservationRoomingListGenerationException invalidName(int rowNumber) {
return new ReservationRoomingListGenerationException(
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_SOURCE_FILE_INVALID",
"来源名单文件不合法。",
List.of("" + rowNumber + " 行护照全名无法拆分为 Name / First Name。"));
}
private boolean isBlank(String value) {
return trimToNull(value) == null;
}
private String trimToNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
/**
* 护照姓名拆分结果。
*
* @param lastName 姓氏
* @param firstName 名字
*/
private record ParsedName(String lastName, String firstName) {
}
}

View File

@@ -0,0 +1,144 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import cn.nianxx.thhotel.workflows.reservation.common.dto.ReservationRoomingListGuestDto;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
/**
* Rooming List 来源 Excel 解析器。第一版扫描 `护照全名` 表头并按行提取名单。
*/
@Component
public class RoomingListSourceExcelParser {
private static final String PASSPORT_NAME_HEADER = "护照全名";
private static final int MAX_HEADER_SCAN_ROWS = 20;
private final RoomingListNameParser nameParser;
/**
* 注入护照姓名解析器。
*/
public RoomingListSourceExcelParser(RoomingListNameParser nameParser) {
this.nameParser = nameParser;
}
/**
* 解析来源 Excel返回按原表顺序排列的旅客名单。
*/
public List<ReservationRoomingListGuestDto> parse(MultipartFile sourceFile) {
validateFile(sourceFile);
DataFormatter formatter = new DataFormatter();
try (InputStream inputStream = sourceFile.getInputStream();
Workbook workbook = WorkbookFactory.create(inputStream)) {
if (workbook.getNumberOfSheets() == 0) {
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()) {
throw invalidFile(List.of("护照全名列没有可用旅客姓名。"));
}
return guests;
} catch (ReservationRoomingListGenerationException exception) {
throw exception;
} catch (IOException | RuntimeException exception) {
throw invalidFile(List.of("来源 Excel 无法读取或格式不受支持。"));
}
}
/**
* 校验来源文件基本信息,避免非 Excel 或空文件进入解析。
*/
private void validateFile(MultipartFile sourceFile) {
if (sourceFile == null || sourceFile.isEmpty()) {
throw invalidFile(List.of("file: 来源 Excel 文件不能为空。"));
}
String fileName = sourceFile.getOriginalFilename();
if (isBlank(fileName) || !(fileName.toLowerCase().endsWith(".xlsx")
|| fileName.toLowerCase().endsWith(".xls"))) {
throw invalidFile(List.of("file: 仅支持 .xls / .xlsx 文件。"));
}
}
/**
* 在前若干行内查找 `护照全名` 表头位置。
*/
private HeaderLocation findPassportNameHeader(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;
}
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
Cell cell = row.getCell(cellIndex);
if (PASSPORT_NAME_HEADER.equals(cellText(cell, formatter))) {
return new HeaderLocation(rowIndex, cellIndex);
}
}
}
throw invalidFile(List.of("未找到表头:护照全名"));
}
/**
* 从表头下一行开始提取护照姓名,空白行自动跳过。
*/
private List<ReservationRoomingListGuestDto> parseGuests(
Sheet sheet,
HeaderLocation header,
DataFormatter formatter) {
List<ReservationRoomingListGuestDto> guests = new ArrayList<>();
for (int rowIndex = header.rowIndex() + 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
String passportName = cellText(row.getCell(header.cellIndex()), formatter);
if (isBlank(passportName)) {
continue;
}
guests.add(nameParser.parse(rowIndex + 1, passportName));
}
return guests;
}
private String cellText(Cell cell, DataFormatter formatter) {
if (cell == null) {
return "";
}
return formatter.formatCellValue(cell).trim();
}
private ReservationRoomingListGenerationException invalidFile(List<String> details) {
return new ReservationRoomingListGenerationException(
HttpStatus.BAD_REQUEST,
"ROOMING_LIST_SOURCE_FILE_INVALID",
"来源名单文件不合法。",
details);
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
/**
* 来源表头坐标。
*
* @param rowIndex 0 基行号
* @param cellIndex 0 基列号
*/
private record HeaderLocation(int rowIndex, int cellIndex) {
}
}

View File

@@ -76,6 +76,7 @@ class AuthControllerTest {
"RESERVATION_OPERA_SIM_EXECUTE",
"RESERVATION_AUDIT_READ",
"RESERVATION_INVOICE_GENERATE",
"RESERVATION_ROOMING_LIST_GENERATE",
"HOTEL_SWITCH",
"SYSTEM_AUTH_READ",
"SYSTEM_USER_MANAGE",

View File

@@ -0,0 +1,202 @@
package cn.nianxx.thhotel.workflows.reservation.control;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.loginToken;
import static cn.nianxx.thhotel.support.MockMvcAuthTestSupport.performAuthorized;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import cn.nianxx.thhotel.ThHotelApplication;
import cn.nianxx.thhotel.platform.hotel.repository.PlatformHotelRepository;
import cn.nianxx.thhotel.platform.identity.common.enums.PlatformUserStatus;
import cn.nianxx.thhotel.platform.identity.domain.PlatformUserEntity;
import cn.nianxx.thhotel.platform.identity.repository.PlatformIdentityRepository;
import cn.nianxx.thhotel.platform.identity.service.impl.AuthPasswordService;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
classes = ThHotelApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:reservation_rooming_list_generation;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=rooming-admin",
"auth.bootstrap.admin.password=Admin@123456",
"auth.bootstrap.admin.display-name=系统管理员",
"auth.bootstrap.default-hotel-id=HOTEL-TEST",
"auth.bootstrap.default-hotel-name=测试酒店",
"auth.bootstrap.default-hotel-time-zone=Asia/Bangkok",
"superagent.task-result.hmac-secret=test-superagent-secret",
"mcp.enabled=true",
"mcp.auth-token=test-mcp-token"
})
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ReservationRoomingListGenerationControllerTest {
private static final String ENDPOINT = "/api/reservation/rooming-lists/generations";
@Autowired
private MockMvc mockMvc;
@Autowired
private PlatformIdentityRepository identityRepository;
@Autowired
private PlatformHotelRepository hotelRepository;
@Autowired
private AuthPasswordService passwordService;
@BeforeEach
void setUpNoPermissionUser() {
PlatformUserEntity user = identityRepository.findUserByUsername("rooming-no-permission")
.orElseGet(() -> {
LocalDateTime now = LocalDateTime.now();
PlatformUserEntity created = new PlatformUserEntity();
created.setUsername("rooming-no-permission");
created.setPasswordHash(passwordService.hash("NoPerm@123456"));
created.setDisplayName("无权限用户");
created.setUserStatus(PlatformUserStatus.ACTIVE.name());
created.setSuperAdmin(false);
created.setPasswordChangedAt(now);
created.setCreatedAt(now);
created.setUpdatedAt(now);
identityRepository.insertUser(created);
return created;
});
hotelRepository.ensureUserHotel(user.getId(), "HOTEL-TEST", true);
}
@Test
void shouldDownloadGeneratedRoomingListWorkbook() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
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("room_type", "UG1")
.param("rate_code", "RACK")
.param("payment_type", "CA")
.param("nationality", "CN"))
.andExpect(status().isOk())
.andExpect(content().contentType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.andExpect(header().string("Content-Disposition", containsString("attachment;")))
.andExpect(header().string("Content-Disposition", containsString("rooming-list-HOTEL-TEST-")))
.andExpect(result -> assertThat(result.getResponse().getContentAsByteArray())
.startsWith(new byte[]{0x50, 0x4B}))
.andExpect(content().string(not(containsString("P123456"))));
}
@Test
void shouldRejectRoomingListGenerationWhenTokenMissing() throws Exception {
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("room_type", "UG1"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
}
@Test
void shouldRejectRoomingListGenerationWhenPermissionMissing() throws Exception {
String token = loginToken(mockMvc, "rooming-no-permission", "NoPerm@123456");
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("room_type", "UG1"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
void shouldRejectRoomingListGenerationWhenHotelAccessDenied() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
performAuthorized(mockMvc, token, multipart(ENDPOINT)
.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"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
@Test
void shouldRejectRoomingListGenerationWhenRequiredFieldMissing() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
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"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("room_type: 必填字段缺失。"));
}
@Test
void shouldRejectRoomingListGenerationWhenDateFormatInvalid() throws Exception {
String token = loginToken(mockMvc, "rooming-admin", "Admin@123456");
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("room_type", "UG1"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("ROOMING_LIST_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("arrival: 参数格式不合法。"));
}
private MockMultipartFile sourceFile() 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("游客1");
row1.createCell(1).setCellValue("LI/CHUNHONG");
Row row2 = sheet.createRow(2);
row2.createCell(0).setCellValue("游客2");
row2.createCell(1).setCellValue("ZHANG GAILI");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"LLT260509FA203.xlsx",
MediaType.APPLICATION_OCTET_STREAM_VALUE,
outputStream.toByteArray());
}
}
}

View File

@@ -0,0 +1,245 @@
package cn.nianxx.thhotel.workflows.reservation.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.Mockito.when;
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.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;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
@ExtendWith(MockitoExtension.class)
class ReservationRoomingListGenerationServiceImplTest {
@Mock
private HotelContextService hotelContextService;
private ReservationRoomingListGenerationServiceImpl service;
@BeforeEach
void setUp() {
service = new ReservationRoomingListGenerationServiceImpl(
hotelContextService,
new RoomingListSourceExcelParser(new RoomingListNameParser()),
new RoomingListGroupingService(),
new RoomingListExcelRenderer());
}
@Test
void shouldGenerateRoomingListWorkbookByPassportNamesAndPeoplePerRoom() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGeneratedFile generatedFile = service.generate(
sourceFile(passportNames32()),
request(3),
actor());
assertThat(generatedFile.fileName()).startsWith("rooming-list-HOTEL-TEST-");
assertThat(generatedFile.contentType())
.isEqualTo("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
assertThat(generatedFile.content()).startsWith(new byte[]{0x50, 0x4B});
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(generatedFile.content()))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
assertThat(workbook.getSheetAt(0).getPhysicalNumberOfRows()).isEqualTo(12);
Row firstRoom = workbook.getSheetAt(0).getRow(1);
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(5))).isEqualTo("2026-07-29");
assertThat(cellText(firstRoom.getCell(6))).isEqualTo("UG1");
assertThat(cellText(firstRoom.getCell(8))).isEqualTo("1");
assertThat(cellText(firstRoom.getCell(9))).isEqualTo("3");
assertThat(cellText(firstRoom.getCell(13))).isEqualTo("ZHANG GAILI, LI HONG");
Row secondRoom = workbook.getSheetAt(0).getRow(2);
assertThat(cellText(secondRoom.getCell(1))).isEqualTo("LIU");
assertThat(cellText(secondRoom.getCell(2))).isEqualTo("JIAYI");
assertThat(cellText(secondRoom.getCell(13))).isEqualTo("ZHU XINGTONG, CHEN YUCHAI");
Row lastRoom = workbook.getSheetAt(0).getRow(11);
assertThat(cellText(lastRoom.getCell(0))).isEqualTo("11");
assertThat(cellText(lastRoom.getCell(1))).isEqualTo("ZHOU");
assertThat(cellText(lastRoom.getCell(2))).isEqualTo("XIAOYAN");
assertThat(cellText(lastRoom.getCell(9))).isEqualTo("2");
assertThat(cellText(lastRoom.getCell(13))).isEqualTo("GUO YAN");
}
}
@Test
void shouldRejectSourceWorkbookWithoutPassportNameHeader() throws Exception {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFileWithoutPassportHeader(), request(2), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
assertThat(exception.getDetails()).contains("未找到表头:护照全名");
}
@Test
void shouldRejectNonExcelSourceFile() {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(nonExcelFile(), request(2), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_SOURCE_FILE_INVALID");
assertThat(exception.getDetails()).contains("file: 仅支持 .xls / .xlsx 文件。");
}
@Test
void shouldRejectInvalidPeoplePerRoom() throws Exception {
ReservationRoomingListGenerationException exception = catchThrowableOfType(
() -> service.generate(sourceFile(passportNames32()), request(0), actor()),
ReservationRoomingListGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("ROOMING_LIST_VALIDATION_FAILED");
assertThat(exception.getDetails()).contains("people_per_room: 必须大于 0。");
}
private ReservationRoomingListGenerationRequest request(int peoplePerRoom) {
return new ReservationRoomingListGenerationRequest(
"HOTEL-TEST",
peoplePerRoom,
LocalDate.of(2026, 7, 26),
LocalDate.of(2026, 7, 29),
"UG1",
"RACK",
null,
0,
"CA",
"",
"CN",
"",
"",
"");
}
private AuthenticatedUserContext actor() {
return new AuthenticatedUserContext(
1L,
"rooming-admin",
"系统管理员",
true,
"HOTEL-TEST",
List.of("HOTEL-TEST"),
List.of("RESERVATION_ROOMING_LIST_GENERATE"));
}
private MockMultipartFile sourceFile(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("护照全名");
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));
}
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"LLT260509FA203.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
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("证件号");
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("游客1");
row.createCell(1).setCellValue("P123456");
workbook.write(outputStream);
return new MockMultipartFile(
"file",
"invalid.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
outputStream.toByteArray());
}
}
private MockMultipartFile nonExcelFile() {
return new MockMultipartFile(
"file",
"LLT260509FA203.txt",
"text/plain",
"LI/CHUNHONG".getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
private List<String> passportNames32() {
return List.of(
"LI/CHUNHONG",
"ZHANG/GAILI",
"LI HONG",
"LIU/JIAYI ",
"ZHU/XINGTONG",
"CHEN/YUCHAI",
"WANG/XIAOLI",
"SUN/JIE",
"MA/LING",
"WU/YUAN",
"HUANG/XIAOMEI",
"ZHAO/MING",
"YANG/LILI",
"XU/JUN",
"LIN/FANG",
"LUO/HUA",
"HE/MEI",
"GAO/LEI",
"PAN/XIN",
"DENG/NA",
"TANG/YAN",
"CAO/YING",
"FAN/QIANG",
"JIANG/MIN",
"SHEN/YUE",
"YU/PING",
"FENG/QIU",
"XIE/RUI",
"SONG/LAN",
"LU/JIA",
"ZHOU/XIAOYAN",
"GUO/YAN");
}
private String cellText(Cell cell) {
if (cell == null) {
return "";
}
if (cell.getCellType() == CellType.NUMERIC) {
double value = cell.getNumericCellValue();
if (value == Math.rint(value)) {
return String.valueOf((long) value);
}
return String.valueOf(value);
}
return cell.getStringCellValue();
}
}