From fcf9127ff0648fd9ef0cfbc7db4ab03d3dcc9f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=A8=E9=B1=BC=E8=BE=A3=E6=A4=92?= Date: Sat, 8 Aug 2026 16:03:05 +0800 Subject: [PATCH] feat(booking): add contracts v1 processing runtime --- .../verify-booking-postgresql-migration.sh | 116 +++ .../adapter/AgentBusFrameProcessor.java | 132 ++- .../dto/BookingMessageAttachmentInput.java | 40 + .../common/dto/BookingMessageInput.java | 78 ++ .../common/dto/BookingMessageMasterData.java | 101 ++ .../dto/BookingOrchestrationResult.java | 24 + .../dto/BookingProcessingRunSnapshot.java | 21 + .../dto/BookingTargetStateSnapshot.java | 38 + .../BookingConfirmationItemRequest.java | 23 + .../request/BookingConfirmationRequest.java | 11 + .../result/BookingConfirmationResult.java | 10 + .../result/BookingProcessingRunView.java | 30 + .../config/BookingAgentConfiguration.java | 10 + .../config/BookingAgentProperties.java | 58 ++ .../config/BookingPostgresConfiguration.java | 99 ++ .../config/BookingPostgresProperties.java | 141 +++ .../BookingConfirmationActionDraft.java | 23 + .../BookingCurrentStateRepository.java | 11 + ...BookingPostgresCurrentStateRepository.java | 65 ++ ...ookingPostgresProcessingRunRepository.java | 642 ++++++++++++ ...kingPostgresRateCodeMappingRepository.java | 36 + ...ookingPostgresSenderMappingRepository.java | 41 + .../BookingProcessingRunRepository.java | 69 ++ .../BookingRateCodeMappingRepository.java | 12 + .../BookingSenderMappingRepository.java | 18 + .../service/BookingAgentDecisionService.java | 16 + .../BookingConfirmationProjectionFactory.java | 12 + .../service/BookingContextAssembler.java | 10 + .../service/BookingDecisionService.java | 20 + .../service/BookingDecisionValidator.java | 12 + .../service/BookingMasterDataResolver.java | 12 + .../service/BookingMessageOrchestrator.java | 15 + .../service/BookingProcessingRunService.java | 13 + .../BookingAgentBusMaterialPreprocessor.java | 123 +++ .../BookingAgentContinuationDispatcher.java | 36 + .../impl/BookingAgentInputMaterialPolicy.java | 125 +++ .../service/impl/BookingAllotmentSupport.java | 118 +++ ...kingConfirmationProjectionFactoryImpl.java | 269 +++++ .../impl/BookingContextAssemblerImpl.java | 674 ++++++++++++ .../impl/BookingContractAssembler.java | 962 ++++++++++++++++++ .../service/impl/BookingDecisionPipeline.java | 225 ++++ .../impl/BookingDecisionServiceImpl.java | 641 ++++++++++++ .../impl/BookingDecisionValidatorImpl.java | 751 ++++++++++++++ .../impl/BookingMasterDataResolverImpl.java | 243 +++++ .../BookingPostgresMessageOrchestrator.java | 298 ++++++ .../impl/BookingPostgresRetentionService.java | 37 + .../impl/BookingProcessingRunServiceImpl.java | 404 ++++++++ ...okingRiskFallbackAgentDecisionService.java | 53 + .../service/impl/BookingSenderNormalizer.java | 31 + .../BookingSuperAgentDecisionService.java | 425 ++++++++ .../common/dto/booking/BookingContracts.java | 776 ++++++++++++++ ...rvationBookingProcessingRunController.java | 79 ++ .../migration/V1__booking_contracts_v1.sql | 381 +++++++ .../V2__booking_sender_mapping_direction.sql | 48 + ...okingAgentBusMaterialPreprocessorTest.java | 111 ++ .../impl/BookingAllotmentProjectionTest.java | 197 ++++ ...ConfirmationProjectionFactoryImplTest.java | 99 ++ .../impl/BookingContextAssemblerImplTest.java | 495 +++++++++ .../impl/BookingContractAssemblerTest.java | 278 +++++ .../impl/BookingDecisionPipelineTest.java | 96 ++ .../impl/BookingDecisionServiceImplTest.java | 320 ++++++ .../BookingDecisionValidatorImplTest.java | 322 ++++++ .../BookingMasterDataResolverImplTest.java | 141 +++ ...ookingPostgresMessageOrchestratorTest.java | 263 +++++ .../BookingSuperAgentDecisionServiceTest.java | 197 ++++ .../dto/booking/BookingContractsTest.java | 108 ++ 66 files changed, 11283 insertions(+), 2 deletions(-) create mode 100755 scripts/verify-booking-postgresql-migration.sh create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageAttachmentInput.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageInput.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageMasterData.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingOrchestrationResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingProcessingRunSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingTargetStateSnapshot.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationItemRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationRequest.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingConfirmationResult.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingProcessingRunView.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentConfiguration.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentProperties.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresConfiguration.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresProperties.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingConfirmationActionDraft.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingCurrentStateRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresCurrentStateRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresProcessingRunRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresRateCodeMappingRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresSenderMappingRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingProcessingRunRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingRateCodeMappingRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingSenderMappingRepository.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingAgentDecisionService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingConfirmationProjectionFactory.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingContextAssembler.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionValidator.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMasterDataResolver.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMessageOrchestrator.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingProcessingRunService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessor.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentContinuationDispatcher.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentInputMaterialPolicy.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentSupport.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssembler.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipeline.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestrator.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresRetentionService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingProcessingRunServiceImpl.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingRiskFallbackAgentDecisionService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSenderNormalizer.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionService.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContracts.java create mode 100644 server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingProcessingRunController.java create mode 100644 server/src/main/resources/db/booking-postgresql/migration/V1__booking_contracts_v1.sql create mode 100644 server/src/main/resources/db/booking-postgresql/migration/V2__booking_sender_mapping_direction.sql create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessorTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentProjectionTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImplTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImplTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssemblerTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipelineTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImplTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImplTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImplTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestratorTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionServiceTest.java create mode 100644 server/src/test/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContractsTest.java diff --git a/scripts/verify-booking-postgresql-migration.sh b/scripts/verify-booking-postgresql-migration.sh new file mode 100755 index 0000000..6ba3fcc --- /dev/null +++ b/scripts/verify-booking-postgresql-migration.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Validate the contracts-v1 PostgreSQL migration without touching any schema +# other than th_hotel_booking. This script is safe by default: it refuses a +# schema containing objects outside the booking_contract_* namespace. +set -euo pipefail + +readonly BOOKING_SCHEMA='th_hotel_booking' +readonly MIGRATION_FILES=( + 'server/src/main/resources/db/booking-postgresql/migration/V1__booking_contracts_v1.sql' + 'server/src/main/resources/db/booking-postgresql/migration/V2__booking_sender_mapping_direction.sql' +) + +database_url="${BOOKING_PG_VERIFY_URL:-${TH_HOTEL_BOOKING_PG_URL:-}}" +if [[ -z "${database_url}" ]]; then + echo 'BOOKING_PG_VERIFY_URL (or TH_HOTEL_BOOKING_PG_URL) is required.' >&2 + exit 64 +fi +if [[ "${BOOKING_PG_VERIFY_SCHEMA:-${BOOKING_SCHEMA}}" != "${BOOKING_SCHEMA}" ]]; then + echo "Only ${BOOKING_SCHEMA} may be verified by this script." >&2 + exit 64 +fi +for migration_file in "${MIGRATION_FILES[@]}"; do + if [[ ! -f "${migration_file}" ]]; then + echo "Canonical booking migration was not found: ${migration_file}" >&2 + exit 66 + fi +done +if ! command -v psql >/dev/null 2>&1; then + echo 'psql is required to run PostgreSQL migration verification.' >&2 + exit 69 +fi + +psql_query() { + psql -X -qAt -v ON_ERROR_STOP=1 -d "${database_url}" -c "$1" +} + +schema_exists="$(psql_query "SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '${BOOKING_SCHEMA}');")" +if [[ "${schema_exists}" == 't' ]]; then + unexpected_objects="$(psql_query " + SELECT count(*) + FROM pg_class relation + JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = '${BOOKING_SCHEMA}' + AND relation.relkind IN ('r', 'p', 'S', 'v', 'm') + AND relation.relname NOT LIKE 'booking_contract_%' + AND relation.relname <> 'flyway_schema_history';")" + if [[ "${unexpected_objects}" != '0' ]]; then + echo "Refusing to use ${BOOKING_SCHEMA}: it contains ${unexpected_objects} non-contract object(s)." >&2 + exit 65 + fi +fi + +# Count user objects outside the project schema. The migration is allowed to +# create only th_hotel_booking objects; a count change is a hard failure. +other_objects_before="$(psql_query " + SELECT count(*) + FROM pg_class relation + JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema', '${BOOKING_SCHEMA}') + AND namespace.nspname NOT LIKE 'pg_toast%' + AND relation.relkind IN ('r', 'p', 'S', 'v', 'm');")" + +psql -X -q --single-transaction -v ON_ERROR_STOP=1 -d "${database_url}" \ + -f "${MIGRATION_FILES[0]}" \ + -f "${MIGRATION_FILES[1]}" + +other_objects_after="$(psql_query " + SELECT count(*) + FROM pg_class relation + JOIN pg_namespace namespace ON namespace.oid = relation.relnamespace + WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema', '${BOOKING_SCHEMA}') + AND namespace.nspname NOT LIKE 'pg_toast%' + AND relation.relkind IN ('r', 'p', 'S', 'v', 'm');")" +if [[ "${other_objects_before}" != "${other_objects_after}" ]]; then + echo 'PostgreSQL migration verification detected a non-booking schema object change.' >&2 + exit 70 +fi + +required_tables=( + booking_contract_source_message + booking_contract_source_attachment + booking_contract_processing_run + booking_contract_processing_attempt + booking_contract_evidence_reference + booking_contract_artifact + booking_contract_parsed_fact + booking_contract_candidate_target + booking_contract_confirmation_action + booking_contract_channel_profile + booking_contract_sender_mapping + booking_contract_rate_code_mapping +) +for table_name in "${required_tables[@]}"; do + present="$(psql_query "SELECT to_regclass('${BOOKING_SCHEMA}.${table_name}') IS NOT NULL;")" + if [[ "${present}" != 't' ]]; then + echo "Missing required booking table: ${BOOKING_SCHEMA}.${table_name}" >&2 + exit 71 + fi +done + +cross_schema_fk_count="$(psql_query " + SELECT count(*) + FROM pg_constraint constraint + JOIN pg_class source_relation ON source_relation.oid = constraint.conrelid + JOIN pg_namespace source_namespace ON source_namespace.oid = source_relation.relnamespace + JOIN pg_class target_relation ON target_relation.oid = constraint.confrelid + JOIN pg_namespace target_namespace ON target_namespace.oid = target_relation.relnamespace + WHERE constraint.contype = 'f' + AND (source_namespace.nspname = '${BOOKING_SCHEMA}' OR target_namespace.nspname = '${BOOKING_SCHEMA}') + AND source_namespace.nspname <> target_namespace.nspname;")" +if [[ "${cross_schema_fk_count}" != '0' ]]; then + echo 'Booking PostgreSQL migration created or found a cross-schema foreign key.' >&2 + exit 72 +fi + +echo "Booking PostgreSQL migration verified in ${BOOKING_SCHEMA}; other schema object count unchanged (${other_objects_after})." diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java index 4db7234..ab7fed8 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java @@ -1,15 +1,29 @@ package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentDispatchService; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageMedia; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; import cn.nianxx.thhotel.platform.hotel.service.HotelContextService; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageAttachmentInput; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageInput; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingMessageOrchestrator; +import cn.nianxx.thhotel.workflows.reservation.booking.service.impl.BookingAgentBusMaterialPreprocessor; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; import java.util.Set; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; /** @@ -30,10 +44,13 @@ public class AgentBusFrameProcessor { private final AgentBusProperties properties; private final HotelContextService hotelContextService; private final SuperAgentDispatchService dispatchService; + private final ObjectProvider bookingMessageOrchestratorProvider; + private final ObjectProvider bookingMaterialPreprocessorProvider; /** * 注入 AgentBus frame 处理依赖。外部协议转换与平台捕获服务通过稳定命令隔离。 */ + @Autowired public AgentBusFrameProcessor( ObjectMapper objectMapper, AgentBusSourceMessageAdapter sourceMessageAdapter, @@ -41,7 +58,9 @@ public class AgentBusFrameProcessor { SuperAgentDispatchService dispatchService, AgentBusConnectionStatus status, AgentBusProperties properties, - HotelContextService hotelContextService) { + HotelContextService hotelContextService, + ObjectProvider bookingMessageOrchestratorProvider, + ObjectProvider bookingMaterialPreprocessorProvider) { this.objectMapper = objectMapper; this.sourceMessageAdapter = sourceMessageAdapter; this.captureService = captureService; @@ -49,6 +68,29 @@ public class AgentBusFrameProcessor { this.status = status; this.properties = properties; this.hotelContextService = hotelContextService; + this.bookingMessageOrchestratorProvider = bookingMessageOrchestratorProvider; + this.bookingMaterialPreprocessorProvider = bookingMaterialPreprocessorProvider; + } + + /** 保留现有单元测试和历史构造方;正式 Spring 容器使用带统一编排器的构造器。 */ + AgentBusFrameProcessor( + ObjectMapper objectMapper, + AgentBusSourceMessageAdapter sourceMessageAdapter, + SourceMessageCaptureService captureService, + SuperAgentDispatchService dispatchService, + AgentBusConnectionStatus status, + AgentBusProperties properties, + HotelContextService hotelContextService) { + this( + objectMapper, + sourceMessageAdapter, + captureService, + dispatchService, + status, + properties, + hotelContextService, + null, + null); } /** @@ -83,7 +125,15 @@ public class AgentBusFrameProcessor { frame); SourceMessageCaptureResult result = captureService.capture(command); if ("RECEIVED".equals(result.captureStatus())) { - dispatchService.enqueueAgentBusRealtime(command, result); + BookingMessageOrchestrator bookingMessageOrchestrator = bookingMessageOrchestratorProvider == null + ? null + : bookingMessageOrchestratorProvider.getIfAvailable(); + if (bookingMessageOrchestrator != null) { + bookingMessageOrchestrator.process(bookingMessageInput(command, result)); + } else { + // PostgreSQL 主线未启用时保留历史受控 dispatch,避免双写旧 V4 与 contracts-v1。 + dispatchService.enqueueAgentBusRealtime(command, result); + } } status.markFrameCaptured(); return AgentBusFrameProcessResult.captured(result.inboxId(), result.captureStatus()); @@ -127,4 +177,82 @@ public class AgentBusFrameProcessor { String trimmed = value == null ? "" : value.trim(); return trimmed.isEmpty() ? null : trimmed; } + + /** 将已捕获的 AgentBus 来源事实转换为统一 Booking 输入。 */ + private BookingMessageInput bookingMessageInput( + CaptureSourceMessageCommand command, + SourceMessageCaptureResult captureResult) { + String sourceMessageId = "agentbus-inbox-" + captureResult.inboxId(); + BookingAgentBusMaterialPreprocessor preprocessor = bookingMaterialPreprocessorProvider == null + ? null + : bookingMaterialPreprocessorProvider.getIfAvailable(); + List attachments = preprocessor == null + ? referenceAttachments(command.mediaItems()) + : preprocessor.prepare(command.mediaItems()); + String externalMessageId = hasText(command.externalMessageId()) + ? command.externalMessageId().trim() + : sourceMessageId; + String idempotencySource = hasText(command.providerFrameId()) + ? command.providerFrameId().trim() + : externalMessageId; + return new BookingMessageInput( + command.hotelId(), + BookingContracts.MessageOrigin.AGENTBUS, + sourceMessageId, + externalMessageId, + command.externalConversationId(), + null, + "agentbus:" + sha256(idempotencySource), + command.sourceReceivedAt() != null + ? command.sourceReceivedAt() + : command.sourceSentAt() != null ? command.sourceSentAt() : Instant.now(), + command.subject(), + hasText(command.textBody()) ? command.textBody() : command.htmlBody(), + null, + attachments, + command.senderIdentifier()); + } + + /** PostgreSQL 主线未启用或预处理器不可用时,只保留安全引用,不下载字节。 */ + private List referenceAttachments(List mediaItems) { + List attachments = new ArrayList<>(); + int ordinal = 0; + for (CaptureSourceMessageMedia media : mediaItems == null ? List.of() : mediaItems) { + ordinal++; + String name = hasText(media.fileName()) ? media.fileName().trim() : "agentbus-attachment-" + ordinal; + String mediaType = hasText(media.contentType()) ? media.contentType().trim() : "application/octet-stream"; + String reference = String.join("|", + value(media.externalMediaId()), + value(media.externalUrl()), + name, + mediaType, + value(media.sizeBytes())); + attachments.add(new BookingMessageAttachmentInput( + "agentbus-att-" + ordinal, + name, + mediaType, + media.sizeBytes() == null ? 0L : Math.max(0L, media.sizeBytes()), + sha256(reference), + null, + true)); + } + return List.copyOf(attachments); + } + + private String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value(source).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } + + private String value(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageAttachmentInput.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageAttachmentInput.java new file mode 100644 index 0000000..7d25902 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageAttachmentInput.java @@ -0,0 +1,40 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.dto; + +/** + * 编排器内部使用的附件材料输入。 + * + *

字节仅在本次预处理/Parser 内存中使用;持久化层只保存哈希、摘要和证据定位。

+ */ +public record BookingMessageAttachmentInput( + String attachmentId, + String fileName, + String mediaType, + long sizeBytes, + String contentHash, + byte[] bytes, + boolean currentMaterial) { + + public BookingMessageAttachmentInput { + if (attachmentId == null || attachmentId.isBlank()) { + throw new IllegalArgumentException("attachmentId 不能为空"); + } + if (fileName == null || fileName.isBlank()) { + throw new IllegalArgumentException("fileName 不能为空"); + } + if (mediaType == null || mediaType.isBlank()) { + throw new IllegalArgumentException("mediaType 不能为空"); + } + if (contentHash == null || contentHash.isBlank()) { + throw new IllegalArgumentException("contentHash 不能为空"); + } + if (sizeBytes < 0) { + throw new IllegalArgumentException("sizeBytes 不能小于 0"); + } + bytes = bytes == null ? null : bytes.clone(); + } + + @Override + public byte[] bytes() { + return bytes == null ? null : bytes.clone(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageInput.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageInput.java new file mode 100644 index 0000000..4087c35 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageInput.java @@ -0,0 +1,78 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.dto; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.time.Instant; +import java.util.List; + +/** + * 统一入口送给 BookingMessageOrchestrator 的内部消息输入。 + * + *

手工 EML 与 AgentBus 都必须映射到该结构;它只在服务内部存在,不能作为前端或 AgentBus 对外 DTO。

+ */ +public record BookingMessageInput( + String hotelId, + BookingContracts.MessageOrigin messageOrigin, + String platformSourceMessageId, + String externalMessageId, + String conversationId, + String inReplyTo, + String idempotencyKey, + Instant receivedAt, + String safeSubject, + String currentBody, + String quotedHistory, + List attachments, + String senderIdentifier) { + + public BookingMessageInput { + requireText(hotelId, "hotelId"); + if (messageOrigin == null) { + throw new IllegalArgumentException("messageOrigin 不能为空"); + } + requireText(platformSourceMessageId, "platformSourceMessageId"); + requireText(idempotencyKey, "idempotencyKey"); + if (receivedAt == null) { + throw new IllegalArgumentException("receivedAt 不能为空"); + } + attachments = attachments == null ? List.of() : List.copyOf(attachments); + senderIdentifier = senderIdentifier == null || senderIdentifier.isBlank() + ? null + : senderIdentifier.trim(); + } + + /** 兼容 contracts-v1 早期入口;新入口应传入最小 sender identifier。 */ + public BookingMessageInput( + String hotelId, + BookingContracts.MessageOrigin messageOrigin, + String platformSourceMessageId, + String externalMessageId, + String conversationId, + String inReplyTo, + String idempotencyKey, + Instant receivedAt, + String safeSubject, + String currentBody, + String quotedHistory, + List attachments) { + this( + hotelId, + messageOrigin, + platformSourceMessageId, + externalMessageId, + conversationId, + inReplyTo, + idempotencyKey, + receivedAt, + safeSubject, + currentBody, + quotedHistory, + attachments, + null); + } + + private static void requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " 不能为空"); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageMasterData.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageMasterData.java new file mode 100644 index 0000000..ba54fd3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingMessageMasterData.java @@ -0,0 +1,101 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.dto; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 由项目 PostgreSQL 主数据得出的、可交给 Context/Decision 的最小映射结果。 + * + *

这个对象不携带原始 sender、邮件正文或附件地址;它只暴露确定的业务代码、方向和显式未解决状态。 + * 因此可安全放入 {@code ContextPackage},也可供 Booking Agent 消费。

+ */ +public record BookingMessageMasterData( + SenderMappingStatus senderMappingStatus, + MessageDirection messageDirection, + String accountCode, + String marketCode, + String sourceCode, + Map rateCodeByFactId, + List warningCodes) { + + public BookingMessageMasterData { + senderMappingStatus = senderMappingStatus == null ? SenderMappingStatus.SENDER_UNAVAILABLE : senderMappingStatus; + messageDirection = messageDirection == null ? MessageDirection.UNKNOWN : messageDirection; + accountCode = normalized(accountCode); + marketCode = normalized(marketCode); + sourceCode = normalized(sourceCode); + rateCodeByFactId = rateCodeByFactId == null + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(rateCodeByFactId)); + warningCodes = warningCodes == null ? List.of() : List.copyOf(warningCodes); + } + + /** 只有显式主数据方向才允许跳过本封邮件;未知 sender 永远不是忽略依据。 */ + public boolean shouldIgnore() { + return messageDirection == MessageDirection.OUTBOUND || messageDirection == MessageDirection.INTERNAL; + } + + public RateCodeResolution rateCodeFor(String factId) { + return factId == null ? null : rateCodeByFactId.get(factId); + } + + /** 生成可持久化、可供 Agent 消费的 Context 片段,不包含 sender 本身。 */ + public Map contextValue() { + Map senderMapping = new LinkedHashMap<>(); + senderMapping.put("status", senderMappingStatus.name()); + senderMapping.put("direction", messageDirection.name()); + senderMapping.put("account_code", accountCode); + senderMapping.put("market_code", marketCode); + senderMapping.put("source_code", sourceCode); + + Map rateMappings = new LinkedHashMap<>(); + for (Map.Entry entry : rateCodeByFactId.entrySet()) { + RateCodeResolution resolution = entry.getValue(); + Map value = new LinkedHashMap<>(); + value.put("status", resolution.status().name()); + value.put("rate_code", resolution.rateCode()); + rateMappings.put(entry.getKey(), value); + } + + Map result = new LinkedHashMap<>(); + result.put("sender_mapping", senderMapping); + result.put("rate_code_mappings", rateMappings); + result.put("warning_codes", warningCodes); + return Collections.unmodifiableMap(result); + } + + private static String normalized(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + public enum SenderMappingStatus { + RESOLVED, + MISSING, + AMBIGUOUS, + SENDER_UNAVAILABLE + } + + public enum MessageDirection { + INBOUND, + OUTBOUND, + INTERNAL, + UNKNOWN + } + + public record RateCodeResolution(RateCodeResolutionStatus status, String rateCode) { + + public RateCodeResolution { + status = status == null ? RateCodeResolutionStatus.NOT_APPLICABLE : status; + rateCode = rateCode == null || rateCode.isBlank() ? null : rateCode.trim(); + } + } + + public enum RateCodeResolutionStatus { + RESOLVED, + MISSING, + AMBIGUOUS, + NOT_APPLICABLE + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingOrchestrationResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingOrchestrationResult.java new file mode 100644 index 0000000..ec5d8a9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingOrchestrationResult.java @@ -0,0 +1,24 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.dto; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.List; + +/** 统一编排器返回给入口层的安全结果。 */ +public record BookingOrchestrationResult( + String processingRunId, + boolean duplicate, + boolean asynchronous, + BookingContracts.ProcessingStatus processingStatus, + BookingContracts.ResultDisposition resultDisposition, + List warningCodes) { + + public BookingOrchestrationResult { + if (processingRunId == null || processingRunId.isBlank()) { + throw new IllegalArgumentException("processingRunId 不能为空"); + } + if (processingStatus == null) { + throw new IllegalArgumentException("processingStatus 不能为空"); + } + warningCodes = warningCodes == null ? List.of() : List.copyOf(warningCodes); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingProcessingRunSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingProcessingRunSnapshot.java new file mode 100644 index 0000000..28e30c3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingProcessingRunSnapshot.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.dto; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.time.OffsetDateTime; + +/** 新 Booking 主线 processing run 的安全查询快照。 */ +public record BookingProcessingRunSnapshot( + long databaseId, + String runId, + long sourceMessageDatabaseId, + String sourceMessageId, + int revisionNumber, + BookingContracts.ProcessingStatus status, + BookingContracts.ResultDisposition resultDisposition, + boolean created, + int retryCount, + String errorCode, + String errorSummary, + OffsetDateTime startedAt, + OffsetDateTime completedAt) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingTargetStateSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingTargetStateSnapshot.java new file mode 100644 index 0000000..7ab7e12 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/dto/BookingTargetStateSnapshot.java @@ -0,0 +1,38 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.dto; + +import java.time.OffsetDateTime; + +/** 按 Group Code 定向读取的 Booking 当前态安全摘要。 */ +public record BookingTargetStateSnapshot( + long candidateTargetId, + String groupCode, + String businessType, + String actionKind, + String validationStatus, + String processingRunStatus, + OffsetDateTime createdAt, + String roomItemsJson, + String linkedActionsJson) { + + /** 兼容仅保存房量摘要的早期 contracts-v1 current-state adapter。 */ + public BookingTargetStateSnapshot( + long candidateTargetId, + String groupCode, + String businessType, + String actionKind, + String validationStatus, + String processingRunStatus, + OffsetDateTime createdAt, + String roomItemsJson) { + this( + candidateTargetId, + groupCode, + businessType, + actionKind, + validationStatus, + processingRunStatus, + createdAt, + roomItemsJson, + null); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationItemRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationItemRequest.java new file mode 100644 index 0000000..fddaca9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationItemRequest.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** 单个 contracts-v1 确认项的用户选择和受控字段修正。 */ +public record BookingConfirmationItemRequest( + @JsonProperty("confirmation_item_id") + String confirmationItemId, + @JsonProperty("field_overrides") + Map fieldOverrides) { + + public BookingConfirmationItemRequest { + if (confirmationItemId == null || confirmationItemId.isBlank()) { + throw new IllegalArgumentException("confirmation_item_id 不能为空。"); + } + fieldOverrides = fieldOverrides == null || fieldOverrides.isEmpty() + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(fieldOverrides)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationRequest.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationRequest.java new file mode 100644 index 0000000..9a3d131 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/request/BookingConfirmationRequest.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.request; + +import java.util.List; + +/** 用户确认一封 Booking 邮件的全部可确认项。确认只冻结参数与审计,不产生 PMS/Opera 副作用。 */ +public record BookingConfirmationRequest(List items) { + + public BookingConfirmationRequest { + items = items == null ? List.of() : List.copyOf(items); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingConfirmationResult.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingConfirmationResult.java new file mode 100644 index 0000000..778a2bb --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingConfirmationResult.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.result; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** contracts-v1 确认结果;confirmed=false 时前端应继续展示 Validator 的阻断信息。 */ +public record BookingConfirmationResult( + Boolean confirmed, + @JsonProperty("processing_run") + BookingProcessingRunView processingRun) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingProcessingRunView.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingProcessingRunView.java new file mode 100644 index 0000000..15b8dbc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/common/result/BookingProcessingRunView.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.common.result; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** 面向前端的 processing run 安全查询视图,不包含原始邮件正文、附件或 Agent 原始回答。 */ +public record BookingProcessingRunView( + @JsonProperty("processing_run_id") + String processingRunId, + @JsonProperty("source_message_id") + String sourceMessageId, + @JsonProperty("revision_number") + int revisionNumber, + String status, + @JsonProperty("result_disposition") + String resultDisposition, + Boolean duplicate, + @JsonProperty("retry_count") + int retryCount, + @JsonProperty("error_code") + String errorCode, + @JsonProperty("error_summary") + String errorSummary, + @JsonProperty("started_at") + String startedAt, + @JsonProperty("completed_at") + String completedAt, + @JsonProperty("confirmation_projection") + BookingContracts.ConfirmationProjection confirmationProjection) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentConfiguration.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentConfiguration.java new file mode 100644 index 0000000..437040c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentConfiguration.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** 注册 Booking Agent 的独立配置,避免把 Agent 业务开关混入通用 SuperAgent 配置。 */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(BookingAgentProperties.class) +public class BookingAgentConfiguration { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentProperties.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentProperties.java new file mode 100644 index 0000000..393ac57 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingAgentProperties.java @@ -0,0 +1,58 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Booking Agent 适配器配置。模型/API Secret 继续由既有 superagent.open-api Secret 注入。 */ +@ConfigurationProperties(prefix = "booking.agent") +public class BookingAgentProperties { + + private boolean enabled; + /** 真实 Agent 调用是否通过 processing-run worker 异步执行。 */ + private boolean asyncEnabled = true; + private String profileVersion = "booking-agent-v1"; + private String externalSubjectId; + + public void validateEnabledConfiguration() { + if (!enabled) { + return; + } + if (profileVersion == null || profileVersion.isBlank()) { + throw new IllegalStateException("booking.agent.profile-version 不能为空。"); + } + if (externalSubjectId == null || externalSubjectId.isBlank()) { + throw new IllegalStateException("booking.agent.external-subject-id 必须通过部署配置提供。"); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isAsyncEnabled() { + return asyncEnabled; + } + + public void setAsyncEnabled(boolean asyncEnabled) { + this.asyncEnabled = asyncEnabled; + } + + public String getProfileVersion() { + return profileVersion; + } + + public void setProfileVersion(String profileVersion) { + this.profileVersion = profileVersion; + } + + public String getExternalSubjectId() { + return externalSubjectId; + } + + public void setExternalSubjectId(String externalSubjectId) { + this.externalSubjectId = externalSubjectId; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresConfiguration.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresConfiguration.java new file mode 100644 index 0000000..50e347f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresConfiguration.java @@ -0,0 +1,99 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.config; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import javax.sql.DataSource; +import java.util.concurrent.Executor; +import org.flywaydb.core.Flyway; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 新 Booking PostgreSQL 的独立 datasource、Flyway 和事务边界。 + * + *

本配置不替换现有 MySQL/H2 datasource。启用后所有新 Booking 查询必须使用命名 bean,禁止跨库事务。

+ */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(BookingPostgresProperties.class) +@EnableScheduling +@EnableAsync +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingPostgresConfiguration { + + /** 创建只供新 Booking 主线使用的数据源。 */ + @Bean(name = "bookingPostgresDataSource", destroyMethod = "close") + public HikariDataSource bookingPostgresDataSource(BookingPostgresProperties properties) { + properties.validateEnabledConfiguration(); + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(properties.getUrl()); + config.setUsername(properties.getUsername()); + config.setPassword(properties.getPassword()); + config.setMaximumPoolSize(properties.getMaximumPoolSize()); + config.setConnectionTimeout(properties.getConnectionTimeoutMs()); + config.setPoolName("booking-postgres-runtime"); + config.setAutoCommit(false); + return new HikariDataSource(config); + } + + /** 在项目专属 schema 中执行独立 Flyway migration;非空且无 history 的 schema 必须人工 preflight/baseline。 */ + @Bean(name = "bookingPostgresFlyway") + public Flyway bookingPostgresFlyway( + @Qualifier("bookingPostgresDataSource") DataSource dataSource, + BookingPostgresProperties properties) { + Flyway flyway = Flyway.configure() + .dataSource(dataSource) + .schemas(properties.getSchema()) + .defaultSchema(properties.getSchema()) + .createSchemas(true) + .locations("classpath:db/booking-postgresql/migration") + .baselineOnMigrate(false) + .validateOnMigrate(true) + .cleanDisabled(true) + .load(); + flyway.migrate(); + return flyway; + } + + /** 提供已完成 migration 的命名 JDBC 模板。 */ + @Bean(name = "bookingPostgresJdbcTemplate") + @DependsOn("bookingPostgresFlyway") + public JdbcTemplate bookingPostgresJdbcTemplate( + @Qualifier("bookingPostgresDataSource") DataSource dataSource) { + return new JdbcTemplate(dataSource); + } + + /** 新 Booking 主线专用事务管理器,不与旧 MySQL/H2 事务混用。 */ + @Bean(name = "bookingPostgresTransactionManager") + public PlatformTransactionManager bookingPostgresTransactionManager( + @Qualifier("bookingPostgresDataSource") DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } + + /** + * 真实 Booking Agent 使用独立小型 worker,避免占用邮件入口线程;队列满时由调用线程执行, + * 保证已记录的 processing run 不会悄悄丢失。 + */ + @Bean(name = "bookingAgentPipelineTaskExecutor") + public Executor bookingAgentPipelineTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("booking-agent-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresProperties.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresProperties.java new file mode 100644 index 0000000..bf3ff67 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/config/BookingPostgresProperties.java @@ -0,0 +1,141 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 新 Booking 主线 PostgreSQL 连接配置。 + * + *

仅在 {@code booking.postgres.enabled=true} 时校验必填项;连接信息只能由部署 Secret 注入。

+ */ +@ConfigurationProperties(prefix = "booking.postgres") +public class BookingPostgresProperties { + + /** 是否启用新 Booking PostgreSQL runtime。 */ + private boolean enabled; + /** JDBC 地址。 */ + private String url; + /** 数据库用户名。 */ + private String username; + /** 数据库密码。 */ + private String password; + /** 本项目唯一允许使用的 schema。 */ + private String schema = "th_hotel_booking"; + /** 最大连接池大小。 */ + private int maximumPoolSize = 4; + /** 建连超时毫秒。 */ + private long connectionTimeoutMs = 10_000L; + /** AgentBus 当前 Excel 附件在材料预处理阶段允许读取的最大字节数。 */ + private long materialDownloadMaxBytes = 10L * 1024L * 1024L; + /** 是否启用仅针对项目 schema 的三个月到期清理任务。 */ + private boolean retentionEnabled; + /** 清理任务 cron;默认每日低峰执行。 */ + private String retentionCron = "0 15 3 * * *"; + + /** 启用 runtime 后校验连接与 schema 边界。 */ + public void validateEnabledConfiguration() { + if (!enabled) { + return; + } + requireText(url, "booking.postgres.url"); + requireText(username, "booking.postgres.username"); + requireText(password, "booking.postgres.password"); + if (!"th_hotel_booking".equals(schema)) { + throw new IllegalStateException("Booking PostgreSQL 只能使用 th_hotel_booking schema。"); + } + if (maximumPoolSize < 1) { + throw new IllegalStateException("booking.postgres.maximum-pool-size 必须大于 0。"); + } + if (connectionTimeoutMs < 1) { + throw new IllegalStateException("booking.postgres.connection-timeout-ms 必须大于 0。"); + } + if (materialDownloadMaxBytes < 1) { + throw new IllegalStateException("booking.postgres.material-download-max-bytes 必须大于 0。"); + } + } + + private void requireText(String value, String propertyName) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(propertyName + " 必须通过部署 Secret 配置。"); + } + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getSchema() { + return schema; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public int getMaximumPoolSize() { + return maximumPoolSize; + } + + public void setMaximumPoolSize(int maximumPoolSize) { + this.maximumPoolSize = maximumPoolSize; + } + + public long getConnectionTimeoutMs() { + return connectionTimeoutMs; + } + + public void setConnectionTimeoutMs(long connectionTimeoutMs) { + this.connectionTimeoutMs = connectionTimeoutMs; + } + + public long getMaterialDownloadMaxBytes() { + return materialDownloadMaxBytes; + } + + public void setMaterialDownloadMaxBytes(long materialDownloadMaxBytes) { + this.materialDownloadMaxBytes = materialDownloadMaxBytes; + } + + public boolean isRetentionEnabled() { + return retentionEnabled; + } + + public void setRetentionEnabled(boolean retentionEnabled) { + this.retentionEnabled = retentionEnabled; + } + + public String getRetentionCron() { + return retentionCron; + } + + public void setRetentionCron(String retentionCron) { + this.retentionCron = retentionCron; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingConfirmationActionDraft.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingConfirmationActionDraft.java new file mode 100644 index 0000000..5c7fc6f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingConfirmationActionDraft.java @@ -0,0 +1,23 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +/** 写入 PostgreSQL 前的最小确认审计草稿。字段修正只保存为 JSON,不包含邮件正文或附件。 */ +public record BookingConfirmationActionDraft( + String confirmationItemKey, + String targetDecisionKey, + String actionType, + String fieldOverridesJson, + String actorId) { + + public BookingConfirmationActionDraft { + requireText(confirmationItemKey, "confirmationItemKey"); + requireText(actionType, "actionType"); + requireText(fieldOverridesJson, "fieldOverridesJson"); + requireText(actorId, "actorId"); + } + + private static void requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " 不能为空"); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingCurrentStateRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingCurrentStateRepository.java new file mode 100644 index 0000000..5507e1a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingCurrentStateRepository.java @@ -0,0 +1,11 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingTargetStateSnapshot; +import java.util.List; + +/** 按明确 Group Code 定向读取新 Booking 主线当前态,禁止全库模糊扫描。 */ +public interface BookingCurrentStateRepository { + + /** 查询同酒店、同 Group Code 的有限当前态摘要。 */ + List findByGroupCode(String hotelId, String groupCode); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresCurrentStateRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresCurrentStateRepository.java new file mode 100644 index 0000000..b6a07cd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresCurrentStateRepository.java @@ -0,0 +1,65 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingTargetStateSnapshot; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** PostgreSQL 当前态查询,只读取本项目 schema 的 contracts-v1 候选目标。 */ +@Repository +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingPostgresCurrentStateRepository implements BookingCurrentStateRepository { + + private static final int MAX_CONTEXT_ROWS = 20; + + private final JdbcTemplate jdbcTemplate; + + public BookingPostgresCurrentStateRepository( + @Qualifier("bookingPostgresJdbcTemplate") JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** 按酒店与 Group Code 查询最近候选/确认态,不读取旧 MySQL 或其他 schema。 */ + @Override + @Transactional(readOnly = true, transactionManager = "bookingPostgresTransactionManager") + public List findByGroupCode(String hotelId, String groupCode) { + if (hotelId == null || hotelId.isBlank() || groupCode == null || groupCode.isBlank()) { + return List.of(); + } + return jdbcTemplate.query( + "SELECT candidate.id, candidate.target_group_code, candidate.business_type, candidate.action_kind, " + + "candidate.validation_status, run.run_status, candidate.created_at, " + + "candidate.decision_payload #>> '{fields,room_items}' AS room_items_json, " + + "candidate.decision_payload #> '{linked_actions}' AS linked_actions_json " + + "FROM th_hotel_booking.booking_contract_candidate_target candidate " + + "JOIN th_hotel_booking.booking_contract_processing_run run " + + "ON run.id = candidate.processing_run_id " + + "JOIN th_hotel_booking.booking_contract_source_message source " + + "ON source.id = run.source_message_id " + + "WHERE source.hotel_id = ? AND candidate.target_group_code = ? " + + "ORDER BY candidate.created_at DESC, candidate.id DESC LIMIT ?", + this::mapSnapshot, + hotelId, + groupCode.trim(), + MAX_CONTEXT_ROWS); + } + + private BookingTargetStateSnapshot mapSnapshot(ResultSet resultSet, int rowNumber) throws SQLException { + return new BookingTargetStateSnapshot( + resultSet.getLong("id"), + resultSet.getString("target_group_code"), + resultSet.getString("business_type"), + resultSet.getString("action_kind"), + resultSet.getString("validation_status"), + resultSet.getString("run_status"), + resultSet.getObject("created_at", OffsetDateTime.class), + resultSet.getString("room_items_json"), + resultSet.getString("linked_actions_json")); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresProcessingRunRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresProcessingRunRepository.java new file mode 100644 index 0000000..3b9b39a --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresProcessingRunRepository.java @@ -0,0 +1,642 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingProcessingRunSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.sql.Date; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * PostgreSQL contracts-v1 运行时仓储实现。 + * + *

SQL 始终全限定到 {@code th_hotel_booking};该类不读取或写入旧 MySQL V4 表。

+ */ +@Repository +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingPostgresProcessingRunRepository implements BookingProcessingRunRepository { + + private static final RowMapper RUN_ROW_MAPPER = + BookingPostgresProcessingRunRepository::mapRun; + + private final JdbcTemplate jdbcTemplate; + + public BookingPostgresProcessingRunRepository( + @Qualifier("bookingPostgresJdbcTemplate") JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** 幂等保存当前来源邮件;同一外部投递仅返回已有 run,不隐式创建 revision。 */ + @Override + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public BookingProcessingRunSnapshot startOrReuse( + String requestedRunId, + String hotelId, + BookingContracts.SourceMessageEnvelope envelope, + String messageSha256) { + requireText(requestedRunId, "requestedRunId"); + requireText(hotelId, "hotelId"); + requireText(messageSha256, "messageSha256"); + BookingContracts.ContractMeta meta = envelope.contractMeta(); + SourceRow source = findSourceForUpdate(hotelId, envelope); + if (source == null) { + long sourceMessageDatabaseId = insertSourceMessage(hotelId, envelope, messageSha256, 0L); + insertAttachments(sourceMessageDatabaseId, envelope.attachmentDescriptors()); + return insertRun(requestedRunId, sourceMessageDatabaseId, 1, meta, messageSha256); + } + + BookingProcessingRunSnapshot latestRun = jdbcTemplate.query( + runSelectSql() + " WHERE run.source_message_id = ? " + + "ORDER BY run.revision_number DESC LIMIT 1", + RUN_ROW_MAPPER, + source.id()) + .stream() + .findFirst() + .orElse(null); + if (latestRun != null && messageSha256.equals(source.messageSha256())) { + return withCreated(latestRun, false); + } + + int nextRevisionNumber = latestRun == null ? 1 : latestRun.revisionNumber() + 1; + updateSourceMessage(source.id(), envelope, messageSha256, nextRevisionNumber - 1L); + replaceAttachments(source.id(), envelope.attachmentDescriptors()); + return insertRun(requestedRunId, source.id(), nextRevisionNumber, meta, messageSha256); + } + + private BookingProcessingRunSnapshot insertRun( + String runId, + long sourceMessageDatabaseId, + int revisionNumber, + BookingContracts.ContractMeta meta, + String messageSha256) { + long runDatabaseId = jdbcTemplate.queryForObject( + "INSERT INTO th_hotel_booking.booking_contract_processing_run (" + + "run_id, source_message_id, revision_number, run_status, contract_version, " + + "catalog_version, parser_version, agent_profile_version, input_fingerprint" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + Long.class, + runId, + sourceMessageDatabaseId, + revisionNumber, + BookingContracts.ProcessingStatus.RECEIVED.name(), + meta.contractVersion(), + meta.catalogVersion(), + meta.parserVersion(), + meta.agentProfileVersion(), + messageSha256); + jdbcTemplate.update( + "INSERT INTO th_hotel_booking.booking_contract_processing_attempt (" + + "processing_run_id, attempt_number, attempt_status" + + ") VALUES (?, 1, 'STARTED')", + runDatabaseId); + return findByRunId(runId) + .map(snapshot -> withCreated(snapshot, true)) + .orElseThrow(() -> new IllegalStateException("Booking processing run 创建后无法读取。")); + } + + /** 将 contracts-v1 阶段快照和证据引用写入同一个 PostgreSQL 事务边界。 */ + @Override + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public void saveArtifact( + String runId, + String artifactType, + String payloadJson, + String payloadSha256, + List evidenceRefs) { + requireText(runId, "runId"); + requireText(artifactType, "artifactType"); + requireText(payloadJson, "payloadJson"); + requireText(payloadSha256, "payloadSha256"); + RunReference runReference = runReference(runId); + jdbcTemplate.update( + "INSERT INTO th_hotel_booking.booking_contract_artifact (" + + "processing_run_id, artifact_type, contract_version, payload, payload_sha256" + + ") VALUES (?, ?, ?, CAST(? AS jsonb), ?) " + + "ON CONFLICT (processing_run_id, artifact_type) DO UPDATE SET " + + "contract_version = EXCLUDED.contract_version, payload = EXCLUDED.payload, " + + "payload_sha256 = EXCLUDED.payload_sha256, updated_at = CURRENT_TIMESTAMP", + runReference.processingRunDatabaseId(), + artifactType, + BookingContracts.CONTRACT_VERSION, + payloadJson, + payloadSha256); + for (BookingContracts.EvidenceReference evidence : safeEvidenceReferences(evidenceRefs)) { + jdbcTemplate.update( + "INSERT INTO th_hotel_booking.booking_contract_evidence_reference (" + + "processing_run_id, evidence_key, evidence_kind, source_message_id, " + + "locator, content_hash, is_current_material" + + ") VALUES (?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (processing_run_id, evidence_key) DO UPDATE SET " + + "evidence_kind = EXCLUDED.evidence_kind, locator = EXCLUDED.locator, " + + "content_hash = EXCLUDED.content_hash, is_current_material = EXCLUDED.is_current_material", + runReference.processingRunDatabaseId(), + evidence.evidenceId(), + evidence.kind().name(), + runReference.sourceMessageDatabaseId(), + evidence.locator(), + evidence.contentHash(), + evidence.currentMaterial()); + } + } + + /** 保存已解析事实;同一 run 重放时使用确定键覆盖,而不生成额外行。 */ + @Override + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public void saveParsedFacts( + String runId, + List facts, + List factPayloadJsons) { + requireText(runId, "runId"); + List safeFacts = facts == null ? List.of() : List.copyOf(facts); + List safePayloads = factPayloadJsons == null ? List.of() : List.copyOf(factPayloadJsons); + if (safeFacts.size() != safePayloads.size()) { + throw new IllegalArgumentException("ParsedFact 与 payload 数量不一致。"); + } + RunReference runReference = runReference(runId); + jdbcTemplate.update( + "DELETE FROM th_hotel_booking.booking_contract_parsed_fact WHERE processing_run_id = ?", + runReference.processingRunDatabaseId()); + int ordinal = 0; + for (BookingContracts.ParsedFact fact : safeFacts) { + String payload = safePayloads.get(ordinal); + ordinal++; + jdbcTemplate.update( + "INSERT INTO th_hotel_booking.booking_contract_parsed_fact (" + + "processing_run_id, fact_key, fact_ordinal, action_hint, booking_type, " + + "group_code, group_name, tour_code, arrival_date, departure_date, fact_payload" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS jsonb))", + runReference.processingRunDatabaseId(), + fact.factId(), + ordinal, + blankToNull(fact.actionHint()), + blankToNull(fact.bookingType()), + fact.targetIdentity() == null ? null : blankToNull(fact.targetIdentity().groupCode()), + fact.targetIdentity() == null ? null : blankToNull(fact.targetIdentity().groupName()), + fact.targetIdentity() == null ? null : blankToNull(fact.targetIdentity().tourCode()), + sqlDate(fact.stay() == null ? null : fact.stay().arrivalDate()), + sqlDate(fact.stay() == null ? null : fact.stay().departureDate()), + payload); + } + } + + /** 保存候选目标及目标级校验状态,供 Context 按 Group Code 定向读取。 */ + @Override + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public void saveCandidateTargets( + String runId, + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ContextPackage contextPackage, + BookingContracts.ValidationResult validationResult, + List targetPayloadJsons) { + requireText(runId, "runId"); + if (candidateDecision == null || contextPackage == null || validationResult == null) { + throw new IllegalArgumentException("CandidateDecision、ContextPackage 和 ValidationResult 不能为空。"); + } + List targets = candidateDecision.targetDecisions(); + List safePayloads = targetPayloadJsons == null ? List.of() : List.copyOf(targetPayloadJsons); + if (targets.size() != safePayloads.size()) { + throw new IllegalArgumentException("Candidate target 与 payload 数量不一致。"); + } + RunReference runReference = runReference(runId); + Map validationByTarget = validationResult.validatedTargets().stream() + .collect(java.util.stream.Collectors.toMap( + BookingContracts.ValidatedTarget::targetDecisionId, + BookingContracts.ValidatedTarget::status, + (first, ignored) -> first, + LinkedHashMap::new)); + Map resolutionByReference = contextPackage.targetResolutions().stream() + .collect(java.util.stream.Collectors.toMap( + BookingContracts.TargetResolution::targetReference, + BookingContracts.TargetResolution::resolutionStatus, + (first, ignored) -> first, + LinkedHashMap::new)); + for (int index = 0; index < targets.size(); index++) { + BookingContracts.TargetDecision target = targets.get(index); + String groupCode = target.targetIdentity() == null + ? stringField(target.fields(), "group_code") + : blankToNull(target.targetIdentity().groupCode()); + String resolutionStatus = resolutionByReference.get(groupCode); + if (resolutionStatus == null) { + resolutionStatus = resolutionByReference.get(target.targetDecisionId()); + } + if (resolutionStatus == null) { + resolutionStatus = "UNRESOLVED"; + } + BookingContracts.ValidationStatus validationStatus = validationByTarget.get(target.targetDecisionId()); + jdbcTemplate.update( + "INSERT INTO th_hotel_booking.booking_contract_candidate_target (" + + "processing_run_id, target_decision_key, business_type, action_kind, result_disposition, " + + "target_resolution_status, target_group_code, decision_payload, validation_status" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, CAST(? AS jsonb), ?) " + + "ON CONFLICT (processing_run_id, target_decision_key) DO UPDATE SET " + + "business_type = EXCLUDED.business_type, action_kind = EXCLUDED.action_kind, " + + "result_disposition = EXCLUDED.result_disposition, " + + "target_resolution_status = EXCLUDED.target_resolution_status, " + + "target_group_code = EXCLUDED.target_group_code, decision_payload = EXCLUDED.decision_payload, " + + "validation_status = EXCLUDED.validation_status, updated_at = CURRENT_TIMESTAMP", + runReference.processingRunDatabaseId(), + target.targetDecisionId(), + target.businessType(), + target.actionKind(), + candidateDecision.resultDisposition().name(), + resolutionStatus, + groupCode, + safePayloads.get(index), + validationStatus == null ? null : validationStatus.name()); + } + } + + /** 将最终版本元数据从 staged contract 回写 run 摘要。 */ + @Override + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public void updateRunContractMeta(String runId, BookingContracts.ContractMeta contractMeta) { + requireText(runId, "runId"); + if (contractMeta == null) { + throw new IllegalArgumentException("contractMeta 不能为空。"); + } + int updated = jdbcTemplate.update( + "UPDATE th_hotel_booking.booking_contract_processing_run SET " + + "contract_version = ?, catalog_version = ?, parser_version = ?, " + + "agent_profile_version = ?, updated_at = CURRENT_TIMESTAMP WHERE run_id = ?", + contractMeta.contractVersion(), + contractMeta.catalogVersion(), + contractMeta.parserVersion(), + contractMeta.agentProfileVersion(), + runId); + if (updated != 1) { + throw new IllegalArgumentException("Booking processing run 不存在。"); + } + } + + /** 读取安全 artifact JSON,供只读查询和确认服务反序列化。 */ + @Override + @Transactional(readOnly = true, transactionManager = "bookingPostgresTransactionManager") + public Optional findArtifactPayload(String runId, String artifactType) { + requireText(runId, "runId"); + requireText(artifactType, "artifactType"); + return jdbcTemplate.query( + "SELECT artifact.payload::text AS payload_json " + + "FROM th_hotel_booking.booking_contract_artifact artifact " + + "JOIN th_hotel_booking.booking_contract_processing_run run " + + "ON run.id = artifact.processing_run_id " + + "WHERE run.run_id = ? AND artifact.artifact_type = ?", + (resultSet, rowNumber) -> resultSet.getString("payload_json"), + runId, + artifactType) + .stream() + .findFirst(); + } + + /** + * 在单个 PostgreSQL 事务中记录确认审计并将 run 置为 CONFIRMED。 + * + *

已经确认的 run 不重复追加 action,调用方可安全重放同一确认请求。

+ */ + @Override + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public void confirmRun(String runId, List actions) { + requireText(runId, "runId"); + List safeActions = actions == null ? List.of() : List.copyOf(actions); + if (safeActions.isEmpty()) { + throw new IllegalArgumentException("确认动作不能为空。"); + } + BookingProcessingRunSnapshot snapshot = findByRunId(runId) + .orElseThrow(() -> new IllegalArgumentException("Booking processing run 不存在。")); + if (snapshot.status() == BookingContracts.ProcessingStatus.CONFIRMED) { + return; + } + if (snapshot.status() != BookingContracts.ProcessingStatus.AWAITING_CONFIRMATION) { + throw new IllegalStateException("当前 processing run 尚不可确认。"); + } + RunReference runReference = runReference(runId); + Map candidateIds = jdbcTemplate.query( + "SELECT target_decision_key, id FROM th_hotel_booking.booking_contract_candidate_target " + + "WHERE processing_run_id = ?", + (resultSet, rowNumber) -> Map.entry( + resultSet.getString("target_decision_key"), resultSet.getLong("id")), + runReference.processingRunDatabaseId()) + .stream() + .collect(java.util.stream.Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (first, ignored) -> first, + LinkedHashMap::new)); + for (BookingConfirmationActionDraft action : safeActions) { + if (!"CONFIRM".equals(action.actionType()) && !"RESOLVE_REVIEW".equals(action.actionType())) { + throw new IllegalArgumentException("不支持的 Booking 确认动作。"); + } + Long candidateId = action.targetDecisionKey() == null + ? null + : candidateIds.get(action.targetDecisionKey()); + if (action.targetDecisionKey() != null && candidateId == null) { + throw new IllegalArgumentException("确认目标不属于当前 processing run。"); + } + jdbcTemplate.update( + "INSERT INTO th_hotel_booking.booking_contract_confirmation_action (" + + "processing_run_id, candidate_target_id, confirmation_item_key, action_type, " + + "field_overrides, actor_id" + + ") VALUES (?, ?, ?, ?, CAST(? AS jsonb), ?) " + + "ON CONFLICT (processing_run_id, confirmation_item_key, action_type) DO NOTHING", + runReference.processingRunDatabaseId(), + candidateId, + action.confirmationItemKey(), + action.actionType(), + action.fieldOverridesJson(), + action.actorId()); + } + int updated = jdbcTemplate.update( + "UPDATE th_hotel_booking.booking_contract_processing_run SET " + + "run_status = 'CONFIRMED', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP " + + "WHERE id = ? AND run_status = 'AWAITING_CONFIRMATION'", + runReference.processingRunDatabaseId()); + if (updated != 1) { + throw new IllegalStateException("Booking processing run 确认时状态已变化。"); + } + completeLatestAttempt(runId, "SUCCEEDED", null, null); + } + + /** 更新 processing run 的可查询状态;错误摘要由调用方先脱敏。 */ + @Override + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public void updateRunStatus( + String runId, + BookingContracts.ProcessingStatus status, + BookingContracts.ResultDisposition resultDisposition, + String errorCode, + String errorSummary) { + requireText(runId, "runId"); + if (status == null) { + throw new IllegalArgumentException("status 不能为空"); + } + int updated = jdbcTemplate.update( + "UPDATE th_hotel_booking.booking_contract_processing_run SET " + + "run_status = ?, result_disposition = ?, error_code = ?, error_summary = ?, " + + "completed_at = CASE WHEN ? IN ('CONFIRMED', 'FAILED') THEN CURRENT_TIMESTAMP ELSE completed_at END, " + + "updated_at = CURRENT_TIMESTAMP " + + "WHERE run_id = ?", + status.name(), + resultDisposition == null ? null : resultDisposition.name(), + blankToNull(errorCode), + safeSummary(errorSummary), + status.name(), + runId); + if (updated != 1) { + throw new IllegalArgumentException("Booking processing run 不存在。"); + } + if (status == BookingContracts.ProcessingStatus.FAILED) { + completeLatestAttempt(runId, "FAILED", errorCode, errorSummary); + } else if (status == BookingContracts.ProcessingStatus.VALIDATED + || status == BookingContracts.ProcessingStatus.AWAITING_CONFIRMATION + || status == BookingContracts.ProcessingStatus.CONFIRMED) { + completeLatestAttempt(runId, "SUCCEEDED", null, null); + } + } + + /** 按 run ID 查询安全摘要。 */ + @Override + @Transactional(readOnly = true, transactionManager = "bookingPostgresTransactionManager") + public Optional findByRunId(String runId) { + requireText(runId, "runId"); + return jdbcTemplate.query(runSelectSql() + " WHERE run.run_id = ?", RUN_ROW_MAPPER, runId) + .stream() + .findFirst(); + } + + private long insertSourceMessage( + String hotelId, + BookingContracts.SourceMessageEnvelope envelope, + String messageSha256, + long sourceRevision) { + Long sourceMessageId = jdbcTemplate.queryForObject( + "INSERT INTO th_hotel_booking.booking_contract_source_message (" + + "hotel_id, platform_source_message_id, message_origin, external_message_id, conversation_id, idempotency_key, " + + "source_revision, message_sha256, safe_subject, sender_summary, received_at, current_body_evidence_key, " + + "quoted_history_evidence_key" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + Long.class, + hotelId, + envelope.contractMeta().sourceMessageId(), + envelope.messageOrigin().name(), + blankToNull(envelope.externalMessageId()), + blankToNull(envelope.conversationId()), + envelope.idempotencyKey(), + sourceRevision, + messageSha256, + blankToNull(envelope.safeSubject()), + blankToNull(envelope.senderSummary()), + Timestamp.from(envelope.receivedAt().toInstant()), + envelope.currentBodyEvidenceId(), + blankToNull(envelope.quotedHistoryEvidenceId())); + if (sourceMessageId == null) { + throw new IllegalStateException("Booking 来源邮件创建后未返回 ID。"); + } + return sourceMessageId; + } + + private SourceRow findSourceForUpdate( + String hotelId, + BookingContracts.SourceMessageEnvelope envelope) { + return jdbcTemplate.query( + "SELECT id, message_sha256 FROM th_hotel_booking.booking_contract_source_message " + + "WHERE hotel_id = ? AND message_origin = ? AND idempotency_key = ? FOR UPDATE", + (resultSet, rowNumber) -> new SourceRow( + resultSet.getLong("id"), + resultSet.getString("message_sha256")), + hotelId, + envelope.messageOrigin().name(), + envelope.idempotencyKey()) + .stream() + .findFirst() + .orElse(null); + } + + private void updateSourceMessage( + long sourceMessageDatabaseId, + BookingContracts.SourceMessageEnvelope envelope, + String messageSha256, + long sourceRevision) { + int updated = jdbcTemplate.update( + "UPDATE th_hotel_booking.booking_contract_source_message SET " + + "source_revision = ?, message_sha256 = ?, safe_subject = ?, sender_summary = ?, received_at = ?, " + + "current_body_evidence_key = ?, quoted_history_evidence_key = ?, updated_at = CURRENT_TIMESTAMP " + + "WHERE id = ?", + sourceRevision, + messageSha256, + blankToNull(envelope.safeSubject()), + blankToNull(envelope.senderSummary()), + Timestamp.from(envelope.receivedAt().toInstant()), + envelope.currentBodyEvidenceId(), + blankToNull(envelope.quotedHistoryEvidenceId()), + sourceMessageDatabaseId); + if (updated != 1) { + throw new IllegalStateException("Booking 来源邮件 revision 更新失败。"); + } + } + + private void replaceAttachments( + long sourceMessageDatabaseId, + List attachmentDescriptors) { + jdbcTemplate.update( + "DELETE FROM th_hotel_booking.booking_contract_source_attachment WHERE source_message_id = ?", + sourceMessageDatabaseId); + insertAttachments(sourceMessageDatabaseId, attachmentDescriptors); + } + + private void insertAttachments( + long sourceMessageDatabaseId, + List attachmentDescriptors) { + int ordinal = 0; + for (BookingContracts.AttachmentDescriptor attachment : safeAttachmentDescriptors(attachmentDescriptors)) { + ordinal++; + jdbcTemplate.update( + "INSERT INTO th_hotel_booking.booking_contract_source_attachment (" + + "source_message_id, attachment_ordinal, attachment_key, file_name, media_type, " + + "size_bytes, content_sha256, evidence_key" + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (source_message_id, attachment_key) DO NOTHING", + sourceMessageDatabaseId, + ordinal, + attachment.attachmentId(), + attachment.fileName(), + attachment.mediaType(), + attachment.sizeBytes(), + attachment.contentHash(), + attachment.evidenceId()); + } + } + + private RunReference runReference(String runId) { + return jdbcTemplate.query( + "SELECT id, source_message_id FROM th_hotel_booking.booking_contract_processing_run " + + "WHERE run_id = ?", + (resultSet, rowNumber) -> new RunReference( + resultSet.getLong("id"), resultSet.getLong("source_message_id")), + runId) + .stream() + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Booking processing run 不存在。")); + } + + private void completeLatestAttempt( + String runId, + String attemptStatus, + String errorCode, + String errorSummary) { + RunReference runReference = runReference(runId); + jdbcTemplate.update( + "UPDATE th_hotel_booking.booking_contract_processing_attempt SET " + + "attempt_status = ?, error_code = ?, error_summary = ?, completed_at = CURRENT_TIMESTAMP " + + "WHERE id = (SELECT id FROM th_hotel_booking.booking_contract_processing_attempt " + + "WHERE processing_run_id = ? ORDER BY attempt_number DESC LIMIT 1)", + attemptStatus, + blankToNull(errorCode), + safeSummary(errorSummary), + runReference.processingRunDatabaseId()); + } + + private Date sqlDate(String value) { + String normalized = blankToNull(value); + if (normalized == null) { + return null; + } + try { + return Date.valueOf(LocalDate.parse(normalized)); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("contracts-v1 日期必须为 ISO-8601 格式。", exception); + } + } + + private String stringField(Map fields, String key) { + if (fields == null) { + return null; + } + Object value = fields.get(key); + return value instanceof String text ? blankToNull(text) : null; + } + + private static BookingProcessingRunSnapshot mapRun(ResultSet resultSet, int rowNumber) throws SQLException { + String disposition = resultSet.getString("result_disposition"); + return new BookingProcessingRunSnapshot( + resultSet.getLong("id"), + resultSet.getString("run_id"), + resultSet.getLong("source_message_id"), + resultSet.getString("source_message_id_text"), + resultSet.getInt("revision_number"), + BookingContracts.ProcessingStatus.valueOf(resultSet.getString("run_status")), + disposition == null ? null : BookingContracts.ResultDisposition.valueOf(disposition), + false, + resultSet.getInt("retry_count"), + resultSet.getString("error_code"), + resultSet.getString("error_summary"), + resultSet.getObject("started_at", OffsetDateTime.class), + resultSet.getObject("completed_at", OffsetDateTime.class)); + } + + private String runSelectSql() { + return "SELECT run.id, run.run_id, run.source_message_id, source.platform_source_message_id AS source_message_id_text, " + + "run.revision_number, run.run_status, run.result_disposition, run.retry_count, run.error_code, " + + "run.error_summary, run.started_at, run.completed_at " + + "FROM th_hotel_booking.booking_contract_processing_run run " + + "JOIN th_hotel_booking.booking_contract_source_message source ON source.id = run.source_message_id"; + } + + private BookingProcessingRunSnapshot withCreated(BookingProcessingRunSnapshot snapshot, boolean created) { + return new BookingProcessingRunSnapshot( + snapshot.databaseId(), + snapshot.runId(), + snapshot.sourceMessageDatabaseId(), + snapshot.sourceMessageId(), + snapshot.revisionNumber(), + snapshot.status(), + snapshot.resultDisposition(), + created, + snapshot.retryCount(), + snapshot.errorCode(), + snapshot.errorSummary(), + snapshot.startedAt(), + snapshot.completedAt()); + } + + private List safeEvidenceReferences( + List values) { + return values == null ? List.of() : List.copyOf(values); + } + + private List safeAttachmentDescriptors( + List values) { + return values == null ? List.of() : List.copyOf(values); + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private String safeSummary(String value) { + String normalized = blankToNull(value); + return normalized == null ? null : normalized.substring(0, Math.min(normalized.length(), 512)); + } + + private void requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " 不能为空"); + } + } + + private record RunReference(long processingRunDatabaseId, long sourceMessageDatabaseId) { + } + + private record SourceRow(long id, String messageSha256) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresRateCodeMappingRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresRateCodeMappingRepository.java new file mode 100644 index 0000000..d1a1963 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresRateCodeMappingRepository.java @@ -0,0 +1,36 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** Rate Code 映射只读,解析逻辑在服务层做严格唯一匹配。 */ +@Repository +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingPostgresRateCodeMappingRepository implements BookingRateCodeMappingRepository { + + private final JdbcTemplate jdbcTemplate; + + public BookingPostgresRateCodeMappingRepository( + @Qualifier("bookingPostgresJdbcTemplate") JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public List findActiveForCatalog(String catalogVersion) { + if (catalogVersion == null || catalogVersion.isBlank()) { + return List.of(); + } + return jdbcTemplate.query( + "SELECT mapping_key, match_payload::text AS match_payload_json, rate_code " + + "FROM th_hotel_booking.booking_contract_rate_code_mapping " + + "WHERE catalog_version = ? AND status = 'ACTIVE' ORDER BY id", + (resultSet, rowNumber) -> new RateCodeMapping( + resultSet.getString("mapping_key"), + resultSet.getString("match_payload_json"), + resultSet.getString("rate_code")), + catalogVersion); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresSenderMappingRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresSenderMappingRepository.java new file mode 100644 index 0000000..6c3848f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingPostgresSenderMappingRepository.java @@ -0,0 +1,41 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import java.util.List; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** 所有查询都只触及项目专属 {@code th_hotel_booking} schema。 */ +@Repository +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingPostgresSenderMappingRepository implements BookingSenderMappingRepository { + + private final JdbcTemplate jdbcTemplate; + + public BookingPostgresSenderMappingRepository( + @Qualifier("bookingPostgresJdbcTemplate") JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public List findActiveExact(String normalizedSender, String catalogVersion) { + if (normalizedSender == null || normalizedSender.isBlank() || catalogVersion == null || catalogVersion.isBlank()) { + return List.of(); + } + return jdbcTemplate.query( + "SELECT mapping_key, sender_normalized, processing_direction, account_code, market_code, source_code " + + "FROM th_hotel_booking.booking_contract_sender_mapping " + + "WHERE sender_normalized = ? AND catalog_version = ? AND status = 'ACTIVE' " + + "ORDER BY id", + (resultSet, rowNumber) -> new SenderMapping( + resultSet.getString("mapping_key"), + resultSet.getString("sender_normalized"), + resultSet.getString("processing_direction"), + resultSet.getString("account_code"), + resultSet.getString("market_code"), + resultSet.getString("source_code")), + normalizedSender, + catalogVersion); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingProcessingRunRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingProcessingRunRepository.java new file mode 100644 index 0000000..f6d3189 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingProcessingRunRepository.java @@ -0,0 +1,69 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingProcessingRunSnapshot; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.List; +import java.util.Optional; + +/** + * 新 Booking PostgreSQL 主线的处理运行仓储。 + * + *

所有实现必须只写项目 schema {@code th_hotel_booking},不得调用旧 MySQL V4 写入。

+ */ +public interface BookingProcessingRunRepository { + + /** 幂等保存来源邮件,并为首次投递创建 processing run。 */ + BookingProcessingRunSnapshot startOrReuse( + String requestedRunId, + String hotelId, + BookingContracts.SourceMessageEnvelope envelope, + String messageSha256); + + /** 保存各层 contracts-v1 产物和最小证据引用。 */ + void saveArtifact( + String runId, + String artifactType, + String payloadJson, + String payloadSha256, + List evidenceRefs); + + /** 保存已解析的标准事实,供后续定向 Context 查询和回归诊断使用。 */ + void saveParsedFacts( + String runId, + List facts, + List factPayloadJsons); + + /** 保存候选目标及其目标级校验状态;不创建最终业务任务。 */ + void saveCandidateTargets( + String runId, + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ContextPackage contextPackage, + BookingContracts.ValidationResult validationResult, + List targetPayloadJsons); + + /** 将最终 Parser/Agent 版本写回 processing run,避免运行摘要停留在 sentinel。 */ + void updateRunContractMeta(String runId, BookingContracts.ContractMeta contractMeta); + + /** 更新运行状态、结果类型与安全错误摘要。 */ + void updateRunStatus( + String runId, + BookingContracts.ProcessingStatus status, + BookingContracts.ResultDisposition resultDisposition, + String errorCode, + String errorSummary); + + /** 读取某个安全 contracts artifact 的 JSON;只用于后端确认/查询,不返回原始邮件材料。 */ + Optional findArtifactPayload(String runId, String artifactType); + + /** + * 在项目 schema 内写入用户确认审计并冻结 processing run。 + * + *

该动作不调用 PMS/Opera、不写旧 MySQL V4。

+ */ + void confirmRun( + String runId, + List actions); + + /** 查询单个 processing run 的安全摘要。 */ + Optional findByRunId(String runId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingRateCodeMappingRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingRateCodeMappingRepository.java new file mode 100644 index 0000000..16fdfe1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingRateCodeMappingRepository.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import java.util.List; + +/** 项目 schema 内 Rate Code 主数据的只读端口。 */ +public interface BookingRateCodeMappingRepository { + + List findActiveForCatalog(String catalogVersion); + + record RateCodeMapping(String mappingKey, String matchPayloadJson, String rateCode) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingSenderMappingRepository.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingSenderMappingRepository.java new file mode 100644 index 0000000..63dd499 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/repository/BookingSenderMappingRepository.java @@ -0,0 +1,18 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.repository; + +import java.util.List; + +/** 项目 schema 内 sender 主数据的只读端口。 */ +public interface BookingSenderMappingRepository { + + List findActiveExact(String normalizedSender, String catalogVersion); + + record SenderMapping( + String mappingKey, + String normalizedSender, + String processingDirection, + String accountCode, + String marketCode, + String sourceCode) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingAgentDecisionService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingAgentDecisionService.java new file mode 100644 index 0000000..b3704fe --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingAgentDecisionService.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; + +/** + * Booking Agent 的受控端口。 + * + *

实现只消费 ParsedFactSet 与 ContextPackage;不得自行下载附件、写数据库、创建最终任务或调用 PMS。

+ */ +public interface BookingAgentDecisionService { + + /** 在确定性 Parser 不适用、失败或歧义时生成候选决定。 */ + BookingContracts.CandidateDecision decide( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingConfirmationProjectionFactory.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingConfirmationProjectionFactory.java new file mode 100644 index 0000000..2cdb4fe --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingConfirmationProjectionFactory.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; + +/** 将已校验的候选决定转换为前端可安全展示的确认投影。 */ +public interface BookingConfirmationProjectionFactory { + + /** 只有经过 Validator 的结果可调用该方法。 */ + BookingContracts.ConfirmationProjection project( + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ValidationResult validationResult); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingContextAssembler.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingContextAssembler.java new file mode 100644 index 0000000..9198b81 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingContextAssembler.java @@ -0,0 +1,10 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; + +/** 组装一封当前邮件所需的最小生命周期与当前态上下文。 */ +public interface BookingContextAssembler { + + /** 只按明确目标线索读取 Context,不扫描全库。 */ + BookingContracts.ContextPackage assemble(String hotelId, BookingContracts.ParsedFactSet parsedFactSet); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionService.java new file mode 100644 index 0000000..3ee0bbe --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionService.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; + +/** 将 Parser 事实或 Agent 结果统一成 CandidateDecision。 */ +public interface BookingDecisionService { + + /** + * 是否需要由 Booking Agent 处理当前事实集。 + * 严格绑定当前材料的 Rooming List / Payment 事实不需要 Agent;其余 Parser 不适用、失败或歧义情况需要兜底。 + */ + boolean requiresAgentFallback(BookingContracts.ParsedFactSet parsedFactSet); + + /** 生成一封当前邮件的候选业务决定。 */ + BookingContracts.CandidateDecision decide( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage, + boolean businessSignalPresent, + boolean attachmentPresent); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionValidator.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionValidator.java new file mode 100644 index 0000000..d096823 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingDecisionValidator.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; + +/** Validator:校验 contracts-v1、证据、目标、生命周期前置和确认阻断。 */ +public interface BookingDecisionValidator { + + /** 对候选决定执行目标级校验;无效 Agent 输出必须进入 Risk,不可绕过。 */ + BookingContracts.ValidationResult validate( + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ContextPackage contextPackage); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMasterDataResolver.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMasterDataResolver.java new file mode 100644 index 0000000..9f478f2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMasterDataResolver.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageMasterData; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; + +/** 从项目专属主数据得到方向、sender 映射与唯一 Rate Code 映射。 */ +public interface BookingMasterDataResolver { + + BookingMessageMasterData resolve( + String senderSummary, + BookingContracts.ParsedFactSet parsedFactSet); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMessageOrchestrator.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMessageOrchestrator.java new file mode 100644 index 0000000..ec879f1 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingMessageOrchestrator.java @@ -0,0 +1,15 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageInput; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingOrchestrationResult; + +/** + * AgentBus 和手工 EML 共用的 Booking 邮件编排入口。 + * + *

该接口不调用 PMS/Opera,不返回原始邮件或附件;实现必须按 contracts-v1 持久化 processing run。

+ */ +public interface BookingMessageOrchestrator { + + /** 处理一封当前邮件,并返回 processing run 的安全摘要。 */ + BookingOrchestrationResult process(BookingMessageInput input); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingProcessingRunService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingProcessingRunService.java new file mode 100644 index 0000000..a824b30 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/BookingProcessingRunService.java @@ -0,0 +1,13 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.request.BookingConfirmationRequest; +import cn.nianxx.thhotel.workflows.reservation.booking.common.result.BookingConfirmationResult; +import cn.nianxx.thhotel.workflows.reservation.booking.common.result.BookingProcessingRunView; + +/** processing run 查询与用户确认服务;不承载 PMS/Opera 执行。 */ +public interface BookingProcessingRunService { + + BookingProcessingRunView get(String runId); + + BookingConfirmationResult confirm(String runId, BookingConfirmationRequest request, String actorId); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessor.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessor.java new file mode 100644 index 0000000..8bfc73c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessor.java @@ -0,0 +1,123 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +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.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageAttachmentInput; +import cn.nianxx.thhotel.workflows.reservation.booking.config.BookingPostgresProperties; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * AgentBus 入口的材料预处理器。 + * + *

只在新 Booking PostgreSQL 主线启用时读取当前 Excel 附件的短生命周期字节,用于固定渠道 Parser。 + * 外部 URL 与二进制绝不进入 contracts-v1 artifact、Agent 输入或 PostgreSQL Booking 表;下载失败时保留 + * 无字节附件,由 Parser 明确输出 fallback/Risk,而不是猜测。

+ */ +@Component +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingAgentBusMaterialPreprocessor { + + private final ObjectStorageService objectStorageService; + private final BookingPostgresProperties properties; + + public BookingAgentBusMaterialPreprocessor( + ObjectStorageService objectStorageService, + BookingPostgresProperties properties) { + this.objectStorageService = objectStorageService; + this.properties = properties; + } + + /** 将 AgentBus 的当前附件转换为只供本次 Parser 使用的安全材料。 */ + public List prepare(List mediaItems) { + List attachments = new ArrayList<>(); + int ordinal = 0; + for (CaptureSourceMessageMedia media : mediaItems == null ? List.of() : mediaItems) { + ordinal++; + String name = hasText(media.fileName()) ? media.fileName().trim() : "agentbus-attachment-" + ordinal; + String mediaType = hasText(media.contentType()) ? media.contentType().trim() : "application/octet-stream"; + byte[] content = readCurrentExcel(media, name); + String reference = String.join("|", + value(media.externalMediaId()), + value(media.externalUrl()), + name, + mediaType, + value(media.sizeBytes())); + attachments.add(new BookingMessageAttachmentInput( + "agentbus-att-" + ordinal, + name, + mediaType, + content == null + ? normalizedSize(media.sizeBytes()) + : content.length, + content == null ? sha256(reference) : sha256(content), + content, + true)); + } + return List.copyOf(attachments); + } + + private byte[] readCurrentExcel(CaptureSourceMessageMedia media, String name) { + if (!isExcelAttachment(name) || !hasText(media.externalUrl()) || exceedsDownloadLimit(media.sizeBytes())) { + return null; + } + try { + ObjectStorageReadResult result = readResult(media); + byte[] content = result == null ? null : result.content(); + return content == null || content.length == 0 || content.length > properties.getMaterialDownloadMaxBytes() + ? null + : content; + } catch (RuntimeException ignored) { + // 下游只收到“当前 Excel 无字节”的安全事实,绝不泄露 URL、签名或存储异常文本。 + return null; + } + } + + private ObjectStorageReadResult readResult(CaptureSourceMessageMedia media) { + return objectStorageService.readObject(new ObjectStorageReadRequest( + media.externalUrl(), + properties.getMaterialDownloadMaxBytes())); + } + + private boolean exceedsDownloadLimit(Long sizeBytes) { + return sizeBytes != null && sizeBytes > properties.getMaterialDownloadMaxBytes(); + } + + private boolean isExcelAttachment(String fileName) { + String normalized = fileName == null ? "" : fileName.trim().toLowerCase(java.util.Locale.ROOT); + return normalized.endsWith(".xlsx") || normalized.endsWith(".xls"); + } + + private long normalizedSize(Long sizeBytes) { + return sizeBytes == null ? 0L : Math.max(0L, sizeBytes); + } + + private String sha256(String value) { + return sha256(value.getBytes(StandardCharsets.UTF_8)); + } + + private String sha256(byte[] value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } + + private String value(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentContinuationDispatcher.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentContinuationDispatcher.java new file mode 100644 index 0000000..9595d45 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentContinuationDispatcher.java @@ -0,0 +1,36 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** 真实 Booking Agent 的受控异步续跑器;worker 只接收 contracts-v1 对象。 */ +@Component +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingAgentContinuationDispatcher { + + private final BookingDecisionPipeline decisionPipeline; + + public BookingAgentContinuationDispatcher(BookingDecisionPipeline decisionPipeline) { + this.decisionPipeline = decisionPipeline; + } + + /** + * 外部 Agent 不阻塞邮件入口。异常已由 pipeline 转为 processing run 的安全失败状态。 + */ + @Async("bookingAgentPipelineTaskExecutor") + public void completeAsync( + String runId, + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage, + boolean businessSignalPresent, + boolean attachmentPresent) { + decisionPipeline.complete( + runId, + parsedFactSet, + contextPackage, + businessSignalPresent, + attachmentPresent); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentInputMaterialPolicy.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentInputMaterialPolicy.java new file mode 100644 index 0000000..e2e2c84 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentInputMaterialPolicy.java @@ -0,0 +1,125 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 单一的 Booking Agent current-message 输入策略。 + * + *

Assembler 用它产生可持久化的受控材料;SuperAgent adapter 再用它校验、脱敏和限长一次。这样即使未来 + * 有调用方错误地构造 ParsedFactSet,也不会把 quoted history、外链或 Secret 送出本系统。

+ */ +final class BookingAgentInputMaterialPolicy { + + static final String POLICY_VERSION = "booking-agent-current-message-v1"; + static final int MAX_CURRENT_MESSAGE_CODE_POINTS = 12_000; + private static final int MAX_SUBJECT_CODE_POINTS = 512; + + private static final Pattern URL = Pattern.compile("(?iu)\\b(?:https?|s3|oss)://[^\\s<>\\\"']+"); + private static final Pattern EMAIL = Pattern.compile( + "(?iu)\\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}\\b"); + private static final Pattern SECRET_ASSIGNMENT = Pattern.compile( + "(?imu)(\\b(?:password|passwd|secret|token|api[_-]?(?:key|token)|access[_-]?token|credential)" + + "\\b\\s*(?:(?:is)\\s+|[:=])\\s*)([^\\s,;]+)"); + private static final Pattern UNSAFE_CONTROL = Pattern.compile("[\\p{Cntrl}&&[^\\r\\n\\t]]"); + + private BookingAgentInputMaterialPolicy() { + } + + static BookingContracts.AgentInputMaterial fromCurrentMessage( + String currentBodyEvidenceId, + String safeSubject, + String currentBody) { + String normalizedSubject = normalize(safeSubject); + String normalizedBody = normalize(currentBody); + if (normalizedSubject.isBlank() && normalizedBody.isBlank()) { + return null; + } + int sourceCharacterCount = codePointCount(normalizedSubject) + codePointCount(normalizedBody); + Truncation subjectTruncation = truncate(redact(normalizedSubject), MAX_SUBJECT_CODE_POINTS); + String sanitizedSubject = subjectTruncation.text(); + String rendered = render(sanitizedSubject, redact(normalizedBody)); + Truncation truncation = truncate(rendered, MAX_CURRENT_MESSAGE_CODE_POINTS); + return new BookingContracts.AgentInputMaterial( + currentBodyEvidenceId, + truncation.text(), + sourceCharacterCount, + subjectTruncation.truncated() || truncation.truncated(), + POLICY_VERSION); + } + + /** 纵深防御:只保留与 current-body evidence 绑定的材料,并按当前 policy 重做安全处理。 */ + static BookingContracts.AgentInputMaterial safeForAgent( + BookingContracts.AgentInputMaterial source, + List evidenceRefs) { + if (source == null || !isAllowedCurrentEvidence(source.evidenceId(), evidenceRefs)) { + return null; + } + Truncation truncation = truncate(redact(normalize(source.currentMessageText())), MAX_CURRENT_MESSAGE_CODE_POINTS); + return new BookingContracts.AgentInputMaterial( + source.evidenceId(), + truncation.text(), + source.sourceCharacterCount(), + source.truncated() || truncation.truncated(), + POLICY_VERSION); + } + + private static boolean isAllowedCurrentEvidence( + String evidenceId, + List evidenceRefs) { + Set currentBodyIds = (evidenceRefs == null ? List.of() : evidenceRefs) + .stream() + .filter(reference -> reference.currentMaterial() + && reference.kind() == BookingContracts.EvidenceKind.CURRENT_BODY) + .map(BookingContracts.EvidenceReference::evidenceId) + .collect(Collectors.toSet()); + return currentBodyIds.contains(evidenceId); + } + + private static String render(String subject, String body) { + if (subject.isBlank()) { + return "Body:\n" + body; + } + if (body.isBlank()) { + return "Subject:\n" + subject; + } + return "Subject:\n" + subject + "\n\nBody:\n" + body; + } + + private static String normalize(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return UNSAFE_CONTROL.matcher(value.replace("\r\n", "\n").replace('\r', '\n')) + .replaceAll(" ") + .trim(); + } + + private static String redact(String value) { + String withoutSecrets = SECRET_ASSIGNMENT.matcher(value).replaceAll("$1[REDACTED]"); + String withoutUrls = URL.matcher(withoutSecrets).replaceAll("[URL_REDACTED]"); + return EMAIL.matcher(withoutUrls).replaceAll("[EMAIL_REDACTED]"); + } + + private static Truncation truncate(String value, int maximumCodePoints) { + int actualCount = codePointCount(value); + if (actualCount <= maximumCodePoints) { + return new Truncation(value, false); + } + String marker = "\n[TRUNCATED]"; + int markerCount = codePointCount(marker); + int retainedCount = Math.max(0, maximumCodePoints - markerCount); + int end = value.offsetByCodePoints(0, retainedCount); + return new Truncation(value.substring(0, end) + marker, true); + } + + private static int codePointCount(String value) { + return value == null ? 0 : value.codePointCount(0, value.length()); + } + + private record Truncation(String text, boolean truncated) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentSupport.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentSupport.java new file mode 100644 index 0000000..84baaa9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentSupport.java @@ -0,0 +1,118 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Allotment source/actual 关系的纯数据计算,不读库、不写库、不触发库存扣减。 */ +final class BookingAllotmentSupport { + + private BookingAllotmentSupport() { + } + + /** 仅汇总合法的房型代码和正数量;非法项由 Validator 的通用房量校验负责阻断。 */ + static Map roomCounts(Object value) { + Map result = new LinkedHashMap<>(); + if (!(value instanceof List items)) { + return result; + } + for (Object item : items) { + String roomType = null; + Integer roomCount = null; + if (item instanceof BookingContracts.RoomItemFact room) { + roomType = room.roomTypeCodeHint(); + roomCount = room.roomCount(); + } else if (item instanceof Map fields) { + roomType = stringField(fields, "room_type_code_hint", "roomTypeCodeHint", "room_type_code", "roomTypeCode"); + roomCount = integerField(fields, "room_count", "roomCount"); + } + String normalizedType = normalizeRoomType(roomType); + if (normalizedType == null || roomCount == null || roomCount <= 0) { + continue; + } + result.merge(normalizedType, roomCount, Integer::sum); + } + return result; + } + + /** 将内部汇总结果转为安全、稳定的前端/contract 字段。 */ + static List> roomItems(Map counts) { + List> result = new ArrayList<>(); + for (Map.Entry entry : counts.entrySet()) { + Map item = new LinkedHashMap<>(); + item.put("room_type_code", entry.getKey()); + item.put("room_count", entry.getValue()); + result.add(item); + } + return List.copyOf(result); + } + + /** 计算 source old / actual deduct / remaining,短缺只作为提示,绝不在这里写入 source。 */ + static DeductionSummary deduction(Map sourceOld, Map actualDeduct) { + Map source = sourceOld == null ? Map.of() : Map.copyOf(sourceOld); + Map deduct = actualDeduct == null ? Map.of() : Map.copyOf(actualDeduct); + Map remaining = new LinkedHashMap<>(source); + Map shortages = new LinkedHashMap<>(); + for (Map.Entry entry : deduct.entrySet()) { + int oldCount = source.getOrDefault(entry.getKey(), 0); + int difference = oldCount - entry.getValue(); + remaining.put(entry.getKey(), Math.max(0, difference)); + if (difference < 0) { + shortages.put(entry.getKey(), -difference); + } + } + return new DeductionSummary( + Map.copyOf(source), + Map.copyOf(deduct), + Map.copyOf(remaining), + Map.copyOf(shortages)); + } + + private static String stringField(Map fields, String... keys) { + for (String key : keys) { + Object value = fields.get(key); + if (value instanceof String text && !text.isBlank()) { + return text.trim(); + } + } + return null; + } + + private static Integer integerField(Map fields, String... keys) { + for (String key : keys) { + Object value = fields.get(key); + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String text) { + try { + return Integer.parseInt(text.trim()); + } catch (RuntimeException ignored) { + // 继续检查兼容字段;最终由 Validator 产生字段错误。 + } + } + } + return null; + } + + private static String normalizeRoomType(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.trim().toUpperCase(Locale.ROOT); + } + + record DeductionSummary( + Map sourceOld, + Map actualDeduct, + Map remaining, + Map shortages) { + + boolean sufficient() { + return shortages.isEmpty(); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImpl.java new file mode 100644 index 0000000..31bb9d8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImpl.java @@ -0,0 +1,269 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingConfirmationProjectionFactory; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** 将经过 Validator 的 CandidateDecision 投影为前端确认数据,不透传 Agent 原始 payload。 */ +@Component +public class BookingConfirmationProjectionFactoryImpl implements BookingConfirmationProjectionFactory { + + /** 生成可确认目标和安全阻断字段;Risk/General 仅生成不可确认通知投影。 */ + @Override + public BookingContracts.ConfirmationProjection project( + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ValidationResult validationResult) { + List items = new ArrayList<>(); + for (BookingContracts.TargetDecision target : candidateDecision.targetDecisions()) { + BookingContracts.ValidatedTarget validatedTarget = validationResult.validatedTargets().stream() + .filter(candidate -> candidate.targetDecisionId().equals(target.targetDecisionId())) + .findFirst() + .orElse(null); + if (validatedTarget == null) { + continue; + } + if (validatedTarget.status() == BookingContracts.ValidationStatus.RISK + || validatedTarget.status() == BookingContracts.ValidationStatus.REJECTED) { + // 目标级 Risk 仍须对用户可见,但绝不成为可确认动作。 + items.add(new BookingContracts.ConfirmationItem( + "risk-" + target.targetDecisionId(), + target.targetDecisionId(), + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + List.of(new BookingContracts.ConfirmationField( + "business_type", + target.businessType(), + false, + List.of(), + target.evidenceRefs(), + target.issues())), + blockedFields(validatedTarget, target), + linkedActions(target, candidateDecision, validationResult), + target.evidenceRefs())); + continue; + } + items.add(new BookingContracts.ConfirmationItem( + "confirmation-" + target.targetDecisionId(), + target.targetDecisionId(), + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + displayFields(target), + blockedFields(validatedTarget, target), + linkedActions(target, candidateDecision, validationResult), + target.evidenceRefs())); + } + for (BookingContracts.MessageNotification notification : candidateDecision.messageNotifications()) { + if (notification.disposition() == BookingContracts.ResultDisposition.IN_SCOPE_TASKS) { + continue; + } + items.add(new BookingContracts.ConfirmationItem( + "notification-" + notification.notificationCode(), + null, + notification.disposition(), + List.of(new BookingContracts.ConfirmationField( + "notification_code", + notification.notificationCode(), + false, + List.of(), + notification.evidenceRefs(), + notification.issues())), + List.of(), + List.of(), + notification.evidenceRefs())); + } + boolean hasConfirmableItem = items.stream() + .anyMatch(item -> item.disposition() == BookingContracts.ResultDisposition.IN_SCOPE_TASKS); + boolean canConfirm = hasConfirmableItem + && candidateDecision.resultDisposition() == BookingContracts.ResultDisposition.IN_SCOPE_TASKS + && validationResult.validationStatus() != BookingContracts.ValidationStatus.RISK + && validationResult.validationStatus() != BookingContracts.ValidationStatus.REJECTED; + return new BookingContracts.ConfirmationProjection( + candidateDecision.contractMeta(), + candidateDecision.evidenceRefs(), + canConfirm + ? (validationResult.validationStatus() == BookingContracts.ValidationStatus.REVIEW_REQUIRED + ? BookingContracts.ConfirmationStatus.REVIEW_REQUIRED + : BookingContracts.ConfirmationStatus.AWAITING_CONFIRMATION) + : BookingContracts.ConfirmationStatus.NOT_AVAILABLE, + items, + validationResult.blockers(), + canConfirm + ? BookingContracts.ProcessingStatus.AWAITING_CONFIRMATION + : BookingContracts.ProcessingStatus.VALIDATED); + } + + private List displayFields(BookingContracts.TargetDecision target) { + List result = new ArrayList<>(); + for (Map.Entry entry : target.fields().entrySet()) { + result.add(new BookingContracts.ConfirmationField( + entry.getKey(), + entry.getValue(), + editable(entry.getKey()), + allowedValues(entry.getKey()), + target.evidenceRefs(), + List.of())); + } + return List.copyOf(result); + } + + /** + * 将 Allotment source/actual 关系投影到 actual 卡片上,而非生成额外 source 卡。 + * 这里仅计算展示值:确认仍只冻结参数,绝不执行库存/订单扣减。 + */ + private List> linkedActions( + BookingContracts.TargetDecision target, + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ValidationResult validationResult) { + return target.linkedActions().stream() + .map(action -> isAllotmentSourceDeduction(action) + ? allotmentLinkedAction(action, candidateDecision, validationResult) + : action) + .toList(); + } + + private Map allotmentLinkedAction( + Map sourceAction, + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ValidationResult validationResult) { + Map result = new java.util.LinkedHashMap<>(sourceAction); + String relationshipId = stringValue(sourceAction.get("relationship_id")); + List actuals = new ArrayList<>(); + Map actualDeduct = new java.util.LinkedHashMap<>(); + for (BookingContracts.TargetDecision candidate : candidateDecision.targetDecisions()) { + Map linkedAction = candidate.linkedActions().stream() + .filter(this::isAllotmentSourceDeduction) + .filter(action -> sameRelationship(relationshipId, sourceAction, action)) + .findFirst() + .orElse(null); + if (linkedAction == null) { + continue; + } + BookingContracts.ValidatedTarget validation = validationResult.validatedTargets().stream() + .filter(value -> candidate.targetDecisionId().equals(value.targetDecisionId())) + .findFirst() + .orElse(null); + boolean included = validation != null && validation.status() == BookingContracts.ValidationStatus.VALID; + Map actual = new java.util.LinkedHashMap<>(); + actual.put("actual_target_decision_id", candidate.targetDecisionId()); + actual.put("actual_group_code", fieldString(candidate, "group_code")); + actual.put("validation_status", validation == null ? "UNRESOLVED" : validation.status().name()); + actual.put("included_in_source_deduction", included); + actual.put("uncreated_reason_codes", validation == null + ? List.of("ALLOTMENT_ACTUAL_VALIDATION_MISSING") + : validation.blockers().stream().map(BookingContracts.ContractIssue::code).toList()); + actuals.add(new AllotmentActualProjection(candidate, included, actual)); + if (included) { + BookingAllotmentSupport.roomCounts(candidate.fields().get("room_items")) + .forEach((roomType, count) -> actualDeduct.merge(roomType, count, Integer::sum)); + } + } + Map sourceOld = BookingAllotmentSupport.roomCounts(sourceAction.get("source_old_room_items")); + BookingAllotmentSupport.DeductionSummary summary = BookingAllotmentSupport.deduction(sourceOld, actualDeduct); + result.put("actual_targets", actuals.stream().map(AllotmentActualProjection::display).toList()); + result.put("source_old_room_items", BookingAllotmentSupport.roomItems(summary.sourceOld())); + result.put("source_deduct_room_items", BookingAllotmentSupport.roomItems(summary.actualDeduct())); + result.put("source_remaining_room_items", BookingAllotmentSupport.roomItems(summary.remaining())); + result.put("source_deduction_status", allotmentStatus(sourceAction, actuals, summary)); + return result; + } + + private String allotmentStatus( + Map sourceAction, + List actuals, + BookingAllotmentSupport.DeductionSummary summary) { + if (actuals.stream().noneMatch(AllotmentActualProjection::included)) { + return "NO_ELIGIBLE_ACTUAL"; + } + if (!Boolean.TRUE.equals(sourceAction.get("source_exists"))) { + return "SOURCE_UNAVAILABLE"; + } + if (!Boolean.TRUE.equals(sourceAction.get("source_active"))) { + return "SOURCE_UNAVAILABLE"; + } + if (summary.sourceOld().isEmpty()) { + return "SOURCE_ROOM_ITEMS_UNAVAILABLE"; + } + return summary.sufficient() ? "SOURCE_READY" : "SOURCE_INSUFFICIENT"; + } + + private boolean isAllotmentSourceDeduction(Map action) { + Object hintType = action.get("hint_type"); + return "ALLOTMENT_SOURCE_DEDUCTION".equals(hintType) || "ALLOTMENT_SOURCE".equals(hintType); + } + + private boolean sameRelationship( + String relationshipId, + Map sourceAction, + Map candidateAction) { + String candidateRelationshipId = stringValue(candidateAction.get("relationship_id")); + if (relationshipId != null || candidateRelationshipId != null) { + return relationshipId != null && relationshipId.equals(candidateRelationshipId); + } + return java.util.Objects.equals( + sourceAction.get("source_group_code"), + candidateAction.get("source_group_code")); + } + + private String fieldString(BookingContracts.TargetDecision target, String fieldName) { + Object value = target.fields().get(fieldName); + return value instanceof String text && !text.isBlank() ? text.trim() : null; + } + + private String stringValue(Object value) { + return value instanceof String text && !text.isBlank() ? text.trim() : null; + } + + private List blockedFields( + BookingContracts.ValidatedTarget validatedTarget, + BookingContracts.TargetDecision target) { + return validatedTarget.blockers().stream() + .map(blocker -> new BookingContracts.ConfirmationField( + fieldCode(blocker.code()), + null, + true, + allowedValues(fieldCode(blocker.code())), + target.evidenceRefs(), + List.of(blocker))) + .toList(); + } + + private boolean editable(String fieldCode) { + return switch (fieldCode) { + case "booking_type", "group_code", "group_name", "names", "arrival_date", "departure_date", + "room_items", "rate_code" -> true; + default -> false; + }; + } + + private List allowedValues(String fieldCode) { + return switch (fieldCode) { + case "booking_type" -> List.of("FIT", "GROUP"); + // FO_HSK 表示同一条 Trace 同时投递给前厅与客房;不得以空部门默认代替用户选择。 + case "department_code" -> List.of("FO", "HSK", "FO_HSK"); + default -> List.of(); + }; + } + + private String fieldCode(String blockerCode) { + return switch (blockerCode) { + case "BOOKING_TYPE_REQUIRED" -> "booking_type"; + case "NAME_REQUIRED" -> "names"; + case "TARGET_IDENTITY_REQUIRED" -> "group_code"; + case "ARRIVAL_DATE_REQUIRED" -> "arrival_date"; + case "DEPARTURE_DATE_REQUIRED" -> "departure_date"; + case "ROOM_ITEM_REQUIRED" -> "room_items"; + case "RATE_CODE_REQUIRED" -> "rate_code"; + case "GROUP_CODE_REQUIRED" -> "group_code"; + case "TRACE_DEPARTMENT_REQUIRED" -> "department_code"; + case "ROOMING_LIST_ATTACHMENT_REQUIRED", "PAYMENT_EVIDENCE_REQUIRED" -> "attachment_ids"; + default -> blockerCode.toLowerCase(); + }; + } + + private record AllotmentActualProjection( + BookingContracts.TargetDecision target, + boolean included, + Map display) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImpl.java new file mode 100644 index 0000000..7cc30cf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImpl.java @@ -0,0 +1,674 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingTargetStateSnapshot; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingCurrentStateRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingContextAssembler; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * contracts-v1 当前态组装器。 + * + *

只在有明确 Group Code 时查询 PostgreSQL。旧 MySQL V4 兼容读取将在独立 adapter 中接入,不能在此处扫描或双写。

+ */ +@Component +public class BookingContextAssemblerImpl implements BookingContextAssembler { + + private final ObjectProvider currentStateRepositoryProvider; + private final ObjectMapper objectMapper; + + @Autowired + public BookingContextAssemblerImpl( + ObjectProvider currentStateRepositoryProvider, + ObjectMapper objectMapper) { + this.currentStateRepositoryProvider = currentStateRepositoryProvider; + this.objectMapper = objectMapper; + } + + /** 仅供无 Spring 容器的单元测试构造;生产路径由上方构造器注入统一 ObjectMapper。 */ + BookingContextAssemblerImpl(ObjectProvider currentStateRepositoryProvider) { + this(currentStateRepositoryProvider, new ObjectMapper()); + } + + /** 组装目标解析、当前态、会话与生命周期的安全摘要。 */ + @Override + public BookingContracts.ContextPackage assemble(String hotelId, BookingContracts.ParsedFactSet parsedFactSet) { + BookingCurrentStateRepository currentStateRepository = currentStateRepositoryProvider.getIfAvailable(); + List resolutions = new ArrayList<>(); + List contextEvidence = new ArrayList<>(parsedFactSet.evidenceRefs()); + List> targetStates = new ArrayList<>(); + List> lifecycleTargets = new ArrayList<>(); + List> groupCodeReplacements = new ArrayList<>(); + Map>> traceHistoryByGroupCode = new LinkedHashMap<>(); + List> allotmentRelationships = new ArrayList<>(); + Set lookedUpGroupCodes = new LinkedHashSet<>(); + Map> statesByGroupCode = new LinkedHashMap<>(); + for (BookingContracts.ParsedFact fact : parsedFactSet.facts()) { + GroupCodeReplacement replacement = groupCodeReplacement(fact); + if (replacement != null) { + List oldStates = statesForGroupCode( + hotelId, + replacement.oldGroupCode(), + currentStateRepository, + lookedUpGroupCodes, + statesByGroupCode); + List newStates = statesForGroupCode( + hotelId, + replacement.newGroupCode(), + currentStateRepository, + lookedUpGroupCodes, + statesByGroupCode); + List targetEvidence = new ArrayList<>(fact.evidenceRefs()); + Set relationStateIds = new LinkedHashSet<>(); + appendContextStates( + oldStates, + fact, + parsedFactSet.contractMeta().sourceMessageId(), + relationStateIds, + targetEvidence, + contextEvidence, + targetStates); + appendContextStates( + newStates, + fact, + parsedFactSet.contractMeta().sourceMessageId(), + relationStateIds, + targetEvidence, + contextEvidence, + targetStates); + GroupCodeReplacementSummary lifecycle = groupCodeReplacementSummary( + replacement, + oldStates, + newStates, + targetEvidence); + resolutions.add(new BookingContracts.TargetResolution( + replacement.newGroupCode(), + lifecycle.resolutionStatus(), + lifecycle.candidateTargetIds(), + targetEvidence, + lifecycle.issues())); + lifecycleTargets.add(replacementLifecycleMap(fact, replacement, lifecycle)); + groupCodeReplacements.add(replacementMap(fact, replacement, lifecycle)); + appendTraceHistory(traceHistoryByGroupCode, replacement.oldGroupCode(), oldStates); + appendTraceHistory(traceHistoryByGroupCode, replacement.newGroupCode(), newStates); + } else { + String groupCode = fact.targetIdentity() == null ? null : fact.targetIdentity().groupCode(); + List states = statesForGroupCode( + hotelId, + groupCode, + currentStateRepository, + lookedUpGroupCodes, + statesByGroupCode); + List targetEvidence = new ArrayList<>(fact.evidenceRefs()); + appendContextStates( + states, + fact, + parsedFactSet.contractMeta().sourceMessageId(), + new LinkedHashSet<>(), + targetEvidence, + contextEvidence, + targetStates); + String targetReference = groupCode == null || groupCode.isBlank() ? fact.factId() : groupCode; + LifecycleSummary lifecycle = lifecycleSummary(fact, states, targetEvidence); + resolutions.add(new BookingContracts.TargetResolution( + targetReference, + lifecycle.resolutionStatus(), + states.stream().map(state -> "candidate-" + state.candidateTargetId()).toList(), + targetEvidence, + lifecycle.issues())); + lifecycleTargets.add(lifecycleMap(fact, targetReference, lifecycle)); + appendTraceHistory(traceHistoryByGroupCode, groupCode, states); + } + appendAllotmentRelationships( + fact, + hotelId, + currentStateRepository, + lookedUpGroupCodes, + statesByGroupCode, + contextEvidence, + allotmentRelationships); + } + Map currentState = new LinkedHashMap<>(); + currentState.put("known_target_states", List.copyOf(targetStates)); + currentState.put("lookup_mode", currentStateRepository == null ? "POSTGRES_RUNTIME_DISABLED" : "GROUP_CODE_DIRECT"); + Map conversationContext = Map.of( + "current_message_only", true, + "quoted_history_retriggers_actions", false); + Map lifecycleContext = new LinkedHashMap<>(); + lifecycleContext.put("target_resolution_count", resolutions.size()); + lifecycleContext.put("requires_lifecycle_validation", true); + lifecycleContext.put("target_lifecycles", List.copyOf(lifecycleTargets)); + lifecycleContext.put("group_code_replacements", List.copyOf(groupCodeReplacements)); + lifecycleContext.put("trace_history_by_group_code", Map.copyOf(traceHistoryByGroupCode)); + Map allotmentContext = new LinkedHashMap<>(); + allotmentContext.put("relationships", List.copyOf(allotmentRelationships)); + allotmentContext.put("lookup_mode", currentStateRepository == null ? "POSTGRES_RUNTIME_DISABLED" : "GROUP_CODE_DIRECT"); + return new BookingContracts.ContextPackage( + parsedFactSet.contractMeta(), + List.copyOf(contextEvidence), + "MESSAGE", + resolutions, + currentState, + conversationContext, + lifecycleContext, + allotmentContext, + List.of()); + } + + private void appendContextStates( + List states, + BookingContracts.ParsedFact fact, + String sourceMessageId, + Set includedStateIds, + List targetEvidence, + List contextEvidence, + List> targetStates) { + for (BookingTargetStateSnapshot state : states) { + if (!includedStateIds.add(state.candidateTargetId())) { + continue; + } + BookingContracts.EvidenceReference evidence = new BookingContracts.EvidenceReference( + "context-target-" + state.candidateTargetId(), + BookingContracts.EvidenceKind.CONTEXT_RECORD, + sourceMessageId, + "candidate-target:" + state.candidateTargetId(), + sha256(state.candidateTargetId() + "|" + state.createdAt()), + false); + targetEvidence.add(evidence); + contextEvidence.add(evidence); + targetStates.add(stateMap(state)); + } + } + + private GroupCodeReplacement groupCodeReplacement(BookingContracts.ParsedFact fact) { + List replacements = fact.relatedHints().stream() + .filter(hint -> "GROUP_CODE_REPLACEMENT".equals(hint.hintType())) + .map(hint -> new GroupCodeReplacement( + stringAttribute(hint.attributes(), "relationship_id"), + stringAttribute(hint.attributes(), "old_group_code"), + stringAttribute(hint.attributes(), "new_group_code"))) + .filter(value -> value.oldGroupCode() != null && value.newGroupCode() != null) + .toList(); + return replacements.size() == 1 ? replacements.get(0) : null; + } + + private GroupCodeReplacementSummary groupCodeReplacementSummary( + GroupCodeReplacement replacement, + List oldStates, + List newStates, + List evidenceRefs) { + GroupCodeState oldState = groupCodeState(oldStates); + GroupCodeState newState = groupCodeState(newStates); + List candidateTargetIds = mergeTargetIds(oldState.confirmedTargetIds(), newState.confirmedTargetIds()); + if (replacement.oldGroupCode().equalsIgnoreCase(replacement.newGroupCode())) { + return new GroupCodeReplacementSummary( + "CONFLICT", + oldState, + newState, + candidateTargetIds, + List.of(issue("GROUP_CODE_REPLACEMENT_SAME_GROUP_CODE", evidenceRefs))); + } + if (!oldState.active()) { + String code = oldState.latestIsCancel() + ? "GROUP_CODE_REPLACEMENT_OLD_GROUP_CANCELLED" + : "GROUP_CODE_REPLACEMENT_OLD_GROUP_UNRESOLVED"; + return new GroupCodeReplacementSummary( + "UNRESOLVED", + oldState, + newState, + candidateTargetIds, + List.of(issue(code, evidenceRefs))); + } + if (!newStates.isEmpty()) { + String code = newState.active() + ? "GROUP_CODE_REPLACEMENT_NEW_GROUP_ACTIVE" + : "GROUP_CODE_REPLACEMENT_NEW_GROUP_EXISTS"; + return new GroupCodeReplacementSummary( + "CONFLICT", + oldState, + newState, + candidateTargetIds, + List.of(issue(code, evidenceRefs))); + } + return new GroupCodeReplacementSummary( + "RESOLVED", + oldState, + newState, + candidateTargetIds, + List.of()); + } + + private GroupCodeState groupCodeState(List states) { + List confirmed = states.stream() + .filter(state -> BookingContracts.ProcessingStatus.CONFIRMED.name().equals(state.processingRunStatus())) + .toList(); + BookingTargetStateSnapshot latestConfirmed = confirmed.isEmpty() ? null : confirmed.get(0); + boolean hasConfirmedBase = confirmed.stream() + .anyMatch(state -> "NEW_BOOKING".equals(state.businessType()) + || "UPDATE_BOOKING".equals(state.businessType())); + boolean latestIsCancel = latestConfirmed != null && "CANCEL_BOOKING".equals(latestConfirmed.businessType()); + boolean active = hasConfirmedBase && !latestIsCancel; + List confirmedTargetIds = confirmed.stream() + .map(state -> "candidate-" + state.candidateTargetId()) + .toList(); + List activeUnfinishedTargetIds = states.stream() + .filter(state -> !BookingContracts.ProcessingStatus.CONFIRMED.name().equals(state.processingRunStatus())) + .map(state -> "candidate-" + state.candidateTargetId()) + .toList(); + return new GroupCodeState(active, latestIsCancel, confirmedTargetIds, activeUnfinishedTargetIds); + } + + private List mergeTargetIds(List first, List second) { + LinkedHashSet result = new LinkedHashSet<>(); + result.addAll(first); + result.addAll(second); + return List.copyOf(result); + } + + private void appendAllotmentRelationships( + BookingContracts.ParsedFact fact, + String hotelId, + BookingCurrentStateRepository currentStateRepository, + Set lookedUpGroupCodes, + Map> statesByGroupCode, + List contextEvidence, + List> relationships) { + for (BookingContracts.RelatedHint hint : fact.relatedHints()) { + if (!"ALLOTMENT_SOURCE".equals(hint.hintType())) { + continue; + } + String sourceGroupCode = stringAttribute(hint.attributes(), "source_group_code"); + if (sourceGroupCode == null) { + continue; + } + List sourceStates = statesForGroupCode( + hotelId, + sourceGroupCode, + currentStateRepository, + lookedUpGroupCodes, + statesByGroupCode); + SourceAllotmentState source = sourceAllotmentState(sourceStates); + if (source.snapshot() != null) { + contextEvidence.add(contextEvidence(source.snapshot(), fact)); + } + Map relationship = new LinkedHashMap<>(); + relationship.put("relationship_id", stringAttribute(hint.attributes(), "relationship_id")); + relationship.put("actual_fact_id", fact.factId()); + relationship.put("actual_group_code", fact.targetIdentity() == null ? null : fact.targetIdentity().groupCode()); + relationship.put("source_group_code", sourceGroupCode); + relationship.put("source_exists", !sourceStates.isEmpty()); + relationship.put("source_active", source.active()); + relationship.put("source_candidate_target_id", source.snapshot() == null + ? null + : "candidate-" + source.snapshot().candidateTargetId()); + relationship.put("source_old_room_items", BookingAllotmentSupport.roomItems(source.roomCounts())); + relationship.put("source_issue_code", source.issueCode()); + relationships.add(relationship); + } + } + + private SourceAllotmentState sourceAllotmentState(List states) { + List confirmed = states.stream() + .filter(state -> BookingContracts.ProcessingStatus.CONFIRMED.name().equals(state.processingRunStatus())) + .toList(); + BookingTargetStateSnapshot latestConfirmed = confirmed.isEmpty() ? null : confirmed.get(0); + if (states.isEmpty()) { + return new SourceAllotmentState(null, false, Map.of(), "ALLOTMENT_SOURCE_NOT_FOUND"); + } + if (latestConfirmed == null) { + return new SourceAllotmentState(null, false, Map.of(), "ALLOTMENT_SOURCE_NOT_CONFIRMED"); + } + if ("CANCEL_BOOKING".equals(latestConfirmed.businessType())) { + return new SourceAllotmentState(latestConfirmed, false, Map.of(), "ALLOTMENT_SOURCE_CANCELLED"); + } + if (!"NEW_BOOKING".equals(latestConfirmed.businessType()) + && !"UPDATE_BOOKING".equals(latestConfirmed.businessType())) { + return new SourceAllotmentState(latestConfirmed, false, Map.of(), "ALLOTMENT_SOURCE_UNUSABLE"); + } + Map roomCounts = roomCounts(latestConfirmed.roomItemsJson()); + return roomCounts.isEmpty() + ? new SourceAllotmentState(latestConfirmed, true, Map.of(), "ALLOTMENT_SOURCE_ROOM_ITEMS_UNAVAILABLE") + : new SourceAllotmentState(latestConfirmed, true, roomCounts, null); + } + + private Map roomCounts(String roomItemsJson) { + if (roomItemsJson == null || roomItemsJson.isBlank()) { + return Map.of(); + } + try { + List> roomItems = objectMapper.readValue( + roomItemsJson, + new TypeReference>>() { + }); + return BookingAllotmentSupport.roomCounts(roomItems); + } catch (RuntimeException | java.io.IOException ignored) { + // 历史 payload 不是可用的 room_items 时只保留“来源房量不可用”,不能猜库存。 + return Map.of(); + } + } + + private BookingContracts.EvidenceReference contextEvidence( + BookingTargetStateSnapshot state, + BookingContracts.ParsedFact fact) { + return new BookingContracts.EvidenceReference( + "context-target-" + state.candidateTargetId(), + BookingContracts.EvidenceKind.CONTEXT_RECORD, + fact.evidenceRefs().isEmpty() + ? fact.factId() + : fact.evidenceRefs().get(0).sourceMessageId(), + "candidate-target:" + state.candidateTargetId(), + sha256(state.candidateTargetId() + "|" + state.createdAt()), + false); + } + + private String stringAttribute(Map attributes, String key) { + Object value = attributes.get(key); + return value instanceof String text && !text.isBlank() ? text.trim() : null; + } + + private List statesForGroupCode( + String hotelId, + String groupCode, + BookingCurrentStateRepository currentStateRepository, + Set lookedUpGroupCodes, + Map> statesByGroupCode) { + if (groupCode == null || groupCode.isBlank() || currentStateRepository == null) { + return List.of(); + } + String normalizedGroupCode = groupCode.trim(); + if (lookedUpGroupCodes.add(normalizedGroupCode)) { + statesByGroupCode.put( + normalizedGroupCode, + List.copyOf(currentStateRepository.findByGroupCode(hotelId, normalizedGroupCode))); + } + return statesByGroupCode.getOrDefault(normalizedGroupCode, List.of()); + } + + /** + * Context 只归纳已有事实,不替业务做状态迁移:Update/Cancel 需要一个未取消的已确认链; + * New 遇到同团仍有效的已确认链时只标出冲突,交给 Validator/用户确认,不自动合并或删除。 + */ + private LifecycleSummary lifecycleSummary( + BookingContracts.ParsedFact fact, + List states, + List evidenceRefs) { + List confirmed = states.stream() + .filter(state -> BookingContracts.ProcessingStatus.CONFIRMED.name().equals(state.processingRunStatus())) + .toList(); + BookingTargetStateSnapshot latestConfirmed = confirmed.isEmpty() ? null : confirmed.get(0); + boolean hasConfirmedBase = confirmed.stream() + .anyMatch(state -> "NEW_BOOKING".equals(state.businessType()) + || "UPDATE_BOOKING".equals(state.businessType())); + boolean latestIsCancel = latestConfirmed != null && "CANCEL_BOOKING".equals(latestConfirmed.businessType()); + boolean active = hasConfirmedBase && !latestIsCancel; + List activeUnfinishedTargetIds = states.stream() + .filter(state -> !BookingContracts.ProcessingStatus.CONFIRMED.name().equals(state.processingRunStatus())) + .map(state -> "candidate-" + state.candidateTargetId()) + .toList(); + List confirmedTargetIds = confirmed.stream() + .map(state -> "candidate-" + state.candidateTargetId()) + .toList(); + String actionHint = fact.actionHint(); + if ("UPDATE_BOOKING".equals(actionHint) || "CANCEL_BOOKING".equals(actionHint)) { + if (latestIsCancel) { + return new LifecycleSummary( + "CONFLICT", + active, + confirmedTargetIds, + activeUnfinishedTargetIds, + List.of(issue("TARGET_ALREADY_CANCELLED", evidenceRefs))); + } + if (active) { + return new LifecycleSummary( + "RESOLVED", + true, + confirmedTargetIds, + activeUnfinishedTargetIds, + List.of()); + } + return new LifecycleSummary( + "UNRESOLVED", + false, + confirmedTargetIds, + activeUnfinishedTargetIds, + List.of(issue("TARGET_CURRENT_STATE_UNRESOLVED", evidenceRefs))); + } + if ("NEW_BOOKING".equals(actionHint) && active) { + return new LifecycleSummary( + "CONFLICT", + true, + confirmedTargetIds, + activeUnfinishedTargetIds, + List.of(issue("NEW_BOOKING_POSSIBLE_DUPLICATE", evidenceRefs))); + } + return new LifecycleSummary( + states.isEmpty() ? "UNRESOLVED" : "RESOLVED", + active, + confirmedTargetIds, + activeUnfinishedTargetIds, + List.of()); + } + + private Map lifecycleMap( + BookingContracts.ParsedFact fact, + String targetReference, + LifecycleSummary lifecycle) { + Map result = new LinkedHashMap<>(); + result.put("fact_id", fact.factId()); + result.put("target_reference", targetReference); + result.put("action_hint", fact.actionHint()); + result.put("resolution_status", lifecycle.resolutionStatus()); + result.put("active", lifecycle.active()); + result.put("confirmed_target_ids", lifecycle.confirmedTargetIds()); + result.put("active_unfinished_target_ids", lifecycle.activeUnfinishedTargetIds()); + result.put("issue_codes", lifecycle.issues().stream().map(BookingContracts.ContractIssue::code).toList()); + return result; + } + + private Map replacementLifecycleMap( + BookingContracts.ParsedFact fact, + GroupCodeReplacement replacement, + GroupCodeReplacementSummary lifecycle) { + Map result = new LinkedHashMap<>(); + result.put("fact_id", fact.factId()); + result.put("target_reference", replacement.newGroupCode()); + result.put("action_hint", fact.actionHint()); + result.put("relationship_id", replacement.relationshipId()); + result.put("old_group_code", replacement.oldGroupCode()); + result.put("new_group_code", replacement.newGroupCode()); + result.put("resolution_status", lifecycle.resolutionStatus()); + result.put("old_group_active", lifecycle.oldState().active()); + result.put("new_group_active", lifecycle.newState().active()); + result.put("old_confirmed_target_ids", lifecycle.oldState().confirmedTargetIds()); + result.put("new_confirmed_target_ids", lifecycle.newState().confirmedTargetIds()); + result.put("active_unfinished_target_ids", mergeTargetIds( + lifecycle.oldState().activeUnfinishedTargetIds(), + lifecycle.newState().activeUnfinishedTargetIds())); + result.put("issue_codes", lifecycle.issues().stream().map(BookingContracts.ContractIssue::code).toList()); + return result; + } + + private Map replacementMap( + BookingContracts.ParsedFact fact, + GroupCodeReplacement replacement, + GroupCodeReplacementSummary lifecycle) { + Map result = new LinkedHashMap<>(); + result.put("relationship_id", replacement.relationshipId()); + result.put("fact_id", fact.factId()); + result.put("old_group_code", replacement.oldGroupCode()); + result.put("new_group_code", replacement.newGroupCode()); + result.put("resolution_status", lifecycle.resolutionStatus()); + result.put("old_group_active", lifecycle.oldState().active()); + result.put("new_group_active", lifecycle.newState().active()); + result.put("old_confirmed_target_ids", lifecycle.oldState().confirmedTargetIds()); + result.put("new_confirmed_target_ids", lifecycle.newState().confirmedTargetIds()); + result.put("candidate_target_ids", lifecycle.candidateTargetIds()); + result.put("issue_codes", lifecycle.issues().stream().map(BookingContracts.ContractIssue::code).toList()); + return result; + } + + private BookingContracts.ContractIssue issue( + String code, + List evidenceRefs) { + return new BookingContracts.ContractIssue( + code, + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + "booking.context." + code.toLowerCase(), + evidenceRefs, + false); + } + + private Map stateMap(BookingTargetStateSnapshot state) { + Map result = new LinkedHashMap<>(); + result.put("candidate_target_id", state.candidateTargetId()); + result.put("group_code", state.groupCode()); + result.put("business_type", state.businessType()); + result.put("action_kind", state.actionKind()); + result.put("validation_status", state.validationStatus()); + result.put("processing_run_status", state.processingRunStatus()); + result.put("created_at", state.createdAt() == null ? null : state.createdAt().toString()); + return result; + } + + /** + * Trace 跨封不合并为旧卡:这里只提供按创建顺序排列的安全摘要,供本封新卡判断与展示。历史自由文本、附件 + * 引用和部门以外的未批准字段均不进入 Context。 + */ + private void appendTraceHistory( + Map>> traceHistoryByGroupCode, + String groupCode, + List states) { + if (groupCode == null || groupCode.isBlank() || traceHistoryByGroupCode.containsKey(groupCode)) { + return; + } + List> entries = new ArrayList<>(); + List chronological = states.stream() + .sorted(java.util.Comparator + .comparing(BookingTargetStateSnapshot::createdAt, java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())) + .thenComparingLong(BookingTargetStateSnapshot::candidateTargetId)) + .toList(); + for (BookingTargetStateSnapshot state : chronological) { + List> traceActions = traceActions(state); + if (traceActions.isEmpty() && "TRACE_RESERVATION_NOTES".equals(state.businessType())) { + entries.add(traceHistoryEntry(state, Map.of(), entries.size() + 1)); + continue; + } + for (Map action : traceActions) { + entries.add(traceHistoryEntry(state, action, entries.size() + 1)); + } + } + traceHistoryByGroupCode.put(groupCode, List.copyOf(entries)); + } + + private List> traceActions(BookingTargetStateSnapshot state) { + if (state.linkedActionsJson() == null || state.linkedActionsJson().isBlank()) { + return List.of(); + } + try { + List> actions = objectMapper.readValue( + state.linkedActionsJson(), + new TypeReference>>() { + }); + return actions.stream() + .filter(action -> "TRACE_RESERVATION_NOTES".equals(action.get("hint_type"))) + .map(LinkedHashMap::new) + .map(Map::copyOf) + .toList(); + } catch (RuntimeException | java.io.IOException ignored) { + // 历史 payload 不可读时不猜 Trace 内容;该 run 仍作为正常 Context state 留给人工复核。 + return List.of(); + } + } + + private Map traceHistoryEntry( + BookingTargetStateSnapshot state, + Map action, + int sequence) { + Map result = new LinkedHashMap<>(); + result.put("trace_sequence", sequence); + result.put("candidate_target_id", "candidate-" + state.candidateTargetId()); + result.put("group_code", state.groupCode()); + result.put("business_type", state.businessType()); + result.put("processing_run_status", state.processingRunStatus()); + result.put("validation_status", state.validationStatus()); + if (state.createdAt() != null) { + result.put("created_at", state.createdAt().toString()); + } + copyTraceText(action, result, "department_code"); + copyTraceText(action, result, "item_type"); + copyTraceText(action, result, "target_room_type_code"); + Object extraBedRoomCount = action.get("extra_bed_room_count"); + if (extraBedRoomCount instanceof Number number && number.intValue() > 0) { + result.put("extra_bed_room_count", number.intValue()); + } + return Map.copyOf(result); + } + + private void copyTraceText(Map source, Map target, String key) { + String value = stringAttribute(source, key); + if (value != null) { + target.put(key, value); + } + } + + private String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest( + String.valueOf(source).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } + + private record LifecycleSummary( + String resolutionStatus, + boolean active, + List confirmedTargetIds, + List activeUnfinishedTargetIds, + List issues) { + } + + private record SourceAllotmentState( + BookingTargetStateSnapshot snapshot, + boolean active, + Map roomCounts, + String issueCode) { + } + + private record GroupCodeReplacement( + String relationshipId, + String oldGroupCode, + String newGroupCode) { + } + + private record GroupCodeState( + boolean active, + boolean latestIsCancel, + List confirmedTargetIds, + List activeUnfinishedTargetIds) { + } + + private record GroupCodeReplacementSummary( + String resolutionStatus, + GroupCodeState oldState, + GroupCodeState newState, + List candidateTargetIds, + List issues) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssembler.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssembler.java new file mode 100644 index 0000000..f8c1d61 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssembler.java @@ -0,0 +1,962 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMediaItem; +import cn.nianxx.thhotel.platform.message.common.dto.ParsedEmlMessage; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageAttachmentInput; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageInput; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationBookingCatalog; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationBookingEmailParser; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * 负责把统一入口材料转换为 contracts-v1 的 Source/Material/ParsedFact 三层对象。 + * + *

本类可调用确定性 Parser,但不会决定最终业务类型、写数据库或调用 Agent。

+ */ +@Component +public class BookingContractAssembler { + + private static final Set PROFILE_AMBIGUITY_WARNINGS = Set.of( + "CHANNEL_PROFILE_AMBIGUOUS", + "CHANNEL_PROFILE_SHEET_INVALID", + "CHANNEL_PROFILE_UNKNOWN"); + private static final Pattern ROOMING_LIST_SIGNAL = Pattern.compile( + "(?iu)\\b(?:rooming\\s*list|name\\s*list)\\b|รายชื่อห้องพัก"); + private static final Pattern PAYMENT_SIGNAL = Pattern.compile( + "(?iu)\\b(?:payment\\s*(?:slip|receipt|proof)?|transfer\\s*slip|receipt)\\b|หลักฐานการโอน"); + private static final Pattern EXPLICIT_GROUP_CODE = Pattern.compile( + "(?i)(?:name\\s+of\\s+group|group\\s+name|tour\\s+code|group\\s+code)\\s*(?:[::#-]\\s*)?([A-Z][A-Z0-9_-]{3,})"); + private static final String GROUP_CODE_TOKEN = "([A-Z][A-Z0-9_-]{3,})"; + private static final Pattern GROUP_CODE_REPLACEMENT_INLINE = Pattern.compile( + "(?iu)\\b(?:change|update|replace)\\s+(?:the\\s+)?(?:group|tour)\\s+code\\s+" + + "(?:from\\s+)?" + GROUP_CODE_TOKEN + "\\s*(?:to|->|→)\\s*" + GROUP_CODE_TOKEN); + private static final Pattern GROUP_CODE_REPLACEMENT_LABELED = Pattern.compile( + "(?isu)\\b(?:old|previous)\\s+(?:group|tour)\\s+code\\s*[::#-]?\\s*" + + GROUP_CODE_TOKEN + "[\\s,;]{1,40}(?:new|updated)\\s+(?:group|tour)\\s+code\\s*[::#-]?\\s*" + + GROUP_CODE_TOKEN); + private static final Pattern GROUP_CODE_REPLACEMENT_CHINESE = Pattern.compile( + "(?isu)(?:旧|原)(?:团号|团|group\\s*code|tour\\s*code)\\s*[::#-]?\\s*" + + GROUP_CODE_TOKEN + "[\\s,,;;]{1,40}(?:新|改后)(?:团号|团|group\\s*code|tour\\s*code)\\s*[::#-]?\\s*" + + GROUP_CODE_TOKEN); + private static final Pattern GROUP_CODE_REPLACEMENT_THAI = Pattern.compile( + "(?isu)(?:รหัส)?(?:กรุ๊ป|ทัวร์)\\s*(?:เดิม|เก่า)\\s*[::#-]?\\s*" + + GROUP_CODE_TOKEN + "[\\s,;]{1,40}(?:รหัส)?(?:กรุ๊ป|ทัวร์)\\s*ใหม่\\s*[::#-]?\\s*" + + GROUP_CODE_TOKEN); + private static final Pattern ALLOTMENT_SIGNAL = Pattern.compile("(?iu)\\ballotment\\b"); + private static final Pattern ALLOTMENT_SOURCE = Pattern.compile( + "(?iu)\\ballotment\\b\\s*(?:(?:from|of|source)\\s+|(?:group\\s+code|tour\\s+code)\\s*[::#-]?\\s*|[::#-]\\s*)([A-Z][A-Z0-9_-]{3,})"); + + private final ReservationBookingEmailParser parser; + private final ReservationBookingCatalog catalog; + + public BookingContractAssembler( + ReservationBookingEmailParser parser, + ReservationBookingCatalog catalog) { + this.parser = parser; + this.catalog = catalog; + } + + /** 创建尚未运行 Parser/Agent 的来源邮件信封。 */ + public BookingContracts.SourceMessageEnvelope sourceMessageEnvelope( + BookingMessageInput input, + String processingRunId) { + return sourceMessageEnvelope(input, processingRunId, 0L); + } + + /** + * 创建带来源 revision 的邮件信封。 + * + *

revision 由 processing-run 仓储在幂等锁内确定。调用方必须在拿到首次创建的 run 后, + * 使用该 revision 重建后续 contracts,避免下游把重投或修订误当成同一份来源事实。

+ */ + public BookingContracts.SourceMessageEnvelope sourceMessageEnvelope( + BookingMessageInput input, + String processingRunId, + long sourceRevision) { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + BookingContracts.CATALOG_NOT_APPLIED, + BookingContracts.PARSER_NOT_RUN, + BookingContracts.AGENT_NOT_INVOKED, + processingRunId, + input.platformSourceMessageId(), + sourceRevision); + List evidenceRefs = evidenceReferences(input); + return new BookingContracts.SourceMessageEnvelope( + meta, + evidenceRefs, + input.messageOrigin(), + input.externalMessageId(), + input.conversationId(), + input.inReplyTo(), + input.idempotencyKey(), + input.receivedAt().atOffset(ZoneOffset.UTC), + blankToNull(input.safeSubject()), + currentBodyEvidenceId(input), + quotedHistoryEvidenceId(input), + attachmentDescriptors(input), + BookingSenderNormalizer.normalize(input.senderIdentifier())); + } + + /** 组装 current/history 分离后的材料包。 */ + public BookingContracts.MaterialPackage materialPackage( + BookingMessageInput input, + BookingContracts.SourceMessageEnvelope envelope) { + List currentMaterials = new ArrayList<>(); + currentMaterials.add(new BookingContracts.MaterialReference( + envelope.currentBodyEvidenceId(), + BookingContracts.EvidenceKind.CURRENT_BODY, + true, + Map.of("content_available", hasText(input.currentBody())))); + for (BookingMessageAttachmentInput attachment : input.attachments()) { + currentMaterials.add(new BookingContracts.MaterialReference( + attachmentEvidenceId(attachment), + evidenceKind(attachment), + attachment.currentMaterial(), + Map.of( + "attachment_id", attachment.attachmentId(), + "file_name", attachment.fileName(), + "binary_available", attachment.bytes() != null))); + } + List quotedHistoryMaterials = hasText(input.quotedHistory()) + ? List.of(new BookingContracts.MaterialReference( + quotedHistoryEvidenceId(input), + BookingContracts.EvidenceKind.QUOTED_HISTORY, + false, + Map.of("content_available", true))) + : List.of(); + BookingContracts.MaterialSelectionStatus selectionStatus = input.attachments().isEmpty() + ? BookingContracts.MaterialSelectionStatus.NO_ATTACHMENT + : BookingContracts.MaterialSelectionStatus.READY; + List imageInspections = input.attachments().stream() + .filter(this::isImageAttachment) + .map(attachment -> new BookingContracts.ImageInspection( + attachment.attachmentId(), + attachmentEvidenceId(attachment), + null, + List.of())) + .toList(); + return new BookingContracts.MaterialPackage( + envelope.contractMeta(), + envelope.evidenceRefs(), + envelope, + currentMaterials, + quotedHistoryMaterials, + List.of(), + imageInspections, + selectionStatus, + List.of()); + } + + /** 执行固定渠道 Parser,并转换为不含原始行文本的标准事实集。 */ + public BookingContracts.ParsedFactSet parsedFactSet( + BookingMessageInput input, + BookingContracts.MaterialPackage materialPackage) { + boolean hasExcelReference = input.attachments().stream().anyMatch(this::isExcelAttachment); + boolean hasExcelBytes = input.attachments().stream() + .anyMatch(attachment -> isExcelAttachment(attachment) && attachment.bytes() != null); + List materialSignalFacts = materialSignalFacts(input, materialPackage); + GroupCodeReplacementInstruction replacementInstruction = groupCodeReplacementInstruction(input); + List fallbackFacts = new ArrayList<>(materialSignalFacts); + if (replacementInstruction.detected()) { + fallbackFacts.add(groupCodeReplacementContextFact(replacementInstruction, materialPackage)); + } else if (!replacementInstruction.reasonCodes().isEmpty()) { + fallbackFacts.add(groupCodeReplacementUnresolvedFact(replacementInstruction, materialPackage)); + } else if (fallbackFacts.isEmpty()) { + String explicitCurrentGroupCode = explicitGroupCode(currentMessageText(input)); + if (explicitCurrentGroupCode != null) { + fallbackFacts.add(contextGroupReferenceFact(explicitCurrentGroupCode, materialPackage)); + } + } + if (!hasExcelReference) { + return parsedFactSetWithoutFixedChannel( + input, + materialPackage, + BookingContracts.ParserApplicability.NOT_APPLICABLE, + mergeReasonCodes(List.of("NO_FIXED_CHANNEL_EXCEL"), replacementInstruction.reasonCodes()), + List.of(), + fallbackFacts); + } + if (!hasExcelBytes) { + return parsedFactSetWithoutFixedChannel( + input, + materialPackage, + BookingContracts.ParserApplicability.NOT_APPLICABLE, + mergeReasonCodes(List.of("ATTACHMENT_BYTES_UNAVAILABLE"), replacementInstruction.reasonCodes()), + List.of(issue( + "ATTACHMENT_BYTES_UNAVAILABLE", + BookingContracts.IssueSeverity.WARNING, + BookingContracts.IssueScope.MATERIAL, + materialPackage.evidenceRefs())), + fallbackFacts); + } + + ReservationBookingEmailParser.ParseOutcome outcome = parser.parse(parsedEmlMessage(input)); + BookingContracts.ParserApplicability applicability = parserApplicability(outcome); + BookingContracts.ContractMeta parsedMeta = withVersions( + materialPackage.contractMeta(), + catalog.catalogVersion(), + catalog.parserVersion(), + BookingContracts.AGENT_NOT_INVOKED); + List issues = outcome.warnings().stream() + .map(warning -> issue( + warning.code(), + severityForWarning(warning.code()), + BookingContracts.IssueScope.MATERIAL, + materialPackage.evidenceRefs())) + .toList(); + List facts = new ArrayList<>(); + List unresolvedFields = new ArrayList<>(); + AllotmentInstruction allotmentInstruction = allotmentInstruction(input); + boolean linkedAllotmentActual = false; + boolean linkedGroupCodeReplacement = false; + int ordinal = 0; + for (ReservationBookingEmailParser.BookingFact fact : outcome.facts()) { + ordinal++; + List factEvidence = factEvidence(input, fact); + List roomItems = fact.roomItems().stream() + .map(room -> new BookingContracts.RoomItemFact( + room.roomTypeCode(), + room.roomCount(), + room.sourcePrice() == null ? null : String.valueOf(room.sourcePrice()), + factEvidence)) + .toList(); + BookingContracts.TargetIdentity targetIdentity = new BookingContracts.TargetIdentity( + blankToNull(fact.groupCode()), + blankToNull(fact.groupName()), + // Tour Code 是 Group Code 的来源别名,不是客人姓名;legacy 字段只保留同一团号镜像。 + blankToNull(fact.groupCode()), + nonBlankValues(fact.names()), + factEvidence); + BookingContracts.StayFact stay = new BookingContracts.StayFact( + fact.arrivalDate() == null ? null : fact.arrivalDate().toString(), + fact.departureDate() == null ? null : fact.departureDate().toString(), + null, + roomItems, + factEvidence); + List relatedHints = new ArrayList<>(fact.traceItems().stream() + .map(trace -> new BookingContracts.RelatedHint( + "TRACE_RESERVATION_NOTES", + traceAttributes(trace), + factEvidence)) + .toList()); + if (canBindAllotmentToActual(allotmentInstruction, fact)) { + relatedHints.add(allotmentRelatedHint( + allotmentInstruction, + fact.groupCode(), + factEvidence, + materialPackage)); + linkedAllotmentActual = true; + } + if (canBindGroupCodeReplacement(replacementInstruction, fact)) { + relatedHints.add(groupCodeReplacementHint( + replacementInstruction, + factEvidence, + materialPackage)); + linkedGroupCodeReplacement = true; + } + facts.add(new BookingContracts.ParsedFact( + "fact-" + ordinal + "-" + fact.recognition().attachmentIndex() + "-" + + fact.recognition().sheetIndex() + "-" + fact.recognition().rowNumber(), + blankToNull(fact.action()), + blankToNull(fact.bookingType()), + targetIdentity, + stay, + relatedHints, + factEvidence)); + for (String warningCode : fact.recognition().warnings()) { + unresolvedFields.add(new BookingContracts.UnresolvedField( + "facts[" + (ordinal - 1) + "]", + warningCode, + factEvidence)); + } + if (fact.bookingType() == null) { + unresolvedFields.add(new BookingContracts.UnresolvedField( + "facts[" + (ordinal - 1) + "].booking_type", + "BOOKING_TYPE_UNRESOLVED", + factEvidence)); + } + } + facts.addAll(materialSignalFacts); + if (allotmentInstruction.detected() && !linkedAllotmentActual) { + facts.add(allotmentUnresolvedFact(allotmentInstruction, materialPackage)); + } + if (replacementInstruction.detected() && !linkedGroupCodeReplacement) { + facts.add(groupCodeReplacementContextFact(replacementInstruction, materialPackage)); + } else if (!replacementInstruction.reasonCodes().isEmpty()) { + facts.add(groupCodeReplacementUnresolvedFact(replacementInstruction, materialPackage)); + } + List fallbackReasons = new ArrayList<>(); + if (facts.isEmpty()) { + fallbackReasons.add("NO_DETERMINISTIC_FACTS"); + } + if (applicability == BookingContracts.ParserApplicability.AMBIGUOUS) { + fallbackReasons.add("CHANNEL_PROFILE_AMBIGUOUS"); + } + if (applicability == BookingContracts.ParserApplicability.FAILED) { + fallbackReasons.add("PARSER_FAILED"); + } + fallbackReasons.addAll(replacementInstruction.reasonCodes()); + return new BookingContracts.ParsedFactSet( + parsedMeta, + materialPackage.evidenceRefs(), + applicability, + detectedProfile(outcome), + facts, + unresolvedFields, + fallbackReasons, + issues, + agentInputMaterial(input, materialPackage)); + } + + /** + * 仅识别证据唯一明确的材料型业务,不把图片或文件名本身误当成预订指令。 + * Rooming List 必须有明确 rooming/name-list 信号和当前 Excel;Payment 必须有当前正文信号和图片。 + */ + private List materialSignalFacts( + BookingMessageInput input, + BookingContracts.MaterialPackage materialPackage) { + String currentText = currentMessageText(input); + String attachmentNames = input.attachments().stream() + .filter(BookingMessageAttachmentInput::currentMaterial) + .map(BookingMessageAttachmentInput::fileName) + .collect(Collectors.joining("\n")); + String groupCode = explicitGroupCode(currentText); + List facts = new ArrayList<>(); + List currentExcels = input.attachments().stream() + .filter(BookingMessageAttachmentInput::currentMaterial) + .filter(this::isExcelAttachment) + .toList(); + if (!currentExcels.isEmpty() + && ROOMING_LIST_SIGNAL.matcher(currentText + "\n" + attachmentNames).find()) { + facts.add(materialSignalFact( + "material-rooming-list", + "ROOMING_LIST", + groupCode, + currentExcels, + materialPackage)); + } + List currentImages = input.attachments().stream() + .filter(BookingMessageAttachmentInput::currentMaterial) + .filter(this::isImageAttachment) + .toList(); + if (!currentImages.isEmpty() && PAYMENT_SIGNAL.matcher(currentText).find()) { + facts.add(materialSignalFact( + "material-payment", + "PAYMENT", + groupCode, + currentImages, + materialPackage)); + } + return List.copyOf(facts); + } + + /** + * Allotment 只在 current 文本出现明确关键字且能唯一提取来源团号时绑定到已解析的 New actual。 + * 不能安全绑定时单独保留一个无动作事实,让同封安全 actual 继续而 source 关系进入 Risk/fallback。 + */ + private AllotmentInstruction allotmentInstruction(BookingMessageInput input) { + String currentText = currentMessageText(input); + if (!ALLOTMENT_SIGNAL.matcher(currentText).find()) { + return AllotmentInstruction.notDetected(); + } + LinkedHashSet sourceGroupCodes = new LinkedHashSet<>(); + Matcher matcher = ALLOTMENT_SOURCE.matcher(currentText); + while (matcher.find()) { + String sourceGroupCode = blankToNull(matcher.group(1)); + if (isLikelyGroupCode(sourceGroupCode)) { + sourceGroupCodes.add(sourceGroupCode); + } + } + return new AllotmentInstruction( + true, + sourceGroupCodes.size() == 1 ? sourceGroupCodes.iterator().next() : null, + sourceGroupCodes.size() == 1 ? List.of() : List.of("ALLOTMENT_SOURCE_UNRESOLVED")); + } + + /** + * 只接受 current body 中明示 old→new 的受限格式;两个普通团号、主题文字或 quoted history 均不能构造关系。 + * 多个不同关系时停止确定性绑定,让 Agent/人工复核,而不是随意挑一个。 + */ + private GroupCodeReplacementInstruction groupCodeReplacementInstruction(BookingMessageInput input) { + String currentBody = input.currentBody() == null ? "" : input.currentBody(); + Map matches = new LinkedHashMap<>(); + appendGroupCodeReplacementMatches(matches, GROUP_CODE_REPLACEMENT_INLINE, currentBody); + appendGroupCodeReplacementMatches(matches, GROUP_CODE_REPLACEMENT_LABELED, currentBody); + appendGroupCodeReplacementMatches(matches, GROUP_CODE_REPLACEMENT_CHINESE, currentBody); + appendGroupCodeReplacementMatches(matches, GROUP_CODE_REPLACEMENT_THAI, currentBody); + if (matches.isEmpty()) { + return GroupCodeReplacementInstruction.notDetected(); + } + if (matches.size() == 1) { + return matches.values().iterator().next(); + } + return GroupCodeReplacementInstruction.ambiguous(); + } + + private void appendGroupCodeReplacementMatches( + Map matches, + Pattern pattern, + String currentBody) { + Matcher matcher = pattern.matcher(currentBody); + while (matcher.find()) { + String oldGroupCode = normalizeGroupCode(matcher.group(1)); + String newGroupCode = normalizeGroupCode(matcher.group(2)); + if (oldGroupCode == null || newGroupCode == null) { + continue; + } + GroupCodeReplacementInstruction value = new GroupCodeReplacementInstruction( + true, + oldGroupCode, + newGroupCode, + List.of()); + matches.putIfAbsent(oldGroupCode + "|" + newGroupCode, value); + } + } + + private String normalizeGroupCode(String value) { + String normalized = blankToNull(value); + return isLikelyGroupCode(normalized) ? normalized.toUpperCase(Locale.ROOT) : null; + } + + private boolean canBindGroupCodeReplacement( + GroupCodeReplacementInstruction instruction, + ReservationBookingEmailParser.BookingFact fact) { + return instruction.detected() + && "UPDATE_BOOKING".equals(fact.action()) + && instruction.newGroupCode().equalsIgnoreCase(blankToNull(fact.groupCode())); + } + + private BookingContracts.RelatedHint groupCodeReplacementHint( + GroupCodeReplacementInstruction instruction, + List factEvidence, + BookingContracts.MaterialPackage materialPackage) { + List evidenceRefs = mergeEvidence( + factEvidence, + currentBodyEvidence(materialPackage)); + Map attributes = new LinkedHashMap<>(); + attributes.put("relationship_id", groupCodeReplacementRelationshipId(instruction, materialPackage)); + attributes.put("old_group_code", instruction.oldGroupCode()); + attributes.put("new_group_code", instruction.newGroupCode()); + attributes.put("source_instruction_present", true); + return new BookingContracts.RelatedHint("GROUP_CODE_REPLACEMENT", attributes, evidenceRefs); + } + + private BookingContracts.ParsedFact groupCodeReplacementContextFact( + GroupCodeReplacementInstruction instruction, + BookingContracts.MaterialPackage materialPackage) { + List evidenceRefs = currentBodyEvidence(materialPackage); + BookingContracts.RelatedHint hint = groupCodeReplacementHint(instruction, evidenceRefs, materialPackage); + return new BookingContracts.ParsedFact( + "context-group-code-replacement-" + groupCodeReplacementRelationshipId(instruction, materialPackage) + .substring("group-code-replacement-".length()), + null, + null, + new BookingContracts.TargetIdentity( + instruction.newGroupCode(), + null, + instruction.newGroupCode(), + List.of(), + evidenceRefs), + null, + List.of(hint), + evidenceRefs); + } + + private BookingContracts.ParsedFact groupCodeReplacementUnresolvedFact( + GroupCodeReplacementInstruction instruction, + BookingContracts.MaterialPackage materialPackage) { + List evidenceRefs = currentBodyEvidence(materialPackage); + Map attributes = new LinkedHashMap<>(); + attributes.put("reason_codes", instruction.reasonCodes()); + attributes.put("source_instruction_present", true); + return new BookingContracts.ParsedFact( + "context-group-code-replacement-unresolved", + null, + null, + new BookingContracts.TargetIdentity(null, null, null, List.of(), evidenceRefs), + null, + List.of(new BookingContracts.RelatedHint( + "GROUP_CODE_REPLACEMENT_UNRESOLVED", + attributes, + evidenceRefs)), + evidenceRefs); + } + + /** 非固定渠道只把明确标签中的唯一团号作为 Context 定向读取线索,不决定业务动作。 */ + private BookingContracts.ParsedFact contextGroupReferenceFact( + String groupCode, + BookingContracts.MaterialPackage materialPackage) { + List evidenceRefs = currentBodyEvidence(materialPackage); + return new BookingContracts.ParsedFact( + "context-group-reference-" + sha256(groupCode + "|" + materialPackage.contractMeta().sourceMessageId()) + .substring(0, 24), + null, + null, + new BookingContracts.TargetIdentity(groupCode, null, groupCode, List.of(), evidenceRefs), + null, + List.of(), + evidenceRefs); + } + + private String currentMessageText(BookingMessageInput input) { + return String.join("\n", + input.safeSubject() == null ? "" : input.safeSubject(), + input.currentBody() == null ? "" : input.currentBody()); + } + + private List currentBodyEvidence( + BookingContracts.MaterialPackage materialPackage) { + List currentEvidence = materialPackage.evidenceRefs().stream() + .filter(evidence -> evidence.kind() == BookingContracts.EvidenceKind.CURRENT_BODY + && evidence.currentMaterial()) + .toList(); + return currentEvidence.isEmpty() ? materialPackage.evidenceRefs() : currentEvidence; + } + + private String groupCodeReplacementRelationshipId( + GroupCodeReplacementInstruction instruction, + BookingContracts.MaterialPackage materialPackage) { + return "group-code-replacement-" + sha256( + instruction.oldGroupCode() + "|" + instruction.newGroupCode() + "|" + + materialPackage.contractMeta().sourceMessageId()).substring(0, 24); + } + + private List mergeReasonCodes(List first, List second) { + LinkedHashSet result = new LinkedHashSet<>(); + result.addAll(first); + result.addAll(second); + return List.copyOf(result); + } + + private boolean canBindAllotmentToActual( + AllotmentInstruction instruction, + ReservationBookingEmailParser.BookingFact fact) { + return instruction.detected() + && instruction.sourceGroupCode() != null + && "NEW_BOOKING".equals(fact.action()) + && blankToNull(fact.groupCode()) != null + && !instruction.sourceGroupCode().equalsIgnoreCase(fact.groupCode()); + } + + private BookingContracts.RelatedHint allotmentRelatedHint( + AllotmentInstruction instruction, + String actualGroupCode, + List factEvidence, + BookingContracts.MaterialPackage materialPackage) { + List evidenceRefs = mergeEvidence( + factEvidence, + materialPackage.evidenceRefs().stream() + .filter(evidence -> evidence.kind() == BookingContracts.EvidenceKind.CURRENT_BODY) + .toList()); + Map attributes = new LinkedHashMap<>(); + // 同一 current 邮件、同一来源团的 N 个 actual 共用 relationship ID,Validator 才能只汇总通过准入的 N 项。 + attributes.put("relationship_id", "allotment-" + sha256( + instruction.sourceGroupCode() + "|" + materialPackage.contractMeta().sourceMessageId()) + .substring(0, 24)); + attributes.put("source_group_code", instruction.sourceGroupCode()); + attributes.put("actual_group_code", actualGroupCode); + attributes.put("source_instruction_present", true); + return new BookingContracts.RelatedHint("ALLOTMENT_SOURCE", attributes, evidenceRefs); + } + + private BookingContracts.ParsedFact allotmentUnresolvedFact( + AllotmentInstruction instruction, + BookingContracts.MaterialPackage materialPackage) { + List evidenceRefs = materialPackage.evidenceRefs().stream() + .filter(evidence -> evidence.kind() == BookingContracts.EvidenceKind.CURRENT_BODY) + .toList(); + if (evidenceRefs.isEmpty()) { + evidenceRefs = materialPackage.evidenceRefs(); + } + Map attributes = new LinkedHashMap<>(); + attributes.put("source_instruction_present", true); + attributes.put("source_group_code", instruction.sourceGroupCode()); + attributes.put("reason_codes", instruction.reasonCodes()); + return new BookingContracts.ParsedFact( + "material-allotment-unresolved", + null, + null, + new BookingContracts.TargetIdentity(null, null, null, List.of(), evidenceRefs), + null, + List.of(new BookingContracts.RelatedHint("ALLOTMENT_SOURCE_UNRESOLVED", attributes, evidenceRefs)), + evidenceRefs); + } + + private List mergeEvidence( + List first, + List second) { + Map result = new LinkedHashMap<>(); + for (BookingContracts.EvidenceReference evidence : first) { + result.putIfAbsent(evidence.evidenceId(), evidence); + } + for (BookingContracts.EvidenceReference evidence : second) { + result.putIfAbsent(evidence.evidenceId(), evidence); + } + return List.copyOf(result.values()); + } + + private boolean isLikelyGroupCode(String value) { + return value != null && value.matches("(?i)[A-Z][A-Z0-9_-]{3,}") + && value.chars().anyMatch(Character::isDigit); + } + + private BookingContracts.ParsedFact materialSignalFact( + String factId, + String businessType, + String groupCode, + List attachments, + BookingContracts.MaterialPackage materialPackage) { + List evidenceRefs = materialEvidence(materialPackage, attachments); + Map attributes = new LinkedHashMap<>(); + attributes.put("attachment_present", !attachments.isEmpty()); + attributes.put("attachment_ids", attachments.stream() + .map(BookingMessageAttachmentInput::attachmentId) + .toList()); + attributes.put("target_identified", groupCode != null); + return new BookingContracts.ParsedFact( + factId, + null, + null, + new BookingContracts.TargetIdentity( + groupCode, + null, + groupCode, + List.of(), + evidenceRefs), + null, + List.of(new BookingContracts.RelatedHint(businessType, attributes, evidenceRefs)), + evidenceRefs); + } + + private List materialEvidence( + BookingContracts.MaterialPackage materialPackage, + List attachments) { + Set attachmentEvidenceIds = attachments.stream() + .map(this::attachmentEvidenceId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List evidenceRefs = materialPackage.evidenceRefs().stream() + .filter(evidence -> evidence.currentMaterial() + && (attachmentEvidenceIds.contains(evidence.evidenceId()) + || evidence.kind() == BookingContracts.EvidenceKind.CURRENT_BODY)) + .toList(); + return evidenceRefs.isEmpty() ? materialPackage.evidenceRefs() : evidenceRefs; + } + + private String explicitGroupCode(String currentText) { + LinkedHashSet values = new LinkedHashSet<>(); + Matcher matcher = EXPLICIT_GROUP_CODE.matcher(currentText == null ? "" : currentText); + while (matcher.find()) { + String value = blankToNull(matcher.group(1)); + if (value != null) { + values.add(value); + } + } + return values.size() == 1 ? values.iterator().next() : null; + } + + /** 生成用于 processing run 幂等/审计的输入摘要,不把邮件内容写入日志。 */ + public String sourceFingerprint(BookingMessageInput input) { + String attachmentHashes = input.attachments().stream() + .map(BookingMessageAttachmentInput::contentHash) + .collect(Collectors.joining("|")); + return sha256(input.idempotencyKey() + "|" + sha256(input.currentBody()) + "|" + attachmentHashes + + "|" + String.valueOf(BookingSenderNormalizer.normalize(input.senderIdentifier()))); + } + + private BookingContracts.ParsedFactSet parsedFactSetWithoutFixedChannel( + BookingMessageInput input, + BookingContracts.MaterialPackage materialPackage, + BookingContracts.ParserApplicability applicability, + List fallbackReasons, + List issues, + List facts) { + return new BookingContracts.ParsedFactSet( + withVersions( + materialPackage.contractMeta(), + catalog.catalogVersion(), + catalog.parserVersion(), + BookingContracts.AGENT_NOT_INVOKED), + materialPackage.evidenceRefs(), + applicability, + null, + facts, + List.of(), + fallbackReasons, + issues, + agentInputMaterial(input, materialPackage)); + } + + /** 只从 current subject/body 建立 Agent 的受控材料;quoted history 与附件从不进入该对象。 */ + private BookingContracts.AgentInputMaterial agentInputMaterial( + BookingMessageInput input, + BookingContracts.MaterialPackage materialPackage) { + return BookingAgentInputMaterialPolicy.fromCurrentMessage( + materialPackage.messageEnvelope().currentBodyEvidenceId(), + input.safeSubject(), + input.currentBody()); + } + + private ParsedEmlMessage parsedEmlMessage(BookingMessageInput input) { + List mediaItems = input.attachments().stream() + .filter(attachment -> attachment.bytes() != null) + .map(attachment -> new ParsedEmlMediaItem( + "ATTACHMENT", + attachment.fileName(), + attachment.mediaType(), + attachment.sizeBytes(), + null, + attachment.bytes())) + .toList(); + return new ParsedEmlMessage( + input.externalMessageId(), + input.conversationId(), + null, + input.safeSubject(), + input.receivedAt(), + input.currentBody(), + null, + mediaItems); + } + + private List evidenceReferences(BookingMessageInput input) { + List result = new ArrayList<>(); + result.add(new BookingContracts.EvidenceReference( + currentBodyEvidenceId(input), + BookingContracts.EvidenceKind.CURRENT_BODY, + input.platformSourceMessageId(), + "current-body", + sha256(input.currentBody()), + true)); + if (hasText(input.quotedHistory())) { + result.add(new BookingContracts.EvidenceReference( + quotedHistoryEvidenceId(input), + BookingContracts.EvidenceKind.QUOTED_HISTORY, + input.platformSourceMessageId(), + "quoted-history", + sha256(input.quotedHistory()), + false)); + } + for (BookingMessageAttachmentInput attachment : input.attachments()) { + result.add(new BookingContracts.EvidenceReference( + attachmentEvidenceId(attachment), + evidenceKind(attachment), + input.platformSourceMessageId(), + "attachment:" + attachment.attachmentId(), + attachment.contentHash(), + attachment.currentMaterial())); + } + return List.copyOf(result); + } + + private List attachmentDescriptors(BookingMessageInput input) { + return input.attachments().stream() + .map(attachment -> new BookingContracts.AttachmentDescriptor( + attachment.attachmentId(), + attachment.fileName(), + attachment.mediaType(), + attachment.sizeBytes(), + attachment.contentHash(), + attachmentEvidenceId(attachment))) + .toList(); + } + + private List factEvidence( + BookingMessageInput input, + ReservationBookingEmailParser.BookingFact fact) { + String attachmentHash = input.attachments().stream() + .filter(attachment -> Objects.equals(attachment.fileName(), fact.recognition().attachmentName())) + .map(BookingMessageAttachmentInput::contentHash) + .findFirst() + .orElseGet(() -> sha256(fact.recognition().attachmentName())); + String locator = "sheet:" + fact.recognition().sheet() + ";row:" + fact.recognition().rowNumber(); + return List.of(new BookingContracts.EvidenceReference( + "workbook-row-" + sha256(attachmentHash + "|" + locator).substring(0, 24), + BookingContracts.EvidenceKind.WORKBOOK_ROW, + input.platformSourceMessageId(), + locator, + sha256(attachmentHash + "|" + locator), + true)); + } + + private BookingContracts.ParserApplicability parserApplicability( + ReservationBookingEmailParser.ParseOutcome outcome) { + boolean parserFailure = outcome.warnings().stream() + .map(ReservationBookingEmailParser.ParseWarning::code) + .anyMatch("BOOKING_EXCEL_PARSE_FAILED"::equals); + if (parserFailure) { + return BookingContracts.ParserApplicability.FAILED; + } + boolean profileAmbiguity = outcome.warnings().stream() + .map(ReservationBookingEmailParser.ParseWarning::code) + .anyMatch(PROFILE_AMBIGUITY_WARNINGS::contains); + return profileAmbiguity + ? BookingContracts.ParserApplicability.AMBIGUOUS + : BookingContracts.ParserApplicability.APPLICABLE; + } + + private String detectedProfile(ReservationBookingEmailParser.ParseOutcome outcome) { + return outcome.facts().isEmpty() + ? null + : blankToNull(outcome.facts().get(0).recognition().profile()); + } + + private BookingContracts.ContractMeta withVersions( + BookingContracts.ContractMeta source, + String catalogVersion, + String parserVersion, + String agentProfileVersion) { + return new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + catalogVersion, + parserVersion, + agentProfileVersion, + source.processingRunId(), + source.sourceMessageId(), + source.sourceRevision()); + } + + private BookingContracts.ContractIssue issue( + String code, + BookingContracts.IssueSeverity severity, + BookingContracts.IssueScope scope, + List evidenceRefs) { + return new BookingContracts.ContractIssue( + code, + severity, + scope, + "booking.contract." + code.toLowerCase(Locale.ROOT), + evidenceRefs, + false); + } + + private BookingContracts.IssueSeverity severityForWarning(String code) { + return "BOOKING_EXCEL_PARSE_FAILED".equals(code) + || PROFILE_AMBIGUITY_WARNINGS.contains(code) + ? BookingContracts.IssueSeverity.BLOCKER + : BookingContracts.IssueSeverity.WARNING; + } + + private Map traceAttributes(ReservationBookingEmailParser.TraceFact trace) { + Map attributes = new LinkedHashMap<>(); + attributes.put("item_type", trace.itemType()); + putIfText(attributes, "text", trace.text()); + putIfText(attributes, "target_room_type_code", trace.targetRoomTypeCode()); + if (trace.extraBedRoomCount() > 0) { + attributes.put("extra_bed_room_count", trace.extraBedRoomCount()); + } + putIfText(attributes, "department_code", trace.departmentCode()); + return attributes; + } + + private boolean isExcelAttachment(BookingMessageAttachmentInput attachment) { + String fileName = attachment.fileName().toLowerCase(Locale.ROOT); + String mediaType = attachment.mediaType().toLowerCase(Locale.ROOT); + return fileName.endsWith(".xlsx") || fileName.endsWith(".xls") + || mediaType.contains("spreadsheet") || mediaType.contains("excel"); + } + + private boolean isImageAttachment(BookingMessageAttachmentInput attachment) { + String fileName = attachment.fileName().toLowerCase(Locale.ROOT); + String mediaType = attachment.mediaType().toLowerCase(Locale.ROOT); + return mediaType.startsWith("image/") + || fileName.endsWith(".png") + || fileName.endsWith(".jpg") + || fileName.endsWith(".jpeg") + || fileName.endsWith(".webp"); + } + + private BookingContracts.EvidenceKind evidenceKind(BookingMessageAttachmentInput attachment) { + return isImageAttachment(attachment) + ? BookingContracts.EvidenceKind.IMAGE + : BookingContracts.EvidenceKind.ATTACHMENT; + } + + private String currentBodyEvidenceId(BookingMessageInput input) { + return "current-body-" + sha256(input.currentBody()).substring(0, 24); + } + + private String quotedHistoryEvidenceId(BookingMessageInput input) { + return "quoted-history-" + sha256(input.quotedHistory()).substring(0, 24); + } + + private String attachmentEvidenceId(BookingMessageAttachmentInput attachment) { + return "attachment-" + attachment.attachmentId() + "-" + attachment.contentHash().substring(0, 12); + } + + private List nonBlankValues(List values) { + return values == null + ? List.of() + : values.stream().filter(this::hasText).map(String::trim).toList(); + } + + private void putIfText(Map target, String key, String value) { + if (hasText(value)) { + target.put(key, value.trim()); + } + } + + private String blankToNull(String value) { + return hasText(value) ? value.trim() : null; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest( + (source == null ? "" : source).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } + + private record AllotmentInstruction( + boolean detected, + String sourceGroupCode, + List reasonCodes) { + + private static AllotmentInstruction notDetected() { + return new AllotmentInstruction(false, null, List.of()); + } + } + + private record GroupCodeReplacementInstruction( + boolean detected, + String oldGroupCode, + String newGroupCode, + List reasonCodes) { + + private static GroupCodeReplacementInstruction notDetected() { + return new GroupCodeReplacementInstruction(false, null, null, List.of()); + } + + private static GroupCodeReplacementInstruction ambiguous() { + return new GroupCodeReplacementInstruction( + false, + null, + null, + List.of("GROUP_CODE_REPLACEMENT_AMBIGUOUS")); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipeline.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipeline.java new file mode 100644 index 0000000..4867ecd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipeline.java @@ -0,0 +1,225 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingProcessingRunRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingConfirmationProjectionFactory; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionService; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionValidator; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * Context 之后的候选、校验和确认投影收口。 + * + *

该类不持有原始邮件或附件;因此可被同步确定性路径和异步 Booking Agent worker 安全复用。任何异常都只 + * 持久化安全失败码,绝不回传 Agent 原始错误或启动 PMS/Opera。

+ */ +@Service +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingDecisionPipeline { + + private final BookingProcessingRunRepository processingRunRepository; + private final BookingDecisionService decisionService; + private final BookingDecisionValidator decisionValidator; + private final BookingConfirmationProjectionFactory confirmationProjectionFactory; + private final ObjectMapper objectMapper; + + public BookingDecisionPipeline( + BookingProcessingRunRepository processingRunRepository, + BookingDecisionService decisionService, + BookingDecisionValidator decisionValidator, + BookingConfirmationProjectionFactory confirmationProjectionFactory, + ObjectMapper objectMapper) { + this.processingRunRepository = processingRunRepository; + this.decisionService = decisionService; + this.decisionValidator = decisionValidator; + this.confirmationProjectionFactory = confirmationProjectionFactory; + this.objectMapper = objectMapper; + } + + /** 完成 CandidateDecision → ValidationResult → ConfirmationProjection;本期终点仍是用户确认。 */ + public BookingPipelineCompletion complete( + String runId, + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage, + boolean businessSignalPresent, + boolean attachmentPresent) { + try { + BookingContracts.CandidateDecision candidateDecision = decisionService.decide( + parsedFactSet, + contextPackage, + businessSignalPresent, + attachmentPresent); + return completeCandidate(runId, parsedFactSet, contextPackage, candidateDecision); + } catch (RuntimeException exception) { + markFailed(runId); + throw exception; + } + } + + /** 外发/内部邮件的唯一合法终点:持久化 ignored disposition,不产生任务或调用 Agent。 */ + public BookingPipelineCompletion completeIgnoredDirection( + String runId, + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage, + cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageMasterData.MessageDirection direction) { + try { + BookingContracts.ContractIssue issue = new BookingContracts.ContractIssue( + "MESSAGE_DIRECTION_IGNORED", + BookingContracts.IssueSeverity.INFO, + BookingContracts.IssueScope.MESSAGE, + "booking.direction.ignored", + parsedFactSet.evidenceRefs(), + false); + BookingContracts.MessageNotification notification = new BookingContracts.MessageNotification( + BookingContracts.ResultDisposition.IGNORED_DIRECTION, + "IGNORED_" + direction.name(), + parsedFactSet.evidenceRefs(), + List.of(issue)); + BookingContracts.CandidateDecision candidateDecision = new BookingContracts.CandidateDecision( + parsedFactSet.contractMeta(), + parsedFactSet.evidenceRefs(), + BookingContracts.DecisionOrigin.DETERMINISTIC, + BookingContracts.ResultDisposition.IGNORED_DIRECTION, + List.of(), + List.of(notification), + null, + List.of(issue)); + return completeCandidate(runId, parsedFactSet, contextPackage, candidateDecision); + } catch (RuntimeException exception) { + markFailed(runId); + throw exception; + } + } + + private BookingPipelineCompletion completeCandidate( + String runId, + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage, + BookingContracts.CandidateDecision candidateDecision) { + processingRunRepository.updateRunContractMeta(runId, candidateDecision.contractMeta()); + persist(runId, "CANDIDATE_DECISION", candidateDecision, candidateDecision.evidenceRefs()); + if (candidateDecision.decisionOrigin() == BookingContracts.DecisionOrigin.AGENT + || candidateDecision.decisionOrigin() == BookingContracts.DecisionOrigin.MIXED) { + processingRunRepository.updateRunStatus( + runId, + BookingContracts.ProcessingStatus.AGENT_COMPLETE, + candidateDecision.resultDisposition(), + null, + null); + } + + BookingContracts.ValidationResult validationResult = decisionValidator.validate(candidateDecision, contextPackage); + persist(runId, "VALIDATION_RESULT", validationResult, validationResult.evidenceRefs()); + processingRunRepository.saveCandidateTargets( + runId, + candidateDecision, + contextPackage, + validationResult, + candidateDecision.targetDecisions().stream().map(this::json).toList()); + processingRunRepository.updateRunStatus( + runId, + BookingContracts.ProcessingStatus.VALIDATED, + candidateDecision.resultDisposition(), + null, + null); + + BookingContracts.ConfirmationProjection projection = + confirmationProjectionFactory.project(candidateDecision, validationResult); + persist(runId, "CONFIRMATION_PROJECTION", projection, projection.evidenceRefs()); + // 已在上一步写入 VALIDATED。无确认投影(例如 General/Risk/IGNORED_DIRECTION)不应重复制造 + // 同一状态事件;只有真正进入 AWAITING_CONFIRMATION 时才推进状态机。 + if (projection.processingStatus() != BookingContracts.ProcessingStatus.VALIDATED) { + processingRunRepository.updateRunStatus( + runId, + projection.processingStatus(), + candidateDecision.resultDisposition(), + null, + null); + } + return new BookingPipelineCompletion( + projection.processingStatus(), + candidateDecision.resultDisposition(), + warningCodes(parsedFactSet, candidateDecision, validationResult)); + } + + /** worker 失败时仅写安全失败摘要,保留原异常给应用监控。 */ + public void markFailed(String runId) { + try { + processingRunRepository.updateRunStatus( + runId, + BookingContracts.ProcessingStatus.FAILED, + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + "BOOKING_ORCHESTRATION_FAILED", + "Booking processing failed before confirmation."); + } catch (RuntimeException ignored) { + // 不能让失败状态的二次异常遮蔽原始故障;监控会根据未完成 run 发现它。 + } + } + + /** 在尚未产生 CandidateDecision 的异步排队响应中也可安全返回的 warning 码。 */ + public List preDecisionWarningCodes(BookingContracts.ParsedFactSet parsedFactSet) { + Set codes = new LinkedHashSet<>(); + parsedFactSet.issues().forEach(issue -> codes.add(issue.code())); + parsedFactSet.unresolvedFields().forEach(field -> codes.add(field.reasonCode())); + return List.copyOf(codes); + } + + private void persist( + String runId, + String artifactType, + Object artifact, + List evidenceRefs) { + String payload = json(artifact); + processingRunRepository.saveArtifact(runId, artifactType, payload, sha256(payload), evidenceRefs); + } + + private List warningCodes( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ValidationResult validationResult) { + Set codes = new LinkedHashSet<>(preDecisionWarningCodes(parsedFactSet)); + candidateDecision.issues().forEach(issue -> codes.add(issue.code())); + validationResult.blockers().forEach(issue -> codes.add(issue.code())); + return List.copyOf(codes); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Booking contracts-v1 JSON 序列化失败。", exception); + } + } + + private String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return java.util.HexFormat.of().formatHex(digest.digest(source.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } + + /** 编排器只需要的安全阶段摘要,不包含 Agent payload 或原始材料。 */ + public record BookingPipelineCompletion( + BookingContracts.ProcessingStatus processingStatus, + BookingContracts.ResultDisposition resultDisposition, + List warningCodes) { + + public BookingPipelineCompletion { + if (processingStatus == null || resultDisposition == null) { + throw new IllegalArgumentException("Booking pipeline completion 不能为空。"); + } + warningCodes = warningCodes == null ? List.of() : List.copyOf(warningCodes); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImpl.java new file mode 100644 index 0000000..0001615 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImpl.java @@ -0,0 +1,641 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingAgentDecisionService; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +/** 先使用确定性事实;仅在 Parser 不适用、失败或歧义时调用 Booking Agent 端口。 */ +@Service +public class BookingDecisionServiceImpl implements BookingDecisionService { + + private final BookingAgentDecisionService bookingAgentDecisionService; + + public BookingDecisionServiceImpl(BookingAgentDecisionService bookingAgentDecisionService) { + this.bookingAgentDecisionService = bookingAgentDecisionService; + } + + /** 根据 Parser 适用性形成确定性候选、General/Risk,或转交受控 Agent 端口。 */ + @Override + public BookingContracts.CandidateDecision decide( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage, + boolean businessSignalPresent, + boolean attachmentPresent) { + // 没有当前附件、没有业务信号且 Parser 也未给出事实时,才能确定整封没有支持任务。 + // 此分支必须先于 Agent fallback,避免把普通通知一律升级为 S99 Risk。 + if (parsedFactSet.facts().isEmpty() && !businessSignalPresent && !attachmentPresent) { + return enrichWithMasterData(notificationDecision(parsedFactSet, false), contextPackage); + } + // Rooming List / Payment 的严格材料信号本身不依赖 Excel 行解析或图片 OCR。 + // 当事实集只包含这类已绑定当前附件的事实时,可以安全地绕过 Agent;这既避免把附件 + // 字节、URL 交给 Agent,也不会把普通图片/文件名误判为业务。 + if (containsOnlyStandaloneMaterialFacts(parsedFactSet)) { + return enrichWithMasterData(deterministicTasks(parsedFactSet, contextPackage), contextPackage); + } + if (requiresAgentFallback(parsedFactSet)) { + return enrichWithMasterData( + enrichAgentDecisionWithGroupCodeReplacement( + normalizeSameMessageTraceTargets( + bookingAgentDecisionService.decide(parsedFactSet, contextPackage)), + parsedFactSet), + contextPackage); + } + if (!parsedFactSet.facts().isEmpty()) { + return enrichWithMasterData(deterministicTasks(parsedFactSet, contextPackage), contextPackage); + } + return enrichWithMasterData(notificationDecision(parsedFactSet, businessSignalPresent || attachmentPresent), contextPackage); + } + + /** + * 与 {@link #decide(BookingContracts.ParsedFactSet, BookingContracts.ContextPackage, boolean, boolean)} + * 使用同一条 fallback 判定,供 Orchestrator 决定是否把真实 Agent 调用放入异步 worker。 + */ + @Override + public boolean requiresAgentFallback(BookingContracts.ParsedFactSet parsedFactSet) { + return parsedFactSet != null + && !containsOnlyStandaloneMaterialFacts(parsedFactSet) + && requiresAgent(parsedFactSet.parserApplicability()); + } + + private BookingContracts.CandidateDecision deterministicTasks( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage) { + List targets = new ArrayList<>(); + List issues = new ArrayList<>(parsedFactSet.issues()); + List notifications = new ArrayList<>(); + List unresolvedEvidence = new ArrayList<>(); + for (BookingContracts.ParsedFact fact : parsedFactSet.facts()) { + String standaloneMaterialBusinessType = standaloneMaterialBusinessType(fact); + if (standaloneMaterialBusinessType != null) { + targets.add(standaloneMaterialTarget(fact, standaloneMaterialBusinessType, contextPackage)); + continue; + } + if (fact.actionHint() == null || fact.actionHint().isBlank()) { + BookingContracts.ContractIssue unresolvedAction = issue("ACTION_UNRESOLVED", fact.evidenceRefs()); + issues.add(unresolvedAction); + unresolvedEvidence.addAll(fact.evidenceRefs()); + continue; + } + Map fields = new LinkedHashMap<>(); + fields.put("booking_type", bookingType(fact)); + fields.put("group_code", targetField(fact, "group_code")); + fields.put("group_name", targetField(fact, "group_name")); + fields.put("tour_code", targetField(fact, "tour_code")); + fields.put("names", fact.targetIdentity() == null ? List.of() : fact.targetIdentity().nameValues()); + fields.put("arrival_date", fact.stay() == null ? null : fact.stay().arrivalDate()); + fields.put("departure_date", fact.stay() == null ? null : fact.stay().departureDate()); + fields.put("room_items", fact.stay() == null ? List.of() : fact.stay().roomItems()); + fields.put("rate_code", resolvedRateCode(contextPackage, fact.factId())); + List> linkedActions = fact.relatedHints().stream() + .map(hint -> linkedAction(hint, fact.factId(), contextPackage)) + .toList(); + targets.add(new BookingContracts.TargetDecision( + fact.factId(), + fact.actionHint(), + actionKind(fact.actionHint()), + fact.targetIdentity(), + fields, + linkedActions, + fact.evidenceRefs(), + List.of())); + } + if (targets.isEmpty()) { + return notificationDecision(parsedFactSet, true); + } + // 同封中已经能确定的目标继续进入确认;仅把无法判定动作的事实片段隔离为唯一一张 Risk。 + // 不能因一段脏数据/历史残留而阻断其他严格 Parser 已确认的目标。 + if (!unresolvedEvidence.isEmpty()) { + List safeEvidence = List.copyOf(unresolvedEvidence); + BookingContracts.ContractIssue unresolvedAction = issue("ACTION_UNRESOLVED", safeEvidence); + notifications.add(new BookingContracts.MessageNotification( + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + "S99", + safeEvidence, + List.of(unresolvedAction))); + } + return new BookingContracts.CandidateDecision( + parsedFactSet.contractMeta(), + parsedFactSet.evidenceRefs(), + BookingContracts.DecisionOrigin.DETERMINISTIC, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + targets, + notifications, + null, + issues); + } + + private boolean containsOnlyStandaloneMaterialFacts(BookingContracts.ParsedFactSet parsedFactSet) { + return !parsedFactSet.facts().isEmpty() + && parsedFactSet.facts().stream() + .allMatch(fact -> standaloneMaterialBusinessType(fact) != null); + } + + /** + * 仅接受预处理层已经建立“当前附件 + 明确正文信号”的单一材料事实。 + * 多个不同 hint 或带 booking action 的事实不能走这里,避免模糊材料绕过 Agent。 + */ + private String standaloneMaterialBusinessType(BookingContracts.ParsedFact fact) { + if (fact.actionHint() != null && !fact.actionHint().isBlank()) { + return null; + } + List allowedHints = fact.relatedHints().stream() + .map(BookingContracts.RelatedHint::hintType) + .filter(hintType -> "ROOMING_LIST".equals(hintType) || "PAYMENT".equals(hintType)) + .distinct() + .toList(); + return allowedHints.size() == 1 && fact.relatedHints().size() == 1 ? allowedHints.get(0) : null; + } + + private BookingContracts.TargetDecision standaloneMaterialTarget( + BookingContracts.ParsedFact fact, + String businessType, + BookingContracts.ContextPackage contextPackage) { + BookingContracts.RelatedHint hint = fact.relatedHints().get(0); + Map fields = new LinkedHashMap<>(); + fields.put("group_code", targetField(fact, "group_code")); + fields.put("group_name", targetField(fact, "group_name")); + fields.put("tour_code", targetField(fact, "tour_code")); + fields.put("names", fact.targetIdentity() == null ? List.of() : fact.targetIdentity().nameValues()); + fields.put("attachment_present", hint.attributes().get("attachment_present")); + fields.put("attachment_ids", hint.attributes().get("attachment_ids")); + fields.put("target_identified", hint.attributes().get("target_identified")); + return new BookingContracts.TargetDecision( + fact.factId(), + businessType, + standaloneMaterialActionKind(businessType), + fact.targetIdentity(), + fields, + List.of(linkedAction(hint, fact.factId(), contextPackage)), + fact.evidenceRefs(), + List.of()); + } + + private String standaloneMaterialActionKind(String businessType) { + return switch (businessType) { + case "ROOMING_LIST" -> "DISPLAY_ROOMING_LIST"; + case "PAYMENT" -> "DISPLAY_PAYMENT_EVIDENCE"; + default -> throw new IllegalArgumentException("不支持的材料型业务:" + businessType); + }; + } + + private BookingContracts.CandidateDecision notificationDecision( + BookingContracts.ParsedFactSet parsedFactSet, + boolean risk) { + BookingContracts.ResultDisposition disposition = risk + ? BookingContracts.ResultDisposition.RISK_NOTIFICATION + : BookingContracts.ResultDisposition.GENERAL_NOTIFICATION; + String notificationCode = risk ? "S99" : "S10"; + BookingContracts.ContractIssue issue = risk + ? issue("CURRENT_MESSAGE_REQUIRES_REVIEW", parsedFactSet.evidenceRefs()) + : null; + BookingContracts.MessageNotification notification = new BookingContracts.MessageNotification( + disposition, + notificationCode, + parsedFactSet.evidenceRefs(), + issue == null ? List.of() : List.of(issue)); + return new BookingContracts.CandidateDecision( + parsedFactSet.contractMeta(), + parsedFactSet.evidenceRefs(), + BookingContracts.DecisionOrigin.DETERMINISTIC, + disposition, + List.of(), + List.of(notification), + null, + issue == null ? List.of() : List.of(issue)); + } + + private boolean requiresAgent(BookingContracts.ParserApplicability applicability) { + return applicability == BookingContracts.ParserApplicability.NOT_APPLICABLE + || applicability == BookingContracts.ParserApplicability.FAILED + || applicability == BookingContracts.ParserApplicability.AMBIGUOUS; + } + + /** + * 把唯一 sender 映射的业务代码填入本地候选或 Agent 候选;缺失/多候选不填默认值,后续 Validator + * 会把该目标保留在复核。 + */ + private BookingContracts.CandidateDecision enrichWithMasterData( + BookingContracts.CandidateDecision decision, + BookingContracts.ContextPackage contextPackage) { + Map senderMapping = senderMapping(contextPackage); + if (senderMapping.isEmpty() || decision.targetDecisions().isEmpty()) { + return decision; + } + List targets = decision.targetDecisions().stream() + .map(target -> { + Map fields = new LinkedHashMap<>(target.fields()); + putIfPresent(fields, "account_code", senderMapping.get("account_code")); + putIfPresent(fields, "market_code", senderMapping.get("market_code")); + putIfPresent(fields, "source_code", senderMapping.get("source_code")); + return new BookingContracts.TargetDecision( + target.targetDecisionId(), + target.businessType(), + target.actionKind(), + target.targetIdentity(), + fields, + target.linkedActions(), + target.evidenceRefs(), + target.issues()); + }) + .toList(); + return new BookingContracts.CandidateDecision( + decision.contractMeta(), + decision.evidenceRefs(), + decision.decisionOrigin(), + decision.resultDisposition(), + targets, + decision.messageNotifications(), + decision.agentTraceRef(), + decision.issues()); + } + + private String resolvedRateCode(BookingContracts.ContextPackage contextPackage, String factId) { + Object rawMasterData = contextPackage == null ? null : contextPackage.currentState().get("message_master_data"); + if (!(rawMasterData instanceof Map masterData)) { + return null; + } + Object rawRateMappings = masterData.get("rate_code_mappings"); + if (!(rawRateMappings instanceof Map rateMappings)) { + return null; + } + Object rawFactResolution = rateMappings.get(factId); + if (!(rawFactResolution instanceof Map resolution) + || !"RESOLVED".equals(resolution.get("status"))) { + return null; + } + return stringValue(resolution.get("rate_code")); + } + + private Map senderMapping(BookingContracts.ContextPackage contextPackage) { + Object rawMasterData = contextPackage == null ? null : contextPackage.currentState().get("message_master_data"); + if (!(rawMasterData instanceof Map masterData) + || !(masterData.get("sender_mapping") instanceof Map rawSenderMapping)) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + rawSenderMapping.forEach((key, value) -> { + if (key instanceof String stringKey) { + result.put(stringKey, value); + } + }); + return result; + } + + private void putIfPresent(Map fields, String key, Object value) { + if (value instanceof String text && !text.isBlank()) { + fields.putIfAbsent(key, text.trim()); + } + } + + private Map linkedAction( + BookingContracts.RelatedHint hint, + String factId, + BookingContracts.ContextPackage contextPackage) { + Map value = new LinkedHashMap<>(); + value.put("hint_type", "ALLOTMENT_SOURCE".equals(hint.hintType()) + ? "ALLOTMENT_SOURCE_DEDUCTION" + : hint.hintType()); + value.putAll(hint.attributes()); + if ("ALLOTMENT_SOURCE".equals(hint.hintType())) { + allotmentRelationship(contextPackage, factId, stringValue(hint.attributes().get("relationship_id"))) + .forEach(value::putIfAbsent); + } + return value; + } + + /** + * old→new 团号关系来自当前邮件的确定性受限提取,而不是 Agent 猜测。Agent 只要明确选择了匹配 new group + * code 的 Update,就由本地把该关系补到候选卡上;多关系或不匹配时保持原输出,随后由 Validator 复核。 + */ + private BookingContracts.CandidateDecision enrichAgentDecisionWithGroupCodeReplacement( + BookingContracts.CandidateDecision decision, + BookingContracts.ParsedFactSet parsedFactSet) { + List enrichedTargets = new ArrayList<>(); + boolean changed = false; + for (BookingContracts.TargetDecision target : decision.targetDecisions()) { + Map replacement = uniqueGroupCodeReplacementForTarget(parsedFactSet, target); + if (replacement.isEmpty() || hasMatchingGroupCodeReplacement(target.linkedActions(), replacement)) { + enrichedTargets.add(target); + continue; + } + List> linkedActions = new ArrayList<>(target.linkedActions()); + Map derivedAction = new LinkedHashMap<>(); + derivedAction.put("hint_type", "GROUP_CODE_REPLACEMENT"); + derivedAction.putAll(replacement); + linkedActions.add(Map.copyOf(derivedAction)); + enrichedTargets.add(new BookingContracts.TargetDecision( + target.targetDecisionId(), + target.businessType(), + target.actionKind(), + target.targetIdentity(), + target.fields(), + linkedActions, + target.evidenceRefs(), + target.issues())); + changed = true; + } + if (!changed) { + return decision; + } + return new BookingContracts.CandidateDecision( + decision.contractMeta(), + decision.evidenceRefs(), + decision.decisionOrigin(), + decision.resultDisposition(), + enrichedTargets, + decision.messageNotifications(), + decision.agentTraceRef(), + decision.issues()); + } + + /** + * CandidateDecision 只对应一封 current 邮件。因此同团的多个 standalone Trace 在这里合并为一张卡;跨封 + * 不可能进入同一个 CandidateDecision,自然保持为新卡。每条原 Trace 信息保留在 linked actions,不能因 + * 合并丢失部门/服务线索。 + */ + private BookingContracts.CandidateDecision normalizeSameMessageTraceTargets( + BookingContracts.CandidateDecision decision) { + List normalized = new ArrayList<>(); + Map traceTargetIndexes = new LinkedHashMap<>(); + boolean changed = false; + for (BookingContracts.TargetDecision sourceTarget : decision.targetDecisions()) { + if (!"TRACE_RESERVATION_NOTES".equals(sourceTarget.businessType())) { + normalized.add(sourceTarget); + continue; + } + BookingContracts.TargetDecision traceTarget = ensureTraceLinkedAction(sourceTarget); + changed |= traceTarget != sourceTarget; + String groupCode = targetGroupCode(traceTarget); + if (groupCode == null) { + normalized.add(traceTarget); + continue; + } + String key = groupCode.toUpperCase(java.util.Locale.ROOT); + Integer existingIndex = traceTargetIndexes.get(key); + if (existingIndex == null) { + traceTargetIndexes.put(key, normalized.size()); + normalized.add(traceTarget); + continue; + } + normalized.set(existingIndex, mergeTraceTargets(normalized.get(existingIndex), traceTarget)); + changed = true; + } + if (!changed) { + return decision; + } + return new BookingContracts.CandidateDecision( + decision.contractMeta(), + decision.evidenceRefs(), + decision.decisionOrigin(), + decision.resultDisposition(), + normalized, + decision.messageNotifications(), + decision.agentTraceRef(), + decision.issues()); + } + + private BookingContracts.TargetDecision ensureTraceLinkedAction(BookingContracts.TargetDecision target) { + boolean hasTraceAction = target.linkedActions().stream() + .anyMatch(action -> "TRACE_RESERVATION_NOTES".equals(action.get("hint_type"))); + if (hasTraceAction) { + return target; + } + Map action = new LinkedHashMap<>(); + action.put("hint_type", "TRACE_RESERVATION_NOTES"); + action.put("trace_item_id", target.targetDecisionId()); + copyIfPresent(action, target.fields(), "department_code"); + copyIfPresent(action, target.fields(), "department_codes"); + copyIfPresent(action, target.fields(), "item_type"); + copyIfPresent(action, target.fields(), "text"); + copyIfPresent(action, target.fields(), "target_room_type_code"); + copyIfPresent(action, target.fields(), "extra_bed_room_count"); + List> linkedActions = new ArrayList<>(target.linkedActions()); + linkedActions.add(action); + return new BookingContracts.TargetDecision( + target.targetDecisionId(), + target.businessType(), + target.actionKind(), + target.targetIdentity(), + target.fields(), + linkedActions, + target.evidenceRefs(), + target.issues()); + } + + private BookingContracts.TargetDecision mergeTraceTargets( + BookingContracts.TargetDecision first, + BookingContracts.TargetDecision second) { + Map fields = new LinkedHashMap<>(first.fields()); + second.fields().forEach((key, value) -> { + if (!fields.containsKey(key) || fields.get(key) == null) { + fields.put(key, value); + } + }); + mergeTraceDepartments(fields, second.fields()); + List mergedTargetIds = new ArrayList<>(); + appendMergedTraceTargetIds(mergedTargetIds, first.fields().get("merged_trace_target_ids")); + appendMergedTraceTargetIds(mergedTargetIds, second.fields().get("merged_trace_target_ids")); + if (!mergedTargetIds.contains(first.targetDecisionId())) { + mergedTargetIds.add(first.targetDecisionId()); + } + if (!mergedTargetIds.contains(second.targetDecisionId())) { + mergedTargetIds.add(second.targetDecisionId()); + } + fields.put("merged_trace_target_ids", List.copyOf(mergedTargetIds)); + List> linkedActions = new ArrayList<>(first.linkedActions()); + linkedActions.addAll(second.linkedActions()); + return new BookingContracts.TargetDecision( + first.targetDecisionId(), + first.businessType(), + first.actionKind(), + first.targetIdentity(), + fields, + linkedActions, + mergeEvidence(first.evidenceRefs(), second.evidenceRefs()), + mergeIssues(first.issues(), second.issues())); + } + + private void mergeTraceDepartments(Map targetFields, Map additionalFields) { + List departments = new ArrayList<>(); + appendDepartments(departments, targetFields.get("department_code")); + appendDepartments(departments, targetFields.get("department_codes")); + appendDepartments(departments, additionalFields.get("department_code")); + appendDepartments(departments, additionalFields.get("department_codes")); + if (departments.isEmpty()) { + return; + } + targetFields.remove("department_code"); + targetFields.put("department_codes", List.copyOf(departments)); + } + + private void appendDepartments(List target, Object source) { + if (source instanceof String value) { + for (String part : value.split("[,/|]")) { + appendDepartment(target, part); + } + return; + } + if (source instanceof List values) { + values.forEach(value -> appendDepartment(target, value)); + } + } + + private void appendDepartment(List target, Object value) { + String department = stringValue(value); + if (department != null && !target.contains(department.toUpperCase(java.util.Locale.ROOT))) { + target.add(department.toUpperCase(java.util.Locale.ROOT)); + } + } + + private void appendMergedTraceTargetIds(List target, Object value) { + if (value instanceof List values) { + for (Object item : values) { + String targetId = stringValue(item); + if (targetId != null && !target.contains(targetId)) { + target.add(targetId); + } + } + } + } + + private void copyIfPresent(Map target, Map source, String key) { + Object value = source.get(key); + if (value != null) { + target.put(key, value); + } + } + + private List mergeEvidence( + List first, + List second) { + Map result = new LinkedHashMap<>(); + first.forEach(reference -> result.putIfAbsent(reference.evidenceId(), reference)); + second.forEach(reference -> result.putIfAbsent(reference.evidenceId(), reference)); + return List.copyOf(result.values()); + } + + private List mergeIssues( + List first, + List second) { + List result = new ArrayList<>(first); + second.forEach(issue -> { + if (!result.contains(issue)) { + result.add(issue); + } + }); + return List.copyOf(result); + } + + private Map uniqueGroupCodeReplacementForTarget( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.TargetDecision target) { + if (!"UPDATE_BOOKING".equals(target.businessType())) { + return Map.of(); + } + String targetGroupCode = targetGroupCode(target); + if (targetGroupCode == null) { + return Map.of(); + } + List> matches = parsedFactSet.facts().stream() + .flatMap(fact -> fact.relatedHints().stream()) + .filter(hint -> "GROUP_CODE_REPLACEMENT".equals(hint.hintType())) + .map(BookingContracts.RelatedHint::attributes) + .filter(attributes -> targetGroupCode.equalsIgnoreCase(stringValue(attributes.get("new_group_code")))) + .>map(attributes -> new LinkedHashMap<>(attributes)) + .toList(); + return matches.size() == 1 ? matches.get(0) : Map.of(); + } + + private boolean hasMatchingGroupCodeReplacement( + List> linkedActions, + Map replacement) { + String relationshipId = stringValue(replacement.get("relationship_id")); + return linkedActions.stream().anyMatch(action -> "GROUP_CODE_REPLACEMENT".equals(action.get("hint_type")) + && (relationshipId == null || relationshipId.equals(stringValue(action.get("relationship_id"))))); + } + + private String targetGroupCode(BookingContracts.TargetDecision target) { + if (target.targetIdentity() != null && stringValue(target.targetIdentity().groupCode()) != null) { + return stringValue(target.targetIdentity().groupCode()); + } + return stringValue(target.fields().get("group_code")); + } + + @SuppressWarnings("unchecked") + private Map allotmentRelationship( + BookingContracts.ContextPackage contextPackage, + String factId, + String relationshipId) { + if (contextPackage == null || contextPackage.allotmentContext() == null) { + return Map.of(); + } + Object rawRelationships = contextPackage.allotmentContext().get("relationships"); + if (!(rawRelationships instanceof List relationships)) { + return Map.of(); + } + for (Object raw : relationships) { + if (!(raw instanceof Map map)) { + continue; + } + Object actualFactId = map.get("actual_fact_id"); + Object candidateRelationshipId = map.get("relationship_id"); + if (factId.equals(actualFactId) + && (relationshipId == null || relationshipId.equals(candidateRelationshipId))) { + Map result = new LinkedHashMap<>(); + map.forEach((key, value) -> { + if (key instanceof String stringKey) { + result.put(stringKey, value); + } + }); + return result; + } + } + return Map.of(); + } + + private String stringValue(Object value) { + return value instanceof String text && !text.isBlank() ? text.trim() : null; + } + + private String bookingType(BookingContracts.ParsedFact fact) { + return fact.bookingType(); + } + + private Object targetField(BookingContracts.ParsedFact fact, String fieldName) { + if (fact.targetIdentity() == null) { + return null; + } + return switch (fieldName) { + case "group_code" -> fact.targetIdentity().groupCode(); + case "group_name" -> fact.targetIdentity().groupName(); + case "tour_code" -> fact.targetIdentity().tourCode(); + default -> null; + }; + } + + private String actionKind(String actionHint) { + return switch (actionHint) { + case "NEW_BOOKING" -> "CREATE"; + case "UPDATE_BOOKING" -> "AMEND"; + case "CANCEL_BOOKING" -> "CANCEL"; + default -> "REVIEW"; + }; + } + + private BookingContracts.ContractIssue issue( + String code, + List evidenceRefs) { + return new BookingContracts.ContractIssue( + code, + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + "booking.decision." + code.toLowerCase(), + evidenceRefs, + false); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImpl.java new file mode 100644 index 0000000..99bb23b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImpl.java @@ -0,0 +1,751 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionValidator; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** contracts-v1 第一版确定性校验器。它只产生确认阻断和 Risk,不创建最终任务。 */ +@Component +public class BookingDecisionValidatorImpl implements BookingDecisionValidator { + + /** 校验版本、证据和第一版业务必填项;每个目标独立返回状态。 */ + @Override + public BookingContracts.ValidationResult validate( + BookingContracts.CandidateDecision candidateDecision, + BookingContracts.ContextPackage contextPackage) { + List schemaChecks = new ArrayList<>(); + List businessChecks = new ArrayList<>(); + List blockers = new ArrayList<>(); + if (!BookingContracts.CONTRACT_VERSION.equals(candidateDecision.contractMeta().contractVersion())) { + blockers.add(issue( + "UNSUPPORTED_CONTRACT_VERSION", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + candidateDecision.evidenceRefs())); + schemaChecks.add(check("CONTRACT_VERSION", BookingContracts.ValidationStatus.REJECTED, blockers)); + } else { + schemaChecks.add(check("CONTRACT_VERSION", BookingContracts.ValidationStatus.VALID, List.of())); + } + if (!samePipelineMeta(candidateDecision.contractMeta(), contextPackage.contractMeta())) { + BookingContracts.ContractIssue metaBlocker = issue( + "PIPELINE_VERSION_OR_SOURCE_MISMATCH", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + candidateDecision.evidenceRefs()); + blockers.add(metaBlocker); + schemaChecks.add(check("PIPELINE_META", BookingContracts.ValidationStatus.REJECTED, List.of(metaBlocker))); + } else { + schemaChecks.add(check("PIPELINE_META", BookingContracts.ValidationStatus.VALID, List.of())); + } + if (candidateDecision.evidenceRefs().isEmpty()) { + BookingContracts.ContractIssue evidenceBlocker = issue( + "EVIDENCE_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + List.of()); + blockers.add(evidenceBlocker); + schemaChecks.add(check("EVIDENCE_REFERENCE", BookingContracts.ValidationStatus.REJECTED, List.of(evidenceBlocker))); + } else { + schemaChecks.add(check("EVIDENCE_REFERENCE", BookingContracts.ValidationStatus.VALID, List.of())); + } + + List validatedTargets = new ArrayList<>(); + for (BookingContracts.TargetDecision target : candidateDecision.targetDecisions()) { + List targetBlockers = targetBlockers(target, contextPackage); + BookingContracts.ValidationStatus status = targetBlockers.isEmpty() + ? BookingContracts.ValidationStatus.VALID + : BookingContracts.ValidationStatus.REVIEW_REQUIRED; + validatedTargets.add(new BookingContracts.ValidatedTarget( + target.targetDecisionId(), + status, + List.of(check("TARGET_REQUIRED_FIELDS", status, targetBlockers)), + targetBlockers)); + businessChecks.add(check("TARGET_" + target.targetDecisionId(), status, targetBlockers)); + blockers.addAll(targetBlockers); + } + businessChecks.addAll(allotmentChecks(candidateDecision, validatedTargets)); + + if (candidateDecision.resultDisposition() == BookingContracts.ResultDisposition.IN_SCOPE_TASKS + && candidateDecision.targetDecisions().isEmpty()) { + BookingContracts.ContractIssue issue = issue( + "IN_SCOPE_TARGET_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + candidateDecision.evidenceRefs()); + blockers.add(issue); + businessChecks.add(check("IN_SCOPE_TARGET", BookingContracts.ValidationStatus.REJECTED, List.of(issue))); + } + + long riskNotificationCount = candidateDecision.messageNotifications().stream() + .filter(notification -> notification.disposition() == BookingContracts.ResultDisposition.RISK_NOTIFICATION) + .count(); + if (riskNotificationCount > 1) { + BookingContracts.ContractIssue issue = issue( + "RISK_NOTIFICATION_LIMIT_EXCEEDED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + candidateDecision.evidenceRefs()); + blockers.add(issue); + businessChecks.add(check("RISK_NOTIFICATION_LIMIT", BookingContracts.ValidationStatus.REVIEW_REQUIRED, List.of(issue))); + } + + BookingContracts.MessageNotification riskNotification = candidateDecision.messageNotifications().stream() + .filter(notification -> notification.disposition() == BookingContracts.ResultDisposition.RISK_NOTIFICATION) + .findFirst() + .orElse(null); + BookingContracts.ValidationStatus overallStatus = overallStatus( + candidateDecision, + validatedTargets, + blockers, + riskNotification); + return new BookingContracts.ValidationResult( + candidateDecision.contractMeta(), + candidateDecision.evidenceRefs(), + overallStatus, + validatedTargets, + schemaChecks, + businessChecks, + blockers, + riskNotification); + } + + private List targetBlockers( + BookingContracts.TargetDecision target, + BookingContracts.ContextPackage contextPackage) { + List blockers = new ArrayList<>(); + Map fields = target.fields(); + if ("NEW_BOOKING".equals(target.businessType())) { + requireValue(blockers, target, fields, "booking_type", "BOOKING_TYPE_REQUIRED"); + requireNewBookingIdentity(blockers, target, fields); + requireValue(blockers, target, fields, "arrival_date", "ARRIVAL_DATE_REQUIRED"); + requireValue(blockers, target, fields, "departure_date", "DEPARTURE_DATE_REQUIRED"); + requireNonEmptyList(blockers, target, fields, "room_items", "ROOM_ITEM_REQUIRED"); + // 当前 Rate Code 主数据尚未配置,必须留给用户选择,不能从价格样本猜测。 + requireValue(blockers, target, fields, "rate_code", "RATE_CODE_REQUIRED"); + requireValidStayRange(blockers, target, fields); + requireValidRoomItems(blockers, target, fields); + if ("GROUP".equals(fields.get("booking_type"))) { + requireValue(blockers, target, fields, "group_code", "GROUP_CODE_REQUIRED"); + } + requireSenderMappingResolution(blockers, target, contextPackage); + requireNoNewBookingLifecycleConflict(blockers, target, fields, contextPackage); + } + if ("UPDATE_BOOKING".equals(target.businessType()) || "CANCEL_BOOKING".equals(target.businessType())) { + requireTargetIdentity(blockers, target, fields); + requireSenderMappingResolution(blockers, target, contextPackage); + requireCurrentStateResolution(blockers, target, fields, contextPackage); + if ("UPDATE_BOOKING".equals(target.businessType())) { + requireGroupCodeReplacementConsistency(blockers, target, fields, contextPackage); + } + } + if ("TRACE_RESERVATION_NOTES".equals(target.businessType())) { + requireTargetIdentity(blockers, target, fields); + // Standalone Trace 应绑定到已有可识别目标;同封 New/Update 上的 linked Trace 走各自主目标校验。 + requireCurrentStateResolution(blockers, target, fields, contextPackage); + } + if ("ROOMING_LIST".equals(target.businessType())) { + requireTrue(blockers, target, fields, "attachment_present", "ROOMING_LIST_ATTACHMENT_REQUIRED"); + requireNonEmptyList(blockers, target, fields, "attachment_ids", "ROOMING_LIST_ATTACHMENT_REQUIRED"); + // 未能识别团号时仍保留这张通知卡,但不能在未补齐目标的情况下确认。 + requireTargetIdentity(blockers, target, fields); + } + if ("PAYMENT".equals(target.businessType())) { + requireTrue(blockers, target, fields, "attachment_present", "PAYMENT_EVIDENCE_REQUIRED"); + requireNonEmptyList(blockers, target, fields, "attachment_ids", "PAYMENT_EVIDENCE_REQUIRED"); + // 付款凭证不核对到账、不改订单状态;此处只保证它绑定到当前邮件材料和目标线索。 + requireTargetIdentity(blockers, target, fields); + } + if ("ALLOTMENT".equals(target.businessType())) { + blockers.add(issue( + "ALLOTMENT_RELATION_MUST_BE_LINKED_TO_ACTUAL", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + } + if (containsTrace(target)) { + requireTraceDepartmentSelection(blockers, target); + } + for (Map linkedAction : target.linkedActions()) { + if (isAllotmentSourceDeduction(linkedAction)) { + requireAllotmentActualShape(blockers, target, fields, linkedAction); + } + } + return List.copyOf(blockers); + } + + private boolean containsTrace(BookingContracts.TargetDecision target) { + return "TRACE_RESERVATION_NOTES".equals(target.businessType()) + || target.linkedActions().stream() + .anyMatch(action -> "TRACE_RESERVATION_NOTES".equals(action.get("hint_type"))); + } + + /** Trace 最终确认至少选择 FO 或 HSK;一条合并卡可选择两个部门,不能把未知部门静默当成有效。 */ + private void requireTraceDepartmentSelection( + List blockers, + BookingContracts.TargetDecision target) { + TraceDepartmentSelection selection = traceDepartmentSelection(target); + if (selection.departments().isEmpty()) { + blockers.add(issue( + selection.invalidValuePresent() ? "TRACE_DEPARTMENT_INVALID" : "TRACE_DEPARTMENT_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.FIELD, + target.evidenceRefs())); + } + } + + private TraceDepartmentSelection traceDepartmentSelection(BookingContracts.TargetDecision target) { + java.util.LinkedHashSet departments = new java.util.LinkedHashSet<>(); + boolean invalid = appendTraceDepartments(departments, target.fields().get("department_code"), false); + invalid |= appendTraceDepartments(departments, target.fields().get("department_codes"), false); + for (Map action : target.linkedActions()) { + if (!"TRACE_RESERVATION_NOTES".equals(action.get("hint_type"))) { + continue; + } + invalid |= appendTraceDepartments(departments, action.get("department_code"), false); + invalid |= appendTraceDepartments(departments, action.get("department_codes"), false); + } + return new TraceDepartmentSelection(List.copyOf(departments), invalid); + } + + private boolean appendTraceDepartments( + java.util.Set target, + Object value, + boolean invalid) { + if (value instanceof String text) { + for (String part : text.split("[,/|]")) { + String department = stringValue(part); + if (department == null) { + continue; + } + String normalized = department.toUpperCase(java.util.Locale.ROOT); + if ("FO".equals(normalized) || "HSK".equals(normalized)) { + target.add(normalized); + } else { + invalid = true; + } + } + return invalid; + } + if (value instanceof List values) { + for (Object item : values) { + invalid |= appendTraceDepartments(target, item, invalid); + } + } + return invalid; + } + + /** + * Allotment source 关系不是第 N+1 张可确认卡:每个 actual 仍先按 New Booking 准入。 + * 仅用所有已 VALID actual 汇总 source 扣减;source 不存在/不足只产生提示,绝不让安全 actual 回滚。 + */ + private List allotmentChecks( + BookingContracts.CandidateDecision candidateDecision, + List validatedTargets) { + Map validationByTarget = validatedTargets.stream() + .collect(java.util.stream.Collectors.toMap( + BookingContracts.ValidatedTarget::targetDecisionId, + value -> value, + (first, ignored) -> first, + java.util.LinkedHashMap::new)); + Map> actualsByRelationship = new java.util.LinkedHashMap<>(); + for (BookingContracts.TargetDecision target : candidateDecision.targetDecisions()) { + for (Map linkedAction : target.linkedActions()) { + if (!isAllotmentSourceDeduction(linkedAction)) { + continue; + } + String relationshipId = stringValue(linkedAction.get("relationship_id")); + if (relationshipId == null) { + relationshipId = "allotment-source-" + stringValue(linkedAction.get("source_group_code")); + } + actualsByRelationship.computeIfAbsent(relationshipId, ignored -> new ArrayList<>()) + .add(new AllotmentActual(target, linkedAction, validationByTarget.get(target.targetDecisionId()))); + } + } + List checks = new ArrayList<>(); + for (Map.Entry> entry : actualsByRelationship.entrySet()) { + List safeActuals = entry.getValue(); + List eligibleActuals = safeActuals.stream() + .filter(actual -> actual.validation() != null + && actual.validation().status() == BookingContracts.ValidationStatus.VALID) + .toList(); + if (eligibleActuals.isEmpty()) { + checks.add(check("ALLOTMENT_" + entry.getKey(), BookingContracts.ValidationStatus.VALID, List.of())); + continue; + } + Map source = safeActuals.get(0).linkedAction(); + List issues = allotmentSourceIssues( + source, + eligibleActuals, + safeActuals.get(0).target().evidenceRefs()); + checks.add(check( + "ALLOTMENT_" + entry.getKey(), + BookingContracts.ValidationStatus.VALID, + issues)); + } + return List.copyOf(checks); + } + + private List allotmentSourceIssues( + Map source, + List eligibleActuals, + List evidenceRefs) { + String sourceIssueCode = stringValue(source.get("source_issue_code")); + if (!Boolean.TRUE.equals(source.get("source_exists"))) { + return List.of(issue( + sourceIssueCode == null ? "ALLOTMENT_SOURCE_NOT_FOUND" : sourceIssueCode, + BookingContracts.IssueSeverity.WARNING, + BookingContracts.IssueScope.TARGET, + evidenceRefs)); + } + if (!Boolean.TRUE.equals(source.get("source_active"))) { + return List.of(issue( + sourceIssueCode == null ? "ALLOTMENT_SOURCE_UNAVAILABLE" : sourceIssueCode, + BookingContracts.IssueSeverity.WARNING, + BookingContracts.IssueScope.TARGET, + evidenceRefs)); + } + Map sourceOld = BookingAllotmentSupport.roomCounts(source.get("source_old_room_items")); + if (sourceOld.isEmpty()) { + return List.of(issue( + sourceIssueCode == null ? "ALLOTMENT_SOURCE_ROOM_ITEMS_UNAVAILABLE" : sourceIssueCode, + BookingContracts.IssueSeverity.WARNING, + BookingContracts.IssueScope.TARGET, + evidenceRefs)); + } + Map actualDeduct = new java.util.LinkedHashMap<>(); + for (AllotmentActual actual : eligibleActuals) { + BookingAllotmentSupport.roomCounts(actual.target().fields().get("room_items")) + .forEach((roomType, count) -> actualDeduct.merge(roomType, count, Integer::sum)); + } + BookingAllotmentSupport.DeductionSummary summary = BookingAllotmentSupport.deduction(sourceOld, actualDeduct); + return summary.sufficient() + ? List.of() + : List.of(issue( + "ALLOTMENT_SOURCE_INSUFFICIENT", + BookingContracts.IssueSeverity.WARNING, + BookingContracts.IssueScope.TARGET, + evidenceRefs)); + } + + private void requireAllotmentActualShape( + List blockers, + BookingContracts.TargetDecision target, + Map fields, + Map linkedAction) { + if (!"NEW_BOOKING".equals(target.businessType())) { + blockers.add(issue( + "ALLOTMENT_ACTUAL_MUST_BE_NEW_BOOKING", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + } + String sourceGroupCode = stringValue(linkedAction.get("source_group_code")); + if (sourceGroupCode == null) { + blockers.add(issue( + "ALLOTMENT_SOURCE_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.FIELD, + target.evidenceRefs())); + return; + } + String actualGroupCode = stringValue(fields.get("group_code")); + if (actualGroupCode != null && sourceGroupCode.equalsIgnoreCase(actualGroupCode)) { + blockers.add(issue( + "ALLOTMENT_SOURCE_EQUALS_ACTUAL", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.FIELD, + target.evidenceRefs())); + } + } + + private boolean isAllotmentSourceDeduction(Map linkedAction) { + Object hintType = linkedAction.get("hint_type"); + return "ALLOTMENT_SOURCE_DEDUCTION".equals(hintType) || "ALLOTMENT_SOURCE".equals(hintType); + } + + private void requireCurrentStateResolution( + List blockers, + BookingContracts.TargetDecision target, + Map fields, + BookingContracts.ContextPackage contextPackage) { + String reference = stringValue(fields.get("group_code")); + if (reference == null) { + reference = target.targetDecisionId(); + } + String finalReference = reference; + BookingContracts.TargetResolution resolution = contextPackage.targetResolutions().stream() + .filter(candidate -> finalReference.equals(candidate.targetReference())) + .findFirst() + .orElse(null); + if (resolution == null || "UNRESOLVED".equals(resolution.resolutionStatus())) { + blockers.add(issue( + "TARGET_CURRENT_STATE_UNRESOLVED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + return; + } + if ("CONFLICT".equals(resolution.resolutionStatus())) { + addContextIssues(blockers, resolution, target); + if (resolution.issues().isEmpty()) { + blockers.add(issue( + "TARGET_LIFECYCLE_CONFLICT", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + } + } + } + + private void requireNoNewBookingLifecycleConflict( + List blockers, + BookingContracts.TargetDecision target, + Map fields, + BookingContracts.ContextPackage contextPackage) { + String groupCode = stringValue(fields.get("group_code")); + if (groupCode == null) { + return; + } + contextPackage.targetResolutions().stream() + .filter(resolution -> groupCode.equals(resolution.targetReference())) + .filter(resolution -> "CONFLICT".equals(resolution.resolutionStatus())) + .forEach(resolution -> addContextIssues(blockers, resolution, target)); + } + + /** + * 改团号只能来自 Context 已确认的 current-mail old→new relation。普通 Update 不要求该 linked action; + * 但一旦目标正是 relation 的 new group code,Agent/调用方就不能省略、伪造或改写来源团号。 + */ + private void requireGroupCodeReplacementConsistency( + List blockers, + BookingContracts.TargetDecision target, + Map fields, + BookingContracts.ContextPackage contextPackage) { + String resolvedTargetGroupCode = stringValue(fields.get("group_code")); + if (resolvedTargetGroupCode == null && target.targetIdentity() != null) { + resolvedTargetGroupCode = stringValue(target.targetIdentity().groupCode()); + } + final String targetGroupCode = resolvedTargetGroupCode; + List> relations = groupCodeReplacements(contextPackage); + List> matchingRelations = relations.stream() + .filter(relation -> targetGroupCode != null + && targetGroupCode.equalsIgnoreCase(stringValue(relation.get("new_group_code")))) + .toList(); + List> replacementActions = target.linkedActions().stream() + .filter(action -> "GROUP_CODE_REPLACEMENT".equals(action.get("hint_type"))) + .toList(); + if (matchingRelations.isEmpty()) { + if (!replacementActions.isEmpty()) { + blockers.add(issue( + "GROUP_CODE_REPLACEMENT_CONTEXT_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + } + return; + } + if (matchingRelations.size() != 1 || replacementActions.size() != 1) { + blockers.add(issue( + matchingRelations.size() != 1 + ? "GROUP_CODE_REPLACEMENT_CONTEXT_AMBIGUOUS" + : "GROUP_CODE_REPLACEMENT_LINK_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + return; + } + Map relation = matchingRelations.get(0); + Map action = replacementActions.get(0); + boolean matches = sameText(relation.get("relationship_id"), action.get("relationship_id")) + && sameText(relation.get("old_group_code"), action.get("old_group_code")) + && sameText(relation.get("new_group_code"), action.get("new_group_code")) + && Boolean.TRUE.equals(action.get("source_instruction_present")); + if (!matches) { + blockers.add(issue( + "GROUP_CODE_REPLACEMENT_LINK_MISMATCH", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.FIELD, + target.evidenceRefs())); + } + } + + private List> groupCodeReplacements(BookingContracts.ContextPackage contextPackage) { + Object raw = contextPackage.lifecycleContext().get("group_code_replacements"); + if (!(raw instanceof List values)) { + return List.of(); + } + List> result = new ArrayList<>(); + for (Object value : values) { + if (!(value instanceof Map map)) { + continue; + } + Map converted = new java.util.LinkedHashMap<>(); + map.forEach((key, nestedValue) -> { + if (key instanceof String textKey) { + converted.put(textKey, nestedValue); + } + }); + result.add(Map.copyOf(converted)); + } + return List.copyOf(result); + } + + private boolean sameText(Object first, Object second) { + String firstValue = stringValue(first); + String secondValue = stringValue(second); + return firstValue != null && secondValue != null && firstValue.equalsIgnoreCase(secondValue); + } + + /** 零/多 sender 主数据候选必须停留在复核,不能把邮件方向或 Account/Market/Source 猜成默认值。 */ + private void requireSenderMappingResolution( + List blockers, + BookingContracts.TargetDecision target, + BookingContracts.ContextPackage contextPackage) { + Object rawMasterData = contextPackage.currentState().get("message_master_data"); + if (!(rawMasterData instanceof Map masterData) + || !(masterData.get("sender_mapping") instanceof Map senderMapping)) { + return; + } + Object rawStatus = senderMapping.get("status"); + String status = rawStatus instanceof String text ? text : null; + String issueCode = switch (status == null ? "" : status) { + case "MISSING" -> "SENDER_MAPPING_UNRESOLVED"; + case "AMBIGUOUS" -> "SENDER_MAPPING_AMBIGUOUS"; + case "SENDER_UNAVAILABLE" -> "SENDER_IDENTIFIER_UNAVAILABLE"; + default -> null; + }; + if (issueCode != null) { + blockers.add(issue( + issueCode, + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + } + } + + private void addContextIssues( + List blockers, + BookingContracts.TargetResolution resolution, + BookingContracts.TargetDecision target) { + if (resolution.issues().isEmpty()) { + return; + } + for (BookingContracts.ContractIssue issue : resolution.issues()) { + blockers.add(new BookingContracts.ContractIssue( + issue.code(), + issue.severity(), + issue.scope(), + issue.messageKey(), + target.evidenceRefs(), + issue.retryable())); + } + } + + private void requireValidStayRange( + List blockers, + BookingContracts.TargetDecision target, + Map fields) { + String arrival = stringValue(fields.get("arrival_date")); + String departure = stringValue(fields.get("departure_date")); + if (arrival == null || departure == null) { + return; + } + try { + if (!java.time.LocalDate.parse(departure).isAfter(java.time.LocalDate.parse(arrival))) { + blockers.add(issue( + "STAY_DATE_RANGE_INVALID", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.FIELD, + target.evidenceRefs())); + } + } catch (RuntimeException exception) { + blockers.add(issue( + "STAY_DATE_FORMAT_INVALID", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.FIELD, + target.evidenceRefs())); + } + } + + private void requireValidRoomItems( + List blockers, + BookingContracts.TargetDecision target, + Map fields) { + Object value = fields.get("room_items"); + if (!(value instanceof List roomItems) || roomItems.isEmpty()) { + return; + } + boolean invalid = roomItems.stream().anyMatch(item -> { + if (item instanceof BookingContracts.RoomItemFact room) { + return !hasValue(room.roomTypeCodeHint()) || room.roomCount() == null || room.roomCount() <= 0; + } + if (item instanceof Map map) { + Object roomType = map.containsKey("room_type_code_hint") + ? map.get("room_type_code_hint") + : map.get("roomTypeCodeHint"); + Object roomCount = map.containsKey("room_count") ? map.get("room_count") : map.get("roomCount"); + return !hasValue(roomType) || !(roomCount instanceof Number number) || number.intValue() <= 0; + } + return true; + }); + if (invalid) { + blockers.add(issue( + "ROOM_ITEM_VALUE_INVALID", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.FIELD, + target.evidenceRefs())); + } + } + + private void requireTargetIdentity( + List blockers, + BookingContracts.TargetDecision target, + Map fields) { + if (hasValue(fields.get("group_code")) || hasValue(fields.get("tour_code")) || hasNonEmptyList(fields.get("names"))) { + return; + } + blockers.add(issue( + "TARGET_IDENTITY_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + } + + /** + * 固定渠道的 Tour Code / Name of Group / Group Name / Group Code 统一为团号, + * 因此新预订可由明确 group_code 标识;没有团号时才允许以姓名列表作为身份线索。 + */ + private void requireNewBookingIdentity( + List blockers, + BookingContracts.TargetDecision target, + Map fields) { + if (hasValue(fields.get("group_code")) + || hasValue(fields.get("tour_code")) + || hasNonEmptyList(fields.get("names"))) { + return; + } + blockers.add(issue( + "TARGET_IDENTITY_REQUIRED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + target.evidenceRefs())); + } + + private void requireValue( + List blockers, + BookingContracts.TargetDecision target, + Map fields, + String fieldName, + String code) { + if (!hasValue(fields.get(fieldName))) { + blockers.add(issue(code, BookingContracts.IssueSeverity.BLOCKER, BookingContracts.IssueScope.FIELD, target.evidenceRefs())); + } + } + + private void requireNonEmptyList( + List blockers, + BookingContracts.TargetDecision target, + Map fields, + String fieldName, + String code) { + if (!hasNonEmptyList(fields.get(fieldName))) { + blockers.add(issue(code, BookingContracts.IssueSeverity.BLOCKER, BookingContracts.IssueScope.FIELD, target.evidenceRefs())); + } + } + + private void requireTrue( + List blockers, + BookingContracts.TargetDecision target, + Map fields, + String fieldName, + String code) { + if (!Boolean.TRUE.equals(fields.get(fieldName))) { + blockers.add(issue(code, BookingContracts.IssueSeverity.BLOCKER, BookingContracts.IssueScope.FIELD, target.evidenceRefs())); + } + } + + private BookingContracts.ValidationStatus overallStatus( + BookingContracts.CandidateDecision candidateDecision, + List validatedTargets, + List blockers, + BookingContracts.MessageNotification riskNotification) { + if (candidateDecision.resultDisposition() == BookingContracts.ResultDisposition.RISK_NOTIFICATION) { + return BookingContracts.ValidationStatus.RISK; + } + if (blockers.stream().anyMatch(blocker -> blocker.code().equals("UNSUPPORTED_CONTRACT_VERSION") + || blocker.code().equals("EVIDENCE_REQUIRED") + || blocker.code().equals("IN_SCOPE_TARGET_REQUIRED") + || blocker.code().equals("PIPELINE_VERSION_OR_SOURCE_MISMATCH"))) { + return BookingContracts.ValidationStatus.REJECTED; + } + if (validatedTargets.stream().anyMatch(target -> target.status() == BookingContracts.ValidationStatus.REVIEW_REQUIRED)) { + return BookingContracts.ValidationStatus.REVIEW_REQUIRED; + } + // Risk 可以只隔离同封的一个不明确片段。只要还有清晰目标,消息级 Risk 不得把它们降级为不可确认。 + // 当没有任何可继续目标时,Risk 仍是整封消息的最终校验结果。 + if (riskNotification != null && validatedTargets.isEmpty()) { + return BookingContracts.ValidationStatus.RISK; + } + return BookingContracts.ValidationStatus.VALID; + } + + private BookingContracts.ValidationCheck check( + String code, + BookingContracts.ValidationStatus status, + List issues) { + return new BookingContracts.ValidationCheck(code, status, issues); + } + + private BookingContracts.ContractIssue issue( + String code, + BookingContracts.IssueSeverity severity, + BookingContracts.IssueScope scope, + List evidenceRefs) { + return new BookingContracts.ContractIssue( + code, + severity, + scope, + "booking.validator." + code.toLowerCase(), + evidenceRefs, + false); + } + + private boolean hasValue(Object value) { + return value != null && (!(value instanceof String text) || !text.isBlank()); + } + + private String stringValue(Object value) { + return value instanceof String text && !text.isBlank() ? text.trim() : null; + } + + private boolean samePipelineMeta( + BookingContracts.ContractMeta candidateMeta, + BookingContracts.ContractMeta contextMeta) { + return candidateMeta != null + && contextMeta != null + && BookingContracts.CONTRACT_VERSION.equals(candidateMeta.contractVersion()) + && candidateMeta.processingRunId().equals(contextMeta.processingRunId()) + && candidateMeta.sourceMessageId().equals(contextMeta.sourceMessageId()) + && candidateMeta.sourceRevision() == contextMeta.sourceRevision() + && candidateMeta.catalogVersion().equals(contextMeta.catalogVersion()) + && candidateMeta.parserVersion().equals(contextMeta.parserVersion()); + } + + private boolean hasNonEmptyList(Object value) { + return value instanceof List values && !values.isEmpty(); + } + + private record AllotmentActual( + BookingContracts.TargetDecision target, + Map linkedAction, + BookingContracts.ValidatedTarget validation) { + } + + private record TraceDepartmentSelection( + List departments, + boolean invalidValuePresent) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImpl.java new file mode 100644 index 0000000..150ffce --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImpl.java @@ -0,0 +1,243 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageMasterData; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingRateCodeMappingRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingSenderMappingRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingMasterDataResolver; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * 主数据只做可证明的精确映射。 + * + *

sender 没有唯一候选时不会选择方向或业务代码;Rate Code 只有 profile、单一房型和单一价格线索全部 + * 精确命中一个 active mapping 时才写入。所有其他情况都保持待确认。

+ */ +@Service +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingMasterDataResolverImpl implements BookingMasterDataResolver { + + private final BookingSenderMappingRepository senderMappingRepository; + private final BookingRateCodeMappingRepository rateCodeMappingRepository; + private final ObjectMapper objectMapper; + + public BookingMasterDataResolverImpl( + BookingSenderMappingRepository senderMappingRepository, + BookingRateCodeMappingRepository rateCodeMappingRepository, + ObjectMapper objectMapper) { + this.senderMappingRepository = senderMappingRepository; + this.rateCodeMappingRepository = rateCodeMappingRepository; + this.objectMapper = objectMapper; + } + + @Override + public BookingMessageMasterData resolve( + String senderSummary, + BookingContracts.ParsedFactSet parsedFactSet) { + if (parsedFactSet == null) { + throw new IllegalArgumentException("parsedFactSet 不能为空。"); + } + Set warnings = new LinkedHashSet<>(); + SenderResolution sender = resolveSender( + BookingSenderNormalizer.normalize(senderSummary), + parsedFactSet.contractMeta().catalogVersion(), + warnings); + Map rateCodes = resolveRateCodes(parsedFactSet, warnings); + return new BookingMessageMasterData( + sender.status(), + sender.direction(), + sender.accountCode(), + sender.marketCode(), + sender.sourceCode(), + rateCodes, + List.copyOf(warnings)); + } + + private SenderResolution resolveSender( + String normalizedSender, + String catalogVersion, + Set warnings) { + if (normalizedSender == null) { + warnings.add("SENDER_IDENTIFIER_UNAVAILABLE"); + return SenderResolution.unresolved(BookingMessageMasterData.SenderMappingStatus.SENDER_UNAVAILABLE); + } + List mappings = + senderMappingRepository.findActiveExact(normalizedSender, catalogVersion); + if (mappings.isEmpty()) { + warnings.add("SENDER_MAPPING_UNRESOLVED"); + return SenderResolution.unresolved(BookingMessageMasterData.SenderMappingStatus.MISSING); + } + if (mappings.size() != 1) { + warnings.add("SENDER_MAPPING_AMBIGUOUS"); + return SenderResolution.unresolved(BookingMessageMasterData.SenderMappingStatus.AMBIGUOUS); + } + BookingSenderMappingRepository.SenderMapping mapping = mappings.get(0); + BookingMessageMasterData.MessageDirection direction = direction(mapping.processingDirection()); + if (direction == BookingMessageMasterData.MessageDirection.UNKNOWN) { + warnings.add("SENDER_DIRECTION_INVALID"); + } + return new SenderResolution( + BookingMessageMasterData.SenderMappingStatus.RESOLVED, + direction, + mapping.accountCode(), + mapping.marketCode(), + mapping.sourceCode()); + } + + private Map resolveRateCodes( + BookingContracts.ParsedFactSet parsedFactSet, + Set warnings) { + List mappings = rateCodeMappingRepository + .findActiveForCatalog(parsedFactSet.contractMeta().catalogVersion()); + Map result = new LinkedHashMap<>(); + for (BookingContracts.ParsedFact fact : parsedFactSet.facts()) { + if (!"NEW_BOOKING".equals(fact.actionHint())) { + result.put(fact.factId(), new BookingMessageMasterData.RateCodeResolution( + BookingMessageMasterData.RateCodeResolutionStatus.NOT_APPLICABLE, + null)); + continue; + } + RateMatchInput input = rateMatchInput(parsedFactSet.channelProfileCode(), fact); + if (input == null) { + result.put(fact.factId(), new BookingMessageMasterData.RateCodeResolution( + BookingMessageMasterData.RateCodeResolutionStatus.NOT_APPLICABLE, + null)); + continue; + } + List exactMatches = new ArrayList<>(); + for (BookingRateCodeMappingRepository.RateCodeMapping mapping : mappings) { + try { + if (matchesExactly(objectMapper.readTree(mapping.matchPayloadJson()), input)) { + exactMatches.add(mapping); + } + } catch (RuntimeException | java.io.IOException exception) { + warnings.add("RATE_CODE_MAPPING_INVALID"); + } + } + if (exactMatches.size() == 1) { + result.put(fact.factId(), new BookingMessageMasterData.RateCodeResolution( + BookingMessageMasterData.RateCodeResolutionStatus.RESOLVED, + exactMatches.get(0).rateCode())); + } else if (exactMatches.isEmpty()) { + warnings.add("RATE_CODE_MAPPING_UNRESOLVED"); + result.put(fact.factId(), new BookingMessageMasterData.RateCodeResolution( + BookingMessageMasterData.RateCodeResolutionStatus.MISSING, + null)); + } else { + warnings.add("RATE_CODE_MAPPING_AMBIGUOUS"); + result.put(fact.factId(), new BookingMessageMasterData.RateCodeResolution( + BookingMessageMasterData.RateCodeResolutionStatus.AMBIGUOUS, + null)); + } + } + return result; + } + + private RateMatchInput rateMatchInput(String channelProfileCode, BookingContracts.ParsedFact fact) { + if (channelProfileCode == null || channelProfileCode.isBlank() || fact.stay() == null) { + return null; + } + Set roomTypes = uniqueValues(fact.stay().roomItems().stream() + .map(BookingContracts.RoomItemFact::roomTypeCodeHint) + .map(this::normalizedCode) + .toList()); + Set rateHints = uniqueValues(fact.stay().roomItems().stream() + .map(BookingContracts.RoomItemFact::rateHint) + .map(this::normalizedRateHint) + .toList()); + if (roomTypes.size() != 1 || rateHints.size() != 1) { + return null; + } + return new RateMatchInput( + channelProfileCode.trim(), + roomTypes.iterator().next(), + rateHints.iterator().next()); + } + + /** Mapping payload 必须恰好声明三项匹配条件,避免宽泛 profile 或房型规则偷偷猜测 Rate Code。 */ + private boolean matchesExactly(JsonNode payload, RateMatchInput input) { + if (payload == null || !payload.isObject() || payload.size() != 3) { + return false; + } + return exactText(payload, "channel_profile_code", input.channelProfileCode()) + && exactText(payload, "room_type_code", input.roomTypeCode()) + && exactRate(payload, "rate_hint", input.rateHint()); + } + + private boolean exactText(JsonNode payload, String fieldName, String expected) { + JsonNode value = payload.get(fieldName); + return value != null && value.isTextual() && expected.equals(value.asText().trim()); + } + + private boolean exactRate(JsonNode payload, String fieldName, String expected) { + JsonNode value = payload.get(fieldName); + return value != null && value.isTextual() && expected.equals(normalizedRateHint(value.asText())); + } + + private BookingMessageMasterData.MessageDirection direction(String value) { + if (value == null || value.isBlank()) { + return BookingMessageMasterData.MessageDirection.UNKNOWN; + } + try { + return BookingMessageMasterData.MessageDirection.valueOf(value.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return BookingMessageMasterData.MessageDirection.UNKNOWN; + } + } + + private Set uniqueValues(List values) { + Set result = new LinkedHashSet<>(); + for (String value : values) { + if (value != null && !value.isBlank()) { + result.add(value); + } + } + return result; + } + + private String normalizedCode(String value) { + return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT); + } + + private String normalizedRateHint(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return new BigDecimal(value.trim()).stripTrailingZeros().toPlainString(); + } catch (NumberFormatException exception) { + return null; + } + } + + private record SenderResolution( + BookingMessageMasterData.SenderMappingStatus status, + BookingMessageMasterData.MessageDirection direction, + String accountCode, + String marketCode, + String sourceCode) { + + private static SenderResolution unresolved(BookingMessageMasterData.SenderMappingStatus status) { + return new SenderResolution( + status, + BookingMessageMasterData.MessageDirection.UNKNOWN, + null, + null, + null); + } + } + + private record RateMatchInput(String channelProfileCode, String roomTypeCode, String rateHint) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestrator.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestrator.java new file mode 100644 index 0000000..651102c --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestrator.java @@ -0,0 +1,298 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageInput; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageMasterData; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingOrchestrationResult; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingProcessingRunSnapshot; +import cn.nianxx.thhotel.workflows.reservation.booking.config.BookingAgentProperties; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingProcessingRunRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingContextAssembler; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionService; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingMessageOrchestrator; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingMasterDataResolver; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * 新 Booking PostgreSQL 主线的统一入口编排器。 + * + *

AgentBus 与手工 EML 都必须在入口层转换为 {@link BookingMessageInput} 后调用本类。它只形成 + * contracts-v1 候选、校验和确认投影;不调用 PMS/Opera、不创建旧 V4 任务,也不把原始正文/附件持久化到 + * PostgreSQL。若需 Agent fallback,只持久化 policy 脱敏、限长的 current-message 摘要。

+ */ +@Service +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingPostgresMessageOrchestrator implements BookingMessageOrchestrator { + + private static final List BUSINESS_SIGNAL_KEYWORDS = List.of( + "BOOKING", + "AMEND", + "UPDATE", + "CANCEL", + "CXL", + "ROOMING", + "PAYMENT", + "VOUCHER", + "TRACE", + "ALLOTMENT", + "TOUR CODE", + "ยกเลิก", + "จอง", + "ห้อง"); + + private final BookingContractAssembler contractAssembler; + private final BookingProcessingRunRepository processingRunRepository; + private final BookingContextAssembler contextAssembler; + private final BookingMasterDataResolver masterDataResolver; + private final BookingDecisionService decisionService; + private final BookingDecisionPipeline decisionPipeline; + private final BookingAgentContinuationDispatcher agentContinuationDispatcher; + private final BookingAgentProperties bookingAgentProperties; + private final ObjectMapper objectMapper; + + public BookingPostgresMessageOrchestrator( + BookingContractAssembler contractAssembler, + BookingProcessingRunRepository processingRunRepository, + BookingContextAssembler contextAssembler, + BookingMasterDataResolver masterDataResolver, + BookingDecisionService decisionService, + BookingDecisionPipeline decisionPipeline, + BookingAgentContinuationDispatcher agentContinuationDispatcher, + BookingAgentProperties bookingAgentProperties, + ObjectMapper objectMapper) { + this.contractAssembler = contractAssembler; + this.processingRunRepository = processingRunRepository; + this.contextAssembler = contextAssembler; + this.masterDataResolver = masterDataResolver; + this.decisionService = decisionService; + this.decisionPipeline = decisionPipeline; + this.agentContinuationDispatcher = agentContinuationDispatcher; + this.bookingAgentProperties = bookingAgentProperties; + this.objectMapper = objectMapper; + } + + /** + * 按已冻结的状态机执行统一入口:确定性路径同步完成;已启用的真实 Booking Agent 在 Context 后交给 + * worker,入口立即返回 202/run ID。 + */ + @Override + public BookingOrchestrationResult process(BookingMessageInput input) { + String provisionalRunId = "booking-" + UUID.randomUUID(); + String sourceFingerprint = contractAssembler.sourceFingerprint(input); + BookingContracts.SourceMessageEnvelope provisionalEnvelope = + contractAssembler.sourceMessageEnvelope(input, provisionalRunId, 0L); + BookingProcessingRunSnapshot started = processingRunRepository.startOrReuse( + provisionalRunId, + input.hotelId(), + provisionalEnvelope, + sourceFingerprint); + if (!started.created()) { + return resultForExisting(started); + } + + String runId = started.runId(); + boolean decisionPipelineStarted = false; + try { + long sourceRevision = Math.max(0L, started.revisionNumber() - 1L); + BookingContracts.SourceMessageEnvelope envelope = + contractAssembler.sourceMessageEnvelope(input, runId, sourceRevision); + persist(runId, "SOURCE_MESSAGE_ENVELOPE", envelope, envelope.evidenceRefs()); + + BookingContracts.MaterialPackage materialPackage = contractAssembler.materialPackage(input, envelope); + persist(runId, "MATERIAL_PACKAGE", materialPackage, materialPackage.evidenceRefs()); + processingRunRepository.updateRunStatus( + runId, + BookingContracts.ProcessingStatus.MATERIAL_READY, + null, + null, + null); + + BookingContracts.ParsedFactSet parsedFactSet = contractAssembler.parsedFactSet(input, materialPackage); + persist(runId, "PARSED_FACT_SET", parsedFactSet, parsedFactSet.evidenceRefs()); + processingRunRepository.saveParsedFacts( + runId, + parsedFactSet.facts(), + parsedFactSet.facts().stream().map(this::json).toList()); + processingRunRepository.updateRunStatus( + runId, + parserStatus(parsedFactSet.parserApplicability()), + null, + null, + null); + + BookingMessageMasterData masterData = masterDataResolver.resolve(envelope.senderSummary(), parsedFactSet); + BookingContracts.ContextPackage contextPackage = withMasterData( + contextAssembler.assemble(input.hotelId(), parsedFactSet), + masterData); + persist(runId, "CONTEXT_PACKAGE", contextPackage, contextPackage.evidenceRefs()); + processingRunRepository.updateRunStatus( + runId, + BookingContracts.ProcessingStatus.CONTEXT_READY, + null, + null, + null); + + if (masterData.shouldIgnore()) { + decisionPipelineStarted = true; + BookingDecisionPipeline.BookingPipelineCompletion completion = decisionPipeline.completeIgnoredDirection( + runId, + parsedFactSet, + contextPackage, + masterData.messageDirection()); + return new BookingOrchestrationResult( + runId, + false, + false, + completion.processingStatus(), + completion.resultDisposition(), + mergeWarningCodes(masterData.warningCodes(), completion.warningCodes())); + } + + boolean businessSignalPresent = containsBusinessSignal(input); + boolean attachmentPresent = !input.attachments().isEmpty(); + if (shouldDispatchAgentAsynchronously(parsedFactSet)) { + agentContinuationDispatcher.completeAsync( + runId, + parsedFactSet, + contextPackage, + businessSignalPresent, + attachmentPresent); + return new BookingOrchestrationResult( + runId, + false, + true, + BookingContracts.ProcessingStatus.CONTEXT_READY, + null, + mergeWarningCodes(masterData.warningCodes(), decisionPipeline.preDecisionWarningCodes(parsedFactSet))); + } + decisionPipelineStarted = true; + BookingDecisionPipeline.BookingPipelineCompletion completion = decisionPipeline.complete( + runId, + parsedFactSet, + contextPackage, + businessSignalPresent, + attachmentPresent); + return new BookingOrchestrationResult( + runId, + false, + false, + completion.processingStatus(), + completion.resultDisposition(), + mergeWarningCodes(masterData.warningCodes(), completion.warningCodes())); + } catch (RuntimeException exception) { + if (!decisionPipelineStarted) { + decisionPipeline.markFailed(runId); + } + throw exception; + } + } + + private BookingOrchestrationResult resultForExisting(BookingProcessingRunSnapshot snapshot) { + return new BookingOrchestrationResult( + snapshot.runId(), + true, + isProcessingAsynchronously(snapshot.status()), + snapshot.status(), + snapshot.resultDisposition(), + snapshot.errorCode() == null ? List.of("DUPLICATE_DELIVERY") : List.of("DUPLICATE_DELIVERY", snapshot.errorCode())); + } + + private boolean shouldDispatchAgentAsynchronously(BookingContracts.ParsedFactSet parsedFactSet) { + return bookingAgentProperties.isEnabled() + && bookingAgentProperties.isAsyncEnabled() + && decisionService.requiresAgentFallback(parsedFactSet); + } + + /** 把不含 sender 原文的 master-data 片段并入 Context,供本地 Decision 与受控 Agent 共同消费。 */ + private BookingContracts.ContextPackage withMasterData( + BookingContracts.ContextPackage contextPackage, + BookingMessageMasterData masterData) { + Map currentState = new LinkedHashMap<>(contextPackage.currentState()); + currentState.put("message_master_data", masterData.contextValue()); + return new BookingContracts.ContextPackage( + contextPackage.contractMeta(), + contextPackage.evidenceRefs(), + contextPackage.contextScope(), + contextPackage.targetResolutions(), + currentState, + contextPackage.conversationContext(), + contextPackage.lifecycleContext(), + contextPackage.allotmentContext(), + contextPackage.issues()); + } + + private List mergeWarningCodes(List first, List second) { + java.util.LinkedHashSet result = new java.util.LinkedHashSet<>(); + if (first != null) { + result.addAll(first); + } + if (second != null) { + result.addAll(second); + } + return List.copyOf(result); + } + + private boolean isProcessingAsynchronously(BookingContracts.ProcessingStatus status) { + return switch (status) { + case RECEIVED, MATERIAL_READY, PARSER_COMPLETE, PARSER_NOT_APPLICABLE, + PARSER_FAILED, CONTEXT_READY, AGENT_COMPLETE -> true; + case VALIDATED, AWAITING_CONFIRMATION, CONFIRMED, FAILED -> false; + }; + } + + private BookingContracts.ProcessingStatus parserStatus(BookingContracts.ParserApplicability applicability) { + return switch (applicability) { + case APPLICABLE -> BookingContracts.ProcessingStatus.PARSER_COMPLETE; + case NOT_APPLICABLE -> BookingContracts.ProcessingStatus.PARSER_NOT_APPLICABLE; + case FAILED, AMBIGUOUS -> BookingContracts.ProcessingStatus.PARSER_FAILED; + }; + } + + private void persist( + String runId, + String artifactType, + Object artifact, + List evidenceRefs) { + String payload = json(artifact); + processingRunRepository.saveArtifact(runId, artifactType, payload, sha256(payload), evidenceRefs); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Booking contracts-v1 JSON 序列化失败。", exception); + } + } + + private boolean containsBusinessSignal(BookingMessageInput input) { + String source = String.join("\n", + input.safeSubject() == null ? "" : input.safeSubject(), + input.currentBody() == null ? "" : input.currentBody()) + .toUpperCase(Locale.ROOT); + return BUSINESS_SIGNAL_KEYWORDS.stream() + .map(keyword -> keyword.toUpperCase(Locale.ROOT)) + .anyMatch(source::contains); + } + + private String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(source.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresRetentionService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresRetentionService.java new file mode 100644 index 0000000..7a42a7f --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresRetentionService.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 三个月保留期清理器。删除只从项目 schema 的来源根记录开始,级联范围由 PostgreSQL FK 限定。 */ +@Service +@ConditionalOnProperty(prefix = "booking.postgres", name = {"enabled", "retention-enabled"}, havingValue = "true") +public class BookingPostgresRetentionService { + + private static final Logger log = LoggerFactory.getLogger(BookingPostgresRetentionService.class); + + private final JdbcTemplate jdbcTemplate; + + public BookingPostgresRetentionService( + @Qualifier("bookingPostgresJdbcTemplate") JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + /** 只删除本项目到期的 SourceMessage 根记录;不会触碰其他 schema、主数据或旧 MySQL。 */ + @Scheduled(cron = "${booking.postgres.retention-cron:0 15 3 * * *}") + @Transactional(transactionManager = "bookingPostgresTransactionManager") + public void purgeExpired() { + int deleted = jdbcTemplate.update( + "DELETE FROM th_hotel_booking.booking_contract_source_message " + + "WHERE retention_until < CURRENT_TIMESTAMP"); + if (deleted > 0) { + log.info("Purged {} expired booking contracts-v1 source messages from project schema.", deleted); + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingProcessingRunServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingProcessingRunServiceImpl.java new file mode 100644 index 0000000..84c970e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingProcessingRunServiceImpl.java @@ -0,0 +1,404 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingProcessingRunSnapshot; +import cn.nianxx.thhotel.workflows.reservation.booking.common.request.BookingConfirmationItemRequest; +import cn.nianxx.thhotel.workflows.reservation.booking.common.request.BookingConfirmationRequest; +import cn.nianxx.thhotel.workflows.reservation.booking.common.result.BookingConfirmationResult; +import cn.nianxx.thhotel.workflows.reservation.booking.common.result.BookingProcessingRunView; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingConfirmationActionDraft; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingProcessingRunRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingConfirmationProjectionFactory; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionValidator; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingProcessingRunService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +/** contracts-v1 processing run 的安全查询与确认实现。 */ +@Service +@ConditionalOnProperty(prefix = "booking.postgres", name = "enabled", havingValue = "true") +public class BookingProcessingRunServiceImpl implements BookingProcessingRunService { + + private final BookingProcessingRunRepository processingRunRepository; + private final BookingDecisionValidator decisionValidator; + private final BookingConfirmationProjectionFactory projectionFactory; + private final ObjectMapper objectMapper; + + public BookingProcessingRunServiceImpl( + BookingProcessingRunRepository processingRunRepository, + BookingDecisionValidator decisionValidator, + BookingConfirmationProjectionFactory projectionFactory, + ObjectMapper objectMapper) { + this.processingRunRepository = processingRunRepository; + this.decisionValidator = decisionValidator; + this.projectionFactory = projectionFactory; + this.objectMapper = objectMapper; + } + + /** 查询 run 的安全状态和 ConfirmationProjection。 */ + @Override + public BookingProcessingRunView get(String runId) { + BookingProcessingRunSnapshot snapshot = snapshot(runId); + return view(snapshot, confirmationProjection(runId)); + } + + /** + * 只允许确认当前 projection 中全部 in-scope 项。用户修正会重新走 Validator,只有全量有效后才冻结为 + * CONFIRMED;本方法不调用 PMS/Opera。 + */ + @Override + public BookingConfirmationResult confirm( + String runId, + BookingConfirmationRequest request, + String actorId) { + BookingProcessingRunSnapshot before = snapshot(runId); + if (before.status() == BookingContracts.ProcessingStatus.CONFIRMED) { + return new BookingConfirmationResult(true, view(before, confirmationProjection(runId))); + } + if (before.status() != BookingContracts.ProcessingStatus.AWAITING_CONFIRMATION) { + throw workflowError( + HttpStatus.CONFLICT, + "BOOKING_PROCESSING_RUN_NOT_CONFIRMABLE", + "当前邮件尚未进入可确认状态。"); + } + BookingContracts.ConfirmationProjection projection = confirmationProjection(runId); + if (projection == null || projection.projectionStatus() == BookingContracts.ConfirmationStatus.NOT_AVAILABLE) { + throw workflowError( + HttpStatus.UNPROCESSABLE_ENTITY, + "BOOKING_CONFIRMATION_NOT_AVAILABLE", + "当前邮件没有可确认的 Booking 候选。"); + } + Map requestedItems = requestedItems(request); + Map expectedItems = expectedItems(projection); + if (!requestedItems.keySet().equals(expectedItems.keySet())) { + throw workflowError( + HttpStatus.UNPROCESSABLE_ENTITY, + "BOOKING_CONFIRMATION_ITEMS_INCOMPLETE", + "请确认当前邮件中的全部可确认项目。", + List.of("expected=" + expectedItems.size(), "submitted=" + requestedItems.size())); + } + + BookingContracts.CandidateDecision candidateDecision = artifact( + runId, + "CANDIDATE_DECISION", + BookingContracts.CandidateDecision.class); + BookingContracts.ContextPackage contextPackage = artifact( + runId, + "CONTEXT_PACKAGE", + BookingContracts.ContextPackage.class); + BookingContracts.CandidateDecision correctedDecision = applyCorrections( + candidateDecision, + expectedItems, + requestedItems); + BookingContracts.ValidationResult validationResult = + decisionValidator.validate(correctedDecision, contextPackage); + BookingContracts.ConfirmationProjection refreshedProjection = + projectionFactory.project(correctedDecision, validationResult); + + persist(runId, "CANDIDATE_DECISION", correctedDecision, correctedDecision.evidenceRefs()); + persist(runId, "VALIDATION_RESULT", validationResult, validationResult.evidenceRefs()); + processingRunRepository.saveCandidateTargets( + runId, + correctedDecision, + contextPackage, + validationResult, + correctedDecision.targetDecisions().stream().map(this::json).toList()); + persist(runId, "CONFIRMATION_PROJECTION", refreshedProjection, refreshedProjection.evidenceRefs()); + + if (validationResult.validationStatus() != BookingContracts.ValidationStatus.VALID) { + return new BookingConfirmationResult(false, view(snapshot(runId), refreshedProjection)); + } + + List actions = new ArrayList<>(); + String actionType = projection.projectionStatus() == BookingContracts.ConfirmationStatus.REVIEW_REQUIRED + ? "RESOLVE_REVIEW" + : "CONFIRM"; + for (Map.Entry entry : requestedItems.entrySet()) { + BookingContracts.ConfirmationItem item = expectedItems.get(entry.getKey()); + actions.add(new BookingConfirmationActionDraft( + item.confirmationItemId(), + item.targetDecisionId(), + actionType, + json(entry.getValue().fieldOverrides()), + actorId)); + } + processingRunRepository.confirmRun(runId, actions); + return new BookingConfirmationResult(true, view(snapshot(runId), refreshedProjection)); + } + + private BookingContracts.CandidateDecision applyCorrections( + BookingContracts.CandidateDecision candidateDecision, + Map expectedItems, + Map requestedItems) { + String correctionJson = json(requestedItems.values().stream().toList()); + BookingContracts.EvidenceReference userCorrectionEvidence = new BookingContracts.EvidenceReference( + "user-correction-" + sha256(correctionJson).substring(0, 24), + BookingContracts.EvidenceKind.USER_CORRECTION, + candidateDecision.contractMeta().sourceMessageId(), + "confirmation:" + candidateDecision.contractMeta().processingRunId(), + sha256(correctionJson), + false); + Map requestsByTarget = new LinkedHashMap<>(); + for (Map.Entry entry : requestedItems.entrySet()) { + BookingContracts.ConfirmationItem item = expectedItems.get(entry.getKey()); + requestsByTarget.put(item.targetDecisionId(), entry.getValue()); + } + List correctedTargets = new ArrayList<>(); + for (BookingContracts.TargetDecision target : candidateDecision.targetDecisions()) { + BookingConfirmationItemRequest request = requestsByTarget.get(target.targetDecisionId()); + if (request == null) { + correctedTargets.add(target); + continue; + } + BookingContracts.ConfirmationItem item = expectedItems.values().stream() + .filter(value -> target.targetDecisionId().equals(value.targetDecisionId())) + .findFirst() + .orElseThrow(() -> workflowError( + HttpStatus.UNPROCESSABLE_ENTITY, + "BOOKING_CONFIRMATION_TARGET_UNKNOWN", + "确认目标不属于当前邮件。")); + validateOverrides(item, request.fieldOverrides()); + Map fields = new LinkedHashMap<>(target.fields()); + List> linkedActions = mutableLinkedActions(target.linkedActions(), request.fieldOverrides()); + for (Map.Entry override : request.fieldOverrides().entrySet()) { + if (!"department_code".equals(override.getKey())) { + fields.put(override.getKey(), override.getValue()); + } + } + List evidenceRefs = new ArrayList<>(target.evidenceRefs()); + evidenceRefs.add(userCorrectionEvidence); + correctedTargets.add(new BookingContracts.TargetDecision( + target.targetDecisionId(), + target.businessType(), + target.actionKind(), + correctedTargetIdentity(target.targetIdentity(), fields, evidenceRefs), + fields, + linkedActions, + evidenceRefs, + target.issues())); + } + List evidenceRefs = new ArrayList<>(candidateDecision.evidenceRefs()); + evidenceRefs.add(userCorrectionEvidence); + return new BookingContracts.CandidateDecision( + candidateDecision.contractMeta(), + evidenceRefs, + candidateDecision.decisionOrigin(), + candidateDecision.resultDisposition(), + correctedTargets, + candidateDecision.messageNotifications(), + candidateDecision.agentTraceRef(), + candidateDecision.issues()); + } + + private BookingContracts.TargetIdentity correctedTargetIdentity( + BookingContracts.TargetIdentity original, + Map fields, + List evidenceRefs) { + List names = stringList(fields.get("names")); + return new BookingContracts.TargetIdentity( + stringValue(fields.get("group_code"), original == null ? null : original.groupCode()), + stringValue(fields.get("group_name"), original == null ? null : original.groupName()), + stringValue(fields.get("tour_code"), original == null ? null : original.tourCode()), + names.isEmpty() && original != null ? original.nameValues() : names, + evidenceRefs); + } + + private String stringValue(Object value, String fallback) { + return value instanceof String text && !text.isBlank() ? text.trim() : fallback; + } + + private List stringList(Object value) { + if (!(value instanceof List values)) { + return List.of(); + } + return values.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .map(String::trim) + .filter(text -> !text.isBlank()) + .toList(); + } + + private List> mutableLinkedActions( + List> source, + Map overrides) { + List> result = new ArrayList<>(); + for (Map linkedAction : source) { + Map copied = new LinkedHashMap<>(linkedAction); + if ("TRACE_RESERVATION_NOTES".equals(copied.get("hint_type")) + && overrides.containsKey("department_code")) { + copied.put("department_code", overrides.get("department_code")); + } + result.add(copied); + } + return List.copyOf(result); + } + + private void validateOverrides( + BookingContracts.ConfirmationItem item, + Map overrides) { + Map fields = new LinkedHashMap<>(); + item.displayFields().forEach(field -> fields.put(field.fieldCode(), field)); + item.blockedFields().forEach(field -> fields.put(field.fieldCode(), field)); + for (Map.Entry override : overrides.entrySet()) { + BookingContracts.ConfirmationField field = fields.get(override.getKey()); + if (field == null || !field.editable()) { + throw workflowError( + HttpStatus.UNPROCESSABLE_ENTITY, + "BOOKING_CONFIRMATION_FIELD_NOT_EDITABLE", + "提交了不可修改的 Booking 字段。", + List.of(override.getKey())); + } + if (!field.allowedValues().isEmpty() + && (!(override.getValue() instanceof String value) || !field.allowedValues().contains(value))) { + throw workflowError( + HttpStatus.UNPROCESSABLE_ENTITY, + "BOOKING_CONFIRMATION_FIELD_VALUE_INVALID", + "提交了不支持的 Booking 字段值。", + List.of(override.getKey())); + } + } + } + + private Map requestedItems(BookingConfirmationRequest request) { + if (request == null || request.items().isEmpty()) { + throw workflowError( + HttpStatus.BAD_REQUEST, + "BOOKING_CONFIRMATION_ITEMS_REQUIRED", + "请至少提交一个确认项目。"); + } + Map result = new LinkedHashMap<>(); + for (BookingConfirmationItemRequest item : request.items()) { + if (result.putIfAbsent(item.confirmationItemId(), item) != null) { + throw workflowError( + HttpStatus.BAD_REQUEST, + "BOOKING_CONFIRMATION_ITEM_DUPLICATED", + "同一确认项目不能重复提交。"); + } + } + return Collections.unmodifiableMap(result); + } + + private Map expectedItems( + BookingContracts.ConfirmationProjection projection) { + Map result = new LinkedHashMap<>(); + for (BookingContracts.ConfirmationItem item : projection.confirmationItems()) { + if (item.disposition() == BookingContracts.ResultDisposition.IN_SCOPE_TASKS) { + result.put(item.confirmationItemId(), item); + } + } + return Collections.unmodifiableMap(result); + } + + private BookingContracts.ConfirmationProjection confirmationProjection(String runId) { + String payload = processingRunRepository.findArtifactPayload(runId, "CONFIRMATION_PROJECTION").orElse(null); + return payload == null ? null : read(payload, BookingContracts.ConfirmationProjection.class); + } + + private T artifact(String runId, String artifactType, Class type) { + String payload = processingRunRepository.findArtifactPayload(runId, artifactType) + .orElseThrow(() -> workflowError( + HttpStatus.CONFLICT, + "BOOKING_CONTRACT_ARTIFACT_MISSING", + "当前邮件缺少可确认的处理产物。", + List.of(artifactType))); + return read(payload, type); + } + + private T read(String payload, Class type) { + try { + return objectMapper.readValue(payload, type); + } catch (JsonProcessingException exception) { + throw workflowError( + HttpStatus.INTERNAL_SERVER_ERROR, + "BOOKING_CONTRACT_ARTIFACT_INVALID", + "Booking 处理产物无法读取。"); + } + } + + private BookingProcessingRunSnapshot snapshot(String runId) { + if (runId == null || runId.isBlank()) { + throw workflowError(HttpStatus.BAD_REQUEST, "BOOKING_PROCESSING_RUN_ID_REQUIRED", "processing_run_id 不能为空。"); + } + return processingRunRepository.findByRunId(runId) + .orElseThrow(() -> workflowError( + HttpStatus.NOT_FOUND, + "BOOKING_PROCESSING_RUN_NOT_FOUND", + "未找到指定的 Booking processing run。")); + } + + private BookingProcessingRunView view( + BookingProcessingRunSnapshot snapshot, + BookingContracts.ConfirmationProjection projection) { + return new BookingProcessingRunView( + snapshot.runId(), + snapshot.sourceMessageId(), + snapshot.revisionNumber(), + snapshot.status().name(), + snapshot.resultDisposition() == null ? null : snapshot.resultDisposition().name(), + snapshot.created(), + snapshot.retryCount(), + snapshot.errorCode(), + snapshot.errorSummary(), + snapshot.startedAt() == null ? null : snapshot.startedAt().toString(), + snapshot.completedAt() == null ? null : snapshot.completedAt().toString(), + projection); + } + + private void persist( + String runId, + String artifactType, + Object artifact, + List evidenceRefs) { + String payload = json(artifact); + processingRunRepository.saveArtifact(runId, artifactType, payload, sha256(payload), evidenceRefs); + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw workflowError( + HttpStatus.INTERNAL_SERVER_ERROR, + "BOOKING_CONTRACT_SERIALIZATION_FAILED", + "Booking 处理产物无法保存。"); + } + } + + private String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(source.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } + + private ReservationTaskWorkflowException workflowError( + HttpStatus status, + String code, + String message) { + return new ReservationTaskWorkflowException(status, code, message); + } + + private ReservationTaskWorkflowException workflowError( + HttpStatus status, + String code, + String message, + List details) { + return new ReservationTaskWorkflowException(status, code, message, details); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingRiskFallbackAgentDecisionService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingRiskFallbackAgentDecisionService.java new file mode 100644 index 0000000..6180ee9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingRiskFallbackAgentDecisionService.java @@ -0,0 +1,53 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingAgentDecisionService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 未配置真实 Booking Agent 时的 fail-closed 实现。 + * + *

它不把不确定材料伪造成业务任务,而是返回可见 Risk,等待 AgentBus Agent profile 接入。

+ */ +@Component +@ConditionalOnProperty(prefix = "booking.agent", name = "enabled", havingValue = "false", matchIfMissing = true) +public class BookingRiskFallbackAgentDecisionService implements BookingAgentDecisionService { + + /** 将 Agent 缺失转换为一条安全 Risk 候选。 */ + @Override + public BookingContracts.CandidateDecision decide( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage) { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + parsedFactSet.contractMeta().catalogVersion(), + parsedFactSet.contractMeta().parserVersion(), + "booking-agent-unconfigured-v1", + parsedFactSet.contractMeta().processingRunId(), + parsedFactSet.contractMeta().sourceMessageId(), + parsedFactSet.contractMeta().sourceRevision()); + BookingContracts.ContractIssue issue = new BookingContracts.ContractIssue( + "BOOKING_AGENT_NOT_CONFIGURED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + "booking.agent.notConfigured", + parsedFactSet.evidenceRefs(), + false); + BookingContracts.MessageNotification notification = new BookingContracts.MessageNotification( + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + "S99", + parsedFactSet.evidenceRefs(), + List.of(issue)); + return new BookingContracts.CandidateDecision( + meta, + parsedFactSet.evidenceRefs(), + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + List.of(), + List.of(notification), + "booking-agent-unconfigured-v1", + List.of(issue)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSenderNormalizer.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSenderNormalizer.java new file mode 100644 index 0000000..b7168f4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSenderNormalizer.java @@ -0,0 +1,31 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 将入口提供的 sender 降为可安全精确匹配的单一邮件标识。 */ +final class BookingSenderNormalizer { + + private static final Pattern EMAIL = Pattern.compile( + "(?i)[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,63}"); + + private BookingSenderNormalizer() { + } + + /** + * 只接受一个可识别的邮箱地址。多个地址、展示名或非法值均返回 null,交由后续人工/主数据复核, + * 不能把模糊 sender 误映射到渠道。 + */ + static String normalize(String value) { + if (value == null || value.isBlank()) { + return null; + } + Matcher matcher = EMAIL.matcher(value); + if (!matcher.find()) { + return null; + } + String normalized = matcher.group().toLowerCase(Locale.ROOT); + return matcher.find() || normalized.length() > 320 ? null : normalized; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionService.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionService.java new file mode 100644 index 0000000..01aeecf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionService.java @@ -0,0 +1,425 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentOpenApiMessageRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient; +import cn.nianxx.thhotel.workflows.reservation.booking.config.BookingAgentProperties; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingAgentDecisionService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * 真实 Booking Agent 的受控适配器。 + * + *

它只发送 {@code ParsedFactSet + ContextPackage},不发送原始邮件、附件 URL 或数据库凭据;Agent 的 + * 输出必须是 contracts-v1 CandidateDecision,随后仍由本地 Validator 决定是否可以确认。

+ */ +@Service +@ConditionalOnProperty(prefix = "booking.agent", name = "enabled", havingValue = "true") +public class BookingSuperAgentDecisionService implements BookingAgentDecisionService { + + private static final Object DROPPED = new Object(); + private static final Pattern SENSITIVE_CONTEXT_KEY = Pattern.compile( + "(?i)(?:^|[_-])(raw|html|body|sender|recipient|email|url|uri|bytes|password|secret|token|credential|quoted)(?:$|[_-])"); + private static final Pattern EXTERNAL_REFERENCE = Pattern.compile("(?i)^(?:https?|s3|oss)://"); + + private static final Set ALLOWED_BUSINESS_TYPES = Set.of( + "NEW_BOOKING", + "UPDATE_BOOKING", + "CANCEL_BOOKING", + "TRACE_RESERVATION_NOTES", + "ROOMING_LIST", + "PAYMENT", + "ALLOTMENT"); + + private final SuperAgentOpenApiClient superAgentOpenApiClient; + private final BookingAgentProperties properties; + private final ObjectMapper objectMapper; + + public BookingSuperAgentDecisionService( + SuperAgentOpenApiClient superAgentOpenApiClient, + BookingAgentProperties properties, + ObjectMapper objectMapper) { + this.superAgentOpenApiClient = superAgentOpenApiClient; + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** 调用已部署的 Booking Agent profile;任何传输或契约错误都 fail-closed 为 Risk。 */ + @Override + public BookingContracts.CandidateDecision decide( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage) { + try { + properties.validateEnabledConfiguration(); + String requestPayload = objectMapper.writeValueAsString(new BookingAgentInput( + safeParsedFactSet(parsedFactSet), + safeContextPackage(contextPackage))); + SuperAgentOpenApiResult result = superAgentOpenApiClient.invokeMessage(new SuperAgentOpenApiMessageRequest( + requestMessage(requestPayload), + "booking-agent-" + parsedFactSet.contractMeta().processingRunId(), + properties.getExternalSubjectId(), + parsedFactSet.contractMeta().processingRunId(), + java.util.Map.of( + "contract_version", BookingContracts.CONTRACT_VERSION, + "processing_run_id", parsedFactSet.contractMeta().processingRunId(), + "source_message_id", parsedFactSet.contractMeta().sourceMessageId(), + "agent_profile_version", properties.getProfileVersion()))); + BookingContracts.CandidateDecision raw = objectMapper.readValue( + result.rawAnswer(), + BookingContracts.CandidateDecision.class); + return normalize(raw, parsedFactSet, contextPackage, result); + } catch (RuntimeException | JsonProcessingException exception) { + return risk(parsedFactSet, "BOOKING_AGENT_CALL_OR_OUTPUT_FAILED", null); + } + } + + private BookingContracts.CandidateDecision normalize( + BookingContracts.CandidateDecision raw, + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage, + SuperAgentOpenApiResult result) { + if (raw == null || raw.resultDisposition() == null + || raw.targetDecisions().stream().anyMatch(target -> !ALLOWED_BUSINESS_TYPES.contains(target.businessType()))) { + return risk(parsedFactSet, "BOOKING_AGENT_OUTPUT_INVALID", null); + } + List allowedEvidence = mergedEvidence(parsedFactSet, contextPackage); + Set allowedEvidenceIds = allowedEvidence.stream() + .map(BookingContracts.EvidenceReference::evidenceId) + .collect(java.util.stream.Collectors.toSet()); + if (!candidateReferencesAllowed(raw, allowedEvidenceIds)) { + return risk(parsedFactSet, "BOOKING_AGENT_EVIDENCE_INVALID", null); + } + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + parsedFactSet.contractMeta().catalogVersion(), + parsedFactSet.contractMeta().parserVersion(), + profileVersion(result), + parsedFactSet.contractMeta().processingRunId(), + parsedFactSet.contractMeta().sourceMessageId(), + parsedFactSet.contractMeta().sourceRevision()); + List targets = raw.targetDecisions().stream() + .map(target -> new BookingContracts.TargetDecision( + target.targetDecisionId(), + target.businessType(), + target.actionKind(), + target.targetIdentity(), + target.fields(), + target.linkedActions(), + target.evidenceRefs().isEmpty() ? allowedEvidence : target.evidenceRefs(), + target.issues())) + .toList(); + return new BookingContracts.CandidateDecision( + meta, + raw.evidenceRefs().isEmpty() ? allowedEvidence : raw.evidenceRefs(), + BookingContracts.DecisionOrigin.AGENT, + raw.resultDisposition(), + targets, + raw.messageNotifications(), + traceReference(result), + raw.issues()); + } + + private BookingContracts.CandidateDecision risk( + BookingContracts.ParsedFactSet parsedFactSet, + String code, + String traceReference) { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + parsedFactSet.contractMeta().catalogVersion(), + parsedFactSet.contractMeta().parserVersion(), + configuredProfileVersion(), + parsedFactSet.contractMeta().processingRunId(), + parsedFactSet.contractMeta().sourceMessageId(), + parsedFactSet.contractMeta().sourceRevision()); + BookingContracts.ContractIssue issue = new BookingContracts.ContractIssue( + code, + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.MESSAGE, + "booking.agent." + code.toLowerCase(), + parsedFactSet.evidenceRefs(), + true); + BookingContracts.MessageNotification notification = new BookingContracts.MessageNotification( + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + "S99", + parsedFactSet.evidenceRefs(), + List.of(issue)); + return new BookingContracts.CandidateDecision( + meta, + parsedFactSet.evidenceRefs(), + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + List.of(), + List.of(notification), + traceReference, + List.of(issue)); + } + + private String requestMessage(String payload) { + return "You are the Booking Agent. Return one JSON CandidateDecision only, matching booking-contracts-v1. " + + "Use only the supplied ParsedFactSet and ContextPackage. agent_input_material, if present, is the only " + + "approved current-message excerpt; do not fetch or request raw email, quoted history, attachments, URLs, " + + "or sender data. Do not call APIs, create tasks, infer unknown values, or include secrets, reasoning, or markdown. " + + "If uncertain, return RISK_NOTIFICATION with notification_code S99.\n" + + payload; + } + + /** + * 纵深防御:即使未来某个 Context 组装器误放入正文、sender、URL 或二进制线索,Agent adapter 也只发送 + * contracts-v1 所允许的安全投影。证据 ID/哈希保留,供 Agent 回填时引用,但不暴露原始材料定位。 + */ + private BookingContracts.ParsedFactSet safeParsedFactSet(BookingContracts.ParsedFactSet source) { + List facts = source.facts().stream() + .map(fact -> new BookingContracts.ParsedFact( + fact.factId(), + fact.actionHint(), + fact.bookingType(), + safeTargetIdentity(fact.targetIdentity()), + safeStay(fact.stay()), + fact.relatedHints().stream() + .map(hint -> new BookingContracts.RelatedHint( + hint.hintType(), + sanitizeMap(hint.attributes()), + safeEvidence(hint.evidenceRefs()))) + .toList(), + safeEvidence(fact.evidenceRefs()))) + .toList(); + return new BookingContracts.ParsedFactSet( + source.contractMeta(), + safeEvidence(source.evidenceRefs()), + source.parserApplicability(), + source.channelProfileCode(), + facts, + source.unresolvedFields().stream() + .map(field -> new BookingContracts.UnresolvedField( + field.fieldPath(), + field.reasonCode(), + safeEvidence(field.evidenceRefs()))) + .toList(), + source.fallbackReasons(), + safeIssues(source.issues()), + BookingAgentInputMaterialPolicy.safeForAgent(source.agentInputMaterial(), source.evidenceRefs())); + } + + private BookingContracts.ContextPackage safeContextPackage(BookingContracts.ContextPackage source) { + return new BookingContracts.ContextPackage( + source.contractMeta(), + safeEvidence(source.evidenceRefs()), + source.contextScope(), + source.targetResolutions().stream() + .map(resolution -> new BookingContracts.TargetResolution( + resolution.targetReference(), + resolution.resolutionStatus(), + resolution.candidateTargetIds(), + safeEvidence(resolution.evidenceRefs()), + safeIssues(resolution.issues()))) + .toList(), + sanitizeMap(source.currentState()), + sanitizeMap(source.conversationContext()), + sanitizeMap(source.lifecycleContext()), + sanitizeMap(source.allotmentContext()), + safeIssues(source.issues())); + } + + private BookingContracts.TargetIdentity safeTargetIdentity(BookingContracts.TargetIdentity source) { + if (source == null) { + return null; + } + return new BookingContracts.TargetIdentity( + source.groupCode(), + source.groupName(), + source.tourCode(), + source.nameValues(), + safeEvidence(source.evidenceRefs())); + } + + private BookingContracts.StayFact safeStay(BookingContracts.StayFact source) { + if (source == null) { + return null; + } + return new BookingContracts.StayFact( + source.arrivalDate(), + source.departureDate(), + source.nights(), + source.roomItems().stream() + .map(room -> new BookingContracts.RoomItemFact( + room.roomTypeCodeHint(), + room.roomCount(), + room.rateHint(), + safeEvidence(room.evidenceRefs()))) + .toList(), + safeEvidence(source.priceEvidenceRefs())); + } + + private List safeIssues(List source) { + return source.stream() + .map(issue -> new BookingContracts.ContractIssue( + issue.code(), + issue.severity(), + issue.scope(), + issue.messageKey(), + safeEvidence(issue.evidenceRefs()), + issue.retryable())) + .toList(); + } + + private List safeEvidence(List source) { + return source.stream() + .map(reference -> new BookingContracts.EvidenceReference( + reference.evidenceId(), + reference.kind(), + reference.sourceMessageId(), + externalReference(reference.locator()) + ? "redacted-evidence:" + reference.evidenceId() + : reference.locator(), + reference.contentHash(), + reference.currentMaterial())) + .toList(); + } + + private Map sanitizeMap(Map source) { + Map result = new LinkedHashMap<>(); + if (source == null) { + return result; + } + for (Map.Entry entry : source.entrySet()) { + if (entry.getKey() == null || SENSITIVE_CONTEXT_KEY.matcher(entry.getKey()).find()) { + continue; + } + Object value = sanitizeValue(entry.getKey(), entry.getValue()); + if (value != DROPPED) { + result.put(entry.getKey(), value); + } + } + return result; + } + + private Object sanitizeValue(String key, Object value) { + if (value instanceof String text) { + return externalReference(text) ? DROPPED : text; + } + if (value instanceof Map map) { + Map nested = new LinkedHashMap<>(); + map.forEach((nestedKey, nestedValue) -> { + if (nestedKey instanceof String textKey + && !SENSITIVE_CONTEXT_KEY.matcher(textKey).find()) { + Object safeValue = sanitizeValue(textKey, nestedValue); + if (safeValue != DROPPED) { + nested.put(textKey, safeValue); + } + } + }); + return nested; + } + if (value instanceof List values) { + List nested = new ArrayList<>(); + for (Object item : values) { + Object safeValue = sanitizeValue(key, item); + if (safeValue != DROPPED) { + nested.add(safeValue); + } + } + return List.copyOf(nested); + } + return value; + } + + private boolean externalReference(String value) { + return value != null && EXTERNAL_REFERENCE.matcher(value.trim()).find(); + } + + private List mergedEvidence( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage) { + List result = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (BookingContracts.EvidenceReference evidence : parsedFactSet.evidenceRefs()) { + if (seen.add(evidence.evidenceId())) { + result.add(evidence); + } + } + for (BookingContracts.EvidenceReference evidence : contextPackage.evidenceRefs()) { + if (seen.add(evidence.evidenceId())) { + result.add(evidence); + } + } + return List.copyOf(result); + } + + private boolean referencesOnlyAllowed( + List references, + Set allowedEvidenceIds) { + return references == null || references.stream() + .allMatch(reference -> allowedEvidenceIds.contains(reference.evidenceId())); + } + + private boolean candidateReferencesAllowed( + BookingContracts.CandidateDecision candidateDecision, + Set allowedEvidenceIds) { + if (!referencesOnlyAllowed(candidateDecision.evidenceRefs(), allowedEvidenceIds) + || candidateDecision.messageNotifications().stream() + .anyMatch(notification -> !referencesOnlyAllowed(notification.evidenceRefs(), allowedEvidenceIds)) + || candidateDecision.issues().stream() + .anyMatch(issue -> !referencesOnlyAllowed(issue.evidenceRefs(), allowedEvidenceIds))) { + return false; + } + for (BookingContracts.TargetDecision target : candidateDecision.targetDecisions()) { + if (!referencesOnlyAllowed(target.evidenceRefs(), allowedEvidenceIds) + || target.targetIdentity() != null + && !referencesOnlyAllowed(target.targetIdentity().evidenceRefs(), allowedEvidenceIds) + || target.issues().stream() + .anyMatch(issue -> !referencesOnlyAllowed(issue.evidenceRefs(), allowedEvidenceIds))) { + return false; + } + } + return true; + } + + private String profileVersion(SuperAgentOpenApiResult result) { + return result.profileVersionId() == null || result.profileVersionId().isBlank() + ? configuredProfileVersion() + : result.profileVersionId().trim(); + } + + private String configuredProfileVersion() { + return properties.getProfileVersion() == null || properties.getProfileVersion().isBlank() + ? "booking-agent-config-invalid" + : properties.getProfileVersion().trim(); + } + + private String traceReference(SuperAgentOpenApiResult result) { + String runId = result.runId(); + return runId == null || runId.isBlank() ? null : "superagent-run-" + sha256(runId).substring(0, 24); + } + + private String sha256(String source) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(source.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 运行时不支持 SHA-256。", exception); + } + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + private record BookingAgentInput( + BookingContracts.ParsedFactSet parsedFactSet, + BookingContracts.ContextPackage contextPackage) { + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContracts.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContracts.java new file mode 100644 index 0000000..9898c12 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContracts.java @@ -0,0 +1,776 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto.booking; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Booking 邮件主线 contracts-v1 的程序内数据定义。 + * + *

该类只承载跨层稳定输入输出,不承载 Parser、Agent、Context 或持久化的实现细节。各层必须保留同一份 + * {@link ContractMeta} 与证据引用,禁止用旧 V4 payload、原始邮件或数据库 Entity 作为跨层接口。

+ */ +public final class BookingContracts { + + /** 当前稳定契约版本。 */ + public static final String CONTRACT_VERSION = "booking-contracts-v1"; + /** 尚未应用 Catalog 时必须使用的显式版本标记。 */ + public static final String CATALOG_NOT_APPLIED = "catalog-not-applied"; + /** 尚未运行 Parser 时必须使用的显式版本标记。 */ + public static final String PARSER_NOT_RUN = "parser-not-run"; + /** 尚未调用 Booking Agent 时必须使用的显式版本标记。 */ + public static final String AGENT_NOT_INVOKED = "agent-not-invoked"; + + private BookingContracts() { + } + + /** 处理运行状态,终点是用户确认,不包含 PMS/Opera 执行。 */ + public enum ProcessingStatus { + RECEIVED, + MATERIAL_READY, + PARSER_COMPLETE, + PARSER_NOT_APPLICABLE, + PARSER_FAILED, + CONTEXT_READY, + AGENT_COMPLETE, + VALIDATED, + AWAITING_CONFIRMATION, + CONFIRMED, + FAILED + } + + /** 邮件入口来源。 */ + public enum MessageOrigin { + AGENTBUS, + MANUAL_EML + } + + /** 证据材料分类。 */ + public enum EvidenceKind { + CURRENT_BODY, + QUOTED_HISTORY, + ATTACHMENT, + WORKBOOK_SHEET, + WORKBOOK_ROW, + IMAGE, + OCR_TEXT, + CONTEXT_RECORD, + USER_CORRECTION + } + + /** 材料预处理结果。 */ + public enum MaterialSelectionStatus { + READY, + NO_ATTACHMENT, + UNSUPPORTED, + AMBIGUOUS, + FAILED + } + + /** 固定渠道 Parser 的适用与执行结果。 */ + public enum ParserApplicability { + APPLICABLE, + NOT_APPLICABLE, + FAILED, + AMBIGUOUS + } + + /** CandidateDecision 的来源。 */ + public enum DecisionOrigin { + DETERMINISTIC, + AGENT, + MIXED + } + + /** 邮件/目标的稳定处理结果类型。 */ + public enum ResultDisposition { + IN_SCOPE_TASKS, + GENERAL_NOTIFICATION, + RISK_NOTIFICATION, + IGNORED_DIRECTION + } + + /** Validator 输出状态。 */ + public enum ValidationStatus { + VALID, + REVIEW_REQUIRED, + RISK, + REJECTED + } + + /** 前端确认投影状态。 */ + public enum ConfirmationStatus { + AWAITING_CONFIRMATION, + REVIEW_REQUIRED, + NOT_AVAILABLE + } + + /** 问题严重程度。 */ + public enum IssueSeverity { + INFO, + WARNING, + BLOCKER + } + + /** 问题作用范围。 */ + public enum IssueScope { + MESSAGE, + MATERIAL, + TARGET, + FIELD + } + + /** + * 所有顶层契约都必须携带的版本与运行信息。 + * + *

Parser 或 Agent 未运行时,分别使用显式 sentinel,不能留空或由下游猜测。

+ */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ContractMeta( + String contractVersion, + String catalogVersion, + String parserVersion, + String agentProfileVersion, + String processingRunId, + String sourceMessageId, + long sourceRevision) { + + public ContractMeta { + if (!CONTRACT_VERSION.equals(contractVersion)) { + throw new IllegalArgumentException("只接受 booking-contracts-v1"); + } + requireText(catalogVersion, "catalogVersion"); + requireText(parserVersion, "parserVersion"); + requireText(agentProfileVersion, "agentProfileVersion"); + requireText(processingRunId, "processingRunId"); + requireText(sourceMessageId, "sourceMessageId"); + if (sourceRevision < 0) { + throw new IllegalArgumentException("sourceRevision 不能小于 0"); + } + } + } + + /** 不含邮件原文或 URL 的最小证据定位。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record EvidenceReference( + String evidenceId, + EvidenceKind kind, + String sourceMessageId, + String locator, + String contentHash, + boolean currentMaterial) { + + public EvidenceReference { + requireText(evidenceId, "evidenceId"); + Objects.requireNonNull(kind, "kind 不能为空"); + requireText(sourceMessageId, "sourceMessageId"); + requireText(locator, "locator"); + requireText(contentHash, "contentHash"); + } + } + + /** 跨层统一问题结构。messageKey 由前端决定展示文案,不放原始邮件文本。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ContractIssue( + String code, + IssueSeverity severity, + IssueScope scope, + String messageKey, + List evidenceRefs, + boolean retryable) { + + public ContractIssue { + requireText(code, "code"); + Objects.requireNonNull(severity, "severity 不能为空"); + Objects.requireNonNull(scope, "scope 不能为空"); + requireText(messageKey, "messageKey"); + evidenceRefs = immutableList(evidenceRefs); + } + } + + /** 附件安全摘要;原件仍由 SourceMessage 受控能力保存和读取。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record AttachmentDescriptor( + String attachmentId, + String fileName, + String mediaType, + long sizeBytes, + String contentHash, + String evidenceId) { + + public AttachmentDescriptor { + requireText(attachmentId, "attachmentId"); + requireText(fileName, "fileName"); + requireText(mediaType, "mediaType"); + requireText(contentHash, "contentHash"); + requireText(evidenceId, "evidenceId"); + if (sizeBytes < 0) { + throw new IllegalArgumentException("sizeBytes 不能小于 0"); + } + } + } + + /** 层 1 的邮件信封,统一 AgentBus 和手工 EML。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record SourceMessageEnvelope( + ContractMeta contractMeta, + List evidenceRefs, + MessageOrigin messageOrigin, + String externalMessageId, + String conversationId, + String inReplyTo, + String idempotencyKey, + OffsetDateTime receivedAt, + String safeSubject, + String currentBodyEvidenceId, + String quotedHistoryEvidenceId, + List attachmentDescriptors, + String senderSummary) { + + public SourceMessageEnvelope { + Objects.requireNonNull(contractMeta, "contractMeta 不能为空"); + evidenceRefs = immutableList(evidenceRefs); + Objects.requireNonNull(messageOrigin, "messageOrigin 不能为空"); + requireText(idempotencyKey, "idempotencyKey"); + Objects.requireNonNull(receivedAt, "receivedAt 不能为空"); + requireText(currentBodyEvidenceId, "currentBodyEvidenceId"); + attachmentDescriptors = immutableList(attachmentDescriptors); + senderSummary = senderSummary == null || senderSummary.isBlank() ? null : senderSummary.trim(); + } + + /** 兼容尚未携带 sender 的早期 contracts-v1 产物。 */ + public SourceMessageEnvelope( + ContractMeta contractMeta, + List evidenceRefs, + MessageOrigin messageOrigin, + String externalMessageId, + String conversationId, + String inReplyTo, + String idempotencyKey, + OffsetDateTime receivedAt, + String safeSubject, + String currentBodyEvidenceId, + String quotedHistoryEvidenceId, + List attachmentDescriptors) { + this( + contractMeta, + evidenceRefs, + messageOrigin, + externalMessageId, + conversationId, + inReplyTo, + idempotencyKey, + receivedAt, + safeSubject, + currentBodyEvidenceId, + quotedHistoryEvidenceId, + attachmentDescriptors, + null); + } + } + + /** 层 2 对某个材料的最小描述。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record MaterialReference( + String evidenceId, + EvidenceKind kind, + boolean currentMaterial, + Map attributes) { + + public MaterialReference { + requireText(evidenceId, "evidenceId"); + Objects.requireNonNull(kind, "kind 不能为空"); + attributes = immutableMap(attributes); + } + } + + /** 层 2 对工作簿的结构和 Profile 检查结果。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record WorkbookInspection( + String attachmentId, + String contentHash, + List sheetNames, + List headerSignatures, + String selectedProfileCode, + List candidateRows, + List issues) { + + public WorkbookInspection { + requireText(attachmentId, "attachmentId"); + requireText(contentHash, "contentHash"); + sheetNames = immutableList(sheetNames); + headerSignatures = immutableList(headerSignatures); + candidateRows = immutableList(candidateRows); + issues = immutableList(issues); + } + } + + /** 层 2 的图片检查结果,OCR 只能作为辅助证据。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ImageInspection( + String attachmentId, + String evidenceId, + String ocrEvidenceId, + List issues) { + + public ImageInspection { + requireText(attachmentId, "attachmentId"); + requireText(evidenceId, "evidenceId"); + issues = immutableList(issues); + } + } + + /** 层 2 输出:分离 current/history 后的可消费材料。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record MaterialPackage( + ContractMeta contractMeta, + List evidenceRefs, + SourceMessageEnvelope messageEnvelope, + List currentMaterials, + List quotedHistoryMaterials, + List workbookInspections, + List imageInspections, + MaterialSelectionStatus materialSelectionStatus, + List issues) { + + public MaterialPackage { + Objects.requireNonNull(contractMeta, "contractMeta 不能为空"); + evidenceRefs = immutableList(evidenceRefs); + Objects.requireNonNull(messageEnvelope, "messageEnvelope 不能为空"); + currentMaterials = immutableList(currentMaterials); + quotedHistoryMaterials = immutableList(quotedHistoryMaterials); + workbookInspections = immutableList(workbookInspections); + imageInspections = immutableList(imageInspections); + Objects.requireNonNull(materialSelectionStatus, "materialSelectionStatus 不能为空"); + issues = immutableList(issues); + } + } + + /** Parser 输出中的目标身份线索,字段未知时保持 null。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record TargetIdentity( + String groupCode, + String groupName, + String tourCode, + List nameValues, + List evidenceRefs) { + + public TargetIdentity { + nameValues = immutableList(nameValues); + evidenceRefs = immutableList(evidenceRefs); + } + } + + /** Parser 输出中的房型/数量/价格证据,Extra Bed 不应使用该结构伪装为房型。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record RoomItemFact( + String roomTypeCodeHint, + Integer roomCount, + String rateHint, + List evidenceRefs) { + + public RoomItemFact { + evidenceRefs = immutableList(evidenceRefs); + } + } + + /** Parser 输出中的入住信息,价格只保留为证据线索。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record StayFact( + String arrivalDate, + String departureDate, + Integer nights, + List roomItems, + List priceEvidenceRefs) { + + public StayFact { + roomItems = immutableList(roomItems); + priceEvidenceRefs = immutableList(priceEvidenceRefs); + } + } + + /** Parser 输出中的关联业务线索,例如 Trace、Payment、Allotment。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record RelatedHint( + String hintType, + Map attributes, + List evidenceRefs) { + + public RelatedHint { + requireText(hintType, "hintType"); + attributes = immutableMap(attributes); + evidenceRefs = immutableList(evidenceRefs); + } + } + + /** 一条可追溯的确定性事实。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ParsedFact( + String factId, + String actionHint, + String bookingType, + TargetIdentity targetIdentity, + StayFact stay, + List relatedHints, + List evidenceRefs) { + + public ParsedFact { + requireText(factId, "factId"); + relatedHints = immutableList(relatedHints); + evidenceRefs = immutableList(evidenceRefs); + } + } + + /** 未解决字段必须显式保留,不能被 Parser 或 Agent 默认为零值。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record UnresolvedField( + String fieldPath, + String reasonCode, + List evidenceRefs) { + + public UnresolvedField { + requireText(fieldPath, "fieldPath"); + requireText(reasonCode, "reasonCode"); + evidenceRefs = immutableList(evidenceRefs); + } + } + + /** + * 允许 Booking Agent 消费的受控 current 邮件文本。 + * + *

它不是原始邮件副本:只能来自 current subject/body,经本地策略脱敏和限长;quoted history、附件字节、 + * 附件 URL 和 sender 原文不得出现在该结构中。该字段留在 ParsedFactSet 内,使异步 Agent/retry 仍可在 + * retention 期内使用完全相同的受控材料。

+ */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + public record AgentInputMaterial( + String evidenceId, + String currentMessageText, + int sourceCharacterCount, + boolean truncated, + String policyVersion) { + + public AgentInputMaterial { + requireText(evidenceId, "evidenceId"); + requireText(currentMessageText, "currentMessageText"); + if (sourceCharacterCount < 0) { + throw new IllegalArgumentException("sourceCharacterCount 不能小于 0"); + } + requireText(policyVersion, "policyVersion"); + } + } + + /** 层 3 输出:确定性事实集。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ParsedFactSet( + ContractMeta contractMeta, + List evidenceRefs, + ParserApplicability parserApplicability, + String channelProfileCode, + List facts, + List unresolvedFields, + List fallbackReasons, + List issues, + AgentInputMaterial agentInputMaterial) { + + public ParsedFactSet { + Objects.requireNonNull(contractMeta, "contractMeta 不能为空"); + evidenceRefs = immutableList(evidenceRefs); + Objects.requireNonNull(parserApplicability, "parserApplicability 不能为空"); + facts = immutableList(facts); + unresolvedFields = immutableList(unresolvedFields); + fallbackReasons = immutableList(fallbackReasons); + issues = immutableList(issues); + } + + /** 兼容尚未携带受控 Agent 材料的早期 contracts-v1 产物。 */ + public ParsedFactSet( + ContractMeta contractMeta, + List evidenceRefs, + ParserApplicability parserApplicability, + String channelProfileCode, + List facts, + List unresolvedFields, + List fallbackReasons, + List issues) { + this( + contractMeta, + evidenceRefs, + parserApplicability, + channelProfileCode, + facts, + unresolvedFields, + fallbackReasons, + issues, + null); + } + } + + /** Context 对一个目标线索的定向解析结果。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record TargetResolution( + String targetReference, + String resolutionStatus, + List candidateTargetIds, + List evidenceRefs, + List issues) { + + public TargetResolution { + requireText(targetReference, "targetReference"); + requireText(resolutionStatus, "resolutionStatus"); + candidateTargetIds = immutableList(candidateTargetIds); + evidenceRefs = immutableList(evidenceRefs); + issues = immutableList(issues); + } + } + + /** 层 4 输出:仅包含当前判断所需的定向上下文。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ContextPackage( + ContractMeta contractMeta, + List evidenceRefs, + String contextScope, + List targetResolutions, + Map currentState, + Map conversationContext, + Map lifecycleContext, + Map allotmentContext, + List issues) { + + public ContextPackage { + Objects.requireNonNull(contractMeta, "contractMeta 不能为空"); + requireText(contextScope, "contextScope"); + evidenceRefs = immutableList(evidenceRefs); + targetResolutions = immutableList(targetResolutions); + currentState = immutableMap(currentState); + conversationContext = immutableMap(conversationContext); + lifecycleContext = immutableMap(lifecycleContext); + allotmentContext = immutableMap(allotmentContext); + issues = immutableList(issues); + } + } + + /** 单个目标的候选业务决定。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record TargetDecision( + String targetDecisionId, + String businessType, + String actionKind, + TargetIdentity targetIdentity, + Map fields, + List> linkedActions, + List evidenceRefs, + List issues) { + + public TargetDecision { + requireText(targetDecisionId, "targetDecisionId"); + requireText(businessType, "businessType"); + requireText(actionKind, "actionKind"); + fields = immutableMap(fields); + linkedActions = immutableList(linkedActions); + evidenceRefs = immutableList(evidenceRefs); + issues = immutableList(issues); + } + } + + /** General、Risk 或 ignored 的邮件级最小结果。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record MessageNotification( + ResultDisposition disposition, + String notificationCode, + List evidenceRefs, + List issues) { + + public MessageNotification { + Objects.requireNonNull(disposition, "disposition 不能为空"); + requireText(notificationCode, "notificationCode"); + evidenceRefs = immutableList(evidenceRefs); + issues = immutableList(issues); + } + } + + /** 层 5 输出:确定性合成或 Booking Agent 的候选决定。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record CandidateDecision( + ContractMeta contractMeta, + List evidenceRefs, + DecisionOrigin decisionOrigin, + ResultDisposition resultDisposition, + List targetDecisions, + List messageNotifications, + String agentTraceRef, + List issues) { + + public CandidateDecision { + Objects.requireNonNull(contractMeta, "contractMeta 不能为空"); + evidenceRefs = immutableList(evidenceRefs); + Objects.requireNonNull(decisionOrigin, "decisionOrigin 不能为空"); + Objects.requireNonNull(resultDisposition, "resultDisposition 不能为空"); + targetDecisions = immutableList(targetDecisions); + messageNotifications = immutableList(messageNotifications); + issues = immutableList(issues); + } + } + + /** Validator 的单项检查结果。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ValidationCheck( + String checkCode, + ValidationStatus status, + List issues) { + + public ValidationCheck { + requireText(checkCode, "checkCode"); + Objects.requireNonNull(status, "status 不能为空"); + issues = immutableList(issues); + } + } + + /** Validator 的目标级通过或隔离结果。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ValidatedTarget( + String targetDecisionId, + ValidationStatus status, + List checks, + List blockers) { + + public ValidatedTarget { + requireText(targetDecisionId, "targetDecisionId"); + Objects.requireNonNull(status, "status 不能为空"); + checks = immutableList(checks); + blockers = immutableList(blockers); + } + } + + /** 层 6 的校验结果。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ValidationResult( + ContractMeta contractMeta, + List evidenceRefs, + ValidationStatus validationStatus, + List validatedTargets, + List schemaChecks, + List businessChecks, + List blockers, + MessageNotification riskNotification) { + + public ValidationResult { + Objects.requireNonNull(contractMeta, "contractMeta 不能为空"); + evidenceRefs = immutableList(evidenceRefs); + Objects.requireNonNull(validationStatus, "validationStatus 不能为空"); + validatedTargets = immutableList(validatedTargets); + schemaChecks = immutableList(schemaChecks); + businessChecks = immutableList(businessChecks); + blockers = immutableList(blockers); + } + } + + /** 前端展示字段与确认可编辑性的安全投影。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ConfirmationField( + String fieldCode, + Object value, + boolean editable, + List allowedValues, + List evidenceRefs, + List issues) { + + public ConfirmationField { + requireText(fieldCode, "fieldCode"); + allowedValues = immutableList(allowedValues); + evidenceRefs = immutableList(evidenceRefs); + issues = immutableList(issues); + } + } + + /** 一项用户可确认的任务或通知。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ConfirmationItem( + String confirmationItemId, + String targetDecisionId, + ResultDisposition disposition, + List displayFields, + List blockedFields, + List> linkedActions, + List safeEvidenceRefs) { + + public ConfirmationItem { + requireText(confirmationItemId, "confirmationItemId"); + Objects.requireNonNull(disposition, "disposition 不能为空"); + displayFields = immutableList(displayFields); + blockedFields = immutableList(blockedFields); + linkedActions = immutableList(linkedActions); + safeEvidenceRefs = immutableList(safeEvidenceRefs); + } + } + + /** 层 6 面向前端的确认投影,不是 Agent 原始输出。 */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + + public record ConfirmationProjection( + ContractMeta contractMeta, + List evidenceRefs, + ConfirmationStatus projectionStatus, + List confirmationItems, + List issues, + ProcessingStatus processingStatus) { + + public ConfirmationProjection { + Objects.requireNonNull(contractMeta, "contractMeta 不能为空"); + evidenceRefs = immutableList(evidenceRefs); + Objects.requireNonNull(projectionStatus, "projectionStatus 不能为空"); + confirmationItems = immutableList(confirmationItems); + issues = immutableList(issues); + Objects.requireNonNull(processingStatus, "processingStatus 不能为空"); + } + } + + private static void requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + " 不能为空"); + } + } + + private static List immutableList(List values) { + return values == null ? List.of() : List.copyOf(values); + } + + private static Map immutableMap(Map values) { + // contracts-v1 用 null 明确表达“字段未知”。Map.copyOf 会错误地把这种受控未知值当成 + // 程序异常,因此这里保留 key 的插入顺序并只冻结容器本身。 + return values == null || values.isEmpty() + ? Map.of() + : Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingProcessingRunController.java b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingProcessingRunController.java new file mode 100644 index 0000000..648ebe9 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/workflows/reservation/control/ReservationBookingProcessingRunController.java @@ -0,0 +1,79 @@ +package cn.nianxx.thhotel.workflows.reservation.control; + +import cn.nianxx.thhotel.platform.access.common.enums.PlatformPermissionCode; +import cn.nianxx.thhotel.platform.security.common.dto.AuthenticatedUserContext; +import cn.nianxx.thhotel.platform.security.service.FrontendAuthorizationService; +import cn.nianxx.thhotel.workflows.reservation.booking.common.request.BookingConfirmationRequest; +import cn.nianxx.thhotel.workflows.reservation.booking.common.result.BookingConfirmationResult; +import cn.nianxx.thhotel.workflows.reservation.booking.common.result.BookingProcessingRunView; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingProcessingRunService; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationTaskWorkflowException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** contracts-v1 processing run 查询和用户确认 API;没有 PMS/Opera 调用。 */ +@RestController +@RequestMapping("/api/reservation/booking-processing-runs") +public class ReservationBookingProcessingRunController { + + private final ObjectProvider processingRunServiceProvider; + private final FrontendAuthorizationService authorizationService; + + public ReservationBookingProcessingRunController( + ObjectProvider processingRunServiceProvider, + FrontendAuthorizationService authorizationService) { + this.processingRunServiceProvider = processingRunServiceProvider; + this.authorizationService = authorizationService; + } + + /** 前端按 processing run ID 轮询 Parser/Agent/Validator/确认状态。 */ + @GetMapping(value = "/{runId}", produces = MediaType.APPLICATION_JSON_VALUE) + public BookingProcessingRunView get(@PathVariable String runId) { + authorizationService.requirePermission(PlatformPermissionCode.RESERVATION_TASK_READ.name()); + return service().get(runId); + } + + /** 冻结经校验的参数并写确认审计;不会创建 PMS/Opera 操作。 */ + @PostMapping( + value = "/{runId}/confirmations", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public BookingConfirmationResult confirm( + @PathVariable String runId, + @RequestBody(required = false) BookingConfirmationRequest request) { + AuthenticatedUserContext user = authorizationService.requirePermission( + PlatformPermissionCode.RESERVATION_TASK_EDIT.name()); + return service().confirm(runId, request, actorId(user)); + } + + private BookingProcessingRunService service() { + BookingProcessingRunService service = processingRunServiceProvider.getIfAvailable(); + if (service == null) { + throw new ReservationTaskWorkflowException( + HttpStatus.SERVICE_UNAVAILABLE, + "BOOKING_POSTGRES_RUNTIME_DISABLED", + "新 Booking PostgreSQL 运行时尚未启用。"); + } + return service; + } + + private String actorId(AuthenticatedUserContext user) { + if (user.userId() != null) { + return "user-" + user.userId(); + } + if (user.username() != null && !user.username().isBlank()) { + return "username-" + user.username().trim(); + } + throw new ReservationTaskWorkflowException( + HttpStatus.UNAUTHORIZED, + "AUTH_USER_CONTEXT_REQUIRED", + "当前登录用户缺少可审计标识。"); + } +} diff --git a/server/src/main/resources/db/booking-postgresql/migration/V1__booking_contracts_v1.sql b/server/src/main/resources/db/booking-postgresql/migration/V1__booking_contracts_v1.sql new file mode 100644 index 0000000..cbb3f32 --- /dev/null +++ b/server/src/main/resources/db/booking-postgresql/migration/V1__booking_contracts_v1.sql @@ -0,0 +1,381 @@ +CREATE SCHEMA IF NOT EXISTS th_hotel_booking; + +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_source_message_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_source_attachment_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_processing_run_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_processing_attempt_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_evidence_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_artifact_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_parsed_fact_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_candidate_target_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_confirmation_action_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_channel_profile_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_sender_mapping_seq; +CREATE SEQUENCE IF NOT EXISTS th_hotel_booking.booking_contract_rate_code_mapping_seq; + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_source_message ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_source_message_seq'::regclass), + hotel_id VARCHAR(64) NOT NULL, + platform_source_message_id VARCHAR(160) NOT NULL, + message_origin VARCHAR(32) NOT NULL, + external_message_id VARCHAR(256), + conversation_id VARCHAR(256), + idempotency_key VARCHAR(320) NOT NULL, + source_revision BIGINT NOT NULL DEFAULT 0, + message_sha256 CHAR(64) NOT NULL, + safe_subject TEXT, + sender_summary TEXT, + received_at TIMESTAMPTZ NOT NULL, + current_body_evidence_key VARCHAR(160) NOT NULL, + quoted_history_evidence_key VARCHAR(160), + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_source_idempotency + UNIQUE (hotel_id, message_origin, idempotency_key), + CONSTRAINT uq_booking_contract_source_platform_id + UNIQUE (hotel_id, platform_source_message_id), + CONSTRAINT ck_booking_contract_source_origin + CHECK (message_origin IN ('AGENTBUS', 'MANUAL_EML')), + CONSTRAINT ck_booking_contract_source_revision + CHECK (source_revision >= 0), + CONSTRAINT ck_booking_contract_source_sha256 + CHECK (message_sha256 ~ '^[0-9a-f]{64}$') +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_source_message_seq + OWNED BY th_hotel_booking.booking_contract_source_message.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_source_conversation + ON th_hotel_booking.booking_contract_source_message (hotel_id, conversation_id, received_at, id); +CREATE INDEX IF NOT EXISTS idx_booking_contract_source_retention + ON th_hotel_booking.booking_contract_source_message (retention_until); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_source_attachment ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_source_attachment_seq'::regclass), + source_message_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_source_message (id) ON DELETE CASCADE, + attachment_ordinal INTEGER NOT NULL, + attachment_key VARCHAR(160) NOT NULL, + file_name TEXT NOT NULL, + media_type VARCHAR(255) NOT NULL, + size_bytes BIGINT NOT NULL, + content_sha256 CHAR(64) NOT NULL, + evidence_key VARCHAR(160) NOT NULL, + is_current_material BOOLEAN NOT NULL DEFAULT TRUE, + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_attachment_ordinal + UNIQUE (source_message_id, attachment_ordinal), + CONSTRAINT uq_booking_contract_attachment_key + UNIQUE (source_message_id, attachment_key), + CONSTRAINT ck_booking_contract_attachment_ordinal + CHECK (attachment_ordinal > 0), + CONSTRAINT ck_booking_contract_attachment_size + CHECK (size_bytes >= 0), + CONSTRAINT ck_booking_contract_attachment_sha256 + CHECK (content_sha256 ~ '^[0-9a-f]{64}$') +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_source_attachment_seq + OWNED BY th_hotel_booking.booking_contract_source_attachment.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_attachment_source + ON th_hotel_booking.booking_contract_source_attachment (source_message_id); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_processing_run ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_processing_run_seq'::regclass), + run_id VARCHAR(64) NOT NULL UNIQUE, + source_message_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_source_message (id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL, + run_status VARCHAR(32) NOT NULL, + contract_version VARCHAR(64) NOT NULL, + catalog_version VARCHAR(128) NOT NULL, + parser_version VARCHAR(128) NOT NULL, + agent_profile_version VARCHAR(160) NOT NULL, + result_disposition VARCHAR(32), + input_fingerprint CHAR(64) NOT NULL, + retry_count INTEGER NOT NULL DEFAULT 0, + error_code VARCHAR(128), + error_summary VARCHAR(512), + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ, + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_run_revision + UNIQUE (source_message_id, revision_number), + CONSTRAINT ck_booking_contract_run_revision + CHECK (revision_number > 0), + CONSTRAINT ck_booking_contract_run_status + CHECK (run_status IN ( + 'RECEIVED', 'MATERIAL_READY', 'PARSER_COMPLETE', 'PARSER_NOT_APPLICABLE', + 'PARSER_FAILED', 'CONTEXT_READY', 'AGENT_COMPLETE', 'VALIDATED', + 'AWAITING_CONFIRMATION', 'CONFIRMED', 'FAILED' + )), + CONSTRAINT ck_booking_contract_run_disposition + CHECK (result_disposition IS NULL OR result_disposition IN ( + 'IN_SCOPE_TASKS', 'GENERAL_NOTIFICATION', 'RISK_NOTIFICATION', 'IGNORED_DIRECTION' + )), + CONSTRAINT ck_booking_contract_run_fingerprint + CHECK (input_fingerprint ~ '^[0-9a-f]{64}$') +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_processing_run_seq + OWNED BY th_hotel_booking.booking_contract_processing_run.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_run_source + ON th_hotel_booking.booking_contract_processing_run (source_message_id, revision_number DESC); +CREATE INDEX IF NOT EXISTS idx_booking_contract_run_status + ON th_hotel_booking.booking_contract_processing_run (run_status, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_booking_contract_run_retention + ON th_hotel_booking.booking_contract_processing_run (retention_until); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_processing_attempt ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_processing_attempt_seq'::regclass), + processing_run_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_processing_run (id) ON DELETE CASCADE, + attempt_number INTEGER NOT NULL, + attempt_status VARCHAR(32) NOT NULL, + error_code VARCHAR(128), + error_summary VARCHAR(512), + started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ, + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + CONSTRAINT uq_booking_contract_attempt_number + UNIQUE (processing_run_id, attempt_number), + CONSTRAINT ck_booking_contract_attempt_number + CHECK (attempt_number > 0), + CONSTRAINT ck_booking_contract_attempt_status + CHECK (attempt_status IN ('STARTED', 'SUCCEEDED', 'FAILED', 'RETRY_SCHEDULED')) +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_processing_attempt_seq + OWNED BY th_hotel_booking.booking_contract_processing_attempt.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_attempt_run + ON th_hotel_booking.booking_contract_processing_attempt (processing_run_id, attempt_number DESC); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_evidence_reference ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_evidence_seq'::regclass), + processing_run_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_processing_run (id) ON DELETE CASCADE, + evidence_key VARCHAR(160) NOT NULL, + evidence_kind VARCHAR(32) NOT NULL, + source_message_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_source_message (id) ON DELETE CASCADE, + locator VARCHAR(512) NOT NULL, + content_hash CHAR(64) NOT NULL, + is_current_material BOOLEAN NOT NULL, + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_evidence_key + UNIQUE (processing_run_id, evidence_key), + CONSTRAINT ck_booking_contract_evidence_kind + CHECK (evidence_kind IN ( + 'CURRENT_BODY', 'QUOTED_HISTORY', 'ATTACHMENT', 'WORKBOOK_SHEET', 'WORKBOOK_ROW', + 'IMAGE', 'OCR_TEXT', 'CONTEXT_RECORD', 'USER_CORRECTION' + )), + CONSTRAINT ck_booking_contract_evidence_hash + CHECK (content_hash ~ '^[0-9a-f]{64}$') +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_evidence_seq + OWNED BY th_hotel_booking.booking_contract_evidence_reference.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_evidence_source + ON th_hotel_booking.booking_contract_evidence_reference (source_message_id, evidence_kind); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_artifact ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_artifact_seq'::regclass), + processing_run_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_processing_run (id) ON DELETE CASCADE, + artifact_type VARCHAR(48) NOT NULL, + contract_version VARCHAR(64) NOT NULL, + payload JSONB NOT NULL, + payload_sha256 CHAR(64) NOT NULL, + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_artifact_type + UNIQUE (processing_run_id, artifact_type), + CONSTRAINT ck_booking_contract_artifact_type + CHECK (artifact_type IN ( + 'SOURCE_MESSAGE_ENVELOPE', 'MATERIAL_PACKAGE', 'PARSED_FACT_SET', 'CONTEXT_PACKAGE', + 'CANDIDATE_DECISION', 'VALIDATION_RESULT', 'CONFIRMATION_PROJECTION' + )), + CONSTRAINT ck_booking_contract_artifact_payload + CHECK (jsonb_typeof(payload) = 'object'), + CONSTRAINT ck_booking_contract_artifact_hash + CHECK (payload_sha256 ~ '^[0-9a-f]{64}$') +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_artifact_seq + OWNED BY th_hotel_booking.booking_contract_artifact.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_artifact_run + ON th_hotel_booking.booking_contract_artifact (processing_run_id, artifact_type); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_parsed_fact ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_parsed_fact_seq'::regclass), + processing_run_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_processing_run (id) ON DELETE CASCADE, + fact_key VARCHAR(160) NOT NULL, + fact_ordinal INTEGER NOT NULL, + action_hint VARCHAR(48), + booking_type VARCHAR(16), + group_code VARCHAR(128), + group_name TEXT, + tour_code VARCHAR(160), + arrival_date DATE, + departure_date DATE, + fact_payload JSONB NOT NULL, + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_parsed_fact_key + UNIQUE (processing_run_id, fact_key), + CONSTRAINT ck_booking_contract_parsed_fact_ordinal + CHECK (fact_ordinal >= 0), + CONSTRAINT ck_booking_contract_parsed_fact_action + CHECK (action_hint IS NULL OR action_hint IN ( + 'NEW_BOOKING', 'UPDATE_BOOKING', 'CANCEL_BOOKING' + )), + CONSTRAINT ck_booking_contract_parsed_fact_booking_type + CHECK (booking_type IS NULL OR booking_type IN ('FIT', 'GROUP')), + CONSTRAINT ck_booking_contract_parsed_fact_payload + CHECK (jsonb_typeof(fact_payload) = 'object'), + CONSTRAINT ck_booking_contract_parsed_fact_stay + CHECK (arrival_date IS NULL OR departure_date IS NULL OR departure_date > arrival_date) +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_parsed_fact_seq + OWNED BY th_hotel_booking.booking_contract_parsed_fact.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_parsed_fact_group + ON th_hotel_booking.booking_contract_parsed_fact (group_code, arrival_date, departure_date); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_candidate_target ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_candidate_target_seq'::regclass), + processing_run_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_processing_run (id) ON DELETE CASCADE, + target_decision_key VARCHAR(160) NOT NULL, + business_type VARCHAR(48) NOT NULL, + action_kind VARCHAR(96) NOT NULL, + result_disposition VARCHAR(32) NOT NULL, + target_resolution_status VARCHAR(32) NOT NULL, + target_group_code VARCHAR(128), + decision_payload JSONB NOT NULL, + validation_status VARCHAR(32), + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_candidate_target + UNIQUE (processing_run_id, target_decision_key), + CONSTRAINT ck_booking_contract_candidate_business_type + CHECK (business_type IN ( + 'NEW_BOOKING', 'UPDATE_BOOKING', 'CANCEL_BOOKING', 'TRACE_RESERVATION_NOTES', + 'ROOMING_LIST', 'PAYMENT', 'ALLOTMENT' + )), + CONSTRAINT ck_booking_contract_candidate_disposition + CHECK (result_disposition IN ( + 'IN_SCOPE_TASKS', 'GENERAL_NOTIFICATION', 'RISK_NOTIFICATION', 'IGNORED_DIRECTION' + )), + CONSTRAINT ck_booking_contract_candidate_resolution + CHECK (target_resolution_status IN ('UNRESOLVED', 'RESOLVED', 'CONFLICT')), + CONSTRAINT ck_booking_contract_candidate_validation + CHECK (validation_status IS NULL OR validation_status IN ('VALID', 'REVIEW_REQUIRED', 'RISK', 'REJECTED')), + CONSTRAINT ck_booking_contract_candidate_payload + CHECK (jsonb_typeof(decision_payload) = 'object') +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_candidate_target_seq + OWNED BY th_hotel_booking.booking_contract_candidate_target.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_candidate_queue + ON th_hotel_booking.booking_contract_candidate_target (business_type, validation_status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_booking_contract_candidate_group + ON th_hotel_booking.booking_contract_candidate_target (target_group_code, created_at DESC); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_confirmation_action ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_confirmation_action_seq'::regclass), + processing_run_id BIGINT NOT NULL + REFERENCES th_hotel_booking.booking_contract_processing_run (id) ON DELETE CASCADE, + candidate_target_id BIGINT + REFERENCES th_hotel_booking.booking_contract_candidate_target (id) ON DELETE SET NULL, + confirmation_item_key VARCHAR(160) NOT NULL, + action_type VARCHAR(32) NOT NULL, + field_overrides JSONB NOT NULL DEFAULT '{}'::jsonb, + actor_id VARCHAR(128) NOT NULL, + acted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + retention_until TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '3 months'), + CONSTRAINT uq_booking_contract_confirmation_item + UNIQUE (processing_run_id, confirmation_item_key, action_type), + CONSTRAINT ck_booking_contract_confirmation_action + CHECK (action_type IN ('CONFIRM', 'RESOLVE_REVIEW')), + CONSTRAINT ck_booking_contract_confirmation_overrides + CHECK (jsonb_typeof(field_overrides) = 'object') +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_confirmation_action_seq + OWNED BY th_hotel_booking.booking_contract_confirmation_action.id; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_confirmation_run + ON th_hotel_booking.booking_contract_confirmation_action (processing_run_id, acted_at DESC); + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_channel_profile ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_channel_profile_seq'::regclass), + profile_code VARCHAR(96) NOT NULL UNIQUE, + profile_version VARCHAR(128) NOT NULL, + sender_rule VARCHAR(256), + profile_payload JSONB NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_booking_contract_channel_profile_payload + CHECK (jsonb_typeof(profile_payload) = 'object'), + CONSTRAINT ck_booking_contract_channel_profile_status + CHECK (status IN ('ACTIVE', 'INACTIVE')) +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_channel_profile_seq + OWNED BY th_hotel_booking.booking_contract_channel_profile.id; + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_sender_mapping ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_sender_mapping_seq'::regclass), + sender_normalized VARCHAR(320) NOT NULL, + account_code VARCHAR(96), + market_code VARCHAR(96), + source_code VARCHAR(96), + catalog_version VARCHAR(128) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_sender_mapping + UNIQUE (sender_normalized, catalog_version), + CONSTRAINT ck_booking_contract_sender_mapping_status + CHECK (status IN ('ACTIVE', 'INACTIVE')) +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_sender_mapping_seq + OWNED BY th_hotel_booking.booking_contract_sender_mapping.id; + +CREATE TABLE IF NOT EXISTS th_hotel_booking.booking_contract_rate_code_mapping ( + id BIGINT PRIMARY KEY DEFAULT nextval('th_hotel_booking.booking_contract_rate_code_mapping_seq'::regclass), + mapping_key VARCHAR(160) NOT NULL, + catalog_version VARCHAR(128) NOT NULL, + match_payload JSONB NOT NULL, + rate_code VARCHAR(96) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_booking_contract_rate_code_mapping + UNIQUE (mapping_key, catalog_version), + CONSTRAINT ck_booking_contract_rate_code_mapping_payload + CHECK (jsonb_typeof(match_payload) = 'object'), + CONSTRAINT ck_booking_contract_rate_code_mapping_status + CHECK (status IN ('ACTIVE', 'INACTIVE')) +); + +ALTER SEQUENCE th_hotel_booking.booking_contract_rate_code_mapping_seq + OWNED BY th_hotel_booking.booking_contract_rate_code_mapping.id; diff --git a/server/src/main/resources/db/booking-postgresql/migration/V2__booking_sender_mapping_direction.sql b/server/src/main/resources/db/booking-postgresql/migration/V2__booking_sender_mapping_direction.sql new file mode 100644 index 0000000..e59ba16 --- /dev/null +++ b/server/src/main/resources/db/booking-postgresql/migration/V2__booking_sender_mapping_direction.sql @@ -0,0 +1,48 @@ +-- Sender mapping 的候选不能被唯一约束掩盖:零/多条候选都必须显式留给人工或主数据维护。 +ALTER TABLE th_hotel_booking.booking_contract_sender_mapping + ADD COLUMN IF NOT EXISTS mapping_key VARCHAR(160); + +UPDATE th_hotel_booking.booking_contract_sender_mapping +SET mapping_key = 'legacy-' || id::text +WHERE mapping_key IS NULL; + +ALTER TABLE th_hotel_booking.booking_contract_sender_mapping + ALTER COLUMN mapping_key SET NOT NULL; + +ALTER TABLE th_hotel_booking.booking_contract_sender_mapping + ADD COLUMN IF NOT EXISTS processing_direction VARCHAR(16) NOT NULL DEFAULT 'INBOUND'; + +ALTER TABLE th_hotel_booking.booking_contract_sender_mapping + DROP CONSTRAINT IF EXISTS uq_booking_contract_sender_mapping; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'th_hotel_booking.booking_contract_sender_mapping'::regclass + AND conname = 'uq_booking_contract_sender_mapping_key' + ) THEN + ALTER TABLE th_hotel_booking.booking_contract_sender_mapping + ADD CONSTRAINT uq_booking_contract_sender_mapping_key + UNIQUE (sender_normalized, catalog_version, mapping_key); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'th_hotel_booking.booking_contract_sender_mapping'::regclass + AND conname = 'ck_booking_contract_sender_mapping_direction' + ) THEN + ALTER TABLE th_hotel_booking.booking_contract_sender_mapping + ADD CONSTRAINT ck_booking_contract_sender_mapping_direction + CHECK (processing_direction IN ('INBOUND', 'OUTBOUND', 'INTERNAL')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_booking_contract_sender_mapping_lookup + ON th_hotel_booking.booking_contract_sender_mapping (sender_normalized, catalog_version) + WHERE status = 'ACTIVE'; diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessorTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessorTest.java new file mode 100644 index 0000000..1936727 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAgentBusMaterialPreprocessorTest.java @@ -0,0 +1,111 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +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.platform.message.common.request.CaptureSourceMessageMedia; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageAttachmentInput; +import cn.nianxx.thhotel.workflows.reservation.booking.config.BookingPostgresProperties; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class BookingAgentBusMaterialPreprocessorTest { + + @Test + void shouldDownloadOnlyCurrentExcelForDeterministicParser() { + ObjectStorageService storage = mock(ObjectStorageService.class); + BookingPostgresProperties properties = properties(1_024L); + byte[] workbookBytes = "xlsx-content".getBytes(StandardCharsets.UTF_8); + when(storage.readObject(any(ObjectStorageReadRequest.class))) + .thenReturn(new ObjectStorageReadResult( + workbookBytes, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + (long) workbookBytes.length)); + BookingAgentBusMaterialPreprocessor preprocessor = new BookingAgentBusMaterialPreprocessor(storage, properties); + + List attachments = preprocessor.prepare(List.of( + new CaptureSourceMessageMedia( + "ATTACHMENT", + "booking.xlsx", + "application/octet-stream", + 100L, + "https://storage.example.test/booking.xlsx?temporary=signature", + "media-001"), + new CaptureSourceMessageMedia( + "IMAGE", + "payment.png", + "image/png", + 20L, + "https://storage.example.test/payment.png?temporary=signature", + "media-002"))); + + assertThat(attachments).hasSize(2); + assertThat(attachments.get(0).bytes()).isEqualTo(workbookBytes); + assertThat(attachments.get(0).contentHash()).hasSize(64); + assertThat(attachments.get(1).bytes()).isNull(); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(ObjectStorageReadRequest.class); + verify(storage).readObject(requestCaptor.capture()); + assertThat(requestCaptor.getValue().externalUrl()).contains("booking.xlsx"); + assertThat(requestCaptor.getValue().maxBytes()).isEqualTo(1_024L); + } + + @Test + void shouldKeepSafeReferenceAndFallbackWhenExcelDownloadFailsOrExceedsLimit() { + ObjectStorageService storage = mock(ObjectStorageService.class); + BookingPostgresProperties properties = properties(20L); + BookingAgentBusMaterialPreprocessor preprocessor = new BookingAgentBusMaterialPreprocessor(storage, properties); + + List attachments = preprocessor.prepare(List.of( + new CaptureSourceMessageMedia( + "ATTACHMENT", + "too-large.xlsx", + "application/octet-stream", + 21L, + "https://storage.example.test/too-large.xlsx?temporary=signature", + "media-003"))); + + assertThat(attachments).singleElement().satisfies(attachment -> { + assertThat(attachment.bytes()).isNull(); + assertThat(attachment.contentHash()).hasSize(64); + assertThat(attachment.currentMaterial()).isTrue(); + }); + verifyNoInteractions(storage); + } + + @Test + void shouldKeepSafeReferenceWhenObjectStorageReadThrows() { + ObjectStorageService storage = mock(ObjectStorageService.class); + when(storage.readObject(any(ObjectStorageReadRequest.class))) + .thenThrow(new IllegalStateException("storage unavailable")); + BookingAgentBusMaterialPreprocessor preprocessor = new BookingAgentBusMaterialPreprocessor(storage, properties(100L)); + + List attachments = preprocessor.prepare(List.of(new CaptureSourceMessageMedia( + "ATTACHMENT", + "download-fails.xlsx", + "application/octet-stream", + 20L, + "https://storage.example.test/download-fails.xlsx?temporary=signature", + "media-004"))); + + assertThat(attachments).singleElement().satisfies(attachment -> { + assertThat(attachment.bytes()).isNull(); + assertThat(attachment.contentHash()).hasSize(64); + }); + verify(storage).readObject(any(ObjectStorageReadRequest.class)); + } + + private BookingPostgresProperties properties(long maxBytes) { + BookingPostgresProperties properties = new BookingPostgresProperties(); + properties.setMaterialDownloadMaxBytes(maxBytes); + return properties; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentProjectionTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentProjectionTest.java new file mode 100644 index 0000000..1968579 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingAllotmentProjectionTest.java @@ -0,0 +1,197 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BookingAllotmentProjectionTest { + + private final BookingDecisionValidatorImpl validator = new BookingDecisionValidatorImpl(); + private final BookingConfirmationProjectionFactoryImpl projectionFactory = new BookingConfirmationProjectionFactoryImpl(); + + @Test + void shouldDeductOnlyActualsThatPassedNewBookingAdmissionAndCreateNoSourceCard() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.CandidateDecision decision = decision( + meta, + evidence, + List.of( + newActual("actual-1", "ACT260808A", 6, "QBD-850", evidence), + newActual("actual-2", "ACT260808B", 6, null, evidence))); + + BookingContracts.ValidationResult validation = validator.validate(decision, context(meta, evidence)); + BookingContracts.ConfirmationProjection projection = projectionFactory.project(decision, validation); + + assertThat(validation.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(validation.validatedTargets()).extracting(BookingContracts.ValidatedTarget::status) + .containsExactly(BookingContracts.ValidationStatus.VALID, BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(validation.businessChecks()).flatExtracting(BookingContracts.ValidationCheck::issues) + .extracting(BookingContracts.ContractIssue::code) + .doesNotContain("ALLOTMENT_SOURCE_INSUFFICIENT"); + assertThat(projection.confirmationItems()).hasSize(2) + .extracting(BookingContracts.ConfirmationItem::targetDecisionId) + .containsExactlyInAnyOrder("actual-1", "actual-2"); + + Map relationship = relationFor(projection, "actual-1"); + assertThat(relationship).containsEntry("source_deduction_status", "SOURCE_READY") + .containsEntry("source_deduct_room_items", List.of(Map.of("room_type_code", "RM3", "room_count", 6))) + .containsEntry("source_remaining_room_items", List.of(Map.of("room_type_code", "RM3", "room_count", 4))); + List> actualTargets = actualTargets(relationship); + assertThat(actualTargets) + .extracting(item -> (String) item.get("actual_target_decision_id")) + .containsExactlyInAnyOrder("actual-1", "actual-2"); + Map rejectedActual = actualTargets.stream() + .filter(item -> "actual-2".equals(item.get("actual_target_decision_id"))) + .findFirst() + .orElseThrow(); + assertThat(rejectedActual) + .containsEntry("included_in_source_deduction", false) + .containsEntry("uncreated_reason_codes", List.of("RATE_CODE_REQUIRED")); + } + + @Test + void shouldWarnAboutInsufficientSourceButKeepSafeActualAwaitingConfirmation() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.CandidateDecision decision = decision( + meta, + evidence, + List.of(newActual("actual-3", "ACT260808C", 12, "QBD-850", evidence))); + + BookingContracts.ValidationResult validation = validator.validate(decision, context(meta, evidence)); + BookingContracts.ConfirmationProjection projection = projectionFactory.project(decision, validation); + + assertThat(validation.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.VALID); + assertThat(validation.blockers()).extracting(BookingContracts.ContractIssue::code) + .doesNotContain("ALLOTMENT_SOURCE_INSUFFICIENT"); + assertThat(validation.businessChecks()).flatExtracting(BookingContracts.ValidationCheck::issues) + .extracting(BookingContracts.ContractIssue::code) + .contains("ALLOTMENT_SOURCE_INSUFFICIENT"); + assertThat(projection.projectionStatus()).isEqualTo(BookingContracts.ConfirmationStatus.AWAITING_CONFIRMATION); + assertThat(relationFor(projection, "actual-3")) + .containsEntry("source_deduction_status", "SOURCE_INSUFFICIENT") + .containsEntry("source_remaining_room_items", List.of(Map.of("room_type_code", "RM3", "room_count", 0))); + } + + private Map relationFor( + BookingContracts.ConfirmationProjection projection, + String targetDecisionId) { + BookingContracts.ConfirmationItem item = projection.confirmationItems().stream() + .filter(candidate -> targetDecisionId.equals(candidate.targetDecisionId())) + .findFirst() + .orElseThrow(); + return item.linkedActions().stream() + .filter(action -> "ALLOTMENT_SOURCE_DEDUCTION".equals(action.get("hint_type"))) + .findFirst() + .orElseThrow(); + } + + private List> actualTargets(Map relationship) { + Object rawTargets = relationship.get("actual_targets"); + assertThat(rawTargets).isInstanceOf(List.class); + return ((List) rawTargets).stream() + .map(rawTarget -> { + assertThat(rawTarget).isInstanceOf(Map.class); + Map target = new LinkedHashMap<>(); + ((Map) rawTarget).forEach((key, value) -> target.put(String.valueOf(key), value)); + return target; + }) + .toList(); + } + + private BookingContracts.CandidateDecision decision( + BookingContracts.ContractMeta meta, + List evidence, + List targets) { + return new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.DETERMINISTIC, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + targets, + List.of(), + null, + List.of()); + } + + private BookingContracts.TargetDecision newActual( + String targetId, + String groupCode, + int roomCount, + String rateCode, + List evidence) { + Map fields = new LinkedHashMap<>(); + fields.put("booking_type", "GROUP"); + fields.put("group_code", groupCode); + fields.put("group_name", null); + fields.put("tour_code", groupCode); + fields.put("names", List.of()); + fields.put("arrival_date", "2026-08-12"); + fields.put("departure_date", "2026-08-14"); + fields.put("room_items", List.of(new BookingContracts.RoomItemFact("RM3", roomCount, "850", evidence))); + fields.put("rate_code", rateCode); + return new BookingContracts.TargetDecision( + targetId, + "NEW_BOOKING", + "CREATE", + new BookingContracts.TargetIdentity(groupCode, null, groupCode, List.of(), evidence), + fields, + List.of(allotmentLink(groupCode)), + evidence, + List.of()); + } + + private Map allotmentLink(String actualGroupCode) { + Map action = new LinkedHashMap<>(); + action.put("hint_type", "ALLOTMENT_SOURCE_DEDUCTION"); + action.put("relationship_id", "allotment-rel-001"); + action.put("source_group_code", "LTQ260806"); + action.put("actual_group_code", actualGroupCode); + action.put("source_instruction_present", true); + action.put("source_exists", true); + action.put("source_active", true); + action.put("source_old_room_items", List.of(Map.of("room_type_code", "RM3", "room_count", 10))); + return action; + } + + private BookingContracts.ContextPackage context( + BookingContracts.ContractMeta meta, + List evidence) { + return new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(), + Map.of(), + Map.of(), + Map.of(), + Map.of(), + List.of()); + } + + private BookingContracts.ContractMeta meta() { + return new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-test-v1", + "parser-test-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-allotment-001", + "source-allotment-001", + 0); + } + + private List evidence(BookingContracts.ContractMeta meta) { + return List.of(new BookingContracts.EvidenceReference( + "evidence-allotment-001", + BookingContracts.EvidenceKind.WORKBOOK_ROW, + meta.sourceMessageId(), + "sheet:booking;row:4", + "a".repeat(64), + true)); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImplTest.java new file mode 100644 index 0000000..bad51b1 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingConfirmationProjectionFactoryImplTest.java @@ -0,0 +1,99 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BookingConfirmationProjectionFactoryImplTest { + + private final BookingDecisionValidatorImpl validator = new BookingDecisionValidatorImpl(); + private final BookingConfirmationProjectionFactoryImpl factory = new BookingConfirmationProjectionFactoryImpl(); + + @Test + void shouldExposeRiskButKeepClearTargetAwaitingConfirmation() { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-test-v1", + "parser-test-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-projection-001", + "source-projection-001", + 0); + List evidence = List.of(new BookingContracts.EvidenceReference( + "evidence-projection-001", + BookingContracts.EvidenceKind.WORKBOOK_ROW, + meta.sourceMessageId(), + "sheet:booking;row:4", + "c".repeat(64), + true)); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-projection-001", + "NEW_BOOKING", + "CREATE", + new BookingContracts.TargetIdentity("LTQ260808D", null, "LTQ260808D", List.of(), evidence), + validNewBookingFields(), + List.of(), + evidence, + List.of()); + BookingContracts.ContractIssue riskIssue = new BookingContracts.ContractIssue( + "ACTION_UNRESOLVED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + "booking.decision.action_unresolved", + evidence, + false); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.MIXED, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(new BookingContracts.MessageNotification( + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + "S99", + evidence, + List.of(riskIssue))), + null, + List.of(riskIssue)); + BookingContracts.ContextPackage context = new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(), + Map.of(), + Map.of(), + Map.of(), + Map.of(), + List.of()); + + BookingContracts.ValidationResult validation = validator.validate(decision, context); + BookingContracts.ConfirmationProjection projection = factory.project(decision, validation); + + assertThat(projection.projectionStatus()) + .isEqualTo(BookingContracts.ConfirmationStatus.AWAITING_CONFIRMATION); + assertThat(projection.processingStatus()) + .isEqualTo(BookingContracts.ProcessingStatus.AWAITING_CONFIRMATION); + assertThat(projection.confirmationItems()).extracting(BookingContracts.ConfirmationItem::disposition) + .containsExactlyInAnyOrder( + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + BookingContracts.ResultDisposition.RISK_NOTIFICATION); + } + + private Map validNewBookingFields() { + Map fields = new LinkedHashMap<>(); + fields.put("booking_type", "GROUP"); + fields.put("group_code", "LTQ260808D"); + fields.put("tour_code", "LTQ260808D"); + fields.put("group_name", null); + fields.put("names", List.of()); + fields.put("arrival_date", "2026-08-12"); + fields.put("departure_date", "2026-08-14"); + fields.put("room_items", List.of(new BookingContracts.RoomItemFact("U-TWN", 6, "850", List.of()))); + fields.put("rate_code", "GRPA1"); + return fields; + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImplTest.java new file mode 100644 index 0000000..304d781 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContextAssemblerImplTest.java @@ -0,0 +1,495 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingTargetStateSnapshot; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingCurrentStateRepository; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; + +class BookingContextAssemblerImplTest { + + private final BookingDecisionValidatorImpl validator = new BookingDecisionValidatorImpl(); + + @Test + void shouldResolveUpdateAgainstAnActiveConfirmedNewChainWithOneDirectLookup() { + BookingCurrentStateRepository repository = mock(BookingCurrentStateRepository.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(repository); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260808A")).thenReturn(List.of( + state(101L, "LTQ260808A", "NEW_BOOKING", "CONFIRMED", "2026-08-08T00:00:00Z"))); + BookingContextAssemblerImpl assembler = new BookingContextAssemblerImpl(provider); + BookingContracts.ParsedFactSet facts = factSet("UPDATE_BOOKING", "LTQ260808A"); + + BookingContracts.ContextPackage context = assembler.assemble("hotel-wyndham", facts); + + assertThat(context.targetResolutions()).singleElement().satisfies(resolution -> { + assertThat(resolution.resolutionStatus()).isEqualTo("RESOLVED"); + assertThat(resolution.candidateTargetIds()).containsExactly("candidate-101"); + }); + assertThat(context.lifecycleContext()).containsEntry("requires_lifecycle_validation", true); + verify(repository).findByGroupCode("hotel-wyndham", "LTQ260808A"); + + BookingContracts.ValidationResult result = validator.validate(updateDecision(facts.contractMeta(), facts.evidenceRefs()), context); + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.VALID); + } + + @Test + void shouldKeepNewBookingAsReviewWhenSameGroupHasAnActiveConfirmedChain() { + BookingCurrentStateRepository repository = mock(BookingCurrentStateRepository.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(repository); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260808B")).thenReturn(List.of( + state(102L, "LTQ260808B", "NEW_BOOKING", "CONFIRMED", "2026-08-07T00:00:00Z"))); + BookingContextAssemblerImpl assembler = new BookingContextAssemblerImpl(provider); + BookingContracts.ParsedFactSet facts = factSet("NEW_BOOKING", "LTQ260808B"); + + BookingContracts.ContextPackage context = assembler.assemble("hotel-wyndham", facts); + BookingContracts.ValidationResult result = validator.validate(newBookingDecision( + facts.contractMeta(), + facts.evidenceRefs(), + "LTQ260808B"), context); + + assertThat(context.targetResolutions()).singleElement().satisfies(resolution -> { + assertThat(resolution.resolutionStatus()).isEqualTo("CONFLICT"); + assertThat(resolution.issues()).extracting(BookingContracts.ContractIssue::code) + .containsExactly("NEW_BOOKING_POSSIBLE_DUPLICATE"); + }); + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .contains("NEW_BOOKING_POSSIBLE_DUPLICATE"); + } + + @Test + void shouldBlockCancelAfterTheTargetWasAlreadyConfirmedCancelled() { + BookingCurrentStateRepository repository = mock(BookingCurrentStateRepository.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(repository); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260808C")).thenReturn(List.of( + state(103L, "LTQ260808C", "CANCEL_BOOKING", "CONFIRMED", "2026-08-08T01:00:00Z"), + state(102L, "LTQ260808C", "NEW_BOOKING", "CONFIRMED", "2026-08-07T00:00:00Z"))); + BookingContextAssemblerImpl assembler = new BookingContextAssemblerImpl(provider); + BookingContracts.ParsedFactSet facts = factSet("CANCEL_BOOKING", "LTQ260808C"); + + BookingContracts.ContextPackage context = assembler.assemble("hotel-wyndham", facts); + BookingContracts.ValidationResult result = validator.validate(cancelDecision( + facts.contractMeta(), + facts.evidenceRefs(), + "LTQ260808C"), context); + + assertThat(context.targetResolutions()).singleElement().satisfies(resolution -> { + assertThat(resolution.resolutionStatus()).isEqualTo("CONFLICT"); + assertThat(resolution.issues()).extracting(BookingContracts.ContractIssue::code) + .containsExactly("TARGET_ALREADY_CANCELLED"); + }); + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .contains("TARGET_ALREADY_CANCELLED"); + } + + @Test + void shouldAssembleAllotmentSourceInventoryByDirectSourceGroupLookupOnly() { + BookingCurrentStateRepository repository = mock(BookingCurrentStateRepository.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(repository); + when(repository.findByGroupCode("hotel-wyndham", "ACT260808A")).thenReturn(List.of()); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260806")).thenReturn(List.of( + state( + 104L, + "LTQ260806", + "NEW_BOOKING", + "CONFIRMED", + "2026-08-07T00:00:00Z", + "[{\"room_type_code_hint\":\"RM3\",\"room_count\":10}]"))); + BookingContextAssemblerImpl assembler = new BookingContextAssemblerImpl(provider); + BookingContracts.ParsedFactSet facts = allotmentFactSet(); + + BookingContracts.ContextPackage context = assembler.assemble("hotel-wyndham", facts); + + assertThat(context.allotmentContext().get("relationships")).asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LIST) + .singleElement().asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.MAP) + .containsEntry("source_group_code", "LTQ260806") + .containsEntry("source_active", true) + .containsEntry("source_old_room_items", List.of(Map.of("room_type_code", "RM3", "room_count", 10))); + verify(repository).findByGroupCode("hotel-wyndham", "ACT260808A"); + verify(repository).findByGroupCode("hotel-wyndham", "LTQ260806"); + } + + @Test + void shouldResolveAnExplicitGroupCodeReplacementAgainstOldActiveAndNewAbsentWithTwoDirectLookups() { + BookingCurrentStateRepository repository = mock(BookingCurrentStateRepository.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(repository); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260806")).thenReturn(List.of( + state(201L, "LTQ260806", "NEW_BOOKING", "CONFIRMED", "2026-08-07T00:00:00Z"))); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260807")).thenReturn(List.of()); + BookingContextAssemblerImpl assembler = new BookingContextAssemblerImpl(provider); + BookingContracts.ParsedFactSet facts = groupCodeReplacementFactSet("LTQ260806", "LTQ260807", "group-replacement-001"); + + BookingContracts.ContextPackage context = assembler.assemble("hotel-wyndham", facts); + BookingContracts.ValidationResult result = validator.validate(groupCodeReplacementUpdateDecision( + facts.contractMeta(), + facts.evidenceRefs(), + "LTQ260806", + "LTQ260807", + "group-replacement-001"), context); + + assertThat(context.targetResolutions()).singleElement().satisfies(resolution -> { + assertThat(resolution.targetReference()).isEqualTo("LTQ260807"); + assertThat(resolution.resolutionStatus()).isEqualTo("RESOLVED"); + assertThat(resolution.candidateTargetIds()).containsExactly("candidate-201"); + }); + assertThat(context.lifecycleContext().get("group_code_replacements")) + .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LIST) + .singleElement().asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.MAP) + .containsEntry("old_group_code", "LTQ260806") + .containsEntry("new_group_code", "LTQ260807") + .containsEntry("resolution_status", "RESOLVED"); + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.VALID); + verify(repository).findByGroupCode("hotel-wyndham", "LTQ260806"); + verify(repository).findByGroupCode("hotel-wyndham", "LTQ260807"); + } + + @Test + void shouldKeepGroupCodeReplacementInReviewWhenTheNewGroupAlreadyHasAnActiveChain() { + BookingCurrentStateRepository repository = mock(BookingCurrentStateRepository.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(repository); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260806")).thenReturn(List.of( + state(202L, "LTQ260806", "NEW_BOOKING", "CONFIRMED", "2026-08-07T00:00:00Z"))); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260807")).thenReturn(List.of( + state(203L, "LTQ260807", "NEW_BOOKING", "CONFIRMED", "2026-08-08T00:00:00Z"))); + BookingContextAssemblerImpl assembler = new BookingContextAssemblerImpl(provider); + BookingContracts.ParsedFactSet facts = groupCodeReplacementFactSet("LTQ260806", "LTQ260807", "group-replacement-002"); + + BookingContracts.ContextPackage context = assembler.assemble("hotel-wyndham", facts); + BookingContracts.ValidationResult result = validator.validate(groupCodeReplacementUpdateDecision( + facts.contractMeta(), + facts.evidenceRefs(), + "LTQ260806", + "LTQ260807", + "group-replacement-002"), context); + + assertThat(context.targetResolutions()).singleElement().satisfies(resolution -> { + assertThat(resolution.resolutionStatus()).isEqualTo("CONFLICT"); + assertThat(resolution.issues()).extracting(BookingContracts.ContractIssue::code) + .containsExactly("GROUP_CODE_REPLACEMENT_NEW_GROUP_ACTIVE"); + }); + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .contains("GROUP_CODE_REPLACEMENT_NEW_GROUP_ACTIVE"); + } + + @Test + void shouldExposeCrossMessageTraceHistoryInChronologicalOrderWithoutHistoricalFreeText() { + BookingCurrentStateRepository repository = mock(BookingCurrentStateRepository.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(repository); + when(repository.findByGroupCode("hotel-wyndham", "LTQ260808T")).thenReturn(List.of( + state( + 302L, + "LTQ260808T", + "UPDATE_BOOKING", + "CONFIRMED", + "2026-08-08T00:00:00Z", + "[]", + "[{\"hint_type\":\"TRACE_RESERVATION_NOTES\",\"department_code\":\"HSK\"," + + "\"item_type\":\"HONEYMOON\",\"text\":\"must-not-leak-new\"}]"), + state( + 301L, + "LTQ260808T", + "NEW_BOOKING", + "CONFIRMED", + "2026-08-07T00:00:00Z", + "[]", + "[{\"hint_type\":\"TRACE_RESERVATION_NOTES\",\"department_code\":\"FO\"," + + "\"item_type\":\"EXTRA_BED\",\"text\":\"must-not-leak-old\"}]"))); + BookingContextAssemblerImpl assembler = new BookingContextAssemblerImpl(provider); + BookingContracts.ParsedFactSet facts = factSet("UPDATE_BOOKING", "LTQ260808T"); + + BookingContracts.ContextPackage context = assembler.assemble("hotel-wyndham", facts); + + assertThat(context.lifecycleContext().get("trace_history_by_group_code")) + .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.MAP) + .extractingByKey("LTQ260808T") + .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.LIST) + .extracting("candidate_target_id", "department_code", "item_type") + .containsExactly( + org.assertj.core.groups.Tuple.tuple("candidate-301", "FO", "EXTRA_BED"), + org.assertj.core.groups.Tuple.tuple("candidate-302", "HSK", "HONEYMOON")); + String serialized = context.lifecycleContext().get("trace_history_by_group_code").toString(); + assertThat(serialized).doesNotContain("must-not-leak-old", "must-not-leak-new", "text="); + } + + private BookingContracts.ParsedFactSet factSet(String action, String groupCode) { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-test-v1", + "parser-test-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-context-" + groupCode, + "source-context-" + groupCode, + 0); + List evidence = List.of(new BookingContracts.EvidenceReference( + "evidence-context-" + groupCode, + BookingContracts.EvidenceKind.WORKBOOK_ROW, + meta.sourceMessageId(), + "sheet:booking;row:4", + "d".repeat(64), + true)); + BookingContracts.ParsedFact fact = new BookingContracts.ParsedFact( + "fact-context-" + groupCode, + action, + "GROUP", + new BookingContracts.TargetIdentity(groupCode, null, groupCode, List.of(), evidence), + new BookingContracts.StayFact( + "2026-08-12", + "2026-08-14", + 2, + List.of(new BookingContracts.RoomItemFact("U-TWN", 6, "850", evidence)), + evidence), + List.of(), + evidence); + return new BookingContracts.ParsedFactSet( + meta, + evidence, + BookingContracts.ParserApplicability.APPLICABLE, + "QBD_MONTHLY", + List.of(fact), + List.of(), + List.of(), + List.of()); + } + + private BookingContracts.CandidateDecision updateDecision( + BookingContracts.ContractMeta meta, + List evidence) { + return targetDecision(meta, evidence, "UPDATE_BOOKING", "LTQ260808A", Map.of()); + } + + private BookingContracts.CandidateDecision cancelDecision( + BookingContracts.ContractMeta meta, + List evidence, + String groupCode) { + return targetDecision(meta, evidence, "CANCEL_BOOKING", groupCode, Map.of()); + } + + private BookingContracts.CandidateDecision newBookingDecision( + BookingContracts.ContractMeta meta, + List evidence, + String groupCode) { + Map fields = new LinkedHashMap<>(); + fields.put("booking_type", "GROUP"); + fields.put("group_code", groupCode); + fields.put("tour_code", groupCode); + fields.put("group_name", null); + fields.put("names", List.of()); + fields.put("arrival_date", "2026-08-12"); + fields.put("departure_date", "2026-08-14"); + fields.put("room_items", List.of(new BookingContracts.RoomItemFact("U-TWN", 6, "850", evidence))); + fields.put("rate_code", "QBD-850"); + return targetDecision(meta, evidence, "NEW_BOOKING", groupCode, fields); + } + + private BookingContracts.CandidateDecision targetDecision( + BookingContracts.ContractMeta meta, + List evidence, + String businessType, + String groupCode, + Map suppliedFields) { + Map fields = new LinkedHashMap<>(); + fields.put("group_code", groupCode); + fields.put("tour_code", groupCode); + fields.put("names", List.of()); + fields.putAll(suppliedFields); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-" + businessType + "-" + groupCode, + businessType, + "CANCEL_BOOKING".equals(businessType) ? "CANCEL" : "UPDATE_BOOKING".equals(businessType) ? "AMEND" : "CREATE", + new BookingContracts.TargetIdentity(groupCode, null, groupCode, List.of(), evidence), + fields, + List.of(), + evidence, + List.of()); + return new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.DETERMINISTIC, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + null, + List.of()); + } + + private BookingContracts.ParsedFactSet groupCodeReplacementFactSet( + String oldGroupCode, + String newGroupCode, + String relationshipId) { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-test-v1", + "parser-test-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-replacement-" + relationshipId, + "source-replacement-" + relationshipId, + 0); + List evidence = List.of(new BookingContracts.EvidenceReference( + "evidence-replacement-" + relationshipId, + BookingContracts.EvidenceKind.CURRENT_BODY, + meta.sourceMessageId(), + "current-body", + "e".repeat(64), + true)); + BookingContracts.ParsedFact fact = new BookingContracts.ParsedFact( + "fact-replacement-" + relationshipId, + null, + null, + new BookingContracts.TargetIdentity(newGroupCode, null, newGroupCode, List.of(), evidence), + null, + List.of(new BookingContracts.RelatedHint( + "GROUP_CODE_REPLACEMENT", + Map.of( + "relationship_id", relationshipId, + "old_group_code", oldGroupCode, + "new_group_code", newGroupCode, + "source_instruction_present", true), + evidence)), + evidence); + return new BookingContracts.ParsedFactSet( + meta, + evidence, + BookingContracts.ParserApplicability.NOT_APPLICABLE, + null, + List.of(fact), + List.of(), + List.of("NO_FIXED_CHANNEL_EXCEL"), + List.of()); + } + + private BookingContracts.CandidateDecision groupCodeReplacementUpdateDecision( + BookingContracts.ContractMeta meta, + List evidence, + String oldGroupCode, + String newGroupCode, + String relationshipId) { + Map fields = new LinkedHashMap<>(); + fields.put("group_code", newGroupCode); + fields.put("tour_code", newGroupCode); + fields.put("names", List.of()); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-replacement-" + relationshipId, + "UPDATE_BOOKING", + "AMEND", + new BookingContracts.TargetIdentity(newGroupCode, null, newGroupCode, List.of(), evidence), + fields, + List.of(Map.of( + "hint_type", "GROUP_CODE_REPLACEMENT", + "relationship_id", relationshipId, + "old_group_code", oldGroupCode, + "new_group_code", newGroupCode, + "source_instruction_present", true)), + evidence, + List.of()); + return new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + "agent-safe-ref", + List.of()); + } + + private BookingContracts.ParsedFactSet allotmentFactSet() { + BookingContracts.ParsedFactSet base = factSet("NEW_BOOKING", "ACT260808A"); + BookingContracts.ParsedFact original = base.facts().get(0); + BookingContracts.ParsedFact actual = new BookingContracts.ParsedFact( + original.factId(), + original.actionHint(), + original.bookingType(), + original.targetIdentity(), + original.stay(), + List.of(new BookingContracts.RelatedHint( + "ALLOTMENT_SOURCE", + Map.of( + "relationship_id", "allotment-rel-001", + "source_group_code", "LTQ260806", + "actual_group_code", "ACT260808A", + "source_instruction_present", true), + original.evidenceRefs())), + original.evidenceRefs()); + return new BookingContracts.ParsedFactSet( + base.contractMeta(), + base.evidenceRefs(), + base.parserApplicability(), + base.channelProfileCode(), + List.of(actual), + base.unresolvedFields(), + base.fallbackReasons(), + base.issues()); + } + + private BookingTargetStateSnapshot state( + long id, + String groupCode, + String businessType, + String status, + String createdAt) { + return state(id, groupCode, businessType, status, createdAt, "[]"); + } + + private BookingTargetStateSnapshot state( + long id, + String groupCode, + String businessType, + String status, + String createdAt, + String roomItemsJson) { + return new BookingTargetStateSnapshot( + id, + groupCode, + businessType, + "CREATE", + "VALID", + status, + OffsetDateTime.parse(createdAt), + roomItemsJson); + } + + private BookingTargetStateSnapshot state( + long id, + String groupCode, + String businessType, + String status, + String createdAt, + String roomItemsJson, + String linkedActionsJson) { + return new BookingTargetStateSnapshot( + id, + groupCode, + businessType, + "CREATE", + "VALID", + status, + OffsetDateTime.parse(createdAt), + roomItemsJson, + linkedActionsJson); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssemblerTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssemblerTest.java new file mode 100644 index 0000000..51e6863 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingContractAssemblerTest.java @@ -0,0 +1,278 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageAttachmentInput; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageInput; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationBookingCatalog; +import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationBookingEmailParser; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.List; +import org.apache.poi.ss.usermodel.CellStyle; +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.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +class BookingContractAssemblerTest { + + private final ReservationBookingCatalog catalog = new ReservationBookingCatalog(new ObjectMapper()); + private final BookingContractAssembler assembler = new BookingContractAssembler( + new ReservationBookingEmailParser(catalog), + catalog); + + @Test + void shouldCreateRoomingListFactFromExplicitCurrentSignalWithoutReadingGuestRows() { + BookingMessageInput input = input( + "Rooming List is attached", + "Please review. Tour Code: LTQ260808A", + List.of(attachment( + "rooming-001", + "LTQ260808A-rooming-list.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + null))); + + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-rooming-001"); + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.parserApplicability()).isEqualTo(BookingContracts.ParserApplicability.NOT_APPLICABLE); + assertThat(facts.facts()).singleElement().satisfies(fact -> { + assertThat(fact.actionHint()).isNull(); + assertThat(fact.targetIdentity().groupCode()).isEqualTo("LTQ260808A"); + assertThat(fact.relatedHints()).singleElement().satisfies(hint -> { + assertThat(hint.hintType()).isEqualTo("ROOMING_LIST"); + assertThat(hint.attributes()).containsEntry("attachment_present", true) + .containsEntry("target_identified", true) + .containsEntry("attachment_ids", List.of("rooming-001")); + }); + }); + } + + @Test + void shouldCreatePaymentFactOnlyWhenCurrentTextAndImageBothProvideExplicitEvidence() { + BookingMessageInput input = input( + "Payment receipt", + "Please replace the payment proof. Group Code: LTQ260808B", + List.of(attachment("payment-001", "receipt.png", "image/png", null))); + + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-payment-001"); + BookingContracts.MaterialPackage material = assembler.materialPackage(input, envelope); + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, material); + + assertThat(material.imageInspections()).singleElement() + .extracting(BookingContracts.ImageInspection::attachmentId) + .isEqualTo("payment-001"); + assertThat(facts.facts()).singleElement().satisfies(fact -> { + assertThat(fact.targetIdentity().groupCode()).isEqualTo("LTQ260808B"); + assertThat(fact.relatedHints()).singleElement() + .extracting(BookingContracts.RelatedHint::hintType) + .isEqualTo("PAYMENT"); + }); + } + + @Test + void shouldNotTreatAnArbitraryVoucherImageAsPaymentOrRoomingList() { + BookingMessageInput input = input( + "Voucher", + "Please see the attached image.", + List.of(attachment("image-001", "voucher.jpg", "image/jpeg", null))); + + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-voucher-001"); + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.parserApplicability()).isEqualTo(BookingContracts.ParserApplicability.NOT_APPLICABLE); + assertThat(facts.facts()).isEmpty(); + } + + @Test + void shouldProvideOnlyBoundedRedactedCurrentMessageMaterialForAgentFallback() { + String currentBody = "Please update Group Code LTQ260808A. Contact guest@example.com; " + + "review https://private-attachment.example/path; api_token=top-secret-value.\n" + + "x".repeat(12_500); + BookingMessageInput input = input("Update booking", currentBody, List.of()); + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-agent-material-001"); + + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.agentInputMaterial()).isNotNull().satisfies(material -> { + assertThat(material.evidenceId()).isEqualTo(envelope.currentBodyEvidenceId()); + assertThat(material.currentMessageText()) + .contains("LTQ260808A") + .doesNotContain("guest@example.com") + .doesNotContain("https://private-attachment.example") + .doesNotContain("top-secret-value") + .doesNotContain("Older quoted history must not trigger a task."); + assertThat(material.truncated()).isTrue(); + assertThat(material.currentMessageText().codePointCount(0, material.currentMessageText().length())) + .isLessThanOrEqualTo(12_000); + }); + } + + @Test + void shouldExtractOnlyAnExplicitCurrentBodyOldToNewGroupCodeRelationForContextAndAgent() { + BookingMessageInput input = input( + "Group code update", + "Please change Group Code from LTQ260806 to LTQ260807 for this booking.", + List.of()); + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-group-replacement-001"); + + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.parserApplicability()).isEqualTo(BookingContracts.ParserApplicability.NOT_APPLICABLE); + assertThat(facts.facts()).singleElement().satisfies(fact -> { + assertThat(fact.actionHint()).isNull(); + assertThat(fact.targetIdentity().groupCode()).isEqualTo("LTQ260807"); + assertThat(fact.relatedHints()).singleElement().satisfies(hint -> { + assertThat(hint.hintType()).isEqualTo("GROUP_CODE_REPLACEMENT"); + assertThat(hint.attributes()) + .containsEntry("old_group_code", "LTQ260806") + .containsEntry("new_group_code", "LTQ260807") + .containsEntry("source_instruction_present", true); + }); + }); + assertThat(facts.agentInputMaterial().currentMessageText()) + .contains("LTQ260806", "LTQ260807") + .doesNotContain("Older quoted history must not trigger a task."); + } + + @Test + void shouldNotInferAReplacementFromTwoUnlabelledGroupCodes() { + BookingMessageInput input = input( + "Update", + "Please review LTQ260806 together with LTQ260807 before booking.", + List.of()); + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-no-replacement-001"); + + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.facts()).isEmpty(); + assertThat(facts.fallbackReasons()).doesNotContain("GROUP_CODE_REPLACEMENT_AMBIGUOUS"); + } + + @Test + void shouldProvideAnExplicitGroupCodeAsContextOnlyClueForTraceAgentFallback() { + BookingMessageInput input = input( + "Trace request", + "Please add an extra bed. Tour Code: LTQ260808T", + List.of()); + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-trace-context-001"); + + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.facts()).singleElement().satisfies(fact -> { + assertThat(fact.actionHint()).isNull(); + assertThat(fact.targetIdentity().groupCode()).isEqualTo("LTQ260808T"); + assertThat(fact.relatedHints()).isEmpty(); + }); + assertThat(facts.parserApplicability()).isEqualTo(BookingContracts.ParserApplicability.NOT_APPLICABLE); + } + + @Test + void shouldBindAUniqueAllotmentSourceToEveryParsedNewActualWithoutCreatingSourceFact() throws Exception { + byte[] workbook = qbdTemplateNewBookingWorkbook("ACT260808A", 6); + BookingMessageInput input = input( + "New booking via allotment", + "Use Allotment from LTQ260806 for the current booking.", + List.of(attachment( + "qbd-actual-001", + "LT_WYNDHAM_JULY_样板.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + workbook))); + + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-allotment-001"); + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.parserApplicability()).isEqualTo(BookingContracts.ParserApplicability.APPLICABLE); + assertThat(facts.facts()).singleElement().satisfies(fact -> { + assertThat(fact.actionHint()).isEqualTo("NEW_BOOKING"); + assertThat(fact.targetIdentity().groupCode()).isEqualTo("ACT260808A"); + assertThat(fact.relatedHints()).singleElement().satisfies(hint -> { + assertThat(hint.hintType()).isEqualTo("ALLOTMENT_SOURCE"); + assertThat(hint.attributes()).containsEntry("source_group_code", "LTQ260806") + .containsEntry("actual_group_code", "ACT260808A") + .containsEntry("source_instruction_present", true); + }); + }); + } + + @Test + void shouldIsolateAnAllotmentInstructionWithoutUniqueSourceAsASeparateUnresolvedFact() throws Exception { + byte[] workbook = qbdTemplateNewBookingWorkbook("ACT260808B", 6); + BookingMessageInput input = input( + "New booking via allotment", + "Use Allotment for the current booking.", + List.of(attachment( + "qbd-actual-002", + "LT_WYNDHAM_JULY_样板.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + workbook))); + + BookingContracts.SourceMessageEnvelope envelope = assembler.sourceMessageEnvelope(input, "run-allotment-unresolved-001"); + BookingContracts.ParsedFactSet facts = assembler.parsedFactSet(input, assembler.materialPackage(input, envelope)); + + assertThat(facts.facts()).hasSize(2); + assertThat(facts.facts()).anySatisfy(fact -> assertThat(fact.factId()).isEqualTo("material-allotment-unresolved")); + } + + private BookingMessageInput input( + String subject, + String currentBody, + List attachments) { + return new BookingMessageInput( + "hotel-wyndham", + BookingContracts.MessageOrigin.MANUAL_EML, + "source-material-test-001-" + subject.replaceAll("\\s+", "-").toLowerCase(), + "external-material-test-001", + "conversation-material-test-001", + null, + "idempotency-material-test-001-" + subject.replaceAll("\\s+", "-").toLowerCase(), + Instant.parse("2026-08-08T00:00:00Z"), + subject, + currentBody, + "Older quoted history must not trigger a task.", + attachments); + } + + private BookingMessageAttachmentInput attachment( + String id, + String fileName, + String mediaType, + byte[] bytes) { + return new BookingMessageAttachmentInput( + id, + fileName, + mediaType, + 1024, + (id + "-hash-").repeat(8), + bytes, + true); + } + + private byte[] qbdTemplateNewBookingWorkbook(String groupCode, int roomCount) throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + Sheet sheet = workbook.createSheet("BOOKING_JULY.26"); + Row header = sheet.createRow(2); + header.createCell(1).setCellValue("Tour Code"); + header.createCell(3).setCellValue("โรงแรม"); + header.createCell(5).setCellValue("Ratecode"); + header.createCell(6).setCellValue("หมายเหุต"); + CellStyle fill = workbook.createCellStyle(); + fill.setFillForegroundColor(IndexedColors.LIGHT_ORANGE.getIndex()); + fill.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Row current = sheet.createRow(3); + for (int column = 1; column <= 7; column++) { + current.createCell(column).setCellStyle(fill); + } + current.getCell(1).setCellValue(groupCode); + current.getCell(3).setCellValue("12-14 Jomtien Wyndham Pattaya (【U-TWN】 " + roomCount + ")(850 office pay)"); + current.getCell(6).setCellValue("08/08/2026 NEW BOOKING"); + workbook.write(output); + return output.toByteArray(); + } + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipelineTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipelineTest.java new file mode 100644 index 0000000..6e88170 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionPipelineTest.java @@ -0,0 +1,96 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageMasterData; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingProcessingRunRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BookingDecisionPipelineTest { + + @Test + void shouldPersistIgnoredOutboundDispositionWithoutCallingDecisionServiceOrCreatingTargets() { + BookingProcessingRunRepository repository = mock(BookingProcessingRunRepository.class); + BookingDecisionService decisionService = mock(BookingDecisionService.class); + BookingDecisionPipeline pipeline = new BookingDecisionPipeline( + repository, + decisionService, + new BookingDecisionValidatorImpl(), + new BookingConfirmationProjectionFactoryImpl(), + new ObjectMapper()); + BookingContracts.ParsedFactSet parsedFactSet = parsedFactSet(); + + BookingDecisionPipeline.BookingPipelineCompletion completion = pipeline.completeIgnoredDirection( + "run-direction-001", + parsedFactSet, + context(parsedFactSet.contractMeta(), parsedFactSet.evidenceRefs()), + BookingMessageMasterData.MessageDirection.OUTBOUND); + + assertThat(completion.resultDisposition()).isEqualTo(BookingContracts.ResultDisposition.IGNORED_DIRECTION); + assertThat(completion.processingStatus()).isEqualTo(BookingContracts.ProcessingStatus.VALIDATED); + assertThat(completion.warningCodes()).contains("MESSAGE_DIRECTION_IGNORED"); + verify(decisionService, never()).decide(any(), any(), any(Boolean.class), any(Boolean.class)); + verify(repository).saveArtifact(eq("run-direction-001"), eq("CANDIDATE_DECISION"), any(), any(), any()); + verify(repository).saveArtifact(eq("run-direction-001"), eq("VALIDATION_RESULT"), any(), any(), any()); + verify(repository).saveArtifact(eq("run-direction-001"), eq("CONFIRMATION_PROJECTION"), any(), any(), any()); + verify(repository).saveCandidateTargets(eq("run-direction-001"), any(), any(), any(), eq(List.of())); + verify(repository).updateRunStatus( + "run-direction-001", + BookingContracts.ProcessingStatus.VALIDATED, + BookingContracts.ResultDisposition.IGNORED_DIRECTION, + null, + null); + } + + private BookingContracts.ParsedFactSet parsedFactSet() { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-v1", + "parser-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-direction-001", + "source-direction-001", + 0); + BookingContracts.EvidenceReference evidence = new BookingContracts.EvidenceReference( + "evidence-direction-001", + BookingContracts.EvidenceKind.CURRENT_BODY, + meta.sourceMessageId(), + "current-body", + "d".repeat(64), + true); + return new BookingContracts.ParsedFactSet( + meta, + List.of(evidence), + BookingContracts.ParserApplicability.NOT_APPLICABLE, + null, + List.of(), + List.of(), + List.of("NO_FIXED_CHANNEL"), + List.of()); + } + + private BookingContracts.ContextPackage context( + BookingContracts.ContractMeta meta, + List evidence) { + return new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(), + Map.of(), + Map.of(), + Map.of(), + Map.of(), + List.of()); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImplTest.java new file mode 100644 index 0000000..8353cbc --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionServiceImplTest.java @@ -0,0 +1,320 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingAgentDecisionService; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BookingDecisionServiceImplTest { + + @Test + void shouldCreateGeneralWithoutCallingAgentWhenCurrentMessageCertainlyHasNoTask() { + BookingAgentDecisionService agent = mock(BookingAgentDecisionService.class); + BookingDecisionServiceImpl service = new BookingDecisionServiceImpl(agent); + BookingContracts.ParsedFactSet parsedFacts = parsedFacts(BookingContracts.ParserApplicability.NOT_APPLICABLE); + BookingContracts.ContextPackage context = context(parsedFacts.contractMeta(), parsedFacts.evidenceRefs()); + + BookingContracts.CandidateDecision decision = service.decide(parsedFacts, context, false, false); + + assertThat(decision.resultDisposition()).isEqualTo(BookingContracts.ResultDisposition.GENERAL_NOTIFICATION); + assertThat(decision.messageNotifications()).singleElement() + .extracting(BookingContracts.MessageNotification::notificationCode) + .isEqualTo("S10"); + verifyNoInteractions(agent); + } + + @Test + void shouldCallAgentWhenBusinessSignalNeedsFallback() { + BookingAgentDecisionService agent = mock(BookingAgentDecisionService.class); + BookingDecisionServiceImpl service = new BookingDecisionServiceImpl(agent); + BookingContracts.ParsedFactSet parsedFacts = parsedFacts(BookingContracts.ParserApplicability.NOT_APPLICABLE); + BookingContracts.ContextPackage context = context(parsedFacts.contractMeta(), parsedFacts.evidenceRefs()); + BookingContracts.CandidateDecision expected = new BookingContracts.CandidateDecision( + parsedFacts.contractMeta(), + parsedFacts.evidenceRefs(), + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + List.of(), + List.of(new BookingContracts.MessageNotification( + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + "S99", + parsedFacts.evidenceRefs(), + List.of())), + "agent-run-safe-ref", + List.of()); + when(agent.decide(parsedFacts, context)).thenReturn(expected); + + BookingContracts.CandidateDecision decision = service.decide(parsedFacts, context, true, false); + + assertThat(decision).isSameAs(expected); + verify(agent).decide(parsedFacts, context); + } + + @Test + void shouldKeepClearTargetAndIsolateUnresolvedFactAsSingleRisk() { + BookingAgentDecisionService agent = mock(BookingAgentDecisionService.class); + BookingDecisionServiceImpl service = new BookingDecisionServiceImpl(agent); + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.ParsedFact knownFact = new BookingContracts.ParsedFact( + "fact-clear-001", + "NEW_BOOKING", + "GROUP", + new BookingContracts.TargetIdentity("LTQ260808A", null, "LTQ260808A", List.of(), evidence), + new BookingContracts.StayFact( + "2026-08-12", + "2026-08-14", + 2, + List.of(new BookingContracts.RoomItemFact("U-TWN", 6, "850", evidence)), + evidence), + List.of(), + evidence); + BookingContracts.ParsedFact unresolvedFact = new BookingContracts.ParsedFact( + "fact-unclear-002", + null, + null, + new BookingContracts.TargetIdentity("LTQ260808B", null, "LTQ260808B", List.of(), evidence), + null, + List.of(), + evidence); + BookingContracts.ParsedFactSet parsedFacts = new BookingContracts.ParsedFactSet( + meta, + evidence, + BookingContracts.ParserApplicability.APPLICABLE, + "QBD_MONTHLY", + List.of(knownFact, unresolvedFact), + List.of(), + List.of(), + List.of()); + + BookingContracts.CandidateDecision decision = service.decide( + parsedFacts, + context(meta, evidence), + true, + true); + + assertThat(decision.resultDisposition()).isEqualTo(BookingContracts.ResultDisposition.IN_SCOPE_TASKS); + assertThat(decision.targetDecisions()).extracting(BookingContracts.TargetDecision::targetDecisionId) + .containsExactly("fact-clear-001"); + assertThat(decision.messageNotifications()).singleElement() + .extracting(BookingContracts.MessageNotification::disposition) + .isEqualTo(BookingContracts.ResultDisposition.RISK_NOTIFICATION); + assertThat(decision.issues()).extracting(BookingContracts.ContractIssue::code) + .contains("ACTION_UNRESOLVED"); + verifyNoInteractions(agent); + } + + @Test + void shouldCreateRoomingListCandidateWithoutCallingAgentWhenSignalIsBoundToCurrentExcel() { + BookingAgentDecisionService agent = mock(BookingAgentDecisionService.class); + BookingDecisionServiceImpl service = new BookingDecisionServiceImpl(agent); + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.ParsedFact roomingFact = new BookingContracts.ParsedFact( + "material-rooming-list", + null, + null, + new BookingContracts.TargetIdentity("LTQ260808A", null, "LTQ260808A", List.of(), evidence), + null, + List.of(new BookingContracts.RelatedHint( + "ROOMING_LIST", + Map.of( + "attachment_present", true, + "attachment_ids", List.of("rooming-001"), + "target_identified", true), + evidence)), + evidence); + BookingContracts.ParsedFactSet parsedFacts = new BookingContracts.ParsedFactSet( + meta, + evidence, + BookingContracts.ParserApplicability.NOT_APPLICABLE, + null, + List.of(roomingFact), + List.of(), + List.of("NO_FIXED_CHANNEL_EXCEL"), + List.of()); + + BookingContracts.CandidateDecision decision = service.decide(parsedFacts, context(meta, evidence), true, true); + + assertThat(decision.decisionOrigin()).isEqualTo(BookingContracts.DecisionOrigin.DETERMINISTIC); + assertThat(decision.resultDisposition()).isEqualTo(BookingContracts.ResultDisposition.IN_SCOPE_TASKS); + assertThat(decision.targetDecisions()).singleElement().satisfies(target -> { + assertThat(target.businessType()).isEqualTo("ROOMING_LIST"); + assertThat(target.actionKind()).isEqualTo("DISPLAY_ROOMING_LIST"); + assertThat(target.fields()).containsEntry("attachment_ids", List.of("rooming-001")); + }); + verifyNoInteractions(agent); + } + + @Test + void shouldAttachDeterministicGroupCodeReplacementToMatchingAgentUpdate() { + BookingAgentDecisionService agent = mock(BookingAgentDecisionService.class); + BookingDecisionServiceImpl service = new BookingDecisionServiceImpl(agent); + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.ParsedFact replacementFact = new BookingContracts.ParsedFact( + "fact-group-replacement-001", + null, + null, + new BookingContracts.TargetIdentity("LTQ260807", null, "LTQ260807", List.of(), evidence), + null, + List.of(new BookingContracts.RelatedHint( + "GROUP_CODE_REPLACEMENT", + Map.of( + "relationship_id", "replacement-001", + "old_group_code", "LTQ260806", + "new_group_code", "LTQ260807", + "source_instruction_present", true), + evidence)), + evidence); + BookingContracts.ParsedFactSet parsedFacts = new BookingContracts.ParsedFactSet( + meta, + evidence, + BookingContracts.ParserApplicability.NOT_APPLICABLE, + null, + List.of(replacementFact), + List.of(), + List.of("NO_FIXED_CHANNEL_EXCEL"), + List.of()); + BookingContracts.ContextPackage context = context(meta, evidence); + BookingContracts.TargetDecision agentTarget = new BookingContracts.TargetDecision( + "agent-update-001", + "UPDATE_BOOKING", + "AMEND", + new BookingContracts.TargetIdentity("LTQ260807", null, "LTQ260807", List.of(), evidence), + Map.of("group_code", "LTQ260807", "tour_code", "LTQ260807", "names", List.of()), + List.of(), + evidence, + List.of()); + BookingContracts.CandidateDecision agentDecision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(agentTarget), + List.of(), + "agent-safe-ref", + List.of()); + when(agent.decide(parsedFacts, context)).thenReturn(agentDecision); + + BookingContracts.CandidateDecision decision = service.decide(parsedFacts, context, true, false); + + assertThat(decision.targetDecisions()).singleElement().satisfies(target -> { + assertThat(target.linkedActions()).singleElement() + .asInstanceOf(org.assertj.core.api.InstanceOfAssertFactories.MAP) + .containsEntry("hint_type", "GROUP_CODE_REPLACEMENT") + .containsEntry("old_group_code", "LTQ260806") + .containsEntry("new_group_code", "LTQ260807"); + }); + verify(agent).decide(parsedFacts, context); + } + + @Test + void shouldMergeSameMessageTraceTargetsForTheSameGroupButKeepEveryTraceAction() { + BookingAgentDecisionService agent = mock(BookingAgentDecisionService.class); + BookingDecisionServiceImpl service = new BookingDecisionServiceImpl(agent); + BookingContracts.ParsedFactSet parsedFacts = parsedFacts(BookingContracts.ParserApplicability.NOT_APPLICABLE); + BookingContracts.ContextPackage context = context(parsedFacts.contractMeta(), parsedFacts.evidenceRefs()); + BookingContracts.TargetDecision first = traceTarget( + parsedFacts.evidenceRefs(), "trace-001", "LTQ260808T", "FO", "EXTRA_BED"); + BookingContracts.TargetDecision second = traceTarget( + parsedFacts.evidenceRefs(), "trace-002", "LTQ260808T", "HSK", "HONEYMOON"); + BookingContracts.CandidateDecision agentDecision = new BookingContracts.CandidateDecision( + parsedFacts.contractMeta(), + parsedFacts.evidenceRefs(), + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(first, second), + List.of(), + "agent-safe-ref", + List.of()); + when(agent.decide(parsedFacts, context)).thenReturn(agentDecision); + + BookingContracts.CandidateDecision decision = service.decide(parsedFacts, context, true, false); + + assertThat(decision.targetDecisions()).singleElement().satisfies(target -> { + assertThat(target.targetDecisionId()).isEqualTo("trace-001"); + assertThat(target.linkedActions()).hasSize(2); + assertThat(target.fields()).containsEntry("department_codes", List.of("FO", "HSK")) + .containsEntry("merged_trace_target_ids", List.of("trace-001", "trace-002")); + }); + } + + private BookingContracts.ParsedFactSet parsedFacts(BookingContracts.ParserApplicability applicability) { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + return new BookingContracts.ParsedFactSet( + meta, + evidence, + applicability, + null, + List.of(), + List.of(), + List.of("NO_FIXED_CHANNEL_EXCEL"), + List.of()); + } + + private BookingContracts.ContextPackage context( + BookingContracts.ContractMeta meta, + List evidence) { + return new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(), + Map.of(), + Map.of(), + Map.of(), + Map.of(), + List.of()); + } + + private BookingContracts.ContractMeta meta() { + return new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-test-v1", + "parser-test-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-decision-001", + "source-decision-001", + 0); + } + + private List evidence(BookingContracts.ContractMeta meta) { + return List.of(new BookingContracts.EvidenceReference( + "evidence-decision-001", + BookingContracts.EvidenceKind.CURRENT_BODY, + meta.sourceMessageId(), + "current-body", + "a".repeat(64), + true)); + } + + private BookingContracts.TargetDecision traceTarget( + List evidence, + String targetDecisionId, + String groupCode, + String departmentCode, + String itemType) { + return new BookingContracts.TargetDecision( + targetDecisionId, + "TRACE_RESERVATION_NOTES", + "REVIEW", + new BookingContracts.TargetIdentity(groupCode, null, groupCode, List.of(), evidence), + Map.of( + "group_code", groupCode, + "tour_code", groupCode, + "department_code", departmentCode, + "item_type", itemType), + List.of(), + evidence, + List.of()); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImplTest.java new file mode 100644 index 0000000..8b4732b --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingDecisionValidatorImplTest.java @@ -0,0 +1,322 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BookingDecisionValidatorImplTest { + + private final BookingDecisionValidatorImpl validator = new BookingDecisionValidatorImpl(); + + @Test + void shouldAcceptGroupCodeAsNewBookingIdentityWithoutInventingNameOrGroupName() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + Map fields = validNewBookingFields(); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-new-group-001", + "NEW_BOOKING", + "CREATE", + new BookingContracts.TargetIdentity("LTQ260808A", null, "LTQ260808A", List.of(), evidence), + fields, + List.of(), + evidence, + List.of()); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.DETERMINISTIC, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + null, + List.of()); + + BookingContracts.ValidationResult result = validator.validate(decision, context(meta, evidence)); + + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.VALID); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .doesNotContain("NAME_REQUIRED", "GROUP_NAME_REQUIRED", "TARGET_IDENTITY_REQUIRED"); + } + + @Test + void shouldBlockTraceUntilDepartmentIsExplicitlySelected() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-trace-001", + "NEW_BOOKING", + "CREATE", + new BookingContracts.TargetIdentity("LTQ260808B", null, "LTQ260808B", List.of(), evidence), + validNewBookingFields(), + List.of(Map.of("hint_type", "TRACE_RESERVATION_NOTES", "item_type", "EXTRA_BED")), + evidence, + List.of()); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.DETERMINISTIC, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + null, + List.of()); + + BookingContracts.ValidationResult result = validator.validate(decision, context(meta, evidence)); + + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .contains("TRACE_DEPARTMENT_REQUIRED"); + } + + @Test + void shouldNotBlockClearTargetWhenSameMessageHasOneIsolatedRiskNotification() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-clear-001", + "NEW_BOOKING", + "CREATE", + new BookingContracts.TargetIdentity("LTQ260808C", null, "LTQ260808C", List.of(), evidence), + validNewBookingFields(), + List.of(), + evidence, + List.of()); + BookingContracts.ContractIssue riskIssue = new BookingContracts.ContractIssue( + "ACTION_UNRESOLVED", + BookingContracts.IssueSeverity.BLOCKER, + BookingContracts.IssueScope.TARGET, + "booking.decision.action_unresolved", + evidence, + false); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.MIXED, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(new BookingContracts.MessageNotification( + BookingContracts.ResultDisposition.RISK_NOTIFICATION, + "S99", + evidence, + List.of(riskIssue))), + null, + List.of(riskIssue)); + + BookingContracts.ValidationResult result = validator.validate(decision, context(meta, evidence)); + + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.VALID); + assertThat(result.riskNotification()).isNotNull(); + assertThat(result.validatedTargets()).singleElement() + .extracting(BookingContracts.ValidatedTarget::status) + .isEqualTo(BookingContracts.ValidationStatus.VALID); + } + + @Test + void shouldKeepRoomingListVisibleForReviewWhenGroupCodeIsUnknown() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-rooming-unknown-001", + "ROOMING_LIST", + "DISPLAY_ROOMING_LIST", + new BookingContracts.TargetIdentity(null, null, null, List.of(), evidence), + materialFields(null, true, List.of("rooming-001")), + List.of(Map.of("hint_type", "ROOMING_LIST", "attachment_ids", List.of("rooming-001"))), + evidence, + List.of()); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.DETERMINISTIC, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + null, + List.of()); + + BookingContracts.ValidationResult result = validator.validate(decision, context(meta, evidence)); + + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(result.validatedTargets()).singleElement().satisfies(validated -> { + assertThat(validated.status()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(validated.blockers()).extracting(BookingContracts.ContractIssue::code) + .containsExactly("TARGET_IDENTITY_REQUIRED"); + }); + } + + @Test + void shouldRejectPaymentCandidateThatIsNotBoundToAnAttachment() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-payment-no-attachment-001", + "PAYMENT", + "DISPLAY_PAYMENT_EVIDENCE", + new BookingContracts.TargetIdentity("LTQ260808P", null, "LTQ260808P", List.of(), evidence), + materialFields("LTQ260808P", false, List.of()), + List.of(Map.of("hint_type", "PAYMENT")), + evidence, + List.of()); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + "agent-safe-ref", + List.of()); + + BookingContracts.ValidationResult result = validator.validate(decision, context(meta, evidence)); + + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .contains("PAYMENT_EVIDENCE_REQUIRED"); + } + + @Test + void shouldBlockStandaloneTraceUntilFoOrHskIsSelected() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-trace-standalone-001", + "TRACE_RESERVATION_NOTES", + "REVIEW", + new BookingContracts.TargetIdentity("LTQ260808T", null, "LTQ260808T", List.of(), evidence), + Map.of("group_code", "LTQ260808T", "tour_code", "LTQ260808T", "names", List.of()), + List.of(), + evidence, + List.of()); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + "agent-safe-ref", + List.of()); + BookingContracts.ContextPackage context = new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(new BookingContracts.TargetResolution( + "LTQ260808T", "RESOLVED", List.of("candidate-801"), evidence, List.of())), + Map.of(), + Map.of(), + Map.of(), + Map.of(), + List.of()); + + BookingContracts.ValidationResult result = validator.validate(decision, context); + + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.REVIEW_REQUIRED); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .containsExactly("TRACE_DEPARTMENT_REQUIRED"); + } + + @Test + void shouldAcceptMergedTraceWhenFoAndHskAreExplicitlySelected() { + BookingContracts.ContractMeta meta = meta(); + List evidence = evidence(meta); + Map fields = validNewBookingFields(); + fields.put("department_codes", List.of("FO", "HSK")); + BookingContracts.TargetDecision target = new BookingContracts.TargetDecision( + "target-trace-merged-001", + "NEW_BOOKING", + "CREATE", + new BookingContracts.TargetIdentity("LTQ260808A", null, "LTQ260808A", List.of(), evidence), + fields, + List.of( + Map.of("hint_type", "TRACE_RESERVATION_NOTES", "item_type", "EXTRA_BED"), + Map.of("hint_type", "TRACE_RESERVATION_NOTES", "item_type", "HONEYMOON")), + evidence, + List.of()); + BookingContracts.CandidateDecision decision = new BookingContracts.CandidateDecision( + meta, + evidence, + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(target), + List.of(), + "agent-safe-ref", + List.of()); + + BookingContracts.ValidationResult result = validator.validate(decision, context(meta, evidence)); + + assertThat(result.validationStatus()).isEqualTo(BookingContracts.ValidationStatus.VALID); + assertThat(result.blockers()).extracting(BookingContracts.ContractIssue::code) + .doesNotContain("TRACE_DEPARTMENT_REQUIRED", "TRACE_DEPARTMENT_INVALID"); + } + + private Map validNewBookingFields() { + Map fields = new LinkedHashMap<>(); + fields.put("booking_type", "GROUP"); + fields.put("group_code", "LTQ260808A"); + fields.put("tour_code", "LTQ260808A"); + fields.put("group_name", null); + fields.put("names", List.of()); + fields.put("arrival_date", "2026-08-12"); + fields.put("departure_date", "2026-08-14"); + fields.put("room_items", List.of(new BookingContracts.RoomItemFact( + "RM3", 6, "850", List.of()))); + fields.put("rate_code", "GRPA1"); + return fields; + } + + private Map materialFields( + String groupCode, + boolean attachmentPresent, + List attachmentIds) { + Map fields = new LinkedHashMap<>(); + fields.put("group_code", groupCode); + fields.put("group_name", null); + fields.put("tour_code", groupCode); + fields.put("names", List.of()); + fields.put("attachment_present", attachmentPresent); + fields.put("attachment_ids", attachmentIds); + fields.put("target_identified", groupCode != null); + return fields; + } + + private BookingContracts.ContextPackage context( + BookingContracts.ContractMeta meta, + List evidence) { + return new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(), + Map.of(), + Map.of(), + Map.of(), + Map.of(), + List.of()); + } + + private BookingContracts.ContractMeta meta() { + return new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-test-v1", + "parser-test-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-validator-001", + "source-validator-001", + 0); + } + + private List evidence(BookingContracts.ContractMeta meta) { + return List.of(new BookingContracts.EvidenceReference( + "evidence-validator-001", + BookingContracts.EvidenceKind.WORKBOOK_ROW, + meta.sourceMessageId(), + "sheet:booking;row:4", + "b".repeat(64), + true)); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImplTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImplTest.java new file mode 100644 index 0000000..fb13436 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingMasterDataResolverImplTest.java @@ -0,0 +1,141 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageMasterData; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingRateCodeMappingRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingSenderMappingRepository; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BookingMasterDataResolverImplTest { + + @Test + void shouldResolveOnlyOneExactInboundSenderAndRateMappingWithoutExposingSender() { + BookingMasterDataResolverImpl resolver = resolver( + (sender, catalog) -> List.of(new BookingSenderMappingRepository.SenderMapping( + "qbd-main", + sender, + "INBOUND", + "ACC-QBD", + "MKT-TH", + "SRC-QBD")), + catalog -> List.of(new BookingRateCodeMappingRepository.RateCodeMapping( + "qbd-rm3-850", + "{\"channel_profile_code\":\"QBD\",\"room_type_code\":\"RM3\",\"rate_hint\":\"850\"}", + "QBD-850"))); + + BookingMessageMasterData resolved = resolver.resolve("Vendor ", parsedFactSet()); + + assertThat(resolved.senderMappingStatus()).isEqualTo(BookingMessageMasterData.SenderMappingStatus.RESOLVED); + assertThat(resolved.messageDirection()).isEqualTo(BookingMessageMasterData.MessageDirection.INBOUND); + assertThat(resolved.shouldIgnore()).isFalse(); + assertThat(resolved.accountCode()).isEqualTo("ACC-QBD"); + assertThat(resolved.rateCodeFor("fact-001")) + .isEqualTo(new BookingMessageMasterData.RateCodeResolution( + BookingMessageMasterData.RateCodeResolutionStatus.RESOLVED, + "QBD-850")); + assertThat(resolved.contextValue().toString()).doesNotContain("vendor@example.com"); + } + + @Test + void shouldIgnoreOnlyAnExplicitUniqueOutboundDirection() { + BookingMasterDataResolverImpl resolver = resolver( + (sender, catalog) -> List.of(new BookingSenderMappingRepository.SenderMapping( + "hotel-outbound", + sender, + "OUTBOUND", + null, + null, + null)), + catalog -> List.of()); + + BookingMessageMasterData resolved = resolver.resolve("Reservations ", parsedFactSet()); + + assertThat(resolved.senderMappingStatus()).isEqualTo(BookingMessageMasterData.SenderMappingStatus.RESOLVED); + assertThat(resolved.messageDirection()).isEqualTo(BookingMessageMasterData.MessageDirection.OUTBOUND); + assertThat(resolved.shouldIgnore()).isTrue(); + } + + @Test + void shouldKeepZeroOrMultipleSenderAndRateMappingsUnresolved() { + BookingMasterDataResolverImpl noMappingResolver = resolver( + (sender, catalog) -> List.of(), + catalog -> List.of()); + BookingMessageMasterData missing = noMappingResolver.resolve("vendor@example.com", parsedFactSet()); + + assertThat(missing.senderMappingStatus()).isEqualTo(BookingMessageMasterData.SenderMappingStatus.MISSING); + assertThat(missing.messageDirection()).isEqualTo(BookingMessageMasterData.MessageDirection.UNKNOWN); + assertThat(missing.shouldIgnore()).isFalse(); + assertThat(missing.rateCodeFor("fact-001").status()) + .isEqualTo(BookingMessageMasterData.RateCodeResolutionStatus.MISSING); + + BookingMasterDataResolverImpl ambiguousResolver = resolver( + (sender, catalog) -> List.of( + new BookingSenderMappingRepository.SenderMapping("a", sender, "INBOUND", null, null, null), + new BookingSenderMappingRepository.SenderMapping("b", sender, "OUTBOUND", null, null, null)), + catalog -> List.of( + new BookingRateCodeMappingRepository.RateCodeMapping( + "r1", + "{\"channel_profile_code\":\"QBD\",\"room_type_code\":\"RM3\",\"rate_hint\":\"850\"}", + "QBD-850"), + new BookingRateCodeMappingRepository.RateCodeMapping( + "r2", + "{\"channel_profile_code\":\"QBD\",\"room_type_code\":\"RM3\",\"rate_hint\":\"850\"}", + "QBD-850-ALT"))); + BookingMessageMasterData ambiguous = ambiguousResolver.resolve("vendor@example.com", parsedFactSet()); + + assertThat(ambiguous.senderMappingStatus()).isEqualTo(BookingMessageMasterData.SenderMappingStatus.AMBIGUOUS); + assertThat(ambiguous.shouldIgnore()).isFalse(); + assertThat(ambiguous.rateCodeFor("fact-001").status()) + .isEqualTo(BookingMessageMasterData.RateCodeResolutionStatus.AMBIGUOUS); + } + + private BookingMasterDataResolverImpl resolver( + BookingSenderMappingRepository senderMappings, + BookingRateCodeMappingRepository rateMappings) { + return new BookingMasterDataResolverImpl(senderMappings, rateMappings, new ObjectMapper()); + } + + private BookingContracts.ParsedFactSet parsedFactSet() { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-v1", + "parser-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-001", + "source-001", + 0); + BookingContracts.EvidenceReference evidence = new BookingContracts.EvidenceReference( + "evidence-001", + BookingContracts.EvidenceKind.WORKBOOK_ROW, + meta.sourceMessageId(), + "sheet:booking;row:4", + "a".repeat(64), + true); + BookingContracts.ParsedFact fact = new BookingContracts.ParsedFact( + "fact-001", + "NEW_BOOKING", + "GROUP", + new BookingContracts.TargetIdentity("LTQ260806", null, "LTQ260806", List.of(), List.of(evidence)), + new BookingContracts.StayFact( + "2026-08-12", + "2026-08-14", + 2, + List.of(new BookingContracts.RoomItemFact("RM3", 6, "850.0", List.of(evidence))), + List.of(evidence)), + List.of(), + List.of(evidence)); + return new BookingContracts.ParsedFactSet( + meta, + List.of(evidence), + BookingContracts.ParserApplicability.APPLICABLE, + "QBD", + List.of(fact), + List.of(), + List.of(), + List.of()); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestratorTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestratorTest.java new file mode 100644 index 0000000..c5dfe2b --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingPostgresMessageOrchestratorTest.java @@ -0,0 +1,263 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageInput; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingMessageMasterData; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingOrchestrationResult; +import cn.nianxx.thhotel.workflows.reservation.booking.common.dto.BookingProcessingRunSnapshot; +import cn.nianxx.thhotel.workflows.reservation.booking.config.BookingAgentProperties; +import cn.nianxx.thhotel.workflows.reservation.booking.repository.BookingProcessingRunRepository; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingContextAssembler; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingDecisionService; +import cn.nianxx.thhotel.workflows.reservation.booking.service.BookingMasterDataResolver; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class BookingPostgresMessageOrchestratorTest { + + @Test + void shouldQueueAgentFallbackAfterContextAndReturnAsyncRun() { + Fixture fixture = fixture(inboundMasterData()); + when(fixture.decisionService.requiresAgentFallback(fixture.parsedFactSet)).thenReturn(true); + when(fixture.pipeline.preDecisionWarningCodes(fixture.parsedFactSet)).thenReturn(List.of("NO_FIXED_CHANNEL")); + + BookingOrchestrationResult result = fixture.orchestrator.process(input()); + + assertThat(result.asynchronous()).isTrue(); + assertThat(result.processingStatus()).isEqualTo(BookingContracts.ProcessingStatus.CONTEXT_READY); + assertThat(result.warningCodes()).contains("NO_FIXED_CHANNEL"); + ArgumentCaptor contextCaptor = + ArgumentCaptor.forClass(BookingContracts.ContextPackage.class); + verify(fixture.dispatcher).completeAsync( + eq("run-orchestrator-001"), + eq(fixture.parsedFactSet), + contextCaptor.capture(), + eq(true), + eq(false)); + assertThat(contextCaptor.getValue().currentState().toString()).doesNotContain("vendor@example.com"); + verify(fixture.pipeline, never()).complete(anyString(), any(), any(), eq(true), eq(false)); + } + + @Test + void shouldFinishExplicitOutboundAsIgnoredWithoutQueueingAgent() { + Fixture fixture = fixture(new BookingMessageMasterData( + BookingMessageMasterData.SenderMappingStatus.RESOLVED, + BookingMessageMasterData.MessageDirection.OUTBOUND, + null, + null, + null, + Map.of(), + List.of())); + when(fixture.pipeline.completeIgnoredDirection( + eq("run-orchestrator-001"), + eq(fixture.parsedFactSet), + any(), + eq(BookingMessageMasterData.MessageDirection.OUTBOUND))) + .thenReturn(new BookingDecisionPipeline.BookingPipelineCompletion( + BookingContracts.ProcessingStatus.VALIDATED, + BookingContracts.ResultDisposition.IGNORED_DIRECTION, + List.of("MESSAGE_DIRECTION_IGNORED"))); + + BookingOrchestrationResult result = fixture.orchestrator.process(input()); + + assertThat(result.asynchronous()).isFalse(); + assertThat(result.resultDisposition()).isEqualTo(BookingContracts.ResultDisposition.IGNORED_DIRECTION); + assertThat(result.warningCodes()).contains("MESSAGE_DIRECTION_IGNORED"); + verify(fixture.dispatcher, never()).completeAsync(anyString(), any(), any(), eq(true), eq(false)); + verify(fixture.decisionService, never()).requiresAgentFallback(any()); + verify(fixture.pipeline, never()).complete(anyString(), any(), any(), eq(true), eq(false)); + } + + private Fixture fixture(BookingMessageMasterData masterData) { + BookingContractAssembler assembler = mock(BookingContractAssembler.class); + BookingProcessingRunRepository repository = mock(BookingProcessingRunRepository.class); + BookingContextAssembler contextAssembler = mock(BookingContextAssembler.class); + BookingMasterDataResolver masterDataResolver = mock(BookingMasterDataResolver.class); + BookingDecisionService decisionService = mock(BookingDecisionService.class); + BookingDecisionPipeline pipeline = mock(BookingDecisionPipeline.class); + BookingAgentContinuationDispatcher dispatcher = mock(BookingAgentContinuationDispatcher.class); + BookingAgentProperties properties = new BookingAgentProperties(); + properties.setEnabled(true); + properties.setAsyncEnabled(true); + BookingContracts.ParsedFactSet parsedFactSet = parsedFactSet(); + BookingContracts.ContextPackage contextPackage = context(parsedFactSet.contractMeta(), parsedFactSet.evidenceRefs()); + + when(assembler.sourceFingerprint(any())).thenReturn("a".repeat(64)); + when(assembler.sourceMessageEnvelope(any(), anyString(), anyLong())).thenAnswer(invocation -> + envelope(invocation.getArgument(1), invocation.getArgument(2))); + when(assembler.materialPackage(any(), any())).thenAnswer(invocation -> materialPackage(invocation.getArgument(1))); + when(assembler.parsedFactSet(any(), any())).thenReturn(parsedFactSet); + when(repository.startOrReuse(anyString(), eq("hotel-wyndham"), any(), anyString())).thenReturn(new BookingProcessingRunSnapshot( + 1L, + "run-orchestrator-001", + 2L, + "source-orchestrator-001", + 1, + BookingContracts.ProcessingStatus.RECEIVED, + null, + true, + 0, + null, + null, + OffsetDateTime.parse("2026-08-08T00:00:00Z"), + null)); + when(contextAssembler.assemble("hotel-wyndham", parsedFactSet)).thenReturn(contextPackage); + when(masterDataResolver.resolve("vendor@example.com", parsedFactSet)).thenReturn(masterData); + + return new Fixture( + new BookingPostgresMessageOrchestrator( + assembler, + repository, + contextAssembler, + masterDataResolver, + decisionService, + pipeline, + dispatcher, + properties, + objectMapper()), + decisionService, + pipeline, + dispatcher, + parsedFactSet); + } + + private BookingMessageMasterData inboundMasterData() { + return new BookingMessageMasterData( + BookingMessageMasterData.SenderMappingStatus.RESOLVED, + BookingMessageMasterData.MessageDirection.INBOUND, + "ACC-QBD", + "MKT-TH", + "SRC-QBD", + Map.of(), + List.of()); + } + + private ObjectMapper objectMapper() { + return new ObjectMapper().findAndRegisterModules(); + } + + private BookingMessageInput input() { + return new BookingMessageInput( + "hotel-wyndham", + BookingContracts.MessageOrigin.MANUAL_EML, + "source-orchestrator-001", + "external-orchestrator-001", + "conversation-orchestrator-001", + null, + "idempotency-orchestrator-001", + Instant.parse("2026-08-08T00:00:00Z"), + "Booking question", + "Please review the booking.", + null, + List.of(), + "Vendor "); + } + + private BookingContracts.SourceMessageEnvelope envelope(String runId, long revision) { + BookingContracts.ContractMeta meta = meta(runId, revision); + BookingContracts.EvidenceReference evidence = evidence(meta); + return new BookingContracts.SourceMessageEnvelope( + meta, + List.of(evidence), + BookingContracts.MessageOrigin.MANUAL_EML, + "external-orchestrator-001", + "conversation-orchestrator-001", + null, + "idempotency-orchestrator-001", + OffsetDateTime.parse("2026-08-08T00:00:00Z"), + "Booking question", + evidence.evidenceId(), + null, + List.of(), + "vendor@example.com"); + } + + private BookingContracts.MaterialPackage materialPackage(BookingContracts.SourceMessageEnvelope envelope) { + return new BookingContracts.MaterialPackage( + envelope.contractMeta(), + envelope.evidenceRefs(), + envelope, + List.of(new BookingContracts.MaterialReference( + envelope.currentBodyEvidenceId(), + BookingContracts.EvidenceKind.CURRENT_BODY, + true, + Map.of("content_available", true))), + List.of(), + List.of(), + List.of(), + BookingContracts.MaterialSelectionStatus.NO_ATTACHMENT, + List.of()); + } + + private BookingContracts.ParsedFactSet parsedFactSet() { + BookingContracts.ContractMeta meta = meta("run-orchestrator-001", 0); + return new BookingContracts.ParsedFactSet( + meta, + List.of(evidence(meta)), + BookingContracts.ParserApplicability.NOT_APPLICABLE, + null, + List.of(), + List.of(), + List.of("NO_FIXED_CHANNEL"), + List.of()); + } + + private BookingContracts.ContextPackage context( + BookingContracts.ContractMeta meta, + List evidence) { + return new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(), + Map.of(), + Map.of(), + Map.of(), + Map.of(), + List.of()); + } + + private BookingContracts.ContractMeta meta(String runId, long revision) { + return new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-v1", + "parser-v1", + BookingContracts.AGENT_NOT_INVOKED, + runId, + "source-orchestrator-001", + revision); + } + + private BookingContracts.EvidenceReference evidence(BookingContracts.ContractMeta meta) { + return new BookingContracts.EvidenceReference( + "evidence-orchestrator-001", + BookingContracts.EvidenceKind.CURRENT_BODY, + meta.sourceMessageId(), + "current-body", + "a".repeat(64), + true); + } + + private record Fixture( + BookingPostgresMessageOrchestrator orchestrator, + BookingDecisionService decisionService, + BookingDecisionPipeline pipeline, + BookingAgentContinuationDispatcher dispatcher, + BookingContracts.ParsedFactSet parsedFactSet) { + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionServiceTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionServiceTest.java new file mode 100644 index 0000000..d44f4cc --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/booking/service/impl/BookingSuperAgentDecisionServiceTest.java @@ -0,0 +1,197 @@ +package cn.nianxx.thhotel.workflows.reservation.booking.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentOpenApiMessageRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient; +import cn.nianxx.thhotel.workflows.reservation.booking.config.BookingAgentProperties; +import cn.nianxx.thhotel.workflows.reservation.common.dto.booking.BookingContracts; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class BookingSuperAgentDecisionServiceTest { + + @Test + void shouldSendOnlyContractsToAgentAndNormalizeAValidResult() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + SuperAgentOpenApiClient client = mock(SuperAgentOpenApiClient.class); + BookingContracts.ParsedFactSet parsedFactSet = parsedFactSet(); + BookingContracts.ContextPackage contextPackage = context(parsedFactSet.contractMeta(), parsedFactSet.evidenceRefs()); + BookingContracts.CandidateDecision raw = new BookingContracts.CandidateDecision( + parsedFactSet.contractMeta(), + parsedFactSet.evidenceRefs(), + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(new BookingContracts.TargetDecision( + "agent-target-001", + "TRACE_RESERVATION_NOTES", + "REVIEW", + new BookingContracts.TargetIdentity("LTQ260806", null, "LTQ260806", List.of(), parsedFactSet.evidenceRefs()), + Map.of("group_code", "LTQ260806"), + List.of(), + parsedFactSet.evidenceRefs(), + List.of())), + List.of(), + null, + List.of()); + when(client.invokeMessage(any(SuperAgentOpenApiMessageRequest.class))).thenReturn(result( + objectMapper.writeValueAsString(raw), + "deployed-booking-profile-v2")); + BookingSuperAgentDecisionService service = new BookingSuperAgentDecisionService( + client, + configuredProperties(), + objectMapper); + + BookingContracts.CandidateDecision decision = service.decide(parsedFactSet, contextPackage); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(SuperAgentOpenApiMessageRequest.class); + verify(client).invokeMessage(requestCaptor.capture()); + SuperAgentOpenApiMessageRequest request = requestCaptor.getValue(); + assertThat(request.message()) + .contains("\"parsed_fact_set\"") + .contains("\"context_package\"") + .contains("\"agent_input_material\"") + .contains("LTQ260806") + .doesNotContain("raw-mail-body-must-never-leave-local") + .doesNotContain("https://private-attachment.example") + .doesNotContain("guest@example.com") + .doesNotContain("top-secret-value") + .doesNotContain("quoted-history-must-never-leave-local"); + assertThat(request.metadata()) + .containsEntry("contract_version", BookingContracts.CONTRACT_VERSION) + .containsEntry("processing_run_id", parsedFactSet.contractMeta().processingRunId()); + assertThat(decision.decisionOrigin()).isEqualTo(BookingContracts.DecisionOrigin.AGENT); + assertThat(decision.contractMeta().agentProfileVersion()).isEqualTo("deployed-booking-profile-v2"); + assertThat(decision.agentTraceRef()).startsWith("superagent-run-"); + assertThat(decision.targetDecisions()).singleElement() + .extracting(BookingContracts.TargetDecision::businessType) + .isEqualTo("TRACE_RESERVATION_NOTES"); + } + + @Test + void shouldFailClosedWhenAgentReferencesEvidenceOutsideTheContracts() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + SuperAgentOpenApiClient client = mock(SuperAgentOpenApiClient.class); + BookingContracts.ParsedFactSet parsedFactSet = parsedFactSet(); + BookingContracts.EvidenceReference unknownEvidence = new BookingContracts.EvidenceReference( + "agent-invented-evidence", + BookingContracts.EvidenceKind.CURRENT_BODY, + parsedFactSet.contractMeta().sourceMessageId(), + "invented", + "b".repeat(64), + true); + BookingContracts.CandidateDecision raw = new BookingContracts.CandidateDecision( + parsedFactSet.contractMeta(), + List.of(unknownEvidence), + BookingContracts.DecisionOrigin.AGENT, + BookingContracts.ResultDisposition.IN_SCOPE_TASKS, + List.of(), + List.of(), + null, + List.of()); + when(client.invokeMessage(any(SuperAgentOpenApiMessageRequest.class))).thenReturn(result( + objectMapper.writeValueAsString(raw), + "deployed-booking-profile-v2")); + BookingSuperAgentDecisionService service = new BookingSuperAgentDecisionService( + client, + configuredProperties(), + objectMapper); + + BookingContracts.CandidateDecision decision = service.decide( + parsedFactSet, + context(parsedFactSet.contractMeta(), parsedFactSet.evidenceRefs())); + + assertThat(decision.resultDisposition()).isEqualTo(BookingContracts.ResultDisposition.RISK_NOTIFICATION); + assertThat(decision.issues()).extracting(BookingContracts.ContractIssue::code) + .containsExactly("BOOKING_AGENT_EVIDENCE_INVALID"); + } + + private BookingAgentProperties configuredProperties() { + BookingAgentProperties properties = new BookingAgentProperties(); + properties.setEnabled(true); + properties.setExternalSubjectId("booking-test-subject"); + properties.setProfileVersion("booking-agent-test-v1"); + return properties; + } + + private SuperAgentOpenApiResult result(String answer, String profileVersion) { + return new SuperAgentOpenApiResult( + "session-001", + "run-001", + "profile-001", + profileVersion, + "test-model", + answer, + 1, + 1, + 2, + List.of()); + } + + private BookingContracts.ParsedFactSet parsedFactSet() { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-v1", + "parser-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-agent-001", + "source-agent-001", + 0); + BookingContracts.EvidenceReference evidence = new BookingContracts.EvidenceReference( + "evidence-agent-001", + BookingContracts.EvidenceKind.CURRENT_BODY, + meta.sourceMessageId(), + "current-body", + "a".repeat(64), + true); + return new BookingContracts.ParsedFactSet( + meta, + List.of(evidence), + BookingContracts.ParserApplicability.NOT_APPLICABLE, + null, + List.of(), + List.of(), + List.of("NO_FIXED_CHANNEL"), + List.of(), + new BookingContracts.AgentInputMaterial( + evidence.evidenceId(), + "Update Group Code LTQ260806. guest@example.com https://private-attachment.example " + + "api_token=top-secret-value", + 128, + false, + "untrusted-upstream-policy")); + } + + private BookingContracts.ContextPackage context( + BookingContracts.ContractMeta meta, + List evidence) { + Map masterData = new LinkedHashMap<>(); + masterData.put("sender_mapping", Map.of("status", "RESOLVED", "direction", "INBOUND")); + masterData.put("rate_code_mappings", Map.of()); + Map currentState = new LinkedHashMap<>(); + currentState.put("message_master_data", masterData); + currentState.put("external_material_reference", "https://private-attachment.example"); + currentState.put("source_body", "raw-mail-body-must-never-leave-local"); + currentState.put("quoted_history", "quoted-history-must-never-leave-local"); + return new BookingContracts.ContextPackage( + meta, + evidence, + "MESSAGE", + List.of(), + currentState, + Map.of(), + Map.of(), + Map.of(), + List.of()); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContractsTest.java b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContractsTest.java new file mode 100644 index 0000000..cc1b284 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/workflows/reservation/common/dto/booking/BookingContractsTest.java @@ -0,0 +1,108 @@ +package cn.nianxx.thhotel.workflows.reservation.common.dto.booking; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class BookingContractsTest { + + @Test + void shouldRejectUnknownContractVersion() { + assertThatThrownBy(() -> new BookingContracts.ContractMeta( + "booking-contracts-v0", + BookingContracts.CATALOG_NOT_APPLIED, + BookingContracts.PARSER_NOT_RUN, + BookingContracts.AGENT_NOT_INVOKED, + "run-001", + "source-001", + 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("booking-contracts-v1"); + } + + @Test + void shouldRepresentNoAttachmentMessageWithoutSkippingEnvelopeOrMaterialPackage() { + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + BookingContracts.CATALOG_NOT_APPLIED, + BookingContracts.PARSER_NOT_RUN, + BookingContracts.AGENT_NOT_INVOKED, + "run-002", + "source-002", + 0); + BookingContracts.EvidenceReference bodyEvidence = new BookingContracts.EvidenceReference( + "evidence-current-body-002", + BookingContracts.EvidenceKind.CURRENT_BODY, + "source-002", + "current-body", + "hash-current-body-002", + true); + BookingContracts.SourceMessageEnvelope envelope = new BookingContracts.SourceMessageEnvelope( + meta, + List.of(bodyEvidence), + BookingContracts.MessageOrigin.MANUAL_EML, + "message-002@example.test", + "conversation-002@example.test", + null, + "idem-002", + OffsetDateTime.parse("2026-08-08T00:00:00Z"), + "Booking wording without attachment", + bodyEvidence.evidenceId(), + null, + List.of()); + + BookingContracts.MaterialPackage materialPackage = new BookingContracts.MaterialPackage( + meta, + List.of(bodyEvidence), + envelope, + List.of(new BookingContracts.MaterialReference( + bodyEvidence.evidenceId(), + BookingContracts.EvidenceKind.CURRENT_BODY, + true, + null)), + List.of(), + List.of(), + List.of(), + BookingContracts.MaterialSelectionStatus.NO_ATTACHMENT, + List.of()); + + assertThat(materialPackage.messageEnvelope().attachmentDescriptors()).isEmpty(); + assertThat(materialPackage.materialSelectionStatus()) + .isEqualTo(BookingContracts.MaterialSelectionStatus.NO_ATTACHMENT); + assertThat(materialPackage.currentMaterials()).singleElement() + .extracting(BookingContracts.MaterialReference::currentMaterial) + .isEqualTo(true); + } + + @Test + void shouldKeepExplicitUnknownMapValuesAndSerializeContractKeysInSnakeCase() throws Exception { + Map attributes = new LinkedHashMap<>(); + attributes.put("rate_code", null); + BookingContracts.MaterialReference reference = new BookingContracts.MaterialReference( + "evidence-unknown-map-001", + BookingContracts.EvidenceKind.CURRENT_BODY, + true, + attributes); + BookingContracts.ContractMeta meta = new BookingContracts.ContractMeta( + BookingContracts.CONTRACT_VERSION, + "catalog-v1", + "parser-v1", + BookingContracts.AGENT_NOT_INVOKED, + "run-003", + "source-003", + 0); + + assertThat(reference.attributes()).containsEntry("rate_code", null); + String json = new ObjectMapper().writeValueAsString(meta); + assertThat(json) + .contains("\"contract_version\"") + .contains("\"processing_run_id\"") + .doesNotContain("\"contractVersion\""); + } +}