实现 Booking Excel 预处理接入 SuperAgent

This commit is contained in:
andy
2026-07-20 00:32:01 +07:00
parent b7b04cf1ac
commit d1955f5097
45 changed files with 2955 additions and 10 deletions

View File

@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunDraft;
@@ -17,14 +18,30 @@ import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentOpe
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult;
import cn.nianxx.thhotel.integrations.ai.superagent.repository.SuperAgentDispatchRunRepository;
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStorageReadRequest;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStorageReadResult;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl.ObjectStorageException;
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalContent;
import cn.nianxx.thhotel.platform.message.common.dto.SourceMessageOriginalMediaItem;
import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository;
import cn.nianxx.thhotel.workflows.reservation.excelimport.service.ReservationBookingExcelAttachmentExtractionService;
import cn.nianxx.thhotel.workflows.reservation.excelimport.service.impl.BookingExcelExtractionProperties;
import cn.nianxx.thhotel.workflows.reservation.excelimport.service.impl.ReservationBookingExcelAttachmentExtractionServiceImpl;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
@@ -153,20 +170,234 @@ class SuperAgentDispatchServiceImplTest {
assertThat(updateCaptor.getValue().superagentParsedJson()).isEqualTo("{\"route_code\":\"S10\"}");
}
@Test
void shouldAppendBookingExcelExtractionsBeforeDispatchingToSuperAgent() throws Exception {
SuperAgentDispatchRunRepository runRepository = mock(SuperAgentDispatchRunRepository.class);
SourceMessageInboxRepository sourceMessageRepository = mock(SourceMessageInboxRepository.class);
SuperAgentOpenApiClient openApiClient = mock(SuperAgentOpenApiClient.class);
ObjectStorageService objectStorageService = mock(ObjectStorageService.class);
SuperAgentDispatchProperties properties = properties(true, true);
properties.setIncludeBookingExcelExtractions(true);
SuperAgentDispatchServiceImpl service = service(
runRepository,
sourceMessageRepository,
openApiClient,
objectStorageService,
bookingExcelExtractionService(true),
properties);
SuperAgentDispatchRunSnapshot snapshot = snapshot();
byte[] excelBytes = bookingUpdateExcelBytes();
when(runRepository.claimDue(any(), any(), any(), any(), any(Integer.class))).thenReturn(List.of(snapshot));
when(sourceMessageRepository.findPayloadJson(88001L)).thenReturn(Optional.of("""
{
"received_at": "2026-07-19T03:00:00Z",
"source": {"external_message_id": "mail-agentbus-001"},
"body": {"text": "Please check highlighted booking update rows"},
"attachments": [
{
"file_name": "WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx",
"content_type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"external_url": "https://oss.example.test/booking-update.xlsx"
}
]
}
"""));
when(sourceMessageRepository.findOriginalContent(88001L)).thenReturn(Optional.of(new SourceMessageOriginalContent(
88001L,
null,
null,
List.of(new SourceMessageOriginalMediaItem(
"ATTACHMENT",
"WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
(long) excelBytes.length,
"https://oss.example.test/private/booking-update.xlsx?token=download-secret",
"media-agentbus-excel-001")))));
when(objectStorageService.readObject(any(ObjectStorageReadRequest.class))).thenReturn(new ObjectStorageReadResult(
excelBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
(long) excelBytes.length));
when(openApiClient.invokeMessage(any(SuperAgentOpenApiMessageRequest.class))).thenReturn(successResult());
service.processDueDispatches();
ArgumentCaptor<ObjectStorageReadRequest> storageRequestCaptor =
ArgumentCaptor.forClass(ObjectStorageReadRequest.class);
verify(objectStorageService).readObject(storageRequestCaptor.capture());
assertThat(storageRequestCaptor.getValue().externalUrl())
.isEqualTo("https://oss.example.test/private/booking-update.xlsx?token=download-secret");
ArgumentCaptor<SuperAgentOpenApiMessageRequest> requestCaptor =
ArgumentCaptor.forClass(SuperAgentOpenApiMessageRequest.class);
verify(openApiClient).invokeMessage(requestCaptor.capture());
assertThat(requestCaptor.getValue().message())
.contains("\"attachment_extractions\"")
.contains("\"file_type\":\"BOOKING_UPDATE\"")
.contains("\"group_code\":\"GRP-2605-001\"")
.doesNotContain("download-secret");
}
@Test
void shouldNotReadAttachmentsWhenBookingExcelDispatchSwitchDisabled() {
SuperAgentDispatchRunRepository runRepository = mock(SuperAgentDispatchRunRepository.class);
SourceMessageInboxRepository sourceMessageRepository = mock(SourceMessageInboxRepository.class);
SuperAgentOpenApiClient openApiClient = mock(SuperAgentOpenApiClient.class);
ObjectStorageService objectStorageService = mock(ObjectStorageService.class);
SuperAgentDispatchServiceImpl service = service(
runRepository,
sourceMessageRepository,
openApiClient,
objectStorageService,
bookingExcelExtractionService(true),
properties(true, true));
SuperAgentDispatchRunSnapshot snapshot = snapshot();
when(runRepository.claimDue(any(), any(), any(), any(), any(Integer.class))).thenReturn(List.of(snapshot));
when(sourceMessageRepository.findPayloadJson(88001L)).thenReturn(Optional.of("""
{
"received_at": "2026-07-19T03:00:00Z",
"source": {"external_message_id": "mail-agentbus-001"},
"attachments": [
{"file_name": "WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx"}
]
}
"""));
when(openApiClient.invokeMessage(any(SuperAgentOpenApiMessageRequest.class))).thenReturn(successResult());
service.processDueDispatches();
verify(sourceMessageRepository, never()).findOriginalContent(88001L);
verifyNoInteractions(objectStorageService);
ArgumentCaptor<SuperAgentOpenApiMessageRequest> requestCaptor =
ArgumentCaptor.forClass(SuperAgentOpenApiMessageRequest.class);
verify(openApiClient).invokeMessage(requestCaptor.capture());
assertThat(requestCaptor.getValue().message()).doesNotContain("\"attachment_extractions\"");
}
@Test
void shouldContinueDispatchWithSafeWarningWhenBookingExcelAttachmentReadFails() {
SuperAgentDispatchRunRepository runRepository = mock(SuperAgentDispatchRunRepository.class);
SourceMessageInboxRepository sourceMessageRepository = mock(SourceMessageInboxRepository.class);
SuperAgentOpenApiClient openApiClient = mock(SuperAgentOpenApiClient.class);
ObjectStorageService objectStorageService = mock(ObjectStorageService.class);
SuperAgentDispatchProperties properties = properties(true, true);
properties.setIncludeBookingExcelExtractions(true);
SuperAgentDispatchServiceImpl service = service(
runRepository,
sourceMessageRepository,
openApiClient,
objectStorageService,
bookingExcelExtractionService(true),
properties);
SuperAgentDispatchRunSnapshot snapshot = snapshot();
when(runRepository.claimDue(any(), any(), any(), any(), any(Integer.class))).thenReturn(List.of(snapshot));
when(sourceMessageRepository.findPayloadJson(88001L)).thenReturn(Optional.of("""
{
"received_at": "2026-07-19T03:00:00Z",
"source": {"external_message_id": "mail-agentbus-001"},
"body": {"text": "Please check attachment"}
}
"""));
when(sourceMessageRepository.findOriginalContent(88001L)).thenReturn(Optional.of(new SourceMessageOriginalContent(
88001L,
null,
null,
List.of(new SourceMessageOriginalMediaItem(
"ATTACHMENT",
"WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2048L,
"https://oss.example.test/private/booking-update.xlsx?token=download-secret",
"media-agentbus-excel-001")))));
when(objectStorageService.readObject(any(ObjectStorageReadRequest.class)))
.thenThrow(new ObjectStorageException("对象存储读取失败token=download-secret。"));
when(openApiClient.invokeMessage(any(SuperAgentOpenApiMessageRequest.class))).thenReturn(successResult());
service.processDueDispatches();
ArgumentCaptor<SuperAgentOpenApiMessageRequest> requestCaptor =
ArgumentCaptor.forClass(SuperAgentOpenApiMessageRequest.class);
verify(openApiClient).invokeMessage(requestCaptor.capture());
assertThat(requestCaptor.getValue().message())
.contains("\"attachment_extractions\"")
.contains("ATTACHMENT_DOWNLOAD_FAILED")
.doesNotContain("download-secret");
verify(runRepository).markSucceeded(any(SuperAgentDispatchRunSuccessUpdate.class));
}
@Test
void shouldContinueDispatchWhenBookingExcelPayloadEnrichmentFails() {
SuperAgentDispatchRunRepository runRepository = mock(SuperAgentDispatchRunRepository.class);
SourceMessageInboxRepository sourceMessageRepository = mock(SourceMessageInboxRepository.class);
SuperAgentOpenApiClient openApiClient = mock(SuperAgentOpenApiClient.class);
SuperAgentDispatchProperties properties = properties(true, true);
properties.setIncludeBookingExcelExtractions(true);
SuperAgentDispatchServiceImpl service = service(
runRepository,
sourceMessageRepository,
openApiClient,
mock(ObjectStorageService.class),
bookingExcelExtractionService(true),
properties);
SuperAgentDispatchRunSnapshot snapshot = snapshot();
when(runRepository.claimDue(any(), any(), any(), any(), any(Integer.class))).thenReturn(List.of(snapshot));
when(sourceMessageRepository.findPayloadJson(88001L)).thenReturn(Optional.of("""
{"received_at":"2026-07-19T03:00:00Z","source":{"external_message_id":"mail-agentbus-001"}}
"""));
when(sourceMessageRepository.findOriginalContent(88001L))
.thenThrow(new RuntimeException("unexpected token=download-secret"));
when(openApiClient.invokeMessage(any(SuperAgentOpenApiMessageRequest.class))).thenReturn(successResult());
service.processDueDispatches();
ArgumentCaptor<SuperAgentOpenApiMessageRequest> requestCaptor =
ArgumentCaptor.forClass(SuperAgentOpenApiMessageRequest.class);
verify(openApiClient).invokeMessage(requestCaptor.capture());
assertThat(requestCaptor.getValue().message())
.doesNotContain("\"attachment_extractions\"")
.doesNotContain("download-secret");
verify(runRepository).markSucceeded(any(SuperAgentDispatchRunSuccessUpdate.class));
}
private SuperAgentDispatchServiceImpl service(
SuperAgentDispatchRunRepository runRepository,
SourceMessageInboxRepository sourceMessageRepository,
SuperAgentOpenApiClient openApiClient,
SuperAgentDispatchProperties properties) {
return service(
runRepository,
sourceMessageRepository,
openApiClient,
mock(ObjectStorageService.class),
bookingExcelExtractionService(false),
properties);
}
private SuperAgentDispatchServiceImpl service(
SuperAgentDispatchRunRepository runRepository,
SourceMessageInboxRepository sourceMessageRepository,
SuperAgentOpenApiClient openApiClient,
ObjectStorageService objectStorageService,
ReservationBookingExcelAttachmentExtractionService bookingExcelExtractionService,
SuperAgentDispatchProperties properties) {
return new SuperAgentDispatchServiceImpl(
properties,
new SuperAgentOpenApiProperties(),
runRepository,
sourceMessageRepository,
openApiClient,
objectStorageService,
bookingExcelExtractionService,
new ObjectMapper());
}
private ReservationBookingExcelAttachmentExtractionService bookingExcelExtractionService(boolean enabled) {
BookingExcelExtractionProperties properties = new BookingExcelExtractionProperties();
properties.setEnabled(enabled);
properties.setLookbackMonths(6);
properties.setMaxSelectedMonths(3);
properties.setMaxFileSizeBytes(2 * 1024 * 1024);
return new ReservationBookingExcelAttachmentExtractionServiceImpl(properties);
}
private SuperAgentDispatchProperties properties(boolean enabled, boolean workerEnabled) {
SuperAgentDispatchProperties properties = new SuperAgentDispatchProperties();
properties.setEnabled(enabled);
@@ -224,4 +455,51 @@ class SuperAgentDispatchServiceImplTest {
now,
now);
}
private SuperAgentOpenApiResult successResult() {
return new SuperAgentOpenApiResult(
"session-dispatch-001",
"run-dispatch-001",
"profile-dispatch",
"version-dispatch",
"model-dispatch",
"{\"route_code\":\"S10\"}",
10,
5,
15,
List.of("trace", "end"),
List.of(),
"/api/open/agent-sessions/session-dispatch-001/runs/run-dispatch-001",
"4");
}
private byte[] bookingUpdateExcelBytes() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("BOOKING 05-2026");
Row header = sheet.createRow(0);
List<String> headers = List.of("Group Code", "Hotel", "Check In", "Check Out", "Room Type", "Rooms", "Remark");
for (int index = 0; index < headers.size(); index++) {
header.createCell(index).setCellValue(headers.get(index));
}
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("GRP-2605-001");
row.createCell(1).setCellValue("Wyndham Liantai");
row.createCell(2).setCellValue("2026-05-01");
row.createCell(3).setCellValue("2026-05-03");
row.createCell(4).setCellValue("UG1");
row.createCell(5).setCellValue("2");
row.createCell(6).setCellValue("Need update");
row.getCell(6).setCellStyle(yellowFill(workbook));
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private XSSFCellStyle yellowFill(XSSFWorkbook workbook) {
XSSFCellStyle style = workbook.createCellStyle();
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setFillForegroundColor(IndexedColors.YELLOW.getIndex());
return style;
}
}

