实现 V4 复核解阻接口

This commit is contained in:
andy
2026-07-19 11:36:20 +07:00
parent 080266c633
commit d0d26c7fbe
18 changed files with 1162 additions and 43 deletions

View File

@@ -73,6 +73,7 @@ class AuthControllerTest {
"RESERVATION_TASK_READ",
"RESERVATION_TASK_EDIT",
"RESERVATION_TASK_CONFIRM",
"RESERVATION_MANUAL_REVIEW_RESOLVE",
"RESERVATION_OPERA_SIM_EXECUTE",
"RESERVATION_AUDIT_READ",
"RESERVATION_INVOICE_GENERATE",

View File

@@ -28,6 +28,7 @@ import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4CardTyp
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4NotificationStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4OrderTaskStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationV4TargetResolutionStatus;
import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationReviewStatus;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4SourceNotificationRepository;
import cn.nianxx.thhotel.workflows.reservation.repository.ReservationV4WorkflowRepository;
import java.time.Instant;
@@ -206,6 +207,286 @@ class ReservationV4CommandControllerTest {
.andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED"));
}
@Test
void shouldResolveBasicInformationReviewAndConfirmOrderOwnership() throws Exception {
Long confirmedOrderId = 990000000000070002L;
seedReservationOrder(HOTEL_ID, confirmedOrderId);
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-basic-001",
Instant.parse("2026-07-19T01:21:00Z"),
null,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"reason": "确认订单归属并补齐旅行社代码",
"confirmed_order_id": "990000000000070002",
"field_overrides": [
{
"field_pointer": "/basic_information/account_code",
"value": "QBD_TRAVEL"
}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_id").value(confirmedOrderId.toString()))
.andExpect(jsonPath("$.order_task.target_resolution_status").value("RESOLVED"))
.andExpect(jsonPath("$.basic_information_card.card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.basic_information_card.review_status").value("RESOLVED"))
.andExpect(jsonPath("$.basic_information_card.confirmed_by").value("v4-command-admin"))
.andExpect(jsonPath("$.basic_information_card.confirmed_payload.basic_information.account_code").value("QBD_TRAVEL"))
.andExpect(jsonPath("$.basic_information_card.review_resolution.field_overrides[0].field_pointer")
.value("/basic_information/account_code"))
.andExpect(jsonPath("$.business_cards[0].availability.confirmable").value(true));
assertAuditCount("V4_CARD_REVIEW_RESOLVE", "v4-command-admin", seeded.orderTask().id().toString(), 1);
}
@Test
void shouldResolveOrderOwnershipStatusWhenConfirmedOrderIdAlreadyBound() throws Exception {
Long confirmedOrderId = 990000000000070009L;
seedReservationOrder(HOTEL_ID, confirmedOrderId);
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-same-order-001",
Instant.parse("2026-07-19T01:21:30Z"),
confirmedOrderId,
ReservationV4TargetResolutionStatus.UNRESOLVED.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_order_id": "990000000000070009",
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_id").value(confirmedOrderId.toString()))
.andExpect(jsonPath("$.order_task.target_resolution_status").value("RESOLVED"));
}
@Test
void shouldResolveBusinessCardReviewAfterBasicInformationConfirmed() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-business-001",
Instant.parse("2026-07-19T01:22:00Z"),
990000000000070003L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"reason": "确认房型映射",
"field_overrides": [
{
"field_pointer": "/business_fields/room_items/0/pms_room_type_code",
"value": "RM2"
}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.order_task.order_task_status").value("COMPLETED"))
.andExpect(jsonPath("$.business_cards[0].card_status").value("CONFIRMED"))
.andExpect(jsonPath("$.business_cards[0].review_status").value("RESOLVED"))
.andExpect(jsonPath("$.business_cards[0].confirmed_payload.business_fields.room_items[0].pms_room_type_code")
.value("RM2"))
.andExpect(jsonPath("$.business_cards[0].review_resolution.reason").value("确认房型映射"));
}
@Test
void shouldRejectReviewResolutionWhenOrderOwnershipUnresolvedAndConfirmedOrderMissing() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-order-required-001",
Instant.parse("2026-07-19T01:22:30Z"),
null,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_CONFIRMED_ORDER_ID_REQUIRED"));
}
@Test
void shouldRejectReviewResolutionForUnknownConfirmedOrder() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-unknown-order-001",
Instant.parse("2026-07-19T01:22:40Z"),
null,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"confirmed_order_id": "990000000000070199",
"field_overrides": [
{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}
]
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error_code").value("V4_CONFIRMED_ORDER_NOT_FOUND"));
}
@Test
void shouldRejectReviewResolutionForIllegalPointer() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-illegal-pointer-001",
Instant.parse("2026-07-19T01:23:00Z"),
990000000000070004L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "business_fields/room_items/0/pms_room_type_code", "value": "RM2"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_INVALID"));
}
@Test
void shouldRejectReviewResolutionForReadonlyPointer() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-readonly-pointer-001",
Instant.parse("2026-07-19T01:24:00Z"),
990000000000070005L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "/card_type", "value": "PAYMENT"}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_POINTER_READONLY"));
}
@Test
void shouldRejectReviewResolutionForContainerPointer() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-container-pointer-001",
Instant.parse("2026-07-19T01:24:15Z"),
990000000000070010L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "/business_fields/room_items", "value": []}
]
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error_code").value("V4_REVIEW_VALUE_INVALID"));
}
@Test
void shouldRejectRepeatedReviewResolution() throws Exception {
SeededOrderTask seeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-review-repeat-001",
Instant.parse("2026-07-19T01:24:30Z"),
990000000000070006L,
ReservationV4CardStatus.PENDING_CONFIRM.name(),
ReservationV4CardStatus.REVIEW_REQUIRED.name());
confirmBasicCard(seeded);
resolveBusinessReviewCard(seeded);
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 1,
"field_overrides": [
{"field_pointer": "/business_fields/room_items/0/pms_room_type_code", "value": "RM3"}
]
}
"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.error_code").value("V4_CARD_ALREADY_CONFIRMED"));
}
@Test
void shouldRejectCardConfirmWhenPriorOrderTaskIsOpen() throws Exception {
Long sharedOrderId = 990000000000040001L;
@@ -360,6 +641,24 @@ class ReservationV4CommandControllerTest {
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
SeededOrderTask reviewSeeded = seedReviewOrderTask(
HOTEL_ID,
"mail-v4-command-no-permission-review-001",
Instant.parse("2026-07-19T01:42:00Z"),
990000000000070007L,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, noPermissionToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
reviewSeeded.orderTask().id(),
reviewSeeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"version": 0, "field_overrides": [{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}]}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("FRONTEND_PERMISSION_DENIED"));
}
@Test
@@ -391,6 +690,24 @@ class ReservationV4CommandControllerTest {
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
SeededOrderTask reviewSeeded = seedReviewOrderTask(
OTHER_HOTEL_ID,
"mail-v4-command-cross-hotel-review-001",
Instant.parse("2026-07-19T01:52:00Z"),
990000000000070008L,
ReservationV4CardStatus.REVIEW_REQUIRED.name(),
ReservationV4CardStatus.PENDING_CONFIRM.name());
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
reviewSeeded.orderTask().id(),
reviewSeeded.basicCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"version": 0, "field_overrides": [{"field_pointer": "/basic_information/account_code", "value": "QBD_TRAVEL"}]}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.error_code").value("HOTEL_ACCESS_DENIED"));
}
/**
@@ -427,6 +744,23 @@ class ReservationV4CommandControllerTest {
.andExpect(status().isOk());
}
private void resolveBusinessReviewCard(SeededOrderTask seeded) throws Exception {
performAuthorized(mockMvc, adminToken(), post(
"/api/reservation/order-tasks/{orderTaskId}/cards/{cardId}/review-resolution",
seeded.orderTask().id(),
seeded.businessCard().id())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"version": 0,
"field_overrides": [
{"field_pointer": "/business_fields/room_items/0/pms_room_type_code", "value": "RM2"}
]
}
"""))
.andExpect(status().isOk());
}
private SeededOrderTask seedOrderTask(String hotelId, String externalMessageId, Instant receivedAt) {
return seedOrderTask(hotelId, externalMessageId, receivedAt, null);
}
@@ -474,6 +808,77 @@ class ReservationV4CommandControllerTest {
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
}
private SeededOrderTask seedReviewOrderTask(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String basicStatus,
String businessStatus) {
return seedReviewOrderTask(
hotelId,
externalMessageId,
receivedAt,
orderId,
orderId == null
? ReservationV4TargetResolutionStatus.UNRESOLVED.name()
: ReservationV4TargetResolutionStatus.RESOLVED.name(),
basicStatus,
businessStatus);
}
private SeededOrderTask seedReviewOrderTask(
String hotelId,
String externalMessageId,
Instant receivedAt,
Long orderId,
String targetResolutionStatus,
String basicStatus,
String businessStatus) {
SourceMessageCaptureResult source = captureSourceMessage(
hotelId,
externalMessageId,
"V4 Command Review",
receivedAt);
LocalDateTime now = LocalDateTime.ofInstant(receivedAt.plusSeconds(10), ZoneOffset.UTC);
ReservationV4OrderTaskSnapshot orderTask = workflowRepository.findOrCreateOrderTask(new ReservationV4OrderTaskDraft(
hotelId,
source.inboxId(),
990000000000060001L + Math.abs(externalMessageId.hashCode()),
"order-review-" + externalMessageId,
1,
orderId,
"GROUP",
"GROUP_CODE",
"GRP-V4-REVIEW-001",
targetResolutionStatus,
ReservationV4OrderTaskStatus.OPEN.name(),
LocalDateTime.ofInstant(receivedAt, ZoneOffset.UTC),
now));
ReservationV4TaskCardSnapshot sourceCard = insertCard(orderTask, hotelId,
ReservationV4CardType.SOURCE_MESSAGE_DISPLAY.name(), null, 0, 10,
ReservationV4CardStatus.READONLY.name(), null, """
{"card_type":"SOURCE_MESSAGE_DISPLAY","source_message":{"subject":"V4 Command Review"}}
""");
ReservationV4TaskCardSnapshot basicCard = insertCard(orderTask, hotelId,
ReservationV4CardType.BASIC_INFORMATION.name(), null, 0, 20,
basicStatus, reviewStatusFor(basicStatus), """
{"card_type":"BASIC_INFORMATION","order_ref":"ORDER-REVIEW","basic_information":{"account_code":null,"manual_review":true,"missing_fields":["/basic_information/account_code"]}}
""");
ReservationV4TaskCardSnapshot businessCard = insertCard(orderTask, hotelId,
ReservationV4CardType.ROOM_INFORMATION.name(), "NEW_BOOKING", 1, 30,
businessStatus, reviewStatusFor(businessStatus), """
{"event_type":"NEW_BOOKING","route_code":"S01","business_fields":{"order_ref":"ORDER-REVIEW","event_type":"NEW_BOOKING","manual_review":true,"room_items":[{"room_type_code":"TWN","room_count":2,"pms_room_type_code":null}]}}
""");
return new SeededOrderTask(orderTask, sourceCard, basicCard, businessCard);
}
private String reviewStatusFor(String cardStatus) {
return ReservationV4CardStatus.REVIEW_REQUIRED.name().equals(cardStatus)
? ReservationReviewStatus.PENDING.name()
: null;
}
private ReservationV4SourceNotificationSnapshot seedSourceNotification(
String hotelId,
String externalMessageId,
@@ -570,6 +975,29 @@ class ReservationV4CommandControllerTest {
org.assertj.core.api.Assertions.assertThat(count).isEqualTo(expectedCount);
}
private void seedReservationOrder(String hotelId, Long orderId) {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
jdbcTemplate.update(
"""
INSERT INTO workflow_reservation_order
(id, hotel_id, order_key_type, order_business_key, active_business_key,
temporary_order_code, order_status, order_visibility, business_key_source,
display_name, source_message_id, latest_activity_at, version, created_at, updated_at)
VALUES
(?, ?, 'GROUP_CODE', ?, ?, ?, 'ACTIVE', 'VISIBLE', 'USER_CONFIRMED',
?, 0, ?, 0, ?, ?)
""",
orderId,
hotelId,
"GRP-ORDER-" + orderId,
"GRP-ORDER-" + orderId,
"TMP-ORDER-" + orderId,
"订单 " + orderId,
now,
now,
now);
}
private record SeededOrderTask(
ReservationV4OrderTaskSnapshot orderTask,
ReservationV4TaskCardSnapshot sourceCard,

View File

@@ -251,7 +251,7 @@ class ReservationV4QueryControllerTest {
}
@Test
void shouldNotExposeOrderTaskAsConfirmableWhenOnlyReviewCardsRemain() throws Exception {
void shouldExposeReviewCardsAsReviewableButNotConfirmable() throws Exception {
ReservationV4OrderTaskSnapshot orderTask = seedReviewRequiredOrderTask(
"mail-v4-query-review-only-001",
Instant.parse("2026-07-18T03:05:00Z"));
@@ -259,12 +259,14 @@ class ReservationV4QueryControllerTest {
performAuthorized(mockMvc, adminToken(), get("/api/reservation/order-tasks/{orderTaskId}", orderTask.id())
.param("hotel_id", HOTEL_ID))
.andExpect(status().isOk())
.andExpect(jsonPath("$.availability.read_only").value(true))
.andExpect(jsonPath("$.availability.read_only").value(false))
.andExpect(jsonPath("$.availability.confirmable").value(false))
.andExpect(jsonPath("$.availability.readonly_reason_code").value("REVIEW_API_PENDING"))
.andExpect(jsonPath("$.availability.reviewable").value(true))
.andExpect(jsonPath("$.availability.readonly_reason_code").value("PROCESSABLE"))
.andExpect(jsonPath("$.basic_information_card.availability.confirmable").value(false))
.andExpect(jsonPath("$.basic_information_card.availability.reviewable").value(true))
.andExpect(jsonPath("$.basic_information_card.availability.readonly_reason_code")
.value("REVIEW_API_PENDING"));
.value("PROCESSABLE"));
}
@Test