实现 M002 CP5 任务确认与 OPERA 模拟骨架

This commit is contained in:
andy
2026-07-07 16:24:17 +08:00
parent f7d77b45bd
commit bd7b791bee
37 changed files with 2548 additions and 57 deletions

View File

@@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
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;
@@ -494,6 +495,368 @@ class SuperAgentTaskResultControllerTest {
.value(org.hamcrest.Matchers.contains("CNF-MATRIX-001")));
}
@Test
void shouldSaveDraftPayloadForEditableTaskWithoutChangingAiSnapshot() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-draft-save-001");
String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-DRAFT-001");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-draft-save-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {
"extracted_fields.room_quantity": 3,
"extracted_fields.pms_room_type_code": "RM3"
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(taskId))
.andExpect(jsonPath("$.task_status").value("PENDING_CONFIRM"))
.andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.room_quantity']").value(3))
.andExpect(jsonPath("$.draft_payload.field_values['extracted_fields.pms_room_type_code']").value("RM3"));
String draftPayloadJson = jdbcTemplate.queryForObject("""
SELECT draft_payload_json
FROM workflow_reservation_task_card
WHERE task_id = ?
""", String.class, Long.valueOf(taskId));
String aiPayloadJson = jdbcTemplate.queryForObject("""
SELECT ai_payload_json
FROM workflow_reservation_task_card
WHERE task_id = ?
""", String.class, Long.valueOf(taskId));
assertThat(draftPayloadJson).contains("\"extracted_fields.room_quantity\":3");
assertThat(aiPayloadJson).contains("\"room_quantity\":2");
}
@Test
void shouldConfirmTaskWithMergedPayloadAndMoveToReady() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-confirm-ready-001");
String body = completeNewBookingBody(source.inboxId().toString(), null);
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-confirm-ready-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {
"extracted_fields.room_quantity": 4,
"extracted_fields.pms_room_type_code": "RM4"
}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(taskId))
.andExpect(jsonPath("$.task_status").value("READY"))
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.room_quantity']").value(4))
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.pms_room_type_code']").value("RM4"))
.andExpect(jsonPath("$.confirmed_payload.field_values['extracted_fields.arrival_date']").value("2026-08-01"))
.andExpect(jsonPath("$.opera_operations.length()").value(2))
.andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1))
.andExpect(jsonPath("$.opera_operations[0].operation_status").value("PENDING"))
.andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2))
.andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING"));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.opera_operations.length()").value(2))
.andExpect(jsonPath("$.opera_operations[0].operation_sequence").value(1))
.andExpect(jsonPath("$.opera_operations[0].operation_status").value("PENDING"))
.andExpect(jsonPath("$.opera_operations[1].operation_sequence").value(2))
.andExpect(jsonPath("$.opera_operations[1].operation_status").value("PENDING"));
String taskStatus = jdbcTemplate.queryForObject("""
SELECT task_status
FROM workflow_reservation_task
WHERE id = ?
""", String.class, Long.valueOf(taskId));
Long confirmedAtCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_task
WHERE id = ?
AND confirmed_at IS NOT NULL
""", Long.class, Long.valueOf(taskId));
String confirmedPayloadJson = jdbcTemplate.queryForObject("""
SELECT confirmed_payload_json
FROM workflow_reservation_task_card
WHERE task_id = ?
""", String.class, Long.valueOf(taskId));
Long auditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_audit_log
WHERE task_id = ?
AND action = 'TASK_CONFIRM'
""", Long.class, Long.valueOf(taskId));
assertThat(taskStatus).isEqualTo("READY");
assertThat(confirmedAtCount).isEqualTo(1L);
assertThat(confirmedPayloadJson).contains("\"extracted_fields.room_quantity\":4");
assertThat(auditCount).isEqualTo(1L);
}
@Test
void shouldExecuteFirstOperaSimulationOperationAndRecordAttempt() throws Exception {
String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
"mail-cp5-opera-execute-001",
"nonce-cp5-opera-execute-001",
"CNF-CP5-OPERA-EXEC-001");
String taskId = taskAndOperationIds[0];
String firstOperationId = taskAndOperationIds[1];
mockMvc.perform(post(
"/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
taskId,
firstOperationId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"simulate_success": true
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.operation_id").value(firstOperationId))
.andExpect(jsonPath("$.operation_status").value("SUCCEEDED"))
.andExpect(jsonPath("$.attempt_count").value(1))
.andExpect(jsonPath("$.attempts[0].attempt_number").value(1))
.andExpect(jsonPath("$.attempts[0].attempt_status").value("SUCCEEDED"));
mockMvc.perform(get("/api/reservation/tasks/{taskId}", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.opera_operations[0].operation_id").value(firstOperationId))
.andExpect(jsonPath("$.opera_operations[0].operation_status").value("SUCCEEDED"))
.andExpect(jsonPath("$.opera_operations[0].attempt_count").value(1))
.andExpect(jsonPath("$.opera_operations[0].attempts[0].attempt_status").value("SUCCEEDED"));
Long attemptCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_opera_operation_attempt
WHERE operation_id = ?
AND attempt_status = 'SUCCEEDED'
""", Long.class, Long.valueOf(firstOperationId));
Long auditCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_audit_log
WHERE task_id = ?
AND action = 'OPERA_OPERATION_EXECUTE'
""", Long.class, Long.valueOf(taskId));
assertThat(attemptCount).isEqualTo(1L);
assertThat(auditCount).isEqualTo(1L);
}
@Test
void shouldRetryFailedOperaSimulationOperationAndRecordSecondAttempt() throws Exception {
String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
"mail-cp5-opera-retry-001",
"nonce-cp5-opera-retry-001",
"CNF-CP5-OPERA-RETRY-001");
String taskId = taskAndOperationIds[0];
String firstOperationId = taskAndOperationIds[1];
mockMvc.perform(post(
"/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
taskId,
firstOperationId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"simulate_success": false,
"failure_message": "模拟失败"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.operation_status").value("FAILED"))
.andExpect(jsonPath("$.attempt_count").value(1))
.andExpect(jsonPath("$.attempts[0].attempt_status").value("FAILED"));
mockMvc.perform(post(
"/api/reservation/tasks/{taskId}/opera-operations/{operationId}/retry",
taskId,
firstOperationId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"simulate_success": true
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.operation_status").value("SUCCEEDED"))
.andExpect(jsonPath("$.attempt_count").value(2))
.andExpect(jsonPath("$.attempts[1].attempt_number").value(2))
.andExpect(jsonPath("$.attempts[1].attempt_status").value("SUCCEEDED"));
}
@Test
void shouldRejectExecutingSecondOperaOperationBeforePreviousSucceeded() throws Exception {
String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
"mail-cp5-opera-order-001",
"nonce-cp5-opera-order-001",
"CNF-CP5-OPERA-ORDER-001");
String taskId = taskAndOperationIds[0];
String secondOperationId = taskAndOperationIds[2];
mockMvc.perform(post(
"/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
taskId,
secondOperationId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"simulate_success": true
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("OPERA_PREVIOUS_OPERATION_NOT_SUCCEEDED"));
}
@Test
void shouldListTaskAuditLogsForConfirmationAndOperaExecution() throws Exception {
String[] taskAndOperationIds = createReadyTaskWithTwoOperaOperations(
"mail-cp5-audit-list-001",
"nonce-cp5-audit-list-001",
"CNF-CP5-AUDIT-001");
String taskId = taskAndOperationIds[0];
String firstOperationId = taskAndOperationIds[1];
mockMvc.perform(post(
"/api/reservation/tasks/{taskId}/opera-operations/{operationId}/execute",
taskId,
firstOperationId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"simulate_success": true
}
"""))
.andExpect(status().isOk());
mockMvc.perform(get("/api/reservation/tasks/{taskId}/audits", taskId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.task_id").value(taskId))
.andExpect(jsonPath("$.items[?(@.action=='TASK_CONFIRM')].action")
.value(org.hamcrest.Matchers.contains("TASK_CONFIRM")))
.andExpect(jsonPath("$.items[?(@.action=='OPERA_OPERATION_EXECUTE')].action")
.value(org.hamcrest.Matchers.contains("OPERA_OPERATION_EXECUTE")));
}
@Test
void shouldRejectConfirmWhenRequiredMatrixFieldMissing() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-required-missing-001");
String body = minimalBody(source.inboxId().toString(), "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": {},
"extracted_fields": {
"booking_object_type": "FIT Reservation"
}
""");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-required-missing-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.arrival_date")));
}
@Test
void shouldRejectConfirmWhenEnumValueIsOutsideMatrixOptions() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-enum-invalid-001");
String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-ENUM-001");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-enum-invalid-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {
"extracted_fields.pms_room_type_code": "BAD_ROOM"
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.pms_room_type_code")));
}
@Test
void shouldRejectConfirmWhenDateRuleIsInvalid() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-date-invalid-001");
String body = completeNewBookingBody(source.inboxId().toString(), "CNF-CP5-DATE-001");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-date-invalid-001"))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {
"extracted_fields.arrival_date": "not-a-date"
}
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("TASK_FIELD_VALIDATION_FAILED"))
.andExpect(jsonPath("$.details[0]").value(containsString("extracted_fields.arrival_date")));
}
@Test
void shouldRejectDraftSaveWhenPreviousQueueTaskBlocksCurrentTask() throws Exception {
SourceMessageCaptureResult source = captureSourceMessage("mail-cp5-draft-blocked-001");
String body = twoTaskBody(source.inboxId().toString(), "GRP-CP5-BLOCKED-001");
MvcResult createResult = mockMvc.perform(signedPost(body, "nonce-cp5-draft-blocked-001"))
.andExpect(status().isCreated())
.andReturn();
String secondTaskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[1].task_id"
);
mockMvc.perform(put("/api/reservation/tasks/{taskId}/draft", secondTaskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {
"extracted_fields.update_subtypes[]": ["update_stay_dates"]
}
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("TASK_READ_ONLY"));
}
@Test
void shouldConvertFallbackToUpdateBookingAndLogicDeleteEmptyTemporaryOrder() throws Exception {
SourceMessageCaptureResult targetSource = captureSourceMessage("mail-fallback-target-order-001");
@@ -809,6 +1172,58 @@ class SuperAgentTaskResultControllerTest {
""".formatted(sourceMessageId, groupCode, groupCode);
}
private String completeNewBookingBody(String sourceMessageId, String confirmationNumber) {
String caseKeys = confirmationNumber == null || confirmationNumber.isBlank()
? "{}"
: "{\"confirmation_number\": \"" + confirmationNumber + "\"}";
return minimalBody(sourceMessageId, "New Booking", "normal_task", "new_fit_reservation", """
"case_keys": %s,
"extracted_fields": {
"booking_object_type": "FIT Reservation",
"arrival_date": "2026-08-01",
"departure_date": "2026-08-02",
"room_quantity": 2,
"room_type": "Deluxe King",
"pms_room_type_code": "RM2"
}
""".formatted(caseKeys));
}
private String[] createReadyTaskWithTwoOperaOperations(
String externalMessageId,
String nonce,
String confirmationNumber) throws Exception {
SourceMessageCaptureResult source = captureSourceMessage(externalMessageId);
String body = completeNewBookingBody(source.inboxId().toString(), confirmationNumber);
MvcResult createResult = mockMvc.perform(signedPost(body, nonce))
.andExpect(status().isCreated())
.andReturn();
String taskId = com.jayway.jsonpath.JsonPath.read(
createResult.getResponse().getContentAsString(),
"$.items[0].task_id"
);
MvcResult confirmResult = mockMvc.perform(post("/api/reservation/tasks/{taskId}/confirm", taskId)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"field_values": {}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.opera_operations.length()").value(2))
.andReturn();
String firstOperationId = com.jayway.jsonpath.JsonPath.read(
confirmResult.getResponse().getContentAsString(),
"$.opera_operations[0].operation_id"
);
String secondOperationId = com.jayway.jsonpath.JsonPath.read(
confirmResult.getResponse().getContentAsString(),
"$.opera_operations[1].operation_id"
);
return new String[] {taskId, firstOperationId, secondOperationId};
}
private String signature(String body, String nonce, String timestamp) throws Exception {
return signature(body, nonce, timestamp, CLIENT_ID);
}