View File

@@ -0,0 +1,196 @@
package cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStorageReadRequest;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStorageReadResult;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.Authenticator;
import java.net.CookieHandler;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSession;
import org.junit.jupiter.api.Test;
class AliyunOssObjectStorageServiceImplTest {
@Test
void shouldReadObjectContentFromSavedUrl() {
TrackingInputStream responseBody = new TrackingInputStream("excel-bytes".getBytes(StandardCharsets.UTF_8));
AliyunOssObjectStorageServiceImpl service = new AliyunOssObjectStorageServiceImpl(
new AliyunOssProperties(),
new FakeHttpClient(200, responseBody));
ObjectStorageReadResult result = service.readObject(new ObjectStorageReadRequest(
"https://oss.example.test/private/booking.xlsx?token=download-secret",
1024L));
assertThat(result.content()).isEqualTo("excel-bytes".getBytes(StandardCharsets.UTF_8));
assertThat(result.contentType()).isEqualTo("application/vnd.ms-excel");
assertThat(result.sizeBytes()).isEqualTo(11L);
assertThat(responseBody.closed()).isTrue();
}
@Test
void shouldRejectObjectWhenContentExceedsLimit() {
TrackingInputStream responseBody = new TrackingInputStream("too-large".getBytes(StandardCharsets.UTF_8));
AliyunOssObjectStorageServiceImpl service = new AliyunOssObjectStorageServiceImpl(
new AliyunOssProperties(),
new FakeHttpClient(200, responseBody));
assertThatThrownBy(() -> service.readObject(new ObjectStorageReadRequest(
"https://oss.example.test/private/too-large.xlsx?token=download-secret",
4L)))
.isInstanceOf(ObjectStorageException.class)
.hasMessageContaining("对象存储读取内容超过大小限制")
.hasMessageNotContaining("download-secret");
assertThat(responseBody.closed()).isTrue();
}
private static final class TrackingInputStream extends ByteArrayInputStream {
private boolean closed;
private TrackingInputStream(byte[] buffer) {
super(buffer);
}
@Override
public void close() throws IOException {
closed = true;
super.close();
}
private boolean closed() {
return closed;
}
}
private static final class FakeHttpClient extends HttpClient {
private final int statusCode;
private final TrackingInputStream responseBody;
private FakeHttpClient(int statusCode, TrackingInputStream responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
@Override
public Optional<CookieHandler> cookieHandler() {
return Optional.empty();
}
@Override
public Optional<Duration> connectTimeout() {
return Optional.empty();
}
@Override
public Redirect followRedirects() {
return Redirect.NEVER;
}
@Override
public Optional<ProxySelector> proxy() {
return Optional.empty();
}
@Override
public SSLContext sslContext() {
try {
return SSLContext.getDefault();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
@Override
public SSLParameters sslParameters() {
return new SSLParameters();
}
@Override
public Optional<Authenticator> authenticator() {
return Optional.empty();
}
@Override
public Version version() {
return Version.HTTP_1_1;
}
@Override
public Optional<Executor> executor() {
return Optional.empty();
}
@Override
@SuppressWarnings("unchecked")
public <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) {
return new SimpleHttpResponse<>(request, statusCode, (T) responseBody);
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request,
HttpResponse.BodyHandler<T> responseBodyHandler) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
@Override
public <T> CompletableFuture<HttpResponse<T>> sendAsync(
HttpRequest request,
HttpResponse.BodyHandler<T> responseBodyHandler,
HttpResponse.PushPromiseHandler<T> pushPromiseHandler) {
return CompletableFuture.failedFuture(new UnsupportedOperationException());
}
}
private record SimpleHttpResponse<T>(
HttpRequest request,
int statusCode,
T body
) implements HttpResponse<T> {
@Override
public Optional<HttpResponse<T>> previousResponse() {
return Optional.empty();
}
@Override
public HttpHeaders headers() {
return HttpHeaders.of(
Map.of("Content-Type", List.of("application/vnd.ms-excel")),
(name, value) -> true);
}
@Override
public Optional<SSLSession> sslSession() {
return Optional.empty();
}
@Override
public java.net.URI uri() {
return request.uri();
}
@Override
public HttpClient.Version version() {
return HttpClient.Version.HTTP_1_1;
}
}
}

View File

@@ -26,13 +26,21 @@ import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectSto
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.platform.debug.service.DebugEmlSuperAgentRunService;
import cn.nianxx.thhotel.workflows.reservation.excelimport.service.impl.BookingExcelExtractionProperties;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.function.Consumer;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -57,7 +65,9 @@ import org.springframework.web.client.RestClientResponseException;
"debug.eml-upload.access-key=test-debug-upload-key",
"debug.eml-upload.max-file-bytes=1048576",
"debug.eml-upload.sse-heartbeat-interval=25ms",
"debug.eml-upload.include-booking-excel-extractions=true",
"aliyun.oss.debug-eml-prefix=debug/eml/",
"reservation.booking-excel-extraction.enabled=true",
"superagent.open-api.enabled=true",
"superagent.open-api.external-subject-id=test-debug-eml"
})
@@ -77,6 +87,9 @@ class DebugEmlSuperAgentControllerTest {
@Autowired
private DebugEmlSuperAgentRunService runService;
@Autowired
private BookingExcelExtractionProperties bookingExcelExtractionProperties;
@MockBean
private ObjectStorageService objectStorageService;
@@ -259,6 +272,106 @@ class DebugEmlSuperAgentControllerTest {
.andExpect(content().string(not(containsString("test-debug-upload-key"))));
}
@Test
void shouldAttachBookingExcelExtractionsToDebugPayloadAndSuperAgentMessage() throws Exception {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
ObjectStoragePutRequest request = invocation.getArgument(0);
return new ObjectStoragePutResult(
request.objectKey(),
"https://oss.example.test/" + request.objectKey(),
request.contentType(),
request.sizeBytes());
});
when(superAgentOpenApiClient.invokeMailDebug(any())).thenReturn(new SuperAgentOpenApiResult(
"session-debug-excel",
"run-debug-excel",
"profile-debug",
"profile-version-debug",
"debug-model",
"{\"route_code\":\"S10\"}",
11,
7,
18,
List.of("metadata", "values", "end")));
mockMvc.perform(multipart(ENDPOINT)
.file(emlFileWithBookingExcelAttachment())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "controller-excel-extraction")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.attachment_extractions[0].file_type").value("BOOKING_UPDATE"))
.andExpect(jsonPath("$.attachment_extractions[0].month_filter.base_month").value("2026-07"))
.andExpect(jsonPath("$.attachment_extractions[0].month_filter.selected_months[0]").value("2026-05"))
.andExpect(jsonPath("$.attachment_extractions[0].sheets[0].highlighted_rows[0].row.group_code")
.value("GRP-2605-001"))
.andExpect(jsonPath("$.agentbus_like_payload.attachment_extractions[0].file_type")
.value("BOOKING_UPDATE"))
.andExpect(jsonPath("$.agentbus_like_payload.attachment_extractions[0].sheets[0]"
+ ".highlighted_rows[0].highlight_cells[0].header").value("Remark"));
ArgumentCaptor<SuperAgentMailDebugRequest> superAgentRequestCaptor =
ArgumentCaptor.forClass(SuperAgentMailDebugRequest.class);
verify(superAgentOpenApiClient).invokeMailDebug(superAgentRequestCaptor.capture());
org.assertj.core.api.Assertions.assertThat(superAgentRequestCaptor.getValue().message())
.contains("\"attachment_extractions\"")
.contains("\"file_type\":\"BOOKING_UPDATE\"")
.contains("\"group_code\":\"GRP-2605-001\"")
.doesNotContain("test-debug-upload-key");
}
@Test
void shouldQueryBookingExcelExtractionsFromPersistedDebugPayload() throws Exception {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFileWithBookingExcelAttachment())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "query-excel-extraction")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated());
Long runId = jdbcTemplate.queryForObject("""
SELECT id
FROM platform_debug_eml_superagent_run
WHERE run_label = 'query-excel-extraction'
""", Long.class);
mockMvc.perform(get(ENDPOINT + "/" + runId)
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attachment_extractions[0].file_type").value("BOOKING_UPDATE"))
.andExpect(jsonPath("$.attachment_extractions[0].month_filter.selected_months[0]").value("2026-05"))
.andExpect(jsonPath("$.attachment_extractions[0].sheets[0].highlighted_rows[0].row.group_code")
.value("GRP-2605-001"));
}
@Test
void shouldNotAttachBookingExcelExtractionsWhenGlobalExtractionDisabled() throws Exception {
bookingExcelExtractionProperties.setEnabled(false);
try {
mockStorageAndSuperAgentSuccess();
mockMvc.perform(multipart(ENDPOINT)
.file(emlFileWithBookingExcelAttachment())
.param("hotel_id", "HOTEL-TEST")
.param("run_label", "controller-excel-extraction-disabled")
.header("X-TH-Hotel-Debug-Upload-Key", "test-debug-upload-key"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.attachment_extractions").doesNotExist())
.andExpect(jsonPath("$.agentbus_like_payload.attachment_extractions").doesNotExist());
ArgumentCaptor<SuperAgentMailDebugRequest> superAgentRequestCaptor =
ArgumentCaptor.forClass(SuperAgentMailDebugRequest.class);
verify(superAgentOpenApiClient).invokeMailDebug(superAgentRequestCaptor.capture());
org.assertj.core.api.Assertions.assertThat(superAgentRequestCaptor.getValue().message())
.doesNotContain("\"attachment_extractions\"")
.doesNotContain("BOOKING_EXCEL_EXTRACTION_DISABLED");
} finally {
bookingExcelExtractionProperties.setEnabled(true);
}
}
@Test
void shouldStreamDebugStagesSuperAgentTraceAndFinalResult() throws Exception {
when(objectStorageService.putObject(any())).thenAnswer(invocation -> {
@@ -716,6 +829,14 @@ class DebugEmlSuperAgentControllerTest {
unsafeHtmlEmlBytes());
}
private MockMultipartFile emlFileWithBookingExcelAttachment() throws Exception {
return new MockMultipartFile(
"file",
"debug-booking-with-excel.eml",
MediaType.TEXT_PLAIN_VALUE,
emlBytesWithBookingExcelAttachment());
}
private static final class FailingOutputStream extends OutputStream {
@Override
@@ -802,4 +923,61 @@ class DebugEmlSuperAgentControllerTest {
--rel-boundary--
""".replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
private byte[] emlBytesWithBookingExcelAttachment() throws Exception {
String attachmentBase64 = Base64.getMimeEncoder(76, "\r\n".getBytes(StandardCharsets.UTF_8))
.encodeToString(bookingUpdateExcelBytes());
return ("""
From: Guest <guest@example.test>
To: Reservations <reservations@example.test>
Subject: Debug Booking With Excel
Date: Thu, 09 Jul 2026 01:30:00 +0000
Message-ID: <debug-controller-excel-message-001@example.test>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="mixed-boundary"
--mixed-boundary
Content-Type: text/plain; charset=UTF-8
Please check highlighted booking update rows.
--mixed-boundary
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; name="WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx"
%s
--mixed-boundary--
""".formatted(attachmentBase64)).replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
}
private byte[] bookingUpdateExcelBytes() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("BOOKING 05-2026");
Row header = sheet.createRow(0);
List<String> headers = List.of("Group Code", "Hotel", "Check In", "Check Out", "Room Type", "Rooms", "Remark");
for (int index = 0; index < headers.size(); index++) {
header.createCell(index).setCellValue(headers.get(index));
}
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("GRP-2605-001");
row.createCell(1).setCellValue("Wyndham Liantai");
row.createCell(2).setCellValue("2026-05-01");
row.createCell(3).setCellValue("2026-05-03");
row.createCell(4).setCellValue("UG1");
row.createCell(5).setCellValue("2");
row.createCell(6).setCellValue("Need update");
row.getCell(6).setCellStyle(yellowFill(workbook));
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private XSSFCellStyle yellowFill(XSSFWorkbook workbook) {
XSSFCellStyle style = workbook.createCellStyle();
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setFillForegroundColor(IndexedColors.YELLOW.getIndex());
return style;
}
}

View File

@@ -0,0 +1,223 @@
package cn.nianxx.thhotel.workflows.reservation.excelimport.service.impl;
import static org.assertj.core.api.Assertions.assertThat;
import cn.nianxx.thhotel.workflows.reservation.excelimport.common.enums.BookingExcelFileType;
import cn.nianxx.thhotel.workflows.reservation.excelimport.common.request.BookingExcelAttachmentExtractionRequest;
import cn.nianxx.thhotel.workflows.reservation.excelimport.common.result.BookingExcelAttachmentExtractionResult;
import cn.nianxx.thhotel.workflows.reservation.excelimport.common.result.BookingExcelHighlightedRowResult;
import cn.nianxx.thhotel.workflows.reservation.excelimport.service.ReservationBookingExcelAttachmentExtractionService;
import java.io.ByteArrayOutputStream;
import java.time.Instant;
import java.time.ZoneId;
import java.util.List;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFCellStyle;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.util.unit.DataSize;
class ReservationBookingExcelAttachmentExtractionServiceImplTest {
private ReservationBookingExcelAttachmentExtractionService service;
@BeforeEach
void setUp() {
BookingExcelExtractionProperties properties = new BookingExcelExtractionProperties();
properties.setEnabled(true);
properties.setLookbackMonths(6);
properties.setMaxSelectedMonths(3);
properties.setMaxFileSizeBytes(2 * 1024 * 1024);
properties.setMaxSheets(24);
properties.setMaxRowsPerSheet(2000);
service = new ReservationBookingExcelAttachmentExtractionServiceImpl(properties);
}
@Test
void shouldExcludePassengerRosterWorkbookByHeaderCombination() throws Exception {
BookingExcelAttachmentExtractionResult result = service.extract(request(
"LLTNamLis.xlsx",
passengerRosterWorkbook(),
Instant.parse("2026-07-19T03:00:00Z")));
assertThat(result.fileType()).isEqualTo(BookingExcelFileType.PASSENGER_ROSTER);
assertThat(result.excluded()).isTrue();
assertThat(result.skippedReason()).isEqualTo("PASSENGER_ROSTER");
assertThat(result.sheets()).isEmpty();
assertThat(result.warnings()).contains("PASSENGER_ROSTER_EXCLUDED");
}
@Test
void shouldSelectLatestThreeAvailableMonthsWithinSixMonthLookback() throws Exception {
BookingExcelAttachmentExtractionResult result = service.extract(request(
"WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx",
bookingUpdateWorkbookWithMonths("BOOKING 03-2026", "BOOKING 04-2026", "BOOKING 05-2026", "BOOKING 06-2026"),
Instant.parse("2026-07-19T03:00:00Z")));
assertThat(result.fileType()).isEqualTo(BookingExcelFileType.BOOKING_UPDATE);
assertThat(result.excluded()).isFalse();
assertThat(result.monthFilter().baseMonth()).isEqualTo("2026-07");
assertThat(result.monthFilter().candidateFromMonth()).isEqualTo("2026-02");
assertThat(result.monthFilter().candidateToMonth()).isEqualTo("2026-07");
assertThat(result.monthFilter().availableMonths())
.containsExactly("2026-03", "2026-04", "2026-05", "2026-06");
assertThat(result.monthFilter().selectedMonths()).containsExactly("2026-04", "2026-05", "2026-06");
assertThat(result.monthFilter().matchedSheets())
.containsExactly("BOOKING 04-2026", "BOOKING 05-2026", "BOOKING 06-2026");
assertThat(result.monthFilter().skippedSheets()).contains("BOOKING 03-2026");
assertThat(result.sheets()).extracting(sheet -> sheet.sheetName())
.containsExactly("BOOKING 04-2026", "BOOKING 05-2026", "BOOKING 06-2026");
}
@Test
void shouldExtractRowsWhenAnyBusinessColumnHasBackgroundFill() throws Exception {
BookingExcelAttachmentExtractionResult result = service.extract(request(
"春节-附加费用xlsx.xlsx",
surchargeWorkbookWithHighlightedRows(),
Instant.parse("2026-07-19T03:00:00Z")));
assertThat(result.fileType()).isEqualTo(BookingExcelFileType.BOOKING_SURCHARGE);
assertThat(result.sheets()).hasSize(1);
assertThat(result.sheets().get(0).highlightedRows()).hasSize(1);
BookingExcelHighlightedRowResult highlightedRow = result.sheets().get(0).highlightedRows().get(0);
assertThat(highlightedRow.rowNumber()).isEqualTo(2);
assertThat(highlightedRow.row()).containsEntry("group_code", "GRP-2605-001");
assertThat(highlightedRow.row()).containsEntry("hotel", "Wyndham Liantai");
assertThat(highlightedRow.row()).containsEntry("remark", "春节附加费待确认");
assertThat(highlightedRow.highlightColors()).contains("FFFFFF00");
assertThat(highlightedRow.highlightCells()).hasSize(1);
assertThat(highlightedRow.highlightCells().get(0).header()).isEqualTo("Remark");
assertThat(highlightedRow.rawRow()).containsEntry("A", "GRP-2605-001");
}
@Test
void shouldWarnWhenAvailableMonthsLessThanLimit() throws Exception {
BookingExcelAttachmentExtractionResult result = service.extract(request(
"WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx",
bookingUpdateWorkbookWithMonths("BOOKING 03-2026", "BOOKING 04-2026"),
Instant.parse("2026-07-19T03:00:00Z")));
assertThat(result.monthFilter().selectedMonths()).containsExactly("2026-03", "2026-04");
assertThat(result.warnings()).contains("MATCHED_MONTHS_LESS_THAN_LIMIT");
}
@Test
void shouldHonorConfiguredMaxFileSizeDataSize() throws Exception {
BookingExcelExtractionProperties properties = new BookingExcelExtractionProperties();
properties.setEnabled(true);
properties.setMaxFileSize(DataSize.ofBytes(1));
ReservationBookingExcelAttachmentExtractionService localService =
new ReservationBookingExcelAttachmentExtractionServiceImpl(properties);
BookingExcelAttachmentExtractionResult result = localService.extract(request(
"WYNDHAM LIANTAI 2026 UPDATE BOOKING.xlsx",
bookingUpdateWorkbookWithMonths("BOOKING 05-2026"),
Instant.parse("2026-07-19T03:00:00Z")));
assertThat(result.excluded()).isTrue();
assertThat(result.skippedReason()).isEqualTo("EXCEL_ATTACHMENT_TOO_LARGE");
assertThat(result.warnings()).contains("EXCEL_ATTACHMENT_TOO_LARGE");
}
private BookingExcelAttachmentExtractionRequest request(
String fileName,
byte[] content,
Instant baseInstant) {
return new BookingExcelAttachmentExtractionRequest(
fileName,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
(long) content.length,
content,
baseInstant,
ZoneId.of("Asia/Bangkok"),
null,
null);
}
private byte[] passengerRosterWorkbook() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("Sheet1");
Row header = sheet.createRow(0);
List<String> headers = List.of(
"旅游批次", "旅游日期", "团号", "姓名", "护照全名", "证件号", "性别", "生日");
for (int index = 0; index < headers.size(); index++) {
header.createCell(index).setCellValue(headers.get(index));
}
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private byte[] bookingUpdateWorkbookWithMonths(String... sheetNames) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
XSSFCellStyle highlightStyle = yellowFill(workbook);
for (String sheetName : sheetNames) {
Sheet sheet = workbook.createSheet(sheetName);
createBookingHeader(sheet);
Row dataRow = sheet.createRow(1);
dataRow.createCell(0).setCellValue("GRP-" + sheetName.substring(sheetName.length() - 7));
dataRow.createCell(1).setCellValue("Wyndham Liantai");
dataRow.createCell(2).setCellValue("2026-05-01");
dataRow.createCell(3).setCellValue("2026-05-03");
dataRow.createCell(4).setCellValue("UG1");
dataRow.createCell(5).setCellValue("2");
dataRow.createCell(6).setCellValue("Need update");
dataRow.getCell(6).setCellStyle(highlightStyle);
}
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private byte[] surchargeWorkbookWithHighlightedRows() throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("BOOKING 05-2026");
createBookingHeader(sheet);
XSSFCellStyle headerStyle = yellowFill(workbook);
sheet.getRow(0).forEach(cell -> cell.setCellStyle(headerStyle));
Row highlighted = sheet.createRow(1);
highlighted.createCell(0).setCellValue("GRP-2605-001");
highlighted.createCell(1).setCellValue("Wyndham Liantai");
highlighted.createCell(2).setCellValue("2026-05-01");
highlighted.createCell(3).setCellValue("2026-05-03");
highlighted.createCell(4).setCellValue("UG1");
highlighted.createCell(5).setCellValue("2");
highlighted.createCell(6).setCellValue("春节附加费待确认");
highlighted.getCell(6).setCellStyle(yellowFill(workbook));
Row normal = sheet.createRow(2);
normal.createCell(0).setCellValue("GRP-2605-002");
normal.createCell(1).setCellValue("Wyndham Liantai");
normal.createCell(2).setCellValue("2026-05-05");
normal.createCell(3).setCellValue("2026-05-07");
normal.createCell(4).setCellValue("RM1");
normal.createCell(5).setCellValue("1");
normal.createCell(6).setCellValue("无需处理");
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private void createBookingHeader(Sheet sheet) {
Row header = sheet.createRow(0);
List<String> headers = List.of("Group Code", "Hotel", "Check In", "Check Out", "Room Type", "Rooms", "Remark");
for (int index = 0; index < headers.size(); index++) {
header.createCell(index).setCellValue(headers.get(index));
}
}
private XSSFCellStyle yellowFill(XSSFWorkbook workbook) {
XSSFCellStyle style = workbook.createCellStyle();
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
style.setFillForegroundColor(IndexedColors.YELLOW.getIndex());
return style;
}
}