实现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

@@ -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();
}
}