实现手工发票生成后端接口

This commit is contained in:
andy
2026-07-17 11:24:51 +07:00
parent bc0413f21c
commit 11478c6913
35 changed files with 2416 additions and 2 deletions

View File

@@ -0,0 +1,400 @@
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
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.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConversionInput;
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
import cn.nianxx.thhotel.platform.documentconversion.service.DocumentConversionException;
import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter;
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.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
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_invoice_generation;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
"auth.bootstrap.admin.username=invoice-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 ReservationInvoiceGenerationControllerTest {
private static final String ENDPOINT = "/api/reservation/invoices/manual-generations";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private PlatformIdentityRepository identityRepository;
@Autowired
private PlatformHotelRepository hotelRepository;
@Autowired
private AuthPasswordService passwordService;
@MockBean
private ExcelToPdfConverter excelToPdfConverter;
@MockBean
private ObjectStorageService objectStorageService;
@BeforeEach
void setUpNoPermissionUser() {
jdbcTemplate.update("delete from workflow_reservation_invoice_generation");
PlatformUserEntity user = identityRepository.findUserByUsername("invoice-no-permission")
.orElseGet(() -> {
LocalDateTime now = LocalDateTime.now();
PlatformUserEntity created = new PlatformUserEntity();
created.setUsername("invoice-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 shouldGenerateManualInvoicePdfAndPersistGenerationRecord() throws Exception {
byte[] pdfBytes = "%PDF-1.7\nmanual-invoice".getBytes(StandardCharsets.UTF_8);
when(excelToPdfConverter.convert(any())).thenReturn(new ExcelToPdfConvertedDocument(
"proforma-invoice.pdf",
MediaType.APPLICATION_PDF_VALUE,
(long) pdfBytes.length,
pdfBytes,
88L));
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());
});
String token = loginToken(mockMvc, "invoice-admin", "Admin@123456");
performAuthorized(mockMvc, token, post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(validRequest()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.generation_status").value("SUCCEEDED"))
.andExpect(jsonPath("$.source_type").value("MANUAL"))
.andExpect(jsonPath("$.hotel_id").value("HOTEL-TEST"))
.andExpect(jsonPath("$.template_code").value("PROFORMA_INVOICE_V1"))
.andExpect(jsonPath("$.pdf_url", containsString("https://oss.example.test/")))
.andExpect(jsonPath("$.pdf_object_key", containsString("reservation-invoices/HOTEL-TEST/")))
.andExpect(jsonPath("$.generated_excel_object_key", containsString("reservation-invoices/HOTEL-TEST/")))
.andExpect(jsonPath("$.totals.subtotal").value(16822.43))
.andExpect(jsonPath("$.totals.vat").value(1177.57))
.andExpect(jsonPath("$.totals.total").value(18000.00))
.andExpect(jsonPath("$.totals.currency").value("THB"))
.andExpect(jsonPath("$.created_at", containsString("Z")))
.andExpect(content().string(not(containsString("op.liantaitravel@gmail.com"))));
ArgumentCaptor<ExcelToPdfConversionInput> converterInputCaptor =
ArgumentCaptor.forClass(ExcelToPdfConversionInput.class);
verify(excelToPdfConverter).convert(converterInputCaptor.capture());
assertThat(converterInputCaptor.getValue().fileName()).endsWith(".xlsx");
assertThat(converterInputCaptor.getValue().content()).startsWith(new byte[]{0x50, 0x4B});
ArgumentCaptor<ObjectStoragePutRequest> storageRequestCaptor =
ArgumentCaptor.forClass(ObjectStoragePutRequest.class);
verify(objectStorageService, times(2)).putObject(storageRequestCaptor.capture());
assertThat(storageRequestCaptor.getAllValues())
.extracting(ObjectStoragePutRequest::objectKey)
.anySatisfy(objectKey -> assertThat(objectKey).endsWith(".xlsx"))
.anySatisfy(objectKey -> assertThat(objectKey).endsWith(".pdf"));
Integer generatedRows = jdbcTemplate.queryForObject(
"select count(*) from workflow_reservation_invoice_generation where hotel_id = 'HOTEL-TEST'",
Integer.class);
assertThat(generatedRows).isEqualTo(1);
}
@Test
void shouldRejectManualInvoiceWhenTokenMissing() throws Exception {
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(validRequest()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error_code").value("AUTH_TOKEN_REQUIRED"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldRejectManualInvoiceWhenPermissionMissing() throws Exception {
String token = loginToken(mockMvc, "invoice-no-permission", "NoPerm@123456");
performAuthorized(mockMvc, token, post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(validRequest()))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldRejectManualInvoiceWhenChargeLinesMissing() throws Exception {
String token = loginToken(mockMvc, "invoice-admin", "Admin@123456");
performAuthorized(mockMvc, token, post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(emptyChargesRequest()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("RESERVATION_INVOICE_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value("invoice_payload.charges: 至少需要一条费用明细。"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldRejectManualInvoiceWhenTaskDoesNotBelongToOrder() throws Exception {
insertOrder(2080010000000000001L, 2080010000000000101L, "GRP-M009-ORDER-1");
insertOrder(2080010000000000002L, 2080010000000000102L, "GRP-M009-ORDER-2");
insertTask(2080010000000000201L, 2080010000000000002L, 2080010000000000102L);
String token = loginToken(mockMvc, "invoice-admin", "Admin@123456");
performAuthorized(mockMvc, token, post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(validRequestWithContext(2080010000000000001L, 2080010000000000201L)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("RESERVATION_INVOICE_CONTEXT_MISMATCH"));
verifyNoInteractions(excelToPdfConverter, objectStorageService);
}
@Test
void shouldKeepGeneratedExcelObjectKeyWhenPdfConversionFails() 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(excelToPdfConverter.convert(any())).thenThrow(new DocumentConversionException(
HttpStatus.BAD_GATEWAY,
"DOCUMENT_CONVERSION_FAILED",
"PDF 转换失败。"));
String token = loginToken(mockMvc, "invoice-admin", "Admin@123456");
performAuthorized(mockMvc, token, post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.content(validRequest()))
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.error_code").value("DOCUMENT_CONVERSION_FAILED"));
String generationStatus = jdbcTemplate.queryForObject(
"SELECT generation_status FROM workflow_reservation_invoice_generation",
String.class);
String generatedExcelObjectKey = jdbcTemplate.queryForObject(
"SELECT generated_excel_object_key FROM workflow_reservation_invoice_generation",
String.class);
String safeErrorCode = jdbcTemplate.queryForObject(
"SELECT safe_error_code FROM workflow_reservation_invoice_generation",
String.class);
assertThat(generationStatus).isEqualTo("FAILED");
assertThat(generatedExcelObjectKey).endsWith(".xlsx");
assertThat(safeErrorCode).isEqualTo("DOCUMENT_CONVERSION_FAILED");
verify(objectStorageService).putObject(any());
}
private String validRequest() {
return """
{
"hotel_id": "HOTEL-TEST",
"source_type": "MANUAL",
"task_id": null,
"order_id": null,
"template_code": "PROFORMA_INVOICE_V1",
"invoice_payload": {
"document": {
"invoice_date": "2026-07-17",
"booking_date": "2026-07-12",
"due_date": "2026-07-22"
},
"recipient": {
"company_code": "LIAN_TAI",
"contact_id": "LIAN_TAI_KHUN_ANN",
"company": "LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"attention": "Khun Ann",
"address": "2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240",
"telephone": "061-397-2675",
"email": "op.liantaitravel@gmail.com"
},
"booking": {
"group_name": "GRP-DEMO-0802",
"arrival_date": "2026-08-02",
"departure_date": "2026-08-05",
"room_rate_note": "includingBF",
"extra_bed_rate": 1200
},
"charges": [
{
"description": "GRP-DEMO-0802",
"room_type": "Deluxe Room",
"quantity": 2,
"rate": 3000,
"nights": 3
}
]
}
}
""";
}
private String validRequestWithContext(Long orderId, Long taskId) {
return """
{
"hotel_id": "HOTEL-TEST",
"source_type": "MANUAL",
"task_id": %s,
"order_id": %s,
"template_code": "PROFORMA_INVOICE_V1",
"invoice_payload": {
"document": {
"invoice_date": "2026-07-17",
"booking_date": "2026-07-12",
"due_date": "2026-07-22"
},
"recipient": {
"company_code": "LIAN_TAI",
"contact_id": "LIAN_TAI_KHUN_ANN",
"company": "LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"attention": "Khun Ann",
"address": "2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240",
"telephone": "061-397-2675",
"email": "op.liantaitravel@gmail.com"
},
"booking": {
"group_name": "GRP-DEMO-0802",
"arrival_date": "2026-08-02",
"departure_date": "2026-08-05",
"room_rate_note": "includingBF",
"extra_bed_rate": 1200
},
"charges": [
{
"description": "GRP-DEMO-0802",
"room_type": "Deluxe Room",
"quantity": 2,
"rate": 3000,
"nights": 3
}
]
}
}
""".formatted(taskId, orderId);
}
private String emptyChargesRequest() {
return """
{
"hotel_id": "HOTEL-TEST",
"source_type": "MANUAL",
"task_id": null,
"order_id": null,
"template_code": "PROFORMA_INVOICE_V1",
"invoice_payload": {
"document": {
"invoice_date": "2026-07-17",
"booking_date": "2026-07-12",
"due_date": "2026-07-22"
},
"recipient": {
"company_code": "LIAN_TAI",
"contact_id": "LIAN_TAI_KHUN_ANN",
"company": "LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"attention": "Khun Ann",
"address": "2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240",
"telephone": "061-397-2675",
"email": "op.liantaitravel@gmail.com"
},
"booking": {
"group_name": "GRP-DEMO-0802",
"arrival_date": "2026-08-02",
"departure_date": "2026-08-05",
"room_rate_note": "includingBF",
"extra_bed_rate": 1200
},
"charges": []
}
}
""";
}
private void insertOrder(Long orderId, Long sourceMessageId, String groupCode) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_order (
id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, business_key_source, display_name,
source_message_id, version, latest_activity_at, created_at, updated_at
)
VALUES (?, 'HOTEL-TEST', 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'USER_CONFIRMED', ?,
?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", orderId, groupCode, groupCode, "TMP-" + orderId, groupCode, sourceMessageId);
}
private void insertTask(Long taskId, Long orderId, Long sourceMessageId) {
jdbcTemplate.update("""
INSERT INTO workflow_reservation_task (
id, hotel_id, order_id, source_message_id, ai_transition_id,
result_type, ai_task_type, system_task_type, task_card_type, task_subtype,
task_status, queue_participation, execution_order, blocked_until_parent_completed,
version, created_at, updated_at
)
VALUES (?, 'HOTEL-TEST', ?, ?, ?, 'normal_task', 'Update Booking', 'UPDATE_BOOKING',
'UPDATE_BOOKING', 'manual_invoice_test', 'READY', 1, 1, 0, 0,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", taskId, orderId, sourceMessageId, taskId - 1);
}
}

View File

@@ -0,0 +1,143 @@
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult;
import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService;
import cn.nianxx.thhotel.platform.documentconversion.common.dto.ExcelToPdfConvertedDocument;
import cn.nianxx.thhotel.platform.documentconversion.service.ExcelToPdfConverter;
import cn.nianxx.thhotel.platform.hotel.service.HotelContextService;
import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceBookingRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceChargeRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceDocumentRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceManualGenerationRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoicePayloadRequest;
import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationInvoiceRecipientRequest;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationAiWorkflowRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationInvoiceGenerationRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
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.http.MediaType;
@ExtendWith(MockitoExtension.class)
class ReservationInvoiceGenerationServiceImplTest {
@Mock
private HotelContextService hotelContextService;
@Mock
private ReservationAiWorkflowRepository workflowRepository;
@Mock
private ReservationInvoiceGenerationRepository invoiceGenerationRepository;
@Mock
private ReservationInvoiceExcelTemplateRenderer templateRenderer;
@Mock
private ExcelToPdfConverter excelToPdfConverter;
@Mock
private ObjectStorageService objectStorageService;
private ReservationInvoiceGenerationServiceImpl service;
@BeforeEach
void setUp() {
service = new ReservationInvoiceGenerationServiceImpl(
new ObjectMapper().findAndRegisterModules(),
hotelContextService,
workflowRepository,
invoiceGenerationRepository,
templateRenderer,
excelToPdfConverter,
objectStorageService);
}
@Test
void shouldNotMarkGenerationSucceededBeforeAuditIsWritten() {
when(hotelContextService.requireAccessibleHotel("HOTEL-TEST")).thenReturn("HOTEL-TEST");
when(invoiceGenerationRepository.insertGeneration(any())).thenReturn(2080020000000000001L);
when(templateRenderer.render(any())).thenReturn(new byte[]{0x50, 0x4B, 0x03, 0x04});
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(excelToPdfConverter.convert(any())).thenReturn(new ExcelToPdfConvertedDocument(
"proforma-invoice.pdf",
MediaType.APPLICATION_PDF_VALUE,
8L,
"%PDF-1.7".getBytes(),
10L));
when(workflowRepository.insertAuditLog(any())).thenThrow(new RuntimeException("audit insert failed"));
ReservationInvoiceGenerationException exception = catchThrowableOfType(
() -> service.generateManualInvoice(validRequest(), actor()),
ReservationInvoiceGenerationException.class);
assertThat(exception.getErrorCode()).isEqualTo("RESERVATION_INVOICE_GENERATION_FAILED");
verify(invoiceGenerationRepository, never()).markSucceeded(
anyLong(), any(), any(), any(), any(), any());
verify(invoiceGenerationRepository).markFailed(
anyLong(), any(), any(), any());
}
private AuthenticatedUserContext actor() {
return new AuthenticatedUserContext(
1L,
"invoice-admin",
"系统管理员",
true,
"HOTEL-TEST",
List.of("HOTEL-TEST"),
List.of("RESERVATION_INVOICE_GENERATE"));
}
private ReservationInvoiceManualGenerationRequest validRequest() {
return new ReservationInvoiceManualGenerationRequest(
"HOTEL-TEST",
"MANUAL",
null,
null,
"PROFORMA_INVOICE_V1",
new ReservationInvoicePayloadRequest(
new ReservationInvoiceDocumentRequest(
LocalDate.of(2026, 7, 17),
LocalDate.of(2026, 7, 12),
LocalDate.of(2026, 7, 22)),
new ReservationInvoiceRecipientRequest(
"LIAN_TAI",
"LIAN_TAI_KHUN_ANN",
"LIAN TAI TRAVEL (THAILAND) CO., LTD.",
"Khun Ann",
"2/86 Rajpattana Road, Rajpattana, Sapansoong, Bangkok, TH, 10240",
"061-397-2675",
"op.liantaitravel@gmail.com"),
new ReservationInvoiceBookingRequest(
"GRP-DEMO-0802",
LocalDate.of(2026, 8, 2),
LocalDate.of(2026, 8, 5),
"includingBF",
new BigDecimal("1200")),
List.of(new ReservationInvoiceChargeRequest(
"GRP-DEMO-0802",
"Deluxe Room",
new BigDecimal("2"),
new BigDecimal("3000"),
new BigDecimal("3")))));
}
}