实现Rooming List Excel生成接口
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 错误响应结构稳定。
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user