From a1f1d9390287e40da019fb3ba8644c808dce494e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 20:27:35 +0800 Subject: [PATCH] feat: add self-operated analytics and enforce Lastleg module boundaries --- deploy/docker-compose.yml | 12 + deploy/env/.env.test.example | 11 + deploy/sql/README.md | 8 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 18 + deploy/sql/海外区域统一增量-MySQL8.sql | 304 +++++++- docs/backend-business-index.md | 2 + iam/AGENTS.md | 1 + .../db/selfoperated-analytics-permissions.sql | 17 + integration-service/.env.example | 4 +- integration-service/AGENTS.md | 1 + integration-service/README.md | 102 +++ .../config/LingxingEgressProperties.java | 35 + .../config/LingxingWmsProperties.java | 9 +- .../LingxingInternalController.java | 71 +- .../dto/lingxing/LingxingEgressDtos.java | 31 + .../dto/lingxing/LingxingWmsDtos.java | 34 +- .../lingxing/LingxingBudgetAuthority.java | 9 + .../LingxingBudgetDeniedException.java | 11 + .../provider/lingxing/LingxingEgressGate.java | 131 ++++ .../lingxing/LingxingHttpBudgetAuthority.java | 73 ++ .../provider/lingxing/LingxingWmsClient.java | 289 ++++++-- .../lingxing/LingxingWmsException.java | 8 + .../resources/application-lingxing-local.yml | 15 +- .../src/main/resources/application.yml | 15 +- .../resources/openapi/lingxing-wms-v1.yaml | 419 ++++++++++- .../LingxingInternalControllerTest.java | 77 +- .../lingxing/LingxingEgressGateTest.java | 226 ++++++ .../LingxingHttpBudgetAuthorityTest.java | 143 ++++ .../lingxing/LingxingWmsClientTest.java | 464 ++++++------ .../lingxing/LingxingWmsSignerTest.java | 5 +- lastleg-service/AGENTS.md | 1 + ...astleg-internal-modularization-baseline.md | 14 +- .../scripts/analyze-lastleg-dependencies.mjs | 1 + .../analyze-lastleg-dependencies.test.mjs | 47 +- .../AppointmentSourceLinker.java | 42 +- .../fleet/FleetPoolDeliveryAdapter.java | 31 + .../service/DeliveryDocumentService.java | 5 +- .../business/FleetPoolDeliveryEvidence.java | 6 + .../fleet/business/FleetPoolOrderBridge.java | 41 +- .../fleet/business/FleetPoolOrderModels.java | 9 + .../FleetPoolOrderNumberGenerator.java | 23 + .../fleet/trip/FleetBookingService.java | 7 +- .../AppointmentSourceCreateController.java | 12 +- .../dto/SourceFbaAppointmentRequest.java | 5 +- .../InboundAppointmentSourceLinker.java | 46 ++ .../service/InboundFbaAppointmentService.java | 5 +- .../client/AnalyticsClientException.java | 14 + .../client/LingxingAnalyticsClient.java | 25 + .../client/RestLingxingAnalyticsClient.java | 248 +++++++ .../PlatformAnalyticsController.java | 94 +++ .../inventory/InventorySnapshotAssembler.java | 140 ++++ .../inventory/InventorySnapshotService.java | 179 +++++ .../mapper/SelfopCoordinationMapper.java | 30 + .../mapper/SelfopInventorySnapshotMapper.java | 9 + .../SelfopOutboundDiscoveryScopeMapper.java | 9 + .../mapper/SelfopOutboundItemMapper.java | 9 + .../mapper/SelfopOutboundOrderMapper.java | 9 + .../mapper/SelfopSkuDailySalesMapper.java | 9 + .../mapper/SelfopSkuProfileMapper.java | 9 + .../analytics/mapper/SelfopSyncRunMapper.java | 16 + .../mapper/SelfopSyncScopeMapper.java | 9 + .../mapper/SelfopSyncStageMapper.java | 9 + .../mapper/SelfopSyncTaskMapper.java | 9 + .../model/SelfopAnalyticsProperties.java | 66 ++ .../model/SelfopCollectionPolicy.java | 23 + .../model/SelfopInventorySnapshot.java | 33 + .../model/SelfopOutboundDiscoveryScope.java | 33 + .../analytics/model/SelfopOutboundItem.java | 23 + .../analytics/model/SelfopOutboundOrder.java | 35 + .../analytics/model/SelfopPayloads.java | 16 + .../model/SelfopProgressProjection.java | 68 ++ .../analytics/model/SelfopRequestContext.java | 11 + .../model/SelfopRoutingManifest.java | 28 + .../analytics/model/SelfopSkuDailySales.java | 27 + .../analytics/model/SelfopSkuProfile.java | 30 + .../analytics/model/SelfopSupport.java | 37 + .../analytics/model/SelfopSyncRun.java | 56 ++ .../analytics/model/SelfopSyncScope.java | 51 ++ .../analytics/model/SelfopSyncStage.java | 33 + .../analytics/model/SelfopSyncTask.java | 50 ++ .../outbound/OutboundAggregateMapper.java | 163 ++++ .../outbound/OutboundCandidateBuilder.java | 33 + .../OutboundCandidateTransaction.java | 159 ++++ .../outbound/OutboundFactService.java | 245 ++++++ .../outbound/OutboundLockMapper.java | 25 + .../analytics/outbound/OutboundPolicy.java | 87 +++ .../query/AnalyticsInventoryOptions.java | 27 + .../query/AnalyticsInventorySql.java | 75 ++ .../analytics/query/AnalyticsQuery.java | 40 + .../query/AnalyticsQueryService.java | 267 +++++++ .../analytics/query/AnalyticsReadMapper.java | 49 ++ .../analytics/query/AnalyticsReadRows.java | 39 + .../analytics/query/AnalyticsReadSql.java | 147 ++++ .../analytics/query/AnalyticsResponses.java | 41 + .../analytics/query/AnalyticsSqlRequest.java | 10 + .../analytics/query/CollectionQuery.java | 14 + .../query/CollectionQueryService.java | 350 +++++++++ .../analytics/query/CollectionReadMapper.java | 115 +++ .../analytics/query/CollectionReadRows.java | 34 + .../analytics/query/CollectionResponses.java | 41 + .../request/EgressRejectedException.java | 8 + .../analytics/request/SelfopApiBudget.java | 12 + .../request/SelfopEgressController.java | 14 + .../analytics/request/SelfopEgressDtos.java | 12 + .../analytics/request/SelfopEgressMapper.java | 14 + .../request/SelfopEgressService.java | 158 ++++ .../request/SelfopValidationBudget.java | 31 + .../retention/SalesRetentionMapper.java | 44 ++ .../retention/SalesRetentionService.java | 54 ++ .../statistics/InventoryCoveragePolicy.java | 28 + .../statistics/SalesBucketPolicy.java | 104 +++ .../statistics/SalesRangePolicy.java | 51 ++ .../analytics/sync/SelfopBudget.java | 17 + .../analytics/sync/SelfopCommandService.java | 65 ++ .../sync/SelfopDiscoveryService.java | 235 ++++++ .../sync/SelfopExecutionService.java | 101 +++ .../sync/SelfopInventoryCollector.java | 64 ++ .../analytics/sync/SelfopLeaseService.java | 82 ++ .../analytics/sync/SelfopProgressService.java | 85 +++ .../sync/SelfopReferenceRecovery.java | 55 ++ .../analytics/sync/SelfopSalesPublisher.java | 65 ++ .../analytics/sync/SelfopSchedulePlanner.java | 119 +++ .../analytics/sync/SelfopTaskService.java | 222 ++++++ .../sync/SelfopValidationRunner.java | 208 ++++++ .../analytics/sync/SyncController.java | 25 + .../service/ReturnOrderReferenceResolver.java | 31 +- .../ReturnCustomerReferenceClient.java | 19 + .../TransferWarehouseCatalogClient.java | 17 + .../WarehouseTransferReceiptCoordinator.java | 4 +- .../src/main/resources/application.yml | 12 + .../src/main/resources/db/lastleg.sql | 301 ++++++++ .../self-operated-analytics-egress-v1.yaml | 115 +++ .../self-operated-analytics-sync-v1.yaml | 62 ++ .../openapi/self-operated-analytics-v1.yaml | 700 ++++++++++++++++++ .../AppointmentPlanningAuthorizationTest.java | 3 +- .../fleet/FleetPoolDeliveryAdapterTest.java | 43 ++ .../business/FleetPoolOrderBridgeTest.java | 52 +- .../FleetPoolOrderNumberGeneratorTest.java | 21 + .../FbaAppointmentPersistenceTest.java | 6 +- .../InboundFbaAppointmentServiceTest.java | 15 +- ...lfoperatedAnalyticsSchemaContractTest.java | 111 +++ .../RestLingxingAnalyticsClientTest.java | 290 ++++++++ .../PlatformAnalyticsControllerTest.java | 87 +++ .../InventorySnapshotAssemblerTest.java | 111 +++ .../InventorySnapshotServiceTest.java | 256 +++++++ .../outbound/OutboundAggregateMapperTest.java | 91 +++ .../OutboundCandidateBuilderTest.java | 115 +++ .../outbound/OutboundFactServiceTest.java | 130 ++++ .../outbound/OutboundPolicyTest.java | 80 ++ .../query/AnalyticsQueryServiceTest.java | 270 +++++++ .../query/AnalyticsReadMapperTest.java | 105 +++ .../query/AnalyticsSqlSemanticTest.java | 251 +++++++ .../query/CollectionDailySamplesSqlTest.java | 51 ++ .../query/CollectionQueryServiceTest.java | 233 ++++++ .../request/SelfopEgressControllerTest.java | 37 + .../request/SelfopEgressServiceTest.java | 170 +++++ .../retention/SalesRetentionServiceTest.java | 62 ++ .../statistics/SalesBucketPolicyTest.java | 94 +++ .../statistics/SalesRangePolicyTest.java | 57 ++ .../sync/SelfopCommandServiceTest.java | 95 +++ .../sync/SelfopDiscoveryServiceTest.java | 213 ++++++ .../sync/SelfopExecutionServiceTest.java | 43 ++ .../sync/SelfopInventoryCollectorTest.java | 150 ++++ .../sync/SelfopInventoryRecoveryTest.java | 129 ++++ .../sync/SelfopProgressServiceTest.java | 65 ++ .../sync/SelfopReferenceRecoveryTest.java | 118 +++ .../sync/SelfopSalesPublisherTest.java | 93 +++ .../sync/SelfopSchedulePlannerTest.java | 240 ++++++ .../analytics/sync/SelfopTaskServiceTest.java | 346 +++++++++ .../sync/SelfopValidationRunnerTest.java | 156 ++++ .../analytics/sync/SyncControllerTest.java | 98 +++ .../ReturnOrderReferenceResolverTest.java | 49 +- ...rehouseTransferReceiptCoordinatorTest.java | 8 +- .../lastleg-slice-dependencies.json | 78 +- 174 files changed, 13514 insertions(+), 490 deletions(-) create mode 100644 iam/src/main/resources/db/selfoperated-analytics-permissions.sql create mode 100644 integration-service/README.md create mode 100644 integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetAuthority.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetDeniedException.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java create mode 100644 integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolDeliveryEvidence.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java rename lastleg-service/src/main/java/com/baiyue/lastleg/{appointmentplanning => inboundplan/controller}/AppointmentSourceCreateController.java (75%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/AnalyticsClientException.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java create mode 100644 lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionDailySamplesSqlTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 56f4febb3..a32c3906f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -218,6 +218,12 @@ services: environment: <<: *common-environment SERVER_PORT: 9210 + LINGXING_WMS_ENABLED: ${LINGXING_WMS_ENABLED:-false} + LINGXING_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF} + LINGXING_ENVIRONMENT_LABEL: ${DEPLOY_ENV:-test} + LINGXING_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary} + LINGXING_OMS_APP_KEY: ${LINGXING_OMS_APP_KEY:-} + LINGXING_OMS_APP_SECRET: ${LINGXING_OMS_APP_SECRET:-} BAIDU_OCR_ENABLED: ${BAIDU_OCR_ENABLED:-false} BAIDU_OCR_API_KEY: ${BAIDU_OCR_API_KEY:-} BAIDU_OCR_SECRET_KEY: ${BAIDU_OCR_SECRET_KEY:-} @@ -282,6 +288,12 @@ services: environment: <<: *common-environment SERVER_PORT: 9303 + SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false} + SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF} + SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary} + SELFOP_DAILY_REQUEST_BUDGET: ${SELFOP_DAILY_REQUEST_BUDGET:-0} + SELFOP_MIN_REQUEST_INTERVAL_MS: ${SELFOP_MIN_REQUEST_INTERVAL_MS:-5000} + SELFOP_SALES_PUBLICATION_ENABLED: ${SELFOP_SALES_PUBLICATION_ENABLED:-false} EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} diff --git a/deploy/env/.env.test.example b/deploy/env/.env.test.example index 13dcc01d5..09111ffc5 100644 --- a/deploy/env/.env.test.example +++ b/deploy/env/.env.test.example @@ -21,6 +21,17 @@ IMAGE_TAG= FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads CORS_ALLOWED_ORIGINS= INTERNAL_SERVICE_TOKEN= +# 领星唯一账号;凭据仅注入 integration-service。 +LINGXING_OMS_APP_KEY= +LINGXING_OMS_APP_SECRET= +LINGXING_WMS_ENABLED=false +SELFOP_COLLECTION_MODE=OFF +SELFOP_SOURCE_ACCOUNT=lingxing-primary +# 未核准额度使用0,真实出口拒绝请求;有效模式仍需显式的独占交接证明与截止时间。 +SELFOP_DAILY_REQUEST_BUDGET=0 +SELFOP_MIN_REQUEST_INTERVAL_MS=5000 +SELFOP_EXECUTION_ENABLED=false +SELFOP_SALES_PUBLICATION_ENABLED=false REDIS_PASSWORD= AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai AUTH_DB_USERNAME= diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 909f61a79..4c2dc155b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -22,6 +22,8 @@ 内部拼车需求池的当前结构在 Lastleg 源基线和海外统一空库基线同步维护,独立五项权限在 IAM 源种子和国内统一基线维护。已存在基础表的指定环境结构补齐使用 `migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql` 与 `migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql`;执行前确认三张新表、六个新增字段均未存在,并单独核对目标环境。随后 `V11__align_capacity_pool_collation.sql` 将预订与商业桥的客户关联字段显式统一为 `utf8mb4_unicode_ci`,与原订单域一致,避免依赖MySQL实例默认排序规则导致JOIN失败。它们不回填、删除或重置业务数据,不能重放整个空库基线。候选文件存在不表示已经执行。 +平台自营领星统计十张 `ll_selfop_*` 表直接同步 Lastleg 源基线及海外统一空库基线;时间使用 UTC 存储及已确认北京时间业务日,数量使用 DECIMAL 保留 PCS 精度,维持独立发布版本。包含账号共享预算/冷却、验证根用途与硬预算、稳定任务号、阶段进度/心跳和旧版替代时间;库存 run 增加每日代表日期、选择规则及账号/仓库/日期唯一约束,代表库存与 profile 不适用销量副本淘汰。IAM 权限源为 `iam/src/main/resources/db/selfoperated-analytics-permissions.sql`,自营业务下销量分析、库存分析、数据采集的菜单资源与独立读写权限同步国内统一基线,仅平台管理员默认授权。本批只交付文件与离线验证,不执行 SQL、不部署;真实采集模式默认 OFF。 + ## 唯一入口与区域边界 两个统一 SQL 文件仍只用于空库初始化,名称中的“增量”不表示可向已有业务库重放。已有生产库的受控结构补齐单独放在 `migrations/`,只允许明确审阅过的增量变更;不提供存量数据回填、清理或兼容入口。 @@ -29,7 +31,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 181 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 194 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 @@ -51,7 +53,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/181`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/194`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -67,7 +69,7 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、417 个权限、598 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、419 个权限、600 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 340c8c727..5ec203f1e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8313,4 +8313,22 @@ SELECT 0,r.id,p.id,NOW(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND UPPER(r.role_code) IN('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND r.status='ACTIVE' AND r.deleted=0; +-- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。 +USE baiyue_iam; +SET NAMES utf8mb4; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES + (5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES + (4000001,5118000,0,CURRENT_TIMESTAMP(3)), + (4000001,5118001,0,CURRENT_TIMESTAMP(3)), + (4000001,5118100,0,CURRENT_TIMESTAMP(3)), + (4000001,5118110,0,CURRENT_TIMESTAMP(3)), + (4000001,5118120,0,CURRENT_TIMESTAMP(3)), + (4000001,5118130,0,CURRENT_TIMESTAMP(3)); + SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6cae76174..78159631c 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -3908,7 +3908,309 @@ CREATE TABLE ll_procurement_closure_item ( CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 181 AS initialized_tables; +USE baiyue_lastleg; + +-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。 +CREATE TABLE ll_selfop_outbound_discovery_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖', + catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖', + next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧', + next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次', + active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务', + lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者', + lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务', + request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正', + time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正', + min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置', + api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据', + api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除', + api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_discovery_account (source_account), + CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域'; + +CREATE TABLE ll_selfop_sync_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥', + scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单', + scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合', + business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准', + source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区', + schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行', + stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整', + warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表', + initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月', + initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间', + discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界', + discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点', + fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加', + published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号', + rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1', + invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻', + published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次', + lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁', + lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验', + known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标', + known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识', + known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间', + known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间', + history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界', + history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界', + history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置', + history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻', + review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正', + review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正', + review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family), + KEY idx_selfop_scope_lease (lease_until_utc), + CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针'; + +CREATE TABLE ll_selfop_sync_task ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识', + execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承', + validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空', + validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器', + upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游', + discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定', + observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', + request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', + task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', + planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', + query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', + query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', + query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号', + routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷', + scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要', + status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功', + apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE', + affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补', + apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间', + apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数', + next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻', + requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计', + result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空', + progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合', + progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_task_no (task_no), + UNIQUE KEY uk_selfop_task_request (source_account,request_key), + KEY idx_selfop_task_poll (status,next_retry_at_utc,id), + KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code), + KEY idx_selfop_task_apply (apply_state,id), + KEY idx_selfop_task_scope_list (scope_id,created_at,id), + KEY idx_selfop_task_account_list (source_account,task_role,created_at,id), + CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求'; + +CREATE TABLE ll_selfop_sync_run ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_id BIGINT NOT NULL COMMENT '本次执行请求', + execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本', + scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合', + attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增', + scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要', + scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版', + observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围', + fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本', + base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本', + reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实', + inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空', + inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存', + status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头', + last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实', + fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间', + fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空', + build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', + finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间', + published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间', + superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', + page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数', + source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空', + unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数', + progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写', + heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进', + coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断', + covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界', + covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点', + complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含', + complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含', + applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空', + calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号', + error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息', + error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no), + UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date), + KEY idx_selfop_run_scope (scope_id,id), + KEY idx_selfop_run_status (status,updated_at), + CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')), + CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头'; + +CREATE TABLE ll_selfop_sync_stage ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试', + stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页', + last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源', + observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓', + object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK', + object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对', + first_page_no INT NOT NULL COMMENT '首次发现页码', + observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻', + content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要', + payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段', + before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空', + validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常', + validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash), + KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id), + KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence), + KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status), + CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据'; + +CREATE TABLE ll_selfop_outbound_order ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份', + source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示', + source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码', + source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代', + source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代', + source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区', + outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空', + business_date DATE NULL COMMENT '已核准业务时区下的出库日', + business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日', + sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1', + revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数', + content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要', + last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试', + last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no), + KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id), + KEY idx_selfop_order_run (last_run_id), + CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实'; + +CREATE TABLE ll_selfop_outbound_item ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + order_id BIGINT NOT NULL COMMENT '对应外部出库事实', + sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku', + product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示', + actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值', + quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order_sku (order_id,sku_code), + KEY idx_selfop_item_sku (sku_code,order_id), + CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细'; + +CREATE TABLE ll_selfop_inventory_snapshot ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存', + product_name VARCHAR(255) NULL COMMENT '来源名称', + physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空', + available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结', + frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空', + transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存', + source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据', + observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间', + quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生', + quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality), + KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细'; + +CREATE TABLE ll_selfop_sku_daily_sales ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + business_date DATE NOT NULL COMMENT '已核准业务日', + outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负', + outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负', + quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date), + KEY idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code), + CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; + +CREATE TABLE ll_selfop_sku_profile ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称', + quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标', + first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据', + earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移', + evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据', + evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空', + last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code), + KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 194 AS initialized_tables; CREATE TABLE IF NOT EXISTS ll_appointment_impact ( diff --git a/docs/backend-business-index.md b/docs/backend-business-index.md index c28c57c98..fcd5cec42 100644 --- a/docs/backend-business-index.md +++ b/docs/backend-business-index.md @@ -56,3 +56,5 @@ | PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | 事件模型先读 `common-event-contract/AGENTS.md`;国内真实生产验收 `firstleg/.../TaillegRealProducerAcceptanceTest.java`、海外真实消费验收 `lastleg-service/.../TaillegRealConsumerAcceptanceTest.java`;可靠投递与 CN/OVERSEAS 目标路由读 `event-bridge-service/AGENTS.md` | 散货托盘码与跨区传递:`firstleg/` 的 `LoadingPalletLabelService.java` 生成冻结标签并调用 Print 输出 PDF,`LoadingCompletedOutboxService.java` 传递装柜快照;`lastleg-service/` 通过 `LastlegSourcePalletMapper.java` 保存来源托盘,`InboundPlanService.java` 和 `ContainerInboundPlanConversionCoordinator.java` 消费。标签与资源预留的细节继续读对应服务指引。 + +平台自营销量、产品库存与采集:正式入口为 ../docs/自营业务/README.md;适配从 integration-service/AGENTS.md 进入,持久化、指标和平台运行查询从 lastleg-service/AGENTS.md 进入 selfoperated/analytics/,权限源为 iam/src/main/resources/db/selfoperated-analytics-permissions.sql。Admin 从 ../apps/admin-web/src/modules/self-operated/AGENTS.md 进入;与客户物流订单和仓储作业库存分开。 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index d7663b79e..36de3dfaf 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -17,6 +17,7 @@ | 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 | | 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` | | 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` | +| 平台自营领星统计权限 | `src/main/resources/db/selfoperated-analytics-permissions.sql`;自营业务下销量分析、库存分析、数据采集菜单资源同步国内统一空库基线;三个页面和所有只读接口统一使用 `platform:self-operated-analytics:list`,采集写入独立使用 `platform:self-operated-analytics:sync`。菜单资源不替代服务端读写授权。 | | 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` | | 持久化安全版本 | `service/SecurityVersionService.java`、`src/main/resources/db/security-version.sql`;安全变更与 subject/customer 版本同事务提交,Auth 在线核对单 SQL 授权快照,无专用安全事件投影 | | 消息中心/站内通知 | `notification/controller/NotificationInboxController.java`、`notification/controller/PlatformNotificationController.java`、`notification/service/NotificationService.java`;模板、任务、收件箱及事件消费均在 `notification/` | diff --git a/iam/src/main/resources/db/selfoperated-analytics-permissions.sql b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql new file mode 100644 index 000000000..c6d6d1b49 --- /dev/null +++ b/iam/src/main/resources/db/selfoperated-analytics-permissions.sql @@ -0,0 +1,17 @@ +-- 自营业务菜单资源用于菜单管理;三个页面和所有只读接口统一由 list 授权,采集写权限独立。 +USE baiyue_iam; +SET NAMES utf8mb4; +INSERT INTO iam_permission (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,created_at,updated_at,deleted) VALUES + (5118100,0,'PLATFORM','MENU',0,'platform:self-operated:menu','自营业务',NULL,NULL,1100,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118110,0,'PLATFORM','MENU',5118100,'platform:self-operated:sales:menu','销量分析','/self-operated/sales','GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118120,0,'PLATFORM','MENU',5118100,'platform:self-operated:inventory:menu','库存分析','/self-operated/inventory','GET',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118130,0,'PLATFORM','MENU',5118100,'platform:self-operated:collection:menu','数据采集','/self-operated/collection','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118000,0,'PLATFORM','API',5118100,'platform:self-operated-analytics:list','读取平台自营出库销量与库存分析','/api/platform/v1/last-leg/self-operated-analytics','GET',40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0), + (5118001,0,'PLATFORM','API',5118130,'platform:self-operated-analytics:sync','平台自营采集重试与重采集','/api/platform/v1/last-leg/self-operated-analytics/sync-tasks','POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id,permission_id,tenant_id,created_at) VALUES + (4000001,5118000,0,CURRENT_TIMESTAMP(3)), + (4000001,5118001,0,CURRENT_TIMESTAMP(3)), + (4000001,5118100,0,CURRENT_TIMESTAMP(3)), + (4000001,5118110,0,CURRENT_TIMESTAMP(3)), + (4000001,5118120,0,CURRENT_TIMESTAMP(3)), + (4000001,5118130,0,CURRENT_TIMESTAMP(3)); diff --git a/integration-service/.env.example b/integration-service/.env.example index 5a8b20f6e..a246fea07 100644 --- a/integration-service/.env.example +++ b/integration-service/.env.example @@ -1,3 +1,3 @@ # Copy to .env.local for local WMS connectivity verification. -LINGXING_WMS_APP_KEY= -LINGXING_WMS_APP_SECRET= +LINGXING_OMS_APP_KEY= +LINGXING_OMS_APP_SECRET= diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index 8347661ef..a43a01575 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -9,6 +9,7 @@ - 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java` - 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java` +- 领星 WMS 自研系统出库只读查询:`controller/LingxingInternalController.java`、`provider/lingxing/`、`dto/lingxing/`、`config/LingxingWmsProperties.java`;内部契约 `openapi/lingxing-wms-v1.yaml` 位于 `src/main/resources/`。内部提供出库单页摘要、独立按单定位、出库详情、仓库目录及单仓产品库存单页查询;统一账号节流、有限退避和严格分页校验,保留未知数量与源时间原值。不编排定时采集、多页库存同步或业务持久化;本地隔离启动与环境变量入口见 `README.md`,真实调用默认关闭。 - OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java` - 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java` - 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md` diff --git a/integration-service/README.md b/integration-service/README.md new file mode 100644 index 000000000..14974e443 --- /dev/null +++ b/integration-service/README.md @@ -0,0 +1,102 @@ +# Integration 本地领星 WMS 查询 + +本入口用于在现有 Java 服务内验证 WMS 只读调用。仅返回单页出库摘要,不启动月度采集、库存全量同步、定时任务或数据库写入。业务方案见[领星一件代发数据统计一期方案](../../docs/自营业务/领星一件代发数据统计一期方案.md)。 + +后续开发交接见[接口联调手册](../../docs/自营业务/领星WMS接口联调手册.md):已另行用只读 HTTP 验证仓库发现、一件代发详情和按仓产品库存全分页。此处现有 Java 接口仍只有出库摘要;库存内部接口、分仓任务和持久化尚待开发,不能以外部直连成功推定 Java 功能已经具备。 + +## 凭据与开关 + +从 WMS 管理后台的“个人中心 → API 信息 → 自研系统”获取 `appKey` 和 `appSecret`,填写本目录 Git 忽略的 `.env.local`,格式参考 [.env.example](.env.example)。这两个值分别加载为 `LINGXING_OMS_APP_KEY` 和 `LINGXING_OMS_APP_SECRET` 进程环境变量,不提交真实值。Spring 本身不会自动读取 `.env.local`,应在启动 Java 的同一终端加载。该凭据属于 OMP 服务商内部系统对接,不使用 OMS 货主或 WMS 智能设备协议。 + +正式应用配置默认 `LINGXING_WMS_ENABLED=false`。下面的本地命令显式启用适配器,为该本地实例生成内部调用令牌。不要复用共享环境令牌。 + +测试部署通过 `deploy/docker-compose.yml` 将 `LINGXING_OMS_APP_KEY`、`LINGXING_OMS_APP_SECRET` 传入 Integration 容器。现有 Jenkins 下游部署任务读取 `firstleg-test-env` Secret File;可将这两项加入该文件,或在实际执行部署的下游服务任务中以凭据绑定注入同名环境变量。只在上游总控任务设置变量不会自动传给下游任务。变量命名采用用户指定的 `OMS`,接口协议仍为当前 WMS 自研系统(OMP);真实凭据是否有该接口权限仍待联调核验。不要将密钥作为普通构建参数或打印完整 Compose 配置。 + +2026-10-12 用户确认唯一账号、所有源时间按北京时间、数量按 SKU 件数。Lastleg 应用配置使用 `source-account=lingxing-primary`(内部稳定标识,不是 appKey)、`source-query-timezone=Asia/Shanghai`、`quantity-unit=PCS`,分仓配置的源时区默认 `Asia/Shanghai`;数量仍保留十进制精度。仓库编码须在获得真实仓库目录后配置到 `baiyue.self-operated-analytics.warehouses`,不预填虚构仓库。`SELFOP_EXECUTION_ENABLED` 和 `SELFOP_SALES_PUBLICATION_ENABLED` 均默认关闭。 + +## 本地启动 + +先在后端仓库根目录执行构建,包含相关模块的编译与测试: + +```powershell +mvn -pl integration-service -am package +``` + +然后在同一目录的 PowerShell 中执行。只加载明确允许的两个名称,不执行环境文件内的代码: + +```powershell +$credentialNames = @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET') +foreach ($line in [IO.File]::ReadAllLines((Join-Path (Get-Location) 'integration-service/.env.local'))) { + if ($line -match '^\s*(LINGXING_OMS_APP_KEY|LINGXING_OMS_APP_SECRET)\s*=(.*)$') { + $credentialName = $matches[1] + $credentialValue = $matches[2].Trim() + if ($credentialValue.Length -ge 2 -and + (($credentialValue.StartsWith('"') -and $credentialValue.EndsWith('"')) -or + ($credentialValue.StartsWith("'") -and $credentialValue.EndsWith("'")))) { + $credentialValue = $credentialValue.Substring(1, $credentialValue.Length - 2) + } + [Environment]::SetEnvironmentVariable($credentialName, $credentialValue, 'Process') + } +} +foreach ($credentialName in $credentialNames) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($credentialName, 'Process'))) { + throw "Missing credential: $credentialName" + } +} +$env:INTERNAL_SERVICE_TOKEN = [guid]::NewGuid().ToString() +$localInternalToken = $env:INTERNAL_SERVICE_TOKEN +$env:INTEGRATION_LOCAL_PORT = '19210' +$buildJar = (Resolve-Path 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar').Path +$localJava = Start-Process -FilePath (Get-Command java).Source -WindowStyle Hidden -PassThru ` + -ArgumentList @('-jar', ('"' + $buildJar + '"'), + '--spring.config.location=classpath:/application-lingxing-local.yml', + '--baiyue.integration.lingxing-wms.enabled=true') ` + -RedirectStandardOutput 'integration-service/target/lingxing-local.stdout.log' ` + -RedirectStandardError 'integration-service/target/lingxing-local.stderr.log' +``` + +该配置仅监听 `127.0.0.1`,关闭 Nacos Config、Discovery、服务注册及 Spring 定时任务,不导入共享环境配置。若端口占用,选择其他本地端口并同步替换后续 URL,不停止未知进程。确认 `http://127.0.0.1:19210/actuator/health` 返回 `UP` 后再查询。 + +## 读取一条出库单 + +在保留上述变量的同一 PowerShell 中执行一次: + +```powershell +$businessZone = [TimeZoneInfo]::FindSystemTimeZoneById('China Standard Time') +$queryEnd = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $businessZone) +$query = @{ + startTime = $queryEnd.AddMonths(-1).ToString('yyyy-MM-ddTHH:mm:ss') + endTime = $queryEnd.ToString('yyyy-MM-ddTHH:mm:ss') + current = 1 + size = 1 +} | ConvertTo-Json -Compress +$result = Invoke-RestMethod -Method Post ` + -Uri 'http://127.0.0.1:19210/internal/integrations/lingxing/wms/outbound-orders/query' ` + -Headers @{'X-Internal-Service-Token' = $localInternalToken} ` + -ContentType 'application/json' -Body $query +$result | ConvertTo-Json -Depth 6 +``` + +返回摘要只包含出库单号、来源单号、货主编码、仓库编码、状态、创建时间和出库时间,不返回收件人或地址。业务 `code="200"` 且 `records` 包含一条才表示已读到样本;空页表示调用成功但未取得样本;`B0001`/`S0503` 表示明确失败,保留 `errorCode`、来源数值错误码及请求标识排查,不能当作零销量。 + +当前调用 OMP `POST /openapi/v2/order/delivery/page`,固定 `timeType=outboundTime`、`status=100` 已出库,窗口最多向前一个自然月。该本地保护不代表供应商历史上限;单页样本也不代表整月采集完成。订单详情、全量库存、增量任务和统计落库仍按业务方案后续实现。 + +测试完毕,在同一终端停止本次启动的进程,并清理当前终端注入的变量: + +```powershell +if (-not $localJava.HasExited) { Stop-Process -Id $localJava.Id } +foreach ($credentialName in @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET', 'INTERNAL_SERVICE_TOKEN')) { + [Environment]::SetEnvironmentVariable($credentialName, $null, 'Process') +} +$localInternalToken = $null +$credentialValue = $null +``` + +## 接口与验证 + +- 2026-10-12 已通过本地 Java 实例调用 OMP WMS 出库分页,按最近 1 个月、`current=1`、`size=1` 成功返回 1 条已出库摘要;无内部令牌请求被拒绝。验证后本地进程已停止。该结果仅证明当前只读链路可用,不代表全量采集或库存同步完成。 +- 实测成功响应为数值 `code=200`,但缺少官方示例及 Schema 中的 `success`。客户端以成功码和有效分页结构共同判定;`success` 出现时仍须为布尔 `true`。此差异已加入回归测试,失败响应不转换为空页。 +- 内部前缀为 `POST /internal/integrations/lingxing/wms`,提供 `/outbound-orders/query`、`/outbound-orders/locate`、`/outbound-orders/detail`、`/warehouses/query`、`/product-inventory/query`;仅接受 `X-Internal-Service-Token`,不经公网 Gateway。各请求只返回一页或一单,多页与业务版本归 Lastleg 编排。定位不传时间/状态;产品库存必须指定仓库、保留全部品质和零库存。源时间原值与 BigDecimal 数量未知值保留,不推断时区或单位。 +- [OpenAPI](src/main/resources/openapi/lingxing-wms-v1.yaml)定义输入、分页摘要和失败结构。 +- 从后端根执行 `mvn -pl integration-service -am test` 与 `node --test scripts/gateway-route-contract.test.mjs`。 +- 本地模拟 HTTP 单测验证签名、请求体、敏感信息隔离和错误处理;不能代替真实领星样本验证。官方入口为[服务商内部系统对接](https://apidoc-omp.xlwms.com/)与[一件代发出库单 WMS 分页查询](https://apidoc-omp.xlwms.com/reference/post_openapi-v2-order-delivery-page-1)。 diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java new file mode 100644 index 000000000..b09ec18ac --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java @@ -0,0 +1,35 @@ +package com.baiyue.integration.config; + +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; + +/** An operator-attested exclusive window; it is not a distributed account lock. */ +@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress") +public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode, + @DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId, + Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval, + @DefaultValue("5m") Duration cooldownFallback) { + public LingxingEgressProperties { + collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode; + environmentLabel = environmentLabel == null ? "test" : environmentLabel; + minStartInterval = minStartInterval == null ? Duration.ofSeconds(5) : minStartInterval; + cooldownFallback = cooldownFallback == null ? Duration.ofMinutes(5) : cooldownFallback; + if (minStartInterval.compareTo(Duration.ofSeconds(5)) < 0 || minStartInterval.compareTo(Duration.ofMinutes(5)) > 0 + || cooldownFallback.compareTo(Duration.ofSeconds(5)) < 0 || cooldownFallback.compareTo(Duration.ofDays(1)) > 0) { + throw new IllegalArgumentException("领星出口间隔至少5秒,冷却配置须为有界正值"); + } + if (budgetAuthorityBaseUri != null && (budgetAuthorityBaseUri.getHost() == null + || !("http".equals(budgetAuthorityBaseUri.getScheme()) || "https".equals(budgetAuthorityBaseUri.getScheme())) + || budgetAuthorityBaseUri.getUserInfo() != null || budgetAuthorityBaseUri.getQuery() != null + || budgetAuthorityBaseUri.getFragment() != null || !budgetAuthorityBaseUri.getPath().isEmpty())) { + throw new IllegalArgumentException("领星预算授权地址必须为受控内部服务源地址"); + } + } + @Override public String toString() { + return "LingxingEgressProperties[collectionMode=" + collectionMode + ", environmentLabel=" + environmentLabel + "]"; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java index 5226455a5..07ba44975 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java @@ -13,18 +13,15 @@ public record LingxingWmsProperties( String appKey, String appSecret, @DefaultValue("5s") Duration connectTimeout, - @DefaultValue("15s") Duration requestTimeout, - @DefaultValue("1s") Duration minRequestInterval + @DefaultValue("15s") Duration requestTimeout ) { public LingxingWmsProperties { baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri; connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout; requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout; - minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval; if (connectTimeout.isZero() || connectTimeout.isNegative() - || requestTimeout.isZero() || requestTimeout.isNegative() - || minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) { - throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间"); + || requestTimeout.isZero() || requestTimeout.isNegative()) { + throw new IllegalArgumentException("领星 WMS 超时必须为正数"); } String host = baseUri.getHost(); boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java index 6d57af5df..7458395e3 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java @@ -5,6 +5,11 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.web.CorrelationContext; import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.core.JsonParser; import com.baiyue.integration.provider.lingxing.LingxingWmsClient; import com.baiyue.integration.provider.lingxing.LingxingWmsException; import com.baiyue.integration.security.InternalServiceTokenGuard; @@ -25,19 +30,76 @@ import org.springframework.web.bind.annotation.RestController; public class LingxingInternalController { private final InternalServiceTokenGuard tokenGuard; private final LingxingWmsClient client; + private final ObjectMapper mapper; - public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) { + public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client, ObjectMapper mapper) { this.tokenGuard = tokenGuard; this.client = client; + this.mapper = mapper; } @Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要") @PostMapping("/outbound-orders/query") public ApiResponse query( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, @Valid @RequestBody QueryRequest request) { tokenGuard.verify(token); - return ApiResponse.success(client.queryPage(request)); + return ApiResponse.success(client.queryPage(request, context(context))); + } + + @Operation(summary = "读取正常仓库目录单页") + @PostMapping("/warehouses/query") + public ApiResponse> warehouses( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody WarehouseQueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryWarehouses(request, context(context))); + } + + @Operation(summary = "按单号定位单页,不附加时间或状态筛选") + @PostMapping("/outbound-orders/locate") + public ApiResponse locate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody LocateRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.locate(request, context(context))); + } + + @Operation(summary = "按仓库、货主、发货单号读取完整 SKU 详情") + @PostMapping("/outbound-orders/detail") + public ApiResponse detail( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody DetailRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryDetail(request, context(context))); + } + + @Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页") + @PostMapping("/product-inventory/query") + public ApiResponse> inventory( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context, + @Valid @RequestBody InventoryQueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryInventory(request, context(context))); + } + + private EgressContext context(String header) { + if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126)) + throw invalidContext(); + try { + EgressContext context = mapper.readerFor(EgressContext.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, + DeserializationFeature.FAIL_ON_TRAILING_TOKENS).with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION).readValue(header); + if (context == null || !context.wellFormed()) throw invalidContext(); + return context; + } catch (Exception ignored) { throw invalidContext(); } + } + private static LingxingWmsException invalidContext() { + return new LingxingWmsException("LINGXING_WMS_EGRESS_CONTEXT_INVALID", "领星安全出口控制上下文缺失或无效", false, null, null); } @ExceptionHandler(LingxingWmsException.class) @@ -46,10 +108,11 @@ public class LingxingInternalController { ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR; return ApiResponse.of(code, exception.getMessage(), new ProviderFailure(exception.code(), exception.retryable(), - exception.upstreamStatus(), exception.sourceCode()), + exception.upstreamStatus(), exception.sourceCode(), exception.egress() == null + ? new AttemptMetadata(null, 0, false, null) : exception.egress(), exception.denial()), CorrelationContext.requestId(request), CorrelationContext.traceId(request)); } public record ProviderFailure(String errorCode, boolean retryable, - Integer upstreamStatus, Integer sourceCode) { } + Integer upstreamStatus, Integer sourceCode, AttemptMetadata egress, AuthorizationDenial denial) { } } diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java new file mode 100644 index 000000000..5d02a6808 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java @@ -0,0 +1,31 @@ +package com.baiyue.integration.dto.lingxing; + +import java.time.Instant; + +/** Control identities are supplied by the collector and revalidated by its durable authority. */ +public final class LingxingEgressDtos { + private LingxingEgressDtos() { } + public enum CollectionMode { OFF, VALIDATION, BUSINESS } + public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY } + public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId, + String sessionId, String attemptId, String exclusiveGrantId) { + public boolean wellFormed() { + return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS) + && identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128) + && identifier(attemptId, 128) && identifier(exclusiveGrantId, 128); + } + private static boolean identifier(String value, int maximum) { + return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); + } + } + public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { } + public record AttemptPermit(EgressContext context, Operation operation, String requestHash, + int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed, + boolean budgetConsumed) { } + public record CompletionRequest(EgressContext context, Operation operation, String requestHash, + int actualHttpAttempts, String errorCode, Integer upstreamStatus, + Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { } + public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused, + Instant cooldownUntil) { } + public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java index 12ddac758..52257c682 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -3,10 +3,14 @@ package com.baiyue.integration.dto.lingxing; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.math.BigDecimal; import java.time.Instant; import java.time.LocalDateTime; import java.util.List; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata; public final class LingxingWmsDtos { private LingxingWmsDtos() { } @@ -14,12 +18,38 @@ public final class LingxingWmsDtos { public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime, @Min(1) int current, @Min(1) @Max(100) int size) { } - // Preserve source time strings until the account's source timezone has been verified. + public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { } + public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo, + @Min(1) int current, @Min(1) @Max(1000) int size) { } + public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode, + @NotBlank @Size(max=64) String customerCode, + @NotBlank @Size(max=128) String deliveryNo) { } + public record InventoryQueryRequest(@NotBlank @Size(max=64) String warehouseCode, + @Min(1) int current, @Min(1) @Max(1000) int size) { } + public record Warehouse(String whCode, String whNameCn, Integer warehouseType, + Integer status, String timeZone) { } + public record SkuLine(String productSku, String productName, BigDecimal actualDeliveryQty, + BigDecimal deliveryQty) { } + public record OrderDetail(String deliveryNo, String sourceNo, String customerCode, String whCode, + Integer status, String orderCreateTime, String outboundTime, + List skuVOList, int sourceCode, Instant observedAt, AttemptMetadata egress) { + public OrderDetail { skuVOList = List.copyOf(skuVOList); } + } + public record ProductStock(String customerCode, String whCode, String productSku, String productName, + Integer productQuality, BigDecimal stkQty, BigDecimal freezeQty, + BigDecimal useStkQty, BigDecimal transitQty, String innerBatchNo, + String outBatchNo, String uniqueKey4tenant) { } + public record QueryPage(List records, Long total, int current, int size, int pages, + int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) { + public QueryPage { records = List.copyOf(records); } + } + + // Preserve source strings; the collector interprets confirmed Asia/Shanghai source time. public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode, Integer status, String createTime, String outboundTime) { } public record PageResult(List records, Long total, int current, int size, - int sourceCode, Instant observedAt) { + int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) { public PageResult { records = List.copyOf(records); } } } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetAuthority.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetAuthority.java new file mode 100644 index 000000000..bd51b96fd --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetAuthority.java @@ -0,0 +1,9 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; + +/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */ +public interface LingxingBudgetAuthority { + AttemptPermit authorize(AuthorizationRequest request); + void complete(CompletionRequest request); +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetDeniedException.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetDeniedException.java new file mode 100644 index 000000000..b86578610 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingBudgetDeniedException.java @@ -0,0 +1,11 @@ +package com.baiyue.integration.provider.lingxing; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial; + +/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */ +public final class LingxingBudgetDeniedException extends RuntimeException { + private final AuthorizationDenial denial; + public LingxingBudgetDeniedException(AuthorizationDenial denial) { + super("领星持久预算授权拒绝"); this.denial = denial; + } + public AuthorizationDenial denial() { return denial; } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java new file mode 100644 index 000000000..cf6808bcd --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingEgressGate.java @@ -0,0 +1,131 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.time.Instant; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** Local serialization supplements, never replaces, the externally attested window and durable budget. */ +@Component +public final class LingxingEgressGate { + private final LingxingEgressProperties properties; + private final LingxingBudgetAuthority authority; + private final Clock clock; + private final Waiter waiter; + private final Semaphore inFlight = new Semaphore(1, true); + private volatile boolean paused; + private volatile boolean recoverableRatePause; + private Instant lastStart; + private Instant cooldownUntil; + @Autowired public LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority) { + this(properties, authority, Clock.systemUTC(), Thread::sleep); + } + LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) { + this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter; + } + public T execute(EgressContext context, Operation operation, String hash, AuthorizedCall call) { + try { return guarded(context, operation, hash, call); } + catch (LingxingWmsException error) { + if (error.egress() == null) error.withEgress(new AttemptMetadata( + context != null && identifier(context.attemptId(), 128) ? context.attemptId() : null, 0, paused, cooldownUntil)); + throw error; + } + } + private T guarded(EgressContext context, Operation operation, String hash, AuthorizedCall call) { + check(context); + if (operation == null || hash == null || !hash.matches("[a-f0-9]{64}")) throw failure("EGRESS_CONTEXT_INVALID"); + boolean acquired = false; + try { + while (!(acquired = inFlight.tryAcquire(100, TimeUnit.MILLISECONDS))) check(context); + check(context); + if (lastStart != null) { + Instant next = lastStart.plus(properties.minStartInterval()); + long wait = java.time.Duration.between(clock.instant(), next).toMillis(); + if (wait > 0) waiter.sleep(wait + 1); + } + check(context); + AuthorizationRequest request = new AuthorizationRequest(context, operation, hash); + AttemptPermit permit; + try { permit = authority.authorize(request); } + catch (LingxingBudgetDeniedException knownDecision) { + throw failure("BUDGET_AUTHORIZATION_DENIED").withDenial(knownDecision.denial()); + } + catch (RuntimeException error) { recoverableRatePause = false; paused = true; throw failure("BUDGET_AUTHORITY_UNAVAILABLE"); } + if (permit == null || !context.equals(permit.context()) || operation != permit.operation() + || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() + || !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) + || permit.expiresAt().isAfter(clock.instant().plusSeconds(30)) + || permit.expiresAt().isAfter(properties.externalExclusiveUntil())) { + recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID"); + } + // A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state. + // Time alone never clears the pause. VALIDATION and uncertain callbacks cannot reach this branch. + if (paused && recoverableRatePause && properties.collectionMode() == CollectionMode.BUSINESS) { + paused = false; recoverableRatePause = false; cooldownUntil = null; + } + AttemptHandle handle = new AttemptHandle(context, permit.expiresAt()); + T result = null; + LingxingWmsException failure = null; + try { result = call.run(handle); } + catch (LingxingWmsException error) { failure = error; } + catch (RuntimeException error) { failure = failure("LOCAL_PROCESSING_FAILED"); } + CompletionRequest completion = new CompletionRequest(context, operation, hash, handle.actualHttpAttempts, + failure == null ? null : failure.code(), failure == null ? Integer.valueOf(200) : failure.upstreamStatus(), + failure == null ? Integer.valueOf(200) : failure.sourceCode(), paused, cooldownUntil); + try { authority.complete(completion); } + catch (RuntimeException error) { + recoverableRatePause = false; paused = true; + // Unconfirmed completion must remain distinguishable even when the provider already failed. + failure = failure("BUDGET_COMPLETION_UNCONFIRMED"); + } + if (failure != null) throw failure.withEgress(handle.metadata()); + return result; + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); throw failure("INTERRUPTED"); + } finally { if (acquired) inFlight.release(); } + } + private void check(EgressContext context) { + if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF"); + if (properties.collectionMode() == CollectionMode.BUSINESS && !"production".equals(properties.environmentLabel())) + throw failure("BUSINESS_ENVIRONMENT_REJECTED"); + if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS + || cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED"); + if (context == null || context.executionPurpose() != properties.collectionMode() + || !context.wellFormed() + || !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID"); + if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId()) + || properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()) + || properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + private static boolean identifier(String value, int maximum) { + return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); + } + private static LingxingWmsException failure(String code) { + return new LingxingWmsException("LINGXING_WMS_" + code, "领星安全出口拒绝请求(" + code + ")", false, null, null); + } + public final class AttemptHandle { + private final EgressContext context; + private final Instant expiresAt; + private int actualHttpAttempts; + private AttemptHandle(EgressContext context, Instant expiresAt) { this.context = context; this.expiresAt = expiresAt; } + /** Immediately before send, after signing. One permit can never send twice. */ + public void startHttp() { + check(context); + if (actualHttpAttempts != 0 || !clock.instant().isBefore(expiresAt)) throw failure("ATTEMPT_NOT_SENDABLE"); + actualHttpAttempts = 1; lastStart = clock.instant(); + } + public void rateLimited(Instant retryAfter) { + Instant fallback = clock.instant().plus(properties.cooldownFallback()); + cooldownUntil = retryAfter != null && retryAfter.isAfter(fallback) ? retryAfter : fallback; + recoverableRatePause = properties.collectionMode() == CollectionMode.BUSINESS; + paused = true; + } + public AttemptMetadata metadata() { return new AttemptMetadata(context.attemptId(), actualHttpAttempts, paused, cooldownUntil); } + } + @FunctionalInterface interface Waiter { void sleep(long millis) throws InterruptedException; } + @FunctionalInterface public interface AuthorizedCall { T run(AttemptHandle handle); } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java new file mode 100644 index 000000000..37e85301d --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthority.java @@ -0,0 +1,73 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */ +@Component +public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority { + private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/"; + private final LingxingEgressProperties properties; + private final ObjectMapper mapper; + private final String internalToken; + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper, + @Value("${baiyue.security.internal-service-token:}") String internalToken) { + this.properties = properties; this.mapper = mapper; this.internalToken = internalToken; + } + @Override public AttemptPermit authorize(AuthorizationRequest request) { + JsonNode data = post("authorize", request); + if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber() + || data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean() + || !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean() + || !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject() + || !data.path("expiresAt").isTextual()) throw unavailable(); + try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); } + catch (Exception ignored) { throw unavailable(); } + } + @Override public void complete(CompletionRequest request) { + JsonNode data = post("complete", request); + if (!data.isBoolean() || !data.booleanValue()) throw unavailable(); + } + private JsonNode post(String suffix, Object payload) { + if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank() + || internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable(); + try { + HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix)) + .timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken) + .header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) throw unavailable(); + JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body()); + if (root == null || !root.isObject() || !root.path("code").isTextual() + || !root.hasNonNull("data")) throw unavailable(); + if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) { + JsonNode data = root.get("data"); + if (!data.isObject() || !data.path("errorCode").isTextual() + || !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}") + || !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable(); + AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); + throw new LingxingBudgetDeniedException(denial); + } + if (!"200".equals(root.path("code").textValue())) throw unavailable(); + return root.get("data"); + } catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision; + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); throw unavailable(); + } catch (Exception ignored) { throw unavailable(); } + } + private static LingxingWmsException unavailable() { + return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java index 01a27a7c6..fda1731da 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -4,10 +4,21 @@ import com.baiyue.integration.config.LingxingWmsProperties; import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary; import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import java.util.function.BiFunction; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.math.BigDecimal; +import java.util.HashSet; +import java.util.function.Function; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -25,7 +36,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.Semaphore; @Component public final class LingxingWmsClient { @@ -37,49 +47,174 @@ public final class LingxingWmsClient { private final LingxingWmsSigner signer; private final HttpClient httpClient; private final Clock clock; - private final Semaphore concurrency = new Semaphore(1); - private long lastRequestNanos; - private boolean hasRequested; + private final LingxingEgressGate egressGate; @Autowired - public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) { - this(properties, mapper, Clock.systemUTC()); + public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) { + this(properties, mapper, Clock.systemUTC(), egressGate); } - LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) { + LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock, LingxingEgressGate egressGate) { this.properties = properties; - this.mapper = mapper; + this.mapper = mapper.copy().disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); this.clock = clock; - signer = new LingxingWmsSigner(mapper); + this.egressGate = egressGate; + signer = new LingxingWmsSigner(this.mapper); httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()) .followRedirects(HttpClient.Redirect.NEVER).build(); } - public PageResult queryPage(QueryRequest request) { + public PageResult queryPage(QueryRequest request, EgressContext context) { + validate(request); + Map data = pagination(request.current(), request.size()); + data.put("timeType", "outboundTime"); + data.put("startTime", SOURCE_TIME.format(request.startTime())); + data.put("endTime", SOURCE_TIME.format(request.endTime())); + data.put("status", 100); + return execute(PAGE_PATH, data, context, Operation.OUTBOUND_QUERY, + (json, metadata) -> orders(json, request.current(), request.size(), null, metadata)); + } + + public PageResult locate(LocateRequest request, EgressContext context) { + if (request == null) throw badRequest(); + requireInput(request.deliveryNo(), 128); + validatePagination(request.current(), request.size(), 1000); + Map data = pagination(request.current(), request.size()); + data.put("deliveryNo", request.deliveryNo()); + return execute(PAGE_PATH, data, context, Operation.OUTBOUND_LOCATE, + (json, metadata) -> orders(json, request.current(), request.size(), request.deliveryNo(), metadata)); + } + + public QueryPage queryWarehouses(WarehouseQueryRequest request, EgressContext context) { + if (request == null) throw badRequest(); + validatePagination(request.current(), request.size(), 1000); + return execute("/openapi/v2/warehouse/page", pagination(request.current(), request.size()), context, Operation.WAREHOUSES, + (data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, row -> + new Warehouse(requiredText(row, "whCode", 64), optionalText(row, "whNameCn"), + requiredInt(row.get("warehouseType"), "WAREHOUSE_TYPE"), requiredInt(row.get("status"), "STATUS"), + optionalText(row, "timeZone")))); + } + + public OrderDetail queryDetail(DetailRequest request, EgressContext context) { + if (request == null) throw badRequest(); + requireInput(request.warehouseCode(), 64); + requireInput(request.customerCode(), 64); + requireInput(request.deliveryNo(), 128); + Map body = new LinkedHashMap<>(); + body.put("whCode", request.warehouseCode()); + body.put("customerCode", request.customerCode()); + body.put("deliveryNo", request.deliveryNo()); + return execute("/openapi/v2/order/delivery/detail", body, context, Operation.OUTBOUND_DETAIL, + (data, metadata) -> detail(data, request, metadata)); + } + + private OrderDetail detail(JsonNode data, DetailRequest request, AttemptMetadata metadata) { + String deliveryNo = requiredText(data, "deliveryNo", 128); + String customer = requiredText(data, "customerCode", 64); + String warehouse = requiredText(data, "whCode", 64); + if (!deliveryNo.equals(request.deliveryNo()) || !customer.equals(request.customerCode()) + || !warehouse.equals(request.warehouseCode())) throw invalidResponse("DETAIL_IDENTITY_MISMATCH", 200); + JsonNode rows = data.get("skuVOList"); + if (rows == null || !rows.isArray() || rows.isEmpty()) throw invalidResponse("SKU_LIST_INVALID", 200); + List lines = new ArrayList<>(); + for (JsonNode row : rows) { + if (!row.isObject()) throw invalidResponse("SKU_ROW_INVALID", 200); + lines.add(new SkuLine(requiredText(row, "productSku", 128), optionalText(row, "productName"), + quantity(row.get("actualDeliveryQty")), quantity(row.get("deliveryQty")))); + } + return new OrderDetail(deliveryNo, optionalText(data, "sourceNo"), customer, warehouse, + requiredInt(data.get("status"), "STATUS"), optionalText(data, "orderCreateTime"), + optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata); + } + + public QueryPage queryInventory(InventoryQueryRequest request, EgressContext context) { + if (request == null) throw badRequest(); + requireInput(request.warehouseCode(), 64); + validatePagination(request.current(), request.size(), 1000); + Map body = pagination(request.current(), request.size()); + body.put("whCode", request.warehouseCode()); + body.put("isHideInventory", 0); + return execute("/openapi/v2/stock/stockSku/page", body, context, Operation.PRODUCT_INVENTORY, + (data, metadata) -> inventory(data, request, metadata)); + } + + private QueryPage inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) { + Set> keys = new HashSet<>(); + return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> { + String customer = requiredText(row, "customerCode", 64); + String warehouse = requiredText(row, "whCode", 64); + String sku = requiredText(row, "productSku", 128); + int quality = requiredInt(row.get("productQuality"), "PRODUCT_QUALITY"); + if (!warehouse.equals(request.warehouseCode())) throw invalidResponse("INVENTORY_WAREHOUSE_MISMATCH", 200); + if (!keys.add(List.of(customer, warehouse, sku, quality))) throw invalidResponse("DUPLICATE_INVENTORY_KEY", 200); + return new ProductStock(customer, warehouse, sku, optionalText(row, "productName"), quality, + quantity(row.get("stkQty")), quantity(row.get("freezeQty")), quantity(row.get("useStkQty")), + quantity(row.get("transitQty")), optionalText(row, "innerBatchNo"), optionalText(row, "outBatchNo"), + optionalText(row, "uniqueKey4tenant")); + }); + } + + private T execute(String path, Map data, EgressContext context, Operation operation, + BiFunction projection) { if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false); if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false); - validate(request); - if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true); + if (context != null && context.executionPurpose() == CollectionMode.VALIDATION + && data.containsKey("size") && (int)data.get("size") > 10) throw badRequest(); + String hash; try { - reserveRequest(); - Map data = new LinkedHashMap<>(); - data.put("current", request.current()); - data.put("size", request.size()); - data.put("timeType", "outboundTime"); - data.put("startTime", SOURCE_TIME.format(request.startTime())); - data.put("endTime", SOURCE_TIME.format(request.endTime())); - data.put("status", 100); - var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant()); - HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH)) + hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( + (path + "\n" + mapper.writer().without(SerializationFeature.INDENT_OUTPUT, + SerializationFeature.WRAP_ROOT_VALUE).writeValueAsString(data)).getBytes(StandardCharsets.UTF_8))); + } catch (Exception ignored) { throw failure("REQUEST_HASH_FAILED", "领星请求授权摘要生成失败", false); } + return egressGate.execute(context, operation, hash, handle -> { + var signed = signer.sign(properties.appKey(), properties.appSecret(), path, data, clock.instant()); + HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(path)) .timeout(properties.requestTimeout()).header("Accept", "application/json") .header("Content-Type", "application/json; charset=UTF-8") .POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build(); + handle.startHttp(); HttpResponse response = send(httpRequest); + if (response.statusCode() == 429) handle.rateLimited(retryAfter(response)); validateStatus(response.statusCode()); - return page(response.body(), request); - } finally { - concurrency.release(); - } + JsonNode json; + try { json = envelope(response.body()); } + catch (LingxingWmsException error) { + if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) { + handle.rateLimited(retryAfter(response)); + } + throw error; + } + return projection.apply(json, handle.metadata()); + }); + } + + private Instant retryAfter(HttpResponse response) { + String value = response.headers().firstValue("Retry-After").orElse(null); + if (value == null || value.length() > 128) return null; + try { + if (value.matches("[0-9]{1,9}")) return clock.instant().plusSeconds(Long.parseLong(value)); + Instant time = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); + return time.isAfter(clock.instant()) ? time : null; + } catch (RuntimeException ignored) { return null; } + } + + private static Map pagination(int current, int size) { + Map data = new LinkedHashMap<>(); + data.put("current", current); + data.put("size", size); + return data; + } + + private static void validatePagination(int current, int size, int maximum) { + if (current < 1 || size < 1 || size > maximum) throw badRequest(); + } + + private static void requireInput(String value, int maximum) { + if (value == null || value.isBlank() || value.length() > maximum) throw badRequest(); + } + + private static LingxingWmsException badRequest() { + return failure("INVALID_REQUEST", "领星 WMS 查询参数不符合内部契约", false); } private void validate(QueryRequest request) { @@ -92,14 +227,6 @@ public final class LingxingWmsClient { } } - private void reserveRequest() { - long now = System.nanoTime(); - if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) { - throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true); - } - hasRequested = true; - lastRequestNanos = now; - } private HttpResponse send(HttpRequest request) { try { @@ -115,7 +242,7 @@ public final class LingxingWmsClient { } private void validateStatus(int status) { - if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null); + if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流,账号已暂停", false, status, null); if (status == 401 || status == 403) { throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null); } @@ -124,10 +251,11 @@ public final class LingxingWmsClient { } } - private PageResult page(String body, QueryRequest request) { + private JsonNode envelope(String body) { JsonNode root; try { - root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body); + root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, + DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body); } catch (JsonProcessingException exception) { throw invalidResponse("JSON", null); } @@ -153,23 +281,65 @@ public final class LingxingWmsClient { } JsonNode data = root.get("data"); if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200); + return data; + } + + private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) { + QueryPage page = mappedPage(data, current, size, "current", "size", true, metadata, record -> { + String deliveryNo = requiredText(record, "deliveryNo", 128); + if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200); + int status = requiredInt(record.get("status"), "STATUS"); + if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200); + return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64), + requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime")); + }); + return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(), + page.sourceSize(), 200, page.observedAt(), metadata); + } + + private QueryPage mappedPage(JsonNode data, int current, int size, String currentField, + String sizeField, boolean outbound, AttemptMetadata metadata, Function projection) { JsonNode records = data.get("records"); if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200); - if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); + if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); Long total = optionalTotal(data.get("total")); - if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200); - verifyPage(data.get("current"), request.current(), "CURRENT"); - verifyPage(data.get("size"), request.size(), "SIZE"); - List summaries = new ArrayList<>(); + if (total == null) throw invalidResponse("TOTAL_MISSING", 200); + int sourceCurrent = requiredInt(data.get(currentField), "CURRENT"); + int sourceSize = requiredInt(data.get(sizeField), "SIZE"); + int pages = requiredInt(data.get("pages"), "PAGES"); + if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200); + boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1 + && sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty(); + if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200); + long expectedPages = total / size + (total % size == 0 ? 0 : 1); + if (pages < 0 || pages != expectedPages || (total == 0 && current != 1) + || (total > 0 && current > pages)) throw invalidResponse("PAGES_MISMATCH", 200); + long expectedRows = total == 0 ? 0 : Math.min(size, total - (long)(current - 1) * size); + if (records.size() != expectedRows) throw invalidResponse("PAGE_ROW_COUNT_MISMATCH", 200); + List summaries = new ArrayList<>(); for (JsonNode record : records) { - if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200); - String deliveryNo = optionalText(record, "deliveryNo"); - if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200); - summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"), - optionalText(record, "whCode"), optionalStatus(record.get("status")), - optionalText(record, "createTime"), optionalText(record, "outboundTime"))); + if (!record.isObject()) throw invalidResponse("ROW_TYPE_" + nodeType(record), 200); + summaries.add(projection.apply(record)); } - return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant()); + return new QueryPage<>(summaries, total, current, size, pages, sourceCurrent, sourceSize, 200, clock.instant(), metadata); + } + + private static String requiredText(JsonNode row, String field, int maximum) { + String text = optionalText(row, field); + if (text == null || text.isBlank() || text.length() > maximum) throw invalidResponse("IDENTITY_INVALID", 200); + return text; + } + + private static int requiredInt(JsonNode value, String stage) { + if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); + return value.intValue(); + } + + private static BigDecimal quantity(JsonNode value) { + if (missingNumber(value)) return null; + if (value.isNumber()) return value.decimalValue(); + // Nonempty malformed quantities are a contract failure, never invented zero. + throw invalidResponse("QUANTITY_TYPE_" + nodeType(value), 200); } private static String optionalText(JsonNode record, String field) { @@ -190,14 +360,6 @@ public final class LingxingWmsClient { return value.textValue(); } - private static Integer optionalStatus(JsonNode value) { - if (missingNumber(value)) return null; - if (!value.isIntegralNumber() || !value.canConvertToInt()) { - throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200); - } - return value.intValue(); - } - private static Long optionalTotal(JsonNode value) { if (missingNumber(value)) return null; if (!value.isIntegralNumber() || !value.canConvertToLong()) { @@ -207,14 +369,6 @@ public final class LingxingWmsClient { return value.longValue(); } - private static void verifyPage(JsonNode value, int expected, String stage) { - if (missingNumber(value)) return; - if (!value.isIntegralNumber() || !value.canConvertToInt()) { - throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); - } - if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200); - } - private static Integer diagnosticCode(JsonNode value) { if (value == null) return null; if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue(); @@ -237,6 +391,7 @@ public final class LingxingWmsClient { } private static LingxingWmsException sourceRejected(int code) { + if (code == 31001 || code == 429) return upstreamFailure("SOURCE_RATE_LIMITED", "领星 WMS 来源限流,账号已暂停", false, 200, code); if (code == 11008) { return upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code); @@ -244,11 +399,11 @@ public final class LingxingWmsClient { if (code == 11006) { return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code); } - return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code); + return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", false, 200, code); } private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) { - return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")", + return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合接口契约(" + stage + ")", false, 200, sourceCode); } diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java index d75bf0cb8..0131730b1 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java @@ -1,4 +1,6 @@ package com.baiyue.integration.provider.lingxing; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial; /** Only fixed messages and numeric upstream metadata may leave the adapter. */ public final class LingxingWmsException extends RuntimeException { @@ -6,6 +8,8 @@ public final class LingxingWmsException extends RuntimeException { private final boolean retryable; private final Integer upstreamStatus; private final Integer sourceCode; + private AttemptMetadata egress; + private AuthorizationDenial denial; public LingxingWmsException(String code, String message, boolean retryable, Integer upstreamStatus, Integer sourceCode) { @@ -21,4 +25,8 @@ public final class LingxingWmsException extends RuntimeException { public boolean retryable() { return retryable; } public Integer upstreamStatus() { return upstreamStatus; } public Integer sourceCode() { return sourceCode; } + public AttemptMetadata egress() { return egress; } + public LingxingWmsException withEgress(AttemptMetadata metadata) { this.egress = metadata; return this; } + public AuthorizationDenial denial() { return denial; } + public LingxingWmsException withDenial(AuthorizationDenial metadata) { this.denial = metadata; return this; } } diff --git a/integration-service/src/main/resources/application-lingxing-local.yml b/integration-service/src/main/resources/application-lingxing-local.yml index 9fae3d231..d216886d9 100644 --- a/integration-service/src/main/resources/application-lingxing-local.yml +++ b/integration-service/src/main/resources/application-lingxing-local.yml @@ -37,11 +37,20 @@ baiyue: lingxing-wms: enabled: ${LINGXING_WMS_ENABLED:false} base-uri: https://api.xlwms.com - app-key: ${LINGXING_WMS_APP_KEY:} - app-secret: ${LINGXING_WMS_APP_SECRET:} + app-key: ${LINGXING_OMS_APP_KEY:} + app-secret: ${LINGXING_OMS_APP_SECRET:} connect-timeout: 5s request-timeout: 15s - min-request-interval: 1s + egress: + collection-mode: ${LINGXING_COLLECTION_MODE:OFF} + environment-label: ${LINGXING_ENVIRONMENT_LABEL:test} + source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary} + external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:} + budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303} + min-start-interval: 5s + cooldown-fallback: 5m + # Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri. + # Missing proof/authority fails closed even when the old enabled switch is true. baidu-ocr: enabled: false amazon-sp-api: diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 336a6e74b..4264c372f 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -38,11 +38,20 @@ baiyue: lingxing-wms: enabled: ${LINGXING_WMS_ENABLED:false} base-uri: https://api.xlwms.com - app-key: ${LINGXING_WMS_APP_KEY:} - app-secret: ${LINGXING_WMS_APP_SECRET:} + app-key: ${LINGXING_OMS_APP_KEY:} + app-secret: ${LINGXING_OMS_APP_SECRET:} connect-timeout: 5s request-timeout: 15s - min-request-interval: 1s + egress: + collection-mode: ${LINGXING_COLLECTION_MODE:OFF} + environment-label: ${LINGXING_ENVIRONMENT_LABEL:test} + source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary} + external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:} + budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303} + min-start-interval: 5s + cooldown-fallback: 5m + # Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri. + # Missing proof/authority fails closed even when the old enabled switch is true. baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} api-key: ${BAIDU_OCR_API_KEY:} diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml index cb25a3309..36e3e563f 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -2,14 +2,77 @@ openapi: 3.0.3 info: title: Lingxing WMS Internal Read API version: 1.0.0 - description: Read-only provider adapter. No business scheduling, persistence, inventory mutation or public Gateway route. + description: >- + Single-page or single-order adapter. No scheduling, business persistence or public Gateway route. + Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone. + Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive + account window; BUSINESS requires environmentLabel exactly production. All five endpoints apply the same gate. + Missing exclusive proof, persistent account budget authorization or control context fails closed before egress. + At most one account request is in flight and HTTP request starts are separated by at least 5 seconds. + A JVM lock is only local protection and is not proof of cross-environment account exclusivity. + Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries; + 429 is never retried, immediately pauses the account and returns persistent cooldown metadata. + VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence. + External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil; + the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is + recoverable only within the original valid exclusive window, after both the persistently returned + cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new + strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state. + Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost + or malformed authorization and uncertain completion hold an unrecoverable local pause until + operations verify persistent account state. Completion failure never automatically recovers. + A well-formed explicit authorization denial is deterministic and does not cause a permanent pause. + sourceAccount defaults to lingxing-primary and budgetAuthorityBaseUri defaults internally to + http://lastleg-service:9303; neither default enables egress. +x-lastleg-egress-coordination: + description: >- + Integration calls these Lastleg endpoints with X-Internal-Service-Token. These are dependencies, + not Integration Controller paths. Lastleg prepares attemptId server-side and binds context and operation + before authorization. authorize only consumes an already prepared attempt, first binds the opaque + requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization + is refused; arbitrary client-created attempts are refused. complete verifies the same bindings. + A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true + and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil, + and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and + AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose + a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress; + pending or ambiguous attempts remain conservatively consumed, including after restart. + authorize: + method: POST + path: /internal/lastleg/self-operated-analytics/egress/authorize + requestSchema: '#/components/schemas/AuthorizationRequest' + responseSchema: '#/components/schemas/AttemptPermitResponse' + complete: + method: POST + path: /internal/lastleg/self-operated-analytics/egress/complete + requestSchema: '#/components/schemas/CompletionRequest' + responseSchema: '#/components/schemas/CompletionResponse' security: - InternalServiceToken: [] paths: + /internal/integrations/lingxing/wms/warehouses/query: + post: + summary: Query one page of normal warehouses + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Calls /openapi/v2/warehouse/page without filters. Maps page/pageSize into current/size; preserves original metadata. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/WarehouseQueryRequest'} + responses: + '200': + description: Standard envelope with WarehousePage or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/WarehouseResponse'} /internal/integrations/lingxing/wms/outbound-orders/query: post: - summary: Query one page of completed WMS outbound summaries by outbound time - description: Calls OMP /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. Source time strings are preserved. This is a single page, not a complete sales report. It uses the self-developed-system API authorization in the WMS management console. + summary: Query one page of completed outbound orders by source outbound time + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Calls /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. No external warehouse filter. At current=1,size=100 only, records=[],total=0,pages=0,sourceCurrent=1,sourceSize=10 is accepted and internal size remains 100. All other pagination metadata is strictly required. No completeness claim across pages. requestBody: required: true content: @@ -17,65 +80,363 @@ paths: schema: {$ref: '#/components/schemas/QueryRequest'} responses: '200': - description: Standard envelope; code 200 for success, A0401 for rejected internal identity, A0422 for bean validation, B0001 or S0503 for provider/configuration failures. Failures never return a successful empty page. + description: Standard envelope with OrderPage or sanitized ProviderFailure. content: application/json: - schema: {$ref: '#/components/schemas/Response'} + schema: {$ref: '#/components/schemas/OrderResponse'} + /internal/integrations/lingxing/wms/outbound-orders/locate: + post: + summary: Locate one page by delivery number without time or status filters + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Sends only current,size,deliveryNo to delivery/page. Every returned deliveryNo must match exactly. Caller reads all required pages and verifies original customer identity and uniqueness before accepting the current warehouse. Missing results do not prove cancellation. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/LocateRequest'} + responses: + '200': + description: Standard envelope with OrderPage or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/OrderResponse'} + /internal/integrations/lingxing/wms/outbound-orders/detail: + post: + summary: Read one order using warehouse, customer and delivery identifiers + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Maps warehouseCode to whCode for delivery/detail. All three returned identifiers must match. Returns only skuVOList; packageList is never added to quantities. Duplicate SKU lines remain separate for caller normalization. Missing quantities remain null; no quantity unit is inferred. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/DetailRequest'} + responses: + '200': + description: Standard envelope with OrderDetail or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/DetailResponse'} + /internal/integrations/lingxing/wms/product-inventory/query: + post: + summary: Query one page of all qualities and customers in a required warehouse + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + description: Calls stock/stockSku/page with whCode and isHideInventory=0. No SKU, quality, customer or quantity filters. Warehouse mismatch and duplicate business keys within a page fail closed. Caller validates cross-page keys and counts. Unknown quantities stay null, negative quantities remain evidence, stockCompare is not accumulated. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InventoryQueryRequest'} + responses: + '200': + description: Standard envelope with InventoryPage or sanitized ProviderFailure. + content: + application/json: + schema: {$ref: '#/components/schemas/InventoryResponse'} components: + parameters: + LingxingEgressContext: + name: X-Lingxing-Egress-Context + in: header + required: true + description: >- + JSON control context; no credentials. Maximum 2048 characters, all printable ASCII (32 through 126). + Unknown fields, duplicate fields and trailing JSON are rejected. Identifiers are strings supplied + by the server-side coordinator and do not confer authorization by themselves. + content: + application/json: + schema: {$ref: '#/components/schemas/EgressContext'} securitySchemes: InternalServiceToken: type: apiKey in: header name: X-Internal-Service-Token schemas: + EgressContext: + type: object + additionalProperties: false + required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId] + properties: + sourceAccount: {type: string, minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Server-configured account alias, never App Key or Secret.} + executionPurpose: {type: string, enum: [VALIDATION, BUSINESS]} + rootTaskId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable root control identifier; child tasks share root budget.} + sessionId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable session control identifier; reopening or restart does not reset it.} + attemptId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Prepared unique single-attempt control identifier; duplicate authorization is refused.} + exclusiveGrantId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Must equal the valid externalExclusiveGrantId configured server-side.} + EgressRequestHash: + type: string + pattern: '^[0-9a-f]{64}$' + description: >- + Lowercase hexadecimal SHA256 of UTF-8 bytes of OMP path, one LF character and compact + insertion-ordered external business JSON. Lastleg treats this digest as opaque and binds + it on the first authorization of an already prepared attempt. + example: fa60a92c78b4803878930e5914e6db03591acc31b7c09abad53c91912fa3d627 + x-preimage-example: |- + /openapi/v2/stock/stockSku/page + {"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0} + EgressOperation: + type: string + enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY] + EgressMetadata: + type: object + required: [attemptId, actualHttpAttempts, accountPaused] + properties: + attemptId: {type: string, nullable: true, description: Null only if no valid control context was accepted.} + actualHttpAttempts: {type: integer, minimum: 0, maximum: 1, description: Supplier HTTP attempts actually started, including failed calls; authorization callbacks do not count.} + accountPaused: {type: boolean} + cooldownUntil: {type: string, format: date-time, nullable: true, description: Account cooldown deadline to persist; valid supplier retry time must not be shortened.} + AuthorizationRequest: + type: object + required: [context, operation, requestHash] + properties: + context: {$ref: '#/components/schemas/EgressContext'} + operation: {$ref: '#/components/schemas/EgressOperation'} + requestHash: {$ref: '#/components/schemas/EgressRequestHash'} + AuthorizationDenial: + type: object + required: [errorCode, sampleDone] + properties: + errorCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'} + sampleDone: {type: boolean, description: Strict JSON boolean; validation budget normal completion is distinct from a failure.} + waitUntil: {type: string, format: date-time, nullable: true, description: Nullable ISO-8601 Instant from the persistent authority.} + AuthorizationDenialResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + code: {type: string, enum: [B0001]} + data: {$ref: '#/components/schemas/AuthorizationDenial'} + AttemptPermit: + type: object + required: [context, operation, requestHash, allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed] + properties: + context: {$ref: '#/components/schemas/EgressContext'} + operation: {$ref: '#/components/schemas/EgressOperation'} + requestHash: {$ref: '#/components/schemas/EgressRequestHash'} + allowedAttempts: {type: integer, enum: [1]} + expiresAt: {type: string, format: date-time, description: Must be after now and no later than now plus 30 seconds and configured externalExclusiveUntil.} + exclusiveConfirmed: {type: boolean, enum: [true]} + budgetConsumed: {type: boolean, enum: [true], description: Lastleg has atomically consumed persistent account and root/session budget before returning this permit.} + CompletionRequest: + type: object + required: [context, operation, requestHash, actualHttpAttempts, accountPaused] + properties: + context: {$ref: '#/components/schemas/EgressContext'} + operation: {$ref: '#/components/schemas/EgressOperation'} + requestHash: {$ref: '#/components/schemas/EgressRequestHash'} + actualHttpAttempts: {type: integer, minimum: 0, maximum: 1} + errorCode: {type: string, nullable: true, description: Sanitized local error code; null on success.} + upstreamStatus: {type: integer, nullable: true} + sourceCode: {type: integer, nullable: true} + accountPaused: {type: boolean, description: True on 429 or explicit provider rate limiting; all account request families must pause.} + cooldownUntil: {type: string, format: date-time, nullable: true} + AttemptPermitResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + code: {type: string, enum: ['200', B0001]} + data: + oneOf: + - {$ref: '#/components/schemas/AttemptPermit'} + - {$ref: '#/components/schemas/AuthorizationDenial'} + description: Code 200 requires AttemptPermit; code B0001 requires AuthorizationDenial. Lost or malformed responses are uncertain and fail closed with a local pause. + CompletionResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: {type: boolean, enum: [true], description: Successful acknowledgement confirms durable completion and cooldown persistence; any other result fails closed.} + WarehouseQueryRequest: + type: object + required: [current, size] + properties: + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} QueryRequest: type: object required: [startTime, endTime, current, size] properties: - startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime; must precede endTime and must not be earlier than endTime minus one calendar month. This is a local guard.} - endTime: {type: string, example: '2026-10-12T08:00:00', description: Source local datetime.} + startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime. Must precede endTime and be no earlier than endTime minus one calendar month. No source timezone inference.} + endTime: {type: string, example: '2026-10-12T08:00:00'} current: {type: integer, minimum: 1} - size: {type: integer, minimum: 1, maximum: 100, description: Local request cap; not a claim about the provider quota. Use 1 for connectivity verification.} + size: {type: integer, minimum: 1, maximum: 100, description: Local request cap, not supplier quota.} + LocateRequest: + type: object + required: [deliveryNo, current, size] + properties: + deliveryNo: {type: string, minLength: 1, maxLength: 128} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} + DetailRequest: + type: object + required: [warehouseCode, customerCode, deliveryNo] + properties: + warehouseCode: {type: string, minLength: 1, maxLength: 64} + customerCode: {type: string, minLength: 1, maxLength: 64} + deliveryNo: {type: string, minLength: 1, maxLength: 128} + InventoryQueryRequest: + type: object + required: [warehouseCode, current, size] + properties: + warehouseCode: {type: string, minLength: 1, maxLength: 64} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 1000} + Pagination: + type: object + required: [total, current, size, pages, sourceCurrent, sourceSize, sourceCode, observedAt, egress] + properties: + egress: {$ref: '#/components/schemas/EgressMetadata'} + total: {type: string, pattern: '^[0-9]+$', description: Required provider total. Long follows platform string serialization.} + current: {type: integer} + size: {type: integer} + pages: {type: integer, minimum: 0} + sourceCurrent: {type: integer} + sourceSize: {type: integer} + sourceCode: {type: integer, enum: [200]} + observedAt: {type: string, format: date-time} OrderSummary: type: object - required: [deliveryNo] + required: [deliveryNo, customerCode, whCode, status] properties: deliveryNo: {type: string} sourceNo: {type: string, nullable: true} - customerCode: {type: string, nullable: true} - whCode: {type: string, nullable: true} - status: {type: integer, nullable: true} + customerCode: {type: string} + whCode: {type: string} + status: {type: integer} createTime: {type: string, nullable: true} outboundTime: {type: string, nullable: true} - PageResult: + Warehouse: type: object - required: [records, current, size, sourceCode, observedAt] + required: [whCode, warehouseType, status] properties: - records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}} - total: {type: string, pattern: '^[0-9]+$', nullable: true, description: Provider total only when supplied. Java Long follows the platform JSON string convention.} - current: {type: integer} - size: {type: integer} - sourceCode: {type: integer} + whCode: {type: string} + whNameCn: {type: string, nullable: true} + warehouseType: {type: integer} + status: {type: integer} + timeZone: {type: string, nullable: true, description: Warehouse metadata only.} + SkuLine: + type: object + required: [productSku] + properties: + productSku: {type: string} + productName: {type: string, nullable: true} + actualDeliveryQty: {type: number, nullable: true, description: BigDecimal actual quantity, unknown is null.} + deliveryQty: {type: number, nullable: true, description: Forecast only; never a replacement for actual quantity.} + OrderDetail: + type: object + required: [deliveryNo, customerCode, whCode, status, skuVOList, sourceCode, observedAt, egress] + properties: + egress: {$ref: '#/components/schemas/EgressMetadata'} + deliveryNo: {type: string} + sourceNo: {type: string, nullable: true} + customerCode: {type: string} + whCode: {type: string} + status: {type: integer} + orderCreateTime: {type: string, nullable: true} + outboundTime: {type: string, nullable: true} + skuVOList: {type: array, minItems: 1, items: {$ref: '#/components/schemas/SkuLine'}} + sourceCode: {type: integer, enum: [200]} observedAt: {type: string, format: date-time} + ProductStock: + type: object + required: [customerCode, whCode, productSku, productQuality] + properties: + customerCode: {type: string} + whCode: {type: string} + productSku: {type: string} + productName: {type: string, nullable: true} + productQuality: {type: integer} + stkQty: {type: number, nullable: true} + freezeQty: {type: number, nullable: true} + useStkQty: {type: number, nullable: true} + transitQty: {type: number, nullable: true} + innerBatchNo: {type: string, nullable: true} + outBatchNo: {type: string, nullable: true} + uniqueKey4tenant: {type: string, nullable: true} + OrderPage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}} + WarehousePage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/Warehouse'}} + InventoryPage: + allOf: + - {$ref: '#/components/schemas/Pagination'} + - type: object + required: [records] + properties: + records: {type: array, items: {$ref: '#/components/schemas/ProductStock'}} ProviderFailure: type: object - required: [errorCode, retryable] + required: [errorCode, retryable, egress] properties: + egress: {$ref: '#/components/schemas/EgressMetadata'} + denial: + nullable: true + allOf: + - {$ref: '#/components/schemas/AuthorizationDenial'} + description: Well-formed deterministic authority denial, alongside egress; null for other failures. errorCode: {type: string} - retryable: {type: boolean} + retryable: {type: boolean, description: Caller scheduling hint only; never authorizes implicit retries or reuse of an attempt. False for 429 and validation session failure.} upstreamStatus: {type: integer, nullable: true} sourceCode: {type: integer, nullable: true} - Response: + Envelope: type: object required: [code, message, data, timestamp] properties: - code: {type: string} - message: {type: string, description: Sanitized local message; never the raw provider response.} - data: - nullable: true - oneOf: - - {$ref: '#/components/schemas/PageResult'} - - {$ref: '#/components/schemas/ProviderFailure'} + code: {type: string, description: '200 success; A0401 identity; A0422 validation; B0001 or S0503 provider/configuration failure.'} + message: {type: string, description: Sanitized fixed local message, never raw supplier content.} requestId: {type: string, nullable: true} traceId: {type: string, nullable: true} timestamp: {type: string, format: date-time} + OrderResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/OrderPage'} + - {$ref: '#/components/schemas/ProviderFailure'} + WarehouseResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/WarehousePage'} + - {$ref: '#/components/schemas/ProviderFailure'} + DetailResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/OrderDetail'} + - {$ref: '#/components/schemas/ProviderFailure'} + InventoryResponse: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/InventoryPage'} + - {$ref: '#/components/schemas/ProviderFailure'} diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java index 870b2a46b..a911cc96d 100644 --- a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java @@ -9,6 +9,8 @@ import com.baiyue.integration.provider.lingxing.LingxingWmsException; import com.baiyue.integration.security.InternalServiceTokenGuard; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.springframework.http.MediaType; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; @@ -35,6 +37,7 @@ class LingxingInternalControllerTest { private static final String BODY = """ {"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1} """; + private static final String CONTEXT = "{\"sourceAccount\":\"demo\",\"executionPurpose\":\"VALIDATION\",\"rootTaskId\":\"root-1\",\"sessionId\":\"session-1\",\"attemptId\":\"attempt-1\",\"exclusiveGrantId\":\"grant-1\"}"; private final String token = UUID.randomUUID().toString(); private LingxingWmsClient client; private MockMvc mvc; @@ -49,7 +52,7 @@ class LingxingInternalControllerTest { try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) { context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder); } - mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client)) + mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client, builder.build())) .setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build())) .setControllerAdvice(new GlobalExceptionHandler()).build(); } @@ -64,13 +67,73 @@ class LingxingInternalControllerTest { verifyNoInteractions(client); } + @ParameterizedTest + @CsvSource(value = { + "/warehouses/query|{\"current\":1,\"size\":1000}", + "/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}", + "/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}", + "/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":1000}" + }, delimiter = '|') + void everyNewEndpointRequiresInternalIdentity(String suffix, String request) throws Exception { + String path = "/internal/integrations/lingxing/wms" + suffix; + for (String invalidToken : new String[] {"", "wrong", "Bearer user-jwt"}) { + mvc.perform(post(path).header("X-Internal-Service-Token", invalidToken) + .contentType(MediaType.APPLICATION_JSON).content(request)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + } + verifyNoInteractions(client); + } + + @ParameterizedTest + @CsvSource(value = { + "/outbound-orders/query|{\"startTime\":\"2026-09-12T08:00:00\",\"endTime\":\"2026-10-12T08:00:00\",\"current\":1,\"size\":1}", + "/warehouses/query|{\"current\":1,\"size\":10}", + "/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}", + "/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}", + "/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":10}" + }, delimiter = '|') + void everyEndpointRejectsMissingControlContextBeforeProvider(String suffix, String request) throws Exception { + mvc.perform(post("/internal/integrations/lingxing/wms" + suffix).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(request)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001")) + .andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID")) + .andExpect(jsonPath("$.data.egress.actualHttpAttempts").value(0)); + verifyNoInteractions(client); + } + + @Test + void controlHeaderRejectsUnknownDuplicateAndTrailingFields() throws Exception { + for (String header : new String[] { + CONTEXT.replace("\"sourceAccount\":", "\"extra\":1,\"sourceAccount\":"), + CONTEXT.replace("\"sourceAccount\":", "\"sourceAccount\":\"other\",\"sourceAccount\":"), + CONTEXT + " {}", " ", "x".repeat(2049), "{}", "null", + CONTEXT.replace("VALIDATION", "OFF"), CONTEXT.replace("root-1", "root with spaces") + }) { + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", header) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID")); + } + verifyNoInteractions(client); + } + + @Test + void inventoryRequiresWarehouseAndDetailRequiresAllThreeIdentifiers() throws Exception { + for (String suffix : new String[] {"/product-inventory/query", "/outbound-orders/detail"}) { + mvc.perform(post("/internal/integrations/lingxing/wms" + suffix) + .header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT).contentType(MediaType.APPLICATION_JSON) + .content("{\"current\":1,\"size\":1000,\"deliveryNo\":\"DO-DEMO\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422")); + } + verifyNoInteractions(client); + } + @Test void authenticatedReadReturnsOnlyOutboundSummary() throws Exception { - when(client.queryPage(any())).thenReturn(new PageResult( + when(client.queryPage(any(), any())).thenReturn(new PageResult( List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100, "2026-10-01 08:00:00", "2026-10-02 08:00:00")), - 1L, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"))); - mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + 1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null)); + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) .contentType(MediaType.APPLICATION_JSON).content(BODY)) .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200")) .andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1")) @@ -80,7 +143,7 @@ class LingxingInternalControllerTest { @Test void invalidPageCannotReachProvider() throws Exception { - mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) .contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0"))) .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422")); verifyNoInteractions(client); @@ -88,9 +151,9 @@ class LingxingInternalControllerTest { @Test void providerFailureRemainsAnExplicitBusinessFailure() throws Exception { - when(client.queryPage(any())).thenThrow(new LingxingWmsException( + when(client.queryPage(any(), any())).thenThrow(new LingxingWmsException( "LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null)); - mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) .contentType(MediaType.APPLICATION_JSON).content(BODY)) .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503")) .andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED")) diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java new file mode 100644 index 000000000..8b4f90310 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingEgressGateTest.java @@ -0,0 +1,226 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import org.junit.jupiter.api.Test; +import java.net.URI; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.UnaryOperator; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LingxingEgressGateTest { + static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z"); + static final String HASH = "a".repeat(64); + private final MutableClock clock = new MutableClock(NOW); + private final LedgerAuthority ledger = new LedgerAuthority(clock); + private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); } + static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); } + static LingxingEgressProperties props(CollectionMode mode, String environment) { + return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null); + } + @Test void failureCompletionPreservesNullableTransportAndSourceCodes() { + for (Integer status : new Integer[] {null, 503}) { + var gate = gate(); + var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("nullable-" + status), + Operation.WAREHOUSES, HASH, handle -> { + handle.startHttp(); + throw new LingxingWmsException("LINGXING_WMS_IO_FAILED", "固定失败摘要", true, status, null); + })); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); + var completion = ledger.completions.get(ledger.completions.size() - 1); + assertThat(completion.actualHttpAttempts()).isEqualTo(1); + assertThat(completion.upstreamStatus()).isEqualTo(status); + assertThat(completion.sourceCode()).isNull(); + } + } + @Test void defaultOffAndNonProductionBusinessNeverAuthorize() { + var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null); + assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF); + assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5)); + var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF"); + var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "test"), ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> business.execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED"); + assertThat(ledger.authorizations).isZero(); + } + @Test void proofAndContextAreServerBoundBeforeAuthorization() { + var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"), + new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"), + new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", "a", "client-claimed-grant"), + new EgressContext("demo-account", CollectionMode.VALIDATION, "", "session", "a", "demo-grant"), + new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "bad session", "a", "demo-grant")); + for (var c : contexts) assertThrows(LingxingWmsException.class, () -> gate().execute(c, Operation.WAREHOUSES, HASH, h -> true)); + clock.advanceMillis(3600000); + assertThrows(LingxingWmsException.class, () -> gate().execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)); + assertThat(ledger.authorizations).isZero(); + } + @Test void budgetPermitMustBeOneConsumedExclusiveUnexpiredAndExactlyBound() { + List> mutations = List.of( + p -> null, + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 2, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant().plusSeconds(31), true, true), + p -> new AttemptPermit(context("different"), p.operation(), p.requestHash(), 1, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), Operation.OUTBOUND_DETAIL, p.requestHash(), 1, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), "b".repeat(64), 1, p.expiresAt(), true, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), false, true), + p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), true, false)); + var calls = new AtomicInteger(); int n = 0; + for (var mutation : mutations) { + ledger.mutation = mutation; var gate = gate(); var c = context("attempt-" + ++n); + assertThatThrownBy(() -> gate.execute(c, Operation.WAREHOUSES, HASH, h -> calls.incrementAndGet())).hasMessageContaining("BUDGET_PERMIT_INVALID"); + assertThatThrownBy(() -> gate.execute(context("next-" + c.attemptId()), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + } + assertThat(calls).hasValue(0); assertThat(ledger.completions).isEmpty(); + } + @Test void everyAttemptIsPersistentlyConsumedAndCannotReplayEvenAcrossGateInstances() { + var c = context("single-use"); + assertThat(gate().execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + assertThat(ledger.consumed).containsExactly("single-use"); + var second = gate(); var sends = new AtomicInteger(); + assertThatThrownBy(() -> second.execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return sends.incrementAndGet(); })).hasMessageContaining("BUDGET_AUTHORITY_UNAVAILABLE"); + assertThat(sends).hasValue(0); assertThat(ledger.completions).hasSize(1); + } + @Test void handleCanStartOnlyOnceAndExpiredHandleCannotSend() { + var error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("once"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.startHttp(); return true; })); + assertThat(error.code()).endsWith("ATTEMPT_NOT_SENDABLE"); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); + error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("expires"), Operation.WAREHOUSES, HASH, + h -> { clock.advanceMillis(30000); h.startHttp(); return true; })); + assertThat(error.egress().actualHttpAttempts()).isZero(); + assertThat(ledger.completions).extracting(CompletionRequest::actualHttpAttempts).containsExactly(1, 0); + } + @Test void authorizationAndCompletionFailuresPauseAllFurtherCalls() { + ledger.failAuthorize = true; var gate = gate(); + var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("unavailable"), Operation.WAREHOUSES, HASH, h -> true)); + assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isTrue(); + assertThatThrownBy(() -> gate.execute(context("next"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + ledger.failAuthorize = false; ledger.failComplete = true; var fresh = gate(); + error = assertThrows(LingxingWmsException.class, () -> fresh.execute(context("callback"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })); + assertThat(error.code()).endsWith("BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue(); + assertThatThrownBy(() -> fresh.execute(context("after-callback"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + } + @Test void startsAreSerializedAndSpacedByAtLeastFiveSeconds() throws Exception { + var gate = gate(); var executor = Executors.newFixedThreadPool(2); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var starts = new CopyOnWriteArrayList(); + try { + var first = executor.submit(() -> gate.execute(context("first"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); starts.add(clock.instant()); entered.countDown(); + try { if (!release.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + catch (InterruptedException e) { throw new IllegalStateException(e); } + return true; + })); + assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue(); + var secondReady = new CountDownLatch(1); + var second = executor.submit(() -> { secondReady.countDown(); return gate.execute(context("second"), Operation.OUTBOUND_DETAIL, HASH, + h -> { h.startHttp(); starts.add(clock.instant()); return true; }); }); + assertThat(secondReady.await(2, TimeUnit.SECONDS)).isTrue(); + assertThat(ledger.authorizations).isEqualTo(1); assertThat(second.isDone()).isFalse(); + release.countDown(); assertThat(first.get(2, TimeUnit.SECONDS)).isTrue(); assertThat(second.get(2, TimeUnit.SECONDS)).isTrue(); + assertThat(starts).hasSize(2); assertThat(Duration.between(starts.get(0), starts.get(1))).isGreaterThanOrEqualTo(Duration.ofSeconds(5)); + assertThat(ledger.completions).hasSize(2); + } finally { release.countDown(); executor.shutdownNow(); } + } + @Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() { + assertThatThrownBy(() -> new LingxingEgressProperties(null, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class); + assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1"); + } + @Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() { + var gate = gate(); ledger.denial = new AuthorizationDenial("VALIDATION_TOTAL_LIMIT", true, null); + var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("denied"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })); + assertThat(error.denial()).isEqualTo(ledger.denial); + assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isFalse(); + assertThat(ledger.consumed).isEmpty(); assertThat(ledger.completions).isEmpty(); + ledger.denial = null; + assertThat(gate.execute(context("fresh-session-attempt"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + } + private static EgressContext businessContext(String attempt) { + return new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", attempt, "demo-grant"); + } + private LingxingWmsException rateLimit() { return new LingxingWmsException("RATE_LIMIT", "synthetic rate limit", false, 429, null); } + @Test void businessCooldownRequiresFreshDurablePermitBeforeRecovery() { + var gate = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis); + assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("limited"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); h.rateLimited(clock.instant().plusSeconds(600)); throw rateLimit(); + })); + int used = ledger.authorizations; + assertThatThrownBy(() -> gate.execute(businessContext("too-early"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(used); + clock.advanceMillis(600000); ledger.denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, clock.instant().plusSeconds(60)); + var denied = assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("still-denied"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; })); + assertThat(denied.egress().actualHttpAttempts()).isZero(); assertThat(denied.egress().accountPaused()).isTrue(); + ledger.denial = null; + var accepted = gate.execute(businessContext("accepted"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return h.metadata(); }); + assertThat(accepted.actualHttpAttempts()).isEqualTo(1); assertThat(accepted.accountPaused()).isFalse(); assertThat(accepted.cooldownUntil()).isNull(); + assertThat(ledger.completions).hasSize(2); + } + @Test void committedRateCompletionWithLostAckReturnsUncertaintyInsteadOfRecoverableRateError() { + var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"production"),ledger,clock,clock::advanceMillis); + ledger.failComplete=true; + var error=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("rate-lost-ack"),Operation.WAREHOUSES,HASH,handle->{ + handle.startHttp();handle.rateLimited(clock.instant().plusSeconds(600));throw rateLimit(); + })); + assertThat(error.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);assertThat(error.egress().accountPaused()).isTrue(); + assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(600)); + assertThat(ledger.completions).singleElement().satisfies(result->{ + assertThat(result.upstreamStatus()).isEqualTo(429);assertThat(result.actualHttpAttempts()).isEqualTo(1); + assertThat(result.accountPaused()).isTrue(); + }); + clock.advanceMillis(601000);ledger.failComplete=false; + assertThatThrownBy(()->gate.execute(businessContext("after-uncertain-ack"),Operation.WAREHOUSES,HASH,handle->true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1); + } + @Test void validationRatePauseAndUnconfirmedCallbackNeverRecoverByTime() { + var gate = gate(); + assertThrows(LingxingWmsException.class, () -> gate.execute(context("limited"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); h.rateLimited(null); throw rateLimit(); + })); + clock.advanceMillis(600000); + assertThatThrownBy(() -> gate.execute(context("next"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1); + var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis); + ledger.failComplete = true; + assertThrows(LingxingWmsException.class, () -> business.execute(businessContext("lost-complete"), Operation.WAREHOUSES, HASH, h -> { + h.startHttp(); h.rateLimited(null); throw rateLimit(); + })); + clock.advanceMillis(600000); ledger.failComplete = false; + assertThatThrownBy(() -> business.execute(businessContext("no-recover"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(2); + } + static final class MutableClock extends Clock { + private Instant now; + MutableClock(Instant now) { this.now = now; } + synchronized void advanceMillis(long millis) { now = now.plusMillis(millis); } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public synchronized Instant instant() { return now; } + } + /** Synthetic durable single-use ledger, shared between independently constructed gates. */ + static class LedgerAuthority implements LingxingBudgetAuthority { + final Clock clock; + final Set consumed = new LinkedHashSet<>(); + final List completions = new ArrayList<>(); + volatile int authorizations; + boolean failAuthorize, failComplete; + AuthorizationDenial denial; + UnaryOperator mutation = UnaryOperator.identity(); + LedgerAuthority(Clock clock) { this.clock = clock; } + @Override public synchronized AttemptPermit authorize(AuthorizationRequest request) { + authorizations++; + if (denial != null) throw new LingxingBudgetDeniedException(denial); + if (failAuthorize || !consumed.add(request.context().attemptId())) throw new IllegalStateException("budget refused"); + return mutation.apply(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, clock.instant().plusSeconds(30), true, true)); + } + @Override public synchronized void complete(CompletionRequest request) { + completions.add(request); if (failComplete) throw new IllegalStateException("completion not durable"); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java new file mode 100644 index 000000000..729101ff9 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingHttpBudgetAuthorityTest.java @@ -0,0 +1,143 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.io.IOException; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Loopback authority only; the persisted collector ledger is tested by its own service. */ +class LingxingHttpBudgetAuthorityTest { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private final AtomicReference response = new AtomicReference<>(); + private final AtomicInteger status = new AtomicInteger(200); + private final AtomicInteger calls = new AtomicInteger(); + private final AtomicReference path = new AtomicReference<>(), token = new AtomicReference<>(), body = new AtomicReference<>(); + private HttpServer server; + private static final String TOKEN = "synthetic-internal-token-0123456789"; + private final AuthorizationRequest request = new AuthorizationRequest(LingxingEgressGateTest.context("attempt"), Operation.WAREHOUSES, LingxingEgressGateTest.HASH); + @BeforeEach void start() throws IOException { + response.set("{\"code\":\"200\",\"data\":true}"); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath()); + token.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")); + body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + exchange.getResponseHeaders().set("Location", "/redirect-must-not-follow"); + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status.get(), bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + } + @AfterEach void stop() { server.stop(0); } + private LingxingHttpBudgetAuthority authority(String serviceToken) { + return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", + LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null), mapper, serviceToken); + } + private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); } + private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, + LingxingEgressGateTest.NOW.plusSeconds(30), true, true)); } + @Test void fixedInternalEndpointsAndTokenCarryExactAuthorizationAndCompletionContext() throws Exception { + response.set("{\"code\":\"200\",\"data\":" + permit() + "}"); + var permit = authority(TOKEN).authorize(request); + assertThat(permit.context()).isEqualTo(request.context()); assertThat(permit.allowedAttempts()).isEqualTo(1); + assertThat(path.get()).isEqualTo("/internal/lastleg/self-operated-analytics/egress/authorize"); + assertThat(token.get()).isEqualTo(TOKEN); + assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(request)); + response.set("{\"code\":\"200\",\"data\":true}"); + authority(TOKEN).complete(completion()); + assertThat(path.get()).endsWith("/egress/complete"); assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(completion())); + assertThat(calls.get()).isEqualTo(2); + } + @ParameterizedTest @ValueSource(strings = {"false", "\"true\"", "1", "{}", "null"}) + void completionRequiresLiteralBooleanAcknowledgment(String data) { + response.set("{\"code\":\"200\",\"data\":" + data + "}"); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(strings = {"200", "\"201\"", "null"}) + void envelopeRequiresExactStringSuccessCode(String code) { + response.set("{\"code\":" + code + ",\"data\":true}"); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(ints = {302, 401, 429, 503}) + void statusErrorsNeverRetryOrFollowRedirectAndNeverEchoAuthorityBody(int code) { + status.set(code); response.set("private-fixture@example.test"); + var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.getCause()).isNull(); assertThat(error.getMessage()).doesNotContain("private-fixture", "synthetic-token"); + assertThat(calls.get()).isEqualTo(1); + } + @Test void missingTokenPreventsAllAuthorityHttp() { + assertThrows(LingxingWmsException.class, () -> authority("").authorize(request)); + assertThrows(LingxingWmsException.class, () -> authority(null).complete(completion())); + assertThat(calls.get()).isZero(); + } + @Test void missingServerConfiguredAuthorityPreventsHttp() { + var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", + LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null), mapper, "synthetic-token"); + assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero(); + } + @Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception { + String valid = permit(); + String[] invalid = {valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":2"), + valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":\"1\""), + valid.replace("\"exclusiveConfirmed\":true", "\"exclusiveConfirmed\":\"true\""), + valid.replace("\"budgetConsumed\":true", "\"budgetConsumed\":false"), + valid.substring(0, valid.length() - 1) + ",\"untrusted\":true}", + valid.replace("\"operation\":\"WAREHOUSES\"", "\"operation\":\"UNKNOWN\"")}; + for (var data : invalid) { + response.set("{\"code\":\"200\",\"data\":" + data + "}"); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request)); + } + assertThat(calls.get()).isEqualTo(invalid.length); + } + @Test void trailingTokensAndMalformedResponseFailClosed() { + for (String value : new String[] {"{\"code\":\"200\",\"data\":true} {}", "[]", "{"}) { + response.set(value); assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + } + assertThat(calls.get()).isEqualTo(3); + } + @Test void knownAuthorityDenialIsTypedAndDoesNotBecomeUnavailability() { + response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"SESSION_EXPIRED\",\"sampleDone\":true,\"waitUntil\":null}}"); + var error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.denial()).isEqualTo(new AuthorizationDenial("SESSION_EXPIRED", true, null)); + assertThat(calls.get()).isEqualTo(1); + response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:10:00Z\"}}"); + error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.denial().waitUntil()).isEqualTo(LingxingEgressGateTest.NOW.plusSeconds(600)); + assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion())); + } + @ParameterizedTest @ValueSource(strings = { + "{\"errorCode\":\"private@example.test\",\"sampleDone\":true,\"waitUntil\":null}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":\"true\",\"waitUntil\":null}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":\"invalid\"}", + "{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":null,\"extra\":true}" + }) + void malformedDenialFailsAsUnavailability(String data) { + response.set("{\"code\":\"B0001\",\"data\":" + data + "}"); + var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request)); + assertThat(error.code()).endsWith("BUDGET_AUTHORITY_UNAVAILABLE"); + assertThat(error.getMessage()).doesNotContain("private@example.test"); assertThat(calls.get()).isEqualTo(1); + } + @Test void allControlDtosRoundTripWithProductionTimeSerialization() throws Exception { + var permit = new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, LingxingEgressGateTest.NOW.plusSeconds(30), true, true); + var metadata = new AttemptMetadata("attempt", 1, true, LingxingEgressGateTest.NOW.plusSeconds(600)); + var denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, metadata.cooldownUntil()); + for (var dto : new Object[] {request.context(), request, permit, completion(), metadata, denial}) { + assertThat(mapper.readValue(mapper.writeValueAsString(dto), dto.getClass())).isEqualTo(dto); + } + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index aa634c59e..cc0a27698 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -1,261 +1,281 @@ package com.baiyue.integration.provider.lingxing; import com.baiyue.integration.config.LingxingWmsProperties; -import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.baiyue.integration.config.LingxingEgressProperties; +import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.sun.net.httpserver.HttpServer; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import java.io.IOException; -import java.io.PrintWriter; -import java.io.StringWriter; import java.net.InetSocketAddress; import java.net.URI; import java.nio.charset.StandardCharsets; -import java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.HexFormat; +import java.time.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertThrows; +/** Synthetic loopback transport only; no supplier or application services. */ class LingxingWmsClientTest { - private static final String APP_KEY = "local-test-app-key"; - private static final String APP_SECRET = "local-test-app-secret"; - private static final String PRIVATE_DATA = "customer-private@example.test"; - private static final String PATH = "/openapi/v2/order/delivery/page"; - private static final Instant NOW = Instant.parse("2026-10-12T00:00:00.987Z"); - private static final String EMPTY = "{\"code\":200,\"success\":true,\"data\":{\"total\":0,\"current\":1,\"size\":1,\"records\":[]}}"; - private final ObjectMapper mapper = new ObjectMapper(); + private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z"); + private static final String PRIVATE = "private-secret@example.test"; + private static final String ORDER = """ + {"deliveryNo":"DO-DEMO","sourceNo":"SRC-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO", + "status":100,"outboundTime":"2026-10-01 01:00:00"} + """; + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); private final AtomicInteger calls = new AtomicInteger(); - private final AtomicInteger responseStatus = new AtomicInteger(200); - private final AtomicReference responseBody = new AtomicReference<>(EMPTY); - private final AtomicReference receivedBody = new AtomicReference<>(); - private final AtomicReference receivedUri = new AtomicReference<>(); - private final AtomicReference receivedMethod = new AtomicReference<>(); + private final AtomicInteger status = new AtomicInteger(200); + private final AtomicReference response = new AtomicReference<>(); + private final AtomicReference body = new AtomicReference<>(); + private final AtomicReference path = new AtomicReference<>(); private HttpServer server; - - @BeforeEach - void startServer() throws IOException { + private final AtomicInteger attempts = new AtomicInteger(); + private final AtomicReference retryAfter = new AtomicReference<>(); + private final AtomicReference completion = new AtomicReference<>(); + @BeforeEach void start() throws IOException { + response.set(page("current", "size", 1, 1, 1, 1, ORDER)); server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - server.createContext(PATH, exchange -> { - calls.incrementAndGet(); - receivedMethod.set(exchange.getRequestMethod()); - receivedUri.set(exchange.getRequestURI()); - receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); - byte[] bytes = responseBody.get().getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); - exchange.sendResponseHeaders(responseStatus.get(), bytes.length); - exchange.getResponseBody().write(bytes); - exchange.close(); + server.createContext("/openapi/v2/", exchange -> { + calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath()); + body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8); + if (retryAfter.get() != null) exchange.getResponseHeaders().set("Retry-After", retryAfter.get()); + exchange.sendResponseHeaders(status.get(), bytes.length); + exchange.getResponseBody().write(bytes); exchange.close(); }); server.start(); } - - @AfterEach - void stopServer() { server.stop(0); } - - @Test - void sendsOmpWmsSignatureAndOutboundTimeFilterAndOnlyReturnsWhitelist() throws Exception { - responseBody.set(""" - {"code":200,"success":true,"data":{"total":12,"current":1,"size":1,"records":[{ - "deliveryNo":"DO-LOCAL-001","sourceNo":"OB-LOCAL-001","customerCode":"C-LOCAL", - "whCode":"WH-LOCAL","status":100,"createTime":"2026-09-21 10:11:12", - "outboundTime":"2026-09-22 20:21:22","email":"customer-private@example.test", - "remark":"local-test-app-secret","receiver":"PRIVATE_RECEIVER" - }]}} - """); - var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); - - var body = mapper.readTree(receivedBody.get()); - String timestamp = Long.toString(NOW.getEpochSecond()); - String dataValue = "{current=1, size=1, timeType=outboundTime, startTime=2026-09-12 08:00:00, endTime=2026-10-12 08:00:00, status=100}"; - String input = APP_SECRET + PATH + "appKey" + APP_KEY + "data" + dataValue + "timestamp" + timestamp + APP_SECRET; - assertThat(body.path("sign").textValue()).isEqualTo(hmac(input)); - assertThat(body.path("timestamp").textValue()).isEqualTo(timestamp).matches("\\d{10}"); - assertThat(body.path("appKey").textValue()).isEqualTo(APP_KEY); - assertThat(body.path("data").toString()).isEqualTo("{\"current\":1,\"size\":1,\"timeType\":\"outboundTime\",\"startTime\":\"2026-09-12 08:00:00\",\"endTime\":\"2026-10-12 08:00:00\",\"status\":100}"); - assertThat(receivedUri.get().getPath()).isEqualTo(PATH); - assertThat(receivedUri.get().getRawQuery()).isNull(); - assertThat(receivedMethod.get()).isEqualTo("POST"); - assertThat(receivedBody.get()).doesNotContain(APP_SECRET, "authcode", "reqTime", "access_token", "pageSize"); - assertThat(result.total()).isEqualTo(12L); + @AfterEach void stop() { server.stop(0); } + @Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception { + var result = client().queryPage(query(1, 1), context()); + var data = mapper.readTree(body.get()).path("data"); + assertThat(data.size()).isEqualTo(6); + assertThat(data.path("timeType").asText()).isEqualTo("outboundTime"); + assertThat(data.path("status").intValue()).isEqualTo(100); + assertThat(data.has("whCode")).isFalse(); + assertThat(path.get()).isEqualTo("/openapi/v2/order/delivery/page"); assertThat(result.observedAt()).isEqualTo(NOW); - assertThat(result.sourceCode()).isEqualTo(200); - assertThat(result.records()).singleElement().satisfies(order -> { - assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001"); - assertThat(order.sourceNo()).isEqualTo("OB-LOCAL-001"); - assertThat(order.customerCode()).isEqualTo("C-LOCAL"); - assertThat(order.whCode()).isEqualTo("WH-LOCAL"); - assertThat(order.status()).isEqualTo(100); - assertThat(order.createTime()).isEqualTo("2026-09-21 10:11:12"); - assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22"); - }); - String summary = mapper.writeValueAsString(result.records().get(0)); - assertThat(mapper.readTree(summary).size()).isEqualTo(7); - assertThat(summary).doesNotContain(PRIVATE_DATA, APP_SECRET, "PRIVATE_RECEIVER"); + assertThat(result.pages()).isEqualTo(1); } - - @Test - void emptySuccessfulPageHasNoInventedOrder() { - var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); - assertThat(result.records()).isEmpty(); - assertThat(result.total()).isZero(); - assertThat(calls).hasValue(1); - } - - @Test - void verifiedWmsEnvelopeWithoutSuccessRequiresValidPage() { - responseBody.set(""" - {"code":200,"data":{"total":1,"current":1,"size":1,"records":[{ - "deliveryNo":"DO-LOCAL-001","status":100,"outboundTime":"2026-09-22 20:21:22" - }]}} - """); - var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); - assertThat(result.sourceCode()).isEqualTo(200); - assertThat(result.total()).isEqualTo(1L); - assertThat(result.records()).singleElement().satisfies(order -> { - assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001"); - assertThat(order.status()).isEqualTo(100); - assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22"); - }); - assertThat(calls).hasValue(1); - } - - @Test - void documentedEmptyNumericValuesRemainUnknown() { - responseBody.set("{\"code\":200,\"success\":true,\"data\":{\"total\":\"\",\"records\":[{\"deliveryNo\":\"DO-LOCAL\",\"status\":\"\",\"outboundTime\":\"\"}]}}"); - var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); - assertThat(result.total()).isNull(); - assertThat(result.records().get(0).status()).isNull(); + @Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception { + response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", ""))); + var result = client().locate(new LocateRequest("DO-DEMO", 1, 1), context()); + var data = mapper.readTree(body.get()).path("data"); + assertThat(data.size()).isEqualTo(3); + assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse(); + assertThat(result.records().get(0).status()).isEqualTo(99); assertThat(result.records().get(0).outboundTime()).isEmpty(); + response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("DO-DEMO", "DO-DEMO-X"))); + contract(() -> client().locate(new LocateRequest("DO-DEMO", 1, 1), context()), "LOCATE_DELIVERY_NO_MISMATCH"); } - - @Test - void disabledAndMissingCredentialsNeverSendRequest() { - assertFailure(client(false, APP_KEY, APP_SECRET, Duration.ZERO), "DISABLED", false); - assertFailure(client(true, "", APP_SECRET, Duration.ZERO), "MISSING_CREDENTIALS", false); - assertFailure(client(true, APP_KEY, " ", Duration.ZERO), "MISSING_CREDENTIALS", false); - assertThat(calls).hasValue(0); + @Test void exactEmptyExceptionKeepsSourceSizeAndNeverAppliesToInventory() { + response.set(page("current", "size", 1, 10, 0, 0, "")); + var result = client().queryPage(query(1, 100), context()); + assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero(); + assertThat(result.size()).isEqualTo(100); assertThat(result.sourceSize()).isEqualTo(10); + contract(() -> client().queryPage(query(1, 99), context()), "SIZE_MISMATCH"); + contract(() -> client().queryPage(query(2, 100), context()), "CURRENT_MISMATCH"); + contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 100), context()), "SIZE_MISMATCH"); + response.set(page("current", "size", 1, 10, 1, 1, ORDER)); + contract(() -> client().queryPage(query(1, 100), context()), "SIZE_MISMATCH"); } - - @ParameterizedTest - @ValueSource(ints = {11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001}) - void documentedStringAuthErrorsNeedNoDataAndDoNotEchoPayloadOrRetry(int sourceCode) { - responseBody.set("{\"code\":\"" + sourceCode + "\",\"msg\":\"" + APP_KEY + APP_SECRET + PRIVATE_DATA + "\"}"); - String expected = sourceCode == 11008 ? "AUTHORIZATION_REJECTED" - : sourceCode == 11006 ? "SIGNATURE_REJECTED" : "SOURCE_REJECTED"; - var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), expected, sourceCode == 31001); - assertThat(error.sourceCode()).isEqualTo(sourceCode); - assertThat(error.upstreamStatus()).isEqualTo(200); - assertThat(calls).hasValue(1); + @Test void warehousesMapPageAndPageSizeWithoutTreatingTimezoneAsSource() throws Exception { + response.set(page("page", "pageSize", 1, 1000, 1, 1, """ + {"whCode":"W-DEMO","whNameCn":"Demo","warehouseType":1,"status":0,"timeZone":"America/New_York","address":"private"} + """)); + var result = client().queryWarehouses(new WarehouseQueryRequest(1, 1000), context()); + assertThat(result.sourceSize()).isEqualTo(1000); + assertThat(result.records().get(0).timeZone()).isEqualTo("America/New_York"); + assertThat(mapper.readTree(body.get()).path("data").size()).isEqualTo(2); + assertThat(path.get()).isEqualTo("/openapi/v2/warehouse/page"); + assertThat(mapper.writeValueAsString(result)).doesNotContain("address"); } - - @Test - void falseBusinessSuccessIsNotAnEmptySuccess() { - responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}"); - assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false); + @Test void detailPreservesDecimalLinesAndIgnoresPackageDuplicates() throws Exception { + response.set(""" + {"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":100, + "orderCreateTime":"2026-09-01 01:00:00","outboundTime":"2026-10-01 01:00:00", + "skuVOList":[{"productSku":"SKU","actualDeliveryQty":2.123456,"deliveryQty":6}, + {"productSku":"SKU","actualDeliveryQty":3,"deliveryQty":6}], + "packageList":[{"skuList":[{"actualDeliveryQty":"5"}]}],"email":"private"}} + """); + var result = client().queryDetail(detail(), context()); + assertThat(result.skuVOList()).hasSize(2); + assertThat(result.skuVOList().get(0).actualDeliveryQty()).isEqualByComparingTo("2.123456"); + assertThat(result.orderCreateTime()).isEqualTo("2026-09-01 01:00:00"); + assertThat(mapper.writeValueAsString(result.skuVOList())).doesNotContain("email", "packageList"); + assertThat(mapper.readTree(body.get()).path("data").toString()) + .isEqualTo("{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}"); + response.set(response.get().replace("W-DEMO", "OTHER")); + contract(() -> client().queryDetail(detail(), context()), "DETAIL_IDENTITY_MISMATCH"); } - - @ParameterizedTest - @ValueSource(ints = {401, 403, 429, 500, 302}) - void httpFailuresDoNotEchoBodyOrRetry(int status) { - responseStatus.set(status); - responseBody.set(APP_KEY + APP_SECRET + PRIVATE_DATA); - String code = status == 429 ? "HTTP_RATE_LIMITED" : status == 401 || status == 403 ? "AUTHORIZATION_REJECTED" : "HTTP_FAILED"; - var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), code, status == 429 || status >= 500); - assertThat(error.upstreamStatus()).isEqualTo(status); - assertThat(calls).hasValue(1); + @Test void cancelledDetailRetainsUnknownQuantityAndEmptyTime() { + response.set(""" + {"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":99, + "outboundTime":"","skuVOList":[{"productSku":"SKU","actualDeliveryQty":"","deliveryQty":1}]}} + """); + var result = client().queryDetail(detail(), context()); + assertThat(result.status()).isEqualTo(99); assertThat(result.outboundTime()).isEmpty(); + assertThat(result.skuVOList().get(0).actualDeliveryQty()).isNull(); } - - @Test - void malformedResponsesExposeFixedContractStageOnly() { - String[][] scenarios = { - {"" + PRIVATE_DATA + "", "JSON"}, - {"[]", "ROOT_TYPE_ARRAY"}, - {"{}", "CODE_TYPE_MISSING"}, - {"{\"code\":\"200\"}", "CODE_TYPE_STRING"}, - {"{\"code\":\"99999\"}", "CODE_TYPE_STRING"}, - {"{\"code\":200}", "DATA_TYPE_MISSING"}, - {"{\"code\":200,\"data\":{}}", "RECORDS_TYPE_MISSING"}, - {"{\"code\":200,\"success\":null}", "SUCCESS_TYPE_NULL"}, - {"{\"code\":200,\"success\":\"true\"}", "SUCCESS_TYPE_STRING"}, - {"{\"code\":200,\"success\":true,\"data\":[]}", "DATA_TYPE_ARRAY"}, - {"{\"code\":200,\"success\":true,\"data\":{\"records\":{}}}", "RECORDS_TYPE_OBJECT"}, - {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{},{}]}}", "RECORDS_OVER_PAGE_SIZE"}, - {"{\"code\":200,\"success\":true,\"data\":{\"total\":\"0\",\"records\":[]}}", "TOTAL_TYPE_STRING"}, - {"{\"code\":200,\"success\":true,\"data\":{\"current\":2,\"records\":[]}}", "CURRENT_MISMATCH"}, - {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":1}]}}", "DELIVERY_NO_TYPE_NUMBER"}, - {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":\"A\",\"status\":\"100\"}]}}", "STATUS_TYPE_STRING"} - }; - var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO); - for (String[] scenario : scenarios) { - responseBody.set(scenario[0]); - var error = assertFailure(client, "INVALID_RESPONSE", false); - assertThat(error.getMessage()).contains("(" + scenario[1] + ")"); - assertThat(error.upstreamStatus()).isEqualTo(200); + @Test void inventoryPreservesIndependentNumbersAndUnknownsAndRejectsDuplicates() throws Exception { + String stock = """ + {"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU","productQuality":0, + "stkQty":120.123456,"freezeQty":20,"useStkQty":100,"transitQty":30,"innerBatchNo":"", + "stockCompare":{"stkQty":999},"phone":"private"} + """; + response.set(page("current", "size", 1, 1000, 1, 1, stock)); + var result = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()); + assertThat(mapper.readTree(body.get()).path("data").toString()) + .isEqualTo("{\"current\":1,\"size\":1000,\"whCode\":\"W-DEMO\",\"isHideInventory\":0}"); + var row = result.records().get(0); + assertThat(row.stkQty()).isEqualByComparingTo("120.123456"); + assertThat(row.useStkQty()).isEqualByComparingTo("100"); assertThat(row.transitQty()).isEqualByComparingTo("30"); + assertThat(row.innerBatchNo()).isEmpty(); + assertThat(mapper.writeValueAsString(result.records())).doesNotContain("phone", "stockCompare"); + response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("120.123456", "0") + .replace("\"freezeQty\":20", "\"freezeQty\":\"\"").replace("\"useStkQty\":100", "\"useStkQty\":null") + .replace("\"transitQty\":30", "\"transitQty\":-1"))); + row = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()).records().get(0); + assertThat(row.stkQty()).isZero(); assertThat(row.freezeQty()).isNull(); assertThat(row.useStkQty()).isNull(); + assertThat(row.transitQty()).isEqualByComparingTo("-1"); + response.set(page("current", "size", 1, 1000, 2, 1, stock + "," + stock)); + contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "DUPLICATE_INVENTORY_KEY"); + response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("W-DEMO", "OTHER"))); + contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "INVENTORY_WAREHOUSE_MISMATCH"); + } + @ParameterizedTest @ValueSource(strings = {"total", "pages", "current", "size"}) + void missingPaginationIsRejected(String field) { + response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"" + field + "\":1,", "")); + contract(() -> client().queryPage(query(1, 1), context()), ""); + } + @Test void shortPageAndWrongPageCountAreRejected() { + response.set(page("current", "size", 1, 2, 2, 1, ORDER)); + contract(() -> client().queryPage(query(1, 2), context()), "PAGE_ROW_COUNT_MISMATCH"); + response.set(page("current", "size", 1, 1, 1, 2, ORDER)); + contract(() -> client().queryPage(query(1, 1), context()), "PAGES_MISMATCH"); + } + @ParameterizedTest @ValueSource(ints = {401, 403, 302}) + void nonretryableHttpNeverEchoesOrRetries(int code) { + status.set(code); response.set(PRIVATE); + var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); + assertThat(error.retryable()).isFalse(); assertThat(error.getCause()).isNull(); + assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(ints = {429, 500, 503}) + void everyFailureHasOneHttpAttemptAndNeverImplicitRetry(int code) { + status.set(code); response.set(PRIVATE); + var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); + assertThat(calls.get()).isEqualTo(1); + assertThat(error.getMessage()).doesNotContain(PRIVATE); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); + assertThat(completion.get().actualHttpAttempts()).isEqualTo(1); + assertThat(completion.get().upstreamStatus()).isEqualTo(code); + } + @ParameterizedTest @ValueSource(strings = {"600", "Mon, 12 Oct 2026 00:10:00 GMT", "invalid"}) + void rateLimitPausesAccountAcrossAllEntrypointsAndReportsCooldown(String header) { + status.set(429); response.set(PRIVATE); retryAfter.set(header); + var client = client(); + var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query(1, 1), context())); + assertThat(error.egress().accountPaused()).isTrue(); + assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(header.equals("invalid") ? 300 : 600)); + assertThat(completion.get().accountPaused()).isTrue(); + assertThat(completion.get().cooldownUntil()).isEqualTo(error.egress().cooldownUntil()); + assertThatThrownBy(() -> client.locate(new LocateRequest("DO-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThatThrownBy(() -> client.queryWarehouses(new WarehouseQueryRequest(1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThatThrownBy(() -> client.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThatThrownBy(() -> client.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(calls.get()).isEqualTo(1); + } + @Test void validationRejectsLargePageBeforeHttpOrBudget() { + var c = client(CollectionMode.VALIDATION, true, true); + assertThatThrownBy(() -> c.queryPage(query(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThatThrownBy(() -> c.locate(new LocateRequest("DO-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThatThrownBy(() -> c.queryWarehouses(new WarehouseQueryRequest(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThatThrownBy(() -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数"); + assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull(); + } + @Test void validationNeverRetriesEvenServerError() { + status.set(503); response.set(PRIVATE); + assertThrows(LingxingWmsException.class, () -> client(CollectionMode.VALIDATION, true, true) + .queryPage(query(1, 1), context(CollectionMode.VALIDATION))); + assertThat(calls.get()).isEqualTo(1); + } + @ParameterizedTest @ValueSource(strings = {"31001", "\"429\""}) + void businessRateLimitCodesPauseWithoutHttpRetry(String sourceCode) { + response.set("{\"code\":" + sourceCode + ",\"msg\":\"" + PRIVATE + "\"}"); + var c = client(); + var error = assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context())); + assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue(); + assertThat(completion.get().sourceCode()).isIn(31001, 429); + assertThatThrownBy(() -> c.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(calls.get()).isEqualTo(1); assertThat(error.getMessage()).doesNotContain(PRIVATE); + } + @Test void allFiveEntrypointsRequireControlContext() { + var c = client(); + assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), null)); + assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), null)); + assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), null)); + assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), null)); + assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), null)); + assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull(); + } + @Test void allFiveEntrypointsFailClosedForOffMissingGrantOrBudget() { + for (var c : new LingxingWmsClient[] {client(CollectionMode.OFF, true, true), + client(CollectionMode.BUSINESS, false, true), client(CollectionMode.BUSINESS, true, false)}) { + assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context())); + assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), context())); + assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), context())); + assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), context())); + assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())); } + assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull(); } - - @Test - void localRateLimitStopsSecondRequest() { - var client = client(true, APP_KEY, APP_SECRET, Duration.ofMinutes(1)); - client.queryPage(query()); - assertFailure(client, "LOCAL_RATE_LIMITED", true); - assertThat(calls).hasValue(1); + @ParameterizedTest @ValueSource(strings = {"\"11006\"", "11008", "\"200\"", "null"}) + void rejectedEnvelopesNeverEchoRawMessage(String code) { + response.set("{\"code\":" + code + ",\"msg\":\"" + PRIVATE + "\"}"); + var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); + assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(error.getCause()).isNull(); + assertThat(calls.get()).isEqualTo(1); } - - @Test - void validatesLocalPaginationAndMonthBeforeSending() { - var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO); - var query = query(); - for (var invalid : new QueryRequest[] { - new QueryRequest(query.startTime(), query.endTime(), 0, 1), - new QueryRequest(query.startTime(), query.endTime(), 1, 101), - new QueryRequest(query.endTime(), query.startTime(), 1, 1), - new QueryRequest(query.startTime().minusSeconds(1), query.endTime(), 1, 1), - new QueryRequest(null, query.endTime(), 1, 1) - }) assertThatThrownBy(() -> client.queryPage(invalid)).isInstanceOf(LingxingWmsException.class); - assertThat(calls).hasValue(0); + @ParameterizedTest @ValueSource(strings = {"false", "null", "\"true\""}) + void presentSuccessMustBeBooleanTrue(String success) { + response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success)); + assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); } - - private LingxingWmsException assertFailure(LingxingWmsClient client, String code, boolean retryable) { - var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query())); - assertThat(error.code()).isEqualTo("LINGXING_WMS_" + code); - assertThat(error.retryable()).isEqualTo(retryable); - assertThat(error.getCause()).isNull(); - StringWriter trace = new StringWriter(); - error.printStackTrace(new PrintWriter(trace)); - assertThat(trace.toString()).doesNotContain(APP_KEY, APP_SECRET, PRIVATE_DATA, "sign=", "http://"); - return error; + private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); } + private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) { + var clock = new LingxingEgressGateTest.MutableClock(NOW); + var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + "synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2)); + var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null, + NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null); + var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) { + @Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); } + }; + var gate = new LingxingEgressGate(props, ledger, clock, clock::advanceMillis); + return new LingxingWmsClient(p, mapper, clock, gate); } - - private LingxingWmsClient client(boolean enabled, String key, String secret, Duration interval) { - var properties = new LingxingWmsProperties(enabled, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), - key, secret, Duration.ofSeconds(2), Duration.ofSeconds(2), interval); - return new LingxingWmsClient(properties, mapper, Clock.fixed(NOW, ZoneOffset.UTC)); + private EgressContext context() { return context(CollectionMode.BUSINESS); } + private EgressContext context(CollectionMode mode) { + return new EgressContext("demo-account", mode, "demo-root", "demo-session", "attempt-" + attempts.incrementAndGet(), "demo-grant"); } - - private static QueryRequest query() { - LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0); - return new QueryRequest(end.minusMonths(1), end, 1, 1); + private static QueryRequest query(int current, int size) { + var end = LocalDateTime.of(2026, 10, 12, 8, 0); return new QueryRequest(end.minusMonths(1), end, current, size); } - - private static String hmac(String input) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + private static DetailRequest detail() { return new DetailRequest("W-DEMO", "C-DEMO", "DO-DEMO"); } + private static String page(String c, String s, int current, int size, long total, int pages, String rows) { + return "{\"code\":200,\"data\":{\"" + c + "\":" + current + ",\"" + s + "\":" + size + + ",\"total\":" + total + ",\"pages\":" + pages + ",\"records\":[" + rows + "]}}"; + } + private static void contract(Runnable action, String stage) { + var error = assertThrows(LingxingWmsException.class, action::run); + assertThat(error.code()).isEqualTo("LINGXING_WMS_INVALID_RESPONSE"); + assertThat(error.getMessage()).contains(stage).doesNotContain(PRIVATE); assertThat(error.retryable()).isFalse(); } } diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java index c34b9b55c..a6eb7b538 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java @@ -70,16 +70,15 @@ class LingxingWmsSignerTest { assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com")); assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5)); assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15)); - assertThat(properties.minRequestInterval()).isEqualTo(Duration.ofSeconds(1)); assertThat(properties.toString()).doesNotContain("example-key", "example-secret"); } @Test void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() { - var properties = new LingxingWmsProperties(false, null, null, null, null, null, null); + var properties = new LingxingWmsProperties(false, null, null, null, null, null); assertThat(properties.enabled()).isFalse(); assertThat(properties.hasCredentials()).isFalse(); - assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null)) + assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null)) .isInstanceOf(IllegalArgumentException.class); } diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 96083fde7..c271e5fe6 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -22,6 +22,7 @@ | 功能 | 首个入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md` | +| 自营领星统计 | `selfoperated/analytics/`;平台入口 `controller/PlatformAnalyticsController.java`,契约见 `../../docs/自营业务/README.md`;权限源 `../iam/src/main/resources/db/selfoperated-analytics-permissions.sql`。 | | 订单查询与控制 / 导出 | `controller/LastlegOrderController.java`;异步导出见 `export/` 与 `lastleg-exports-v1.yaml` | | 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java` | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java` | diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 3be67471e..68b4e6ef4 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -7,11 +7,23 @@ 现行门禁为 `scripts/analyze-lastleg-dependencies.mjs`、对应 `.test.mjs` 和 `src/test/resources/architecture/lastleg-slice-dependencies.json`。下文第2至8节的规模、原安全投影和首切片说明保留为早期对比,不覆盖本节及当前正式统一认证契约。此次校正不修改既有跨业务流程,也不以新增模块级通配许可接受未知依赖。 - 统一认证使用common-security及Auth权威会话。旧6个LastlegSecurity投影类型不再要求存在,改为禁止恢复该切片及引用其退役类型;不存在的退役类被重新import也会失败。 -- 25条已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。 +- 已核对的既有类引用逐条写入 `reviewedClassDependencies`,必须有完整源类、目标类及原因,拒绝通配符。尾程订单直接换标审核委托换标服务;退货实收转换标在同服务事务中建立申请及明细;仓储转运实测入仓同步计划/分配/收货事实并复用资源网关;PDA入口共用冻结仓身份;费用换算只由既有换算器读取月度汇率。此记录不授权同一模块其他类使用这些实现。 - 未审阅的新增类引用仍按原切片门禁拦截;车辆应用层的向内依赖规则对所有引用执行,不能通过逐类记录绕过。fleet继续只消费车辆应用端口/服务,不读取车辆持久化适配器。 - 主源码禁止包循环。`DeliveryDocumentSnapshot` 是DTO快照解析和摘要装配,已从 `delivery/model` 移至 `delivery/service`,消除原DTO与model互相引用;枚举及公开JSON结构不变。不为循环设长期例外。 - 离线反向测试覆盖未授权新消费者、退役类型恢复/引用、通配记录、绕过车辆应用层和新增包环。通过门禁只证明这些结构规则,不证明消除了所有存量耦合或完成部署/业务验收。 +### 预约、车队与导出依赖收口(2026-10-12) + +- 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。 +- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号保持原 LB/TR 规则,由本域生成;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。 +- 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。 +- 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。 +- 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。 + +### 平台自营统计边界 + +`selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 + ### 车次接入边界 车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index cd4f87a92..b051f695d 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -13,6 +13,7 @@ const sourceRoots = { const internalPrefix = 'com.baiyue.lastleg.'; const sliceRules = [ + ['selfoperated-analytics', /^com\.baiyue\.lastleg\.selfoperated\.analytics(?:\.|$)/], ['fleet', /^com\.baiyue\.lastleg\.fleet(?:\.|$)/], ['security-projection', /(?:^|\.)security\.(?:controller|dto|service)(?:\.|$)/], ['dispatch-delivery', /(?:^|\.)(?:dispatch|delivery|outbound)(?:\.|$)/], diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index d1adbc7cd..ac273c171 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -10,6 +10,27 @@ const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies. const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; +test('selfoperated analytics is independent of customer outbound and inventory slices', () => { + const report = analyze(); + const prefix = 'com.baiyue.lastleg.selfoperated.analytics.'; + const classes = report.classes.filter(entry => entry.fqcn.startsWith(prefix)); + assert.ok(classes.length > 0); + assert.ok(classes.some(entry => entry.packageName.endsWith('.inventory'))); + assert.ok(classes.some(entry => entry.packageName.endsWith('.outbound'))); + assert.ok(classes.every(entry => entry.slice === 'selfoperated-analytics')); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'selfoperated-analytics' + || edge.target === 'selfoperated-analytics'), [{ source: 'selfoperated-analytics', target: 'shared-technical', count: 1 }]); + assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith(prefix) && !edge.target.startsWith(prefix)), [{ + source: prefix + 'request.SelfopEgressController', target: 'com.baiyue.lastleg.security.InternalServiceTokenGuard', + }]); + assert.deepEqual(report.packageSccs.filter(cycle => cycle.some(pkg => pkg.startsWith(prefix))), []); + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const source = classes.find(entry => entry.packageName.endsWith('.inventory')).fqcn; + const target = 'com.baiyue.lastleg.inventory.query.service.InventoryQueryService'; + report.classDependencies.push({ source, target }); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`))); +}); + test('fleet stays a business slice and its policies have no infrastructure dependencies', () => { const report = analyze(); const classes = report.classes.filter(entry => entry.fqcn.startsWith('com.baiyue.lastleg.fleet.')); @@ -30,6 +51,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); assert.ok(dispatchEdges.every(edge => /\.(FleetDispatchGateway|FleetDispatchBindingGuard|FleetTripExecutionService|FleetExecutionModels|FleetCompletionModels)$/.test(edge.target) + || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningAdapter' + && /\.(FleetBookingIncomingGateway|FleetDispatchPlanningGateway)$/.test(edge.target) + || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper' + && edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway' || edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'), 'dispatch must use fleet ports and guards, not its persistence mappers'); @@ -42,6 +67,7 @@ function analyze(sourceSet = 'main') { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { cwd: moduleRoot, encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, })); } @@ -85,7 +111,10 @@ test('vehicle master data remains the reviewed low-coupling first-slice candidat target: 'com.baiyue.lastleg.security.OverseasPermissions', }]); assert.ok(incoming.length > 0); - assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.'))); + assert.ok(incoming.every(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix + '.application.') + || edge.source === 'com.baiyue.lastleg.export.LastlegExportProviderService' + && [vehiclePrefix + '.application.VehicleApplicationService', vehiclePrefix + '.application.model.VehicleModels'].includes(edge.target)), + 'vehicle consumers use application contracts; the export composition has two exact read-only references'); assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith(vehiclePrefix))), false); assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false); assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false); @@ -126,6 +155,22 @@ test('reviewed references do not allow another consumer into the same slice', () assert.ok(contractViolations(report, baseline).some(message => message.includes(`${source}->${target}`))); }); +test('export composition approvals do not grant persistence access or new consumers', () => { + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const source = 'com.baiyue.lastleg.export.LastlegExportProviderService'; + const cases = [ + { source, target: vehiclePrefix + '.adapter.out.persistence.VehicleMapper' }, + { source, target: 'com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper' }, + { source: 'com.baiyue.lastleg.export.LastlegExportColumns', target: 'com.baiyue.lastleg.fleet.quote.FleetQuoteService' }, + ]; + for (const edge of cases) { + const report = analyze(); + report.classDependencies.push(edge); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`)), + 'each unreviewed consumer/target pair must still fail'); + } +}); + test('restoring a retired projection or importing its missing type still fails', () => { const baseline = JSON.parse(readFileSync(contract, 'utf8')); const report = analyze(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java index cc32dcb5f..3325e85c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java @@ -1,42 +1,6 @@ package com.baiyue.lastleg.appointmentplanning; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.http.HttpStatus; -import java.util.List; -import java.util.Objects; - -/** Called by actual incoming materialization, never as a migration or historical data repair. */ -@Service -public class AppointmentSourceLinker { - private final LastlegOrderMapper orders; - private final InboundFbaAppointmentItemMapper items; - private final AppointmentExternalSourceGateway bookings; - public AppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;} - @Transactional(propagation=Propagation.MANDATORY) - public void materialized(String customerId,String bookingNo,Long orderId) { - var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo); - bookings.lockForAppointment(List.of(ref)); - var matched=bookings.resolve(List.of(ref)); - if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对"); - var order=orderId==null?null:orders.selectById(orderId); - if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物"); - var rows=items.selectList(new LambdaQueryWrapper().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo) - .eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE")); - for(var row:rows) { - if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑"); - if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue; - try {items.update(null,new LambdaUpdateWrapper().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true) - .set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));} - catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");} - } - } - private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +/** Associates a newly materialized source with its receiving appointment in the caller's transaction. */ +public interface AppointmentSourceLinker { + void materialized(String customerId, String bookingNo, Long orderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java new file mode 100644 index 000000000..be24cdf27 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.delivery.fleet; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.fleet.business.FleetPoolDeliveryEvidence; +import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.HashSet; + +/** Same-transaction delivery proof; no persistence model escapes into fleet. */ +@Component +public class FleetPoolDeliveryAdapter implements FleetPoolDeliveryEvidence { + private final DeliverySourceEvidenceMapper evidence; + public FleetPoolDeliveryAdapter(DeliverySourceEvidenceMapper evidence){this.evidence=evidence;} + @Override public boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages) { + var dispatchIds=evidence.dispatchIds(customerId,orderId); + if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false; + var deliveries=new ArrayList(); + var picks=new ArrayList(); + var returns=new ArrayList(); + for(Long dispatchId:dispatchIds){ + if(dispatchId==null||dispatchId<=0)return false; + var d=evidence.deliveries(customerId,dispatchId);var p=evidence.picks(customerId,dispatchId); + var r=evidence.returns(customerId,dispatchId); + if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false; + deliveries.addAll(d);picks.addAll(p);returns.addAll(r); + } + return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(incomingNo,orderId,expectedPackages), + deliveries,picks,returns,true).fullySigned(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index 21a80490b..dc7b541a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -1,4 +1,5 @@ package com.baiyue.lastleg.delivery.service; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.SignedDelivery; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; @@ -115,7 +116,9 @@ public class DeliveryDocumentService { orders.updateById(order); if (autoCompleted) { var completion = completeRelatedOrders(order, operatorId, now); - fleetPool.signed(order, completion.sourcePackages(), operatorId, now); + fleetPool.signed(new SignedDelivery( + order.getCustomerId(),order.getDispatchNo(),order.getDeliveryNo(),"DELIVERED".equals(order.getStatus()),order.getSignedAt()), + completion.sourcePackages(),operatorId,now); deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant()); customerVisits.ensureForCompletedDelivery(order, now); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolDeliveryEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolDeliveryEvidence.java new file mode 100644 index 000000000..93a312062 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolDeliveryEvidence.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.fleet.business; + +/** Delivery owns source attribution, returns and full-signature proof. */ +public interface FleetPoolDeliveryEvidence { + boolean fullySigned(String customerId,String incomingNo,long orderId,int expectedPackages); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index 1cc646b23..96d7e93db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -1,15 +1,12 @@ package com.baiyue.lastleg.fleet.business; import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; + import com.baiyue.lastleg.looseboard.entity.*; import com.baiyue.lastleg.looseboard.mapper.*; import com.baiyue.lastleg.trucking.entity.*; import com.baiyue.lastleg.trucking.mapper.*; -import com.baiyue.lastleg.service.LastlegOrderNoGenerator; -import com.baiyue.lastleg.delivery.entity.DeliveryOrder; -import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; -import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; + import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -30,13 +27,13 @@ public class FleetPoolOrderBridge { private final LocalLooseboardHistoryMapper looseHistory; private final UsTruckOrderMapper trucks; private final UsTruckOrderStatusHistoryMapper truckHistory; - private final LastlegOrderNoGenerator numbers; + private final FleetPoolOrderNumberGenerator numbers; private final ObjectMapper json; - private final DeliverySourceEvidenceMapper deliveryEvidence; + private final FleetPoolDeliveryEvidence deliveryEvidence; private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources; public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose, LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks, - UsTruckOrderStatusHistoryMapper truckHistory,LastlegOrderNoGenerator numbers,ObjectMapper json,DeliverySourceEvidenceMapper deliveryEvidence, + UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) { this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks; this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; @@ -111,7 +108,7 @@ public class FleetPoolOrderBridge { if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now()); } @Transactional(propagation=Propagation.MANDATORY) - public void acceptedReceipt(long bookingId,FleetBookingIncomingGateway.Evidence evidence,int palletSpaces,String operator) { + public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) { var row=requiredBooking(bookingId); if(evidence==null||!Objects.equals(row.getIncomingNo(),evidence.incomingNo()) ||!Objects.equals(row.getWarehouseId(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()) @@ -157,33 +154,19 @@ public class FleetPoolOrderBridge { } } @Transactional(propagation=Propagation.MANDATORY) - public void signed(DeliveryOrder delivery,Map actualSourcePackages,String operator,LocalDateTime at) { - if(delivery.getDispatchNo()==null)return; - for(var row:links.lockDispatch(delivery.getCustomerId(),delivery.getDispatchNo())) { + public void signed(SignedDelivery delivery,Map actualSourcePackages,String operator,LocalDateTime at) { + if(delivery.dispatchNo()==null)return; + for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) { if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue; if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整"); if("COMPLETED".equals(row.getFulfillmentStage()))continue; if(!"IN_TRANSIT".equals(row.getFulfillmentStage()))throw conflict("商业单须实际出库后才能签收完成"); - if("DELIVERED".equals(delivery.getStatus())&&delivery.getSignedAt()!=null&&wholeSourceSigned(row)) - move(row,"COMPLETED",operator,"真实全部签收 "+delivery.getDeliveryNo(),at); + if(delivery.delivered()&&delivery.signedAt()!=null&&wholeSourceSigned(row)) + move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at); } } private boolean wholeSourceSigned(FleetPoolOrderLink row) { - var dispatchIds=deliveryEvidence.dispatchIds(row.getCustomerId(),row.getLastlegOrderId()); - if(dispatchIds==null||dispatchIds.isEmpty()||new HashSet<>(dispatchIds).size()!=dispatchIds.size())return false; - var deliveries=new ArrayList(); - var picks=new ArrayList(); - var returns=new ArrayList(); - for(Long dispatchId:dispatchIds) { - if(dispatchId==null||dispatchId<=0)return false; - var d=deliveryEvidence.deliveries(row.getCustomerId(),dispatchId); - var p=deliveryEvidence.picks(row.getCustomerId(),dispatchId); - var r=deliveryEvidence.returns(row.getCustomerId(),dispatchId); - if(d==null||d.isEmpty()||p==null||p.isEmpty()||r==null)return false; - deliveries.addAll(d);picks.addAll(p);returns.addAll(r); - } - return SourceDeliveryCompletionPolicy.evaluate(new SourceDeliveryCompletionPolicy.Source(row.getIncomingNo(), - row.getLastlegOrderId(),row.getActualPackageCount()),deliveries,picks,returns,true).fullySigned(); + return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount()); } public Map businessOrderNumbers(Collection ids) { if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderModels.java index 1d5d3aaf4..8f9938d21 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderModels.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.business; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.Instant; +import java.util.List; public final class FleetPoolOrderModels { private FleetPoolOrderModels() { } @@ -13,6 +14,14 @@ public final class FleetPoolOrderModels { BigDecimal weightKg, BigDecimal volumeCbm, String currency, String contactName, String contactPhone, String remark) { } public record BusinessOrderRef(String businessType, String businessOrderNo) { } + /** Receiving facts needed by commercial fulfillment, independent of trip planning DTOs. */ + public record ReceivedPallet(String warehousePalletId,int packages) { } + public record Receipt(String incomingNo,String warehouseId,String destinationKey,String status, + List pallets,int receivedPackages) { + public Receipt { pallets=List.copyOf(pallets); } + } + public record SignedDelivery(String customerId,String dispatchNo,String deliveryNo, + boolean delivered,LocalDateTime signedAt) { } public record OrderKey(String customerId, String businessType, long businessOrderId) { } public record Arrangement(String sourceType, String businessType, String businessOrderNo, String bookingNo, String tripNo, Instant plannedDepartureAt, String originWarehouseName, String destinationLabel, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java new file mode 100644 index 000000000..894686ddb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.fleet.business; + +import org.springframework.stereotype.Component; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.regex.Pattern; + +/** Commercial pool orders own LB/TR numbering; no tail-leg order sequence is consumed. */ +@Component +public class FleetPoolOrderNumberGenerator { + private static final Pattern LOCATION=Pattern.compile("LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD"); + public String next(String businessType,String destination,long uniqueId){ + String prefix=switch(businessType){case "TRUCK"->"TR";case "LOOSEBOARD"->"LB";default->throw new IllegalArgumentException("Unsupported pool business type");}; + return prefix+"-"+location(destination)+"-"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + +"-"+String.format(Locale.ROOT,"%06d",Math.floorMod(uniqueId,1_000_000)); + } + private static String location(String value){ + String code=value==null?"":value.trim().toUpperCase(Locale.ROOT);var match=LOCATION.matcher(code); + if(Pattern.matches(".*("+LOCATION.pattern()+").*",code)&&match.find())return match.group(); + return code.matches("[A-Z0-9]{2,8}")?code:"WH"; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index 01e8144a0..154c15bd2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -1,4 +1,6 @@ package com.baiyue.lastleg.fleet.trip; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Receipt; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.ReceivedPallet; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; @@ -168,7 +170,10 @@ public class FleetBookingService { if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对"); row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now()))); row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash); - if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),confirmed,totalSpaces,operator); + if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt( + confirmed.incomingNo(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(), + confirmed.pallets().stream().map(p->new ReceivedPallet(p.warehousePalletId(),p.packages())).toList(), + confirmed.receivedPackages()),totalSpaces,operator); save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false); } @Transactional diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceCreateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java similarity index 75% rename from lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceCreateController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java index 05b52d95f..11fd0824b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceCreateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.appointmentplanning; +package com.baiyue.lastleg.inboundplan.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; @@ -10,6 +10,8 @@ import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/v1/last-leg/appointment-planning/appointments") @@ -19,13 +21,17 @@ public class AppointmentSourceCreateController { @PostMapping @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list') and hasAuthority('"+OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT+"')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody SourceFbaAppointmentRequest request) { - AppointmentPlanningController.requirePlatform(user); + requirePlatform(user); return ApiResponse.success(service.createFromSources(String.valueOf(user.userId()),request)); } @GetMapping("/unscheduled") @PreAuthorize("hasAuthority('platform:appointment:list')") public ApiResponse> unscheduled(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(defaultValue="1") int page,@RequestParam(defaultValue="20") int size) { - AppointmentPlanningController.requirePlatform(user); + requirePlatform(user); return ApiResponse.success(service.unscheduled(page,size)); } + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || user.userId()==null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SourceFbaAppointmentRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SourceFbaAppointmentRequest.java index 2d4951339..252e175a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SourceFbaAppointmentRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SourceFbaAppointmentRequest.java @@ -1,6 +1,5 @@ package com.baiyue.lastleg.inboundplan.dto; -import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.util.List; @@ -11,4 +10,6 @@ public record SourceFbaAppointmentRequest( @NotBlank @Size(max=64) String fbaWarehouseCode, @NotNull @Valid FbaAppointmentDetails details, @NotBlank @Size(max=64) String idempotencyKey -) { } +) { + public record SourceRef(String customerId, String sourceKey, String destinationKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java new file mode 100644 index 000000000..fbc023c9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.inboundplan.service; + +import com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker; +import com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.List; +import java.util.Objects; + +/** Called by actual incoming materialization, never as a migration or historical data repair. */ +@Service +public class InboundAppointmentSourceLinker implements AppointmentSourceLinker { + private final LastlegOrderMapper orders; + private final InboundFbaAppointmentItemMapper items; + private final AppointmentExternalSourceGateway bookings; + public InboundAppointmentSourceLinker(LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings) {this.orders=orders;this.items=items;this.bookings=bookings;} + @Override + @Transactional(propagation=Propagation.MANDATORY) + public void materialized(String customerId,String bookingNo,Long orderId) { + var ref=new AppointmentExternalSourceGateway.SourceRef(customerId,bookingNo); + bookings.lockForAppointment(List.of(ref)); + var matched=bookings.resolve(List.of(ref)); + if(matched.size()!=1 || !Objects.equals(matched.get(0).customerId(),customerId) || !Objects.equals(matched.get(0).bookingNo(),bookingNo) || !Objects.equals(matched.get(0).orderId(),orderId)) throw conflict("来货与原登记的归属不一致,请核对"); + var order=orderId==null?null:orders.selectById(orderId); + if(order==null || !Objects.equals(order.getCustomerId(),customerId)) throw conflict("来货尚未形成对应客户的真实货物"); + var rows=items.selectList(new LambdaQueryWrapper().eq(InboundFbaAppointmentItem::getSourceKey,"BOOKING:"+bookingNo) + .eq(InboundFbaAppointmentItem::getCustomerId,customerId).eq(InboundFbaAppointmentItem::getActive,true).last("FOR UPDATE")); + for(var row:rows) { + if(row.getOrderId()!=null && !row.getOrderId().equals(orderId)) throw conflict("原预约已关联其他来货,不能自动改绑"); + if(Objects.equals(row.getOrderId(),orderId) && Objects.equals(row.getInboundCode(),order.getInboundCode())) continue; + try {items.update(null,new LambdaUpdateWrapper().eq(InboundFbaAppointmentItem::getId,row.getId()).eq(InboundFbaAppointmentItem::getActive,true) + .set(InboundFbaAppointmentItem::getOrderId,orderId).set(InboundFbaAppointmentItem::getInboundCode,order.getInboundCode()));} + catch(org.springframework.dao.DuplicateKeyException duplicate){throw conflict("该来货已有其他有效预约,请先核对原安排");} + } + } + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index 5e5a52ebc..ba6b1e7f0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -66,7 +66,10 @@ public class InboundFbaAppointmentService { } @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public FbaAppointmentResponse createFromSources(String operatorId,SourceFbaAppointmentRequest request) { - return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId,request.sources(),request.originWarehouseId(),request.fbaWarehouseCode(),request.details())); + return command("CREATE_FROM_SOURCES",operatorId,request.idempotencyKey(),request,()->create(operatorId, + request.sources()==null?null:request.sources().stream().map(source->source==null?null: + new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList(), + request.originWarehouseId(),request.fbaWarehouseCode(),request.details())); } private FbaAppointmentResponse create(String operator,List refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) { var resolved=resolveForWrite(refs,null); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/AnalyticsClientException.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/AnalyticsClientException.java new file mode 100644 index 000000000..7023cf402 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/AnalyticsClientException.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.selfoperated.analytics.client; + +/** Fixed diagnostic only: never retain provider bodies, token or arbitrary messages. */ +public final class AnalyticsClientException extends RuntimeException { + private final String code; + private final boolean retryable; + public AnalyticsClientException(String code, boolean retryable) { + super(code); + this.code = code; + this.retryable = retryable; + } + public String code() { return code; } + public boolean retryable() { return retryable; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java new file mode 100644 index 000000000..0c44fb887 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.selfoperated.analytics.client; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads.Order; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads.Stock; +import java.time.LocalDateTime; +import java.util.List; +import com.baiyue.lastleg.selfoperated.analytics.model.*; + +/** One upstream page or detail per call. Durable orchestration belongs to sync. */ +public interface LingxingAnalyticsClient { + Page warehouses(SelfopRequestContext context, int current, int size); + Page discover(SelfopRequestContext context, LocalDateTime startTime, LocalDateTime endTime, int current, int size); + Page locate(SelfopRequestContext context, String deliveryNo, int current, int size); + Order detail(SelfopRequestContext context, String warehouseCode, String customerCode, String deliveryNo); + Page inventory(SelfopRequestContext context, String warehouseCode, int current, int size); + + record Page(List records, Integer current, Integer size, Long total, Integer pages) { + public Page { records = List.copyOf(records); } + } + record Warehouse(String warehouseCode, String warehouseName, Integer warehouseType, + Integer status, String timeZone) { } + record Summary(String sourceAccount, String customerCode, String warehouseCode, + String deliveryNo, String sourceNo, Integer sourceStatus, + String sourceCreatedTime, String sourceOutboundTime) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java new file mode 100644 index 000000000..7c677fd5c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java @@ -0,0 +1,248 @@ +package com.baiyue.lastleg.selfoperated.analytics.client; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.Operation; +import org.springframework.beans.factory.annotation.Autowired; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.springframework.http.MediaType; +import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestClientResponseException; +import java.math.BigDecimal; +import java.net.http.HttpClient; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Map; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +@Component +public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { + private static final String PREFIX = "/internal/integrations/lingxing/wms"; + private static final Set PROVIDER_ERROR_CODES = Set.of( + "LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED", + "LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED", + "LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED", + "LINGXING_WMS_ACCOUNT_PAUSED", "LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + private final RestClient client; + private final SelfopAnalyticsProperties properties; + private final SelfopEgressService egress; + private final ObjectMapper responseMapper = new ObjectMapper() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + + @Autowired public RestLingxingAnalyticsClient(RestClient.Builder builder, SelfopAnalyticsProperties properties, SelfopEgressService egress) { + this.egress = egress; + this.properties = properties; + var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build()); + factory.setReadTimeout(Duration.ofSeconds(30)); + this.client = builder.requestFactory(factory).baseUrl(properties.getIntegrationBaseUrl()).build(); + } + + // Allows an entirely in-memory transport in contract tests. + RestLingxingAnalyticsClient(RestClient client, SelfopAnalyticsProperties properties, SelfopEgressService egress) { + this.egress = egress; + this.client = client; + this.properties = properties; + } + + @Override public Page warehouses(SelfopRequestContext context, int current, int size) { + gate(context); String account = context.sourceAccount(); paging(current, size, 1000); + return page(post(context, "/warehouses/query", Map.of("current", current, "size", size)), current, size, + n -> new Warehouse(required(n, "whCode", 64), optional(n, "whNameCn"), + integer(n, "warehouseType"), integer(n, "status"), optional(n, "timeZone"))); + } + + @Override public Page discover(SelfopRequestContext context, LocalDateTime start, LocalDateTime end, int current, int size) { + gate(context); String account = context.sourceAccount(); paging(current, size, 100); + if (start == null || end == null || !start.isBefore(end) || end.isAfter(start.plusMonths(1))) + throw failure("INVALID_DISCOVERY_WINDOW"); + return page(post(context, "/outbound-orders/query", Map.of("startTime", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start), + "endTime", DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end), + "current", current, "size", size)), current, size, n -> summary(account, n)); + } + + @Override public Page locate(SelfopRequestContext context, String deliveryNo, int current, int size) { + gate(context); String account = context.sourceAccount(); paging(current, size, 1000); argument(deliveryNo, 128); + return page(post(context, "/outbound-orders/locate", Map.of("deliveryNo", deliveryNo, + "current", current, "size", size)), current, size, n -> summary(account, n)); + } + + @Override public SelfopPayloads.Order detail(SelfopRequestContext context, String warehouse, String customer, String deliveryNo) { + gate(context); String account = context.sourceAccount(); argument(warehouse, 64); argument(customer, 64); argument(deliveryNo, 128); + JsonNode n = post(context, "/outbound-orders/detail", Map.of("warehouseCode", warehouse, + "customerCode", customer, "deliveryNo", deliveryNo)); + if (!warehouse.equals(required(n, "whCode", 64)) || !customer.equals(required(n, "customerCode", 64)) + || !deliveryNo.equals(required(n, "deliveryNo", 128))) throw failure("DETAIL_IDENTITY_MISMATCH"); + if (!n.path("skuVOList").isArray()) throw failure("DETAIL_ITEMS_MISSING"); + var items = new ArrayList(); + for (JsonNode item : n.path("skuVOList")) { + items.add(new SelfopPayloads.Item(required(item, "productSku", 128), optional(item, "productName"), + quantity(item, "actualDeliveryQty"), properties.getQuantityUnit())); + } + return new SelfopPayloads.Order(account, customer, warehouse, deliveryNo, optional(n, "sourceNo"), + integer(n, "status"), optional(n, "orderCreateTime"), optional(n, "outboundTime"), List.copyOf(items)); + } + + @Override public Page inventory(SelfopRequestContext context, String warehouse, int current, int size) { + gate(context); String account = context.sourceAccount(); argument(warehouse, 64); paging(current, size, 1000); + return page(post(context, "/product-inventory/query", Map.of("warehouseCode", warehouse, + "current", current, "size", size)), current, size, n -> { + if (!warehouse.equals(required(n, "whCode", 64))) throw failure("INVENTORY_WAREHOUSE_MISMATCH"); + return new SelfopPayloads.Stock(account, required(n, "customerCode", 64), warehouse, + required(n, "productSku", 128), integer(n, "productQuality"), optional(n, "productName"), + quantity(n, "stkQty"), quantity(n, "useStkQty"), quantity(n, "freezeQty"), + quantity(n, "transitQty"), optional(n, "uniqueKey4tenant")); + }); + } + + private Summary summary(String account, JsonNode n) { + return new Summary(account, required(n, "customerCode", 64), required(n, "whCode", 64), + required(n, "deliveryNo", 128), optional(n, "sourceNo"), integer(n, "status"), + optional(n, "createTime"), optional(n, "outboundTime")); + } + + private void gate(SelfopRequestContext context) { + if (context == null) throw failure("EGRESS_CONTEXT_MISSING"); + String account = context.sourceAccount(); + if ("OFF".equals(properties.getCollectionMode())) throw failure("EXECUTION_DISABLED"); + if (!properties.getCollectionMode().equals(context.executionPurpose())) throw failure("EGRESS_PURPOSE_MISMATCH"); + argument(account, 64); + if (!account.equals(properties.getSourceAccount())) throw failure("SOURCE_ACCOUNT_MISMATCH"); + if (properties.getInternalServiceToken() == null || properties.getInternalServiceToken().isBlank()) + throw failure("INTERNAL_TOKEN_UNCONFIGURED"); + } + + private JsonNode post(SelfopRequestContext context, String suffix, Object request) { + Map fields = (Map)request; + Operation op = switch(suffix) { + case "/warehouses/query" -> Operation.WAREHOUSES; + case "/outbound-orders/query" -> Operation.OUTBOUND_QUERY; + case "/outbound-orders/locate" -> Operation.OUTBOUND_LOCATE; + case "/outbound-orders/detail" -> Operation.OUTBOUND_DETAIL; + case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY; + default -> throw failure("EGRESS_OPERATION_INVALID"); + }; + int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; + String warehouse=(String)fields.get("warehouseCode"); + String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null; + var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields)); + JsonNode body; + try { + String response = client.post().uri(PREFIX + suffix).contentType(MediaType.APPLICATION_JSON) + .header("X-Internal-Service-Token", properties.getInternalServiceToken()) + .header("X-Lingxing-Egress-Context", SelfopSupport.json(permission)) + .body(request).retrieve().body(String.class); + body = response == null ? null : responseMapper.readTree(response); + } catch (RestClientResponseException e) { + int status = e.getStatusCode().value(); + if(status>=500||status==429)egress.pauseUncertain(permission,op,"INTEGRATION_HTTP_FAILURE"); + throw new AnalyticsClientException("INTEGRATION_HTTP_FAILURE", status == 429 || status >= 500); + } catch (ResourceAccessException e) { + egress.pauseUncertain(permission,op,"INTEGRATION_TRANSPORT_FAILURE"); + throw new AnalyticsClientException("INTEGRATION_TRANSPORT_FAILURE", true); + } catch (RestClientException e) { + egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID"); + throw failure("INTEGRATION_RESPONSE_INVALID"); + } catch (JsonProcessingException e) { + egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID"); + throw failure("INTEGRATION_RESPONSE_INVALID"); + } + if (body == null || !body.isObject()) { + egress.pauseUncertain(permission,op,"INTEGRATION_RESPONSE_INVALID");throw failure("INTEGRATION_RESPONSE_INVALID"); + } + if (!"200".equals(body.path("code").asText())) { + JsonNode metadata=body.path("data").path("egress"); + if(metadata.path("accountPaused").isBoolean()&&metadata.path("accountPaused").booleanValue()){ + if(!metadata.path("attemptId").isTextual()||!permission.attemptId().equals(metadata.path("attemptId").textValue())) + throw failure("INTEGRATION_PAUSE_BINDING_INVALID"); + JsonNode reportedCode=body.path("data").path("errorCode"); + egress.pauseUncertain(permission,op,reportedCode.isTextual()?reportedCode.textValue():"INTEGRATION_RESPONSE_INVALID"); + } + if("LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED".equals(body.path("data").path("errorCode").asText())) { + JsonNode denial=body.path("data").path("denial"); + if(!denial.isObject()||!denial.path("errorCode").isTextual()||!denial.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")||!denial.path("sampleDone").isBoolean()||!denial.has("waitUntil"))throw failure("INTEGRATION_RESPONSE_INVALID"); + LocalDateTime wait=null; + if(!denial.path("waitUntil").isNull()) { + if(!denial.path("waitUntil").isTextual())throw failure("INTEGRATION_RESPONSE_INVALID"); + try {wait=LocalDateTime.ofInstant(java.time.Instant.parse(denial.path("waitUntil").textValue()),java.time.ZoneOffset.UTC);}catch(Exception invalid){throw failure("INTEGRATION_RESPONSE_INVALID");} + } + throw new EgressRejectedException(denial.path("errorCode").textValue(),denial.path("sampleDone").booleanValue(),wait); + } + // Retryability is a normalized Integration field, never infer it from provider text. + boolean retryable = body.path("data").path("retryable").isBoolean() + && body.path("data").path("retryable").booleanValue(); + JsonNode sourceError = body.path("data").path("errorCode"); + String code = sourceError.isTextual() && PROVIDER_ERROR_CODES.contains(sourceError.textValue()) + ? sourceError.textValue() : "INTEGRATION_PROVIDER_FAILURE"; + if ("LINGXING_WMS_AUTHORIZATION_REJECTED".equals(code) || "LINGXING_WMS_SIGNATURE_REJECTED".equals(code)) + retryable = false; + throw new AnalyticsClientException(code, retryable); + } + if (!body.path("data").isObject()) throw failure("INTEGRATION_DATA_MISSING"); + return body.path("data"); + } + + private Page page(JsonNode n, int current, int size, Function mapper) { + int actualCurrent = integer(n, "current"), actualSize = integer(n, "size"), pages = integer(n, "pages"); + JsonNode totalNode = n.path("total"); + long total; + try { + if (totalNode.isIntegralNumber() && totalNode.canConvertToLong()) total = totalNode.longValue(); + else if (totalNode.isTextual() && totalNode.textValue().matches("[0-9]+")) total = Long.parseLong(totalNode.textValue()); + else throw failure("PAGE_METADATA_INVALID"); + } catch (NumberFormatException e) { throw failure("PAGE_METADATA_INVALID"); } + if (!n.path("records").isArray() || actualCurrent != current || actualSize != size || total < 0 || pages < 0 + || pages != (total == 0 ? 0 : ((total - 1) / size) + 1) || (pages > 0 && current > pages) + || (total == 0 && current != 1)) throw failure("PAGE_METADATA_INVALID"); + long expected = total == 0 ? 0 : Math.min(size, total - ((long) current - 1) * size); + if (n.path("records").size() != expected) throw failure("PAGE_ROW_COUNT_INVALID"); + var records = new ArrayList(); + for (JsonNode row : n.path("records")) { + if (!row.isObject()) throw failure("PAGE_ROW_INVALID"); + records.add(mapper.apply(row)); + } + return new Page<>(records, current, size, total, pages); + } + + private static void paging(int current, int size, int max) { + if (current < 1 || size < 1 || size > max) throw failure("INVALID_PAGE_REQUEST"); + } + private static void argument(String value, int max) { + if (value == null || value.isBlank() || value.length() > max) throw failure("INVALID_BUSINESS_IDENTITY"); + } + private static String required(JsonNode n, String field, int max) { + String value = optional(n, field); argument(value, max); return value; + } + private static String optional(JsonNode n, String field) { + JsonNode value = n.get(field); + if (value == null || value.isNull()) return null; + if (!value.isTextual()) throw failure("SOURCE_FIELD_TYPE_INVALID"); + return value.textValue(); + } + private static int integer(JsonNode n, String field) { + JsonNode value = n.path(field); + if (!value.isIntegralNumber() || !value.canConvertToInt()) throw failure("SOURCE_INTEGER_INVALID"); + return value.intValue(); + } + private static BigDecimal quantity(JsonNode n, String field) { + JsonNode value = n.get(field); + if (value == null || value.isNull() || value.isTextual() && value.textValue().isEmpty()) return null; + if (!value.isNumber()) throw failure("SOURCE_QUANTITY_INVALID"); + return value.decimalValue(); + } + private static AnalyticsClientException failure(String code) { return new AnalyticsClientException(code, false); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java new file mode 100644 index 000000000..2163f7e5b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.query.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics") +@PreAuthorize("hasAuthority('platform:self-operated-analytics:list')") +@Tag(name="平台自营出库销量与库存只读分析") +public class PlatformAnalyticsController { + public static final String READ_PERMISSION="platform:self-operated-analytics:list"; + private final AnalyticsQueryService service; + private final CollectionQueryService collection; + public PlatformAnalyticsController(AnalyticsQueryService service,CollectionQueryService collection){this.service=service;this.collection=collection;} + @GetMapping("/sku-sales-summary") + @Operation(summary="五周期或历史自定义范围SKU累计清单") + public ApiResponse> salesSummary( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){ + requirePlatform(user);return ApiResponse.success(service.salesSummary(query)); + } + @GetMapping("/sku-daily-sales") + @Operation(summary="精确货主与SKU的完整日趋势轴") + public ApiResponse dailySales( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){ + requirePlatform(user);return ApiResponse.success(service.dailySales(query)); + } + @GetMapping("/hot-skus") + @Operation(summary="近7或30个完整自然日热销Top100") + public ApiResponse> hotSkus( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){ + requirePlatform(user);return ApiResponse.success(service.hotSkus(query)); + } + @GetMapping("/slow-skus") + @Operation(summary="固定近30天有库存低出库量Top100") + public ApiResponse> slowSkus( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){ + requirePlatform(user);return ApiResponse.success(service.slowSkus(query)); + } + @GetMapping("/inventory-coverage") + @Operation(summary="正品库存、固定30天日均和可售天数") + public ApiResponse> inventoryCoverage( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){ + requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options)); + } + @GetMapping("/owners") + @Operation(summary="固定两版已采集货主编码前缀分页") + public ApiResponse> owners( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){ + requirePlatform(user);return ApiResponse.success(service.owners(query,options)); + } + @GetMapping("/owner-overview") + @Operation(summary="本仓一个货主完整候选的30日经营概览") + public ApiResponse ownerOverview( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){ + requirePlatform(user);return ApiResponse.success(service.ownerOverview(query,options)); + } + @GetMapping("/sync-overview") + @Operation(summary="各仓任务族独立配置、覆盖和采集运行概览") + public ApiResponse syncOverview( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){ + requirePlatform(user);return ApiResponse.success(collection.overview(query)); + } + @GetMapping("/sync-tasks") + @Operation(summary="本仓逻辑采集任务与轻量最新尝试分页") + public ApiResponse> syncTasks( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){ + requirePlatform(user);return ApiResponse.success(collection.tasks(query)); + } + @GetMapping("/sync-tasks/{taskNo}") + @Operation(summary="稳定业务任务号的采集详情与进度") + public ApiResponse syncTaskDetail( + @AuthenticationPrincipal AuthenticatedUser user,@PathVariable String taskNo,@ModelAttribute CollectionQuery query){ + requirePlatform(user);return ApiResponse.success(collection.taskDetail(taskNo,query)); + } + @GetMapping("/sync-runs") + @Operation(summary="本仓或指定任务号的尝试历史与终态进度") + public ApiResponse> syncRuns( + @AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute CollectionQuery query){ + requirePlatform(user);return ApiResponse.success(collection.runs(query)); + } + private static void requirePlatform(AuthenticatedUser user){ + if(user==null||user.tenantId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode())||user.tenantId()!=0L||user.impersonated()||!user.permissions().contains(READ_PERMISSION)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"平台自营分析只读权限不足"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java new file mode 100644 index 000000000..76fab4f36 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java @@ -0,0 +1,140 @@ +package com.baiyue.lastleg.selfoperated.analytics.inventory; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Builds one immutable inventory observation, using only fixed published reference versions. */ +public final class InventorySnapshotAssembler { + private InventorySnapshotAssembler() { } + public record Candidate(List snapshots, List profiles) { } + private record StockKey(String customer, String sku, Integer quality) { } + private record SkuKey(String customer, String sku) { } + + public static Candidate assemble(SelfopSyncScope scope, SelfopSyncRun run, + List stages, List priorStock, + List priorProfiles, List salesProfiles, String unit) { + require("COMPLETE".equals(run.getCoverageStatus()), "SELFOP_INVENTORY_INCOMPLETE"); + require(run.getFetchStartedAtUtc() != null && run.getFetchFinishedAtUtc() != null + && !run.getFetchFinishedAtUtc().isBefore(run.getFetchStartedAtUtc()), "SELFOP_INVENTORY_INTERVAL_INVALID"); + require(run.getPageCount() != null && run.getPageCount() > 0 + && run.getSourceTotalCount() != null && run.getSourceTotalCount() >= 0 + && Objects.equals(run.getSourceTotalCount(), run.getUniqueObjectCount()) + && run.getSourceTotalCount() == stages.size(), "SELFOP_INVENTORY_COUNT_MISMATCH"); + var now = utcNow(); + Map stocks = new LinkedHashMap<>(); + Map profiles = new LinkedHashMap<>(); + Set hashes = new HashSet<>(); + Set observedPages = new HashSet<>(); + for (var stage : stages) { + require(Objects.equals(stage.getRunId(), run.getId()) && Objects.equals(stage.getStageBatchId(), run.getId()) + && Objects.equals(stage.getLastFetchRunId(), run.getId()), "SELFOP_INVENTORY_ATTEMPT_MIXED"); + require("PRODUCT_STOCK".equals(stage.getObjectType()) && "VALID".equals(stage.getValidationStatus()) + && Objects.equals(stage.getWarehouseCode(), scope.getWarehouseCode()), "SELFOP_INVENTORY_STAGE_INVALID"); + require(stage.getFirstPageNo() != null && stage.getFirstPageNo() >= 1 + && stage.getFirstPageNo() <= run.getPageCount() && stage.getObservedAtUtc() != null, + "SELFOP_INVENTORY_PAGE_INVALID"); + require(hashes.add(stage.getObjectKeyHash()), "SELFOP_INVENTORY_DUPLICATE_KEY"); + observedPages.add(stage.getFirstPageNo()); + var value = read(stage.getPayloadJson(), SelfopPayloads.Stock.class); + verifyIdentity(scope, value.sourceAccount(), value.warehouseCode(), value.customerCode(), value.skuCode()); + require(value.productQuality() != null && value.productQuality() >= Short.MIN_VALUE + && value.productQuality() <= Short.MAX_VALUE, "SELFOP_INVENTORY_QUALITY_INVALID"); + require(value.productName() == null || value.productName().length() <= 255, "SELFOP_INVENTORY_NAME_INVALID"); + require(value.sourceUniqueKey() == null || value.sourceUniqueKey().length() <= 512, "SELFOP_INVENTORY_SOURCE_KEY_INVALID"); + require(Objects.equals(hash(value), stage.getContentHash()), "SELFOP_INVENTORY_CONTENT_CHANGED"); + var key = new StockKey(value.customerCode(), value.skuCode(), value.productQuality()); + require(!stocks.containsKey(key), "SELFOP_INVENTORY_DUPLICATE_KEY"); + var row = snapshot(scope, run, key, now); + row.setProductName(value.productName()); row.setSourceUniqueKey(value.sourceUniqueKey()); + row.setPhysicalQuantity(value.physicalQuantity()); row.setAvailableQuantity(value.availableQuantity()); + row.setFrozenQuantity(value.frozenQuantity()); row.setTransitQuantity(value.transitQuantity()); + row.setObservedAtUtc(stage.getObservedAtUtc()); + var quality = quality(value); + row.setQualityStatus(quality[0]); row.setQualityReason(quality[1]); + if ("QUANTITY_PRECISION".equals(quality[1])) { + // The exact offending decimals remain in immutable stage evidence; never let DECIMAL silently round them. + row.setPhysicalQuantity(null); row.setAvailableQuantity(null); row.setFrozenQuantity(null); row.setTransitQuantity(null); + } + stocks.put(key, row); + var skuKey = new SkuKey(key.customer(), key.sku()); + var profile = profiles.computeIfAbsent(skuKey, ignored -> profile(scope, run, skuKey, now)); + profile.setProductName(profile.getProductName() == null ? value.productName() : profile.getProductName()); + profile.setQuantityUnit(unit); + profile.setFirstSeenAtUtc(earlier(profile.getFirstSeenAtUtc(), stage.getObservedAtUtc())); + profile.setEarliestEvidenceAtUtc(profile.getFirstSeenAtUtc()); + profile.setEvidenceType("INVENTORY_OBSERVATION"); profile.setEvidenceRunId(run.getId()); + profile.setLastSeenRunId(run.getId()); + } + require(stages.isEmpty() ? run.getPageCount() == 1 : observedPages.size() == run.getPageCount(), + "SELFOP_INVENTORY_PAGE_MISSING"); + for (var old : priorStock) { + verifyIdentity(scope, old.getSourceAccount(), old.getWarehouseCode(), old.getCustomerCode(), old.getSkuCode()); + require(Objects.equals(old.getSnapshotRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_INVALID"); + var key = new StockKey(old.getCustomerCode(), old.getSkuCode(), old.getProductQuality()); + stocks.computeIfAbsent(key, ignored -> missing(scope, run, key, old.getProductName(), now)); + profiles.computeIfAbsent(new SkuKey(key.customer(), key.sku()), ignored -> profile(scope, run, ignored, now)); + } + for (var old : priorProfiles) { + verifyIdentity(scope, old.getSourceAccount(), old.getWarehouseCode(), old.getCustomerCode(), old.getSkuCode()); + require(Objects.equals(old.getProfileRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_PROFILE_BASE_INVALID"); + var key = new SkuKey(old.getCustomerCode(), old.getSkuCode()); + var current = profiles.computeIfAbsent(key, ignored -> profile(scope, run, key, now)); + if (current.getProductName() == null) current.setProductName(old.getProductName()); + if ("INVENTORY_OBSERVATION".equals(old.getEvidenceType()) && old.getFirstSeenAtUtc() != null) { + boolean observedNow = current.getLastSeenRunId() != null; + current.setFirstSeenAtUtc(earlier(current.getFirstSeenAtUtc(), old.getFirstSeenAtUtc())); + current.setEarliestEvidenceAtUtc(current.getFirstSeenAtUtc()); + current.setEvidenceType("INVENTORY_OBSERVATION"); + current.setEvidenceRunId(old.getEvidenceRunId()); + if (!observedNow) { current.setLastSeenRunId(old.getLastSeenRunId()); current.setQuantityUnit(old.getQuantityUnit()); } + } + } + for (var sales : salesProfiles) { + verifyIdentity(scope, sales.getSourceAccount(), sales.getWarehouseCode(), sales.getCustomerCode(), sales.getSkuCode()); + require(Objects.equals(sales.getProfileRunId(), run.getReferenceSalesRunId()), "SELFOP_INVENTORY_SALES_REFERENCE_INVALID"); + var key = new StockKey(sales.getCustomerCode(), sales.getSkuCode(), 0); + stocks.computeIfAbsent(key, ignored -> missing(scope, run, key, sales.getProductName(), now)); + var skuKey = new SkuKey(key.customer(), key.sku()); + var current = profiles.computeIfAbsent(skuKey, ignored -> profile(scope, run, skuKey, now)); + if (current.getProductName() == null) current.setProductName(sales.getProductName()); + // Sales identifies a candidate; it cannot prove that inventory ever observed it. + } + return new Candidate(List.copyOf(stocks.values()), List.copyOf(profiles.values())); + } + + private static String[] quality(SelfopPayloads.Stock stock) { + var quantities = new BigDecimal[]{ stock.physicalQuantity(), stock.availableQuantity(), stock.frozenQuantity(), stock.transitQuantity() }; + boolean missing = false; + for (var quantity : quantities) { + if (quantity == null) { missing = true; continue; } + if (quantity.scale() > 6 && quantity.stripTrailingZeros().scale() > 6 + || quantity.precision() - quantity.scale() > 14) return new String[]{"INVALID", "QUANTITY_PRECISION"}; + if (quantity.signum() < 0) return new String[]{"INVALID", "NEGATIVE_QUANTITY"}; + } + return missing ? new String[]{"UNKNOWN", "FIELD_MISSING"} : new String[]{"VALID", null}; + } + private static void verifyIdentity(SelfopSyncScope scope, String account, String warehouse, String customer, String sku) { + identity(customer, 64); identity(sku, 128); + require(Objects.equals(scope.getSourceAccount(), account) && Objects.equals(scope.getWarehouseCode(), warehouse), + "SELFOP_INVENTORY_SCOPE_LEAK"); + } + private static SelfopInventorySnapshot snapshot(SelfopSyncScope scope, SelfopSyncRun run, StockKey key, LocalDateTime now) { + var row = new SelfopInventorySnapshot(); row.setId(IdWorker.getId()); row.setCreatedAt(now); row.setUpdatedAt(now); + row.setSnapshotRunId(run.getId()); row.setSourceAccount(scope.getSourceAccount()); row.setWarehouseCode(scope.getWarehouseCode()); + row.setCustomerCode(key.customer()); row.setSkuCode(key.sku()); row.setProductQuality(key.quality()); return row; + } + private static SelfopInventorySnapshot missing(SelfopSyncScope scope, SelfopSyncRun run, StockKey key, String name, LocalDateTime now) { + var row = snapshot(scope, run, key, now); row.setProductName(name); row.setObservedAtUtc(run.getFetchFinishedAtUtc()); + row.setQualityStatus("UNKNOWN"); row.setQualityReason("SOURCE_ROW_MISSING"); return row; + } + private static SelfopSkuProfile profile(SelfopSyncScope scope, SelfopSyncRun run, SkuKey key, LocalDateTime now) { + var row = new SelfopSkuProfile(); row.setId(IdWorker.getId()); row.setCreatedAt(now); row.setUpdatedAt(now); + row.setProfileRunId(run.getId()); row.setSourceAccount(scope.getSourceAccount()); row.setWarehouseCode(scope.getWarehouseCode()); + row.setCustomerCode(key.customer()); row.setSkuCode(key.sku()); row.setEvidenceType("NONE"); return row; + } + private static LocalDateTime earlier(LocalDateTime a, LocalDateTime b) { return a == null || b != null && b.isBefore(a) ? b : a; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotService.java new file mode 100644 index 000000000..7b8482e0d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotService.java @@ -0,0 +1,179 @@ +package com.baiyue.lastleg.selfoperated.analytics.inventory; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +@RequiredArgsConstructor +public class InventorySnapshotService { + static final String DAILY_SAMPLE_RULE = "DAILY_SCHEDULED_FIRST_V1"; + private static final ZoneId SAMPLE_ZONE = ZoneId.of("Asia/Shanghai"); + private final SelfopCoordinationMapper coordination; + private final SelfopSyncScopeMapper scopes; + private final SelfopSyncRunMapper runs; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncStageMapper stages; + private final SelfopInventorySnapshotMapper snapshots; + private final SelfopSkuProfileMapper profiles; + private final SelfopAnalyticsProperties properties; + + /** Whole candidate commits atomically. No source HTTP request occurs in this transaction. */ + @Transactional(rollbackFor = Exception.class) + public void build(long scopeId, long runId, String owner, long fence) { + var scope = coordination.lockScope(scopeId); + var run = coordination.lockRun(runId); + validate(scope, run, owner, fence); + if ("READY".equals(run.getStatus()) || "PUBLISHED".equals(run.getStatus())) return; + require("VALIDATED".equals(run.getStatus()), "SELFOP_INVENTORY_NOT_VALIDATED"); + require(Objects.equals(scope.getPublishedRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_CHANGED"); + var priorStock = List.of(); + var priorProfiles = List.of(); + if (run.getBasePublishedRunId() != null) { + reference(scope, run.getBasePublishedRunId(), "INVENTORY"); + priorStock = snapshots.selectList(new QueryWrapper().eq("snapshot_run_id", run.getBasePublishedRunId())); + priorProfiles = profiles.selectList(new QueryWrapper().eq("profile_run_id", run.getBasePublishedRunId())); + } + var salesProfiles = List.of(); + if (run.getReferenceSalesRunId() != null) { + reference(scope, run.getReferenceSalesRunId(), "OUTBOUND"); + salesProfiles = profiles.selectList(new QueryWrapper().eq("profile_run_id", run.getReferenceSalesRunId())); + } + var candidate = InventorySnapshotAssembler.assemble(scope, run, + stages.selectList(new QueryWrapper().eq("stage_batch_id", runId).eq("object_type", "PRODUCT_STOCK").orderByAsc("id")), + priorStock, priorProfiles, salesProfiles, fixedUnit(run)); + require(snapshots.selectCount(new QueryWrapper().eq("snapshot_run_id", runId)) == 0 + && profiles.selectCount(new QueryWrapper().eq("profile_run_id", runId)) == 0, + "SELFOP_INVENTORY_CANDIDATE_EXISTS"); + run.setBuildStartedAtUtc(utcNow());run.setLastPhase("BUILDING"); + for (var row : candidate.snapshots()) row.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); + for (var row : candidate.profiles()) row.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); + if(!candidate.snapshots().isEmpty())snapshots.insert(candidate.snapshots(),Math.max(1,Math.min(500,properties.getPersistenceBatchSize()))); + if(!candidate.profiles().isEmpty())profiles.insert(candidate.profiles(),Math.max(1,Math.min(500,properties.getPersistenceBatchSize()))); + run.setStatus("READY"); run.setUpdatedAt(utcNow()); runs.updateById(run); + } + + /** Inventory publication deliberately has no outbound apply-state/revision barrier. */ + @Transactional(rollbackFor = Exception.class) + public void publish(long scopeId, long runId, String owner, long fence) { + var scope = coordination.lockScope(scopeId); + var run = coordination.lockRun(runId); + validate(scope, run, owner, fence); + if ("PUBLISHED".equals(run.getStatus())) { + verifyPublishedResult(run); + return; + } + require(fixedUnit(run) != null, "SELFOP_QUANTITY_UNIT_UNCONFIRMED"); + require("READY".equals(run.getStatus()) && "COMPLETE".equals(run.getCoverageStatus()), "SELFOP_INVENTORY_NOT_READY"); + require(Objects.equals(scope.getPublishedRunId(), run.getBasePublishedRunId()), "SELFOP_INVENTORY_BASE_CHANGED"); + var rows = snapshots.selectList(new QueryWrapper().eq("snapshot_run_id", runId)); + var dimensions = profiles.selectList(new QueryWrapper().eq("profile_run_id", runId)); + require(run.getSourceTotalCount() != null && rows.size() >= run.getSourceTotalCount(), "SELFOP_INVENTORY_CANDIDATE_INCOMPLETE"); + Set> rowSkus = new HashSet<>(), profileSkus = new HashSet<>(); + for (var row : rows) { + require(Objects.equals(scope.getSourceAccount(), row.getSourceAccount()) + && Objects.equals(scope.getWarehouseCode(), row.getWarehouseCode()), "SELFOP_INVENTORY_SCOPE_LEAK"); + rowSkus.add(List.of(row.getCustomerCode(), row.getSkuCode())); + } + for (var profile : dimensions) { + require(Objects.equals(scope.getSourceAccount(), profile.getSourceAccount()) + && Objects.equals(scope.getWarehouseCode(), profile.getWarehouseCode()), "SELFOP_INVENTORY_SCOPE_LEAK"); + profileSkus.add(List.of(profile.getCustomerCode(), profile.getSkuCode())); + } + require(profileSkus.containsAll(rowSkus), "SELFOP_INVENTORY_PROFILE_INCOMPLETE"); + var task = coordination.lockTask(run.getTaskId()); + SelfopCollectionPolicy.requireBusiness(task); + require(task != null && Objects.equals(task.getScopeId(), scopeId) && Objects.equals(task.getScopeHash(), scope.getScopeHash()) + && Objects.equals(task.getSourceAccount(), scope.getSourceAccount()) + && Objects.equals(task.getWarehouseCode(), scope.getWarehouseCode()) + && "NOT_APPLICABLE".equals(task.getApplyState()), "SELFOP_INVENTORY_TASK_INVALID"); + require(run.getInventoryDailySampleDate() == null && run.getInventorySampleRuleVersion() == null, + "SELFOP_INVENTORY_UNPUBLISHED_SAMPLE"); + LocalDate sampleDate = eligibleSampleDate(task, run); + if (sampleDate != null) { + var selected = runs.dailyInventorySample(scope.getSourceAccount(), scope.getWarehouseCode(), sampleDate); + require(selected == null || ("PUBLISHED".equals(selected.getStatus()) + && DAILY_SAMPLE_RULE.equals(selected.getInventorySampleRuleVersion())), "SELFOP_INVENTORY_SAMPLE_INVALID"); + if (selected == null) { + run.setInventoryDailySampleDate(sampleDate); + run.setInventorySampleRuleVersion(DAILY_SAMPLE_RULE); + } + } + var now = utcNow(); + if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now)); + scope.setPublishedRunId(runId); scope.setUpdatedAt(now); scopes.updateById(scope); + run.setProgressSnapshotJson(task.getProgressJson());run.setStatus("PUBLISHED"); run.setPublishedAtUtc(now); run.setFinishedAtUtc(now); run.setUpdatedAt(now); runs.updateById(run); + task.setResultPublishedRunId(runId);task.setStatus("SUCCEEDED"); task.setUpdatedAt(now); tasks.updateById(task); + } + + /** Recovery verifies the committed result, never elects an old unmarked version retroactively. */ + private void verifyPublishedResult(SelfopSyncRun run) { + var task = coordination.lockTask(run.getTaskId()); + SelfopCollectionPolicy.requireBusiness(task); + require(task != null && Objects.equals(task.getResultPublishedRunId(), run.getId()) + && run.getPublishedAtUtc() != null, "SELFOP_INVENTORY_PUBLISHED_RESULT_INVALID"); + if (run.getInventoryDailySampleDate() == null) { + require(run.getInventorySampleRuleVersion() == null, "SELFOP_INVENTORY_SAMPLE_INVALID"); + return; + } + require(DAILY_SAMPLE_RULE.equals(run.getInventorySampleRuleVersion()) + && Objects.equals(run.getInventoryDailySampleDate(), eligibleSampleDate(task, run)), "SELFOP_INVENTORY_SAMPLE_INVALID"); + var selected = runs.dailyInventorySample(run.getSourceAccount(), run.getWarehouseCode(), run.getInventoryDailySampleDate()); + require(selected != null && Objects.equals(selected.getId(), run.getId()), "SELFOP_INVENTORY_SAMPLE_INVALID"); + } + + /** The observed interval, not publication/retry time, determines the Beijing observation day. */ + static LocalDate eligibleSampleDate(SelfopSyncTask task, SelfopSyncRun run) { + if (!"BUSINESS".equals(task.getExecutionPurpose()) || !"BUSINESS".equals(run.getExecutionPurpose()) + || !"SCHEDULED".equals(task.getTaskType()) || !"WAREHOUSE_SYNC".equals(task.getTaskRole()) + || !"WAREHOUSE_SYNC".equals(run.getRunRole()) || !"COMPLETE".equals(run.getCoverageStatus()) + || !("READY".equals(run.getStatus()) || "PUBLISHED".equals(run.getStatus())) + || task.getPlannedAtUtc() == null || run.getFetchStartedAtUtc() == null || run.getFetchFinishedAtUtc() == null + || run.getFetchFinishedAtUtc().isBefore(run.getFetchStartedAtUtc()) + || run.getPageCount() == null || run.getPageCount() < 1 || run.getSourceTotalCount() == null + || run.getSourceTotalCount() < 0 || !Objects.equals(run.getSourceTotalCount(), run.getUniqueObjectCount())) return null; + LocalDate planned = task.getPlannedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate(); + LocalDate started = run.getFetchStartedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate(); + LocalDate finished = run.getFetchFinishedAtUtc().atOffset(ZoneOffset.UTC).atZoneSameInstant(SAMPLE_ZONE).toLocalDate(); + return planned.equals(started) && planned.equals(finished) ? planned : null; + } + + private void reference(SelfopSyncScope scope, long referenceId, String family) { + var ref = runs.selectById(referenceId); + SelfopCollectionPolicy.requireBusiness(ref); + require(ref != null && "PUBLISHED".equals(ref.getStatus()) && "WAREHOUSE_SYNC".equals(ref.getRunRole()) + && Objects.equals(scope.getSourceAccount(), ref.getSourceAccount()) + && Objects.equals(scope.getWarehouseCode(), ref.getWarehouseCode()), "SELFOP_INVENTORY_REFERENCE_INVALID"); + var refScope = scopes.selectById(ref.getScopeId()); + require(refScope != null && family.equals(refScope.getDataFamily()) + && Objects.equals(scope.getSourceAccount(), refScope.getSourceAccount()) + && Objects.equals(scope.getWarehouseCode(), refScope.getWarehouseCode()), "SELFOP_INVENTORY_REFERENCE_SCOPE_INVALID"); + } + private static String fixedUnit(SelfopSyncRun run) { + require(run.getScopeSnapshotJson() != null, "SELFOP_INVENTORY_CONFIG_MISSING"); + Object unit = read(run.getScopeSnapshotJson(), Map.class).get("quantityUnit"); + require(unit == null || unit instanceof String, "SELFOP_QUANTITY_UNIT_INVALID"); + if (unit == null || ((String) unit).isBlank()) return null; + return identity((String) unit, 32); + } + private static void validate(SelfopSyncScope scope, SelfopSyncRun run, String owner, long fence) { + SelfopCollectionPolicy.requireBusiness(run); + require(scope != null && run != null && "INVENTORY".equals(scope.getDataFamily()) + && "WAREHOUSE_SYNC".equals(run.getRunRole()) && Objects.equals(scope.getId(), run.getScopeId()) + && Objects.equals(scope.getSourceAccount(), run.getSourceAccount()) + && Objects.equals(scope.getWarehouseCode(), run.getWarehouseCode()) + && Objects.equals(scope.getScopeHash(), run.getScopeHash()), "SELFOP_INVENTORY_SCOPE_INVALID"); + require(owner != null && owner.equals(scope.getLeaseOwner()) && Objects.equals(scope.getFenceToken(), fence) + && Objects.equals(run.getFenceToken(), fence) && scope.getLeaseUntilUtc() != null + && scope.getLeaseUntilUtc().isAfter(utcNow()), "SELFOP_LEASE_LOST"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java new file mode 100644 index 000000000..f1ded1371 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface SelfopCoordinationMapper { + @Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE") + SelfopSyncScope lockScope(long id); + @Select("SELECT * FROM ll_selfop_outbound_discovery_scope WHERE id=#{id} FOR UPDATE") + SelfopOutboundDiscoveryScope lockDiscovery(long id); + @Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE") + SelfopSyncTask lockTask(long id); + @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") + SelfopSyncRun lockRun(long id); + @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") + List due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); + @Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") + long barriers(@Param("account") String account, @Param("warehouse") String warehouse); + @Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND apply_state='APPLIED' AND (query_from_utc IS NULL OR (query_from_utc>=#{from} AND query_until_utc<=#{until})) AND status IN ('READY','RUNNING','RETRY_WAIT','DEAD')") + int completeCoveredTasks(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("from") LocalDateTime from,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now); + @Update("UPDATE ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id SET r.status='REUSED',r.finished_at_utc=#{now},r.updated_at=#{now},r.progress_snapshot_json=COALESCE(r.progress_snapshot_json,CASE WHEN JSON_EXTRACT(t.progress_json,'$.attemptNo')=r.attempt_no THEN t.progress_json ELSE NULL END) WHERE t.result_published_run_id=#{published} AND r.id<>#{published} AND r.attempt_no=t.attempt_count AND r.execution_purpose='BUSINESS' AND r.status IN ('FETCHING','FACTS_APPLIED','BUILDING','READY')") + int completeReusedRuns(@Param("published") long published,@Param("now") LocalDateTime now); + @Update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED',result_published_run_id=#{published},next_retry_at_utc=NULL,updated_at=#{now} WHERE scope_id=#{scope} AND scope_hash=#{hash} AND execution_purpose='BUSINESS' AND task_type='SCHEDULED' AND apply_state='NOT_APPLICABLE' AND status='WAITING_SOURCE' AND planned_at_utc<=#{until}") + int completeCoveredMarkers(@Param("scope") long scope,@Param("hash") String hash,@Param("published") long published,@Param("until") LocalDateTime until,@Param("now") LocalDateTime now); + @Update("") + int heartbeat(@Param("scope") long scope,@Param("account") boolean account,@Param("fence") long fence,@Param("now") LocalDateTime now); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java new file mode 100644 index 000000000..c48016583 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopInventorySnapshot; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopInventorySnapshotMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java new file mode 100644 index 000000000..117c462dd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundDiscoveryScope; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopOutboundDiscoveryScopeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java new file mode 100644 index 000000000..313161408 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopOutboundItemMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java new file mode 100644 index 000000000..15b8721ca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopOutboundOrder; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopOutboundOrderMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java new file mode 100644 index 000000000..82ad4f828 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuDailySales; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSkuDailySalesMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java new file mode 100644 index 000000000..ed752bca3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSkuProfile; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSkuProfileMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java new file mode 100644 index 000000000..9d93655a4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDate; + +@Mapper +public interface SelfopSyncRunMapper extends BaseMapper { + /** Current read after acquiring the inventory scope lock, including after a competing publisher commits. */ + @Select("SELECT * FROM ll_selfop_sync_run WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND inventory_daily_sample_date=#{date} FOR UPDATE") + SelfopSyncRun dailyInventorySample(@Param("account") String account, @Param("warehouse") String warehouse, + @Param("date") LocalDate date); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java new file mode 100644 index 000000000..a937d1ade --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSyncScopeMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java new file mode 100644 index 000000000..da118dc82 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncStage; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSyncStageMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java new file mode 100644 index 000000000..8fcdcbe2a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.selfoperated.analytics.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface SelfopSyncTaskMapper extends BaseMapper { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java new file mode 100644 index 000000000..8078209bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; +import java.util.Map; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Component +@ConfigurationProperties(prefix = "baiyue.self-operated-analytics") +public class SelfopAnalyticsProperties { + private String collectionMode = "OFF"; + private String environment = "test"; + private String exclusiveGrantId; + private LocalDateTime exclusiveGrantExpiresAtUtc; + private boolean exclusiveHandoffVerified = false; + private boolean priorEgressClosed = false; + private boolean inflightDrained = false; + private boolean transferredBudgetVerified = false; + private long transferredDailyHttpCount = 0; + private LocalDate transferredBudgetDate; + private LocalDateTime transferredCooldownUntilUtc; + private String transferredPauseReason; + private int dailyRequestBudget = 0; + private int businessMinRequestIntervalMs = 5000; + private int maxRateLimitFailures = 5; + private int cooldownSeconds = 60; + private int egressLeaseSeconds = 120; + private boolean retentionEnabled = false; + private int salesRetentionDays = 7; + private int persistenceBatchSize = 500; + private int minimumBuildIntervalSeconds = 1800; + /** Read-only UI projection; collectionMode is the sole runtime mode input. */ + public boolean isExecutionEnabled() { + return "VALIDATION".equals(collectionMode)||("BUSINESS".equals(collectionMode)&&"production".equals(environment)); + } + private boolean salesPublicationEnabled = false; + private String integrationBaseUrl = "http://integration-service"; + private String internalServiceToken = ""; + private String sourceAccount = ""; + private String sourceQueryTimezone; + private String quantityUnit; + private int requestBudget = 100; + private int timeBudgetSeconds = 120; + private int minRequestIntervalMs = 1100; + private int leaseSeconds = 180; + private int maxAttempts = 5; + private int pageSize = 100; + private int applyBatchSize = 100; + private Map warehouses = new LinkedHashMap<>(); + @Data + public static class Warehouse { + private String businessTimezone = "Asia/Shanghai"; + private String sourceTimezone = "Asia/Shanghai"; + private String outboundCron = "0 0 8,20 * * *"; + private String inventoryCron = "0 0 8 * * *"; + private int inventoryStaleAfterSeconds = 93600; + private int outboundStaleAfterSeconds = 93600; + private int reviewRequestBudget = 100; + private int reviewTimeBudgetSeconds = 120; + private int reviewTargetDays = 7; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java new file mode 100644 index 000000000..249a563ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.require; + +/** Purpose is persisted on every task/run. Unknown purpose is never treated as business. */ +public final class SelfopCollectionPolicy { + private SelfopCollectionPolicy() { } + public static void requireBusiness(SelfopSyncTask task) { + require(task != null && "BUSINESS".equals(task.getExecutionPurpose()) && task.getValidationRootTaskId()==null, + "SELFOP_BUSINESS_PURPOSE_REQUIRED"); + } + public static void requireBusiness(SelfopSyncRun run) { + require(run != null && "BUSINESS".equals(run.getExecutionPurpose()),"SELFOP_BUSINESS_PURPOSE_REQUIRED"); + } + public static void requireExecution(SelfopAnalyticsProperties config,String purpose) { + require("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose),"SELFOP_PURPOSE_INVALID"); + require(purpose.equals(config.getCollectionMode()),"SELFOP_COLLECTION_MODE_REJECTED"); + if("BUSINESS".equals(purpose))require("production".equals(config.getEnvironment()),"SELFOP_BUSINESS_ENVIRONMENT_REJECTED"); + } + public static String configuredPurpose(SelfopAnalyticsProperties config) { + String purpose=config.getCollectionMode();requireExecution(config,purpose);return purpose; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java new file mode 100644 index 000000000..0255e0a59 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_inventory_snapshot") +public class SelfopInventorySnapshot { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long snapshotRunId; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String skuCode; + private Integer productQuality; + private String productName; + private BigDecimal physicalQuantity; + private BigDecimal availableQuantity; + private BigDecimal frozenQuantity; + private BigDecimal transitQuantity; + private String sourceUniqueKey; + private LocalDateTime observedAtUtc; + private String qualityStatus; + private String qualityReason; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java new file mode 100644 index 000000000..6eec5b877 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_outbound_discovery_scope") +public class SelfopOutboundDiscoveryScope { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private LocalDateTime catalogFromUtc; + private LocalDateTime catalogUntilUtc; + private Long nextSequence; + private Long nextObservationSequence; + private Long activeTaskId; + private String leaseOwner; + private LocalDateTime leaseUntilUtc; + private Long fenceToken; + private Integer requestBudget; + private Integer timeBudgetSeconds; + private Integer minRequestIntervalMs; + private String apiBudgetJson; + private LocalDateTime apiCooldownUntilUtc; + private String apiPauseReason; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java new file mode 100644 index 000000000..75da06354 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_outbound_item") +public class SelfopOutboundItem { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long orderId; + private String skuCode; + private String productName; + private BigDecimal actualQuantity; + private String quantityUnit; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java new file mode 100644 index 000000000..73d6482a6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_outbound_order") +public class SelfopOutboundOrder { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String deliveryNo; + private String sourceNo; + private Integer sourceStatus; + private String sourceCreatedTime; + private String sourceOutboundTime; + private String sourceTimezone; + private LocalDateTime outboundAtUtc; + private LocalDate businessDate; + private String businessTimezone; + private Integer salesEligible; + private Long revisionNo; + private String contentHash; + private Long lastRunId; + private Long lastObservationSequence; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java new file mode 100644 index 000000000..2bf453e96 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.math.BigDecimal; +import java.util.List; + +public final class SelfopPayloads { + private SelfopPayloads() { } + public record Order(String sourceAccount, String customerCode, String warehouseCode, + String deliveryNo, String sourceNo, Integer sourceStatus, String sourceCreatedTime, + String sourceOutboundTime, List items) { } + public record Item(String skuCode, String productName, BigDecimal actualQuantity, String quantityUnit) { } + public record Stock(String sourceAccount, String customerCode, String warehouseCode, + String skuCode, Integer productQuality, String productName, BigDecimal physicalQuantity, + BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity, + String sourceUniqueKey) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java new file mode 100644 index 000000000..a70f05267 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +public final class SelfopProgressProjection { + private SelfopProgressProjection() { } + private static final Set PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY", + "VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED"); + /** Pure projection for callers already holding the scope/task/run locks and fence. */ + public static void applyCheckpoint(SelfopSyncTask task,SelfopSyncRun run,String phase,String unit,long completed,Long total,int scan) { + require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID"); + require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID"); + require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID"); + Map p=progress(task); + if(!Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()))p=new LinkedHashMap<>(); + String countScope=scope(task); Object started=p.get("stageStartedAt"); + if(!phase.equals(p.get("phase")) || started==null)started=instant(utcNow()); + p.put("attemptNo",run.getAttemptNo()); p.put("phase",phase); p.put("progressScope",countScope); + p.put("stageStartedAt",started); p.put("unit",unit); p.put("completed",completed); + p.put("total",total); p.put("totalKnown",total!=null); p.put("scanRound",scan); p.put("countBasis",Set.of("INVENTORY","BUILD").contains(phase)?"ATTEMPT_ONLY":"TASK_CUMULATIVE"); p.put("sliceFrom",instant(task.getQueryFromUtc())); p.put("sliceUntil",instant(task.getQueryUntilUtc())); p.put("stageFinishedAt",null); + // A zero denominator is meaningful evidence, but cannot produce a percentage. + p.put("phasePercent",total==null||total==0?null:BigDecimal.valueOf(completed).multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(total),1,RoundingMode.DOWN)); + List> stages=new ArrayList<>(); + if(p.get("stages") instanceof List prior)for(Object value:prior) { + var entry=map(value); + if(PHASES.contains(Objects.toString(entry.get("phase"),""))&&!phase.equals(entry.get("phase")))stages.add(entry); + } + var current=new LinkedHashMap(); + for(String key:List.of("phase","unit","completed","total","totalKnown","scanRound","progressScope","countBasis", + "attemptNo","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt","phasePercent"))current.put(key,p.get(key)); + stages.add(current);p.remove("phases");p.put("stages",stages); + task.setProgressJson(bounded(p));task.setProgressUpdatedAtUtc(utcNow());task.setUpdatedAt(utcNow()); + run.setLastPhase(phase); run.setHeartbeatAtUtc(utcNow()); run.setUpdatedAt(utcNow()); + } + + /** Pure terminal snapshot; historical attempt evidence is immutable. */ + public static void freezeRun(SelfopSyncTask task,SelfopSyncRun run) { + if(run.getProgressSnapshotJson()!=null)return; + if(task.getProgressJson()==null) { + var fallback=new LinkedHashMap();fallback.put("attemptNo",run.getAttemptNo()); + fallback.put("phase",run.getLastPhase()!=null&&PHASES.contains(run.getLastPhase())?run.getLastPhase():"WAITING"); + fallback.put("progressScope",scope(task));fallback.put("countBasis","TASK_CUMULATIVE");fallback.put("scanRound",0); + fallback.put("unit","NONE");fallback.put("completed",0);fallback.put("total",null);fallback.put("totalKnown",false); + fallback.put("sliceFrom",instant(task.getQueryFromUtc()));fallback.put("sliceUntil",instant(task.getQueryUntilUtc())); + fallback.put("stageStartedAt",instant(run.getFetchStartedAtUtc()));fallback.put("stageFinishedAt",null);fallback.put("stages",List.of()); + run.setProgressSnapshotJson(bounded(fallback)); + return; + } + var p=progress(task); + // A later attempt's projection must never be copied into an earlier historical run. + require(Objects.equals(number(p.get("attemptNo")),run.getAttemptNo().longValue()),"SELFOP_PROGRESS_ATTEMPT_MISMATCH"); + run.setProgressSnapshotJson(bounded(p));run.setUpdatedAt(utcNow()); + } + + private static String bounded(Map p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;} + private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";} + @SuppressWarnings("unchecked") private static Map progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);} + @SuppressWarnings("unchecked") private static Map map(Object value){return value instanceof Map?new LinkedHashMap<>((Map)value):new LinkedHashMap<>();} + private static Long number(Object value){return value instanceof Number n?n.longValue():null;} + private static String instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java new file mode 100644 index 000000000..50b42c69d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java @@ -0,0 +1,11 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +/** Collector-owned identity; no credentials or provider protocol live in this neutral model. */ +public record SelfopRequestContext(String sourceAccount,String executionPurpose,Long rootTaskId, + String sessionId,String exclusiveGrantId,Long taskId,Long runId) { + public static SelfopRequestContext forTask(SelfopSyncTask task,SelfopSyncRun run,SelfopAnalyticsProperties properties) { + Long root="VALIDATION".equals(task.getExecutionPurpose())?task.getValidationRootTaskId():task.getId(); + return new SelfopRequestContext(task.getSourceAccount(),task.getExecutionPurpose(),root, + "selfop-"+root,properties.getExclusiveGrantId(),task.getId(),run.getId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java new file mode 100644 index 000000000..06228e752 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import lombok.Data; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Durable directory cursor and effective immutable stage references. */ +@Data +public class SelfopRoutingManifest { + private int scan = 1; + private int nextPage = 1; + private Integer pages; + private Long total; + private boolean directoryComplete; + private Map objects = new LinkedHashMap<>(); + private Map firstScan = new LinkedHashMap<>(); + @Data + public static class Entry { + private String customerCode; + private String deliveryNo; + private String warehouseCode; + private Long directoryStageId; + private Long effectiveStageId; + private Long refreshTaskId; + private Long newestSeenSequence; + private Long appliedStageId; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java new file mode 100644 index 000000000..a28738009 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sku_daily_sales") +public class SelfopSkuDailySales { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long salesRunId; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String skuCode; + private LocalDate businessDate; + private BigDecimal outboundQuantity; + private Long outboundOrderCount; + private String quantityUnit; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java new file mode 100644 index 000000000..8cf455a7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sku_profile") +public class SelfopSkuProfile { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long profileRunId; + private String sourceAccount; + private String customerCode; + private String warehouseCode; + private String skuCode; + private String productName; + private String quantityUnit; + private LocalDateTime firstSeenAtUtc; + private LocalDateTime earliestEvidenceAtUtc; + private String evidenceType; + private Long evidenceRunId; + private Long lastSeenRunId; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java new file mode 100644 index 000000000..f1d70465b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; + +public final class SelfopSupport { + private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + private SelfopSupport() { } + public static LocalDateTime utcNow() { return LocalDateTime.now(ZoneOffset.UTC); } + public static String json(Object value) { + try { return JSON.writeValueAsString(value); } + catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); } + } + public static T read(String value, Class type) { + try { return JSON.readValue(value, type); } + catch (Exception e) { throw new IllegalArgumentException("SELFOP_PAYLOAD_INVALID"); } + } + public static String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception e) { throw new IllegalStateException("SELFOP_HASH_FAILED"); } + } + public static void require(boolean valid, String code) { + if (!valid) throw new IllegalStateException(code); + } + public static String identity(String value, int max) { + require(value != null && !value.isBlank() && value.length() <= max, "SELFOP_IDENTITY_INVALID"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java new file mode 100644 index 000000000..fadebf6f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_run") +public class SelfopSyncRun { + private String executionPurpose; + private LocalDateTime buildStartedAtUtc; + private LocalDateTime supersededAtUtc; + private String progressSnapshotJson; + private LocalDateTime heartbeatAtUtc; + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long taskId; + private Long scopeId; + private String sourceAccount; + private String warehouseCode; + private String runRole; + private Long upstreamTaskId; + private Integer attemptNo; + private String scopeHash; + private String scopeSnapshotJson; + private String observedDimensionsJson; + private Long fenceToken; + private Long basePublishedRunId; + private Long referenceSalesRunId; + private String status; + private String lastPhase; + private LocalDateTime fetchStartedAtUtc; + private LocalDateTime fetchFinishedAtUtc; + private LocalDate inventoryDailySampleDate; + private String inventorySampleRuleVersion; + private LocalDateTime finishedAtUtc; + private LocalDateTime publishedAtUtc; + private Integer pageCount; + private Long sourceTotalCount; + private Long uniqueObjectCount; + private String coverageStatus; + private LocalDateTime coveredFromUtc; + private LocalDateTime coveredUntilUtc; + private LocalDate completeDayFrom; + private LocalDate completeDayUntil; + private Long appliedFactRevision; + private Long calculatedFactRevision; + private String errorCode; + private String errorMessage; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java new file mode 100644 index 000000000..7bebe65f9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_scope") +public class SelfopSyncScope { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private String warehouseCode; + private String dataFamily; + private String scopeJson; + private String scopeHash; + private String businessTimezone; + private String sourceTimezone; + private String scheduleCron; + private Integer staleAfterSeconds; + private String warehousePolicyRef; + private LocalDateTime initialFromUtc; + private LocalDateTime initialUntilUtc; + private LocalDateTime discoveryFromUtc; + private LocalDateTime discoveryUntilUtc; + private Long factRevision; + private Long publishedFactRevision; + private Integer rebuildRequired; + private LocalDateTime invalidatedAtUtc; + private Long publishedRunId; + private String leaseOwner; + private LocalDateTime leaseUntilUtc; + private Long fenceToken; + private Long knownOrderCursorId; + private Long knownOrderRoundMaxId; + private LocalDateTime knownOrderRoundStartedAtUtc; + private LocalDateTime knownOrderRoundFinishedAtUtc; + private LocalDateTime historyRoundFromUtc; + private LocalDateTime historyRoundUntilUtc; + private LocalDateTime historySliceCursorUtc; + private LocalDateTime historyRoundFinishedAtUtc; + private Integer reviewRequestBudget; + private Integer reviewTimeBudgetSeconds; + private Integer reviewTargetDays; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java new file mode 100644 index 000000000..35ce63485 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_stage") +public class SelfopSyncStage { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long runId; + private Long stageBatchId; + private Long lastFetchRunId; + private Long observationSequence; + private String warehouseCode; + private String previousWarehouseCode; + private String objectType; + private String objectKeyHash; + private Integer firstPageNo; + private LocalDateTime observedAtUtc; + private String contentHash; + private String payloadJson; + private String beforePayloadJson; + private String validationStatus; + private String validationCode; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java new file mode 100644 index 000000000..9bf50a686 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_selfop_sync_task") +public class SelfopSyncTask { + private String taskNo; + private String executionPurpose; + private Long validationRootTaskId; + private String validationBudgetJson; + private Long resultPublishedRunId; + private String progressJson; + private LocalDateTime progressUpdatedAtUtc; + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String sourceAccount; + private String taskRole; + private Long scopeId; + private String warehouseCode; + private Long discoveryScopeId; + private Long upstreamTaskId; + private Long discoverySequence; + private Long observationBaseline; + private String requestKey; + private String requestHash; + private String taskType; + private LocalDateTime plannedAtUtc; + private LocalDateTime queryFromUtc; + private LocalDateTime queryUntilUtc; + private String queryScopeJson; + private String routingManifestJson; + private String scopeHash; + private String status; + private String applyState; + private String affectedWarehouseCodesJson; + private LocalDateTime applyStartedAtUtc; + private LocalDateTime applyCompletedAtUtc; + private Integer attemptCount; + private LocalDateTime nextRetryAtUtc; + private String requestedBy; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java new file mode 100644 index 000000000..7469d3b6d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java @@ -0,0 +1,163 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */ +@Mapper +public interface OutboundAggregateMapper { + @Select(""" + SELECT COUNT(*) FROM ll_selfop_outbound_order o + LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id + LEFT JOIN ll_selfop_sync_stage s ON s.observation_sequence=o.last_observation_sequence + AND s.warehouse_code=o.warehouse_code AND s.object_type='OUTBOUND_ORDER' + AND s.validation_status='VALID' + AND EXISTS (SELECT 1 FROM ll_selfop_sync_run origin WHERE origin.id=s.run_id AND origin.source_account=o.source_account) + LEFT JOIN ll_selfop_sync_run r ON r.id=s.run_id AND r.source_account=o.source_account + LEFT JOIN ll_selfop_sync_task t ON t.id=r.task_id + WHERE o.source_account=#{account} AND o.warehouse_code=#{warehouse} AND o.sales_eligible=1 + AND (o.outbound_at_utc IS NULL OR o.business_date IS NULL + OR o.source_timezone IS NULL OR o.source_timezone != #{sourceTimezone} + OR o.business_timezone IS NULL OR o.business_timezone != #{businessTimezone} + OR i.id IS NULL OR i.actual_quantity IS NULL OR i.actual_quantity < 0 + OR i.quantity_unit IS NULL OR i.quantity_unit != #{unit} + OR r.id IS NULL OR r.execution_purpose IS NULL OR r.execution_purpose != 'BUSINESS' + OR t.execution_purpose IS NULL OR t.execution_purpose != 'BUSINESS' OR t.validation_root_task_id IS NOT NULL) + """) + long invalidFacts(@Param("account") String account, @Param("warehouse") String warehouse, + @Param("sourceTimezone") String sourceTimezone, @Param("businessTimezone") String businessTimezone, + @Param("unit") String unit); + + @Select(""" + + """) + List dailyBatch(@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku,@Param("date") LocalDate date); + + @Select(""" + + """) + List profileBatch(@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku); + + @Select(""" + + """) + List publishedProfileBatch(@Param("runId") Long runId,@Param("customer") String customer,@Param("sku") String sku); + + @Insert(""" + + """) + int insertDaily(@Param("rows") List rows); + + @Insert(""" + + """) + int insertProfiles(@Param("rows") List rows); + + @Select(""" + + """) + List coverageBatch(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash, + @Param("from") LocalDateTime from,@Param("id") Long id); + + @Select(""" + + """) + SelfopSyncRun contiguousCoverage(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash, + @Param("initial") LocalDateTime initial,@Param("discoveryFrom") LocalDateTime discoveryFrom, + @Param("discoveryUntil") LocalDateTime discoveryUntil); + + @Select(""" + SELECT COUNT(*) FROM ll_selfop_outbound_order WHERE source_account=#{account} AND warehouse_code=#{warehouse} + AND sales_eligible=1 AND updated_at >= #{buildStarted} + """) + long observationsChangedSince(@Param("account") String account,@Param("warehouse") String warehouse, + @Param("buildStarted") LocalDateTime buildStarted); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java new file mode 100644 index 000000000..cb3fd7b08 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.*; + +/** Orchestration has no encompassing transaction: checkpoints and heartbeats commit after every batch. */ +@Service +@RequiredArgsConstructor +public class OutboundCandidateBuilder { + private final OutboundCandidateTransaction transaction; + public void build(Long scopeId,Long runId,String owner,long fence) { + OutboundCandidateTransaction.Checkpoint checkpoint=transaction.begin(scopeId,runId,owner,fence); + while(!"FINISH".equals(checkpoint.phase()) && !"DONE".equals(checkpoint.phase()))checkpoint=transaction.batch(scopeId,runId,owner,fence); + if("FINISH".equals(checkpoint.phase()))transaction.finish(scopeId,runId,owner,fence); + } + public Long reusable(Long scopeId,Long runId,String owner,long fence) {return transaction.reusable(scopeId,runId,owner,fence);} + public static boolean regularBuildDue(SelfopSyncTask task,LocalDateTime previousBuild,LocalDateTime now) { + return "REBUILD".equals(task.getTaskType()) || "RECHECK".equals(task.getTaskType()) || task.getAttemptCount()!=null && task.getAttemptCount()>1 + || previousBuild==null || !now.isBefore(previousBuild.plusMinutes(30)); + } + public static Coverage contiguous(LocalDateTime initialFrom,List intervals) { + if(initialFrom==null)return new Coverage(null,null);LocalDateTime until=initialFrom; + for(Coverage interval:intervals.stream().filter(i->i.from()!=null && i.until()!=null && i.from().isBefore(i.until())) + .sorted(Comparator.comparing(Coverage::from)).toList()) { + if(interval.from().isAfter(until))break;if(interval.until().isAfter(until))until=interval.until(); + } + return until.equals(initialFrom)?new Coverage(null,null):new Coverage(initialFrom,until); + } + public record Coverage(LocalDateTime from,LocalDateTime until) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java new file mode 100644 index 000000000..7214b3d35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java @@ -0,0 +1,159 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; + +/** Each public method is one committed bounded transaction, invoked through Spring's proxy by the facade. */ +@Service +@RequiredArgsConstructor +public class OutboundCandidateTransaction { + private final OutboundLockMapper locks; + private final OutboundFactService facts; + private final SelfopSyncRunMapper runs; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncScopeMapper scopes; + private final SelfopSkuDailySalesMapper daily; + private final SelfopSkuProfileMapper profiles; + private final OutboundAggregateMapper aggregates; + private final SelfopAnalyticsProperties config; + + public record Checkpoint(Long runId,String phase,String customer,String sku,LocalDate date,long dailyRows,long profileRows,long committedBatches) { } + private record Held(SelfopSyncScope scope,SelfopSyncRun run,SelfopSyncTask task) { } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public Checkpoint begin(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,false); + SelfopSyncRun run=held.run(); SelfopSyncScope scope=held.scope(); + SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(run.getStatus()),"CANDIDATE_RUN_INVALID"); + SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED"); + facts.assertNoBarrier(scope); validateFacts(scope); + if("READY".equals(run.getStatus())) return new Checkpoint(runId,"DONE",null,null,null,0,0,0); + Checkpoint existing=checkpoint(held.task()); + if(run.getBuildStartedAtUtc()!=null && existing!=null && Objects.equals(existing.runId(),runId)) { + SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED"); + SelfopSupport.require(aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"CANDIDATE_OBSERVATION_CHANGED"); + return existing; + } + SelfopSupport.require(run.getBuildStartedAtUtc()==null,"CANDIDATE_CHECKPOINT_MISSING"); + // A new attempt has a new run ID; failed attempts are never overwritten or emptied. + daily.delete(new QueryWrapper().eq("sales_run_id",runId)); + profiles.delete(new QueryWrapper().eq("profile_run_id",runId)); + OutboundCandidateBuilder.Coverage coverage=coverage(scope); + run.setBasePublishedRunId(scope.getPublishedRunId()); + run.setCoveredFromUtc(coverage.from());run.setCoveredUntilUtc(coverage.until()); + OutboundPolicy.DayCoverage days=OutboundPolicy.completeDays(coverage.from(),coverage.until(),scope.getBusinessTimezone()); + run.setCompleteDayFrom(days.from());run.setCompleteDayUntil(days.until());run.setCoverageStatus(coverage.until()==null?"UNKNOWN":"COMPLETE"); + run.setCalculatedFactRevision(scope.getFactRevision());run.setBuildStartedAtUtc(SelfopSupport.utcNow());run.setStatus("BUILDING"); + Checkpoint result=new Checkpoint(runId,"DAILY",null,null,null,0,0,0);save(held,result);return result; + } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public Checkpoint batch(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,true); Checkpoint cursor=checkpoint(held.task()); + SelfopSupport.require(cursor!=null && Objects.equals(runId,cursor.runId()),"CANDIDATE_CHECKPOINT_MISSING"); + Checkpoint next; + if("DAILY".equals(cursor.phase())) { + List rows=aggregates.dailyBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku(),cursor.date()); + SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT"); + if(rows.isEmpty()) next=new Checkpoint(runId,"PROFILE",null,null,null,cursor.dailyRows(),0,cursor.committedBatches()); + else { + for(var row:rows){row.setId(IdWorker.getId());row.setSalesRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());} + aggregates.insertDaily(rows);var last=rows.get(rows.size()-1); + SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()) || !Objects.equals(cursor.date(),last.getBusinessDate()),"CANDIDATE_CURSOR_STALLED"); + next=new Checkpoint(runId,"DAILY",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate(),cursor.dailyRows()+rows.size(),0,cursor.committedBatches()+1); + } + } else if("PROFILE".equals(cursor.phase())) { + List rows=aggregates.profileBatch(held.scope().getSourceAccount(),held.scope().getWarehouseCode(),cursor.customer(),cursor.sku()); + SelfopSupport.require(rows.size()<=500,"CANDIDATE_BATCH_LIMIT"); + if(rows.isEmpty()) next=new Checkpoint(runId,"FINISH",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches()); + else { + for(var row:rows){row.setId(IdWorker.getId());row.setProfileRunId(runId);row.setSourceAccount(held.scope().getSourceAccount());row.setWarehouseCode(held.scope().getWarehouseCode());row.setEvidenceType("VALID_OUTBOUND");row.setCreatedAt(SelfopSupport.utcNow());row.setUpdatedAt(row.getCreatedAt());} + aggregates.insertProfiles(rows);var last=rows.get(rows.size()-1); + SelfopSupport.require(!Objects.equals(cursor.customer(),last.getCustomerCode()) || !Objects.equals(cursor.sku(),last.getSkuCode()),"CANDIDATE_CURSOR_STALLED"); + next=new Checkpoint(runId,"PROFILE",last.getCustomerCode(),last.getSkuCode(),null,cursor.dailyRows(),cursor.profileRows()+rows.size(),cursor.committedBatches()+1); + } + } else {SelfopSupport.require("FINISH".equals(cursor.phase()),"CANDIDATE_PHASE_INVALID");return cursor;} + save(held,next);return next; + } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public void finish(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,true);Checkpoint cursor=checkpoint(held.task()); + SelfopSupport.require(cursor!=null && Objects.equals(cursor.runId(),runId) && "FINISH".equals(cursor.phase()),"CANDIDATE_NOT_COMPLETE"); + SelfopSupport.require(daily.selectCount(new QueryWrapper().eq("sales_run_id",runId))==cursor.dailyRows() + && profiles.selectCount(new QueryWrapper().eq("profile_run_id",runId))==cursor.profileRows(),"CANDIDATE_ROWS_INCOMPLETE"); + facts.assertNoBarrier(held.scope());held.run().setStatus("READY");save(held,new Checkpoint(runId,"DONE",null,null,null,cursor.dailyRows(),cursor.profileRows(),cursor.committedBatches())); + } + + @Transactional(isolation=Isolation.REPEATABLE_READ,rollbackFor=Exception.class) + public Long reusable(Long scopeId,Long runId,String owner,long fence) { + Held held=held(scopeId,runId,owner,fence,false);SelfopSyncScope scope=held.scope(); + SelfopSupport.require(!Set.of("PUBLISHED","REUSED","FAILED","DEAD").contains(held.run().getStatus()),"CANDIDATE_RUN_INVALID"); + SelfopSupport.require("APPLIED".equals(held.task().getApplyState()) || "REBUILD".equals(held.task().getTaskType()),"TASK_NOT_APPLIED"); + facts.assertNoBarrier(scope);validateFacts(scope); + if(scope.getPublishedRunId()==null)return null; + SelfopSyncRun published=runs.selectById(scope.getPublishedRunId()); + if(published==null || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose()) + || published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash()) + || !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision()) + || !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null; + OutboundCandidateBuilder.Coverage next=coverage(scope); + if(!Objects.equals(next.from(),published.getCoveredFromUtc()) || !Objects.equals(next.until(),published.getCoveredUntilUtc()))return null; + // Content-identical accepted details still change receipt/run evidence; conservatively rebuild them. + return aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),published.getBuildStartedAtUtc())==0?published.getId():null; + } + + private Held held(Long scopeId,Long runId,String owner,long fence,boolean building) { + SelfopSyncScope scope=locks.scope(scopeId);LocalDateTime now=SelfopSupport.utcNow(); + SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()) && Objects.equals(scope.getLeaseOwner(),owner) && Objects.equals(scope.getFenceToken(),fence) + && scope.getLeaseUntilUtc()!=null && scope.getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED"); + SelfopSyncRun run=locks.run(runId);SelfopCollectionPolicy.requireBusiness(run); + SelfopSyncTask task=locks.task(run.getTaskId());SelfopCollectionPolicy.requireBusiness(task); + SelfopSupport.require(Objects.equals(scopeId,run.getScopeId()) && Objects.equals(run.getFenceToken(),fence) && Objects.equals(scope.getScopeHash(),run.getScopeHash()) + && "WAREHOUSE_SYNC".equals(run.getRunRole()) && Objects.equals(scopeId,task.getScopeId()) && Objects.equals(scope.getSourceAccount(),task.getSourceAccount()) + && Objects.equals(scope.getWarehouseCode(),task.getWarehouseCode()) && Objects.equals(scope.getScopeHash(),task.getScopeHash()),"CANDIDATE_RUN_INVALID"); + SelfopSupport.require(run.getStatus()!=null,"CANDIDATE_RUN_INVALID"); + if(building) { + SelfopSupport.require("BUILDING".equals(run.getStatus()) && Objects.equals(scope.getFactRevision(),run.getCalculatedFactRevision()),"CANDIDATE_FACT_REVISION_CHANGED"); + SelfopSupport.require(run.getBuildStartedAtUtc()!=null && aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0, + "CANDIDATE_OBSERVATION_CHANGED"); + } + scope.setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));scope.setUpdatedAt(now);scopes.updateById(scope); + return new Held(scope,run,task); + } + private void validateFacts(SelfopSyncScope scope) { + Map settings=SelfopSupport.read(scope.getScopeJson(),Map.class);String unit=settings.get("quantityUnit") instanceof String s?s:null; + SelfopSupport.require(scope.getSourceTimezone()!=null && !scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED"); + SelfopSupport.require(unit!=null && !unit.isBlank(),"QUANTITY_UNIT_UNCONFIRMED"); + SelfopSupport.require(aggregates.invalidFacts(scope.getSourceAccount(),scope.getWarehouseCode(),scope.getSourceTimezone(),scope.getBusinessTimezone(),unit)==0,"FACT_TIME_UNIT_OR_EVIDENCE_INVALID"); + } + private OutboundCandidateBuilder.Coverage coverage(SelfopSyncScope scope) { + if(scope.getInitialFromUtc()==null)return new OutboundCandidateBuilder.Coverage(null,null); + SelfopSyncRun result=aggregates.contiguousCoverage(scope.getId(),scope.getScopeHash(),scope.getInitialFromUtc(),scope.getDiscoveryFromUtc(),scope.getDiscoveryUntilUtc()); + return result==null?new OutboundCandidateBuilder.Coverage(null,null):new OutboundCandidateBuilder.Coverage(result.getCoveredFromUtc(),result.getCoveredUntilUtc()); + } + private Checkpoint checkpoint(SelfopSyncTask task) { + if(task.getProgressJson()==null)return null;Map progress=SelfopSupport.read(task.getProgressJson(),Map.class);Object value=progress.get("candidateBuild"); + return value==null?null:SelfopSupport.read(SelfopSupport.json(value),Checkpoint.class); + } + private void save(Held held,Checkpoint checkpoint) { + LocalDateTime now=SelfopSupport.utcNow(); + SelfopSupport.require(held.scope().getLeaseUntilUtc().isAfter(now),"LEASE_FENCE_REJECTED"); + held.scope().setLeaseUntilUtc(now.plusSeconds(Math.max(10,config.getLeaseSeconds())));held.scope().setUpdatedAt(now);scopes.updateById(held.scope()); + Map progress=new LinkedHashMap<>(); + if(held.task().getProgressJson()!=null)SelfopSupport.read(held.task().getProgressJson(),Map.class).forEach((k,v)->progress.put(String.valueOf(k),v)); + if(!(progress.get("attemptNo") instanceof Number attempt) || !Objects.equals(attempt.intValue(),held.run().getAttemptNo()))progress.clear(); + progress.put("attemptNo",held.run().getAttemptNo());progress.put("candidateBuild",checkpoint);progress.remove("processedCount"); + held.task().setProgressJson(SelfopSupport.json(progress)); + SelfopProgressProjection.applyCheckpoint(held.task(),held.run(),"BUILD","BATCH",checkpoint.committedBatches(),null,0); + tasks.updateById(held.task());runs.updateById(held.run()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java new file mode 100644 index 000000000..82f6c33f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java @@ -0,0 +1,245 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; + +/** Short bounded transactions; all source requests must finish before entering this service. */ +@Service +@RequiredArgsConstructor +public class OutboundFactService { + private final OutboundLockMapper locks; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopSyncScopeMapper scopes; + private final SelfopSyncStageMapper stages; + private final SelfopOutboundOrderMapper orders; + private final SelfopOutboundItemMapper items; + + public record Lease(String owner, long fence) {} + + @Transactional(rollbackFor = Exception.class) + public void merge(Long taskId, Long runId, List stageIds, Map leases) { + SelfopSupport.require(stageIds != null && stageIds.size() <= 100, "MERGE_BATCH_LIMIT"); + SelfopSyncTask preview = tasks.selectById(taskId); + SelfopSupport.require(preview != null, "TASK_MISSING"); + SelfopCollectionPolicy.requireBusiness(preview); + // Discover the entire old/new lock set before touching facts. Recheck after order locks. + SortedSet warehouses = affected(preview); + warehouses.add(preview.getWarehouseCode()); + // Coordinator acquired this full old/new set from the entire stable routing manifest, + // not only this bounded segment. Register it before the first fact mutation. + warehouses.addAll(leases.keySet()); + for (Long id : stageIds) { + SelfopSyncStage s = stages.selectById(id); + SelfopSupport.require(s != null, "STAGE_MISSING"); + SelfopPayloads.Order p = SelfopSupport.read(s.getPayloadJson(),SelfopPayloads.Order.class); + warehouses.add(p.warehouseCode()); + SelfopOutboundOrder old = findOrder(p); + if (old != null) warehouses.add(old.getWarehouseCode()); + } + Map held = lockScopes(preview.getSourceAccount(),warehouses,leases); + SelfopSyncTask task = locks.task(taskId); + SelfopSyncRun run = locks.run(runId); + validateTaskRun(task,run,held); + SelfopRoutingManifest manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + SelfopSupport.require(!"APPLIED".equals(task.getApplyState()), "TASK_ALREADY_APPLIED"); + task.setAffectedWarehouseCodesJson(SelfopSupport.json(warehouses)); + task.setApplyState("APPLYING"); + if (task.getApplyStartedAtUtc() == null) task.setApplyStartedAtUtc(SelfopSupport.utcNow()); + task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task); + for (Long id : stageIds) { + SelfopSyncStage stage=locks.stage(id); applyOne(task,run,stage,held,manifest); + manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId()); + } + task.setRoutingManifestJson(SelfopSupport.json(manifest)); + mergeProgress(task,run,manifest);tasks.updateById(task); + run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision()); + run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run); + } + + /** The caller supplies the stable complete routing set, including refreshed effective references. */ + @Transactional(rollbackFor = Exception.class) + public void finish(Long taskId, Long runId, List allEffectiveStageIds, Map leases) { + SelfopSyncTask preview = tasks.selectById(taskId); + SelfopSupport.require(preview != null,"TASK_MISSING"); + SelfopCollectionPolicy.requireBusiness(preview); + SortedSet warehouseSet = affected(preview); warehouseSet.add(preview.getWarehouseCode()); + Map held = lockScopes(preview.getSourceAccount(),warehouseSet,leases); + SelfopSyncTask task = locks.task(taskId); SelfopSyncRun run = locks.run(runId); + validateTaskRun(task,run,held); + SelfopSupport.require(allEffectiveStageIds != null && new HashSet<>(allEffectiveStageIds).size() == allEffectiveStageIds.size(),"ROUTING_SET_INVALID"); + SelfopRoutingManifest manifest = SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + SelfopSupport.require(manifest.isDirectoryComplete(),"CATALOG_NOT_COMPLETE"); + Set required = new HashSet<>(); + for(SelfopRoutingManifest.Entry entry : manifest.getObjects().values()) { + SelfopSupport.require(entry.getEffectiveStageId() != null,"DETAIL_RECHECK_REQUIRED"); + required.add(entry.getEffectiveStageId()); + } + SelfopSupport.require(required.equals(new HashSet<>(allEffectiveStageIds)),"ROUTING_SET_INCOMPLETE"); + for (Long id : allEffectiveStageIds) { + SelfopSyncStage stage = locks.stage(id); + SelfopPayloads.Order p = validateStage(task, stage,manifest); + SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo()); + SelfopSupport.require(order != null && Objects.equals(order.getLastObservationSequence(),stage.getObservationSequence()) + && Objects.equals(order.getContentHash(),factHash(p,held.get(p.warehouseCode()))),"OBJECT_NOT_APPLIED"); + manifest.getObjects().get(stage.getObjectKeyHash()).setAppliedStageId(stage.getId()); + } + task.setApplyState("APPLIED"); task.setApplyCompletedAtUtc(SelfopSupport.utcNow()); + task.setRoutingManifestJson(SelfopSupport.json(manifest));mergeProgress(task,run,manifest); + task.setUpdatedAt(SelfopSupport.utcNow()); tasks.updateById(task); + run.setStatus("FACTS_APPLIED"); + run.setAppliedFactRevision(held.get(task.getWarehouseCode()).getFactRevision()); + run.setUpdatedAt(SelfopSupport.utcNow()); runs.updateById(run); + } + + public void assertNoBarrier(SelfopSyncScope scope) { + SelfopSupport.require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode()).isEmpty(),"HALF_APPLIED_FACTS"); + } + + private void applyOne(SelfopSyncTask task, SelfopSyncRun run, SelfopSyncStage stage, Map held,SelfopRoutingManifest manifest) { + SelfopPayloads.Order p = validateStage(task,stage,manifest); + SelfopSyncScope target = held.get(p.warehouseCode()); + SelfopSupport.require(target != null,"LOCK_SET_CHANGED"); + LocalDateTime utc = OutboundPolicy.outboundUtc(p,target); + String unit = unit(target); + for(SelfopPayloads.Item item : p.items()) SelfopSupport.require(Objects.equals(unit,item.quantityUnit()),"QUANTITY_UNIT_UNCONFIRMED"); + SelfopOutboundOrder order = locks.order(p.sourceAccount(),p.customerCode(),p.deliveryNo()); + SelfopSupport.require(order == null || held.containsKey(order.getWarehouseCode()),"LOCK_SET_CHANGED"); + SelfopSupport.require(order == null || order.getLastObservationSequence() <= stage.getObservationSequence(),"DETAIL_RECHECK_REQUIRED"); + String hash = factHash(p,target); + if(order != null && Objects.equals(order.getContentHash(),hash)) { + order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence()); + order.setUpdatedAt(SelfopSupport.utcNow()); orders.updateById(order); return; + } + SelfopSupport.require(order == null || order.getLastObservationSequence() < stage.getObservationSequence(),"OBSERVATION_CONTENT_CONFLICT"); + String oldWarehouse = order == null ? null : order.getWarehouseCode(); + if(order != null && stage.getBeforePayloadJson() == null) { + Map before = new LinkedHashMap<>(); before.put("order",order); + before.put("items",items.selectList(new QueryWrapper().eq("order_id",order.getId()).orderByAsc("sku_code"))); + stages.update(null,new UpdateWrapper().eq("id",stage.getId()) + .isNull("before_payload_json").set("before_payload_json",SelfopSupport.json(before)).set("updated_at",SelfopSupport.utcNow())); + } + boolean fresh = order == null; + if(fresh) { order = new SelfopOutboundOrder(); order.setCreatedAt(SelfopSupport.utcNow()); order.setRevisionNo(1L); } + else order.setRevisionNo(order.getRevisionNo()+1); + order.setSourceAccount(p.sourceAccount()); order.setCustomerCode(p.customerCode()); order.setWarehouseCode(p.warehouseCode()); + order.setDeliveryNo(p.deliveryNo()); order.setSourceNo(p.sourceNo()); order.setSourceStatus(p.sourceStatus()); + order.setSourceCreatedTime(p.sourceCreatedTime()); order.setSourceOutboundTime(p.sourceOutboundTime()); + order.setSourceTimezone(target.getSourceTimezone()); order.setBusinessTimezone(target.getBusinessTimezone()); + order.setOutboundAtUtc(utc); order.setBusinessDate(utc == null ? null : utc.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of(target.getBusinessTimezone())).toLocalDate()); + order.setSalesEligible(Integer.valueOf(100).equals(p.sourceStatus()) ? 1 : 0); + order.setContentHash(hash); order.setLastRunId(run.getId()); order.setLastObservationSequence(stage.getObservationSequence()); + order.setUpdatedAt(SelfopSupport.utcNow()); + if(fresh) orders.insert(order); + else { + orders.updateById(order); + // Explicit nulls are meaningful for cancellation and corrections; MyBatis default ignores null entity fields. + orders.update(null,new UpdateWrapper().eq("id",order.getId()) + .set("outbound_at_utc",order.getOutboundAtUtc()).set("business_date",order.getBusinessDate()) + .set("source_no",order.getSourceNo()).set("source_created_time",order.getSourceCreatedTime()) + .set("source_outbound_time",order.getSourceOutboundTime())); + items.delete(new QueryWrapper().eq("order_id",order.getId())); + } + for (SelfopPayloads.Item value : p.items()) { + SelfopOutboundItem item = new SelfopOutboundItem(); item.setOrderId(order.getId()); item.setSkuCode(value.skuCode()); + item.setProductName(value.productName()); item.setActualQuantity(value.actualQuantity()); item.setQuantityUnit(value.quantityUnit()); + item.setCreatedAt(SelfopSupport.utcNow()); item.setUpdatedAt(item.getCreatedAt()); items.insert(item); + } + Set changed = new HashSet<>(); changed.add(p.warehouseCode()); if(oldWarehouse != null) changed.add(oldWarehouse); + for(String warehouse : changed) { + SelfopSyncScope scope = held.get(warehouse); scope.setFactRevision(scope.getFactRevision()+1); + scope.setRebuildRequired(1); scope.setInvalidatedAtUtc(SelfopSupport.utcNow()); scope.setUpdatedAt(SelfopSupport.utcNow()); scopes.updateById(scope); + if(oldWarehouse != null && !oldWarehouse.equals(p.warehouseCode())) rebuild(task,scope,order); + } + } + + private void rebuild(SelfopSyncTask upstream,SelfopSyncScope scope,SelfopOutboundOrder order) { + String key = "rebuild:"+upstream.getId()+":"+order.getId()+":"+order.getRevisionNo()+":"+scope.getWarehouseCode(); + if(tasks.selectCount(new QueryWrapper().eq("source_account",scope.getSourceAccount()).eq("request_key",key)) > 0) return; + SelfopSyncTask task = new SelfopSyncTask(); task.setSourceAccount(scope.getSourceAccount()); + task.setExecutionPurpose("BUSINESS"); task.setTaskNo("SO-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); + task.setTaskRole("WAREHOUSE_SYNC"); task.setScopeId(scope.getId()); task.setWarehouseCode(scope.getWarehouseCode()); + task.setRequestKey(key); task.setRequestHash(SelfopSupport.hash(List.of(key,scope.getScopeHash()))); + task.setTaskType("REBUILD"); task.setQueryScopeJson(scope.getScopeJson()); task.setScopeHash(scope.getScopeHash()); + task.setStatus("READY"); task.setApplyState("NOT_APPLICABLE"); task.setAttemptCount(0); + task.setCreatedAt(SelfopSupport.utcNow()); task.setUpdatedAt(task.getCreatedAt()); tasks.insert(task); + } + + private SelfopPayloads.Order validateStage(SelfopSyncTask task,SelfopSyncStage stage,SelfopRoutingManifest manifest) { + SelfopSupport.require(stage != null && "VALID".equals(stage.getValidationStatus()) + && "OUTBOUND_ORDER".equals(stage.getObjectType()) && stage.getObservationSequence() != null,"DETAIL_NOT_VALID"); + SelfopSyncRun sourceRun=runs.selectById(stage.getRunId()); + SelfopCollectionPolicy.requireBusiness(sourceRun); + SelfopSyncTask sourceTask=tasks.selectById(sourceRun.getTaskId()); + SelfopCollectionPolicy.requireBusiness(sourceTask); + SelfopSupport.require(Objects.equals(task.getSourceAccount(),sourceRun.getSourceAccount()) + && Objects.equals(task.getSourceAccount(),sourceTask.getSourceAccount()),"STAGE_IDENTITY_CONFLICT"); + SelfopPayloads.Order raw = SelfopSupport.read(stage.getPayloadJson(),SelfopPayloads.Order.class); + SelfopSupport.require(Objects.equals(stage.getContentHash(),SelfopSupport.hash(raw)),"STAGE_CONTENT_CONFLICT"); + SelfopPayloads.Order p = OutboundPolicy.normalize(raw); + SelfopRoutingManifest.Entry reference = manifest.getObjects().get(stage.getObjectKeyHash()); + SelfopSupport.require(reference != null && Objects.equals(reference.getEffectiveStageId(),stage.getId()),"STAGE_NOT_ROUTED"); + if(reference.getRefreshTaskId() != null) { + SelfopSyncTask refresh = tasks.selectById(reference.getRefreshTaskId()); + SelfopSupport.require(refresh != null && "SUCCEEDED".equals(refresh.getStatus()) + && Objects.equals(stage.getStageBatchId(),reference.getRefreshTaskId()),"DETAIL_RECHECK_REQUIRED"); + } else SelfopSupport.require(Objects.equals(stage.getStageBatchId(),task.getUpstreamTaskId()),"STAGE_BATCH_CONFLICT"); + SelfopSupport.require(Objects.equals(task.getSourceAccount(),p.sourceAccount()) && Objects.equals(stage.getWarehouseCode(),p.warehouseCode()),"STAGE_IDENTITY_CONFLICT"); + SelfopSupport.require(Objects.equals(stage.getObjectKeyHash(),SelfopSupport.hash(List.of(p.sourceAccount(),p.customerCode(),p.deliveryNo()))),"STAGE_KEY_CONFLICT"); + SelfopSupport.require(stage.getObservationSequence() >= locks.newestObservation(p.sourceAccount(),stage.getObjectKeyHash()),"DETAIL_RECHECK_REQUIRED"); + return p; + } + private void mergeProgress(SelfopSyncTask task,SelfopSyncRun run,SelfopRoutingManifest manifest) { + Set required=new HashSet<>(),applied=new HashSet<>(); + boolean totalKnown=manifest.isDirectoryComplete(); + for(SelfopRoutingManifest.Entry entry:manifest.getObjects().values()) { + if(entry.getEffectiveStageId()==null)totalKnown=false; + if(entry.getEffectiveStageId()!=null)required.add(entry.getEffectiveStageId()); + if(entry.getEffectiveStageId()!=null && Objects.equals(entry.getEffectiveStageId(),entry.getAppliedStageId()))applied.add(entry.getAppliedStageId()); + } + SelfopProgressProjection.applyCheckpoint(task,run,"MERGE","ORDER",applied.size(),totalKnown?(long)required.size():null,0); + } + private String factHash(SelfopPayloads.Order p,SelfopSyncScope scope) { + SelfopSupport.require(scope != null,"LOCK_SET_CHANGED"); + return SelfopSupport.hash(List.of(p,scope.getBusinessTimezone(),scope.getSourceTimezone(),unit(scope))); + } + private String unit(SelfopSyncScope scope) { + Map json = SelfopSupport.read(scope.getScopeJson(),Map.class); Object value = json.get("quantityUnit"); + SelfopSupport.require(value instanceof String && !((String)value).isBlank(),"QUANTITY_UNIT_UNCONFIRMED"); + return (String)value; + } + private SelfopOutboundOrder findOrder(SelfopPayloads.Order p) { + return orders.selectOne(new QueryWrapper().eq("source_account",p.sourceAccount()) + .eq("customer_code",p.customerCode()).eq("delivery_no",p.deliveryNo())); + } + private SortedSet affected(SelfopSyncTask task) { + SortedSet values = new TreeSet<>(); + if(task.getAffectedWarehouseCodesJson() != null) values.addAll(Arrays.asList(SelfopSupport.read(task.getAffectedWarehouseCodesJson(),String[].class))); + return values; + } + private Map lockScopes(String account,SortedSet warehouses,Map leases) { + Map held = new LinkedHashMap<>(); + for(String warehouse : warehouses) { + SelfopSyncScope scope = locks.warehouse(account,warehouse); Lease lease = leases.get(warehouse); + SelfopSupport.require(scope != null && lease != null && Objects.equals(scope.getLeaseOwner(),lease.owner()) + && Objects.equals(scope.getFenceToken(),lease.fence()) && scope.getLeaseUntilUtc() != null + && scope.getLeaseUntilUtc().isAfter(SelfopSupport.utcNow()),"LEASE_FENCE_REJECTED"); + held.put(warehouse,scope); + } + return held; + } + private void validateTaskRun(SelfopSyncTask task,SelfopSyncRun run,Map held) { + SelfopCollectionPolicy.requireBusiness(task); SelfopCollectionPolicy.requireBusiness(run); + SelfopSyncScope own = held.get(task.getWarehouseCode()); + SelfopSupport.require(run != null && Objects.equals(run.getTaskId(),task.getId()) && Objects.equals(run.getScopeId(),task.getScopeId()) + && Objects.equals(run.getScopeHash(),own.getScopeHash()) && Objects.equals(task.getScopeHash(),own.getScopeHash()) + && Objects.equals(run.getFenceToken(),own.getFenceToken()) && !"PUBLISHED".equals(run.getStatus()),"RUN_SCOPE_FENCE_CONFLICT"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java new file mode 100644 index 000000000..6df9490fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface OutboundLockMapper { + @Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE") + SelfopSyncScope scope(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} AND data_family='OUTBOUND' FOR UPDATE") + SelfopSyncScope warehouse(@Param("account") String account,@Param("warehouse") String warehouse); + @Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE") + SelfopSyncTask task(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") + SelfopSyncRun run(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_stage WHERE id=#{id} FOR UPDATE") + SelfopSyncStage stage(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_outbound_order WHERE source_account=#{account} AND customer_code=#{customer} AND delivery_no=#{delivery} FOR UPDATE") + SelfopOutboundOrder order(@Param("account") String account,@Param("customer") String customer,@Param("delivery") String delivery); + @Select("SELECT COALESCE(MAX(observation_sequence),0) FROM ll_selfop_sync_stage s JOIN ll_selfop_sync_run r ON r.id=s.run_id JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE r.source_account=#{account} AND r.execution_purpose='BUSINESS' AND t.execution_purpose='BUSINESS' AND s.object_type='OUTBOUND_ORDER' AND s.object_key_hash=#{key} AND s.validation_status='VALID'") + Long newestObservation(@Param("account") String account,@Param("key") String key); + @Select("SELECT * FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") + List barriers(@Param("account") String account,@Param("warehouse") String warehouse); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java new file mode 100644 index 000000000..a823c3e4c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import java.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** Source strings are interpreted only after explicit source timezone and unit confirmation. */ +public final class OutboundPolicy { + private OutboundPolicy() {} + private static final DateTimeFormatter SOURCE = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss") + .withResolverStyle(java.time.format.ResolverStyle.STRICT); + + public static SelfopPayloads.Order normalize(SelfopPayloads.Order p) { + text(p.sourceAccount(), 64); text(p.customerCode(), 64); text(p.warehouseCode(), 64); + text(p.deliveryNo(), 128); + optional(p.sourceNo(),128); optional(p.sourceCreatedTime(),64); optional(p.sourceOutboundTime(),64); + SelfopSupport.require(p.sourceStatus() != null, "SOURCE_STATUS_MISSING"); + SelfopSupport.require(p.items() != null, "DETAIL_ITEMS_MISSING"); + SelfopSupport.require(!Integer.valueOf(100).equals(p.sourceStatus()) || !p.items().isEmpty(), "OUTBOUND_ITEMS_EMPTY"); + String quantityUnit = p.items().isEmpty() ? null : p.items().get(0).quantityUnit(); + TreeMap result = new TreeMap<>(); + for (SelfopPayloads.Item item : p.items()) { + text(item.skuCode(),128); optional(item.productName(),255); + SelfopSupport.require(Objects.equals(quantityUnit, item.quantityUnit()) && item.quantityUnit() != null && !item.quantityUnit().isBlank(), "QUANTITY_UNIT_CONFLICT"); + BigDecimal value = item.actualQuantity(); + SelfopSupport.require(value != null && value.signum() >= 0 && value.scale() <= 6 + && value.precision() - value.scale() <= 14, "INVALID_ACTUAL_QUANTITY"); + SelfopPayloads.Item old = result.get(item.skuCode()); + BigDecimal sum = old == null ? value : old.actualQuantity().add(value); + SelfopSupport.require(sum.precision() - sum.scale() <= 14, "QUANTITY_OVERFLOW"); + result.put(item.skuCode(), new SelfopPayloads.Item(item.skuCode(), + old != null && old.productName() != null ? old.productName() : item.productName(), + sum.stripTrailingZeros(), quantityUnit)); + } + return new SelfopPayloads.Order(p.sourceAccount(),p.customerCode(),p.warehouseCode(),p.deliveryNo(), + p.sourceNo(),p.sourceStatus(),p.sourceCreatedTime(),p.sourceOutboundTime(), List.copyOf(result.values())); + } + + public static LocalDateTime outboundUtc(SelfopPayloads.Order p, SelfopSyncScope scope) { + SelfopSupport.require(scope.getSourceTimezone() != null && !scope.getSourceTimezone().isBlank(), + "SOURCE_TIMEZONE_UNCONFIRMED"); + if (!Integer.valueOf(100).equals(p.sourceStatus())) return null; + text(p.sourceOutboundTime(),64); + LocalDateTime local = LocalDateTime.parse(p.sourceOutboundTime(),SOURCE); + ZoneId zone = ZoneId.of(scope.getSourceTimezone()); + List offsets = zone.getRules().getValidOffsets(local); + SelfopSupport.require(offsets.size() == 1, "SOURCE_TIME_DST_AMBIGUOUS_OR_GAP"); + return LocalDateTime.ofInstant(local.toInstant(offsets.get(0)),ZoneOffset.UTC); + } + + /** Source API includes both endpoint seconds; new facts belong to exactly one internal slice. + * Unknown source timezone retains evidence only; caller must keep formal readiness closed. + * Known facts and explicit rechecks retain revisions even when corrected outside the old slice. + */ + public static boolean belongsToSlice(SelfopPayloads.Order p, SelfopSyncScope scope, + LocalDateTime from, LocalDateTime until, + boolean knownOrder, String taskType) { + if(knownOrder || "RECHECK".equals(taskType) || !Integer.valueOf(100).equals(p.sourceStatus())) return true; + if(scope.getSourceTimezone() == null || scope.getSourceTimezone().isBlank()) return true; + if(from == null || until == null) return true; + SelfopSupport.require(from.isBefore(until),"OUTBOUND_SLICE_INVALID"); + LocalDateTime observed = outboundUtc(p,scope); + return !observed.isBefore(from) && observed.isBefore(until); + } + + public static DayCoverage completeDays(LocalDateTime from, LocalDateTime until, String timezone) { + if (from == null || until == null || !from.isBefore(until)) return new DayCoverage(null,null); + ZoneId zone = ZoneId.of(timezone); + ZonedDateTime start = from.atZone(ZoneOffset.UTC).withZoneSameInstant(zone); + ZonedDateTime end = until.atZone(ZoneOffset.UTC).withZoneSameInstant(zone); + LocalDate first = start.toLocalDate(); + if (!start.toInstant().equals(first.atStartOfDay(zone).toInstant())) first = first.plusDays(1); + LocalDate last = end.toLocalDate(); + return first.isAfter(last) ? new DayCoverage(null,null) : new DayCoverage(first,last); + } + public record DayCoverage(LocalDate from, LocalDate until) {} + private static void optional(String value,int max) { + SelfopSupport.require(value == null || value.length() <= max,"IDENTITY_TOO_LONG"); + } + private static void text(String value,int max) { + SelfopSupport.require(value != null && !value.isBlank() && value.length() <= max,"IDENTITY_OR_UNIT_MISSING"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java new file mode 100644 index 000000000..b37960c7e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import java.util.Set; + +/** Endpoint-specific inventory presentation, separate from immutable version/date identity. */ +public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold) { + public String attentionValue(){return attention==null?"ALL":attention;} + public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;} + public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";} + public void validate(String endpoint,AnalyticsQuery q){ + if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数"); + if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效"); + if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_30D_ASC").contains(sortValue()))throw new BizException("库存排序无效"); + if("COVERAGE_GT_THRESHOLD".equals(attentionValue())&&coverageDaysThreshold==null)throw new BizException("超过可售天数筛选必须指定阈值"); + if("OWNERS".equals(endpoint)){ + if(keyword!=null&&(keyword.isBlank()||keyword.length()>64))throw new BizException("货主编码前缀不能为空或超长"); + if(q.customerCode()!=null||q.sku()!=null||attention!=null||sortBy!=null||coverageDaysThreshold!=null)throw new BizException("货主选项仅接受编码前缀与分页"); + }else{ + if(keyword!=null)throw new BizException("此接口不接受货主前缀"); + if("OVERVIEW".equals(endpoint)){ + AnalyticsQuery.text(q.customerCode(),64,"货主"); + if(q.sku()!=null||q.current()!=null||q.size()!=null||attention!=null||sortBy!=null)throw new BizException("货主概览不接受SKU、关注、排序或分页条件"); + } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java new file mode 100644 index 000000000..e1628dd88 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.util.Map; + +/** Immutable, one-scope SQL. Qualities and days aggregate independently before joining SKU keys. */ +public final class AnalyticsInventorySql { + private AnalyticsInventorySql(){} + private static String scope(AnalyticsSqlRequest p){ + return " source_account=#{p.account} AND warehouse_code=#{p.warehouse}" + +(p.customer()==null?"":" AND customer_code=#{p.customer}")+(p.sku()==null?"":" AND sku_code=#{p.sku}"); + } + private static String keys(AnalyticsSqlRequest p){return "SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+scope(p) + +" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+scope(p);} + private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} + private static String quantity(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} + private static String cte(AnalyticsSqlRequest p){ + return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," + +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," + +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," + +"CASE WHEN (s.sku_code IS NULL OR s.quantity_unit='PCS') AND (i.sku_code IS NULL OR i.quantity_unit='PCS') THEN 1 ELSE 0 END unit_valid," + +"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence," + +"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence " + +"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code " + +"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code)," + +" stock AS (SELECT customer_code,sku_code,"+quantity("physical_quantity")+" physical_quantity,"+quantity("frozen_quantity")+" frozen_quantity," + +quantity("transit_quantity")+" transit_quantity,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available_quantity," + +"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 " + +"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective_physical_quantity,MAX(observed_at_utc) observed_at_utc " + +"FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code)," + +" sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_quantity,SUM(outbound_order_count) order_count," + +"MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales " + +"WHERE sales_run_id=#{p.salesRun} AND"+scope(p)+" AND business_date>=#{p.from} AND business_date<#{p.until} GROUP BY customer_code,sku_code)," + +" last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+scope(p) + +" AND business_date<=#{p.anchor} AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)," + +" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid," + +"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc," + +stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc," + +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.complete30}=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound," + +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count,l.last_outbound " + +"FROM profiles c LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code " + +"LEFT JOIN last_sales l ON l.customer_code=c.customer_code AND l.sku_code=c.sku_code)," + +" qualified AS (SELECT v.*,CASE WHEN evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN 1 ELSE 0 END observation_eligible," + +"CASE WHEN unit_valid=1 AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN owner_outbound END outbound_quantity30d FROM values_by_sku v)," + +" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*30/outbound_quantity30d END coverage_days," + +"CASE WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d=0 THEN 1 ELSE 0 END END no_outbound," + +"CASE WHEN #{o.coverageDaysThreshold} IS NULL THEN NULL WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND available_quantity=0 THEN 0 " + +"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*30>outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold " + +"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) "; + } + private static String stockValue(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END "+field;} + private static String filter(AnalyticsInventoryOptions o){return switch(o.attentionValue()){ + case "NO_OUTBOUND_30D" -> " WHERE no_outbound=1"; + case "COVERAGE_GT_THRESHOLD" -> " WHERE above_threshold=1"; + case "FROZEN_STOCK" -> " WHERE frozen_quantity>0"; + case "DEFECTIVE_STOCK" -> " WHERE defective_physical_quantity>0"; + case "UNASSESSABLE" -> " WHERE unassessable=1"; + default -> "";};} + private static String order(AnalyticsInventoryOptions o){String field=switch(o.sortValue()){ + case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_30D_ASC" -> "outbound_quantity30d";default -> "available_quantity";}; + return field+" IS NULL,"+field+("OUTBOUND_30D_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";} + public static String inventoryPage(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); + return typed(cte(p)+"SELECT customer_code,sku_code,product_name,unit_valid,evidence_at_utc,physical_quantity,available_quantity,frozen_quantity,transit_quantity,defective_physical_quantity,observed_at_utc,owner_outbound,observed_outbound,order_count,last_outbound,observation_eligible,outbound_quantity30d,coverage_days,no_outbound,above_threshold,unassessable FROM result"+filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}");} + public static String inventoryCount(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); + return typed("ALL".equals(o.attentionValue())?"SELECT COUNT(*) FROM ("+keys(p)+") k":cte(p)+"SELECT COUNT(*) FROM result"+filter(o));} + public static String ownerTotals(Map args){var p=(AnalyticsSqlRequest)args.get("p");StringBuilder sql=new StringBuilder(cte(p)).append("SELECT COUNT(*) sku_count"); + for(String field:java.util.List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound")) + sql.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); + sql.append(",SUM(CASE WHEN physical_quantity>0 THEN 1 WHEN physical_quantity=0 THEN 0 END) physical_sku_known,SUM(observed_outbound) observed_outbound"); + for(String risk:java.util.List.of("no_outbound","above_threshold"))sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical_quantity ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown,SUM(CASE WHEN ").append(risk).append(" IS NULL THEN physical_quantity END) ").append(risk).append("_unknown_physical"); + return typed(sql+" FROM result");} + private static String ownersBase(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){return "SELECT DISTINCT customer_code FROM ("+keys(p)+") k"+(o.keyword()==null?"":" WHERE customer_code LIKE #{o.keywordPattern} ESCAPE '!'");} + public static String owners(Map args){return typed(ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+" ORDER BY customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}");} + public static String ownerCount(Map args){return typed("SELECT COUNT(*) FROM ("+ownersBase((AnalyticsSqlRequest)args.get("p"),(AnalyticsInventoryOptions)args.get("o"))+") owners");} + private static String typed(String sql){for(String key:java.util.List.of("from","until","anchor"))sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)");return sql.replace("#{o.coverageDaysThreshold}","CAST(#{o.coverageDaysThreshold} AS DECIMAL(20,0))");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java new file mode 100644 index 000000000..31d40d586 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import java.time.Instant; +import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +/** Canonical constructor only: this record is also Spring's HTTP query binding contract. */ +public record AnalyticsQuery(String sourceAccount, String warehouseCode, String customerCode, String sku, + String rangePreset, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate startDate, + @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, String bucket, Integer windowDays, + Integer current, Integer size, Long salesRunId, Long inventoryRunId, String salesScopeHash, + String inventoryScopeHash, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE_TIME) Instant queryAsOf) { + public int pageNumber(){return current==null?1:current;} + public int pageSize(){return size==null?20:size;} + public int rankingDays(){return windowDays==null?30:windowDays;} + public boolean pinned(){return salesRunId!=null;} + public long offset(){return (long)(pageNumber()-1)*pageSize();} + public void validate(boolean chart, boolean exactSku, boolean hot) { + text(sourceAccount,64,"来源账号"); text(warehouseCode,64,"仓库"); + if(customerCode!=null)text(customerCode,64,"货主"); if(sku!=null)text(sku,128,"SKU"); + if(exactSku){text(customerCode,64,"货主");text(sku,128,"SKU");} + if(pageNumber()<1||pageSize()<1||pageSize()>100)throw new BizException("分页范围为current≥1,size为1至100"); + if((salesRunId==null)!=(inventoryRunId==null))throw new BizException("必须携带完整版本对"); + if(pinned()){ + if(salesRunId<0||inventoryRunId<0||queryAsOf==null)throw new BizException("固定版本必须携带queryAsOf"); + if(salesRunId>0&&!hash(salesScopeHash)||inventoryRunId>0&&!hash(inventoryScopeHash))throw new BizException("固定版本缺少范围摘要"); + }else if(queryAsOf!=null||salesScopeHash!=null||inventoryScopeHash!=null||pageNumber()!=1) + throw new BizException("后续查询必须携带首屏完整上下文"); + if(chart){ + if(windowDays!=null)throw new BizException("SKU周期查询不接受windowDays"); + if(bucket!=null&&!"DAY".equals(bucket))throw new BizException("仅支持DAY日粒度"); + }else{ + if(rangePreset!=null||startDate!=null||endDate!=null||bucket!=null)throw new BizException("指标不接受图表周期参数"); + if(hot?(rankingDays()!=7&&rankingDays()!=30):(windowDays!=null))throw new BizException("热销仅支持7/30天;其他指标固定30天"); + } + } + private static boolean hash(String v){return v!=null&&v.matches("[0-9a-f]{64}");} + static void text(String v,int max,String label){if(v==null||v.isBlank()||v.length()>max)throw new BizException(label+"必填且不能超长");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java new file mode 100644 index 000000000..abceab4fd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java @@ -0,0 +1,267 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.selfoperated.analytics.statistics.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*; + +@Service +@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) +public class AnalyticsQueryService { + private static final ZoneId BUSINESS_ZONE=ZoneId.of("Asia/Shanghai"); + private final AnalyticsReadMapper mapper; + private final ObjectMapper json; + private final Clock clock; + @Value("${baiyue.self-operated-analytics.max-query-days:366}") private int maxQueryDays=366; + @Value("${baiyue.self-operated-analytics.sales-retention-days:7}") private int retentionDays=7; + @Autowired public AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json){this(mapper,json,Clock.systemUTC());} + AnalyticsQueryService(AnalyticsReadMapper mapper,ObjectMapper json,Clock clock){this.mapper=mapper;this.json=json;this.clock=clock;} + private record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted, + LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){} + + public AnalyticsResponses.Page salesSummary(AnalyticsQuery q){ + Snapshot s=context(q,true,false,false); AnalyticsSqlRequest p=request(s,"SUMMARY"); + long count=mapper.count(p); + var records=mapper.page(p).stream().map(r->summary(s,r)).toList(); + return page(s,records,count,Map.of()); + } + public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){ + Snapshot s=context(q,true,true,false); + Profile sp=profile(s,s.sales),ip=profile(s,s.inventory); + if(sp==null&&ip==null)throw new BizException("所选固定版本中无该货主和SKU记录"); + String unit=sp!=null?sp.quantityUnit():ip==null?null:ip.quantityUnit(); + boolean valid="PCS".equals(unit); + List rows=s.sales==null?List.of():mapper.daily(s.sales.id(),q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(), + s.range.startDate(),s.range.endDateExclusive()); + Map daily=new HashMap<>(); + for(Daily row:rows){ + if(daily.put(row.businessDate(),row)!=null)throw new BizException("已发布版本存在重复日行"); + if(!Objects.equals(unit,row.quantityUnit()))valid=false; + } + Instant evidence=earliest(sp,ip); + List evaluated=new ArrayList<>(); List points=new ArrayList<>(); + for(LocalDate day=s.range.startDate();day.isBefore(s.range.endDateExclusive());day=day.plusDays(1)){ + Daily row=daily.get(day); + var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,s.fullFrom,s.fullUntil, + s.context.salesCoveredFrom(),s.context.salesCoveredUntil(),evidence,row==null?null:row.outboundQuantity(), + row==null?null:row.outboundOrderCount(),valid,s.sourceKnown,s.context.rebuildRequired(),"STALE".equals(s.context.salesStatus()))); + evaluated.add(point); + points.add(new AnalyticsResponses.Point(day,point.outboundQuantity(),point.observedQuantity(),point.dataStatus(), + point.reasonCodes(),unit,point.provedFrom(),point.provedUntil())); + } + var totals=SalesBucketPolicy.aggregate(evaluated); + var summary=new AnalyticsResponses.SalesSummary(q.sourceAccount(),q.warehouseCode(),q.customerCode(),q.sku(),name(sp,ip),unit, + totals.outboundQuantity(),totals.observedQuantity(),totals.dataStatus(),totals.reasonCodes(), + totals.completeDays(),totals.partialDays(),totals.unknownDays()); + return new AnalyticsResponses.Trend(s.context,summary,List.copyOf(points)); + } + public AnalyticsResponses.Page hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);} + public AnalyticsResponses.Page slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);} + public AnalyticsResponses.Page inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){ + o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY"); + long total=mapper.inventoryCount(p,o); + return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of()); + } + public AnalyticsResponses.Page owners(AnalyticsQuery q,AnalyticsInventoryOptions o){ + o.validate("OWNERS",q);Snapshot s=context(q,false,false,false);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o); + return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,null)).toList(),total,Map.of()); + } + public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){ + o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o); + Map totals=new HashMap<>();if(raw!=null)raw.forEach((key,value)->totals.put(key.toLowerCase(Locale.ROOT),value)); + long count=number(totals,"sku_count");if(count==0)return null; + var physical=quantity(totals,"physical_quantity");var outbound=quantity(totals,"owner_outbound"); + if(outbound.overallTotal()==null)outbound=new AnalyticsResponses.QuantityTotal(null,decimal(totals,"observed_outbound"),outbound.unknownSkuCount(), + decimal(totals,"observed_outbound")==null?"UNKNOWN":"PARTIAL",List.of("SALES_WINDOW_UNAVAILABLE")); + var average=new AnalyticsResponses.QuantityTotal(divide30(outbound.overallTotal()),divide30(outbound.knownSubtotal()),outbound.unknownSkuCount(),outbound.dataStatus(),outbound.reasonCodes()); + var physicalSkus=new AnalyticsResponses.QuantityTotal(physical.unknownSkuCount()==0?decimal(totals,"physical_sku_known"):null, + decimal(totals,"physical_sku_known"),physical.unknownSkuCount(),physical.dataStatus(),physical.reasonCodes()); + return new AnalyticsResponses.OwnerOverview(s.context,q.customerCode(),null,count,physicalSkus,physical,quantity(totals,"available_quantity"), + quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average, + risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null)); + } + private AnalyticsResponses.Metric inventoryMetric(Snapshot s,Inventory r){ + boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID"; + String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT"; + var calculated=InventoryCoveragePolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted, + complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid())); + List attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_30D"); + if(Integer.valueOf(1).equals(r.aboveThreshold()))attention.add("COVERAGE_GT_THRESHOLD"); + if(positive(r.frozenQuantity()))attention.add("FROZEN_STOCK");if(positive(r.defectivePhysicalQuantity()))attention.add("DEFECTIVE_STOCK"); + if(Boolean.TRUE.equals(r.unassessable()))attention.add("UNASSESSABLE"); + List reasons=new ArrayList<>(s.context.reasonCodes());if(!observed)reasons.add("OBSERVATION_INSUFFICIENT"); + if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT"); + if(r.lastOutbound()==null)reasons.add("NO_OUTBOUND_IN_COLLECTED_HISTORY"); + if(!"VALID".equals(calculated.status()))reasons.add(calculated.status()); + Map statuses=new LinkedHashMap<>();String unknown="STALE".equals(s.context.inventoryStatus())?"STALE":"UNKNOWN"; + statuses.put("physicalQuantity",r.physicalQuantity()==null?unknown:"VALID");statuses.put("availableQuantity",r.availableQuantity()==null?unknown:"VALID"); + statuses.put("frozenQuantity",r.frozenQuantity()==null?unknown:"VALID");statuses.put("transitQuantity",r.transitQuantity()==null?unknown:"VALID"); + statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("outboundQuantity30d",r.outboundQuantity30d()==null?"UNKNOWN":"VALID"); + return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null, + r.outboundQuantity30d(),r.orderCount(),null,r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()), + divide30(r.outboundQuantity30d()),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)), + null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses)); + } + private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;} + private static LocalDate historyStart(Snapshot s){return s.context.salesCoveredFrom()==null?null:s.context.salesCoveredFrom().atZone(BUSINESS_ZONE).toLocalDate();} + private static BigDecimal divide30(BigDecimal n){return n==null?null:n.divide(BigDecimal.valueOf(30),12,java.math.RoundingMode.HALF_UP);} + private static BigDecimal decimal(Map row,String key){Object n=row.get(key);return n==null?null:n instanceof BigDecimal d?d:new BigDecimal(n.toString());} + private static long number(Map row,String key){BigDecimal n=decimal(row,key);return n==null?0:n.longValueExact();} + private static AnalyticsResponses.QuantityTotal quantity(Map row,String field){ + BigDecimal known=decimal(row,field+"_known");long unknown=number(row,field+"_unknown"); + return new AnalyticsResponses.QuantityTotal(unknown==0?known:null,known,unknown,unknown==0?"COMPLETE":known==null?"UNKNOWN":"PARTIAL", + unknown==0?List.of():List.of("METRIC_DEPENDENCY_UNKNOWN")); + } + private static AnalyticsResponses.RiskShare risk(Map row,String field,AnalyticsResponses.QuantityTotal physical,boolean unset){ + long unknown=number(row,field+"_unknown");long known=number(row,field+"_count");BigDecimal knownPhysical=decimal(row,field+"_physical"); + boolean complete=!unset&&unknown==0&&physical.overallTotal()!=null; + BigDecimal ratio=complete&&positive(physical.overallTotal())?knownPhysical.divide(physical.overallTotal(),12,java.math.RoundingMode.HALF_UP):null; + List reasons=unset?List.of("THRESHOLD_NOT_SET"):!complete?List.of("RISK_DEPENDENCY_UNKNOWN"): + !positive(physical.overallTotal())?List.of("NO_PHYSICAL_INVENTORY"):List.of(); + return new AnalyticsResponses.RiskShare(complete?known:null,complete?knownPhysical:null,ratio,unset?null:known,unset?null:knownPhysical, + unknown,decimal(row,field+"_unknown_physical"),reasons); + } + private AnalyticsResponses.Page metrics(AnalyticsQuery q,String kind,boolean hot){ + Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind); + long total=mapper.count(p); + List rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r)).toList(); + Map excluded=new TreeMap<>(); + if(!"INVENTORY".equals(kind))for(var e:mapper.excluded(p))excluded.put(e.reason(),e.total()); + return page(s,rows,total,excluded); + } + private AnalyticsResponses.SalesSummary summary(Snapshot s,Aggregate r){ + int complete=r.completeDayCount(),partial=r.partialDayCount(),unknown=r.unknownDayCount(); + String status=complete==s.range.dayCount()?"COMPLETE":r.observedQuantity()!=null?"PARTIAL":"UNKNOWN"; + List reasons=new ArrayList<>(s.context.reasonCodes()); + if(!Boolean.TRUE.equals(r.unitValid()))reasons.add("UNIT_UNKNOWN_OR_CONFLICT"); + if(partial>0)reasons.add("SALES_DAY_INCOMPLETE"); if(unknown>0)reasons.add("SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN"); + return new AnalyticsResponses.SalesSummary(s.q.sourceAccount(),s.q.warehouseCode(),r.customerCode(),r.skuCode(),r.productName(), + r.quantityUnit(),"COMPLETE".equals(status)?r.observedQuantity():null,r.observedQuantity(),status,List.copyOf(reasons),complete,partial,unknown); + } + private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r){ + Instant evidence=utc(r.evidenceAtUtc()); + boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant()); + boolean stockValid="VALID".equals(r.inventoryQuality()); + String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":!s.trusted?"SALES_UNTRUSTED": + complete(s,s.range.startDate(),s.range.endDateExclusive())?"COMPLETE":"COVERAGE_INSUFFICIENT"; + var result=InventoryCoveragePolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.sales30Quantity(), + s.trusted,complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),observed,Boolean.TRUE.equals(r.unitValid())); + List reasons=new ArrayList<>(s.context.reasonCodes()); + if(!"COMPLETE".equals(coverage))reasons.add(coverage); + if(!"VALID".equals(result.status()))reasons.add(result.status()); + if(r.inventoryReason()!=null)reasons.add(r.inventoryReason()); + BigDecimal today=r.todayQuantity(); + if(today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){ + Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant(); + Instant lower=evidence.isAfter(start)?evidence:start; + if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom(); + if(lower.isBefore(s.context.salesCoveredUntil()))today=BigDecimal.ZERO; + } + return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(), + r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null, + stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(), + r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of()); + } + private Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){ + q.validate(chart,exact,hot); Instant now=clock.instant(); + List scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode()); + Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY"); + if(salesScope==null&&stockScope==null)throw new BizException("来源账号或仓库未配置"); + Run sales=resolve(salesScope,q.pinned()?q.salesRunId():null,q.salesScopeHash()); + Run inventory=resolve(stockScope,q.pinned()?q.inventoryRunId():null,q.inventoryScopeHash()); + JsonNode sc=config(sales),ic=config(inventory); + String zone=sales!=null?sc.path("businessTimezone").asText():inventory!=null?ic.path("businessTimezone").asText(): + salesScope!=null?salesScope.businessTimezone():stockScope.businessTimezone(); + if(!BUSINESS_ZONE.getId().equals(zone)||inventory!=null&&!BUSINESS_ZONE.getId().equals(ic.path("businessTimezone").asText())) + throw new BizException("发布版本不符合已确认的北京时间口径"); + Instant asOf=q.pinned()?q.queryAsOf():now; + if(asOf.isAfter(now))throw new BizException("queryAsOf不能晚于服务端时间"); + if(sales!=null&&(sales.publishedAtUtc()==null||asOf.isBefore(utc(sales.publishedAtUtc()))) + ||inventory!=null&&(inventory.publishedAtUtc()==null||asOf.isBefore(utc(inventory.publishedAtUtc())))) + throw new BizException("queryAsOf不能早于所选版本发布时间"); + Instant expires=asOf.plusSeconds(1800); + if(sales!=null&&sales.supersededAtUtc()!=null){ + Instant deadline=utc(sales.supersededAtUtc()).plus(Duration.ofDays(Math.max(7,retentionDays))); + if(deadline.isBefore(expires))expires=deadline; + } + if(!now.isBefore(expires))throw new BizException("查询上下文或统计版本已过期,请刷新整页"); + LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate(); + SalesRangePolicy.Range range; + try{ + range=chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(), + q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays): + new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30, + anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant()); + }catch(IllegalArgumentException e){throw new BizException("查询周期或日期范围无效,最多"+maxQueryDays+"个自然日");} + boolean rebuild=salesScope!=null&&(Boolean.TRUE.equals(salesScope.rebuildRequired()) + ||!Objects.equals(salesScope.factRevision(),salesScope.publishedFactRevision())||mapper.applyBarriers(q.sourceAccount(),q.warehouseCode())>0); + boolean known=sales!=null&&BUSINESS_ZONE.getId().equals(sc.path("sourceTimezone").asText()); + String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(sales.coveredUntilUtc(),threshold(sc))?"STALE":"VALID"; + if(rebuild)salesState="REBUILD_REQUIRED"; + String inventoryState=inventory==null?"UNKNOWN":stale(inventory.fetchFinishedAtUtc(),threshold(ic))?"STALE":"VALID"; + LocalDate fullFrom=sales==null?null:sales.completeDayFrom(),fullUntil=sales==null?null:sales.completeDayUntil(); + if(sales!=null&&(sales.coveredFromUtc()==null||sales.coveredUntilUtc()==null||!sales.coveredFromUtc().isBefore(sales.coveredUntilUtc()))){ + fullFrom=null;fullUntil=null; + } + if(sales!=null&&sales.coveredFromUtc()!=null&&fullFrom!=null){ + var covered=utc(sales.coveredFromUtc()).atZone(BUSINESS_ZONE); LocalDate first=covered.toLocalDate(); + if(!covered.toLocalTime().equals(LocalTime.MIDNIGHT))first=first.plusDays(1); + if(first.isAfter(fullFrom))fullFrom=first; + } + if(sales!=null&&sales.coveredUntilUtc()!=null&&fullUntil!=null){ + LocalDate last=utc(sales.coveredUntilUtc()).atZone(BUSINESS_ZONE).toLocalDate(); + if(last.isBefore(fullUntil))fullUntil=last; if(anchor.isBefore(fullUntil))fullUntil=anchor; + } + String review=reviewStatus(salesScope); List reasons=new ArrayList<>(); + if(!"VALID".equals(salesState))reasons.add("SALES_"+salesState); + if(!"VALID".equals(inventoryState))reasons.add("INVENTORY_"+inventoryState); + if(!"CURRENT".equals(review))reasons.add("REVISION_REVIEW_"+review); + var c=new AnalyticsResponses.Context(sales==null?0L:sales.id(),inventory==null?0L:inventory.id(),sales==null?null:sales.scopeHash(), + inventory==null?null:inventory.scopeHash(),asOf,expires,zone,anchor,range.startDate(),range.endDateExclusive(),chart?null:range.dayCount(), + fullFrom,fullUntil,sales==null?null:utc(sales.coveredFromUtc()),sales==null?null:utc(sales.coveredUntilUtc()), + sales==null?null:utc(sales.publishedAtUtc()),inventory==null?null:utc(inventory.fetchStartedAtUtc()), + inventory==null?null:utc(inventory.fetchFinishedAtUtc()),salesState,inventoryState,rebuild,review,List.copyOf(reasons)); + return new Snapshot(q,sales,inventory,known,"VALID".equals(salesState),fullFrom,fullUntil,range,c); + } + private AnalyticsSqlRequest request(Snapshot s,String kind){ + int size=s.q.pageSize(); if("HOT".equals(kind)||"SLOW".equals(kind))size=(int)Math.max(0,Math.min(size,100-s.q.offset())); + return new AnalyticsSqlRequest(s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku(),s.context.salesRunId(), + s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()), + s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown, + s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()), + s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind); + } + private Run resolve(Scope scope,Long requested,String hash){ + if(scope==null){if(requested!=null&&requested!=0)throw new BizException("版本任务族未配置");return null;} + Long id=requested==null?scope.publishedRunId():requested; if(id==null||id==0)return null; + Run run=mapper.publishedRun(id); + if(run==null||!Objects.equals(run.scopeId(),scope.id())||!scope.sourceAccount().equals(run.sourceAccount()) + ||!scope.warehouseCode().equals(run.warehouseCode())||!scope.scopeHash().equals(run.scopeHash()) + ||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status())) + throw new BizException("版本不可用或范围不符,请刷新整页"); + if(!"COMPLETE".equals(run.coverageStatus()))throw new BizException("发布版本缺少完整覆盖证明"); + return run; + } + private Profile profile(Snapshot s,Run run){return run==null?null:mapper.profile(run.id(),s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku());} + private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&s.sales!=null&&s.sales.coveredFromUtc()!=null&&s.sales.coveredUntilUtc()!=null + &&s.sales.coveredFromUtc().isBefore(s.sales.coveredUntilUtc())&&s.fullFrom!=null&&s.fullUntil!=null&&!from.isBefore(s.fullFrom)&&!until.isAfter(s.fullUntil);} + private JsonNode config(Run r){if(r==null)return json.createObjectNode();try{JsonNode n=json.readTree(r.scopeSnapshotJson());if(n==null||!n.isObject())throw new IllegalArgumentException();return n;}catch(Exception e){throw new BizException("版本配置快照无效");}} + private static int threshold(JsonNode c){int n=c.path("staleAfterSeconds").asInt();if(n<=0)throw new BizException("版本过期阈值无效");return n;} + private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));} + private String reviewStatus(Scope s){if(s==null||s.knownOrderRoundFinishedAtUtc()==null||s.historyRoundFinishedAtUtc()==null||s.reviewTargetDays()==null||s.reviewTargetDays()<=0)return "UNKNOWN";int age=Math.multiplyExact(s.reviewTargetDays(),86400);return stale(s.knownOrderRoundFinishedAtUtc(),age)||stale(s.historyRoundFinishedAtUtc(),age)?"LAGGING":"CURRENT";} + private static Scope scope(List ss,String family){return ss.stream().filter(s->family.equals(s.dataFamily())).findFirst().orElse(null);} + private static String name(Profile s,Profile i){return i!=null&&i.productName()!=null?i.productName():s==null?null:s.productName();} + private static Instant earliest(Profile s,Profile i){Instant a=evidence(s,"VALID_OUTBOUND"),b=evidence(i,"INVENTORY_OBSERVATION");return a==null?b:b==null||a.isBefore(b)?a:b;} + private static Instant evidence(Profile p,String type){return p!=null&&type.equals(p.evidenceType())&&p.evidenceRunId()!=null?utc(p.earliestEvidenceAtUtc()):null;} + private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);} + private static AnalyticsResponses.Page page(Snapshot s,List rows,long total,Map exclusions){int size=s.q.pageSize();return new AnalyticsResponses.Page<>(s.context,rows,s.q.pageNumber(),size,total,total/size+(total%size==0?0:1),Map.copyOf(exclusions));} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java new file mode 100644 index 000000000..b96b79445 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.util.List; +import org.apache.ibatis.annotations.*; + +/** Reads immutable published versions in Lastleg only. */ +@Mapper +public interface AnalyticsReadMapper { + String RUN_COLUMNS="id,task_id,scope_id,source_account,warehouse_code,run_role,scope_hash,scope_snapshot_json,status," + +"coverage_status,covered_from_utc,covered_until_utc,complete_day_from,complete_day_until," + +"fetch_started_at_utc,fetch_finished_at_utc,published_at_utc,calculated_fact_revision,error_code,error_message,superseded_at_utc,execution_purpose"; + @Select("SELECT id,source_account,warehouse_code,data_family,scope_hash,business_timezone,source_timezone," + +"stale_after_seconds,published_run_id,fact_revision,published_fact_revision,rebuild_required," + +"known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days " + +"FROM ll_selfop_sync_scope WHERE source_account=#{account} AND warehouse_code=#{warehouse} ORDER BY data_family") + List scopes(@Param("account") String account,@Param("warehouse") String warehouse); + @Select("SELECT "+RUN_COLUMNS+" FROM ll_selfop_sync_run WHERE id=#{id} AND status='PUBLISHED' AND run_role='WAREHOUSE_SYNC' AND execution_purpose='BUSINESS'") + AnalyticsReadRows.Run publishedRun(@Param("id") long id); + @SelectProvider(type=AnalyticsReadSql.class,method="page") + List page(@Param("p") AnalyticsSqlRequest request); + @SelectProvider(type=AnalyticsReadSql.class,method="count") + long count(@Param("p") AnalyticsSqlRequest request); + record Exclusion(String reason,Long total){} + @SelectProvider(type=AnalyticsReadSql.class,method="excluded") + List excluded(@Param("p") AnalyticsSqlRequest request); + @Select("SELECT customer_code,sku_code,product_name,quantity_unit,first_seen_at_utc,earliest_evidence_at_utc," + +"evidence_type,evidence_run_id,last_seen_run_id FROM ll_selfop_sku_profile WHERE profile_run_id=#{run} " + +"AND source_account=#{account} AND warehouse_code=#{warehouse} AND customer_code=#{customer} AND sku_code=#{sku}") + AnalyticsReadRows.Profile profile(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku); + @Select("SELECT customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit " + +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{run} AND source_account=#{account} AND warehouse_code=#{warehouse} " + +"AND customer_code=#{customer} AND sku_code=#{sku} AND business_date>=#{from} AND business_date<#{until} ORDER BY business_date") + List daily(@Param("run") long run,@Param("account") String account,@Param("warehouse") String warehouse, + @Param("customer") String customer,@Param("sku") String sku,@Param("from") java.time.LocalDate from,@Param("until") java.time.LocalDate until); + @Select("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=#{account} AND apply_state IN ('APPLYING','BLOCKED') " + +"AND JSON_CONTAINS(affected_warehouse_codes_json,JSON_QUOTE(#{warehouse}))") + long applyBarriers(@Param("account") String account,@Param("warehouse") String warehouse); + @SelectProvider(type=AnalyticsInventorySql.class,method="inventoryPage") + List inventoryPage(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="inventoryCount") + long inventoryCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="owners") + List owners(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="ownerCount") + long ownerCount(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); + @SelectProvider(type=AnalyticsInventorySql.class,method="ownerTotals") + java.util.Map ownerTotals(@Param("p") AnalyticsSqlRequest request,@Param("o") AnalyticsInventoryOptions options); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java new file mode 100644 index 000000000..ae5df03e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** Read projections only; DATETIME columns are UTC, never JVM-local time. */ +public final class AnalyticsReadRows { + private AnalyticsReadRows() {} + public record Scope(Long id, String sourceAccount, String warehouseCode, String dataFamily, + String scopeHash, String businessTimezone, String sourceTimezone, + Integer staleAfterSeconds, Long publishedRunId, Long factRevision, + Long publishedFactRevision, Boolean rebuildRequired, + LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc, + Integer reviewTargetDays) {} + public record Run(Long id, Long taskId, Long scopeId, String sourceAccount, String warehouseCode, String runRole, + String scopeHash, String scopeSnapshotJson, String status, String coverageStatus, + LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc, + LocalDate completeDayFrom, LocalDate completeDayUntil, + LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc, + LocalDateTime publishedAtUtc, Long calculatedFactRevision, + String errorCode, String errorMessage, LocalDateTime supersededAtUtc, String executionPurpose) {} + public record Profile(String customerCode, String skuCode, String productName, String quantityUnit, + LocalDateTime firstSeenAtUtc, LocalDateTime earliestEvidenceAtUtc, + String evidenceType, Long evidenceRunId, Long lastSeenRunId) {} + public record Daily(String customerCode, String skuCode, LocalDate businessDate, + BigDecimal outboundQuantity, Long outboundOrderCount, String quantityUnit) {} + public record Aggregate(String customerCode, String skuCode, String productName, String quantityUnit, + LocalDateTime evidenceAtUtc, Boolean unitValid, BigDecimal observedQuantity, Long skuOrderCount, + Integer completeDayCount, Integer partialDayCount, Integer unknownDayCount, + BigDecimal windowQuantity, Long windowSkuOrderCount, BigDecimal sales30Quantity, BigDecimal todayQuantity, + BigDecimal physicalQuantity, BigDecimal availableQuantity, BigDecimal frozenQuantity, BigDecimal transitQuantity, + LocalDateTime inventoryObservedAtUtc, String inventoryQuality, String inventoryReason) {} + public record Inventory(String customerCode,String skuCode,String productName,Boolean unitValid, + LocalDateTime evidenceAtUtc,BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity, + BigDecimal transitQuantity,BigDecimal defectivePhysicalQuantity,LocalDateTime observedAtUtc,BigDecimal ownerOutbound, + BigDecimal observedOutbound,Long orderCount,LocalDate lastOutbound,Boolean observationEligible, + BigDecimal outboundQuantity30d,BigDecimal coverageDays,Integer noOutbound,Integer aboveThreshold,Boolean unassessable){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java new file mode 100644 index 000000000..beefeefef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java @@ -0,0 +1,147 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +/** MySQL 8 CTEs: bounded by one version, warehouse and date range; no order/item/stage reads. */ +public final class AnalyticsReadSql { + private AnalyticsReadSql(){} + private static final String KEY_FILTER=" source_account=#{p.account} AND warehouse_code=#{p.warehouse}" + +" AND (#{p.customer} IS NULL OR customer_code=#{p.customer}) AND (#{p.sku} IS NULL OR sku_code=#{p.sku}) "; + private static String candidates(boolean salesOnly){ + String sql="WITH candidates AS (SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND" + +KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+")," + +" dim AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," + +"COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit," + +"CASE WHEN (s.sku_code IS NOT NULL AND s.quantity_unit IS NULL) OR (i.sku_code IS NOT NULL AND i.quantity_unit IS NULL) THEN 0 " + +"WHEN s.quantity_unit IS NOT NULL AND i.quantity_unit IS NOT NULL AND s.quantity_unit<>i.quantity_unit THEN 0 " + +"WHEN COALESCE(s.quantity_unit,i.quantity_unit)<>'PCS' OR COALESCE(s.quantity_unit,i.quantity_unit) IS NULL THEN 0 ELSE 1 END profile_unit_valid," + +"CASE WHEN s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL THEN s.earliest_evidence_at_utc END sales_evidence," + +"CASE WHEN i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL THEN i.earliest_evidence_at_utc END stock_evidence " + +"FROM candidates c LEFT JOIN ll_selfop_sku_profile s ON s.profile_run_id=#{p.salesRun} AND s.source_account=#{p.account} " + +"AND s.warehouse_code=#{p.warehouse} AND s.customer_code=c.customer_code AND s.sku_code=c.sku_code " + +"LEFT JOIN ll_selfop_sku_profile i ON i.profile_run_id=#{p.inventoryRun} AND i.source_account=#{p.account} " + +"AND i.warehouse_code=#{p.warehouse} AND i.customer_code=c.customer_code AND i.sku_code=c.sku_code)," + +" evidence AS (SELECT dim.*,CASE WHEN sales_evidence IS NULL THEN stock_evidence WHEN stock_evidence IS NULL THEN sales_evidence " + +"ELSE LEAST(sales_evidence,stock_evidence) END evidence_at_utc FROM dim),"; + if(salesOnly){ + int start=sql.indexOf("CASE WHEN (s.sku_code IS NOT NULL"),end=sql.indexOf("profile_unit_valid,",start); + sql=sql.substring(0,start)+"CASE WHEN s.sku_code IS NOT NULL THEN CASE WHEN s.quantity_unit='PCS' THEN 1 ELSE 0 END WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END "+sql.substring(end); + sql=sql.replace("COALESCE(s.quantity_unit,i.quantity_unit) quantity_unit","CASE WHEN s.sku_code IS NOT NULL THEN s.quantity_unit ELSE i.quantity_unit END quantity_unit"); + } + return sql; + } + private static String cte(String kind){ + String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; + String observedStart="CONVERT_TZ(GREATEST(evidence_at_utc,#{p.coveredFrom}),'+00:00','+08:00')"; + String observedEnd="CONVERT_TZ(#{p.coveredUntil},'+00:00','+08:00')"; + String sql=candidates("SUMMARY".equals(kind))+" bounds AS (SELECT evidence.*," + +"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.completeFrom} IS NOT NULL AND #{p.completeUntil} IS NOT NULL THEN " + +"GREATEST(#{p.from},#{p.completeFrom},TIMESTAMPADD(DAY,CASE WHEN CAST("+localEvidence+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+localEvidence+"))) END full_start," + +"LEAST(#{p.until},#{p.completeUntil},#{p.anchor}) full_end," + +"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN GREATEST(#{p.from},DATE("+observedStart+")) END observed_start," + +"CASE WHEN evidence_at_utc IS NOT NULL AND #{p.coveredFrom} IS NOT NULL AND #{p.coveredUntil} IS NOT NULL AND GREATEST(evidence_at_utc,#{p.coveredFrom})<#{p.coveredUntil} THEN LEAST(#{p.until},TIMESTAMPADD(DAY,CASE WHEN CAST("+observedEnd+" AS TIME)='00:00:00' THEN 0 ELSE 1 END,DATE("+observedEnd+"))) END observed_end " + +"FROM evidence)," + +" ranged AS (SELECT b.customer_code,b.sku_code,SUM(d.outbound_quantity) observed_sum,COUNT(d.id) actual_days," + +"SUM(d.outbound_order_count) order_sum,MIN(d.quantity_unit) min_unit,MAX(d.quantity_unit) max_unit," + +"SUM(CASE WHEN d.business_date>=#{p.completeFrom} AND d.business_date<#{p.completeUntil} AND d.business_date<#{p.anchor} THEN 1 ELSE 0 END) actual_complete," + +"SUM(CASE WHEN d.business_date>=b.full_start AND d.business_date=b.observed_start AND d.business_date=#{p.from} AND d.business_date<#{p.until} GROUP BY b.customer_code,b.sku_code)," + +" metric_sales AS (SELECT customer_code,sku_code," + +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity," + +"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders," + +"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity," + +"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity ELSE NULL END) today_quantity," + +"MIN(quantity_unit) min_unit,MAX(quantity_unit) max_unit " + +"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER + +" AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date0 THEN COALESCE(c.observed_sum,0) END observed_quantity," + +"c.order_sum sku_order_count,CASE WHEN c.unit_valid=1 THEN c.complete_count ELSE 0 END complete_day_count," + +"CASE WHEN c.unit_valid=1 THEN GREATEST(0,c.observed_count-c.complete_count) ELSE 0 END partial_day_count," + +"TIMESTAMPDIFF(DAY,#{p.from},#{p.until})-CASE WHEN c.unit_valid=1 THEN c.observed_count ELSE 0 END unknown_day_count," + +"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_quantity,0) END window_quantity," + +"CASE WHEN c.unit_valid=1 AND #{p.completeWindow}=1 THEN COALESCE(c.window_orders,0) END window_sku_order_count," + +"CASE WHEN c.unit_valid=1 AND #{p.complete30}=1 THEN COALESCE(c.sales30_quantity,0) END sales30_quantity," + +"CASE WHEN c.unit_valid=1 AND #{p.salesKnown}=1 THEN c.today_quantity END today_quantity," + +"st.physical_quantity,st.available_quantity,st.frozen_quantity,st.transit_quantity,st.observed_at_utc inventory_observed_at_utc," + +"CASE WHEN st.sku_code IS NULL THEN 'UNKNOWN' WHEN st.quality_status='INVALID' THEN 'INVALID' " + +"WHEN st.quality_status<>'VALID' OR st.available_quantity IS NULL OR st.physical_quantity IS NULL OR st.frozen_quantity IS NULL OR st.transit_quantity IS NULL THEN 'UNKNOWN' " + +"WHEN st.available_quantity<0 OR st.physical_quantity<0 OR st.frozen_quantity<0 OR st.transit_quantity<0 THEN 'INVALID' " + +"WHEN #{p.stockFresh}=0 THEN 'STALE' ELSE 'VALID' END inventory_quality,st.quality_reason inventory_reason " + +"FROM counted c LEFT JOIN ll_selfop_inventory_snapshot st ON st.snapshot_run_id=#{p.inventoryRun} AND st.source_account=#{p.account} " + +"AND st.warehouse_code=#{p.warehouse} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code AND st.product_quality=0) "; + if("SUMMARY".equals(kind)){ + int start=sql.indexOf(" metric_sales AS (");int end=sql.indexOf(" counted AS (",start); + sql=sql.substring(0,start)+" metric_sales AS (SELECT customer_code,sku_code,CAST(NULL AS DECIMAL(20,6)) window_quantity," + +"CAST(NULL AS DECIMAL(20,0)) window_orders,CAST(NULL AS DECIMAL(20,6)) sales30_quantity," + +"CAST(NULL AS DECIMAL(20,6)) today_quantity,CAST(NULL AS CHAR(32)) min_unit,CAST(NULL AS CHAR(32)) max_unit FROM bounds WHERE 1=0),"+sql.substring(end); + // A sales chart does not require inventory quantities or freshness. Keep only profile evidence. + int stockJoin=sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot st"); + sql=sql.substring(0,stockJoin)+"LEFT JOIN (SELECT CAST(NULL AS DECIMAL(20,0)) snapshot_run_id,CAST(NULL AS CHAR(64)) source_account," + +"CAST(NULL AS CHAR(64)) warehouse_code,CAST(NULL AS CHAR(64)) customer_code,CAST(NULL AS CHAR(128)) sku_code," + +"CAST(NULL AS DECIMAL(20,6)) physical_quantity,CAST(NULL AS DECIMAL(20,6)) available_quantity," + +"CAST(NULL AS DECIMAL(20,6)) frozen_quantity,CAST(NULL AS DECIMAL(20,6)) transit_quantity,CAST(NULL AS DATETIME) observed_at_utc," + +"CAST(NULL AS CHAR(16)) quality_status,CAST(NULL AS CHAR(64)) quality_reason,CAST(NULL AS DECIMAL(6,0)) product_quality) st ON 1=0) "; + } + return sql; + } + private static String filter(String kind){ + if("HOT".equals(kind))return " WHERE window_quantity>0 "; + if("SLOW".equals(kind))return " WHERE window_quantity IS NOT NULL AND inventory_quality='VALID' AND available_quantity>0 " + +"AND evidence_at_utc<=CONVERT_TZ(CAST(TIMESTAMPADD(DAY,-30,#{p.anchor}) AS DATETIME),'+08:00','+00:00') "; + return ""; + } + private static String order(String kind){ + if("HOT".equals(kind))return "window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC"; + if("SLOW".equals(kind))return "window_quantity ASC,available_quantity DESC,sku_code ASC,customer_code ASC"; + if("SUMMARY".equals(kind))return "observed_quantity IS NULL,observed_quantity DESC,sku_code ASC,customer_code ASC"; + return "sku_code ASC,customer_code ASC"; + } + public static String page(java.util.Map parameters){ + var p=(AnalyticsSqlRequest)parameters.get("p"); + return typed(cte(p.kind())+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count," + +"complete_day_count,partial_day_count,unknown_day_count,window_quantity,window_sku_order_count,sales30_quantity,today_quantity," + +"physical_quantity,available_quantity,frozen_quantity,transit_quantity,inventory_observed_at_utc,inventory_quality,inventory_reason " + +"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind())+" LIMIT #{p.size} OFFSET #{p.offset}",p); + } + public static String count(java.util.Map parameters){ + var p=(AnalyticsSqlRequest)parameters.get("p"); + // Candidate totals do not scan daily rows for normal lists. + if(!"HOT".equals(p.kind())&&!"SLOW".equals(p.kind()))return typed("SELECT COUNT(*) FROM (SELECT customer_code,sku_code FROM ll_selfop_sku_profile " + +"WHERE profile_run_id=#{p.salesRun} AND"+KEY_FILTER+" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile " + +"WHERE profile_run_id=#{p.inventoryRun} AND"+KEY_FILTER+") candidate_keys",p); + return typed(cte(p.kind())+"SELECT LEAST(COUNT(*),100) FROM result"+filter(p.kind()),p); + } + public static String excluded(java.util.Map parameters){ + var p=(AnalyticsSqlRequest)parameters.get("p"); + String reason="HOT".equals(p.kind())?"CASE WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'ZERO_OUTBOUND' END": + "CASE WHEN inventory_quality<>'VALID' THEN CONCAT('INVENTORY_',inventory_quality) WHEN available_quantity<=0 THEN 'NO_AVAILABLE_INVENTORY' " + +"WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'OBSERVATION_INSUFFICIENT' END"; + return typed(cte(p.kind())+"SELECT "+reason+" reason,COUNT(*) total FROM result WHERE NOT ("+filter(p.kind()).replace(" WHERE ","").strip()+") OR window_quantity IS NULL" + +("SLOW".equals(p.kind())?" OR evidence_at_utc IS NULL":"")+" GROUP BY reason",p); + } + private static String typed(String sql,AnalyticsSqlRequest p){ + String filters=" source_account=#{p.account} AND warehouse_code=#{p.warehouse} "; + if(p.customer()!=null)filters+="AND customer_code=#{p.customer} "; + if(p.sku()!=null)filters+="AND sku_code=#{p.sku} "; + sql=sql.replace(KEY_FILTER,filters); + // Explicit expression types preserve NULL semantics and avoid inference from bare JDBC parameters. + for(String key:java.util.List.of("from","until","anchor","windowFrom","completeFrom","completeUntil")) + sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATE)"); + for(String key:java.util.List.of("coveredFrom","coveredUntil")) + sql=sql.replace("#{p."+key+"}","CAST(#{p."+key+"} AS DATETIME)"); + return sql; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java new file mode 100644 index 000000000..2b6c872d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public final class AnalyticsResponses { + private AnalyticsResponses(){} + public record Context(Long salesRunId,Long inventoryRunId,String salesScopeHash,String inventoryScopeHash, + Instant queryAsOf,Instant queryExpiresAt,String businessTimezone,LocalDate anchorDate, + LocalDate rangeStartDate,LocalDate rangeEndDateExclusive,Integer calculationWindowDays, + LocalDate completeDayFrom,LocalDate completeDayUntil,Instant salesCoveredFrom,Instant salesCoveredUntil, + Instant salesPublishedAt,Instant inventoryObservedFrom,Instant inventoryObservedUntil, + String salesStatus,String inventoryStatus,boolean rebuildRequired,String revisionReviewStatus,List reasonCodes){} + public record Page(Context context,List records,int current,int size,long total,long pages,Map excludedReasons){} + public record SalesSummary(String sourceAccount,String warehouseCode,String customerCode,String sku,String productName, + String quantityUnit,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus,List reasonCodes, + int completeDayCount,int partialDayCount,int unknownDayCount){} + public record Point(LocalDate businessDate,BigDecimal outboundQuantity,BigDecimal observedQuantity,String dataStatus, + List reasonCodes,String quantityUnit,Instant coveredFrom,Instant coveredUntil){} + public record Trend(Context context,SalesSummary summary,List points){} + public record Metric(String sourceAccount,String customerCode,String warehouseCode,String sku,String productName, + String quantityUnit,BigDecimal outboundQuantity,Long skuOutboundOrderCount,BigDecimal todayObservedQuantity, + BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,BigDecimal transitQuantity, + Instant inventoryObservedAt,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays, + String inventoryQuality,String salesCoverage,boolean observationEligible,Instant earliestReliableEvidenceAt, + String status,List reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity, + BigDecimal outboundQuantity30d,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate, + List attentionReasons,Map quantityStatuses){} + public record OwnerOption(String customerCode,String customerName){} + public record QuantityTotal(BigDecimal overallTotal,BigDecimal knownSubtotal,long unknownSkuCount,String dataStatus,List reasonCodes){} + public record RiskShare(Long matchedSkuCount,BigDecimal matchedPhysicalQuantity,BigDecimal physicalQuantityRatio, + Long knownMatchedSkuCount,BigDecimal knownMatchedPhysicalQuantity,long unassessableSkuCount, + BigDecimal unassessableKnownPhysicalQuantity,List reasonCodes){} + public record OwnerOverview(Context context,String customerCode,String customerName,Long skuCount,QuantityTotal physicalSkuCount, + QuantityTotal physicalQuantity,QuantityTotal availableQuantity,QuantityTotal frozenQuantity,QuantityTotal defectivePhysicalQuantity, + QuantityTotal transitQuantity,QuantityTotal outboundQuantity30d,QuantityTotal averageDailyOutboundQuantity, + RiskShare noOutbound30d,RiskShare coverageAboveThreshold){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java new file mode 100644 index 000000000..a9d8bfe78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** Bound parameters only, generated by the validated service context. */ +public record AnalyticsSqlRequest(String account,String warehouse,String customer,String sku,long salesRun,long inventoryRun, + LocalDate from,LocalDate until,LocalDate anchor,LocalDate windowFrom,LocalDate completeFrom,LocalDate completeUntil, + LocalDateTime coveredFrom,LocalDateTime coveredUntil,boolean salesKnown,boolean formalSales,boolean completeWindow, + boolean complete30,boolean stockFresh,long offset,int size,String kind) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java new file mode 100644 index 000000000..fc2e328fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDate; +import java.time.Instant; +import org.springframework.format.annotation.DateTimeFormat; + +/** Single canonical constructor supports Spring ModelAttribute binding. */ +public record CollectionQuery(String sourceAccount, String warehouseCode, String dataFamily, + String taskType, String status, String taskNo, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateUntil, + Integer current, Integer size, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant listUpperCreatedAt, + Long listUpperId) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java new file mode 100644 index 000000000..600117510 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java @@ -0,0 +1,350 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.util.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*; + +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class CollectionQueryService { + private final CollectionReadMapper mapper; + private final SelfopAnalyticsProperties config; + private final ObjectMapper json; + private final Clock clock; + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final Set TERMINAL = Set.of("ROUTED","PUBLISHED","REUSED","YIELDED","FAILED","ABORTED","SAMPLE_DONE"); + private static final Set PROGRESS_FIELDS = Set.of("progressScope","countBasis","attemptNo","scanRound","phase", + "completed","total","totalKnown","unit","sliceFrom","sliceUntil","stageStartedAt","stageFinishedAt", + "validationState","invalidCount","unknownCount","pendingReviewCount","countUnit","summaryAsOf","directoryComplete"); + private static final Set COUNTS = Set.of("completed","total","invalidCount","unknownCount","pendingReviewCount","attemptNo","scanRound"); + private static final Set UNITS = Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","SKU","BATCH","NONE"); + @Autowired + public CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json) { + this(mapper, config, json, Clock.systemUTC()); + } + CollectionQueryService(CollectionReadMapper mapper, SelfopAnalyticsProperties config, ObjectMapper json, Clock clock) { + this.mapper=mapper; this.config=config; this.json=json; this.clock=clock; + } + private record Context(Map tasks,Map latest,Map published) {} + private Context context(String account,List base,List extraPublished) { + Map tasks=new HashMap<>();for(Task t:base)tasks.put(t.id(),t); + Set related=new HashSet<>(); + for(Task t:base){if(t.validationRootTaskId()!=null)related.add(t.validationRootTaskId());if(t.upstreamTaskId()!=null)related.add(t.upstreamTaskId());} + related.removeAll(tasks.keySet()); + if(!related.isEmpty())for(Task t:mapper.tasksByIds(account,new ArrayList<>(related)))tasks.put(t.id(),t); + Map latest=new HashMap<>(); + if(!tasks.isEmpty())for(Attempt a:mapper.latestAttempts(account,new ArrayList<>(tasks.keySet())))latest.put(a.taskId(),a); + Set versions=new HashSet<>(extraPublished);versions.remove(null); + for(Task t:tasks.values())if(t.resultPublishedRunId()!=null)versions.add(t.resultPublishedRunId()); + Map published=new HashMap<>(); + if(!versions.isEmpty())for(Attempt a:mapper.publishedRuns(account,new ArrayList<>(versions)))published.put(a.id(),a); + return new Context(tasks,latest,published); + } + + public CollectionResponses.Overview overview(CollectionQuery query) { + validate(query, false); + List scopes=mapper.scopes(query.sourceAccount(),query.warehouseCode()); + Map persisted=new HashMap<>(); + for(Scope scope:scopes) persisted.put(scope.warehouseCode()+":"+scope.dataFamily(),scope); + var rows=new ArrayList(); + var protection=protection(query.sourceAccount()); + Map inventorySamples=inventorySamples(query); + List scopeIds=scopes.stream().filter(s->config.getWarehouses().containsKey(s.warehouseCode())).map(Scope::id).toList(); + List currentTasks=scopeIds.isEmpty()?List.of():mapper.currentTasks(query.sourceAccount(),scopeIds); + List successes=scopeIds.isEmpty()?List.of():mapper.lastSuccessfulTasks(query.sourceAccount(),scopeIds); + Task active=mapper.sharedActiveAccount(query.sourceAccount()); + List all=new ArrayList<>(currentTasks);all.addAll(successes);if(active!=null)all.add(active); + Context context=context(query.sourceAccount(),all,scopes.stream().map(Scope::publishedRunId).filter(Objects::nonNull).toList()); + Set barriers=config.getWarehouses().isEmpty()?Set.of():new HashSet<>(mapper.barrierWarehouses(query.sourceAccount(),new ArrayList<>(config.getWarehouses().keySet()))); + for(String warehouse:new TreeSet<>(config.getWarehouses().keySet())) { + if(query.warehouseCode()!=null&&!warehouse.equals(query.warehouseCode())) continue; + for(String family:List.of("OUTBOUND","INVENTORY")) { + if(query.dataFamily()!=null&&!family.equals(query.dataFamily()))continue; + Scope scope=persisted.get(warehouse+":"+family); + Task current=currentTasks.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null); + Task last=successes.stream().filter(t->warehouse.equals(t.warehouseCode())&&family.equals(t.dataFamily())).findFirst().orElse(null); + Attempt published=scope==null?null:context.published().get(scope.publishedRunId()); + if(published!=null&&(!warehouse.equals(published.warehouseCode())||!Objects.equals(scope.id(),published.scopeId()) + ||!Objects.equals(scope.scopeHash(),published.scopeHash())))published=null; + Task shared="OUTBOUND".equals(family)&&sharedWindowRelated(active,scope)?active:null; + String blocked=executionBlock(warehouse,protection); + boolean enabled=blocked==null; + boolean publication="BUSINESS".equals(config.getCollectionMode())&&"production".equals(config.getEnvironment()) + &&("INVENTORY".equals(family)||config.isSalesPublicationEnabled()); + String initial="INVENTORY".equals(family)?published!=null&&"COMPLETE".equals(published.coverageStatus())?"COMPLETE":"UNKNOWN" + :scope==null||scope.initialFromUtc()==null||scope.initialUntilUtc()==null?"UNKNOWN" + :published!=null&&"COMPLETE".equals(published.coverageStatus())&&published.coveredFromUtc()!=null&&published.coveredUntilUtc()!=null + &&!published.coveredFromUtc().isAfter(scope.initialFromUtc())&&!published.coveredUntilUtc().isBefore(scope.initialUntilUtc()) + &&!Integer.valueOf(1).equals(scope.rebuildRequired())&&!barriers.contains(warehouse)?"COMPLETE":"PARTIAL"; + Instant observation=published==null?null:utc("INVENTORY".equals(family)?published.fetchFinishedAtUtc():published.coveredUntilUtc()); + String freshness=published==null?"NOT_COLLECTED":scope.staleAfterSeconds()==null||scope.staleAfterSeconds()<=0?"UNKNOWN" + :observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH"; + rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked, + enabled?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()), + scope==null?null:utc(scope.initialUntilUtc()),initial,published==null?null:utc(published.coveredFromUtc()), + published==null?null:utc(published.coveredUntilUtc()),last==null?null:last.taskNo(),last==null||context.latest().get(last.id())==null?null:utc(context.latest().get(last.id()).finishedAtUtc()), + published==null?null:utc(published.publishedAtUtc()),"INVENTORY".equals(family)&&published!=null?utc(published.fetchStartedAtUtc()):null, + "INVENTORY".equals(family)&&published!=null?utc(published.fetchFinishedAtUtc()):null,freshness, + "OUTBOUND".equals(family)&&scope!=null&&(Integer.valueOf(1).equals(scope.rebuildRequired())||barriers.contains(warehouse)),reviewStatus(scope), + current==null?null:taskView(current,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection, + "INVENTORY".equals(family)?inventorySamples.get(warehouse):null)); + } + } + return new CollectionResponses.Overview(query.sourceAccount(),config.getEnvironment(),config.getCollectionMode(),clock.instant(),rows); + } + + private Map inventorySamples(CollectionQuery query) { + if("OUTBOUND".equals(query.dataFamily()))return Map.of(); + List warehouses=config.getWarehouses().keySet().stream() + .filter(w->query.warehouseCode()==null||w.equals(query.warehouseCode())).sorted().toList(); + if(warehouses.isEmpty())return Map.of(); + LocalDate until=LocalDate.now(clock.withZone(BUSINESS_ZONE)),from=until.minusDays(30); + Map> dates=new HashMap<>(); + for(InventorySample sample:mapper.inventoryDailySamples(query.sourceAccount(),warehouses,from,until)) { + if(sample.sampleDate()!=null&&!sample.sampleDate().isBefore(from)&&sample.sampleDate().isBefore(until)) + dates.computeIfAbsent(sample.warehouseCode(),k->new HashSet<>()).add(sample.sampleDate()); + } + Map starts=new HashMap<>(); + for(InventorySample sample:mapper.inventoryAccumulationStarts(query.sourceAccount(),warehouses)) + if(sample.sampleDate()!=null)starts.put(sample.warehouseCode(),sample.sampleDate()); + Map result=new HashMap<>(); + for(String warehouse:warehouses) { + Set observed=dates.getOrDefault(warehouse,Set.of()); + List missing=from.datesUntil(until).filter(d->!observed.contains(d)).toList(); + result.put(warehouse,new CollectionResponses.InventoryDailySamples(from,until,30,30-missing.size(),missing,starts.get(warehouse))); + } + return result; + } + + public CollectionResponses.Page tasks(CollectionQuery query) { + validate(query,true); + var f=filter(query); Bound bound=bound(query,false,f); + if(bound==null)return empty(query); + var scopes=scopeMap(query); var protection=protection(query.sourceAccount()); + List tasks=mapper.tasks(f,bound);Context context=context(query.sourceAccount(),tasks,List.of()); + var rows=tasks.stream().map(t->taskView(t,scopes.get(t.dataFamily()),protection,context)).toList(); + return new CollectionResponses.Page<>(current(query),size(query),mapper.taskCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows); + } + public CollectionResponses.TaskDetail taskDetail(String taskNo,CollectionQuery query) { + validate(query,true); + if(taskNo==null||taskNo.isBlank()||taskNo.length()>64)throw new BizException("任务号无效"); + Task task=mapper.task(query.sourceAccount(),query.warehouseCode(),taskNo); + if(task==null)throw new BizException("任务不存在或不属于所选仓库"); + Scope scope=scopeMap(query).get(task.dataFamily()); + if(scope==null||(query.dataFamily()!=null&&!query.dataFamily().equals(task.dataFamily())))throw new BizException("任务范围与所选仓库不一致"); + Context context=context(query.sourceAccount(),List.of(task),List.of()); + Task shared=relatedUpstream(task,context); + var protection=protection(query.sourceAccount()); + return new CollectionResponses.TaskDetail(taskView(task,scope,protection,context),shared==null?null:taskView(shared,null,protection,context),protection,clock.instant()); + } + public CollectionResponses.Page runs(CollectionQuery query) { + validate(query,true); + var f=filter(query); Bound bound=bound(query,true,f); + if(bound==null)return empty(query); + List runs=mapper.runs(f,bound); + Map tasks=new HashMap<>();List taskIds=runs.stream().map(Attempt::taskId).distinct().toList(); + if(!taskIds.isEmpty())for(Task t:mapper.tasksByIds(query.sourceAccount(),taskIds))tasks.put(t.id(),t); + Map scopes=scopeMap(query);var protection=protection(query.sourceAccount()); + Context context=context(query.sourceAccount(),new ArrayList<>(tasks.values()),List.of()); + var rows=runs.stream().map(r->{Task t=tasks.get(r.taskId());return attemptView(r,t, + t!=null&&retryBlock(t,scopes.get(t.dataFamily()),protection,context)==null);}).toList(); + return new CollectionResponses.Page<>(current(query),size(query),mapper.runCount(f,bound),utc(bound.createdAt()),bound.id(),clock.instant(),rows); + } + private CollectionResponses.Page empty(CollectionQuery q) { + // A zero id freezes an empty first page; later inserts cannot enter it. + return new CollectionResponses.Page<>(current(q),size(q),0,clock.instant(),0L,clock.instant(),List.of()); + } + private Map scopeMap(CollectionQuery q) { + Map scopes=new HashMap<>(); + for(Scope scope:mapper.scopes(q.sourceAccount(),q.warehouseCode()))scopes.put(scope.dataFamily(),scope); + return scopes; + } + private boolean sharedWindowRelated(Task active,Scope scope) { + if(active==null||scope==null||active.queryFromUtc()==null||active.queryUntilUtc()==null)return false; + return overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.initialFromUtc(),scope.initialUntilUtc()) + ||overlaps(active.queryFromUtc(),active.queryUntilUtc(),scope.discoveryFromUtc(),scope.discoveryUntilUtc()); + } + private static boolean overlaps(LocalDateTime from,LocalDateTime until,LocalDateTime otherFrom,LocalDateTime otherUntil) { + return otherFrom!=null&&otherUntil!=null&&from.isBefore(otherUntil)&&until.isAfter(otherFrom); + } + private String reviewStatus(Scope scope) { + if(scope==null||scope.reviewTargetDays()==null||scope.reviewTargetDays()<1)return "UNKNOWN"; + if(scope.knownOrderRoundFinishedAtUtc()==null||scope.historyRoundFinishedAtUtc()==null)return "PENDING"; + Instant deadline=clock.instant().minusSeconds(scope.reviewTargetDays()*86400L); + return utc(scope.knownOrderRoundFinishedAtUtc()).isBefore(deadline)||utc(scope.historyRoundFinishedAtUtc()).isBefore(deadline)?"OVERDUE":"REVIEWED"; + } + private Task relatedUpstream(Task task,Context context) { + if(task.upstreamTaskId()==null)return null; + Task parent=context.tasks().get(task.upstreamTaskId()); + return parent!=null&&"ACCOUNT_DISCOVERY".equals(parent.taskRole())&&Objects.equals(task.sourceAccount(),parent.sourceAccount())?parent:null; + } + private CollectionResponses.Task taskView(Task t,Scope scope,CollectionResponses.Protection protection,Context context) { + Attempt latest=context.latest().get(t.id()); + String blocked=retryBlock(t,scope,protection,context); + Task root=t.validationRootTaskId()==null?null:context.tasks().get(t.validationRootTaskId()); + Attempt result=context.published().get(t.resultPublishedRunId()); + String resultState=t.resultPublishedRunId()==null?"NOT_PUBLISHED":result==null||scope==null||!Objects.equals(scope.id(),result.scopeId()) + ||!Objects.equals(t.warehouseCode(),result.warehouseCode())||!Objects.equals(t.scopeHash(),result.scopeHash())?"RESULT_UNAVAILABLE" + :latest!=null&&"REUSED".equals(latest.status())?"REUSED":latest!=null&&"PUBLISHED".equals(latest.status())?"PUBLISHED":"RESULT_AVAILABLE"; + return new CollectionResponses.Task(t.taskNo(),t.taskType(),t.dataFamily(),t.warehouseCode(),t.taskRole(),t.executionPurpose(), + utc(t.queryFromUtc()),utc(t.queryUntilUtc()),t.status(),t.applyState(),t.attemptCount(),latest==null?null:latest.attemptNo(), + resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()), + blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()), + validation(root==null?t:root),latest==null?null:attemptView(latest,t,blocked==null)); + } + private String retryBlock(Task task,Scope scope,CollectionResponses.Protection protection,Context context) { + if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK"; + if(!"BUSINESS".equals(task.executionPurpose()))return "VALIDATION_NOT_RETRYABLE"; + String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled; + if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS"; + if(!Set.of("DEAD","RETRY_WAIT").contains(task.status()))return "TASK_NOT_RETRYABLE"; + if(scope==null||!Objects.equals(scope.scopeHash(),task.scopeHash()))return "SCOPE_CHANGED"; + if(scope.leaseOwner()!=null&&scope.leaseUntilUtc()!=null&&clock.instant().isBefore(utc(scope.leaseUntilUtc())))return "SCOPE_LEASE_BUSY"; + Task parent=relatedUpstream(task,context); + if(task.upstreamTaskId()!=null&&(parent==null||!"SUCCEEDED".equals(parent.status())))return "UPSTREAM_NOT_READY"; + return null; + } + private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) { + boolean terminal=TERMINAL.contains(run.status()); + boolean same=task!=null&&Objects.equals(run.attemptNo(),task.attemptCount()); + Map progress=progress(terminal?run.progressSnapshotJson():same?task.progressJson():null); + Instant updated=terminal?progressInstant(progress,"summaryAsOf"):same?utc(task.progressUpdatedAtUtc()):null; + Instant started=utc(run.fetchStartedAtUtc()),finished=utc(run.finishedAtUtc()),heartbeat=utc(run.heartbeatAtUtc()); + String health=terminal?"TERMINATED":heartbeat==null||!clock.instant().isBefore(heartbeat.plusSeconds(config.getLeaseSeconds()))?"UNCONFIRMED":"HEALTHY"; + Long elapsed=started==null?null:Math.max(0,Duration.between(started,finished==null?clock.instant():finished).getSeconds()); + return new CollectionResponses.Attempt(run.id(),run.taskNo(),run.attemptNo(),run.status(),run.lastPhase(),run.executionPurpose(), + started,finished,progressInstant(progress,"stageStartedAt"),updated,heartbeat,health,elapsed,run.errorCode(),safeMessage(run.errorMessage()), + retryAllowed&&same&&"BUSINESS".equals(run.executionPurpose())&&Set.of("FAILED","ABORTED","YIELDED").contains(run.status()), + run.coverageStatus(),utc(run.coveredFromUtc()),utc(run.coveredUntilUtc()),utc(run.publishedAtUtc()),progress); + } + private String safeMessage(String error) { + if(error==null)return null; + // Errors are already sanitized by the write side; avoid exposing arbitrary payloads here. + return "采集尝试失败,请根据错误码查看受控诊断记录"; + } + private Instant progressInstant(Map progress,String field) { + Object value=progress.get(field); + try{return value instanceof String text?Instant.parse(text):null;}catch(DateTimeException error){return null;} + } + private CollectionResponses.Protection protection(String account) { + var p=mapper.protection(account); + JsonNode budget=p==null?null:boundedObject(p.apiBudgetJson()); + return new CollectionResponses.Protection(p==null?null:utc(p.apiCooldownUntilUtc()),p==null?null:p.apiPauseReason(), + config.getDailyRequestBudget(),number(budget,"reservedHttp"),number(budget,"confirmedHttp"), + budget==null||!budget.path("budgetDate").isTextual()?null:budget.path("budgetDate").textValue()); + } + private String executionBlock(String warehouse,CollectionResponses.Protection protection) { + if("OFF".equals(config.getCollectionMode()))return "COLLECTION_OFF"; + if(!Set.of("BUSINESS","VALIDATION").contains(config.getCollectionMode()))return "COLLECTION_MODE_INVALID"; + if("BUSINESS".equals(config.getCollectionMode())&&!"production".equals(config.getEnvironment()))return "BUSINESS_ENVIRONMENT_REJECTED"; + if(!config.isExecutionEnabled())return "EXECUTION_DISABLED"; + if(!config.getWarehouses().containsKey(warehouse))return "WAREHOUSE_NOT_CONFIGURED"; + var w=config.getWarehouses().get(warehouse); + if(!"Asia/Shanghai".equals(w.getBusinessTimezone())||!"Asia/Shanghai".equals(w.getSourceTimezone())||!"PCS".equals(config.getQuantityUnit()))return "TIMEZONE_OR_UNIT_NOT_CONFIRMED"; + if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED"; + if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; + if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() + ||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID"; + if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED"; + if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate()) + &&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED"; + return null; + } + private Instant nextSchedule(String warehouse,String family) { + var w=config.getWarehouses().get(warehouse); + try {var next=CronExpression.parse("OUTBOUND".equals(family)?w.getOutboundCron():w.getInventoryCron()).next(clock.instant().atZone(BUSINESS_ZONE));return next==null?null:next.toInstant();} + catch(IllegalArgumentException ex){return null;} + } + private CollectionReadMapper.Filter filter(CollectionQuery q) { + long offset=(long)(current(q)-1)*size(q);if(offset>Integer.MAX_VALUE)throw new BizException("分页范围过大"); + return new CollectionReadMapper.Filter(q.sourceAccount(),q.warehouseCode(),q.dataFamily(),q.taskType(),q.status(),q.taskNo(), + q.dateFrom()==null?null:LocalDateTime.ofInstant(q.dateFrom().atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC), + q.dateUntil()==null?null:LocalDateTime.ofInstant(q.dateUntil().plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant(),ZoneOffset.UTC), + (int)offset,size(q)); + } + private Bound bound(CollectionQuery q,boolean run,CollectionReadMapper.Filter f) { + if(q.listUpperId()!=null)return new Bound(LocalDateTime.ofInstant(q.listUpperCreatedAt(),ZoneOffset.UTC),q.listUpperId()); + return run?mapper.runBound(f):mapper.taskBound(f); + } + private void validate(CollectionQuery q,boolean warehouseRequired) { + if(q==null||q.sourceAccount()==null||q.sourceAccount().isBlank()||!q.sourceAccount().equals(config.getSourceAccount()))throw new BizException("来源账号未配置或无权访问"); + if(warehouseRequired&&(q.warehouseCode()==null||q.warehouseCode().isBlank()))throw new BizException("必须选择仓库"); + if(q.warehouseCode()!=null&&!config.getWarehouses().containsKey(q.warehouseCode()))throw new BizException("仓库不在配置范围内"); + if(q.dataFamily()!=null&&!Set.of("OUTBOUND","INVENTORY").contains(q.dataFamily()))throw new BizException("任务族无效"); + if(current(q)<1||size(q)<1||size(q)>100)throw new BizException("分页参数无效"); + if((q.listUpperCreatedAt()==null)!=(q.listUpperId()==null)||q.listUpperId()!=null&&q.listUpperId()<0)throw new BizException("分页快照上界无效"); + if(current(q)>1&&q.listUpperId()==null)throw new BizException("翻页必须携带首屏快照上界"); + if(q.dateFrom()!=null&&q.dateUntil()!=null&&q.dateUntil().isBefore(q.dateFrom()))throw new BizException("创建日期范围倒置"); + if(q.taskNo()!=null&&(q.taskNo().isBlank()||q.taskNo().length()>64))throw new BizException("任务号无效"); + } + private static int current(CollectionQuery q){return q.current()==null?1:q.current();} + private static int size(CollectionQuery q){return q.size()==null?20:q.size();} + private static Instant utc(LocalDateTime t){return t==null?null:t.toInstant(ZoneOffset.UTC);} + + /** Unknown/invalid quantities stay null; arbitrary raw payload fields never pass through. */ + Map progress(String source) { + JsonNode root=boundedObject(source);if(root==null)return Map.of(); + Map result=projectProgress(root); + if(root.path("stages").isArray()) { + List> stages=new ArrayList<>(); + if(root.path("stages").size()>16)return Map.of(); + root.path("stages").forEach(stage->{if(stage.isObject())stages.add(projectProgress(stage));}); + result.put("stages",stages); + } + return Collections.unmodifiableMap(result); + } + private Map projectProgress(JsonNode node) { + Map result=new LinkedHashMap<>(); + for(String field:PROGRESS_FIELDS) { + JsonNode value=node.get(field);if(value==null)continue; + if(COUNTS.contains(field))result.put(field,value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null); + else if(Set.of("totalKnown","directoryComplete").contains(field))result.put(field,value.isBoolean()?value.booleanValue():null); + else if(value.isTextual()&&value.textValue().length()<=128)result.put(field,value.textValue()); + } + if(result.containsKey("unit")&&!UNITS.contains(result.get("unit")))result.put("unit",null); + if(result.containsKey("countUnit")&&!UNITS.contains(result.get("countUnit")))result.put("countUnit",null); + if(result.containsKey("countBasis")&&!Set.of("TASK_CUMULATIVE","ATTEMPT_ONLY").contains(result.get("countBasis")))result.put("countBasis",null); + if(result.containsKey("progressScope")&&!Set.of("ACCOUNT_SHARED","WAREHOUSE").contains(result.get("progressScope")))result.put("progressScope",null); + if(!Boolean.TRUE.equals(result.get("totalKnown")))result.put("total",null); + if("NONE".equals(result.get("unit"))) { + result.put("completed",null);result.put("total",null);result.put("totalKnown",false); + } + return result; + } + private JsonNode boundedObject(String value) { + if(value==null||value.getBytes(StandardCharsets.UTF_8).length>8192)return null; + try {JsonNode node=json.readTree(value);return node!=null&&node.isObject()?node:null;}catch(Exception ex){return null;} + } + private CollectionResponses.Validation validation(Task root) { + if(!"VALIDATION".equals(root.executionPurpose()))return null; + JsonNode budget=boundedObject(root.validationBudgetJson()); + Map limits=new LinkedHashMap<>(),consumed=new LinkedHashMap<>(); + for(String key:List.of("maxPages","maxPageSize","maxDistinctDetails","maxDetailHttp","maxInventoryPagesPerWarehouse","maxHttp"))limits.put(key,number(budget,key)); + for(String key:List.of("pages","detailHttp","http","confirmedHttp"))consumed.put(key,number(budget,key)); + consumed.put("distinctDetails",budget==null||!budget.path("detailKeys").isArray()?null:(long)budget.path("detailKeys").size()); + // detailKeys themselves contain upstream identities and are never exposed. + return new CollectionResponses.Validation(root.taskNo(),Collections.unmodifiableMap(limits),Collections.unmodifiableMap(consumed), + budgetInstant(budget,"deadlineAtUtc"),budget==null?null:fixedReason(budget.path("stopReason"))); + } + private String fixedReason(JsonNode value) { + if(value==null||value.isNull()||!value.isTextual())return null; + return value.textValue().matches("[A-Z0-9_]{1,64}")?value.textValue():"STOP_REASON_UNAVAILABLE"; + } + private Long number(JsonNode root,String field) { + JsonNode value=root==null?null:root.get(field); + return value!=null&&value.isIntegralNumber()&&value.canConvertToLong()&&value.longValue()>=0?value.longValue():null; + } + private Instant budgetInstant(JsonNode root,String field) { + try{return root==null||!root.path(field).isTextual()?null:utc(LocalDateTime.parse(root.path(field).textValue()));}catch(DateTimeException ex){return null;} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java new file mode 100644 index 000000000..c1eeb40b4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.List; +import org.apache.ibatis.annotations.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.CollectionReadRows.*; + +@Mapper +public interface CollectionReadMapper { + String DAILY_SAMPLE_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_scope s ON s.id=r.scope_id" + + " AND s.source_account=r.source_account AND s.warehouse_code=r.warehouse_code AND s.data_family='INVENTORY'" + + " WHERE r.source_account=#{account} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS'" + + " AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE'" + + " AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1'" + + " AND r.warehouse_code IN #{w}"; + @Select("") + List inventoryDailySamples(@Param("account") String account, + @Param("warehouses") List warehouses, @Param("from") LocalDate from, @Param("until") LocalDate until); + @Select("") + List inventoryAccumulationStarts(@Param("account") String account, + @Param("warehouses") List warehouses); + record Filter(String account, String warehouse, String family, String type, String status, + String taskNo, LocalDateTime from, LocalDateTime until, int offset, int size) {} + String SCOPE = "id,warehouse_code,data_family,scope_hash,business_timezone,schedule_cron,stale_after_seconds," + + "initial_from_utc,initial_until_utc,discovery_from_utc,discovery_until_utc,rebuild_required,published_run_id," + + "known_order_round_finished_at_utc,history_round_finished_at_utc,review_target_days,lease_owner,lease_until_utc"; + String TASK = "t.id,t.task_no,t.source_account,t.warehouse_code,s.data_family,t.task_role,t.task_type," + + "t.execution_purpose,t.scope_hash,t.status,t.apply_state,t.attempt_count,t.query_from_utc,t.query_until_utc," + + "t.next_retry_at_utc,t.upstream_task_id,t.validation_root_task_id,t.result_published_run_id," + + "t.progress_json,t.progress_updated_at_utc,t.validation_budget_json,t.created_at,t.updated_at"; + String ATTEMPT = "r.id,r.task_id,t.task_no,r.attempt_no,r.status,r.last_phase,r.execution_purpose," + + "r.fetch_started_at_utc,r.fetch_finished_at_utc,r.build_started_at_utc,r.finished_at_utc," + + "r.published_at_utc,r.heartbeat_at_utc,r.progress_snapshot_json,r.coverage_status," + + "r.covered_from_utc,r.covered_until_utc,r.error_code,r.error_message,r.created_at,r.warehouse_code,r.scope_id,r.scope_hash"; + String TASK_FROM = " FROM ll_selfop_sync_task t JOIN ll_selfop_sync_scope s ON s.id=t.scope_id " + + "AND s.source_account=t.source_account AND s.warehouse_code=t.warehouse_code "; + String RUN_FROM = " FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id " + + "AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code " + + "JOIN ll_selfop_sync_scope s ON s.id=r.scope_id AND s.id=t.scope_id "; + String FILTER = " WHERE t.source_account=#{f.account} AND t.warehouse_code=#{f.warehouse} " + + "AND t.task_role='WAREHOUSE_SYNC' " + + "AND s.data_family=#{f.family} " + + "AND t.task_type=#{f.type} " + + "AND t.task_no=#{f.taskNo} "; + String TASK_FILTER = FILTER + "AND t.status=#{f.status} " + + "AND t.created_at >= #{f.from} " + + "AND t.created_at < #{f.until} "; + String RUN_FILTER = FILTER + "AND r.status=#{f.status} " + + "AND r.created_at >= #{f.from} " + + "AND r.created_at < #{f.until} "; + String TASK_BOUND = " AND (t.created_at < #{b.createdAt} OR (t.created_at=#{b.createdAt} AND t.id <= #{b.id})) "; + String RUN_BOUND = " AND (r.created_at < #{b.createdAt} OR (r.created_at=#{b.createdAt} AND r.id <= #{b.id})) "; + + @Select("") + List scopes(@Param("account") String account, @Param("warehouse") String warehouse); + @Select("") + Bound taskBound(@Param("f") Filter filter); + @Select("") + Bound runBound(@Param("f") Filter filter); + @Select("") + long taskCount(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("") + List tasks(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("") + long runCount(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("") + List runs(@Param("f") Filter filter, @Param("b") Bound bound); + @Select("SELECT " + TASK + TASK_FROM + " WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} AND t.task_no=#{taskNo}") + Task task(@Param("account") String account, @Param("warehouse") String warehouse, @Param("taskNo") String taskNo); + @Select("SELECT api_cooldown_until_utc,api_pause_reason,api_budget_json FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} LIMIT 1") + Protection protection(@Param("account") String account); + + @Select("") + List tasksByIds(@Param("account") String account,@Param("ids") List ids); + @Select("") + List latestAttempts(@Param("account") String account,@Param("ids") List ids); + @Select("") + List publishedRuns(@Param("account") String account,@Param("ids") List ids); + @Select("") + List currentTasks(@Param("account") String account,@Param("ids") List ids); + @Select("") + List lastSuccessfulTasks(@Param("account") String account,@Param("ids") List ids); + // The one account active task is separately related to warehouses by actual persisted windows. + @Select("SELECT " + TASK + " FROM ll_selfop_outbound_discovery_scope d JOIN ll_selfop_sync_task t ON t.id=d.active_task_id" + + " AND t.source_account=d.source_account LEFT JOIN ll_selfop_sync_scope s ON s.id=t.scope_id" + + " WHERE d.source_account=#{account} AND t.task_role='ACCOUNT_DISCOVERY' LIMIT 1") + Task sharedActiveAccount(@Param("account") String account); + @Select("") + List barrierWarehouses(@Param("account") String account,@Param("warehouses") List warehouses); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java new file mode 100644 index 000000000..811c5be78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.LocalDateTime; +import java.time.LocalDate; + +/** Narrow read projections: no raw manifests, stage payloads or business detail tables. */ +public final class CollectionReadRows { + private CollectionReadRows() {} + public record Bound(LocalDateTime createdAt, Long id) {} + public record InventorySample(String warehouseCode, LocalDate sampleDate) {} + public record Scope(Long id, String warehouseCode, String dataFamily, String scopeHash, + String businessTimezone, String scheduleCron, Integer staleAfterSeconds, + LocalDateTime initialFromUtc, LocalDateTime initialUntilUtc, + LocalDateTime discoveryFromUtc, LocalDateTime discoveryUntilUtc, + Integer rebuildRequired, Long publishedRunId, + LocalDateTime knownOrderRoundFinishedAtUtc, LocalDateTime historyRoundFinishedAtUtc, Integer reviewTargetDays, + String leaseOwner, LocalDateTime leaseUntilUtc) {} + public record Task(Long id, String taskNo, String sourceAccount, String warehouseCode, + String dataFamily, String taskRole, String taskType, String executionPurpose, + String scopeHash, String status, String applyState, Integer attemptCount, + LocalDateTime queryFromUtc, LocalDateTime queryUntilUtc, LocalDateTime nextRetryAtUtc, + Long upstreamTaskId, Long validationRootTaskId, Long resultPublishedRunId, + String progressJson, LocalDateTime progressUpdatedAtUtc, String validationBudgetJson, + LocalDateTime createdAt, LocalDateTime updatedAt) {} + public record Attempt(Long id, Long taskId, String taskNo, Integer attemptNo, + String status, String lastPhase, String executionPurpose, + LocalDateTime fetchStartedAtUtc, LocalDateTime fetchFinishedAtUtc, + LocalDateTime buildStartedAtUtc, LocalDateTime finishedAtUtc, + LocalDateTime publishedAtUtc, LocalDateTime heartbeatAtUtc, + String progressSnapshotJson, String coverageStatus, + LocalDateTime coveredFromUtc, LocalDateTime coveredUntilUtc, + String errorCode, String errorMessage, LocalDateTime createdAt, String warehouseCode, Long scopeId, String scopeHash) {} + public record Protection(LocalDateTime apiCooldownUntilUtc, String apiPauseReason, String apiBudgetJson) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java new file mode 100644 index 000000000..3d9aec8b4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public final class CollectionResponses { + private CollectionResponses() {} + public record Page(int current, int size, long total, Instant listUpperCreatedAt, + Long listUpperId, Instant observedAt, List records) {} + public record Overview(String sourceAccount, String environmentLabel, String collectionMode, + Instant observedAt, List warehouses) {} + public record Warehouse(String warehouseCode, String warehouseName, String dataFamily, + boolean executionEnabled, boolean publicationEnabled, String disabledReason, + Instant nextScheduledAt, Instant observedAt, Instant initialFrom, Instant initialUntil, + String initialCoverageStatus, Instant salesCoveredFrom, Instant salesCoveredUntil, + String lastSuccessfulTaskNo, Instant lastSucceededAt, Instant lastPublishedAt, + Instant inventoryObservedFrom, Instant inventoryObservedUntil, String dataFreshness, + boolean rebuildRequired, String revisionReviewStatus, Task currentTask, + Task sharedUpstream, Protection protection, InventoryDailySamples inventoryDailySamples) {} + public record InventoryDailySamples(LocalDate windowFrom, LocalDate windowUntil, int expectedDays, + int sampleCount, List missingDates, LocalDate accumulationStartedOn) {} + public record Task(String taskNo, String taskType, String dataFamily, String warehouseCode, + String taskRole, String executionPurpose, Instant queryFrom, Instant queryUntil, + String taskStatus, String applyState, Integer attemptCount, Integer latestAttemptNo, + String resultPublicationState, Long resultPublishedRunId, Instant nextRetryAt, + List allowedActions, String actionBlockReason, Instant progressUpdatedAt, + Map progress, Validation validation, Attempt latestAttempt) {} + public record TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {} + public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase, + String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt, + Instant progressUpdatedAt, Instant heartbeatAt, String executionHealth, Long elapsedSeconds, + String errorCode, String failureSummary, boolean retryable, String coverageStatus, + Instant coveredFrom, Instant coveredUntil, Instant publishedAt, Map progress) {} + public record Protection(Instant accountCooldownUntil, String accountPauseReason, + Integer dailyRequestBudget, Long reservedHttpRequests, Long confirmedHttpRequests, String budgetDate) {} + public record Validation(String validationSessionNo, Map budgetLimits, + Map budgetConsumed, Instant deadlineAt, String stopReason) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java new file mode 100644 index 000000000..c74863d35 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import java.time.LocalDateTime; +/** Fixed reason; committed reservations are never refunded after an ambiguous outcome. */ +public final class EgressRejectedException extends RuntimeException { + private final boolean sampleDone; private final LocalDateTime waitUntilUtc; + public EgressRejectedException(String code, boolean sampleDone, LocalDateTime waitUntilUtc) {super(code);this.sampleDone=sampleDone;this.waitUntilUtc=waitUntilUtc;} + public String code(){return getMessage();} public boolean sampleDone(){return sampleDone;} public LocalDateTime waitUntilUtc(){return waitUntilUtc;} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java new file mode 100644 index 000000000..50228a3b3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import java.time.*; +/** Bounded durable account journal: only one outstanding attempt; old IDs cannot match a new permit. */ +public final class SelfopApiBudget { + public int dailyLimit,minIntervalMs; public String configHash; + public LocalDate budgetDate; public long reservedHttp,confirmedHttp; public int rateLimitFailures; + public LocalDateTime lastRequestAtUtc; public String importedGrantId; public Pending pending; + public static final class Pending { + public SelfopEgressDtos.EgressContext context; public SelfopEgressDtos.Operation operation; public String requestHash; public String internalRequestHash; public String completionHash; + public Long taskId,runId; public LocalDateTime expiresAtUtc; public boolean consumed; public boolean completed; public boolean abandoned; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java new file mode 100644 index 000000000..591fd1485 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import java.time.ZoneOffset; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.springframework.web.bind.annotation.*; +@RestController +@RequestMapping("/internal/lastleg/self-operated-analytics/egress") +public class SelfopEgressController { + private final InternalServiceTokenGuard guard;private final SelfopEgressService service; + public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;} + @PostMapping("/authorize") public ApiResponse authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}} + @PostMapping("/complete") public ApiResponse complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java new file mode 100644 index 000000000..438f90d82 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import java.time.Instant; +public final class SelfopEgressDtos { + private SelfopEgressDtos(){} + public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY} + public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){} + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) + public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){} + public record AuthorizationRequest(EgressContext context,Operation operation,String requestHash){} + public record AttemptPermit(EgressContext context,Operation operation,String requestHash,int allowedAttempts,Instant expiresAt,boolean exclusiveConfirmed,boolean budgetConsumed){} + public record CompletionRequest(EgressContext context,Operation operation,String requestHash,int actualHttpAttempts,String errorCode,Integer upstreamStatus,Integer sourceCode,boolean accountPaused,Instant cooldownUntil){} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java new file mode 100644 index 000000000..ce9592030 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +@Mapper +public interface SelfopEgressMapper { + @Select("SELECT * FROM ll_selfop_outbound_discovery_scope WHERE source_account=#{account} FOR UPDATE") SelfopOutboundDiscoveryScope lockAccount(String account); + @Select("SELECT * FROM ll_selfop_sync_task WHERE id=#{id} FOR UPDATE") SelfopSyncTask lockTask(Long id); + @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") SelfopSyncRun lockRun(Long id); + @Select("SELECT COUNT(*) FROM ll_selfop_sync_scope WHERE id=#{scopeId} AND fence_token=#{fence} AND lease_until_utc>#{now}") int warehouseFence(@Param("scopeId") Long scopeId,@Param("fence") Long fence,@Param("taskId") Long taskId,@Param("now") LocalDateTime now); + @Update("UPDATE ll_selfop_outbound_discovery_scope SET api_budget_json=#{apiBudgetJson},api_cooldown_until_utc=#{apiCooldownUntilUtc},api_pause_reason=#{apiPauseReason},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveAccount(SelfopOutboundDiscoveryScope row); + @Update("UPDATE ll_selfop_sync_task SET validation_budget_json=#{validationBudgetJson},status=#{status},next_retry_at_utc=#{nextRetryAtUtc},updated_at=UTC_TIMESTAMP(3) WHERE id=#{id}") int saveRoot(SelfopSyncTask row); + @Update("UPDATE ll_selfop_sync_run SET status=#{status},error_code=#{reason},finished_at_utc=#{now},progress_snapshot_json=(SELECT progress_json FROM ll_selfop_sync_task WHERE id=task_id) WHERE task_id IN (SELECT id FROM ll_selfop_sync_task WHERE validation_root_task_id=#{root}) AND status IN ('FETCHING','VALIDATED','YIELDED')") int terminateValidationRuns(@Param("root") Long root,@Param("status") String status,@Param("reason") String reason,@Param("now") LocalDateTime now); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java new file mode 100644 index 000000000..9bd106d97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +@Service +public class SelfopEgressService { + private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock; + @Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());} + SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;} + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);} + @Transactional(noRollbackFor=EgressRejectedException.class) + public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){ + if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID"); + LocalDateTime now=now();gate(context.sourceAccount(),context.executionPurpose(),context.exclusiveGrantId(),now); + if(operation==null||requestHash==null||!requestHash.matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_INVALID"); + var account=account(context.sourceAccount());var budget=budget(account,now);save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount()); + var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId());verify(task,run,root,account,now); + checkAccount(account,budget,now); + SelfopValidationBudget validation=null; + if("VALIDATION".equals(context.executionPurpose())){ + validation=validation(root);String limit=validation.limit(now,operation,pageSize,warehouseCode,detailKey); + if(limit!=null){stop(root,validation,limit,false,now);throw new EgressRejectedException(limit,true,null);} + if(operation==Operation.PRODUCT_INVENTORY&&(warehouseCode==null||!config.getWarehouses().containsKey(warehouseCode)))throw reject("EGRESS_WAREHOUSE_INVALID"); + if(operation==Operation.OUTBOUND_DETAIL&&(detailKey==null||detailKey.isBlank()))throw reject("EGRESS_DETAIL_IDENTITY_INVALID"); + } + int interval="VALIDATION".equals(context.executionPurpose())?Math.max(5000,budget.minIntervalMs):budget.minIntervalMs; + if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000)); + if(budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8)); + var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId()); + var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds()))); + if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); + if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; + budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now; + if(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);} + save(account,budget);return outbound; + } + @Transactional(noRollbackFor=EgressRejectedException.class) + public AttemptPermit authorize(AuthorizationRequest request){ + requireRequest(request);var c=request.context();var now=now();gate(c.sourceAccount(),c.executionPurpose(),c.exclusiveGrantId(),now); + var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pendingForAuthorization(budget,c,request.operation()); + var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); + var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);verify(task,run,root,account,now); + if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); + if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED"); + if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}} + if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID"); + pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(30);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget); + return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true); + } + @Transactional(noRollbackFor=EgressRejectedException.class) + public void complete(CompletionRequest request){ + if(request==null||request.context()==null||request.actualHttpAttempts()<0||request.actualHttpAttempts()>1)throw reject("EGRESS_COMPLETION_INVALID"); + var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pending(budget,c,request.operation(),request.requestHash()); + String completionHash=SelfopSupport.hash(request); + if(pending.completed&&!pending.abandoned){ + if(!Objects.equals(pending.completionHash,completionHash))throw reject("EGRESS_COMPLETION_CONFLICT"); + return; + } + if(!pending.consumed)throw reject("EGRESS_COMPLETION_NOT_AUTHORIZED"); + pending.completed=true;pending.abandoned=false;pending.completionHash=completionHash;budget.confirmedHttp+=request.actualHttpAttempts(); + var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount()); + SelfopValidationBudget validation="VALIDATION".equals(c.executionPurpose())?validation(root):null; + if(validation!=null){validation.confirmedHttp+=request.actualHttpAttempts();root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);} + boolean auth=Integer.valueOf(401).equals(request.upstreamStatus())||Integer.valueOf(403).equals(request.upstreamStatus()) + ||"LINGXING_WMS_AUTHORIZATION_REJECTED".equals(request.errorCode())||"LINGXING_WMS_SIGNATURE_REJECTED".equals(request.errorCode()); + boolean rate=!auth&&(Integer.valueOf(429).equals(request.upstreamStatus())||Integer.valueOf(31001).equals(request.sourceCode()) + ||Integer.valueOf(429).equals(request.sourceCode())||"LINGXING_WMS_HTTP_RATE_LIMITED".equals(request.errorCode()) + ||"LINGXING_WMS_SOURCE_RATE_LIMITED".equals(request.errorCode())); + if(rate){ + budget.rateLimitFailures++;var until=now.plusSeconds(Math.max(1,config.getCooldownSeconds())*(1L<=config.getMaxRateLimitFailures()?"RATE_LIMIT_REQUIRES_REVIEW":"RATE_LIMIT_COOLDOWN"); + } + if(auth)account.setApiPauseReason("AUTHORIZATION_REQUIRES_REVIEW"); + else if(request.accountPaused()&&!rate&&recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("EGRESS_REQUIRES_REVIEW"); + if(validation!=null&&(request.errorCode()!=null||rate||request.accountPaused()||request.upstreamStatus()!=null&&request.upstreamStatus()>=400))stop(root,validation,auth?"AUTHORIZATION_REJECTED":rate?"RATE_LIMITED":"VALIDATION_PROVIDER_FAILURE",true,now); + save(account,budget); + } + /** Records the caller's uncertain result without changing the original completion or its consumption. */ + @Transactional + public void pauseUncertain(EgressContext context,Operation operation,String errorCode){ + if(context==null||operation==null)throw reject("EGRESS_CONTEXT_INVALID"); + var account=account(context.sourceAccount()); + if(!Objects.equals(account.getSourceAccount(),context.sourceAccount())||account.getApiBudgetJson()==null)throw reject("EGRESS_PERMIT_BINDING_INVALID"); + var budget=SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class); + var pending=pendingForAuthorization(budget,context,operation); + // A durable completion already confirms the supplier rate limit. Keep its recoverable cooldown. + boolean confirmedRate=pending.completed&&!pending.abandoned&&pending.completionHash!=null + &&Set.of("LINGXING_WMS_HTTP_RATE_LIMITED","LINGXING_WMS_SOURCE_RATE_LIMITED").contains(errorCode==null?"":errorCode) + &&Set.of("RATE_LIMIT_COOLDOWN","RATE_LIMIT_REQUIRES_REVIEW").contains(account.getApiPauseReason()==null?"":account.getApiPauseReason()); + if(confirmedRate)return; + if(recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("EGRESS_REQUIRES_REVIEW"); + if(mapper.saveAccount(account)!=1)throw reject("EGRESS_ACCOUNT_WRITE_FAILED"); + } + @Transactional(noRollbackFor=EgressRejectedException.class) + public void failValidation(Long rootTaskId,String code){var initial=mapper.lockTask(rootTaskId);if(initial==null)throw reject("VALIDATION_ROOT_MISSING");/* caller is no longer holding collection locks */var v=validation(initial);stop(initial,v,safeReason(code),true,now());} + @Transactional(noRollbackFor=EgressRejectedException.class) + public void finishSample(Long rootTaskId,String reason){var root=mapper.lockTask(rootTaskId);var v=validation(root);if("DEAD".equals(root.getStatus()))return;stop(root,v,safeReason(reason),false,now());} + private void gate(String account,String purpose,String grant,LocalDateTime now){ + if("OFF".equals(config.getCollectionMode())||!Objects.equals(config.getCollectionMode(),purpose)||!("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose)))throw reject("EGRESS_MODE_REJECTED"); + if("BUSINESS".equals(purpose)&&!"production".equals(config.getEnvironment()))throw reject("BUSINESS_ENVIRONMENT_REJECTED"); + if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH"); + if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + if(config.getDailyRequestBudget()<1||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED"); + } + private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;} + private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){ + var b=account.getApiBudgetJson()==null?new SelfopApiBudget():SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class); + if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID"); + LocalDate day=now.plusHours(8).toLocalDate(); + if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;} + if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget(); + b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs()); + b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds())); + if(!Objects.equals(config.getExclusiveGrantId(),b.importedGrantId)){ + if(config.getTransferredBudgetDate()==null||!config.getTransferredBudgetDate().equals(day)||config.getTransferredDailyHttpCount()<0)throw reject("HANDOFF_BUDGET_DATE_UNCONFIRMED"); + b.reservedHttp=Math.max(b.reservedHttp,config.getTransferredDailyHttpCount());b.importedGrantId=config.getExclusiveGrantId(); + if(config.getTransferredCooldownUntilUtc()!=null&&(account.getApiCooldownUntilUtc()==null||account.getApiCooldownUntilUtc().isBefore(config.getTransferredCooldownUntilUtc())))account.setApiCooldownUntilUtc(config.getTransferredCooldownUntilUtc()); + if(config.getTransferredPauseReason()!=null&&!config.getTransferredPauseReason().isBlank())account.setApiPauseReason(config.getTransferredPauseReason()); + } + return b; + } + private void checkAccount(SelfopOutboundDiscoveryScope account,SelfopApiBudget b,LocalDateTime now){ + if(b.pending!=null&&!b.pending.completed){ + if(now.isBefore(b.pending.expiresAtUtc))throw new EgressRejectedException("ACCOUNT_REQUEST_INFLIGHT",false,b.pending.expiresAtUtc); + /* A lost response may still be in the provider. Conservatively retain charge and cool before another attempt. */ + b.pending.completed=true;b.pending.abandoned=true;var until=now.plusSeconds(config.getCooldownSeconds()); + if(account.getApiCooldownUntilUtc()==null||account.getApiCooldownUntilUtc().isBefore(until))account.setApiCooldownUntilUtc(until); + if(recoverablePause(account.getApiPauseReason()))account.setApiPauseReason("REQUEST_OUTCOME_UNKNOWN");save(account,b); + } + if(account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw new EgressRejectedException("ACCOUNT_COOLDOWN_WAIT",false,account.getApiCooldownUntilUtc()); + if("RATE_LIMIT_COOLDOWN".equals(account.getApiPauseReason())||"REQUEST_OUTCOME_UNKNOWN".equals(account.getApiPauseReason()))account.setApiPauseReason(null); + if(account.getApiPauseReason()!=null)throw reject("ACCOUNT_REQUIRES_REVIEW"); + } + private SelfopSyncTask root(Long id,String purpose,String account){var t=mapper.lockTask(id);if(t==null||!Objects.equals(t.getSourceAccount(),account)||!Objects.equals(t.getExecutionPurpose(),purpose))throw reject("EGRESS_ROOT_INVALID");if("VALIDATION".equals(purpose)&&!Objects.equals(id,t.getValidationRootTaskId()))throw reject("VALIDATION_ROOT_INVALID");return t;} + private void verify(SelfopSyncTask task,SelfopSyncRun run,SelfopSyncTask root,SelfopOutboundDiscoveryScope account,LocalDateTime now){ + if(task==null||run==null||!Objects.equals(run.getTaskId(),task.getId())||!Objects.equals(run.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getExecutionPurpose(),root.getExecutionPurpose())||!Objects.equals(run.getExecutionPurpose(),task.getExecutionPurpose())||!"RUNNING".equals(task.getStatus())||!Set.of("FETCHING","VALIDATED").contains(run.getStatus()))throw reject("EGRESS_TASK_NOT_ACTIVE"); + if("VALIDATION".equals(root.getExecutionPurpose())&&(!Objects.equals(task.getValidationRootTaskId(),root.getId())||Set.of("DEAD","SAMPLE_DONE").contains(root.getStatus())))throw reject("VALIDATION_ROOT_STOPPED"); + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){if(!Objects.equals(task.getDiscoveryScopeId(),account.getId())||!Objects.equals(account.getFenceToken(),run.getFenceToken())||account.getLeaseUntilUtc()==null||!now.isBefore(account.getLeaseUntilUtc()))throw reject("EGRESS_FENCE_LOST");} + else if(!Objects.equals(task.getScopeId(),run.getScopeId())||!Objects.equals(task.getWarehouseCode(),run.getWarehouseCode())||mapper.warehouseFence(task.getScopeId(),run.getFenceToken(),task.getId(),now)!=1)throw reject("EGRESS_FENCE_LOST"); + } + private SelfopValidationBudget validation(SelfopSyncTask root){if(root==null||!"VALIDATION".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getValidationRootTaskId())||root.getValidationBudgetJson()==null)throw reject("VALIDATION_ROOT_INVALID");var b=SelfopSupport.read(root.getValidationBudgetJson(),SelfopValidationBudget.class);b.validate();return b;} + private SelfopApiBudget.Pending pendingForAuthorization(SelfopApiBudget budget,EgressContext c,Operation op){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op)throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;} + private SelfopApiBudget.Pending pending(SelfopApiBudget budget,EgressContext c,Operation op,String hash){var p=budget.pending;if(p==null||!Objects.equals(p.context,c)||p.operation!=op||!Objects.equals(p.requestHash,hash))throw reject("EGRESS_PERMIT_BINDING_INVALID");return p;} + private void stop(SelfopSyncTask root,SelfopValidationBudget v,String reason,boolean failed,LocalDateTime now){if("DEAD".equals(root.getStatus())||"SAMPLE_DONE".equals(root.getStatus()))return;v.stopReason=reason;root.setValidationBudgetJson(SelfopSupport.json(v));root.setStatus(failed?"DEAD":"SAMPLE_DONE");root.setNextRetryAtUtc(null);mapper.saveRoot(root);mapper.terminateValidationRuns(root.getId(),failed?"FAILED":"SAMPLE_DONE",reason,now);} + private void save(SelfopOutboundDiscoveryScope account,SelfopApiBudget b){account.setApiBudgetJson(SelfopSupport.json(b));if(mapper.saveAccount(account)!=1)throw reject("EGRESS_ACCOUNT_WRITE_FAILED");} + private static Long parseId(String id){try{return Long.valueOf(id);}catch(Exception e){throw reject("EGRESS_ROOT_ID_INVALID");}} + private static void requireRequest(AuthorizationRequest r){if(r==null||r.context()==null||r.operation()==null||r.requestHash()==null)throw reject("EGRESS_REQUEST_INVALID");} + private static String safeReason(String code){return code!=null&&code.matches("[A-Z0-9_]{1,64}")?code:"VALIDATION_FAILED";} + private static boolean recoverablePause(String reason){return reason==null||"RATE_LIMIT_COOLDOWN".equals(reason)||"REQUEST_OUTCOME_UNKNOWN".equals(reason);} + private static EgressRejectedException reject(String code){return new EgressRejectedException(code,false,null);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java new file mode 100644 index 000000000..617db45e4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport; +import java.time.LocalDateTime; +import java.util.*; +/** Frozen, persisted once on the root task. Limits may only be lowered before creation. */ +public final class SelfopValidationBudget { + public int maxPages=10,maxPageSize=10,maxDistinctDetails=20,maxDetailHttp=20,maxInventoryPagesPerWarehouse=2,maxHttp=40; + public int pages,detailHttp,http,confirmedHttp; + public LocalDateTime startedAtUtc,deadlineAtUtc; + public String stopReason; + public Set detailKeys=new LinkedHashSet<>(); + public Map inventoryPages=new LinkedHashMap<>(); + public static String freeze(LocalDateTime now){return freeze(now,10,20,40,300);} + public static String freeze(LocalDateTime now,int pages,int details,int http,int seconds){ + if(pages<1||details<1||http<1||seconds<1)throw new IllegalArgumentException("VALIDATION_LIMIT_INVALID"); + var b=new SelfopValidationBudget();b.maxPages=Math.min(10,pages);b.maxDetailHttp=Math.min(20,details);b.maxDistinctDetails=Math.min(20,details);b.maxHttp=Math.min(40,http);b.startedAtUtc=now;b.deadlineAtUtc=now.plusSeconds(Math.min(300,seconds));return SelfopSupport.json(b); + } + public void validate(){ + if(maxPages<1||maxPages>10||maxPageSize<1||maxPageSize>10||maxDistinctDetails<1||maxDistinctDetails>20||maxDetailHttp<1||maxDetailHttp>20||maxInventoryPagesPerWarehouse<1||maxInventoryPagesPerWarehouse>2||maxHttp<1||maxHttp>40||startedAtUtc==null||deadlineAtUtc==null||deadlineAtUtc.isAfter(startedAtUtc.plusMinutes(5))||!deadlineAtUtc.isAfter(startedAtUtc)||pages<0||detailHttp<0||http<0||confirmedHttp<0||confirmedHttp>http||detailKeys==null||inventoryPages==null)throw new EgressRejectedException("VALIDATION_BUDGET_INVALID",false,null); + } + public String limit(LocalDateTime now,SelfopEgressDtos.Operation op,int size,String warehouse,String key){ + validate();if(stopReason!=null)return stopReason; + if(!now.isBefore(deadlineAtUtc))return "VALIDATION_DEADLINE"; + if(http>=maxHttp)return "VALIDATION_HTTP_LIMIT"; + if(op!=SelfopEgressDtos.Operation.OUTBOUND_DETAIL){if(size<1||size>maxPageSize)throw new EgressRejectedException("VALIDATION_PAGE_SIZE",false,null);if(pages>=maxPages)return "VALIDATION_PAGE_LIMIT";} + if(op==SelfopEgressDtos.Operation.PRODUCT_INVENTORY&&inventoryPages.getOrDefault(warehouse,0)>=maxInventoryPagesPerWarehouse)return "VALIDATION_INVENTORY_LIMIT"; + if(op==SelfopEgressDtos.Operation.OUTBOUND_DETAIL){if(detailHttp>=maxDetailHttp)return "VALIDATION_DETAIL_HTTP_LIMIT";if(!detailKeys.contains(key)&&detailKeys.size()>=maxDistinctDetails)return "VALIDATION_DETAIL_IDENTITY_LIMIT";} + return null; + } + public void consume(SelfopEgressDtos.Operation op,String warehouse,String key){http++;if(op!=SelfopEgressDtos.Operation.OUTBOUND_DETAIL)pages++;else{detailHttp++;detailKeys.add(key);}if(op==SelfopEgressDtos.Operation.PRODUCT_INVENTORY)inventoryPages.merge(warehouse,1,Integer::sum);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java new file mode 100644 index 000000000..e3807e2f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.selfoperated.analytics.retention; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface SalesRetentionMapper { + @Select("SELECT * FROM ll_selfop_sync_scope WHERE id=#{id} FOR UPDATE") + SelfopSyncScope scope(@Param("id") Long id); + @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") + SelfopSyncRun run(@Param("id") Long id); + @Select("SELECT id FROM ll_selfop_sync_run WHERE scope_id=#{scopeId} AND status='PUBLISHED' AND execution_purpose='BUSINESS' AND id != #{current} ORDER BY published_at_utc DESC,id DESC LIMIT 1") + Long previous(@Param("scopeId") Long scopeId,@Param("current") Long current); + @Select(""" + SELECT r.* FROM ll_selfop_sync_run r + WHERE r.scope_id=#{scopeId} AND r.status='PUBLISHED' AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' + AND r.superseded_at_utc IS NOT NULL AND r.superseded_at_utc <= #{cutoff} + AND r.inventory_daily_sample_date IS NULL AND r.inventory_sample_rule_version IS NULL + AND r.id != #{current} AND (#{previous} IS NULL OR r.id != #{previous}) + AND (EXISTS (SELECT 1 FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=r.id) + OR EXISTS (SELECT 1 FROM ll_selfop_sku_profile p WHERE p.profile_run_id=r.id)) + AND NOT EXISTS ( + SELECT 1 FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id + WHERE t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code + AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED')) + AND (input.base_published_run_id=r.id OR input.reference_sales_run_id=r.id)) + ORDER BY r.superseded_at_utc,r.id LIMIT 500 + """) + List eligible(@Param("scopeId") Long scopeId,@Param("current") Long current, + @Param("previous") Long previous,@Param("cutoff") LocalDateTime cutoff); + @Select(""" + SELECT COUNT(*) FROM ll_selfop_sync_run input JOIN ll_selfop_sync_task t ON t.id=input.task_id + WHERE t.source_account=#{account} AND t.warehouse_code=#{warehouse} + AND (t.status NOT IN ('SUCCEEDED','SAMPLE_DONE','CANCELLED') OR t.apply_state IN ('APPLYING','BLOCKED')) + AND (input.base_published_run_id=#{id} OR input.reference_sales_run_id=#{id}) + """) + long activeReferences(@Param("account") String account,@Param("warehouse") String warehouse,@Param("id") Long id); + @Delete("DELETE FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{id} LIMIT #{batchSize}") + int deleteDaily(@Param("id") Long id,@Param("batchSize") int batchSize); + @Delete("DELETE FROM ll_selfop_sku_profile WHERE profile_run_id=#{id} LIMIT #{batchSize}") + int deleteProfiles(@Param("id") Long id,@Param("batchSize") int batchSize); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java new file mode 100644 index 000000000..0e7af298c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.selfoperated.analytics.retention; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.*; + +/** No scheduled execution. Enabled, explicitly requested calls can remove only old derived sales copies. */ +@Service +@RequiredArgsConstructor +public class SalesRetentionService { + private final SalesRetentionMapper mapper; + private final SelfopAnalyticsProperties config; + + public record Preview(Long scopeId,Long current,Long previous,List eligibleRunIds,LocalDateTime cutoff) { } + public record Deleted(Long runId,int dailyRows,int profileRows) { } + + @Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class) + public Preview preview(Long scopeId,LocalDateTime now) { + SelfopSyncScope scope=mapper.scope(scopeId); validateScope(scope); + Long previous=scope.getPublishedRunId()==null?null:mapper.previous(scopeId,scope.getPublishedRunId()); + LocalDateTime cutoff=now.minusDays(Math.max(7,config.getSalesRetentionDays())); + if(scope.getPublishedRunId()==null) return new Preview(scopeId,null,null,List.of(),cutoff); + return new Preview(scopeId,scope.getPublishedRunId(),previous, + mapper.eligible(scopeId,scope.getPublishedRunId(),previous,cutoff).stream().map(SelfopSyncRun::getId).toList(),cutoff); + } + + /** Every batch rechecks current pointers and active references while holding the shared OUTBOUND scope lock. */ + @Transactional(isolation=Isolation.READ_COMMITTED,rollbackFor=Exception.class) + public Deleted deleteBatch(Long scopeId,Long runId) { + SelfopSupport.require(config.isRetentionEnabled(),"SALES_RETENTION_DISABLED"); + SelfopSyncScope scope=mapper.scope(scopeId); validateScope(scope); + SelfopSyncRun run=mapper.run(runId); + Long previous=scope.getPublishedRunId()==null?null:mapper.previous(scopeId,scope.getPublishedRunId()); + SelfopSupport.require(run!=null && Objects.equals(run.getScopeId(),scopeId) + && Objects.equals(run.getSourceAccount(),scope.getSourceAccount()) + && Objects.equals(run.getWarehouseCode(),scope.getWarehouseCode()) && "WAREHOUSE_SYNC".equals(run.getRunRole()) + && "PUBLISHED".equals(run.getStatus()) && "BUSINESS".equals(run.getExecutionPurpose()) + && run.getInventoryDailySampleDate()==null && run.getInventorySampleRuleVersion()==null + && run.getPublishedAtUtc()!=null && run.getSupersededAtUtc()!=null + && !run.getSupersededAtUtc().isAfter(SelfopSupport.utcNow().minusDays(Math.max(7,config.getSalesRetentionDays()))) + && scope.getPublishedRunId()!=null && !Objects.equals(runId,scope.getPublishedRunId()) + && !Objects.equals(runId,previous),"SALES_VERSION_PROTECTED"); + SelfopSupport.require(mapper.activeReferences(scope.getSourceAccount(),scope.getWarehouseCode(),runId)==0,"SALES_INPUT_REFERENCED"); + int size=Math.max(1,Math.min(500,config.getPersistenceBatchSize())); + return new Deleted(runId,mapper.deleteDaily(runId,size),mapper.deleteProfiles(runId,size)); + } + private void validateScope(SelfopSyncScope scope) { + SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()),"SALES_RETENTION_SCOPE_INVALID"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java new file mode 100644 index 000000000..c6ff45496 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** Quality precedes zero; denominator uses all 30 complete days. */ +public final class InventoryCoveragePolicy { + private InventoryCoveragePolicy() {} + public record Result(BigDecimal average, BigDecimal days, String status) {} + public static Result calculate(String inventoryQuality, BigDecimal available, BigDecimal sales30, + boolean salesTrusted, boolean complete30, boolean observationEligible, + boolean unitConfirmed) { + if (!"VALID".equals(inventoryQuality) || available == null) + return new Result(null, null, "INVENTORY_" + inventoryQuality); + if (available.signum() < 0) return new Result(null, null, "INVENTORY_INVALID"); + BigDecimal average = unitConfirmed && salesTrusted && complete30 && observationEligible && sales30 != null + ? sales30.divide(BigDecimal.valueOf(30), 12, RoundingMode.HALF_UP) : null; + if (available.signum() == 0) return new Result(average, BigDecimal.ZERO.setScale(1), "NO_AVAILABLE_INVENTORY"); + if (!unitConfirmed) return new Result(null, null, "UNIT_UNKNOWN_OR_CONFLICT"); + if (!salesTrusted) return new Result(null, null, "SALES_UNTRUSTED"); + if (!complete30) return new Result(null, null, "COVERAGE_INSUFFICIENT"); + if (!observationEligible) return new Result(null, null, "OBSERVATION_INSUFFICIENT"); + if (sales30 == null) return new Result(null, null, "SALES_UNKNOWN"); + if (sales30.signum() == 0) return new Result(average, null, "NO_OUTBOUND_30_DAYS"); + // Algebraically stock / (sales / 30), avoiding intermediate rounding. + return new Result(average, available.multiply(BigDecimal.valueOf(30)).divide(sales30, 1, RoundingMode.HALF_UP), "VALID"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java new file mode 100644 index 000000000..614f4643c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/** Daily quantities distinguish complete facts, observed facts and unknown history. */ +public final class SalesBucketPolicy { + private SalesBucketPolicy() {} + public record Input(LocalDate date, LocalDate anchorDate, ZoneId zone, + LocalDate completeDayFrom, LocalDate completeDayUntilExclusive, + Instant coveredFrom, Instant coveredUntil, Instant evidenceAt, + BigDecimal actualQuantity, Long actualOrderCount, boolean unitConfirmed, + boolean salesKnown, boolean rebuild, boolean stale) {} + public record Point(LocalDate date, BigDecimal outboundQuantity, BigDecimal observedQuantity, + Long orderCount, String dataStatus, List reasonCodes, + Instant provedFrom, Instant provedUntil) {} + public record Summary(BigDecimal outboundQuantity, BigDecimal observedQuantity, String dataStatus, + List reasonCodes, int completeDays, int partialDays, int unknownDays) {} + + public static Point evaluate(Input input) { + Objects.requireNonNull(input.date(), "date"); + Objects.requireNonNull(input.anchorDate(), "anchorDate"); + Objects.requireNonNull(input.zone(), "zone"); + if (input.date().isAfter(input.anchorDate())) throw new IllegalArgumentException("Future bucket"); + Instant start = input.date().atStartOfDay(input.zone()).toInstant(); + Instant end = input.date().plusDays(1).atStartOfDay(input.zone()).toInstant(); + Instant provedFrom = input.coveredFrom() == null ? null : later(start, input.coveredFrom()); + Instant provedUntil = input.coveredUntil() == null ? null : earlier(end, input.coveredUntil()); + boolean intersection = provedFrom != null && provedUntil != null && provedFrom.isBefore(provedUntil); + if (!intersection) { provedFrom = null; provedUntil = null; } + List reasons = new ArrayList<>(); + if (input.rebuild()) reasons.add("SALES_REBUILD_REQUIRED"); + if (input.stale()) reasons.add("SALES_STALE"); + if (!input.unitConfirmed()) { + reasons.add("UNIT_UNKNOWN_OR_CONFLICT"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + if (!input.salesKnown()) { + reasons.add("SALES_NOT_COVERED"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + boolean today = input.date().equals(input.anchorDate()); + boolean complete = !today && input.completeDayFrom() != null && input.completeDayUntilExclusive() != null + && !input.date().isBefore(input.completeDayFrom()) + && input.date().isBefore(input.completeDayUntilExclusive()) + && input.coveredFrom() != null && !input.coveredFrom().isAfter(start) + && input.coveredUntil() != null && !input.coveredUntil().isBefore(end); + boolean actual = input.actualQuantity() != null && input.actualQuantity().signum() >= 0; + if (input.actualQuantity() != null && !actual) { + reasons.add("SALES_QUANTITY_INVALID"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + if (complete && (actual || input.evidenceAt() != null && !input.evidenceAt().isAfter(start))) { + BigDecimal quantity = actual ? input.actualQuantity() : BigDecimal.ZERO; + return point(input, quantity, quantity, actual ? input.actualOrderCount() : 0L, + "COMPLETE", reasons, provedFrom, provedUntil); + } + if (today) reasons.add("TODAY_INCOMPLETE"); + else reasons.add("SALES_DAY_INCOMPLETE"); + if (actual) return point(input, null, input.actualQuantity(), input.actualOrderCount(), + "PARTIAL", reasons, provedFrom, provedUntil); + if (intersection && input.evidenceAt() != null && later(provedFrom, input.evidenceAt()).isBefore(provedUntil)) { + reasons.add("SKU_PARTIAL_OBSERVATION"); + return point(input, null, BigDecimal.ZERO, 0L, "PARTIAL", reasons, provedFrom, provedUntil); + } + reasons.add(input.evidenceAt() == null || !input.evidenceAt().isBefore(end) + ? "SKU_HISTORY_UNKNOWN" : "SALES_NOT_COVERED"); + return point(input, null, null, null, "UNKNOWN", reasons, provedFrom, provedUntil); + } + + public static Summary aggregate(List points) { + Objects.requireNonNull(points, "points"); + int complete = 0, partial = 0, unknown = 0; + BigDecimal outbound = BigDecimal.ZERO, observed = BigDecimal.ZERO; + boolean hasObserved = false; + LinkedHashSet reasons = new LinkedHashSet<>(); + for (Point point : points) { + reasons.addAll(point.reasonCodes()); + switch (point.dataStatus()) { + case "COMPLETE" -> { complete++; outbound = outbound.add(point.outboundQuantity()); } + case "PARTIAL" -> partial++; + case "UNKNOWN" -> unknown++; + default -> throw new IllegalArgumentException("Unknown dataStatus"); + } + if (point.observedQuantity() != null) { observed = observed.add(point.observedQuantity()); hasObserved = true; } + } + boolean allComplete = !points.isEmpty() && complete == points.size(); + return new Summary(allComplete ? outbound : null, hasObserved ? observed : null, + allComplete ? "COMPLETE" : hasObserved ? "PARTIAL" : "UNKNOWN", + List.copyOf(reasons), complete, partial, unknown); + } + private static Point point(Input input, BigDecimal outbound, BigDecimal observed, Long count, + String status, List reasons, Instant from, Instant until) { + return new Point(input.date(), outbound, observed, count, status, List.copyOf(reasons), from, until); + } + private static Instant later(Instant a, Instant b) { return a.isAfter(b) ? a : b; } + private static Instant earlier(Instant a, Instant b) { return a.isBefore(b) ? a : b; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java new file mode 100644 index 000000000..6d39f9f78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.Objects; + +/** Natural-day query boundaries, resolved once for the fixed query version pair. */ +public final class SalesRangePolicy { + private SalesRangePolicy() {} + public enum Preset { TODAY, LAST_3_DAYS, THIS_WEEK, LAST_15_DAYS, THIS_MONTH, CUSTOM } + public record Range(LocalDate anchorDate, LocalDate startDate, LocalDate endDateExclusive, + int dayCount, Instant startAt, Instant endAtExclusive) {} + + public static Range resolve(String rangePreset, LocalDate startDate, LocalDate endDate, + String bucket, Instant queryAsOf, ZoneId zone, int maxQueryDays) { + Objects.requireNonNull(queryAsOf, "queryAsOf"); + Objects.requireNonNull(zone, "zone"); + if (maxQueryDays < 1 || maxQueryDays > 366) throw new IllegalArgumentException("maxQueryDays must be 1..366"); + if (!"DAY".equals(bucket)) throw new IllegalArgumentException("bucket must be DAY"); + Preset preset; + try { preset = Preset.valueOf(rangePreset); } + catch (RuntimeException ex) { throw new IllegalArgumentException("Unknown rangePreset", ex); } + LocalDate anchor = queryAsOf.atZone(zone).toLocalDate(); + LocalDate start; + LocalDate end = anchor; + if (preset == Preset.CUSTOM) { + if (startDate == null || endDate == null) throw new IllegalArgumentException("CUSTOM requires both dates"); + start = startDate; + end = endDate; + } else { + if (startDate != null || endDate != null) throw new IllegalArgumentException("Preset dates conflict with explicit dates"); + start = switch (preset) { + case TODAY -> anchor; + case LAST_3_DAYS -> anchor.minusDays(2); + case THIS_WEEK -> anchor.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + case LAST_15_DAYS -> anchor.minusDays(14); + case THIS_MONTH -> anchor.withDayOfMonth(1); + case CUSTOM -> throw new IllegalStateException("CUSTOM handled above"); + }; + } + long days = ChronoUnit.DAYS.between(start, end) + 1; + if (end.isAfter(anchor) || days < 1 || days > maxQueryDays) throw new IllegalArgumentException("Invalid query date range"); + LocalDate exclusive = end.plusDays(1); + return new Range(anchor, start, exclusive, (int) days, start.atStartOfDay(zone).toInstant(), + exclusive.atStartOfDay(zone).toInstant()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java new file mode 100644 index 000000000..46752ce75 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import java.time.Duration; +import java.time.Instant; + +/** Per-dispatch budget: cursors survive this object's lifetime in task manifests. */ +public final class SelfopBudget { + private final int limit; + private final Instant deadline; + private int requests; + public SelfopBudget(int limit,int seconds) { + if(limit<=0||seconds<=0)throw new IllegalArgumentException("SELFOP_BUDGET_INVALID"); + this.limit=limit;this.deadline=Instant.now().plusSeconds(seconds); + } + public boolean take() {if(requests>=limit||!Instant.now().isBefore(deadline))return false;requests++;return true;} + public int used(){return requests;} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java new file mode 100644 index 000000000..6023e7365 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopCommandService { + public record Command(String requestKey,String warehouseCode,String action,String taskNo, + LocalDateTime sourceFrom,LocalDateTime sourceUntil,String customerCode,String deliveryNo,String reason) { } + private final SelfopTaskService taskService; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopAnalyticsProperties config; + private final SelfopSyncTaskMapper tasks; + public SelfopCommandService(SelfopTaskService taskService,SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks){ + this.taskService=taskService;this.scopes=scopes;this.accounts=accounts;this.config=config;this.tasks=tasks; + } + @Transactional + public SelfopSyncTask request(Command c,String operator){ + identity(c.requestKey(),100);identity(c.warehouseCode(),64);require(config.getWarehouses().containsKey(c.warehouseCode()),"SELFOP_WAREHOUSE_UNCONFIGURED"); + taskService.initialize(); + if("RETRY".equals(c.action())){identity(c.taskNo(),64);identity(c.reason(),500); + var original=tasks.selectOne(new QueryWrapper().eq("task_no",c.taskNo()).eq("source_account",config.getSourceAccount()));require(original!=null,"SELFOP_TASK_SCOPE_MISMATCH"); + require("WAREHOUSE_SYNC".equals(original.getTaskRole())&&c.warehouseCode().equals(original.getWarehouseCode()),"SELFOP_TASK_SCOPE_MISMATCH"); + require(!"WAITING_SOURCE".equals(original.getStatus()),"SELFOP_RETRY_WAITING_SOURCE"); + return taskService.retry(original.getId(),c.requestKey(),operator,c.reason());} + require(Set.of("INVENTORY","BACKFILL","RECHECK","REBUILD","VALIDATION").contains(c.action()),"SELFOP_COMMAND_INVALID"); + String purpose=SelfopCollectionPolicy.configuredPurpose(config); + require(("VALIDATION".equals(purpose))==("VALIDATION".equals(c.action())),"SELFOP_VALIDATION_ENTRY_REQUIRED"); + var scope=scopes.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("warehouse_code",c.warehouseCode()) + .eq("data_family","INVENTORY".equals(c.action())?"INVENTORY":"OUTBOUND")); + var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setRequestedBy(operator);task.setRequestKey("manual:"+c.requestKey()); + task.setExecutionPurpose(purpose); + task.setTaskType("INVENTORY".equals(c.action())?"BACKFILL":c.action());task.setScopeHash(scope.getScopeHash()); + if(Set.of("INVENTORY","REBUILD").contains(c.action())){ + task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(scope.getId());task.setWarehouseCode(c.warehouseCode());task.setQueryScopeJson(scope.getScopeJson()); + }else{ + var account=accounts.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount())); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(account.getId()); + if("VALIDATION".equals(c.action())){ + require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil())&&!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_QUERY_WINDOW_INVALID"); + task.setTaskType("VALIDATION");task.setQueryScopeJson(json(Map.of("startTime",c.sourceFrom().toString(),"endTime",c.sourceUntil().toString(),"warehouses",config.getWarehouses().keySet()))); + }else if("RECHECK".equals(c.action())){identity(c.customerCode(),64);identity(c.deliveryNo(),128); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null,null,c.deliveryNo(),c.customerCode(),null))); + }else{ + require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil()),"SELFOP_QUERY_WINDOW_INVALID"); + require(!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_SLICE_MAX_ONE_DAY"); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(c.sourceFrom().toString(),c.sourceUntil().toString(),null,null,null))); + if(config.getSourceQueryTimezone()!=null){ + ZoneId source=ZoneId.of(config.getSourceQueryTimezone()); + require(source.getRules().getValidOffsets(c.sourceFrom()).size()==1&&source.getRules().getValidOffsets(c.sourceUntil()).size()==1,"SELFOP_SOURCE_TIME_AMBIGUOUS"); + task.setQueryFromUtc(c.sourceFrom().atZone(source).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + task.setQueryUntilUtc(c.sourceUntil().atZone(source).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + } + } + } + task.setRequestHash(hash(Arrays.asList(c,task.getScopeHash())));return taskService.create(task); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java new file mode 100644 index 000000000..f2aec1186 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -0,0 +1,235 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException; +import com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundPolicy; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopDiscoveryService { + public record Query(String startTime,String endTime,String deliveryNo,String customerCode,Long parentTaskId) { } + private final LingxingAnalyticsClient client; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopSyncStageMapper stages; + private final SelfopOutboundOrderMapper orders; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopTaskService taskService; + private final SelfopLeaseService leases; + private final SelfopAnalyticsProperties config; + private final TransactionTemplate tx; + public SelfopDiscoveryService(LingxingAnalyticsClient client,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs, + SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopSyncScopeMapper scopes, + SelfopOutboundDiscoveryScopeMapper accounts,SelfopTaskService taskService,SelfopLeaseService leases, + SelfopAnalyticsProperties config,TransactionTemplate tx) { + this.client=client;this.tasks=tasks;this.runs=runs;this.stages=stages;this.orders=orders;this.scopes=scopes; + this.accounts=accounts;this.taskService=taskService;this.leases=leases;this.config=config;this.tx=tx; + } + /** Network requests are outside tx; every accepted cursor/stage write checks the account fence again. */ + public void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) { + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run); + var a=accounts.selectById(task.getDiscoveryScopeId());var budget=new SelfopBudget(a.getRequestBudget(),a.getTimeBudgetSeconds()); + collect(task,run,lease,budget); + } + private void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget) { + Query query=read(task.getQueryScopeJson(),Query.class); + var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + if(query.deliveryNo()!=null) {if(!collectLocated(task,run,lease,budget,query,manifest))return;} + else { + while(!manifest.isDirectoryComplete()) { + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;} + leases.renew(lease); + var page=client.discover(SelfopRequestContext.forTask(task,run,config),LocalDateTime.parse(query.startTime()),LocalDateTime.parse(query.endTime()),manifest.getNextPage(),config.getPageSize()); + require(page.current()==manifest.getNextPage()&&page.size()==config.getPageSize(),"SELFOP_PAGE_MISMATCH"); + require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING"); + if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());} + require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); + tx.executeWithoutResult(s->{leases.verify(lease); + for(var summary:page.records()) { + identity(summary.customerCode(),64);identity(summary.deliveryNo(),128);identity(summary.warehouseCode(),64); + String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));String content=hash(summary); + if(manifest.getScan()==1){ + require(!manifest.getFirstScan().containsKey(key),"SELFOP_DIRECTORY_DUPLICATE");manifest.getFirstScan().put(key,content); + var payload=new SelfopPayloads.Order(task.getSourceAccount(),summary.customerCode(),summary.warehouseCode(),summary.deliveryNo(),summary.sourceNo(),summary.sourceStatus(),summary.sourceCreatedTime(),summary.sourceOutboundTime(),null); + var stage=stage(task,run,payload,manifest.getNextPage(),null,"PENDING"); + var prior=stages.selectOne(new QueryWrapper().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key)); + if(prior==null)stages.insert(stage);else if("PENDING".equals(prior.getValidationStatus())){ + stage.setId(prior.getId());stage.setRunId(prior.getRunId());stage.setCreatedAt(prior.getCreatedAt());stages.updateById(stage); + }else stage=prior; + var entry=new SelfopRoutingManifest.Entry();entry.setCustomerCode(summary.customerCode());entry.setDeliveryNo(summary.deliveryNo()); + entry.setWarehouseCode(summary.warehouseCode());entry.setDirectoryStageId(stage.getId());manifest.getObjects().put(key,entry); + }else{ + require(Objects.equals(manifest.getFirstScan().get(key),content),"SELFOP_DIRECTORY_UNSTABLE"); + require(!manifest.getObjects().containsKey("seen:"+key),"SELFOP_DIRECTORY_DUPLICATE"); + manifest.getObjects().put("seen:"+key,new SelfopRoutingManifest.Entry()); + } + } + manifest.setNextPage(manifest.getNextPage()+1); + if(manifest.getPages()==0||manifest.getNextPage()>manifest.getPages()) { + long count=manifest.getScan()==1?manifest.getFirstScan().size():manifest.getObjects().keySet().stream().filter(k->k.startsWith("seen:")).count(); + require(count==manifest.getTotal(),"SELFOP_DIRECTORY_COUNT_MISMATCH"); + if(manifest.getScan()==1){manifest.setScan(2);manifest.setNextPage(1);} + else{manifest.getObjects().keySet().removeIf(k->k.startsWith("seen:"));manifest.setDirectoryComplete(true);} + } + saveManifest(task,manifest); + }); + } + } + route(task,manifest,lease); + for(var entry:manifest.getObjects().entrySet()) { + var ref=entry.getValue(); + if(ref.getRefreshTaskId()!=null){ + var refresh=tasks.selectById(ref.getRefreshTaskId());require(refresh!=null,"SELFOP_REFRESH_TASK_MISSING"); + if(!"SUCCEEDED".equals(refresh.getStatus())){ + var refreshRun=taskService.begin(refresh.getId(),lease);refresh=tasks.selectById(refresh.getId());collect(refresh,refreshRun,lease,budget); + refresh=tasks.selectById(refresh.getId()); + if(!"SUCCEEDED".equals(refresh.getStatus())){taskService.yield(task.getId(),run.getId(),lease);return;} + } + var refreshed=read(refresh.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(entry.getKey()); + require(refreshed!=null&&refreshed.getEffectiveStageId()!=null,"SELFOP_REFRESH_REFERENCE_MISSING"); + ref.setEffectiveStageId(refreshed.getEffectiveStageId());ref.setWarehouseCode(refreshed.getWarehouseCode()); + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + } + var latest=stages.selectOne(new QueryWrapper().eq("object_type","OUTBOUND_ORDER") + .eq("object_key_hash",entry.getKey()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1")); + var effective=ref.getEffectiveStageId()==null?null:stages.selectById(ref.getEffectiveStageId()); + boolean newer=latest!=null&&(effective==null?latest.getObservationSequence()>task.getObservationBaseline():latest.getObservationSequence()>effective.getObservationSequence()); + if(effective!=null&&!newer) continue; + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;} + leases.renew(lease); + SelfopSyncTask fetchTask=task;SelfopSyncRun fetchRun=run; + if(newer){ + var recheck=new SelfopSyncTask();recheck.setSourceAccount(task.getSourceAccount());recheck.setTaskRole("ACCOUNT_DISCOVERY"); + recheck.setExecutionPurpose(task.getExecutionPurpose()); + recheck.setDiscoveryScopeId(task.getDiscoveryScopeId());recheck.setTaskType("RECHECK");recheck.setScopeHash(task.getScopeHash()); + recheck.setRequestKey("refresh:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+latest.getObservationSequence()); + recheck.setQueryScopeJson(json(new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId())));recheck.setRequestHash(hash(recheck.getQueryScopeJson())); + fetchTask=taskService.create(recheck);ref.setRefreshTaskId(fetchTask.getId());ref.setNewestSeenSequence(latest.getObservationSequence()); + fetchRun=taskService.begin(fetchTask.getId(),lease); + fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); + } + long observation=leases.nextObservation(lease); + SelfopPayloads.Order detail; + try {detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());} + catch(AnalyticsClientException staleIdentity){ + if(!Set.of("DETAIL_IDENTITY_MISMATCH","INTEGRATION_DATA_MISSING","LINGXING_WMS_SOURCE_REJECTED","LINGXING_WMS_INVALID_RESPONSE").contains(staleIdentity.code())||staleIdentity.retryable())throw staleIdentity; + var relocate=new SelfopSyncTask();relocate.setSourceAccount(task.getSourceAccount());relocate.setTaskRole("ACCOUNT_DISCOVERY");relocate.setDiscoveryScopeId(task.getDiscoveryScopeId()); + relocate.setExecutionPurpose(task.getExecutionPurpose()); + relocate.setTaskType("RECHECK");relocate.setScopeHash(task.getScopeHash());relocate.setRequestKey("relocate:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+observation); + var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson())); + fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId()); + fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + var located=new SelfopRoutingManifest(); + if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);});taskService.yield(task.getId(),run.getId(),lease);return;} + ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode()); + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;} + observation=leases.nextObservation(lease);detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo()); + } + require(Objects.equals(detail.sourceAccount(),task.getSourceAccount())&&Objects.equals(detail.customerCode(),ref.getCustomerCode()) + &&Objects.equals(detail.deliveryNo(),ref.getDeliveryNo()),"SELFOP_DETAIL_IDENTITY_MISMATCH"); + require(detail.items()!=null&&detail.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID"); + identity(detail.warehouseCode(),64); + var finalFetchTask=fetchTask;var finalFetchRun=fetchRun;var acceptedDetail=detail;long acceptedObservation=observation; + tx.executeWithoutResult(s->{leases.verify(lease); + var existing=stages.selectOne(new QueryWrapper().eq("stage_batch_id",finalFetchTask.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",entry.getKey())); + SelfopSyncStage full; + if(existing!=null&&"VALID".equals(existing.getValidationStatus())){ + require(existing.getContentHash().equals(hash(acceptedDetail)),"SELFOP_VALID_STAGE_IMMUTABLE");full=existing; + }else{ + full=stage(finalFetchTask,finalFetchRun,acceptedDetail,existing==null?1:existing.getFirstPageNo(),acceptedObservation,"VALID"); + if(existing!=null){full.setId(existing.getId());full.setRunId(existing.getRunId());full.setCreatedAt(existing.getCreatedAt());stages.updateById(full);}else stages.insert(full); + } + ref.setEffectiveStageId(full.getId());ref.setWarehouseCode(acceptedDetail.warehouseCode());saveManifest(task,manifest); + if(!finalFetchTask.getId().equals(task.getId())){var refreshManifest=new SelfopRoutingManifest();refreshManifest.setDirectoryComplete(true);refreshManifest.setPages(1);refreshManifest.setTotal(1L); + var refreshRef=new SelfopRoutingManifest.Entry();refreshRef.setCustomerCode(ref.getCustomerCode());refreshRef.setDeliveryNo(ref.getDeliveryNo());refreshRef.setWarehouseCode(ref.getWarehouseCode());refreshRef.setEffectiveStageId(full.getId()); + refreshManifest.getObjects().put(entry.getKey(),refreshRef);finalFetchTask.setRoutingManifestJson(json(refreshManifest)); + SelfopProgressProjection.freezeRun(finalFetchTask,finalFetchRun);finalFetchRun.setStatus("ROUTED");finalFetchRun.setFetchFinishedAtUtc(utcNow());finalFetchRun.setFinishedAtUtc(utcNow());finalFetchRun.setUpdatedAt(utcNow());runs.updateById(finalFetchRun); + finalFetchTask.setStatus("SUCCEEDED");finalFetchTask.setUpdatedAt(utcNow());tasks.updateById(finalFetchTask);} + }); + route(task,manifest,lease); + } + tx.executeWithoutResult(s->{leases.verify(lease);var now=utcNow(); + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopProgressProjection.freezeRun(task,run); + run.setStatus("ROUTED");run.setCoverageStatus("COMPLETE");run.setPageCount(manifest.getPages()==null?0:manifest.getPages()*2); + run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getObjects().size());run.setFetchFinishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task); + var account=accounts.selectById(task.getDiscoveryScopeId()); + if(task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&& + (account.getCatalogUntilUtc()==null||!task.getQueryFromUtc().isAfter(account.getCatalogUntilUtc()))) { + account.setCatalogFromUtc(account.getCatalogFromUtc()==null?task.getQueryFromUtc():account.getCatalogFromUtc()); + account.setCatalogUntilUtc(account.getCatalogUntilUtc()==null||task.getQueryUntilUtc().isAfter(account.getCatalogUntilUtc())?task.getQueryUntilUtc():account.getCatalogUntilUtc()); + account.setUpdatedAt(now);accounts.updateById(account);} + }); + } + private boolean collectLocated(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget,Query query,SelfopRoutingManifest manifest) { + if(manifest.isDirectoryComplete())return true; + // Identity relocation consumes the full paginated result before trusting one match. + while(!manifest.isDirectoryComplete()){ + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return false;}leases.renew(lease); + var page=client.locate(SelfopRequestContext.forTask(task,run,config),query.deliveryNo(),manifest.getNextPage(),config.getPageSize()); + require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING"); + if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());} + require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); + for(var summary:page.records())if(query.deliveryNo().equals(summary.deliveryNo())&&query.customerCode().equals(summary.customerCode())){ + String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));require(!manifest.getObjects().containsKey(key),"SELFOP_LOCATE_AMBIGUOUS"); + var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode());manifest.getObjects().put(key,e);} + manifest.setNextPage(manifest.getNextPage()+1);if(page.pages()==0||manifest.getNextPage()>page.pages()){ + require(manifest.getObjects().size()==1,"SELFOP_LOCATE_UNRESOLVED");manifest.setDirectoryComplete(true);} + tx.executeWithoutResult(s->{leases.verify(lease);saveManifest(task,manifest);}); + } + return true; + } + private SelfopSyncStage stage(SelfopSyncTask task,SelfopSyncRun run,SelfopPayloads.Order payload,int page,Long observation,String status){ + var s=new SelfopSyncStage();s.setId(IdWorker.getId());s.setRunId(run.getId());s.setStageBatchId(task.getId());s.setLastFetchRunId(run.getId()); + s.setObservationSequence(observation);s.setWarehouseCode(payload.warehouseCode());s.setObjectType("OUTBOUND_ORDER"); + s.setObjectKeyHash(hash(List.of(payload.sourceAccount(),payload.customerCode(),payload.deliveryNo())));s.setFirstPageNo(page);s.setObservedAtUtc(utcNow()); + s.setContentHash(hash(payload));s.setPayloadJson(json(payload));s.setValidationStatus(status);s.setCreatedAt(utcNow());s.setUpdatedAt(utcNow());return s; + } + private void saveManifest(SelfopSyncTask task,SelfopRoutingManifest manifest){task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow()); + var active=runs.selectOne(new QueryWrapper().eq("task_id",task.getId()).eq("status","FETCHING").orderByDesc("attempt_no").last("LIMIT 1")); + if(active!=null){SelfopProgressProjection.applyCheckpoint(task,active,manifest.isDirectoryComplete()?"DETAIL":"DIRECTORY",manifest.isDirectoryComplete()?"ORDER":"PAGE", + manifest.isDirectoryComplete()?manifest.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count():Math.max(0,manifest.getNextPage()-1), + manifest.isDirectoryComplete()?(long)manifest.getObjects().size():manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());runs.updateById(active);} + tasks.updateById(task);} + private void route(SelfopSyncTask parent,SelfopRoutingManifest manifest,SelfopLeaseService.Lease lease){ + // Refresh commands only replace a parent manifest reference; never create an independent + // fact writer that could publish around that parent's half-applied barrier. + if(read(parent.getQueryScopeJson(),Query.class).parentTaskId()!=null)return; + tx.executeWithoutResult(s->{leases.verify(lease);if(!manifest.isDirectoryComplete())return; + for(var scope:scopes.selectList(new QueryWrapper().eq("source_account",parent.getSourceAccount()).eq("data_family","OUTBOUND"))){ + var own=new SelfopRoutingManifest();own.setDirectoryComplete(true);own.setTotal(0L);own.setPages(manifest.getPages()); + for(var e:manifest.getObjects().entrySet()){ + var ref=e.getValue();var previous=orders.selectOne(new QueryWrapper().eq("source_account",parent.getSourceAccount()).eq("customer_code",ref.getCustomerCode()).eq("delivery_no",ref.getDeliveryNo())); + if(scope.getWarehouseCode().equals(ref.getWarehouseCode())||(previous!=null&&scope.getWarehouseCode().equals(previous.getWarehouseCode()))){ + boolean belongs=true; + if(ref.getEffectiveStageId()!=null){var payload=read(stages.selectById(ref.getEffectiveStageId()).getPayloadJson(),SelfopPayloads.Order.class); + belongs=OutboundPolicy.belongsToSlice(payload,scope,parent.getQueryFromUtc(),parent.getQueryUntilUtc(),previous!=null,parent.getTaskType());} + if(belongs)own.getObjects().put(e.getKey(),ref); + } + } + own.setTotal((long)own.getObjects().size()); + boolean ready=scope.getSourceTimezone()!=null&&(parent.getQueryFromUtc()==null||Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())) + &&own.getObjects().values().stream().allMatch(e->e.getEffectiveStageId()!=null); + var child=new SelfopSyncTask();child.setSourceAccount(parent.getSourceAccount());child.setTaskRole("WAREHOUSE_SYNC");child.setScopeId(scope.getId());child.setWarehouseCode(scope.getWarehouseCode()); + child.setExecutionPurpose(parent.getExecutionPurpose()); + child.setUpstreamTaskId(parent.getId());child.setDiscoverySequence(parent.getDiscoverySequence());child.setObservationBaseline(parent.getObservationBaseline()); + child.setRequestKey("route:"+parent.getId()+":"+scope.getWarehouseCode());child.setTaskType(parent.getTaskType());child.setQueryFromUtc(parent.getQueryFromUtc());child.setQueryUntilUtc(parent.getQueryUntilUtc()); + child.setQueryScopeJson(scope.getScopeJson());child.setScopeHash(scope.getScopeHash());child.setRequestHash(hash(List.of(parent.getId(),scope.getScopeHash())));child.setStatus(ready?"READY":"WAITING_SOURCE");child.setApplyState("NOT_STARTED"); + var saved=taskService.create(child); + if(!"SUCCEEDED".equals(saved.getStatus())&& !Set.of("APPLYING","APPLIED","BLOCKED").contains(saved.getApplyState())){ + saved.setRoutingManifestJson(json(own));saved.setStatus(ready?"READY":"WAITING_SOURCE");saved.setUpdatedAt(utcNow());tasks.updateById(saved);} + } + }); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java new file mode 100644 index 000000000..d6038f892 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.outbound.*; +import com.baiyue.lastleg.selfoperated.analytics.inventory.InventorySnapshotService; +import org.springframework.stereotype.Service; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopExecutionService { + private final SelfopAnalyticsProperties config; + private final SelfopTaskService taskService; + private final SelfopLeaseService leases; + private final SelfopDiscoveryService discovery; + private final SelfopInventoryCollector stock; + private final InventorySnapshotService snapshots; + private final OutboundFactService facts; + private final OutboundCandidateBuilder candidates; + private final SelfopSalesPublisher publisher; + private final SelfopSyncScopeMapper scopes; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncStageMapper stages; + private final SelfopOutboundOrderMapper orders; + private final SelfopReferenceRecovery recovery; + private final SelfopValidationRunner validation; + private final SelfopSyncRunMapper runs; + public SelfopExecutionService(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopLeaseService leases, + SelfopDiscoveryService discovery,SelfopInventoryCollector stock,InventorySnapshotService snapshots,OutboundFactService facts, + OutboundCandidateBuilder candidates,SelfopSalesPublisher publisher,SelfopSyncScopeMapper scopes,SelfopSyncTaskMapper tasks, + SelfopSyncStageMapper stages,SelfopOutboundOrderMapper orders,SelfopReferenceRecovery recovery,SelfopValidationRunner validation,SelfopSyncRunMapper runs){ + this.config=config;this.taskService=taskService;this.leases=leases;this.discovery=discovery;this.stock=stock;this.snapshots=snapshots; + this.facts=facts;this.candidates=candidates;this.publisher=publisher;this.scopes=scopes;this.tasks=tasks;this.stages=stages;this.orders=orders;this.recovery=recovery;this.validation=validation;this.runs=runs; + } + public void execute(long taskId){ + var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose()); + if("SUCCEEDED".equals(task.getStatus())||"DEAD".equals(task.getStatus()))return; + String owner=UUID.randomUUID().toString();Map held=new LinkedHashMap<>();SelfopLeaseService.Lease primary; + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){primary=leases.acquireDiscovery(task.getDiscoveryScopeId(),owner);held.put(primary.scopeId(),primary);} + else { + var ids=new TreeSet();ids.add(task.getScopeId()); + if("OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily())){ + var warehouses=new TreeSet();warehouses.add(task.getWarehouseCode()); + if(task.getAffectedWarehouseCodesJson()!=null)warehouses.addAll(read(task.getAffectedWarehouseCodesJson(),List.class)); + if(task.getRoutingManifestJson()!=null){var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + for(var ref:manifest.getObjects().values())if(ref.getEffectiveStageId()!=null){var stage=stages.selectById(ref.getEffectiveStageId());var p=read(stage.getPayloadJson(),SelfopPayloads.Order.class); + warehouses.add(p.warehouseCode());var old=orders.selectOne(new QueryWrapper().eq("source_account",p.sourceAccount()).eq("customer_code",p.customerCode()).eq("delivery_no",p.deliveryNo())); + var latest=stages.selectOne(new QueryWrapper().eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",stage.getObjectKeyHash()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1")); + if(latest!=null)warehouses.add(latest.getWarehouseCode()); + if(old!=null)warehouses.add(old.getWarehouseCode());}} + for(String w:warehouses){var s=scopes.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount()).eq("warehouse_code",w).eq("data_family","OUTBOUND")); + require(s!=null,"SELFOP_WAREHOUSE_UNCONFIGURED");ids.add(s.getId());} + } + held.putAll(leases.acquireScopes(ids,owner));primary=held.get(task.getScopeId()); + } + SelfopSyncRun run=null; + try{ + run=taskService.begin(taskId,primary); + task=tasks.selectById(taskId); + if("VALIDATION".equals(task.getExecutionPurpose())){validation.run(task,run,primary);return;} + if(!primary.account()&&"OUTBOUND".equals(scopes.selectById(task.getScopeId()).getDataFamily()))recovery.refresh(task,held); + if(primary.account())discovery.collect(task,run,primary); + else if("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())){ + if(stock.collect(task,run,primary)){snapshots.build(task.getScopeId(),run.getId(),owner,primary.fence());snapshots.publish(task.getScopeId(),run.getId(),owner,primary.fence());} + }else{ + Map outbound=new TreeMap<>();for(var lease:held.values())outbound.put(scopes.selectById(lease.scopeId()).getWarehouseCode(),new OutboundFactService.Lease(owner,lease.fence())); + if(!"REBUILD".equals(task.getTaskType())){ + var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);var ids=manifest.getObjects().values().stream().map(SelfopRoutingManifest.Entry::getEffectiveStageId).distinct().toList(); + require(ids.stream().noneMatch(Objects::isNull),"SELFOP_DETAILS_INCOMPLETE"); + if(!"APPLIED".equals(task.getApplyState())){ + int batch=Math.max(1,Math.min(100,config.getApplyBatchSize()));for(int i=0;i().eq("scope_id",task.getScopeId()).isNotNull("build_started_at_utc").orderByDesc("build_started_at_utc").last("LIMIT 1")); + if(previousBuild!=null&&!OutboundCandidateBuilder.regularBuildDue(task,previousBuild.getBuildStartedAtUtc(),utcNow())){ + taskService.waitForBudget(taskId,run.getId(),previousBuild.getBuildStartedAtUtc().plusMinutes(30),primary);return; + } + candidates.build(task.getScopeId(),run.getId(),owner,primary.fence()); + if(config.isSalesPublicationEnabled())publisher.publish(task.getScopeId(),run.getId(),owner,primary.fence()); + else taskService.candidateFinished(taskId,run.getId(),primary); + } + }catch(RuntimeException e){ + if(run!=null&&"BUSINESS".equals(task.getExecutionPurpose())&& e instanceof com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException q && q.waitUntilUtc()!=null){ + try{taskService.waitForBudget(taskId,run.getId(),q.waitUntilUtc(),primary);}catch(IllegalStateException lostFence){ }return; + } + if(run!=null){String code=e instanceof AnalyticsClientException c?c.code():safeCode(e.getMessage()); + boolean retryable=!(e instanceof AnalyticsClientException c)||c.retryable(); + try{ + if("VALIDATION".equals(task.getExecutionPurpose()))validation.fail(task,run,primary,code); + else taskService.fail(taskId,run.getId(),code,retryable,primary); + }catch(IllegalStateException lostFence){/* New lease owner decides recovery. */}} + }finally{for(var lease:held.values())leases.release(lease);} + } + private static String safeCode(String message){return message!=null&&message.matches("[A-Z][A-Z0-9_]{0,63}")?message:"SELFOP_EXECUTION_FAILED";} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java new file mode 100644 index 000000000..bd8a04975 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopInventoryCollector { + private final LingxingAnalyticsClient client; + private final SelfopSyncStageMapper stages; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopLeaseService leases; + private final SelfopTaskService taskService; + private final SelfopAnalyticsProperties config; + private final TransactionTemplate tx; + public SelfopInventoryCollector(LingxingAnalyticsClient client,SelfopSyncStageMapper stages,SelfopSyncTaskMapper tasks, + SelfopSyncRunMapper runs,SelfopLeaseService leases,SelfopTaskService taskService,SelfopAnalyticsProperties config,TransactionTemplate tx){ + this.client=client;this.stages=stages;this.tasks=tasks;this.runs=runs;this.leases=leases;this.taskService=taskService;this.config=config;this.tx=tx; + } + public boolean collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease){ + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run); + var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + var budget=new SelfopBudget(config.getRequestBudget(),config.getTimeBudgetSeconds()); + while(!manifest.isDirectoryComplete()){ + if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return false;} + leases.renew(lease);var page=client.inventory(SelfopRequestContext.forTask(task,run,config),task.getWarehouseCode(),manifest.getNextPage(),config.getPageSize()); + require(page.current()==manifest.getNextPage()&&page.size()==config.getPageSize(),"SELFOP_PAGE_MISMATCH"); + require(page.total()!=null&&page.pages()!=null,"SELFOP_PAGE_METADATA_MISSING"); + if(manifest.getTotal()==null){manifest.setTotal(page.total());manifest.setPages(page.pages());} + require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); + tx.executeWithoutResult(s->{leases.verify(lease); + for(var row:page.records()){ + require(task.getSourceAccount().equals(row.sourceAccount())&&task.getWarehouseCode().equals(row.warehouseCode()),"SELFOP_STOCK_SCOPE_LEAK"); + identity(row.customerCode(),64);identity(row.skuCode(),128);require(row.productQuality()!=null,"SELFOP_STOCK_IDENTITY_MISSING"); + String key=hash(List.of(row.sourceAccount(),row.customerCode(),row.warehouseCode(),row.skuCode(),row.productQuality())); + if(manifest.getScan()==1){require(!manifest.getFirstScan().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getFirstScan().put(key,hash(row)); + var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(run.getId());stage.setStageBatchId(run.getId());stage.setLastFetchRunId(run.getId()); + stage.setWarehouseCode(row.warehouseCode());stage.setObjectType("PRODUCT_STOCK");stage.setObjectKeyHash(key);stage.setFirstPageNo(manifest.getNextPage()); + stage.setObservedAtUtc(utcNow());stage.setContentHash(hash(row));stage.setPayloadJson(json(row));stage.setValidationStatus("VALID");stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage); + }else{require(Objects.equals(manifest.getFirstScan().get(key),hash(row)),"SELFOP_STOCK_SCAN_UNSTABLE"); + require(!manifest.getObjects().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getObjects().put(key,new SelfopRoutingManifest.Entry());} + } + manifest.setNextPage(manifest.getNextPage()+1); + if(manifest.getPages()==0||manifest.getNextPage()>manifest.getPages()){ + require((manifest.getScan()==1?manifest.getFirstScan().size():manifest.getObjects().size())==manifest.getTotal(),"SELFOP_STOCK_COUNT_MISMATCH"); + if(manifest.getScan()==1){manifest.setScan(2);manifest.setNextPage(1);}else manifest.setDirectoryComplete(true); + } + task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow());tasks.updateById(task); + SelfopProgressProjection.applyCheckpoint(task,run,"INVENTORY","PAGE",Math.max(0,manifest.getNextPage()-1),manifest.getPages()==null?null:manifest.getPages().longValue(),manifest.getScan());tasks.updateById(task);runs.updateById(run); + }); + } + tx.executeWithoutResult(s->{leases.verify(lease);run.setStatus("VALIDATED");run.setLastPhase("VALIDATED");run.setCoverageStatus("COMPLETE"); + run.setPageCount(Math.max(1,manifest.getPages()));run.setSourceTotalCount(manifest.getTotal());run.setUniqueObjectCount((long)manifest.getFirstScan().size()); + run.setFetchFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); + });return true; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java new file mode 100644 index 000000000..e019daf45 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java @@ -0,0 +1,82 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopLeaseService { + private final SelfopCoordinationMapper locks; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper discoveries; + private final SelfopAnalyticsProperties config; + public SelfopLeaseService(SelfopCoordinationMapper locks, SelfopSyncScopeMapper scopes, + SelfopOutboundDiscoveryScopeMapper discoveries, SelfopAnalyticsProperties config) { + this.locks=locks; this.scopes=scopes; this.discoveries=discoveries; this.config=config; + } + public record Lease(long scopeId, String owner, long fence, boolean account) { } + @Transactional + public Lease acquireDiscovery(long id, String owner) { + var row=locks.lockDiscovery(id); require(row!=null,"SELFOP_SCOPE_MISSING"); + var now=utcNow(); require(available(row.getLeaseOwner(),row.getLeaseUntilUtc(),now),"SELFOP_LEASE_BUSY"); + long fence=row.getFenceToken()+1; row.setLeaseOwner(owner); row.setFenceToken(fence); + row.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); row.setUpdatedAt(now); + discoveries.updateById(row); return new Lease(id,owner,fence,true); + } + /** Atomic all-or-none acquisition; never hold one warehouse while waiting for another. */ + @Transactional + public Map acquireScopes(Collection ids, String owner) { + var rows=ids.stream().distinct().map(scopes::selectById) + .sorted(Comparator.comparing(SelfopSyncScope::getSourceAccount) + .thenComparing(SelfopSyncScope::getWarehouseCode).thenComparing(SelfopSyncScope::getDataFamily)).toList(); + Map result=new LinkedHashMap<>(); var now=utcNow(); + for(var candidate:rows){ + var row=locks.lockScope(candidate.getId()); + require(available(row.getLeaseOwner(),row.getLeaseUntilUtc(),now),"SELFOP_LEASE_BUSY"); + long fence=row.getFenceToken()+1; row.setLeaseOwner(owner); row.setFenceToken(fence); + row.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); row.setUpdatedAt(now); + scopes.updateById(row); result.put(row.getId(),new Lease(row.getId(),owner,fence,false)); + } + return result; + } + @Transactional + public void renew(Lease lease) { + var now=utcNow(); + if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId()); check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),now); + s.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); s.setUpdatedAt(now); discoveries.updateById(s); + } else {var s=locks.lockScope(lease.scopeId()); check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),now); + s.setLeaseUntilUtc(now.plusSeconds(config.getLeaseSeconds())); s.setUpdatedAt(now); scopes.updateById(s);} + locks.heartbeat(lease.scopeId(),lease.account(),lease.fence(),now); + } + @Transactional + public void release(Lease lease) { + if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId()); + if(Objects.equals(s.getLeaseOwner(),lease.owner()) && s.getFenceToken()==lease.fence()) { + s.setLeaseOwner(null); s.setLeaseUntilUtc(null); s.setUpdatedAt(utcNow()); + discoveries.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",s.getId()).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",utcNow())); } + } else {var s=locks.lockScope(lease.scopeId()); + if(Objects.equals(s.getLeaseOwner(),lease.owner()) && s.getFenceToken()==lease.fence()) + scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",s.getId()).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",utcNow())); } + } + @Transactional + public long nextObservation(Lease lease) { + require(lease.account(),"SELFOP_ACCOUNT_LEASE_REQUIRED"); var s=locks.lockDiscovery(lease.scopeId()); + check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow()); + long seq=s.getNextObservationSequence(); s.setNextObservationSequence(seq+1); s.setUpdatedAt(utcNow()); + discoveries.updateById(s); return seq; + } + public void verify(Lease lease) { + if(lease.account()) {var s=locks.lockDiscovery(lease.scopeId());check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow());} + else {var s=locks.lockScope(lease.scopeId());check(lease,s.getLeaseOwner(),s.getFenceToken(),s.getLeaseUntilUtc(),utcNow());} + } + static boolean available(String owner, LocalDateTime until, LocalDateTime now) {return owner==null || until==null || !until.isAfter(now);} + public static void check(Lease lease,String owner,Long fence,LocalDateTime until,LocalDateTime now) { + require(Objects.equals(owner,lease.owner()) && fence!=null && fence==lease.fence() && until!=null && until.isAfter(now),"SELFOP_FENCE_REJECTED"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java new file mode 100644 index 000000000..79d074e3f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Small, durable checkpoint projection. Counts describe committed work, never HTTP attempts. */ +@Service +public class SelfopProgressService { + private static final Set PHASES = Set.of("WAITING_SOURCE", "DIRECTORY", "DETAIL", "INVENTORY", + "VALIDATION", "MERGE", "BUILD", "PUBLISH", "WAITING", "SAMPLE_DONE", "FAILED"); + private final SelfopCoordinationMapper locks; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopLeaseService leases; + public SelfopProgressService(SelfopCoordinationMapper locks, SelfopSyncTaskMapper tasks, + SelfopSyncRunMapper runs, SelfopLeaseService leases) { + this.locks=locks; this.tasks=tasks; this.runs=runs; this.leases=leases; + } + + /** Creation-only waiting evidence; no execution or observation is inferred. */ + @Transactional + public void initializeWaiting(long taskId, String phase) { + require(Set.of("WAITING_SOURCE", "WAITING").contains(phase), "SELFOP_PROGRESS_PHASE_INVALID"); + var task=locks.lockTask(taskId); require(task!=null,"SELFOP_TASK_MISSING"); + require(task.getAttemptCount()==null || task.getAttemptCount()==0,"SELFOP_PROGRESS_ALREADY_STARTED"); + if(task.getProgressJson()!=null)return; + var p=new LinkedHashMap(); p.put("attemptNo",0); p.put("phase",phase); + p.put("progressScope",scope(task)); p.put("stageStartedAt",instant(utcNow())); + p.put("unit","NONE"); p.put("completed",0); p.put("total",null); + p.put("scanRound",0);p.put("totalKnown",false);p.put("countBasis","TASK_CUMULATIVE"); + p.put("sliceFrom",instant(task.getQueryFromUtc()));p.put("sliceUntil",instant(task.getQueryUntilUtc())); + p.put("stageFinishedAt",null);p.put("stages",new ArrayList<>()); persist(task,p); + } + + /** Caller invokes after the corresponding manifest/stage/merge checkpoint in the same transaction. */ + @Transactional + public void checkpoint(long taskId, long runId, SelfopLeaseService.Lease lease, String phase, + String unit, long completed, Long total, int scan) { + require(PHASES.contains(phase),"SELFOP_PROGRESS_PHASE_INVALID"); + require(Set.of("PAGE","ORDER","INVENTORY_ROW","BUSINESS_KEY","BATCH","NONE").contains(unit),"SELFOP_PROGRESS_UNIT_INVALID"); + require(completed>=0 && scan>=0 && (total==null || total>=completed),"SELFOP_PROGRESS_COUNTS_INVALID"); + leases.verify(lease); + var task=locks.lockTask(taskId); var run=locks.lockRun(runId); check(task,run,lease,true); + SelfopProgressProjection.applyCheckpoint(task,run,phase,unit,completed,total,scan); + tasks.updateById(task); runs.updateById(run); + } + + @Transactional + public void heartbeat(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease); var task=locks.lockTask(taskId); var run=locks.lockRun(runId);check(task,run,lease,true); + run.setHeartbeatAtUtc(utcNow()); run.setUpdatedAt(utcNow());runs.updateById(run); + } + + /** Freeze before replacing task progress, including crash takeover under the new valid fence. */ + @Transactional + public void freeze(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);check(task,run,lease,false); + SelfopProgressProjection.freezeRun(task,run);runs.updateById(run); + } + + private void check(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,boolean active) { + require(task!=null&&run!=null&&Objects.equals(run.getTaskId(),task.getId()),"SELFOP_PROGRESS_RUN_MISMATCH"); + require(Objects.equals(lease.scopeId(),lease.account()?task.getDiscoveryScopeId():task.getScopeId()),"SELFOP_PROGRESS_SCOPE_MISMATCH"); + if(active)require(Objects.equals(run.getFenceToken(),lease.fence())&&Objects.equals(task.getAttemptCount(),run.getAttemptNo()) + &&run.getFinishedAtUtc()==null,"SELFOP_FENCE_REJECTED"); + } + private void persist(SelfopSyncTask task,Map p) { + task.setProgressJson(bounded(p));task.setProgressUpdatedAtUtc(utcNow());task.setUpdatedAt(utcNow());tasks.updateById(task); + } + private static String bounded(Map p) {String value=json(p);require(value.getBytes(StandardCharsets.UTF_8).length<=8192,"SELFOP_PROGRESS_TOO_LARGE");return value;} + private static String scope(SelfopSyncTask task){return "ACCOUNT_DISCOVERY".equals(task.getTaskRole())?"ACCOUNT_SHARED":"WAREHOUSE";} + @SuppressWarnings("unchecked") private static Map progress(SelfopSyncTask task){return task.getProgressJson()==null?new LinkedHashMap<>():read(task.getProgressJson(),LinkedHashMap.class);} + @SuppressWarnings("unchecked") private static Map map(Object value){return value instanceof Map?new LinkedHashMap<>((Map)value):new LinkedHashMap<>();} + private static Long number(Object value){return value instanceof Number n?n.longValue():null;} + private static String instant(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java new file mode 100644 index 000000000..3bbab41ec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopReferenceRecovery { + private final SelfopSyncStageMapper stages; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopCoordinationMapper locks; + private final SelfopLeaseService leases; + private final SelfopTaskService taskService; + private final SelfopDiscoveryService discovery; + private final TransactionTemplate tx; + public SelfopReferenceRecovery(SelfopSyncStageMapper stages,SelfopSyncTaskMapper tasks,SelfopSyncScopeMapper scopes, + SelfopOutboundDiscoveryScopeMapper accounts,SelfopCoordinationMapper locks,SelfopLeaseService leases,SelfopTaskService taskService, + SelfopDiscoveryService discovery,TransactionTemplate tx){this.stages=stages;this.tasks=tasks;this.scopes=scopes;this.accounts=accounts; + this.locks=locks;this.leases=leases;this.taskService=taskService;this.discovery=discovery;this.tx=tx;} + public void refresh(SelfopSyncTask task,Map held){ + SelfopCollectionPolicy.requireBusiness(task); + if(task.getRoutingManifestJson()==null||"APPLIED".equals(task.getApplyState()))return; + var manifest=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + for(var object:manifest.getObjects().entrySet()){ + var ref=object.getValue();require(ref.getEffectiveStageId()!=null,"SELFOP_DETAILS_INCOMPLETE"); + var current=stages.selectById(ref.getEffectiveStageId());var latest=stages.selectOne(new QueryWrapper() + .eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",object.getKey()).eq("validation_status","VALID").isNotNull("observation_sequence").orderByDesc("observation_sequence").last("LIMIT 1")); + if(latest==null||latest.getObservationSequence()<=current.getObservationSequence())continue; + for(var lease:held.values())leases.renew(lease); + var account=accounts.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount())); + var accountLease=leases.acquireDiscovery(account.getId(),UUID.randomUUID().toString()); + try{ + var refresh=new SelfopSyncTask();refresh.setSourceAccount(task.getSourceAccount());refresh.setTaskRole("ACCOUNT_DISCOVERY");refresh.setDiscoveryScopeId(account.getId());refresh.setTaskType("RECHECK"); + refresh.setExecutionPurpose(task.getExecutionPurpose()); + refresh.setScopeHash(task.getScopeHash());refresh.setRequestKey("warehouse-refresh:"+task.getId()+":"+object.getKey().substring(0,16)+":"+latest.getObservationSequence()); + refresh.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId())));refresh.setRequestHash(hash(refresh.getQueryScopeJson())); + refresh=taskService.create(refresh); + if(!"SUCCEEDED".equals(refresh.getStatus())){var run=taskService.begin(refresh.getId(),accountLease);refresh=tasks.selectById(refresh.getId());discovery.collect(refresh,run,accountLease);} + refresh=tasks.selectById(refresh.getId());require("SUCCEEDED".equals(refresh.getStatus()),"SELFOP_RECHECK_YIELDED"); + var recovered=read(refresh.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(object.getKey()); + require(recovered!=null&&recovered.getEffectiveStageId()!=null,"SELFOP_RECHECK_INCOMPLETE"); + require(held.values().stream().anyMatch(l->scopes.selectById(l.scopeId()).getWarehouseCode().equals(recovered.getWarehouseCode())),"LOCK_SET_CHANGED"); + ref.setEffectiveStageId(recovered.getEffectiveStageId());ref.setWarehouseCode(recovered.getWarehouseCode());ref.setRefreshTaskId(refresh.getId());ref.setNewestSeenSequence(latest.getObservationSequence()); + tx.executeWithoutResult(s->{for(var lease:held.values())leases.verify(lease);leases.verify(accountLease);var locked=locks.lockTask(task.getId()); + locked.setRoutingManifestJson(json(manifest));locked.setUpdatedAt(utcNow());tasks.updateById(locked);task.setRoutingManifestJson(locked.getRoutingManifestJson());}); + }finally{leases.release(accountLease);} + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java new file mode 100644 index 000000000..067c7eefd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +@Service +public class SelfopSalesPublisher { + private final SelfopCoordinationMapper locks; + private final SelfopSyncScopeMapper scopes; + private final SelfopSyncRunMapper runs; + private final SelfopSyncTaskMapper tasks; + private final SelfopAnalyticsProperties config; + private final com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper aggregates; + public SelfopSalesPublisher(SelfopCoordinationMapper locks,SelfopSyncScopeMapper scopes,SelfopSyncRunMapper runs,SelfopSyncTaskMapper tasks,SelfopAnalyticsProperties config,com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper aggregates){ + this.locks=locks;this.scopes=scopes;this.runs=runs;this.tasks=tasks;this.config=config;this.aggregates=aggregates; + } + @Transactional + public void publish(long scopeId,long runId,String owner,long fence){ + require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED"); + var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var now=utcNow(); + SelfopCollectionPolicy.requireExecution(config,"BUSINESS");SelfopCollectionPolicy.requireBusiness(run); + require(scope!=null&&run!=null&&"OUTBOUND".equals(scope.getDataFamily()),"SELFOP_SALES_SCOPE_INVALID"); + SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),now); + if(Objects.equals(scope.getPublishedRunId(),runId)&&"PUBLISHED".equals(run.getStatus()))return; + require(scope.getSourceTimezone()!=null&&!scope.getSourceTimezone().isBlank(),"SOURCE_TIMEZONE_UNCONFIRMED"); + require(config.getQuantityUnit()!=null&&!config.getQuantityUnit().isBlank(),"QUANTITY_UNIT_UNCONFIRMED"); + require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getScopeHash(),scope.getScopeHash())&&Objects.equals(run.getFenceToken(),fence) + &&"WAREHOUSE_SYNC".equals(run.getRunRole())&&"READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY"); + require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"HALF_APPLIED_FACTS"); + require(Objects.equals(run.getCalculatedFactRevision(),scope.getFactRevision()),"SELFOP_CANDIDATE_OBSOLETE"); + require(run.getBuildStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"SELFOP_CANDIDATE_OBSERVATION_CHANGED"); + require("COMPLETE".equals(run.getCoverageStatus())&&run.getCoveredFromUtc()!=null&&run.getCoveredUntilUtc()!=null,"SELFOP_COVERAGE_UNPROVEN"); + var task=locks.lockTask(run.getTaskId());require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED"); + SelfopCollectionPolicy.requireBusiness(task); + if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now)); + scope.setPublishedRunId(runId);scope.setPublishedFactRevision(run.getCalculatedFactRevision());scope.setRebuildRequired(0); + scope.setDiscoveryFromUtc(run.getCoveredFromUtc());scope.setDiscoveryUntilUtc(run.getCoveredUntilUtc());scope.setUpdatedAt(now);scopes.updateById(scope); + SelfopProgressProjection.applyCheckpoint(task,run,"PUBLISH","NONE",1,1L,1);SelfopProgressProjection.freezeRun(task,run); + run.setStatus("PUBLISHED");run.setPublishedAtUtc(now);run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setResultPublishedRunId(runId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.updateById(task); + locks.completeCoveredTasks(scopeId,scope.getScopeHash(),runId,run.getCoveredFromUtc(),run.getCoveredUntilUtc(),now); + locks.completeReusedRuns(runId,now); + locks.completeCoveredMarkers(scopeId,scope.getScopeHash(),runId,run.getCoveredUntilUtc(),now); + } + @Transactional + public void reuse(long scopeId,long runId,long publishedId,String owner,long fence){ + SelfopCollectionPolicy.requireExecution(config,"BUSINESS");require(config.isSalesPublicationEnabled(),"SELFOP_SALES_PUBLICATION_DISABLED"); + var scope=locks.lockScope(scopeId);var run=locks.lockRun(runId);var task=locks.lockTask(run.getTaskId());var published=runs.selectById(publishedId); + SelfopCollectionPolicy.requireBusiness(task);SelfopCollectionPolicy.requireBusiness(run);SelfopCollectionPolicy.requireBusiness(published); + SelfopLeaseService.check(new SelfopLeaseService.Lease(scopeId,owner,fence,false),scope.getLeaseOwner(),scope.getFenceToken(),scope.getLeaseUntilUtc(),utcNow()); + require(Objects.equals(run.getScopeId(),scopeId)&&Objects.equals(run.getFenceToken(),fence)&&Objects.equals(run.getScopeHash(),scope.getScopeHash()),"SELFOP_REUSE_SCOPE_INVALID"); + require(Objects.equals(scope.getPublishedRunId(),publishedId)&&"PUBLISHED".equals(published.getStatus())&&Objects.equals(published.getCalculatedFactRevision(),scope.getFactRevision()) + &&locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"SELFOP_REUSE_OBSOLETE"); + require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED"); + require(run.getFetchStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getFetchStartedAtUtc())==0,"SELFOP_REUSE_OBSERVATION_CHANGED"); + var now=utcNow();run.setCalculatedFactRevision(published.getCalculatedFactRevision());SelfopProgressProjection.freezeRun(task,run);run.setStatus("REUSED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setResultPublishedRunId(publishedId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper() + .eq("id",task.getId()).set("status","SUCCEEDED").set("result_published_run_id",publishedId).set("next_retry_at_utc",null).set("updated_at",now)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java new file mode 100644 index 000000000..378e878e5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java @@ -0,0 +1,119 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Persistent cron slots + immediate polling of durable work; never starts a live call by default. */ +@Service +public class SelfopSchedulePlanner { + private final SelfopAnalyticsProperties config; + private final SelfopTaskService taskService; + private final SelfopExecutionService execution; + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopSyncTaskMapper tasks; + private final SelfopOutboundOrderMapper orders; + private final SelfopCoordinationMapper locks; + private final TransactionTemplate tx; + public SelfopSchedulePlanner(SelfopAnalyticsProperties config,SelfopTaskService taskService,SelfopExecutionService execution, + SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts,SelfopSyncTaskMapper tasks,SelfopOutboundOrderMapper orders,SelfopCoordinationMapper locks,TransactionTemplate tx){ + this.config=config;this.taskService=taskService;this.execution=execution;this.scopes=scopes;this.accounts=accounts;this.tasks=tasks;this.orders=orders;this.locks=locks;this.tx=tx; + } + @Scheduled(fixedDelayString="${baiyue.self-operated-analytics.poll-delay-ms:1000}") + public void tick(){ + if("OFF".equals(config.getCollectionMode()))return; + if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + var configured=taskService.initialize();var now=utcNow(); + for(var scope:configured){ + if(!"BUSINESS".equals(config.getCollectionMode()))break; + if("INVENTORY".equals(scope.getDataFamily()))plan(scope,now); + else if(config.getSourceQueryTimezone()!=null&&Objects.equals(scope.getSourceTimezone(),config.getSourceQueryTimezone())){plan(scope,now);review(scope,now);} + } + for(var task:taskService.due(10))try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */} + } + public void plan(SelfopSyncScope preview,LocalDateTime now){ + SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone()); + if("OUTBOUND".equals(scope.getDataFamily())&&scope.getInitialUntilUtc()==null){ + scope.setInitialUntilUtc(now);scope.setInitialFromUtc(now.atZone(ZoneOffset.UTC).withZoneSameInstant(business).minusMonths(2).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + scope.setUpdatedAt(now);scopes.updateById(scope);} + var latest=tasks.selectOne(new QueryWrapper().eq("scope_id",scope.getId()).eq("scope_hash",scope.getScopeHash()).eq("task_type","SCHEDULED").isNotNull("planned_at_utc").orderByDesc("planned_at_utc").last("LIMIT 1")); + LocalDateTime from=latest==null?scope.getCreatedAt():latest.getPlannedAtUtc(); + ZonedDateTime slot=CronExpression.parse(scope.getScheduleCron()).next(from.atZone(ZoneOffset.UTC).withZoneSameInstant(business).minusNanos(1)); + ZonedDateTime last=null;int count=0; + while(slot!=null&&!slot.toInstant().isAfter(now.toInstant(ZoneOffset.UTC))&&count++<100){last=slot;slot=CronExpression.parse(scope.getScheduleCron()).next(slot);} + if(last==null&&latest==null)last=now.atZone(ZoneOffset.UTC).withZoneSameInstant(business); + if(last==null)return; + LocalDateTime planned=last.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + if(latest!=null&&Objects.equals(latest.getPlannedAtUtc(),planned))return; + var request=new SelfopSyncTask();request.setSourceAccount(scope.getSourceAccount());request.setTaskRole("WAREHOUSE_SYNC");request.setScopeId(scope.getId());request.setWarehouseCode(scope.getWarehouseCode()); + request.setTaskType("SCHEDULED");request.setPlannedAtUtc(planned);request.setScopeHash(scope.getScopeHash());request.setQueryScopeJson(scope.getScopeJson()); + request.setRequestKey("slot:"+scope.getWarehouseCode()+":"+scope.getDataFamily()+":"+scope.getScopeHash().substring(0,12)+":"+planned);request.setRequestHash(hash(List.of(scope.getScopeHash(),planned))); + if("OUTBOUND".equals(scope.getDataFamily())){ + // This durable slot marker routes through bounded shared account slices, never a month-long request. + request.setStatus("WAITING_SOURCE");request.setApplyState("NOT_APPLICABLE"); + // The fixed historical window is queued atomically with its first slot marker. + // Slice identity is independent of later 08:00/20:00 slots and publication progress. + if(latest==null){ + LocalDateTime start=scope.getInitialFromUtc(),until=scope.getInitialUntilUtc(); + while(start.isBefore(until)){LocalDateTime end=start.plusDays(1);if(end.isAfter(until))end=until; + shared(scope,start,end,"initial:"+start+":"+end,"BACKFILL",null,null);start=end;} + } + // Prior slots retain their durable pending work; the planning cursor can advance + // before publication without skipping any interval. Re-read the recent overlap. + LocalDateTime start=latest==null?scope.getInitialUntilUtc():latest.getPlannedAtUtc().minusHours(48); + if(start.isBefore(scope.getInitialUntilUtc()))start=scope.getInitialUntilUtc(); + while(start.isBefore(planned)){LocalDateTime end=start.plusDays(1);if(end.isAfter(planned))end=planned; + shared(scope,start,end,"schedule:"+planned+":"+start,"SCHEDULED",null,null);start=end;} + } + taskService.create(request); + }); + } + private SelfopSyncTask shared(SelfopSyncScope scope,LocalDateTime from,LocalDateTime until,String key,String type,String customer,String delivery){ + var account=accounts.selectOne(new QueryWrapper().eq("source_account",scope.getSourceAccount())); + var t=new SelfopSyncTask();t.setSourceAccount(scope.getSourceAccount());t.setTaskRole("ACCOUNT_DISCOVERY");t.setDiscoveryScopeId(account.getId()); + t.setTaskType(type);t.setScopeHash(hash(config.getWarehouses()));t.setRequestKey(key+":"+t.getScopeHash().substring(0,12));t.setQueryFromUtc(from);t.setQueryUntilUtc(until); + ZoneId source=ZoneId.of(config.getSourceQueryTimezone()); + t.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from==null?null:from.atZone(ZoneOffset.UTC).withZoneSameInstant(source).toLocalDateTime().toString(), + until==null?null:until.atZone(ZoneOffset.UTC).withZoneSameInstant(source).toLocalDateTime().toString(),delivery,customer,null))); + t.setRequestHash(hash(Arrays.asList(t.getQueryScopeJson(),t.getScopeHash())));return taskService.create(t); + } + public void review(SelfopSyncScope preview,LocalDateTime now){ + SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId()); + if(scope.getKnownOrderRoundMaxId()==null){var max=orders.selectOne(new QueryWrapper().eq("source_account",scope.getSourceAccount()).eq("warehouse_code",scope.getWarehouseCode()).orderByDesc("id").last("LIMIT 1")); + scope.setKnownOrderRoundMaxId(max==null?0L:max.getId());scope.setKnownOrderCursorId(0L);scope.setKnownOrderRoundStartedAtUtc(now);} + var next=orders.selectOne(new QueryWrapper().eq("source_account",scope.getSourceAccount()).eq("warehouse_code",scope.getWarehouseCode()) + .gt("id",scope.getKnownOrderCursorId()).le("id",scope.getKnownOrderRoundMaxId()).orderByAsc("id").last("LIMIT 1")); + if(next!=null){String key="review:"+scope.getId()+":"+scope.getKnownOrderRoundStartedAtUtc()+":"+next.getId(); + var t=shared(scope,null,null,key,"RECHECK",next.getCustomerCode(),next.getDeliveryNo()); + long pending=tasks.selectCount(new QueryWrapper().eq("upstream_task_id",t.getId()).ne("apply_state","APPLIED")); + long applied=tasks.selectCount(new QueryWrapper().eq("upstream_task_id",t.getId()).eq("apply_state","APPLIED")); + if("SUCCEEDED".equals(t.getStatus())&&pending==0&&applied>0)scope.setKnownOrderCursorId(next.getId()); + }else{scope.setKnownOrderRoundFinishedAtUtc(now); + scope.setKnownOrderRoundMaxId(null);scope.setKnownOrderCursorId(null); + scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper().eq("id",scope.getId()) + .set("known_order_round_max_id",null).set("known_order_cursor_id",null).set("known_order_round_finished_at_utc",now));} + if(scope.getDiscoveryUntilUtc()!=null){ + if(scope.getHistoryRoundUntilUtc()==null){scope.setHistoryRoundFromUtc(scope.getInitialFromUtc());scope.setHistoryRoundUntilUtc(scope.getDiscoveryUntilUtc());scope.setHistorySliceCursorUtc(scope.getInitialFromUtc());} + if(scope.getHistorySliceCursorUtc().isBefore(scope.getHistoryRoundUntilUtc())){ + var end=scope.getHistorySliceCursorUtc().plusDays(1);if(end.isAfter(scope.getHistoryRoundUntilUtc()))end=scope.getHistoryRoundUntilUtc(); + var t=shared(scope,scope.getHistorySliceCursorUtc(),end,"history:"+scope.getId()+":"+scope.getHistoryRoundUntilUtc()+":"+scope.getHistorySliceCursorUtc(),"RECHECK",null,null); + var child=tasks.selectOne(new QueryWrapper().eq("upstream_task_id",t.getId()).eq("scope_id",scope.getId())); + if(child!=null&&"APPLIED".equals(child.getApplyState()))scope.setHistorySliceCursorUtc(end); + }else{scope.setHistoryRoundFinishedAtUtc(now);scope.setHistoryRoundUntilUtc(null);scope.setHistorySliceCursorUtc(null);scopes.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper().eq("id",scope.getId()) + .set("history_round_until_utc",null).set("history_slice_cursor_utc",null).set("history_round_finished_at_utc",now));} + } + scope.setUpdatedAt(now);scopes.updateById(scope); + }); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java new file mode 100644 index 000000000..e33da0bce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java @@ -0,0 +1,222 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.*; + +@Service +public class SelfopTaskService { + private final SelfopSyncScopeMapper scopes; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopCoordinationMapper locks; + private final SelfopAnalyticsProperties config; + private final SelfopLeaseService leases; + public SelfopTaskService(SelfopSyncScopeMapper scopes,SelfopOutboundDiscoveryScopeMapper accounts, + SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopCoordinationMapper locks,SelfopAnalyticsProperties config,SelfopLeaseService leases) { + this.scopes=scopes;this.accounts=accounts;this.tasks=tasks;this.runs=runs;this.locks=locks;this.config=config;this.leases=leases; + } + @Transactional + public List initialize() { + identity(config.getSourceAccount(),64); require(!config.getWarehouses().isEmpty(),"SELFOP_WAREHOUSE_UNCONFIGURED"); + var now=utcNow(); + var a=accounts.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount())); + if(a==null){a=new SelfopOutboundDiscoveryScope();a.setId(IdWorker.getId());a.setSourceAccount(config.getSourceAccount()); + a.setNextSequence(1L);a.setNextObservationSequence(1L);a.setFenceToken(0L); + a.setRequestBudget(config.getRequestBudget());a.setTimeBudgetSeconds(config.getTimeBudgetSeconds()); + a.setMinRequestIntervalMs(config.getMinRequestIntervalMs());a.setCreatedAt(now);a.setUpdatedAt(now); + require(a.getRequestBudget()>0&&a.getTimeBudgetSeconds()>0&&a.getMinRequestIntervalMs()>0,"SELFOP_BUDGET_INVALID"); + try{accounts.insert(a);}catch(DuplicateKeyException ignored){ } + } + for(var e:config.getWarehouses().entrySet()) for(String family:List.of("OUTBOUND","INVENTORY")) { + identity(e.getKey(),64);var p=e.getValue();ZoneId.of(p.getBusinessTimezone()); + var existing=scopes.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("warehouse_code",e.getKey()).eq("data_family",family)); + Map definition=new TreeMap<>();definition.put("customerMode","ALL_AUTHORIZED");definition.put("sourceAccount",config.getSourceAccount()); + definition.put("warehouseCode",e.getKey());definition.put("dataFamily",family);definition.put("businessTimezone",p.getBusinessTimezone()); + definition.put("sourceTimezone",p.getSourceTimezone());definition.put("quantityUnit",config.getQuantityUnit()); + definition.put("qualities",family.equals("INVENTORY")?"ALL":"ACTUAL_OUTBOUND"); + definition.put("staleAfterSeconds",family.equals("INVENTORY")?p.getInventoryStaleAfterSeconds():p.getOutboundStaleAfterSeconds()); + String digest=hash(definition); + if(existing!=null){ + if("VALIDATION".equals(config.getCollectionMode()))continue; + if(!existing.getScopeHash().equals(digest)){ + existing=locks.lockScope(existing.getId()); + var update=new UpdateWrapper().eq("id",existing.getId()) + .set("scope_json",json(definition)).set("scope_hash",digest).set("business_timezone",p.getBusinessTimezone()).set("source_timezone",p.getSourceTimezone()) + .set("schedule_cron",family.equals("OUTBOUND")?p.getOutboundCron():p.getInventoryCron()) + .set("stale_after_seconds",family.equals("OUTBOUND")?p.getOutboundStaleAfterSeconds():p.getInventoryStaleAfterSeconds()) + .set("fence_token",existing.getFenceToken()+1).set("lease_owner",null).set("lease_until_utc",null).set("updated_at",now); + if("OUTBOUND".equals(family)){ + update.set("discovery_from_utc",null).set("discovery_until_utc",null).set("rebuild_required",1).set("invalidated_at_utc",now) + .set("fact_revision",existing.getFactRevision()+1).set("known_order_round_max_id",null).set("known_order_cursor_id",null) + .set("history_round_until_utc",null).set("history_slice_cursor_utc",null); + if(existing.getInitialUntilUtc()!=null)update.set("initial_from_utc",existing.getInitialUntilUtc().atZone(ZoneOffset.UTC) + .withZoneSameInstant(ZoneId.of(p.getBusinessTimezone())).minusMonths(2).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()); + } + scopes.update(null,update); + } + continue; + } + var s=new SelfopSyncScope();s.setId(IdWorker.getId());s.setSourceAccount(config.getSourceAccount());s.setWarehouseCode(e.getKey());s.setDataFamily(family); + s.setScopeJson(json(definition));s.setScopeHash(digest);s.setBusinessTimezone(p.getBusinessTimezone());s.setSourceTimezone(p.getSourceTimezone()); + s.setScheduleCron(family.equals("OUTBOUND")?p.getOutboundCron():p.getInventoryCron()); + s.setStaleAfterSeconds(family.equals("OUTBOUND")?p.getOutboundStaleAfterSeconds():p.getInventoryStaleAfterSeconds()); + s.setFactRevision(0L);s.setPublishedFactRevision(0L);s.setRebuildRequired(0);s.setFenceToken(0L); + s.setReviewRequestBudget(p.getReviewRequestBudget());s.setReviewTimeBudgetSeconds(p.getReviewTimeBudgetSeconds());s.setReviewTargetDays(p.getReviewTargetDays()); + require(s.getStaleAfterSeconds()>0&&s.getReviewRequestBudget()>0&&s.getReviewTimeBudgetSeconds()>0,"SELFOP_BUDGET_INVALID"); + s.setCreatedAt(now);s.setUpdatedAt(now);try{scopes.insert(s);}catch(DuplicateKeyException ignored){ } + } + return scopes.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()).orderByAsc("warehouse_code","data_family")); + } + @Transactional + public SelfopSyncTask create(SelfopSyncTask task) { + identity(task.getRequestKey(),128); var existing=tasks.selectOne(new QueryWrapper() + .eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey())); + if(existing!=null){require(existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;} + if(task.getExecutionPurpose()==null)task.setExecutionPurpose(configuredPurpose(config)); + requireExecution(config,task.getExecutionPurpose()); + var now=utcNow();task.setId(IdWorker.getId());task.setAttemptCount(0);task.setCreatedAt(now);task.setUpdatedAt(now); + task.setTaskNo("LXCOL-"+now.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.of("Asia/Shanghai")).toLocalDate().toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,16).toUpperCase(Locale.ROOT)); + if("VALIDATION".equals(task.getExecutionPurpose())){ + require("VALIDATION".equals(task.getTaskType()),"SELFOP_VALIDATION_ENTRY_REQUIRED"); + if(task.getValidationRootTaskId()==null){task.setValidationRootTaskId(task.getId());task.setValidationBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopValidationBudget.freeze(now));} + task.setApplyState("NOT_APPLICABLE"); + } + task.setStatus(task.getStatus()==null?"READY":task.getStatus()); + task.setApplyState(task.getApplyState()==null?"NOT_APPLICABLE":task.getApplyState()); + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())) { + var account=locks.lockDiscovery(task.getDiscoveryScopeId());task.setDiscoverySequence(account.getNextSequence()); + task.setObservationBaseline(account.getNextObservationSequence()-1);account.setNextSequence(account.getNextSequence()+1);account.setUpdatedAt(now);accounts.updateById(account); + } + try{tasks.insert(task);}catch(DuplicateKeyException ignored){existing=tasks.selectOne(new QueryWrapper() + .eq("source_account",task.getSourceAccount()).eq("request_key",task.getRequestKey()).last("FOR UPDATE")); + require(existing!=null&&existing.getRequestHash().equals(task.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT");return existing;} + return task; + } + @Transactional + public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) { + leases.verify(lease); + var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE"); + checkTaskLease(task,lease); + requireExecution(config,task.getExecutionPurpose()); + require(!"SAMPLE_DONE".equals(task.getStatus()),"SELFOP_VALIDATION_TERMINATED"); + if(lease.account()) {var account=locks.lockDiscovery(lease.scopeId());account.setActiveTaskId(taskId);account.setUpdatedAt(utcNow());accounts.updateById(account);} + var now=utcNow();var active=runs.selectOne(new QueryWrapper().eq("task_id",taskId).eq("status","FETCHING").orderByDesc("id").last("LIMIT 1")); + if(task.getScopeId()!=null && "INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { + var yielded=runs.selectOne(new QueryWrapper().eq("task_id",taskId).eq("status","YIELDED").orderByDesc("id").last("LIMIT 1")); + if(yielded!=null){yielded.setStatus("FETCHING");yielded.setFinishedAtUtc(null);yielded.setFenceToken(lease.fence());yielded.setUpdatedAt(now); + runs.update(null,new UpdateWrapper().eq("id",yielded.getId()).set("status","FETCHING").set("fence_token",lease.fence()).set("finished_at_utc",null).set("updated_at",now)); + task.setStatus("RUNNING");task.setUpdatedAt(now);tasks.updateById(task);return yielded;} + } + if(active!=null){require(!Objects.equals(active.getFenceToken(),lease.fence()),"SELFOP_TASK_ALREADY_RUNNING"); + SelfopProgressProjection.freezeRun(task,active); + active.setStatus("ABORTED");active.setLastPhase("FETCHING");active.setFinishedAtUtc(now);active.setErrorCode("LEASE_EXPIRED");active.setErrorMessage("执行租约失效");active.setUpdatedAt(now);runs.updateById(active);} + if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { + // Every genuinely new inventory attempt starts at page one, including a crash that never called fail(). + task.setRoutingManifestJson(null); + tasks.update(null,new UpdateWrapper().eq("id",taskId).set("routing_manifest_json",null)); + } + var run=new SelfopSyncRun();run.setId(IdWorker.getId());run.setTaskId(taskId);run.setScopeId(task.getScopeId()); + run.setExecutionPurpose(task.getExecutionPurpose());run.setHeartbeatAtUtc(now); + run.setSourceAccount(task.getSourceAccount());run.setWarehouseCode(task.getWarehouseCode());run.setRunRole(task.getTaskRole());run.setUpstreamTaskId(task.getUpstreamTaskId()); + run.setAttemptNo(task.getAttemptCount()+1);run.setScopeHash(task.getScopeHash());run.setScopeSnapshotJson(task.getQueryScopeJson()); + run.setFenceToken(lease.fence());run.setStatus("FETCHING");run.setLastPhase("FETCHING");run.setFetchStartedAtUtc(now); + run.setPageCount(0);run.setUniqueObjectCount(0L);run.setCoverageStatus("UNKNOWN");run.setCreatedAt(now);run.setUpdatedAt(now); + if(task.getScopeId()!=null){var scope=scopes.selectById(task.getScopeId());run.setBasePublishedRunId(scope.getPublishedRunId()); + Map snapshot=new TreeMap<>(read(scope.getScopeJson(),Map.class)); + snapshot.put("initialFromUtc",scope.getInitialFromUtc());snapshot.put("initialUntilUtc",scope.getInitialUntilUtc()); + snapshot.put("businessTimezone",scope.getBusinessTimezone());snapshot.put("sourceTimezone",scope.getSourceTimezone()); + snapshot.put("staleAfterSeconds",scope.getStaleAfterSeconds());run.setScopeSnapshotJson(json(snapshot)); + if("INVENTORY".equals(scope.getDataFamily())){var sales=scopes.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount()).eq("warehouse_code",task.getWarehouseCode()).eq("data_family","OUTBOUND")); + if(sales!=null)sales=locks.lockScope(sales.getId()); + run.setReferenceSalesRunId(sales==null?null:sales.getPublishedRunId());}} + runs.insert(run);task.setAttemptCount(run.getAttemptNo());task.setStatus("RUNNING");task.setUpdatedAt(now); + String initialPhase=lease.account()?"DIRECTORY":("INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())?"INVENTORY":"MERGE"); + SelfopProgressProjection.applyCheckpoint(task,run,initialPhase,"PAGE",0,null,1);tasks.updateById(task);return run; + } + @Transactional + public void yield(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease); + var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setStatus("YIELDED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(now.plusSeconds(1));task.setUpdatedAt(now);tasks.updateById(task); + } + @Transactional + public void fail(long taskId,long runId,String code,boolean retryable,SelfopLeaseService.Lease lease) { + leases.verify(lease); + var now=utcNow();var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("FAILED");run.setErrorCode(code); + run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); + // A yielded execution is not a failed request; finite failures are counted separately. + long failures=runs.selectCount(new QueryWrapper().eq("task_id",taskId).eq("status","FAILED")); + task.setStatus(retryable&&failures().eq("id",taskId).set("routing_manifest_json",null)); + } + if(Set.of("SELFOP_DIRECTORY_DUPLICATE","SELFOP_DIRECTORY_COUNT_MISMATCH","SELFOP_DIRECTORY_UNSTABLE","SELFOP_PAGE_TOTAL_CHANGED").contains(code)) { + task.setRoutingManifestJson(null); + tasks.update(null,new UpdateWrapper().eq("id",taskId).set("routing_manifest_json",null)); + } + task.setNextRetryAtUtc(now.plusSeconds(Math.min(3600,5L<().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey); + var prior=tasks.selectOne(commandQuery); + if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return preview;} + // Match executor lock ordering: account/warehouse scope first, task second. + String activeOwner;LocalDateTime activeUntil; + if("ACCOUNT_DISCOVERY".equals(preview.getTaskRole())){var scope=locks.lockDiscovery(preview.getDiscoveryScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} + else{var scope=locks.lockScope(preview.getScopeId());require(scope!=null,"SELFOP_SCOPE_MISSING");activeOwner=scope.getLeaseOwner();activeUntil=scope.getLeaseUntilUtc();} + prior=tasks.selectOne(new QueryWrapper().eq("source_account",preview.getSourceAccount()).eq("request_key","retry:"+requestKey).last("FOR UPDATE")); + if(prior!=null){require(prior.getRequestHash().equals(hash(List.of(id,requestKey,reason))),"SELFOP_IDEMPOTENCY_CONFLICT");return tasks.selectById(id);} + require(SelfopLeaseService.available(activeOwner,activeUntil,utcNow()),"SELFOP_RETRY_LEASE_BUSY"); + var original=locks.lockTask(id);require(original!=null,"SELFOP_TASK_MISSING"); + requireBusiness(original);requireExecution(config,original.getExecutionPurpose()); + require(!"SUCCEEDED".equals(original.getStatus()),"SELFOP_TASK_ALREADY_SUCCEEDED"); + // Resume original logical task, retaining apply barrier and stage set. Record the manual command separately. + var audit=new SelfopSyncTask();audit.setSourceAccount(original.getSourceAccount());audit.setTaskRole(original.getTaskRole());audit.setScopeId(original.getScopeId()); + audit.setExecutionPurpose(original.getExecutionPurpose()); + audit.setWarehouseCode(original.getWarehouseCode());audit.setDiscoveryScopeId(original.getDiscoveryScopeId());audit.setUpstreamTaskId(original.getId()); + audit.setRequestKey("retry:"+requestKey);audit.setRequestHash(hash(List.of(id,requestKey,reason)));audit.setTaskType("MANUAL_RETRY");audit.setScopeHash(original.getScopeHash()); + audit.setQueryScopeJson(json(Map.of("resumesTaskNo",original.getTaskNo(),"reason",reason)));audit.setStatus("SUCCEEDED");audit.setApplyState("NOT_APPLICABLE");audit.setRequestedBy(operator); + create(audit); + original.setStatus("READY");original.setNextRetryAtUtc(utcNow());original.setRequestedBy(operator);original.setUpdatedAt(utcNow());tasks.updateById(original); + return original; + } + public List due(int limit){return locks.due(utcNow(),limit,config.getCollectionMode());} + @Transactional + public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) { + leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId); + checkRunLease(task,run,lease); + require("READY".equals(run.getStatus()),"SELFOP_CANDIDATE_NOT_READY"); + run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); + // Candidate readiness is not business success until a published result is linked. + task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(utcNow().plusSeconds(config.getMinimumBuildIntervalSeconds()));task.setUpdatedAt(utcNow());tasks.updateById(task); + } + @Transactional + public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) { + leases.verify(lease);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run); + SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("YIELDED");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run); + task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(until);task.setUpdatedAt(utcNow());tasks.updateById(task); + } + private static void checkTaskLease(SelfopSyncTask task,SelfopLeaseService.Lease lease){ + require(task!=null&&(lease.account()?"ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&Objects.equals(task.getDiscoveryScopeId(),lease.scopeId()) + :"WAREHOUSE_SYNC".equals(task.getTaskRole())&&Objects.equals(task.getScopeId(),lease.scopeId())),"SELFOP_TASK_LEASE_SCOPE_MISMATCH"); + } + private static void checkRunLease(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease){ + checkTaskLease(task,lease);require(run!=null&&Objects.equals(run.getTaskId(),task.getId())&&Objects.equals(run.getFenceToken(),lease.fence()),"SELFOP_RUN_LEASE_MISMATCH"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java new file mode 100644 index 000000000..3ad5c8a3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java @@ -0,0 +1,208 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.*; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import lombok.Data; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.time.Duration; +import java.util.*; +import java.util.function.Supplier; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Bounded sample workflow. Deliberately has no facts, scope, SKU, candidate or publisher dependencies. */ +@Service +public class SelfopValidationRunner { + private final LingxingAnalyticsClient client; + private final SelfopSyncStageMapper stages; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopCoordinationMapper locks; + private final SelfopLeaseService leases; + private final SelfopEgressService egress; + private final SelfopAnalyticsProperties config; + private final TransactionTemplate tx; + public SelfopValidationRunner(LingxingAnalyticsClient client,SelfopSyncStageMapper stages, + SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs,SelfopCoordinationMapper locks, + SelfopLeaseService leases,SelfopEgressService egress,SelfopAnalyticsProperties config,TransactionTemplate tx) { + this.client=client;this.stages=stages;this.tasks=tasks;this.runs=runs;this.locks=locks; + this.leases=leases;this.egress=egress;this.config=config;this.tx=tx; + } + + public void run(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) { + require("VALIDATION".equals(config.getCollectionMode())&&"VALIDATION".equals(task.getExecutionPurpose()) + &&"VALIDATION".equals(run.getExecutionPurpose()),"SELFOP_VALIDATION_PURPOSE_REQUIRED"); + require(task.getValidationRootTaskId()!=null,"SELFOP_VALIDATION_ROOT_REQUIRED"); + require(lease.account(),"SELFOP_ACCOUNT_LEASE_REQUIRED"); + var context=SelfopRequestContext.forTask(task,run,config); + try { + var state=task.getRoutingManifestJson()==null?new Sample():read(task.getRoutingManifestJson(),Sample.class); + Map query=read(task.getQueryScopeJson(),Map.class); + var from=LocalDateTime.parse(Objects.toString(query.get("startTime"),"")); + var until=LocalDateTime.parse(Objects.toString(query.get("endTime"),"")); + require(until.isAfter(from)&&Duration.between(from,until).compareTo(Duration.ofDays(1))<=0,"SELFOP_VALIDATION_WINDOW_INVALID"); + if(!state.warehousesRead) { + leases.renew(lease); var page=request(task,run,lease,()->client.warehouses(context,1,10)); validatePage(page,1); + tx.executeWithoutResult(s->{check(task,run,lease); + for(var warehouse:page.records()) { + identity(warehouse.warehouseCode(),64); + } + run.setObservedDimensionsJson(json(Map.of("validationWarehouseSamples",page.records(),"truncated",page.pages()>1))); + state.warehousesRead=true;state.pages++;save(task,run,state,"DIRECTORY","PAGE",state.pages,null); + }); + } + while(!state.directoryRead&&state.outboundPage<=2) { + int current=state.outboundPage;leases.renew(lease); + var page=request(task,run,lease,()->client.discover(context,from,until,current,10));validatePage(page,current); + tx.executeWithoutResult(s->{check(task,run,lease); + for(var summary:page.records()) { + require(Objects.equals(task.getSourceAccount(),summary.sourceAccount()),"SELFOP_SAMPLE_ACCOUNT_MISMATCH"); + identity(summary.customerCode(),64);identity(summary.warehouseCode(),64);identity(summary.deliveryNo(),128); + String key=hash(List.of(summary.customerCode(),summary.deliveryNo())); + require(state.orders.stream().noneMatch(o->key.equals(hash(List.of(o.customerCode(),o.deliveryNo())))),"SELFOP_SAMPLE_DIRECTORY_DUPLICATE"); + state.orders.add(summary); + stage(task,run,"OUTBOUND_ORDER","directory:"+key,summary.warehouseCode(),current,summary,"PENDING",null); + } + state.outboundPage++;state.pages++;state.directoryRead=page.pages()==0||current>=page.pages()||state.outboundPage>2; + save(task,run,state,"DIRECTORY","PAGE",state.pages,null); + }); + } + while(state.detailIndexclient.detail(context,summary.warehouseCode(),summary.customerCode(),summary.deliveryNo())); + validateOrder(task,summary,detail); + tx.executeWithoutResult(s->{check(task,run,lease); + boolean unknown=detail.items().stream().anyMatch(i->i.actualQuantity()==null); + boolean invalid=detail.items().stream().anyMatch(i->i.actualQuantity()!=null&&i.actualQuantity().signum()<0); + stage(task,run,"OUTBOUND_ORDER",hash(List.of(detail.customerCode(),detail.deliveryNo())),detail.warehouseCode(),1,detail, + invalid?"INVALID":unknown?"PENDING":"VALID",invalid?"NEGATIVE_QUANTITY":unknown?"QUANTITY_UNKNOWN":null); + state.detailIndex++;save(task,run,state,"DETAIL","ORDER",state.detailIndex,(long)state.orders.size()); + }); + } + // Each configured warehouse is sampled independently, never inferred from directory absence. + for(String warehouse:config.getWarehouses().keySet()) { + int current=state.inventoryNextPage.getOrDefault(warehouse,1); + while(current<=2&&!state.inventoryFinished.contains(warehouse)) { + leases.renew(lease);int pageNo=current; + var page=request(task,run,lease,()->client.inventory(context,warehouse,pageNo,10));validatePage(page,current); + tx.executeWithoutResult(s->{check(task,run,lease); + for(var stock:page.records()) { + require(Objects.equals(stock.sourceAccount(),task.getSourceAccount())&&warehouse.equals(stock.warehouseCode()),"SELFOP_SAMPLE_INVENTORY_IDENTITY"); + identity(stock.customerCode(),64);identity(stock.skuCode(),128);require(stock.productQuality()!=null,"SELFOP_SAMPLE_INVENTORY_STRUCTURE"); + String key=hash(List.of(stock.customerCode(),stock.warehouseCode(),stock.skuCode(),stock.productQuality())); + require(state.inventoryKeys.add(key),"SELFOP_SAMPLE_INVENTORY_DUPLICATE"); + boolean unknown=stock.physicalQuantity()==null||stock.availableQuantity()==null||stock.frozenQuantity()==null||stock.transitQuantity()==null; + boolean invalid=Arrays.asList(stock.physicalQuantity(),stock.availableQuantity(),stock.frozenQuantity(),stock.transitQuantity()).stream().filter(Objects::nonNull).anyMatch(q->q.signum()<0); + stage(task,run,"PRODUCT_STOCK",key,warehouse,pageNo,stock,invalid?"INVALID":unknown?"PENDING":"VALID",invalid?"NEGATIVE_QUANTITY":unknown?"QUANTITY_UNKNOWN":null); + } + state.inventoryNextPage.put(warehouse,pageNo+1);state.pages++;state.inventoryRows+=page.records().size(); + if(page.pages()==0||pageNo>=page.pages()||pageNo==2)state.inventoryFinished.add(warehouse); + save(task,run,state,"INVENTORY","INVENTORY_ROW",state.inventoryRows,null); + });current++; + } + } + finish(task,run,lease,"VALIDATION_SAMPLE_SUFFICIENT",false); + } catch(EgressRejectedException stop) { + // No YIELDED or retry scheduling: every validation root is a single finite session. + finish(task,run,lease,stop.sampleDone()?stop.code():"VALIDATION_REQUEST_REJECTED",!stop.sampleDone()); + } catch(AnalyticsClientException failure) { + finish(task,run,lease,failure.code(),true); + } catch(RuntimeException invalid) { + if("SELFOP_FENCE_REJECTED".equals(invalid.getMessage())||"SELFOP_LEASE_BUSY".equals(invalid.getMessage()))throw invalid; + finish(task,run,lease,"VALIDATION_STRUCTURE_REJECTED",true); + } + } + + /** Execution-level failures still terminate the original bounded validation session. */ + public void fail(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,String code) { + finish(task,run,lease,code,true); + } + + private void check(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease) { + leases.verify(lease);var stored=locks.lockTask(task.getId());var active=locks.lockRun(run.getId()); + require(stored!=null&&active!=null&&Objects.equals(stored.getDiscoveryScopeId(),lease.scopeId()) + &&Objects.equals(active.getTaskId(),stored.getId())&&Objects.equals(active.getFenceToken(),lease.fence()) + &&Objects.equals(stored.getAttemptCount(),active.getAttemptNo())&&"VALIDATION".equals(stored.getExecutionPurpose()) + &&"VALIDATION".equals(active.getExecutionPurpose())&&Objects.equals(stored.getSourceAccount(),task.getSourceAccount()) + &&Objects.equals(active.getSourceAccount(),stored.getSourceAccount()) + &&Objects.equals(stored.getValidationRootTaskId(),task.getValidationRootTaskId()),"SELFOP_FENCE_REJECTED"); + } + private void stage(SelfopSyncTask task,SelfopSyncRun run,String type,String key,String warehouse,int page,Object payload,String status,String code) { + // VALID outbound stages require an observation sequence in the schema. Samples never + // acquire a formal sequence: structure success remains explicit pending sample evidence. + if("VALID".equals(status)){status="PENDING";code="SAMPLE_VALIDATED";} + String keyHash=hash(key); + var prior=stages.selectOne(new QueryWrapper().eq("stage_batch_id",task.getId()).eq("object_type",type).eq("object_key_hash",keyHash)); + if(prior!=null){require(prior.getContentHash().equals(hash(payload)),"SELFOP_SAMPLE_STAGE_CHANGED");return;} + var row=new SelfopSyncStage();row.setId(IdWorker.getId());row.setStageBatchId(task.getId());row.setRunId(run.getId());row.setLastFetchRunId(run.getId()); + row.setObjectType(type);row.setObjectKeyHash(keyHash);row.setWarehouseCode(warehouse);row.setFirstPageNo(page);row.setObservedAtUtc(utcNow()); + row.setContentHash(hash(payload));row.setPayloadJson(json(payload));row.setValidationStatus(status);row.setValidationCode(code); + row.setCreatedAt(utcNow());row.setUpdatedAt(utcNow());stages.insert(row); + } + private void save(SelfopSyncTask task,SelfopSyncRun run,Sample state,String phase,String unit,long completed,Long total) { + task.setRoutingManifestJson(json(state));SelfopProgressProjection.applyCheckpoint(task,run,phase,unit,completed,total,1); + tasks.update(null,new UpdateWrapper().eq("id",task.getId()).set("routing_manifest_json",task.getRoutingManifestJson()) + .set("progress_json",task.getProgressJson()).set("progress_updated_at_utc",task.getProgressUpdatedAtUtc()).set("updated_at",utcNow())); + runs.updateById(run); + } + private void finish(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,String code,boolean failed) { + // Quota owns root stop reason and consumed allowance. Never overwrite it from a stale task object. + tx.executeWithoutResult(s->{check(task,run,lease); + if(failed)egress.failValidation(task.getValidationRootTaskId(),code);else egress.finishSample(task.getValidationRootTaskId(),code); + var saved=locks.lockTask(task.getId());var attempt=locks.lockRun(run.getId()); + boolean dead=failed||"DEAD".equals(saved.getStatus())||"FAILED".equals(attempt.getStatus()); + SelfopProgressProjection.applyCheckpoint(saved,attempt,dead?"FAILED":"SAMPLE_DONE","NONE",0,null,1); + SelfopProgressProjection.freezeRun(saved,attempt);attempt.setStatus(dead?"FAILED":"SAMPLE_DONE"); + if(attempt.getErrorCode()==null)attempt.setErrorCode(code); + attempt.setCoverageStatus("UNKNOWN");attempt.setFetchFinishedAtUtc(utcNow());attempt.setFinishedAtUtc(utcNow());attempt.setUpdatedAt(utcNow());runs.updateById(attempt); + tasks.update(null,new UpdateWrapper().eq("id",task.getId()).set("status",dead?"DEAD":"SAMPLE_DONE") + .set("next_retry_at_utc",null).set("progress_json",saved.getProgressJson()).set("progress_updated_at_utc",saved.getProgressUpdatedAtUtc()).set("updated_at",utcNow())); + }); + } + private T request(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,Supplier request) { + while(true) { + try{return request.get();} + catch(EgressRejectedException wait) { + if(!Set.of("ACCOUNT_INTERVAL_WAIT","ACCOUNT_REQUEST_INFLIGHT").contains(wait.code())||wait.waitUntilUtc()==null)throw wait; + var root=tasks.selectById(task.getValidationRootTaskId()); + require(root!=null&&root.getValidationBudgetJson()!=null,"SELFOP_VALIDATION_ROOT_REQUIRED"); + var budget=read(root.getValidationBudgetJson(),SelfopValidationBudget.class); + if(!wait.waitUntilUtc().isBefore(budget.deadlineAtUtc))throw new EgressRejectedException("VALIDATION_DEADLINE",true,null); + while(utcNow().isBefore(wait.waitUntilUtc())) { + leases.renew(lease); + try{Thread.sleep(Math.min(1000,Math.max(1,Duration.between(utcNow(),wait.waitUntilUtc()).toMillis())));} + catch(InterruptedException interrupted){Thread.currentThread().interrupt();throw new EgressRejectedException("VALIDATION_INTERRUPTED",false,null);} + } + } + } + } + private static void validatePage(LingxingAnalyticsClient.Page page,int current) { + require(page.current()!=null&&page.current()==current&&page.size()!=null&&page.size()==10&&page.total()!=null&&page.total()>=0 + &&page.pages()!=null&&page.pages()>=0&&page.records().size()<=10,"SELFOP_SAMPLE_PAGE_INVALID"); + } + private static void validateOrder(SelfopSyncTask task,LingxingAnalyticsClient.Summary summary,SelfopPayloads.Order order) { + require(Objects.equals(task.getSourceAccount(),order.sourceAccount())&&Objects.equals(summary.warehouseCode(),order.warehouseCode()) + &&Objects.equals(summary.customerCode(),order.customerCode())&&Objects.equals(summary.deliveryNo(),order.deliveryNo()),"SELFOP_SAMPLE_DETAIL_IDENTITY"); + require(order.sourceStatus()!=null&&order.items()!=null,"SELFOP_SAMPLE_DETAIL_STRUCTURE"); + for(var item:order.items())identity(item.skuCode(),128); + } + @Data + public static class Sample { + private boolean warehousesRead; + private int pages; + private int outboundPage=1; + private boolean directoryRead; + private List orders=new ArrayList<>(); + private int detailIndex; + private Map inventoryNextPage=new LinkedHashMap<>(); + private Set inventoryFinished=new LinkedHashSet<>(); + private Set inventoryKeys=new LinkedHashSet<>(); + private long inventoryRows; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java new file mode 100644 index 000000000..f2f8b21cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@RestController +public class SyncController { + private final SelfopCommandService commands; + public SyncController(SelfopCommandService commands){this.commands=commands;} + public record Accepted(String taskNo,String warehouseCode,String status) { } + @PostMapping("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks") + @PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')") + @Operation(summary="创建自营分析人工采集或恢复任务",description="平台独立写权限 platform:self-operated-analytics:sync;持久化后异步执行,执行开关默认关闭。BACKFILL使用不超过一天的来源时间切片;未知源时区仅允许暂存。") + public ApiResponse request(@AuthenticationPrincipal AuthenticatedUser user,@RequestBody SelfopCommandService.Command command){ + if(user==null||user.tenantId()==null||user.tenantId()!=0||!"PLATFORM".equals(user.appCode())||user.impersonated()||!user.permissions().contains("platform:self-operated-analytics:sync")) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅限已授权平台人员"); + var task=commands.request(command,String.valueOf(user.userId()));return ApiResponse.success(new Accepted(task.getTaskNo(),command.warehouseCode(),"ACCEPTED")); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java index 1152f9cd4..919f9f544 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java @@ -1,8 +1,10 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.lastleg.service.reference.ReturnCustomerReferenceClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; @@ -16,12 +18,15 @@ import java.util.Map; @Service public class ReturnOrderReferenceResolver { private static final Logger log = LoggerFactory.getLogger(ReturnOrderReferenceResolver.class); - private final FleetCustomerDirectory customers; + private final ReturnCustomerReferenceClient customers; private final WarehouseResourceGateway warehouses; + private final String internalToken; - public ReturnOrderReferenceResolver(FleetCustomerDirectory customers, WarehouseResourceGateway warehouses) { + public ReturnOrderReferenceResolver(ReturnCustomerReferenceClient customers, WarehouseResourceGateway warehouses, + @Value("${baiyue.security.internal-service-token}") String internalToken) { this.customers = customers; this.warehouses = warehouses; + this.internalToken = internalToken; } public void populate(List orders) { @@ -33,14 +38,14 @@ public class ReturnOrderReferenceResolver { } private void populate(List orders, boolean required) { - Map customerReferences = new HashMap<>(); + Map customerReferences = new HashMap<>(); Map warehouseReferences = new HashMap<>(); for (LastlegOrder order : orders) { if (!"RETURN_RELABEL".equals(order.getOrderType())) continue; if (!StringUtils.hasText(order.getCustomerName())) { try { - var customer = customerReferences.computeIfAbsent(order.getCustomerId(), customers::find); - order.setCustomerName(customer.name()); + var customer = customerReferences.computeIfAbsent(order.getCustomerId(), this::customerReference); + order.setCustomerName(customer.tenantName()); } catch (ResponseStatusException error) { if (required) throw error; log.warn("Return customer reference unavailable for order {}, status {}", order.getId(), error.getStatusCode().value()); @@ -59,4 +64,18 @@ public class ReturnOrderReferenceResolver { } } } + + private ReturnCustomerReferenceClient.Customer customerReference(String id) { + try { + var result = customers.customers(internalToken, 1, 30, null, "ACTIVE", Long.parseLong(id)); + if (result == null || !result.successful() || result.data() == null) + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试"); + return result.data().records().stream() + .filter(row -> row.id() != null && row.id() > 0 && row.id().toString().equals(id) && "ACTIVE".equals(row.status())) + .findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户")); + } catch (ResponseStatusException error) { throw error; } + catch (RuntimeException error) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试"); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java new file mode 100644 index 000000000..691919b9d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.service.reference; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +/** IAM owns customer references used to fill return-order snapshots. */ +@FeignClient(name = "iam-service", contextId = "returnCustomerReferenceClient", url = "${baiyue.clients.iam.url:}") +public interface ReturnCustomerReferenceClient { + @GetMapping("/internal/iam/finance-customers") + ApiResponse> customers(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("page") long page, @RequestParam("size") long size, @RequestParam("keyword") String keyword, + @RequestParam("status") String status, @RequestParam("customerId") Long customerId); + + record Customer(Long id, String tenantCode, String tenantName, String status, String contactName, String contactPhone) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java new file mode 100644 index 000000000..5f7f6a42e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.transfer.gateway; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import java.util.List; + +/** Authoritative warehouse catalog for resolving the frozen transfer destination. */ +@FeignClient(name = "warehouse-service", contextId = "transferWarehouseCatalogClient", + url = "${baiyue.clients.domestic-warehouse.url:}") +public interface TransferWarehouseCatalogClient { + @GetMapping("/internal/warehouses/order-catalog") + ApiResponse> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token); + + record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java index e952751bf..619498af3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java @@ -2,7 +2,7 @@ package com.baiyue.lastleg.transfer.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; -import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.baiyue.lastleg.transfer.gateway.TransferWarehouseCatalogClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses; @@ -41,7 +41,7 @@ public class WarehouseTransferReceiptCoordinator { @Resource private WarehouseTransferReceiptCommandMapper commands; @Resource private WarehouseResourceGateway warehouse; @Resource private DomesticWarehouseDirectoryClient directory; - @Resource private FleetWarehouseClient catalog; + @Resource private TransferWarehouseCatalogClient catalog; @Resource private PlatformTransactionManager transactionManager; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String internalToken; diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 21524c7a4..b7d66926e 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -46,6 +46,18 @@ lastleg: enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} baiyue: + self-operated-analytics: + collection-mode: ${SELFOP_COLLECTION_MODE:OFF} + environment: ${DEPLOY_ENV:test} + daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0} + business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000} + execution-enabled: ${SELFOP_EXECUTION_ENABLED:false} + sales-publication-enabled: ${SELFOP_SALES_PUBLICATION_ENABLED:false} + integration-base-url: http://integration-service:9210 + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + source-account: ${SELFOP_SOURCE_ACCOUNT:lingxing-primary} + source-query-timezone: Asia/Shanghai + quantity-unit: PCS http: clients: unload-photo-file: diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e8f84b81c..dfb7edc76 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3588,3 +3588,304 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- 新签收及新合箱必须打印并扫码确认标签;既有实物按原状态继续作业。 SET @ddl = IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='label_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN label_status VARCHAR(32) NULL COMMENT ''WAIT_LABEL待贴标 CONFIRMED已扫码确认标签'''); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + + +-- 平台自营领星统计:独立事实与不可变发布版本;仅空库初始化,不自动执行。 +CREATE TABLE ll_selfop_outbound_discovery_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖', + catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖', + next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧', + next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次', + active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务', + lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者', + lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务', + request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正', + time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正', + min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置', + api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据', + api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除', + api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_discovery_account (source_account), + CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域'; + +CREATE TABLE ll_selfop_sync_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥', + scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单', + scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合', + business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准', + source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区', + schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行', + stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整', + warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表', + initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月', + initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间', + discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界', + discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点', + fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加', + published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号', + rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1', + invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻', + published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次', + lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁', + lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验', + known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标', + known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识', + known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间', + known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间', + history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界', + history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界', + history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置', + history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻', + review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正', + review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正', + review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family), + KEY idx_selfop_scope_lease (lease_until_utc), + CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针'; + +CREATE TABLE ll_selfop_sync_task ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识', + execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承', + validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空', + validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器', + upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游', + discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定', + observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', + request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', + task_type VARCHAR(24) NOT NULL COMMENT 'SCHEDULED、MANUAL_RETRY、BACKFILL、RECHECK、REBUILD、VALIDATION;验证不由自动计划创建', + planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', + query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', + query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', + query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号', + routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷', + scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要', + status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功', + apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE', + affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补', + apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间', + apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数', + next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻', + requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计', + result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空', + progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合', + progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_task_no (task_no), + UNIQUE KEY uk_selfop_task_request (source_account,request_key), + KEY idx_selfop_task_poll (status,next_retry_at_utc,id), + KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code), + KEY idx_selfop_task_apply (apply_state,id), + KEY idx_selfop_task_scope_list (scope_id,created_at,id), + KEY idx_selfop_task_account_list (source_account,task_role,created_at,id), + CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求'; + +CREATE TABLE ll_selfop_sync_run ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_id BIGINT NOT NULL COMMENT '本次执行请求', + execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本', + scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合', + attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增', + scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要', + scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版', + observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围', + fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本', + base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本', + reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实', + inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空', + inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存', + status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头', + last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实', + fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间', + fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空', + build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', + finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间', + published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间', + superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', + page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数', + source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空', + unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数', + progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写', + heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进', + coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断', + covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界', + covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点', + complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含', + complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含', + applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空', + calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号', + error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息', + error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no), + UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date), + KEY idx_selfop_run_scope (scope_id,id), + KEY idx_selfop_run_status (status,updated_at), + CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')), + CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头'; + +CREATE TABLE ll_selfop_sync_stage ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试', + stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页', + last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源', + observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓', + object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK', + object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对', + first_page_no INT NOT NULL COMMENT '首次发现页码', + observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻', + content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要', + payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段', + before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空', + validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常', + validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash), + KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id), + KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence), + KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status), + CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据'; + +CREATE TABLE ll_selfop_outbound_order ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份', + source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示', + source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码', + source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代', + source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代', + source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区', + outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空', + business_date DATE NULL COMMENT '已核准业务时区下的出库日', + business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日', + sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1', + revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数', + content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要', + last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试', + last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no), + KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id), + KEY idx_selfop_order_run (last_run_id), + CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实'; + +CREATE TABLE ll_selfop_outbound_item ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + order_id BIGINT NOT NULL COMMENT '对应外部出库事实', + sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku', + product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示', + actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值', + quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order_sku (order_id,sku_code), + KEY idx_selfop_item_sku (sku_code,order_id), + CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细'; + +CREATE TABLE ll_selfop_inventory_snapshot ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存', + product_name VARCHAR(255) NULL COMMENT '来源名称', + physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空', + available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结', + frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空', + transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存', + source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据', + observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间', + quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生', + quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality), + KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细'; + +CREATE TABLE ll_selfop_sku_daily_sales ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + business_date DATE NOT NULL COMMENT '已核准业务日', + outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负', + outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负', + quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date), + KEY idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code), + CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; + +CREATE TABLE ll_selfop_sku_profile ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称', + quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标', + first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据', + earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移', + evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据', + evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空', + last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code), + KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据'; diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml new file mode 100644 index 000000000..518f93306 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-egress-v1.yaml @@ -0,0 +1,115 @@ +openapi: 3.0.3 +info: + title: Self-operated Analytics Internal Egress Authority + version: 1.0.0 +paths: + /internal/lastleg/self-operated-analytics/egress/authorize: + post: + operationId: authorizeSelfoperatedEgressAttempt + summary: 一次性消费已持久预留的领星请求许可 + description: >- + 仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配; + requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定, + 后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1, + expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝, + 非 production 环境拒绝 BUSINESS。独占交接证明及接续预算由服务端配置核验。 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AuthorizationRequest' } + responses: + '200': + description: HTTP 200;许可 code=200,正常拒绝 code=B0001,data 为显式预算决策 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: + oneOf: + - { $ref: '#/components/schemas/AttemptPermit' } + - { $ref: '#/components/schemas/AuthorizationDenial' } + '401': { description: 内部令牌无效 } + '503': { description: 内部令牌未配置 } + /internal/lastleg/self-operated-analytics/egress/complete: + post: + operationId: completeSelfoperatedEgressAttempt + summary: 记录真实尝试次数和账号冷却 + description: >- + 回调 context、operation、requestHash 必须匹配已消费许可。actualHttpAttempts 仅 0 或 1; + 即使 0 也不退回预留,响应丢失同样保留保守消耗。重复已确认完成为幂等成功。 + 429 对账号全部采集族持久冷却;验证根 DEAD、run FAILED、禁止自动续跑。 + 未确认结果的许可到期后保守冷却,不使用换日、换仓、重试或重启清零预算。 + security: [{ internalServiceToken: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CompletionRequest' } + responses: + '200': + description: ApiResponse code 字符串 200,data 为布尔 true + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { type: boolean, enum: [true] } + '401': { description: 内部令牌无效 } +components: + securitySchemes: + internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token } + schemas: + EgressContext: + type: object + required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId] + properties: + sourceAccount: { type: string, maxLength: 64 } + executionPurpose: { type: string, enum: [VALIDATION, BUSINESS] } + rootTaskId: { type: string, pattern: '^[0-9]+$' } + sessionId: { type: string, maxLength: 128 } + attemptId: { type: string, maxLength: 128 } + exclusiveGrantId: { type: string, maxLength: 128 } + Operation: + type: string + enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY] + AuthorizationRequest: + type: object + required: [context, operation, requestHash] + properties: + context: { $ref: '#/components/schemas/EgressContext' } + operation: { $ref: '#/components/schemas/Operation' } + requestHash: { type: string, pattern: '^[a-f0-9]{64}$' } + AuthorizationDenial: + type: object + required: [errorCode, sampleDone, waitUntil] + properties: + errorCode: { type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$' } + sampleDone: { type: boolean } + waitUntil: { type: string, format: date-time, nullable: true, description: UTC Instant 或显式 null } + AttemptPermit: + allOf: + - { $ref: '#/components/schemas/AuthorizationRequest' } + - type: object + required: [allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed] + properties: + allowedAttempts: { type: integer, enum: [1] } + expiresAt: { type: string, format: date-time } + exclusiveConfirmed: { type: boolean, enum: [true] } + budgetConsumed: { type: boolean, enum: [true] } + CompletionRequest: + allOf: + - { $ref: '#/components/schemas/AuthorizationRequest' } + - type: object + required: [actualHttpAttempts, accountPaused] + properties: + actualHttpAttempts: { type: integer, minimum: 0, maximum: 1 } + errorCode: { type: string, nullable: true } + upstreamStatus: { type: integer, nullable: true } + sourceCode: { type: integer, nullable: true } + accountPaused: { type: boolean } + cooldownUntil: { type: string, format: date-time, nullable: true } diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml new file mode 100644 index 000000000..b9a0b893c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-sync-v1.yaml @@ -0,0 +1,62 @@ +openapi: 3.0.3 +info: + title: Platform Self-operated Analytics Sync Commands + version: 1.0.0 +paths: + /api/platform/v1/last-leg/self-operated-analytics/sync-tasks: + post: + operationId: requestSelfoperatedAnalyticsSyncTask + summary: 创建人工采集、复查、重建或恢复任务 + description: >- + 仅平台账号且须 platform:self-operated-analytics:sync 权限。命令先持久化, + 不在 HTTP 请求内运行历史采集。collectionMode 默认 OFF;VALIDATION 仅用于持久预算内的样本暂存, + BUSINESS 仅允许生产环境,并要求真实出口取得服务端核验的人工独占许可。 + 出库来源时间及业务时间统一 Asia/Shanghai;首次正式目标固定为 E0 前两个滚动月。 + BACKFILL 单片不超过一天。RETRY 使用 taskNo 和非空 reason 恢复原分仓逻辑任务, + 保留半归并屏障及旧尝试,拒绝验证会话复活、账号共享任务伪装单仓、有效执行租约及上游未齐; + requestKey 同键异载荷拒绝。成功受理只表示排队,不表示采集或发布成功。 + security: + - bearerAuth: [] + x-required-authorities: + - platform:self-operated-analytics:sync + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SyncCommand' + responses: + '200': + description: 平台 ApiResponse 包络,成功 code 为字符串 200;返回稳定业务任务号与 ACCEPTED + content: + application/json: + schema: + type: object + properties: + code: { type: string } + message: { type: string } + data: + type: object + properties: + taskNo: { type: string } + warehouseCode: { type: string } + status: { type: string, enum: [ACCEPTED] } + '401': { description: 未认证 } + '403': { description: 非平台身份或无独立写权限 } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + schemas: + SyncCommand: + type: object + required: [requestKey, warehouseCode, action] + properties: + requestKey: { type: string, minLength: 1, maxLength: 100 } + warehouseCode: { type: string, minLength: 1, maxLength: 64 } + action: { type: string, enum: [INVENTORY, BACKFILL, RECHECK, REBUILD, RETRY, VALIDATION] } + taskNo: { type: string, minLength: 1, maxLength: 64, description: RETRY 必填,原始分仓业务任务号 } + reason: { type: string, minLength: 1, maxLength: 500, description: RETRY 必填,随操作人和原任务审计保存 } + sourceFrom: { type: string, example: '2026-10-11T00:00:00' } + sourceUntil: { type: string, example: '2026-10-12T00:00:00' } + customerCode: { type: string, maxLength: 64, description: RECHECK 必填 } + deliveryNo: { type: string, maxLength: 128, description: RECHECK 必填 } diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml new file mode 100644 index 000000000..805e7a4e3 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -0,0 +1,700 @@ +openapi: 3.0.3 +info: + title: 平台自营出库销量库存及采集运行只读接口 + version: 1.0.0 + description: >- + 全部GET要求PLATFORM身份、tenantId=0、非代登录及独立platform:self-operated-analytics:list权限。 + 来源及业务时区Asia/Shanghai,单位PCS,账号和仓库由服务端已配置范围校验。 + 经营查询首屏生成context,包含版本对、独立范围摘要、queryAsOf/queryExpiresAt。 + 后续列表、图表、榜单和库存复用完整上下文,版本0保持不可用,不偷偷补入新版本。 + 上下文最多30分钟且不超过旧销量版superseded_at_utc+7天;queryAsOf不得未来或早于所选版本发布时间。 + 过期/范围不符明确要求刷新整页,不能静默切版;各请求在同一REPEATABLE_READ快照内校验和读取。 + 五周期包含今日,CUSTOM两端包含且endDate不晚于anchorDate;只有CUSTOM接受startDate/endDate。 + 默认maxQueryDays366,粒度DAY。正式outboundQuantity与observedQuantity独立,全部未知仍为空。 + 每日COMPLETE仅证明全天覆盖及有效日行或可靠SKU存在;今日/部分日正式量空,观察量可用,未知日期保持空。 + SKU区间正式量仅全部日完整才输出;observedQuantity为可靠点合计,完整/部分/未知日数独立。 + 切换图表不改变完整7/30日榜单及30日日均分母;不触发领星请求。 + 运行接口不受经营context限制。动态库存时效、重建和修订待核对独立返回。 + 阶段进度区分ACCOUNT_SHARED/WAREHOUSE及TASK_CUMULATIVE/ATTEMPT_ONLY,不伪造总百分比。 + total未知为空、计数不能跨PAGE/ORDER/INVENTORY_ROW/BUSINESS_KEY/SKU/BATCH单位求和,NONE为非计数阶段且completed/total为空、totalKnown=false;阶段检查点与心跳分开。 + 任务号稳定,内部诊断ID不作为业务编号回退。SKU订单数不可相加为仓总单数。 + 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary: + get: + summary: SKU五周期或历史累计清单 + description: '候选为两版profile并集,SQL按选定日期聚合后按observedQuantity降序空值末尾、SKU及货主升序,再分页。未知日不填零。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} + - {$ref: '#/components/parameters/bucket'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/SalesPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sku-daily-sales: + get: + summary: 精确单SKU日趋势 + description: 'customerCode和sku必填,精确原始业务键匹配。最多366日且返回完整升序日期轴,不分页。无候选明确业务错误。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCodeRequired'} + - {$ref: '#/components/parameters/skuRequired'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/rangePreset'} + - {$ref: '#/components/parameters/startDate'} + - {$ref: '#/components/parameters/endDate'} + - {$ref: '#/components/parameters/bucket'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Trend'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/hot-skus: + get: + summary: 完整7/30日热销Top100 + description: '独立截至queryAsOf昨日的完整窗口,数量大于零,数量降序、SKU单数降序、SKU及货主升序,先Top100再分页;当前缺货仍可入榜。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/windowDays'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/MetricPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/slow-skus: + get: + summary: 固定完整30日有库存低出库Top100 + description: '固定30天,不接受windowDays或rangePreset。正品有效新鲜可售量大于零、完整窗口且起点前可靠存在。数量升序、可售量降序、SKU及货主升序。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/MetricPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/inventory-coverage: + get: + summary: 正品库存与固定30日可售天数 + description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/attention'} + - {$ref: '#/components/parameters/sortBy'} + - {$ref: '#/components/parameters/coverageDaysThreshold'} + + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/customerCode'} + - {$ref: '#/components/parameters/sku'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/MetricPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-overview: + get: + summary: 各仓任务族运行概览 + description: 'warehouseCode可选。轻量读取scope/task/run/账号保护。无发布版和OFF仍返回配置任务。首采COMPLETE须正式发布覆盖且无rebuild或未闭合归并。共享上游明确账号共享范围。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCodeOptional'} + - {$ref: '#/components/parameters/dataFamily'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Overview'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-tasks: + get: + summary: 本仓逻辑采集任务 + description: '任务按创建时间及稳定键倒序。首屏返回listUpperCreatedAt/listUpperId,后页完整重传;新插入不挤移页。只读有限检查点,不展开stage或大manifest。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/dataFamily'} + - {$ref: '#/components/parameters/taskType'} + - {$ref: '#/components/parameters/status'} + - {$ref: '#/components/parameters/taskNo'} + - {$ref: '#/components/parameters/dateFrom'} + - {$ref: '#/components/parameters/dateUntil'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + - {$ref: '#/components/parameters/listUpperCreatedAt'} + - {$ref: '#/components/parameters/listUpperId'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/TaskPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-tasks/{taskNo}: + get: + summary: 本仓业务任务号详情 + description: '路径taskNo为稳定业务任务号,实际归属与请求账号仓必须一致。不以内部ID回退。详情含最新尝试和关联共享上游。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {name: taskNo, in: path, required: true, schema: {type: string, maxLength: 64}} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/TaskDetail'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/sync-runs: + get: + summary: 本仓或任务号的尝试历史 + description: '纯运行元数据分页。当前尝试读同一attemptNo的task进度,终态尝试只读run冻结快照;心跳过期UNCONFIRMED,不自动改判成功失败。 权限platform:self-operated-analytics:list。' + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/dataFamily'} + - {$ref: '#/components/parameters/taskType'} + - {$ref: '#/components/parameters/status'} + - {$ref: '#/components/parameters/taskNo'} + - {$ref: '#/components/parameters/dateFrom'} + - {$ref: '#/components/parameters/dateUntil'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + - {$ref: '#/components/parameters/listUpperCreatedAt'} + - {$ref: '#/components/parameters/listUpperId'} + responses: + '200': + description: '统一ApiResponse,业务失败HTTP200且code非200。' + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/AttemptPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/owners: + get: + summary: 已采集货主编码分页 + description: 固定同屏版本,只读权限platform:self-operated-analytics:list。不调用外部API。概览不接受SKU/attention/sortBy/分页/图表范围,候选不存在data为空。 + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/keyword'} + - {$ref: '#/components/parameters/current'} + - {$ref: '#/components/parameters/size'} + responses: + '200': + description: 统一ApiResponse + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/OwnerPage'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} + /api/platform/v1/last-leg/self-operated-analytics/owner-overview: + get: + summary: 单货主完整候选30日经营概览 + description: 固定同屏版本,只读权限platform:self-operated-analytics:list。不调用外部API。概览不接受SKU/attention/sortBy/分页/图表范围,候选不存在data为空。 + parameters: + - {$ref: '#/components/parameters/sourceAccount'} + - {$ref: '#/components/parameters/warehouseCode'} + - {$ref: '#/components/parameters/salesRunId'} + - {$ref: '#/components/parameters/inventoryRunId'} + - {$ref: '#/components/parameters/salesScopeHash'} + - {$ref: '#/components/parameters/inventoryScopeHash'} + - {$ref: '#/components/parameters/queryAsOf'} + - {$ref: '#/components/parameters/customerCodeRequired'} + - {$ref: '#/components/parameters/coverageDaysThreshold'} + responses: + '200': + description: 统一ApiResponse + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/OwnerOverview'} + '401': {description: 缺少可信认证身份} + '403': {description: 非平台身份或缺少独立只读权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + keyword: {name: keyword, in: query, schema: {type: string, minLength: 1, maxLength: 64}, description: 区分大小写的货主编码字面前缀,先过滤去重再分页} + attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}} + sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_30D_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序} + coverageDaysThreshold: {name: coverageDaysThreshold, in: query, schema: {type: integer, minimum: 1}, description: COVERAGE_GT_THRESHOLD必填,严格大于且未舍入比较,不设置默认经营政策} + sourceAccount: {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + warehouseCode: {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + customerCode: {name: customerCode, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 64}} + sku: {name: sku, in: query, required: false, schema: {type: string, minLength: 1, maxLength: 128}} + rangePreset: {name: rangePreset, in: query, required: false, schema: {type: string, enum: [TODAY, LAST_3_DAYS, THIS_WEEK, LAST_15_DAYS, THIS_MONTH, CUSTOM], default: TODAY}} + startDate: {name: startDate, in: query, required: false, schema: {type: string, format: date}} + endDate: {name: endDate, in: query, required: false, schema: {type: string, format: date}} + bucket: {name: bucket, in: query, required: false, schema: {type: string, enum: [DAY], default: DAY}} + windowDays: {name: windowDays, in: query, required: false, schema: {type: integer, enum: [7, 30], default: 30}} + current: {name: current, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} + size: {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + salesRunId: {name: salesRunId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}} + inventoryRunId: {name: inventoryRunId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}} + salesScopeHash: {name: salesScopeHash, in: query, required: false, schema: {type: string, pattern: '^[0-9a-f]{64}$'}} + inventoryScopeHash: {name: inventoryScopeHash, in: query, required: false, schema: {type: string, pattern: '^[0-9a-f]{64}$'}} + queryAsOf: {name: queryAsOf, in: query, required: false, schema: {type: string, format: date-time}} + dataFamily: {name: dataFamily, in: query, required: false, schema: {type: string, enum: [OUTBOUND, INVENTORY]}} + taskType: {name: taskType, in: query, required: false, schema: {type: string}} + status: {name: status, in: query, required: false, schema: {type: string}} + taskNo: {name: taskNo, in: query, required: false, schema: {type: string, maxLength: 64}} + dateFrom: {name: dateFrom, in: query, required: false, schema: {type: string, format: date}} + dateUntil: {name: dateUntil, in: query, required: false, schema: {type: string, format: date}} + listUpperCreatedAt: {name: listUpperCreatedAt, in: query, required: false, schema: {type: string, format: date-time}} + listUpperId: {name: listUpperId, in: query, required: false, schema: {type: string, pattern: '^[0-9]+$'}} + warehouseCodeOptional: {name: warehouseCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} + customerCodeRequired: {name: customerCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + skuRequired: {name: sku, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + schemas: + OwnerOption: + type: object + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + OwnerPage: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + records: {type: array, items: {$ref: '#/components/schemas/OwnerOption'}} + current: {type: integer} + size: {type: integer} + total: {type: string} + pages: {type: string} + excludedReasons: {type: object, additionalProperties: {type: string}} + QuantityTotal: + type: object + properties: + overallTotal: {type: number, nullable: true} + knownSubtotal: {type: number, nullable: true} + unknownSkuCount: {type: string} + dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]} + reasonCodes: {type: array, items: {type: string}} + RiskShare: + type: object + description: 分母为同一OwnerOverview的physicalQuantity。未知时正式命中与比例为空。未设置阈值返回THRESHOLD_NOT_SET。 + properties: + matchedSkuCount: {type: string, nullable: true} + matchedPhysicalQuantity: {type: number, nullable: true} + physicalQuantityRatio: {type: number, minimum: 0, maximum: 1, nullable: true} + knownMatchedSkuCount: {type: string, nullable: true} + knownMatchedPhysicalQuantity: {type: number, nullable: true} + unassessableSkuCount: {type: string} + unassessableKnownPhysicalQuantity: {type: number, nullable: true} + reasonCodes: {type: array, items: {type: string}} + OwnerOverview: + type: object + nullable: true + properties: + context: {$ref: '#/components/schemas/Context'} + customerCode: {type: string} + customerName: {type: string, nullable: true} + skuCount: {type: string, nullable: true} + physicalSkuCount: {$ref: '#/components/schemas/QuantityTotal'} + physicalQuantity: {$ref: '#/components/schemas/QuantityTotal'} + availableQuantity: {$ref: '#/components/schemas/QuantityTotal'} + frozenQuantity: {$ref: '#/components/schemas/QuantityTotal'} + defectivePhysicalQuantity: {$ref: '#/components/schemas/QuantityTotal'} + transitQuantity: {$ref: '#/components/schemas/QuantityTotal'} + outboundQuantity30d: {$ref: '#/components/schemas/QuantityTotal'} + averageDailyOutboundQuantity: {$ref: '#/components/schemas/QuantityTotal'} + noOutbound30d: {$ref: '#/components/schemas/RiskShare'} + coverageAboveThreshold: {$ref: '#/components/schemas/RiskShare'} + Context: + type: object + properties: + salesRunId: {type: string, nullable: true} + inventoryRunId: {type: string, nullable: true} + salesScopeHash: {type: string, nullable: true} + inventoryScopeHash: {type: string, nullable: true} + queryAsOf: {type: string, format: date-time, nullable: true} + queryExpiresAt: {type: string, format: date-time, nullable: true} + businessTimezone: {type: string, nullable: true} + anchorDate: {type: string, format: date, nullable: true} + rangeStartDate: {type: string, format: date, nullable: true} + rangeEndDateExclusive: {type: string, format: date, nullable: true} + calculationWindowDays: {type: integer, nullable: true} + completeDayFrom: {type: string, format: date, nullable: true} + completeDayUntil: {type: string, format: date, nullable: true} + salesCoveredFrom: {type: string, format: date-time, nullable: true} + salesCoveredUntil: {type: string, format: date-time, nullable: true} + salesPublishedAt: {type: string, format: date-time, nullable: true} + inventoryObservedFrom: {type: string, format: date-time, nullable: true} + inventoryObservedUntil: {type: string, format: date-time, nullable: true} + salesStatus: {type: string, nullable: true} + inventoryStatus: {type: string, nullable: true} + rebuildRequired: {type: boolean, nullable: true} + revisionReviewStatus: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + SalesSummary: + type: object + properties: + sourceAccount: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + customerCode: {type: string, nullable: true} + sku: {type: string, nullable: true} + productName: {type: string, nullable: true} + quantityUnit: {type: string, nullable: true} + outboundQuantity: {type: number, nullable: true} + observedQuantity: {type: number, nullable: true} + dataStatus: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + completeDayCount: {type: integer, nullable: true} + partialDayCount: {type: integer, nullable: true} + unknownDayCount: {type: integer, nullable: true} + Point: + type: object + properties: + businessDate: {type: string, format: date, nullable: true} + outboundQuantity: {type: number, nullable: true} + observedQuantity: {type: number, nullable: true} + dataStatus: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + quantityUnit: {type: string, nullable: true} + coveredFrom: {type: string, format: date-time, nullable: true} + coveredUntil: {type: string, format: date-time, nullable: true} + Trend: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + summary: {$ref: '#/components/schemas/SalesSummary'} + points: {type: array, items: {$ref: '#/components/schemas/Point'}} + Metric: + type: object + properties: + customerName: {type: string, nullable: true} + defectivePhysicalQuantity: {type: number, nullable: true} + outboundQuantity30d: {type: number, nullable: true} + lastObservedOutboundDate: {type: string, format: date, nullable: true} + observedHistoryStartDate: {type: string, format: date, nullable: true} + attentionReasons: {type: array, items: {type: string}} + quantityStatuses: {type: object, additionalProperties: {type: string, enum: [VALID, UNKNOWN, STALE]}} + sourceAccount: {type: string, nullable: true} + customerCode: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + sku: {type: string, nullable: true} + productName: {type: string, nullable: true} + quantityUnit: {type: string, nullable: true} + outboundQuantity: {type: number, nullable: true} + skuOutboundOrderCount: {type: string, nullable: true} + todayObservedQuantity: {type: number, nullable: true} + physicalQuantity: {type: number, nullable: true} + availableQuantity: {type: number, nullable: true} + frozenQuantity: {type: number, nullable: true} + transitQuantity: {type: number, nullable: true} + inventoryObservedAt: {type: string, format: date-time, nullable: true} + averageDailyOutboundQuantity: {type: number, nullable: true} + inventoryCoverageDays: {type: number, nullable: true} + inventoryQuality: {type: string, nullable: true} + salesCoverage: {type: string, nullable: true} + observationEligible: {type: boolean, nullable: true} + earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true} + status: {type: string, nullable: true} + reasonCodes: {type: array, items: {type: string}} + Protection: + type: object + properties: + accountCooldownUntil: {type: string, format: date-time, nullable: true} + accountPauseReason: {type: string, nullable: true} + dailyRequestBudget: {type: integer, nullable: true} + reservedHttpRequests: {type: string, nullable: true} + confirmedHttpRequests: {type: string, nullable: true} + budgetDate: {type: string, format: date, nullable: true} + Validation: + type: object + properties: + validationSessionNo: {type: string, nullable: true} + budgetLimits: {type: object, additionalProperties: {type: string, nullable: true}} + budgetConsumed: {type: object, additionalProperties: {type: string, nullable: true}} + deadlineAt: {type: string, format: date-time, nullable: true} + stopReason: {type: string, nullable: true} + Attempt: + type: object + properties: + runId: {type: string, nullable: true} + taskNo: {type: string, nullable: true} + attemptNo: {type: integer, nullable: true} + status: {type: string, nullable: true} + phase: {type: string, nullable: true} + executionPurpose: {type: string, nullable: true} + startedAt: {type: string, format: date-time, nullable: true} + finishedAt: {type: string, format: date-time, nullable: true} + phaseStartedAt: {type: string, format: date-time, nullable: true} + progressUpdatedAt: {type: string, format: date-time, nullable: true} + heartbeatAt: {type: string, format: date-time, nullable: true} + executionHealth: {type: string, nullable: true} + elapsedSeconds: {type: string, nullable: true} + errorCode: {type: string, nullable: true} + failureSummary: {type: string, nullable: true} + retryable: {type: boolean, nullable: true} + coverageStatus: {type: string, nullable: true} + coveredFrom: {type: string, format: date-time, nullable: true} + coveredUntil: {type: string, format: date-time, nullable: true} + publishedAt: {type: string, format: date-time, nullable: true} + progress: {$ref: '#/components/schemas/Progress'} + Task: + type: object + properties: + taskNo: {type: string, nullable: true} + taskType: {type: string, nullable: true} + dataFamily: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + taskRole: {type: string, nullable: true} + executionPurpose: {type: string, nullable: true} + queryFrom: {type: string, format: date-time, nullable: true} + queryUntil: {type: string, format: date-time, nullable: true} + taskStatus: {type: string, nullable: true} + applyState: {type: string, nullable: true} + attemptCount: {type: integer, nullable: true} + latestAttemptNo: {type: integer, nullable: true} + resultPublicationState: {type: string, nullable: true} + resultPublishedRunId: {type: string, nullable: true} + nextRetryAt: {type: string, format: date-time, nullable: true} + allowedActions: {type: array, items: {type: string}} + actionBlockReason: {type: string, nullable: true} + progressUpdatedAt: {type: string, format: date-time, nullable: true} + progress: {$ref: '#/components/schemas/Progress'} + validation: {$ref: '#/components/schemas/Validation'} + latestAttempt: {$ref: '#/components/schemas/Attempt'} + TaskDetail: + type: object + properties: + task: {$ref: '#/components/schemas/Task'} + sharedUpstream: {$ref: '#/components/schemas/Task'} + protection: {$ref: '#/components/schemas/Protection'} + observedAt: {type: string, format: date-time, nullable: true} + InventoryDailySamples: + type: object + nullable: true + description: 仅库存任务族提供。北京时间近30个完整日的正式日代表积累;缺日不补零或沿用旧批次,不是周转率。 + required: [windowFrom, windowUntil, expectedDays, sampleCount, missingDates] + properties: + windowFrom: {type: string, format: date, description: 包含起日} + windowUntil: {type: string, format: date, description: 排他上界,即北京时间今日} + expectedDays: {type: integer, enum: [30]} + sampleCount: {type: integer, minimum: 0, maximum: 30} + missingDates: {type: array, items: {type: string, format: date}} + accumulationStartedOn: {type: string, format: date, nullable: true, description: 本仓最早正式日代表日期,无样本时为空} + Warehouse: + type: object + properties: + warehouseCode: {type: string, nullable: true} + warehouseName: {type: string, nullable: true} + dataFamily: {type: string, nullable: true} + executionEnabled: {type: boolean, nullable: true} + publicationEnabled: {type: boolean, nullable: true} + disabledReason: {type: string, nullable: true} + nextScheduledAt: {type: string, format: date-time, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} + initialFrom: {type: string, format: date-time, nullable: true} + initialUntil: {type: string, format: date-time, nullable: true} + initialCoverageStatus: {type: string, nullable: true} + salesCoveredFrom: {type: string, format: date-time, nullable: true} + salesCoveredUntil: {type: string, format: date-time, nullable: true} + lastSuccessfulTaskNo: {type: string, nullable: true} + lastSucceededAt: {type: string, format: date-time, nullable: true} + lastPublishedAt: {type: string, format: date-time, nullable: true} + inventoryObservedFrom: {type: string, format: date-time, nullable: true} + inventoryObservedUntil: {type: string, format: date-time, nullable: true} + dataFreshness: {type: string, nullable: true} + rebuildRequired: {type: boolean, nullable: true} + revisionReviewStatus: {type: string, nullable: true} + inventoryDailySamples: {$ref: '#/components/schemas/InventoryDailySamples'} + currentTask: {$ref: '#/components/schemas/Task'} + sharedUpstream: {$ref: '#/components/schemas/Task'} + protection: {$ref: '#/components/schemas/Protection'} + Overview: + type: object + properties: + sourceAccount: {type: string, nullable: true} + environmentLabel: {type: string, nullable: true} + collectionMode: {type: string, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} + warehouses: {type: array, items: {$ref: '#/components/schemas/Warehouse'}} + Progress: + type: object + properties: + progressScope: {type: string, nullable: true} + countBasis: {type: string, nullable: true} + attemptNo: {type: string, nullable: true} + scanRound: {type: string, nullable: true} + phase: {type: string, nullable: true} + completed: {type: string, nullable: true} + total: {type: string, nullable: true} + totalKnown: {type: boolean, nullable: true} + unit: {type: string, nullable: true} + sliceFrom: {type: string, format: date-time, nullable: true} + sliceUntil: {type: string, format: date-time, nullable: true} + stageStartedAt: {type: string, format: date-time, nullable: true} + stageFinishedAt: {type: string, format: date-time, nullable: true} + validationState: {type: string, nullable: true} + invalidCount: {type: string, nullable: true} + unknownCount: {type: string, nullable: true} + pendingReviewCount: {type: string, nullable: true} + countUnit: {type: string, nullable: true} + summaryAsOf: {type: string, format: date-time, nullable: true} + directoryComplete: {type: boolean, nullable: true} + stages: {type: array, items: {$ref: '#/components/schemas/Progress'}} + SalesPage: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + records: {type: array, items: {$ref: '#/components/schemas/SalesSummary'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + pages: {type: string, nullable: true} + excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}} + MetricPage: + type: object + properties: + context: {$ref: '#/components/schemas/Context'} + records: {type: array, items: {$ref: '#/components/schemas/Metric'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + pages: {type: string, nullable: true} + excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}} + TaskPage: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Task'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + listUpperCreatedAt: {type: string, format: date-time, nullable: true} + listUpperId: {type: string, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} + AttemptPage: + type: object + properties: + records: {type: array, items: {$ref: '#/components/schemas/Attempt'}} + current: {type: integer, nullable: true} + size: {type: integer, nullable: true} + total: {type: string, nullable: true} + listUpperCreatedAt: {type: string, format: date-time, nullable: true} + listUpperId: {type: string, nullable: true} + observedAt: {type: string, format: date-time, nullable: true} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index 3f3910460..13f3ef39c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.appointmentplanning; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.inboundplan.controller.AppointmentSourceCreateController; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.*; import org.springframework.security.access.AccessDeniedException; @@ -19,7 +20,7 @@ class AppointmentPlanningAuthorizationTest { @Test void savingSourceAppointmentsNeedsTheExistingFbaWritePermissionAndNeverAcceptsClientIdentity() { try(var context=new AnnotationConfigApplicationContext(Config.class)) { var controller=context.getBean(AppointmentSourceCreateController.class); - var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")), + var request=new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest(List.of(new com.baiyue.lastleg.inboundplan.dto.SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8")), "100","ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),"key"); login(LIST,CARGO,WRITE); assertThrows(AccessDeniedException.class,()->controller.create(user(0L,"PLATFORM"),request)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java new file mode 100644 index 000000000..18617b272 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.delivery.fleet; + +import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy.*; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetPoolDeliveryAdapterTest { + private final DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class); + private final FleetPoolDeliveryAdapter adapter=new FleetPoolDeliveryAdapter(evidence); + private final LocalDateTime signed=LocalDateTime.of(2026,10,12,12,0); + private void shipment(int total,long signatures,List picks,List returns){ + when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L)); + when(evidence.deliveries("9",70)).thenReturn(List.of(new Delivery(71,70,"CODE:ONT8",total,signatures,signed,signed.minusHours(1)))); + when(evidence.picks("9",70)).thenReturn(picks);when(evidence.returns("9",70)).thenReturn(returns); + } + private Pick pick(long id,long source,int quantity){return new Pick(id,70,"CODE:ONT8",source,quantity,id,null,null,null);} + @Test void completeActualSourceIsSignedButPartialSignatureIsNot(){ + shipment(4,2,List.of(pick(1,77,4)),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + shipment(4,4,List.of(pick(1,77,4)),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isTrue(); + } + @Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedSource(){ + shipment(8,6,List.of(pick(1,77,4),pick(2,78,4)),List.of(new Returned(9,71,78,2,signed.minusMinutes(5)))); + assertThat(adapter.fullySigned("9","WT-7",77,4)).isTrue(); + } + @Test void thisSourceReturnCannotBeCountedAsWholeSignature(){ + shipment(4,2,List.of(pick(1,77,4)),List.of(new Returned(9,71,77,2,signed.minusMinutes(5)))); + assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + } + @Test void missingOrDuplicatedDispatchEvidenceIsUnproved(){ + for(var ids:List.of(List.of(),List.of(70L,70L),List.of(0L))){ + when(evidence.dispatchIds("9",77)).thenReturn(ids);assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + } + verify(evidence,never()).deliveries(anyString(),anyLong()); + } + @Test void missingPickOrReturnHistoryIsUnproved(){ + shipment(4,4,List.of(),List.of());assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + shipment(4,4,List.of(pick(1,77,4)),null);assertThat(adapter.fullySigned("9","WT-7",77,4)).isFalse(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index 2729365a6..2bb70c73c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -1,15 +1,11 @@ package com.baiyue.lastleg.fleet.business; -import com.baiyue.lastleg.delivery.entity.DeliveryOrder; -import com.baiyue.lastleg.delivery.mapper.DeliverySourceEvidenceMapper; -import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; -import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.mapper.*; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; import com.baiyue.lastleg.trucking.mapper.*; -import com.baiyue.lastleg.service.LastlegOrderNoGenerator; + import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.springframework.transaction.annotation.*; @@ -26,15 +22,14 @@ class FleetPoolOrderBridgeTest { LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class); UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class); UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class); - LastlegOrderNoGenerator numbers=mock(LastlegOrderNoGenerator.class); - DeliverySourceEvidenceMapper evidence=mock(DeliverySourceEvidenceMapper.class); + FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class); + FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class); com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class); FleetPoolOrderBridge service; FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder; @BeforeEach void setup(){ service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); - when(evidence.dispatchIds("9",77)).thenReturn(List.of(70L)); - when(evidence.returns("9",70)).thenReturn(List.of()); + when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); when(links.lockIncoming("9","WT-7")).thenAnswer(a->row); @@ -66,16 +61,13 @@ class FleetPoolOrderBridgeTest { doThrow(new IllegalStateException("appointment binding conflict")).when(appointmentSources).materialized("9","FB-7",77L); assertThatThrownBy(this::incoming).isInstanceOf(IllegalStateException.class).hasMessageContaining("appointment binding conflict"); } - FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-7","WT-7","92","FBA:ONT8","READY_FOR_DISPATCH","hash",List.of( - new FleetBookingIncomingGateway.Pallet("101","PALLET-1",2,BigDecimal.TEN,BigDecimal.ONE), - new FleetBookingIncomingGateway.Pallet("102","PALLET-2",2,BigDecimal.TEN,BigDecimal.ONE)),4);} + Receipt receipt(){return new Receipt("WT-7","92","FBA:ONT8","READY_FOR_DISPATCH",List.of( + new ReceivedPallet("101",2),new ReceivedPallet("102",2)),4);} void dispatch(String type){service.confirm(input(type),"op");incoming();service.receiving("9","WT-7","pda"); service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op");} - DeliveryOrder delivery(int signed){var d=new DeliveryOrder();d.setId(71L);d.setDispatchOrderId(70L);d.setDeliveryGroupKey("CODE:ONT8");d.setCustomerId("9");d.setDispatchNo("DISPATCH-7");d.setDeliveryNo("DELIVERY-7"); - d.setStatus("DELIVERED");d.setPackageCount(4);d.setSignedPackages(signed);d.setSignedAt(LocalDateTime.now()); - when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",4,(long)signed,d.getSignedAt(),d.getSignedAt().minusHours(1)))); - when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null))); - return d;} + SignedDelivery delivery(int signed){ + when(evidence.fullySigned("9","WT-7",77,4)).thenReturn(signed>=4); + return new SignedDelivery("9","DISPATCH-7","DELIVERY-7",true,LocalDateTime.now());} @Test void looseboardConfirmationCreatesOnlyCommercialRecordAndReplaysWithoutAnotherOrder(){ var result=service.confirm(input("LOCAL_LOOSEBOARD"),"op"); assertThat(result.businessOrderNo()).isEqualTo("LOCAL-001");assertThat(looseOrder.getLastlegOrderId()).isNull(); @@ -130,9 +122,9 @@ class FleetPoolOrderBridgeTest { @Test void incomingBlocksCancellationAndWrongReceiptIdentityOrDuplicatePhysicalPallets(){ service.confirm(input("US_TRUCK"),"op");incoming(); assertThatThrownBy(()->service.cancelBeforeIncoming(7,"9","op","取消")).hasMessageContaining("已登记来货"); - var r=receipt();var wrong=new FleetBookingIncomingGateway.Evidence("OTHER",r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),r.pallets(),4); + var r=receipt();var wrong=new Receipt("OTHER",r.warehouseId(),r.destinationKey(),r.status(),r.pallets(),4); assertThatThrownBy(()->service.acceptedReceipt(7,wrong,3,"op")).hasMessageContaining("来源"); - var duplicate=new FleetBookingIncomingGateway.Evidence(r.incomingNo(),r.inboundCode(),r.warehouseId(),r.destinationKey(),r.status(),r.receiptHash(),List.of(r.pallets().get(0),r.pallets().get(0)),4); + var duplicate=new Receipt(r.incomingNo(),r.warehouseId(),r.destinationKey(),r.status(),List.of(r.pallets().get(0),r.pallets().get(0)),4); assertThatThrownBy(()->service.acceptedReceipt(7,duplicate,3,"op")).hasMessageContaining("重复"); } @Test void actualDepartureAndFullSignatureDriveLooseboardExactlyOnce(){ @@ -157,22 +149,15 @@ class FleetPoolOrderBridgeTest { assertThatThrownBy(()->service.confirm(missing,"op")).hasMessageContaining("独立实托数"); verifyNoInteractions(trucks,loose,links); } - @Test void anotherCoShippedSourceReturnDoesNotBlockThisFullySignedCommercialOrder(){ + @Test void deliveryOwnedProofCompletesOnlyThisCommercialSource(){ dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now()); - var d=delivery(6);d.setPackageCount(8); - when(evidence.deliveries("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Delivery(71,70,"CODE:ONT8",8,6L,d.getSignedAt(),d.getSignedAt().minusHours(1)))); - when(evidence.picks("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Pick(1,70,"CODE:ONT8",77,4,1L,null,null,null), - new SourceDeliveryCompletionPolicy.Pick(2,70,"CODE:ONT8",78,4,2L,null,null,null))); - when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,78,2,d.getSignedAt().minusMinutes(5)))); - service.signed(d,Map.of(77L,4L,78L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED"); + service.signed(delivery(4),Map.of(77L,4L,78L,4L),"op",LocalDateTime.now()); + assertThat(truckOrder.getStatus()).isEqualTo("COMPLETED");verify(evidence).fullySigned("9","WT-7",77,4); } - @Test void thisSourceReturnOrMissingHistoryCannotBeCountedAsWholeSignature(){ + @Test void missingDeliveryOwnedProofCannotCompleteCommercialOrder(){ dispatch("US_TRUCK");service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now()); - var d=delivery(2); - when(evidence.returns("9",70)).thenReturn(List.of(new SourceDeliveryCompletionPolicy.Returned(9,71,77,2,d.getSignedAt().minusMinutes(5)))); - service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT"); - d=delivery(4);when(evidence.dispatchIds("9",77)).thenReturn(List.of()); - service.signed(d,Map.of(77L,4L),"op",LocalDateTime.now());assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT"); + service.signed(delivery(2),Map.of(77L,4L),"op",LocalDateTime.now()); + assertThat(truckOrder.getStatus()).isEqualTo("IN_TRANSIT"); } @Test void bindingDoesNotMeanDepartureAndCannotCompleteBeforeActualDeparture(){ dispatch("US_TRUCK");assertThat(truckOrder.getStatus()).isEqualTo("PENDING_DISPATCH"); @@ -185,6 +170,9 @@ class FleetPoolOrderBridgeTest { assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY); assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName())) .noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory")); + assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getPackageName())) + .noneMatch(name->name.startsWith("com.baiyue.lastleg.delivery")||name.equals("com.baiyue.lastleg.fleet.trip") + ||name.equals("com.baiyue.lastleg.service")); } @Test void poolOnlyCallbacksCannotSucceedWithoutTheirCommercialLink(){ assertThatThrownBy(()->service.acceptedReceipt(7,receipt(),1,"op")).hasMessageContaining("缺少唯一商业订单关联"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java new file mode 100644 index 000000000..76c6a83aa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.fleet.business; + +import org.junit.jupiter.api.Test; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import static org.assertj.core.api.Assertions.*; + +class FleetPoolOrderNumberGeneratorTest { + private final FleetPoolOrderNumberGenerator generator=new FleetPoolOrderNumberGenerator(); + @Test void existingLooseboardAndTruckFormatLocationAndSuffixArePreserved(){ + String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + assertThat(generator.next("LOOSEBOARD","FBA:ONT8",1234567)).isEqualTo("LB-ONT-"+day+"-234567"); + assertThat(generator.next("TRUCK","nyc",1000001)).isEqualTo("TR-NYC-"+day+"-000001"); + } + @Test void existingPrivateDestinationAndShortCodeRulesArePreserved(){ + String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); + assertThat(generator.next("TRUCK","private address",9)).isEqualTo("TR-WH-"+day+"-000009"); + assertThat(generator.next("LOOSEBOARD","ab12",9)).isEqualTo("LB-AB12-"+day+"-000009"); + assertThat(generator.next("TRUCK",null,-1)).isEqualTo("TR-WH-"+day+"-999999"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index df073ba1f..e75881c74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -69,7 +69,7 @@ class FbaAppointmentPersistenceTest { new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓","FBA:ONT8","ONT8",secondPackages,"ORDER:1",true,List.of())); } private static SourceFbaAppointmentRequest request(String key) { - return new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8"),new AppointmentCoverageService.SourceRef("B","ORDER:2","FBA:ONT8")), + return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2","FBA:ONT8")), "100","ONT8",new FbaAppointmentDetails(null,null,null,null,null,null,"PENDING",null,null,null),key); } @Test void failureAfterHeaderAndFirstItemRollsBackTheCommandAndEntireAggregate() { @@ -169,7 +169,7 @@ class FbaAppointmentPersistenceTest { var service=context.getBean(InboundFbaAppointmentService.class);var linker=context.getBean(AppointmentSourceLinker.class); var before=new AppointmentSourceService.Source("BOOKING:B-1","C",null,null,"B-1",null,"100","美国仓","FBA:ONT8","ONT8",null,"BOOKING:1",true,List.of()); when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(before)); - var request=new SourceFbaAppointmentRequest(List.of(new AppointmentCoverageService.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming"); + var request=new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("C","BOOKING:B-1","FBA:ONT8")),"100","ONT8",request("unused").details(),"before-incoming"); var saved=service.createFromSources("operator",request); assertNull(saved.items().get(0).orderId()); assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->linker.materialized("C","B-1",100L)); @@ -221,7 +221,7 @@ class FbaAppointmentPersistenceTest { @Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);} @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} - @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new AppointmentSourceLinker(orders,items,bookings);} + @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} @Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();} } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 572a140cf..08f995e1a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -55,15 +55,26 @@ class InboundFbaAppointmentServiceTest { List refs(String... keys) {return Arrays.stream(keys).map(key->new AppointmentCoverageService.SourceRef(cargo.get(key).customerId(),key,cargo.get(key).destinationKey())).toList();} FbaAppointmentDetails pending(String remark) {return new FbaAppointmentDetails(LocalDate.of(2026,10,15),null,null,null,null,null,"PENDING",remark,null,null);} FbaAppointmentDetails confirmed() {return new FbaAppointmentDetails(LocalDate.of(2026,10,14),null,null,"EXT-ONT8-1",at,at.plusHours(1),"SUCCESS","已核实",null,null);} - SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(refs(sources),"100","ONT8",details,key);} + List requestRefs(String... keys) {return refs(keys).stream().map(r->new SourceFbaAppointmentRequest.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList();} + SourceFbaAppointmentRequest request(String key,FbaAppointmentDetails details,String... sources) {return new SourceFbaAppointmentRequest(requestRefs(sources),"100","ONT8",details,key);} FbaAppointmentResponse create(String key) {return service.createFromSources("operator",request(key,pending(null),"ORDER:1","ORDER:2"));} + @Test void sourceRequestKeepsWireShapeAndPassesAllSourceFieldsToResolution() throws Exception { + var json=new ObjectMapper().findAndRegisterModules(); + var input=request("source-json",pending(null),"ORDER:1","ORDER:2"); + assertEquals(json.valueToTree(refs("ORDER:1","ORDER:2")),json.valueToTree(input).get("sources")); + var decoded=json.readValue(json.writeValueAsString(input),SourceFbaAppointmentRequest.class); + assertEquals(input,decoded); + service.createFromSources("operator",decoded); + verify(sources,times(2)).resolveBatch(refs("ORDER:1","ORDER:2")); + } + @Test void refusesOriginOrDestinationChangedAfterSelection() { var intent=request("origin-changed",pending(null),"ORDER:1"); add("ORDER:1","A",1L,"200","ONT8",10); assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",intent)).getStatusCode().value()); add("ORDER:1","A",1L,"100","LAX9",10); - var changed=new SourceFbaAppointmentRequest(refs("ORDER:1"),"100","ONT8",pending(null),"destination-changed"); + var changed=new SourceFbaAppointmentRequest(requestRefs("ORDER:1"),"100","ONT8",pending(null),"destination-changed"); assertEquals(409,assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",changed)).getStatusCode().value()); verify(headers,never()).insert(any(InboundFbaAppointment.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java new file mode 100644 index 000000000..fa2a3cefb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java @@ -0,0 +1,111 @@ +package com.baiyue.lastleg.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +class SelfoperatedAnalyticsSchemaContractTest { + private static final Pattern TABLE = Pattern.compile( + "(?s)CREATE TABLE (ll_selfop_\\w+) \\(.*?\\) ENGINE=InnoDB[^;]+;"); + + private static Map tables(String sql) { + var result = new LinkedHashMap(); + var matcher = TABLE.matcher(sql); + while (matcher.find()) { + assertNull(result.put(matcher.group(1), matcher.group()), "duplicate table definition"); + } + return result; + } + + @Test + void sourceAndEmptyDatabaseBaselineContainIdenticalTenIndependentTables() throws Exception { + var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"))); + assertEquals(10, source.size()); + assertEquals(source, deployment); + for (var entry : source.entrySet()) { + var ddl = entry.getValue(); + assertTrue(ddl.contains("COLLATE=utf8mb4_bin"), entry.getKey()); + assertTrue(ddl.contains("id BIGINT NOT NULL")); + assertTrue(ddl.contains("created_at DATETIME(3) NOT NULL")); + assertTrue(ddl.contains("updated_at DATETIME(3) NOT NULL")); + assertFalse(ddl.contains("AUTO_INCREMENT")); + assertFalse(ddl.contains("tenant_id")); + assertFalse(ddl.contains("FOREIGN KEY")); + } + } + + @Test + void unknownSourceTimeAndStockCannotBecomeZeroOrEligibleSalesByDefault() throws Exception { + var ddl = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + for (var table : new String[]{"ll_selfop_sync_scope", "ll_selfop_outbound_order"}) { + assertTrue(ddl.get(table).contains("source_timezone VARCHAR(64) NULL COMMENT")); + } + var stock = ddl.get("ll_selfop_inventory_snapshot"); + for (var column : new String[]{"physical_quantity", "available_quantity", "frozen_quantity", "transit_quantity"}) { + assertTrue(stock.contains(column + " DECIMAL(20,6) NULL COMMENT")); + } + assertTrue(ddl.get("ll_selfop_outbound_order").contains("sales_eligible TINYINT NOT NULL DEFAULT 0")); + assertTrue(ddl.get("ll_selfop_outbound_order").contains("source_status = 100 AND source_timezone IS NOT NULL")); + assertTrue(ddl.get("ll_selfop_outbound_item").contains("CHECK (actual_quantity >= 0)")); + // Negative observed stock must be retained as INVALID, never rejected/coerced by a quantity CHECK. + assertFalse(stock.contains("CHECK (available_quantity")); + } + + @Test + void durableRecoveryAndImmutableVersionKeysArePresent() throws Exception { + var ddl = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql"))); + var task = ddl.get("ll_selfop_sync_task"); + for (var column : new String[]{"apply_state", "affected_warehouse_codes_json", "observation_baseline", + "routing_manifest_json", "request_hash", "apply_completed_at_utc", "task_no", "execution_purpose", + "validation_root_task_id", "validation_budget_json", "result_published_run_id", "progress_json", + "progress_updated_at_utc"}) { + assertTrue(task.contains(" " + column + " "), column); + } + assertTrue(task.contains("UNIQUE KEY uk_selfop_task_no (task_no)")); + var account = ddl.get("ll_selfop_outbound_discovery_scope"); + for (var column : new String[]{"api_budget_json", "api_cooldown_until_utc", "api_pause_reason"}) { + assertTrue(account.contains(" " + column + " "), column); + } + var run = ddl.get("ll_selfop_sync_run"); + for (var column : new String[]{"execution_purpose", "build_started_at_utc", "superseded_at_utc", + "progress_snapshot_json", "heartbeat_at_utc", "inventory_daily_sample_date", "inventory_sample_rule_version"}) { + assertTrue(run.contains(" " + column + " "), column); + } + assertTrue(run.contains("uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date)")); + assertTrue(run.contains("CONSTRAINT chk_selfop_inventory_daily CHECK")); + assertTrue(ddl.get("ll_selfop_sku_daily_sales").contains("idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code)")); + assertTrue(ddl.get("ll_selfop_outbound_order").contains("idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id)")); + assertTrue(ddl.get("ll_selfop_sync_stage").contains("stage_batch_id,object_type,object_key_hash")); + assertTrue(ddl.get("ll_selfop_sync_stage").contains("observation_sequence IS NOT NULL")); + assertTrue(ddl.get("ll_selfop_outbound_order").contains("source_account,customer_code,delivery_no")); + assertTrue(ddl.get("ll_selfop_sku_daily_sales").contains("sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date")); + assertTrue(ddl.get("ll_selfop_sku_profile").contains("profile_run_id,source_account,customer_code,warehouse_code,sku_code")); + assertTrue(ddl.get("ll_selfop_inventory_snapshot").contains("snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality")); + } + + @Test + void readAndSyncPermissionsAreIndependentWithThreeAuthorizedAdminMenus() throws Exception { + var seed = Files.readString(Path.of("../iam/src/main/resources/db/selfoperated-analytics-permissions.sql")); + var unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + assertTrue(unified.contains(seed.strip())); + assertTrue(seed.contains("platform:self-operated-analytics:list")); + assertTrue(seed.contains("platform:self-operated-analytics:sync")); + for (var route : new String[]{"/self-operated/sales", "/self-operated/inventory", "/self-operated/collection"}) { + assertTrue(seed.contains(route)); + } + assertTrue(seed.contains("'API',5118100,'platform:self-operated-analytics:list'")); + assertTrue(seed.contains("'API',5118130,'platform:self-operated-analytics:sync'")); + assertFalse(seed.contains("'MERCHANT'")); + assertFalse(seed.contains("INSERT IGNORE")); + assertFalse(seed.contains("ON DUPLICATE KEY")); + assertTrue(seed.contains("(4000001,5118000,0,")); + assertTrue(seed.contains("(4000001,5118001,0,")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java new file mode 100644 index 000000000..7e9208102 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java @@ -0,0 +1,290 @@ +package com.baiyue.lastleg.selfoperated.analytics.client; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext; + +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties; +import org.junit.jupiter.api.Test; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import static org.mockito.Mockito.*; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; +import java.time.LocalDateTime; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +class RestLingxingAnalyticsClientTest { + private final SelfopAnalyticsProperties properties = new SelfopAnalyticsProperties(); + private final RestClient.Builder builder = RestClient.builder().baseUrl("http://synthetic-integration"); + private final MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build(); + private final SelfopEgressService egress = mock(SelfopEgressService.class); + private final RestLingxingAnalyticsClient client = new RestLingxingAnalyticsClient(builder.build(), properties, egress); + private static SelfopRequestContext context(String account) { return new SelfopRequestContext(account,"BUSINESS",1L,"selfop-1","grant",1L,2L); } + private static final String PREFIX = "http://synthetic-integration/internal/integrations/lingxing/wms"; + + private void enable() { + + properties.setCollectionMode("BUSINESS"); + when(egress.reserve(any(),any(),anyInt(),any(),any(),any())).thenAnswer(i -> { + SelfopRequestContext c=i.getArgument(0); + return new SelfopEgressDtos.EgressContext(c.sourceAccount(),c.executionPurpose(),"1",c.sessionId(),"synthetic-attempt",c.exclusiveGrantId()); + }); + properties.setSourceAccount("synthetic-account"); + properties.setInternalServiceToken("synthetic-token"); + } + @Test void disabledByDefaultAndAccountBindingFailBeforeTransport() { + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("EXECUTION_DISABLED"); + enable(); + assertThatThrownBy(() -> client.warehouses(context("other-account"), 1, 100)).hasMessage("SOURCE_ACCOUNT_MISMATCH"); + properties.setInternalServiceToken(""); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("INTERNAL_TOKEN_UNCONFIGURED"); + server.verify(); + } + @Test void oneWarehousePagePreservesTimezoneAsClueAndStringLongMetadata() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":"1","records":[ + {"whCode":"WH-A","whNameCn":"A","warehouseType":1,"status":0,"timeZone":"America/New_York"}]}} + """); + var page = client.warehouses(context("synthetic-account"), 1, 100); + assertThat(page.total()).isEqualTo(1L); + assertThat(page.records().get(0).timeZone()).isEqualTo("America/New_York"); + assertThat(properties.getSourceQueryTimezone()).isNull(); + server.verify(); + } + @Test void discoveryUsesNormalizedEmptyMetadataAndDoesNotSendWarehouseFilter() { + enable(); + expect("/outbound-orders/query", """ + {"startTime":"2026-10-01T00:00:00","endTime":"2026-10-02T00:00:00","current":1,"size":100} + """, "{\"code\":\"200\",\"data\":{\"current\":1,\"size\":100,\"pages\":0,\"total\":\"0\",\"records\":[]}}"); + assertThat(client.discover(context("synthetic-account"), LocalDateTime.of(2026,10,1,0,0), + LocalDateTime.of(2026,10,2,0,0), 1, 100).records()).isEmpty(); + server.verify(); + } + @Test void locateHasNoTimeOrStatusAndReturnsCurrentCancelledIdentity() { + enable(); + expect("/outbound-orders/locate", "{\"deliveryNo\":\"DO-1\",\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[ + {"deliveryNo":"DO-1","sourceNo":"SRC-1","customerCode":"C","whCode":"WH-B","status":99,"outboundTime":null}]}} + """); + var summary = client.locate(context("synthetic-account"), "DO-1", 1, 100).records().get(0); + assertThat(summary.sourceStatus()).isEqualTo(99); + assertThat(summary.warehouseCode()).isEqualTo("WH-B"); + server.verify(); + } + @Test void detailProjectsActualLinesOnlyAndDoesNotInventUnitOrTimestamp() { + enable(); + expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """ + {"code":"200","data":{"deliveryNo":"DO-1","customerCode":"C","whCode":"WH-A","status":100, + "orderCreateTime":"2025-01-01 00:00:00","outboundTime":"2026-10-01 00:00:00", + "skuVOList":[{"productSku":"sku","actualDeliveryQty":2.125,"deliveryQty":99}, + {"productSku":"sku","actualDeliveryQty":3}], + "packageList":[{"skuList":[{"actualDeliveryQty":"5.125"}]}]}} + """); + var order = client.detail(context("synthetic-account"), "WH-A", "C", "DO-1"); + assertThat(order.items()).hasSize(2); + assertThat(order.items().get(0).actualQuantity()).isEqualByComparingTo("2.125"); + assertThat(order.items().get(0).quantityUnit()).isNull(); + assertThat(order.sourceCreatedTime()).isEqualTo("2025-01-01 00:00:00"); + server.verify(); + } + @Test void inventoryPreservesUnknownNegativeAndAllQualitiesWithoutInventedArithmetic() { + enable(); + expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":1000}", """ + {"code":"200","data":{"current":1,"size":1000,"pages":1,"total":1,"records":[ + {"customerCode":"C","whCode":"WH-A","productSku":"sku","productQuality":1, + "stkQty":120,"freezeQty":20,"useStkQty":100.125,"transitQty":-3}]}} + """); + var row = client.inventory(context("synthetic-account"), "WH-A", 1, 1000).records().get(0); + assertThat(row.productQuality()).isEqualTo(1); + assertThat(row.availableQuantity()).isEqualByComparingTo("100.125"); + assertThat(row.transitQuantity()).isEqualByComparingTo("-3"); + server.verify(); + } + @Test void missingPageMetadataCannotBeInterpretedAsEmptySuccess() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", + "{\"code\":\"200\",\"data\":{\"records\":[]}}"); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).isInstanceOf(AnalyticsClientException.class); + server.verify(); + } + @Test void unknownStockIsNotZeroAndDecimalPrecisionIsRetained() { + enable(); + expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[ + {"customerCode":"C","whCode":"WH-A","productSku":"sku","productQuality":0, + "stkQty":99999999999999.123456,"freezeQty":"","useStkQty":null,"transitQty":0}]}} + """); + var stock = client.inventory(context("synthetic-account"), "WH-A", 1, 100).records().get(0); + assertThat(stock.physicalQuantity()).isEqualByComparingTo("99999999999999.123456"); + assertThat(stock.frozenQuantity()).isNull(); + assertThat(stock.availableQuantity()).isNull(); + assertThat(stock.transitQuantity()).isEqualByComparingTo("0"); + server.verify(); + } + @Test void detailIdentityMismatchIsNotTreatedAsCancellationOrZero() { + enable(); + expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """ + {"code":"200","data":{"deliveryNo":"DO-2","customerCode":"C","whCode":"WH-A","status":99,"skuVOList":[]}} + """); + assertThatThrownBy(() -> client.detail(context("synthetic-account"), "WH-A", "C", "DO-1")) + .hasMessage("DETAIL_IDENTITY_MISMATCH"); + server.verify(); + } + @Test void scopeLeakIsRejected() { + enable(); + expect("/product-inventory/query", "{\"warehouseCode\":\"WH-A\",\"current\":1,\"size\":100}", """ + {"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[ + {"customerCode":"C","whCode":"WH-B","productSku":"sku","productQuality":0}]}} + """); + assertThatThrownBy(() -> client.inventory(context("synthetic-account"), "WH-A", 1, 100)).hasMessage("INVENTORY_WAREHOUSE_MISMATCH"); + server.verify(); + } + @Test void errorsKeepRetryabilityWithoutRawProviderText() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", """ + {"code":"BIZ_503","message":"sensitive source response","data":{"retryable":true}} + """); + server.expect(requestTo(PREFIX + "/warehouses/query")).andRespond(withStatus(HttpStatus.UNAUTHORIZED) + .body("sensitive token response")); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage("INTEGRATION_PROVIDER_FAILURE") + .satisfies(e -> assertThat(((AnalyticsClientException)e).retryable()).isTrue()); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage("INTEGRATION_HTTP_FAILURE") + .satisfies(e -> assertThat(((AnalyticsClientException)e).retryable()).isFalse()); + server.verify(); + } + @Test void knownErrorsPreserveSafeClassificationAndCredentialFailuresNeverRetry() { + enable(); + String[] codes = {"LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED", + "LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE"}; + boolean[] upstreamRetryable = {true, true, false, true}; + boolean[] expectedRetryable = {false, false, false, true}; + for (int i = 0; i < codes.length; i++) { + expect("/warehouses/query", "{\"current\":1,\"size\":100}", + "{\"code\":\"BIZ_503\",\"message\":\"secret raw provider message\",\"data\":{\"errorCode\":\"" + + codes[i] + "\",\"retryable\":" + upstreamRetryable[i] + "}}"); + } + for (int i = 0; i < codes.length; i++) { + String code = codes[i]; boolean retryable = expectedRetryable[i]; + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage(code).hasNoCause() + .satisfies(e -> { + assertThat(((AnalyticsClientException)e).code()).isEqualTo(code); + assertThat(((AnalyticsClientException)e).retryable()).isEqualTo(retryable); + }); + } + server.verify(); + } + @Test void unrecognizedProviderCodeCannotLeakThroughDiagnosticMessage() { + enable(); + expect("/warehouses/query", "{\"current\":1,\"size\":100}", """ + {"code":"BIZ_400","message":"sensitive source response", + "data":{"errorCode":"sensitive arbitrary provider code","retryable":false}} + """); + assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)) + .hasMessage("INTEGRATION_PROVIDER_FAILURE").hasNoCause(); + server.verify(); + } + @Test void durableDenialPreservesSampleStopAndQueueDeadline() { + enable(); + expect("/warehouses/query","{\"current\":1,\"size\":100}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\"LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED\",\"denial\":{\"errorCode\":\"ACCOUNT_INTERVAL_WAIT\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:00:05Z\"}}}"); + expect("/warehouses/query","{\"current\":1,\"size\":100}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\"LINGXING_WMS_BUDGET_AUTHORIZATION_DENIED\",\"denial\":{\"errorCode\":\"VALIDATION_DEADLINE\",\"sampleDone\":true,\"waitUntil\":null}}}"); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,100)).isInstanceOf(EgressRejectedException.class).satisfies(e->assertThat(((EgressRejectedException)e).waitUntilUtc()).isEqualTo(LocalDateTime.of(2026,10,12,0,0,5))); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,100)).isInstanceOf(EgressRejectedException.class).satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isTrue());server.verify(); + } + @Test void allFiveEndpointsSendOnlyTheCurrentControlHeaderOverLoopbackHttp() throws Exception { + enable();properties.setEnvironment("production"); + String control="{\"sourceAccount\":\"synthetic-account\",\"executionPurpose\":\"BUSINESS\",\"rootTaskId\":\"1\",\"sessionId\":\"selfop-1\",\"attemptId\":\"synthetic-attempt\",\"exclusiveGrantId\":\"grant\"}"; + var received=new java.util.concurrent.ConcurrentHashMap(); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + var wrongHeaders=new java.util.concurrent.atomic.AtomicInteger(); + var loopback=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0); + loopback.createContext("/internal/integrations/lingxing/wms",exchange->{ + calls.incrementAndGet();String path=exchange.getRequestURI().getPath(); + String actual=exchange.getRequestHeaders().getFirst("X-Lingxing-Egress-Context"); + if(exchange.getRequestHeaders().containsKey("X-Lingxing-Egress-SelfopRequestContext"))wrongHeaders.incrementAndGet(); + boolean accepted=control.equals(actual)&&"synthetic-token".equals(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")) + &&"POST".equals(exchange.getRequestMethod()); + if(actual!=null)received.put(path,actual); + exchange.getRequestBody().readAllBytes(); + String data; + if(path.endsWith("/outbound-orders/detail")) { + data="{\"deliveryNo\":\"D\",\"customerCode\":\"C\",\"whCode\":\"A\",\"status\":100,\"skuVOList\":[{\"productSku\":\"SKU\",\"actualDeliveryQty\":1}]}"; + } else { + String row=path.endsWith("/warehouses/query") + ?"{\"whCode\":\"A\",\"whNameCn\":\"Synthetic\",\"warehouseType\":0,\"status\":0}" + :path.endsWith("/product-inventory/query") + ?"{\"whCode\":\"A\",\"customerCode\":\"C\",\"productSku\":\"SKU\",\"productQuality\":0,\"stkQty\":1,\"useStkQty\":1,\"freezeQty\":0,\"transitQty\":0}" + :"{\"whCode\":\"A\",\"customerCode\":\"C\",\"deliveryNo\":\"D\",\"status\":100}"; + data="{\"current\":1,\"size\":1,\"pages\":1,\"total\":\"1\",\"records\":["+row+"]}"; + } + byte[] response=("{\"code\":\"200\",\"data\":"+data+"}").getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type","application/json"); + exchange.sendResponseHeaders(accepted?200:403,response.length); + exchange.getResponseBody().write(response);exchange.close(); + }); + loopback.start(); + try { + properties.setIntegrationBaseUrl("http://127.0.0.1:"+loopback.getAddress().getPort()); + var httpClient=new RestLingxingAnalyticsClient(RestClient.builder(),properties,egress); + var ctx=context("synthetic-account"); + assertThat(httpClient.warehouses(ctx,1,1).records()).hasSize(1); + assertThat(httpClient.discover(ctx,LocalDateTime.of(2026,10,1,0,0),LocalDateTime.of(2026,10,2,0,0),1,1).records()).hasSize(1); + assertThat(httpClient.locate(ctx,"D",1,1).records()).hasSize(1); + assertThat(httpClient.detail(ctx,"A","C","D").items()).hasSize(1); + assertThat(httpClient.inventory(ctx,"A",1,1).records()).hasSize(1); + assertThat(calls.get()).isEqualTo(5);assertThat(wrongHeaders.get()).isZero(); + assertThat(received).hasSize(5);assertThat(received.values()).containsOnly(control); + verify(egress,never()).pauseUncertain(any(),any(),any()); + } finally {loopback.stop(0);} + } + @Test void unconfirmedCompletionResponsePersistsPauseForItsReservedAttempt() { + enable(); + expect("/warehouses/query","{\"current\":1,\"size\":1}",""" + {"code":"B0001","data":{"errorCode":"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED","retryable":false, + "egress":{"attemptId":"synthetic-attempt","actualHttpAttempts":1,"accountPaused":true,"cooldownUntil":null}}} + """); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + verify(egress).pauseUncertain(argThat(c->"synthetic-account".equals(c.sourceAccount())&&"synthetic-attempt".equals(c.attemptId())), + eq(SelfopEgressDtos.Operation.WAREHOUSES),eq("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"));server.verify(); + } + @Test void wrongResponseAttemptCannotPersistPauseForTheCurrentReservation() { + enable(); + expect("/warehouses/query","{\"current\":1,\"size\":1}",""" + {"code":"B0001","data":{"errorCode":"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED", + "egress":{"attemptId":"another-attempt","actualHttpAttempts":1,"accountPaused":true}}} + """); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("INTEGRATION_PAUSE_BINDING_INVALID"); + verify(egress,never()).pauseUncertain(any(),any(),any());server.verify(); + } + @Test void transportResponseLossPersistsPauseWithoutAnotherHttpAttempt() { + enable(); + server.expect(requestTo(PREFIX+"/warehouses/query")).andRespond(request->{throw new org.springframework.web.client.ResourceAccessException("synthetic reply lost");}); + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage("INTEGRATION_TRANSPORT_FAILURE"); + verify(egress).pauseUncertain(any(),eq(SelfopEgressDtos.Operation.WAREHOUSES),eq("INTEGRATION_TRANSPORT_FAILURE")); + verify(egress,times(1)).reserve(any(),any(),anyInt(),any(),any(),any());server.verify(); + } + @Test void rateAndUnconfirmedAckRemainDifferentSignalsToTheDurableAccount() { + enable(); + String[] codes={"LINGXING_WMS_HTTP_RATE_LIMITED","LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"}; + for(String code:codes){ + expect("/warehouses/query","{\"current\":1,\"size\":1}","{\"code\":\"B0001\",\"data\":{\"errorCode\":\""+code+"\",\"retryable\":false,\"egress\":{\"attemptId\":\"synthetic-attempt\",\"actualHttpAttempts\":1,\"accountPaused\":true}}}"); + } + for(String code:codes){ + assertThatThrownBy(()->client.warehouses(context("synthetic-account"),1,1)).hasMessage(code); + verify(egress).pauseUncertain(any(),eq(SelfopEgressDtos.Operation.WAREHOUSES),eq(code)); + } + server.verify(); + } + private void expect(String path, String request, String response) { + server.expect(requestTo(PREFIX + path)).andExpect(method(HttpMethod.POST)) + .andExpect(header("X-Internal-Service-Token", "synthetic-token")) + .andExpect(header("X-Lingxing-Egress-Context", "{\"sourceAccount\":\"synthetic-account\",\"executionPurpose\":\"BUSINESS\",\"rootTaskId\":\"1\",\"sessionId\":\"selfop-1\",\"attemptId\":\"synthetic-attempt\",\"exclusiveGrantId\":\"grant\"}")) + .andExpect(content().json(request, true)) + .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java new file mode 100644 index 000000000..fee983548 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.query.*; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.*; +import org.springframework.web.server.ResponseStatusException; +import org.mockito.ArgumentCaptor; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformAnalyticsControllerTest { + private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));} + @Test void rejectsCustomerEvenWithReadPermission(){ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var controller=new PlatformAnalyticsController(service,collection); + var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION)); + assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null)); + assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null)); + verifyNoInteractions(service,collection); + } + @Test void writePermissionAloneDoesNotAuthorizeAnyRead(){ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var controller=new PlatformAnalyticsController(service,collection); + var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); + assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null)); + assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null)); + assertThrows(ResponseStatusException.class,()->controller.slowSkus(null,null)); + verifyNoInteractions(service,collection); + } + @Test void allElevenReadEndpointsShareTheIndependentGuard(){ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var c=new PlatformAnalyticsController(service,collection);var user=platform(); + c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null); + c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null); + verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null); + verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null); + verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null); + assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformAnalyticsController.class.getAnnotation(PreAuthorize.class).value()); + } + private static HandlerMethodArgumentResolver identity(){ + return new HandlerMethodArgumentResolver(){ + public boolean supportsParameter(MethodParameter p){return p.getParameterType()==AuthenticatedUser.class;} + public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory b){return platform();} + }; + } + @Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary") + .param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","CUSTOM").param("startDate","2026-10-01") + .param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22") + .param("salesScopeHash","a".repeat(64)).param("inventoryScopeHash","b".repeat(64)) + .param("queryAsOf","2026-10-12T04:00:00Z")).andExpect(status().isOk()); + var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture()); + assertEquals(LocalDate.of(2026,10,1),cap.getValue().startDate());assertEquals(Instant.parse("2026-10-12T04:00:00Z"),cap.getValue().queryAsOf()); + assertEquals(11L,cap.getValue().salesRunId());assertEquals(2,cap.getValue().current()); + } + @Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/LXCOL-DEMO") + .param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk()); + var cap=ArgumentCaptor.forClass(CollectionQuery.class);verify(collection).taskDetail(eq("LXCOL-DEMO"),cap.capture()); + assertEquals("W",cap.getValue().warehouseCode());verifyNoInteractions(service); + } + @Test void inventoryHttpBindsAttentionSortAndThresholdIndependentlyFromVersionIdentity()throws Exception{ + var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class); + var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage").param("sourceAccount","demo").param("warehouseCode","W") + .param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")).andExpect(status().isOk()); + var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class); + verify(service).inventoryCoverage(q.capture(),options.capture());assertEquals("C",q.getValue().customerCode()); + assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold()); + verifyNoInteractions(collection); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java new file mode 100644 index 000000000..f11428b44 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java @@ -0,0 +1,111 @@ +package com.baiyue.lastleg.selfoperated.analytics.inventory; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +class InventorySnapshotAssemblerTest { + private final LocalDateTime time = LocalDateTime.of(2026, 10, 12, 0, 0); + private SelfopSyncScope scope() { + var scope = new SelfopSyncScope(); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); return scope; + } + private SelfopSyncRun run(long count) { + var run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setId(10L); run.setSourceTotalCount(count); run.setUniqueObjectCount(count); + run.setPageCount(1); run.setCoverageStatus("COMPLETE"); run.setFetchStartedAtUtc(time); run.setFetchFinishedAtUtc(time.plusMinutes(1)); + run.setBasePublishedRunId(9L); run.setReferenceSalesRunId(8L); return run; + } + private SelfopPayloads.Stock stock(String customer, String sku, Integer quality, BigDecimal available) { + return new SelfopPayloads.Stock("demo", customer, "A", sku, quality, "Name", new BigDecimal("120"), + available, new BigDecimal("20"), new BigDecimal("30"), null); + } + private SelfopSyncStage stage(SelfopPayloads.Stock stock) { + var stage = new SelfopSyncStage(); stage.setRunId(10L); stage.setStageBatchId(10L); stage.setLastFetchRunId(10L); + stage.setWarehouseCode(stock.warehouseCode()); stage.setObjectType("PRODUCT_STOCK"); stage.setValidationStatus("VALID"); + stage.setFirstPageNo(1); stage.setObservedAtUtc(time); stage.setPayloadJson(json(stock)); stage.setContentHash(hash(stock)); + stage.setObjectKeyHash(hash(List.of(stock.sourceAccount(), stock.customerCode(), stock.warehouseCode(), stock.skuCode(), stock.productQuality()))); + return stage; + } + private InventorySnapshotAssembler.Candidate build(SelfopSyncRun run, List stages, + List previous, List profiles, List sales) { + return InventorySnapshotAssembler.assemble(scope(), run, stages, previous, profiles, sales, "PCS"); + } + @Test void quantitiesRemainIndependentAndAllQualitiesKeepTheirIdentity() { + var rows = List.of(stage(stock("c", "sku", 0, new BigDecimal("100"))), + stage(stock("c", "sku", 1, new BigDecimal("8"))), stage(stock("c", "sku2", 0, BigDecimal.ZERO))); + var candidate = build(run(3), rows, List.of(), List.of(), List.of()); + assertEquals(3, candidate.snapshots().size()); assertEquals(2, candidate.profiles().size()); + var regular = candidate.snapshots().get(0); + assertEquals(new BigDecimal("100"), regular.getAvailableQuantity()); + assertEquals(new BigDecimal("30"), regular.getTransitQuantity()); + assertEquals("VALID", regular.getQualityStatus()); + assertEquals(BigDecimal.ZERO, candidate.snapshots().get(2).getAvailableQuantity()); + } + @Test void missingAndNegativeQuantitiesNeverTurnIntoZero() { + var candidate = build(run(2), List.of(stage(stock("c", "unknown", 0, null)), + stage(stock("c", "negative", 0, BigDecimal.ONE.negate()))), List.of(), List.of(), List.of()); + assertEquals("UNKNOWN", candidate.snapshots().get(0).getQualityStatus()); + assertNull(candidate.snapshots().get(0).getAvailableQuantity()); + assertEquals("INVALID", candidate.snapshots().get(1).getQualityStatus()); + assertEquals(BigDecimal.ONE.negate(), candidate.snapshots().get(1).getAvailableQuantity()); + } + @Test void unrepresentableDecimalsArePreservedInStageAndCannotBeRoundedInSnapshot() { + var source = stage(stock("c", "precise", 0, new BigDecimal("0.1234567"))); + var candidate = build(run(1), List.of(source), List.of(), List.of(), List.of()); + assertEquals("QUANTITY_PRECISION", candidate.snapshots().get(0).getQualityReason()); + assertNull(candidate.snapshots().get(0).getAvailableQuantity()); + assertEquals(new BigDecimal("0.1234567"), read(source.getPayloadJson(), SelfopPayloads.Stock.class).availableQuantity()); + } + @Test void total5741Unique5740RejectsEvenIfRowCountMatchesSourceTotal() { + var run = run(5741); run.setUniqueObjectCount(5740L); run.setPageCount(6); + var rows = new ArrayList(); + for (int i = 0; i < 5741; i++) rows.add(stage(stock("c", "sku" + (i % 5740), 0, BigDecimal.ONE))); + assertThrows(IllegalStateException.class, () -> build(run, rows, List.of(), List.of(), List.of())); + } + @Test void duplicateBusinessIdentityRejectedEvenIfDistinctStageHashesClaimComplete() { + var first = stage(stock("c", "sku", 0, BigDecimal.ONE)); + var second = stage(stock("c", "sku", 0, BigDecimal.TEN)); second.setObjectKeyHash("different"); + assertThrows(IllegalStateException.class, () -> build(run(2), List.of(first, second), List.of(), List.of(), List.of())); + } + @Test void newAttemptCannotBorrowOldAttemptPages() { + var stage = stage(stock("c", "sku", 0, BigDecimal.ONE)); stage.setStageBatchId(9L); + assertThrows(IllegalStateException.class, () -> build(run(1), List.of(stage), List.of(), List.of(), List.of())); + } + @Test void warehouseLeakOrTamperedContentRejected() { + var stock = stock("c", "sku", 0, BigDecimal.ONE); + var wrong = new SelfopPayloads.Stock(stock.sourceAccount(), stock.customerCode(), "B", stock.skuCode(), 0, + null, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null); + assertThrows(IllegalStateException.class, () -> build(run(1), List.of(stage(wrong)), List.of(), List.of(), List.of())); + var tampered = stage(stock); tampered.setContentHash("bad"); + assertThrows(IllegalStateException.class, () -> build(run(1), List.of(tampered), List.of(), List.of(), List.of())); + } + @Test void fixedReferencesGenerateUnknownAndSalesCannotCreateInventoryEvidence() { + var previous = new SelfopInventorySnapshot(); previous.setSnapshotRunId(9L); previous.setSourceAccount("demo"); + previous.setWarehouseCode("A"); previous.setCustomerCode("c"); previous.setSkuCode("old"); previous.setProductQuality(1); + previous.setAvailableQuantity(BigDecimal.TEN); + var old = profile(9L, "old"); old.setEvidenceType("INVENTORY_OBSERVATION"); old.setFirstSeenAtUtc(time.minusDays(3)); + old.setEarliestEvidenceAtUtc(time.minusDays(3)); old.setEvidenceRunId(1L); old.setLastSeenRunId(9L); + var sales = profile(8L, "salesOnly"); sales.setEvidenceType("VALID_OUTBOUND"); sales.setEarliestEvidenceAtUtc(time.minusDays(100)); + var candidate = build(run(0), List.of(), List.of(previous), List.of(old), List.of(sales)); + assertEquals(2, candidate.snapshots().size()); + assertTrue(candidate.snapshots().stream().allMatch(s -> "UNKNOWN".equals(s.getQualityStatus()) && s.getAvailableQuantity() == null)); + var inherited = candidate.profiles().stream().filter(p -> "old".equals(p.getSkuCode())).findFirst().orElseThrow(); + assertEquals(time.minusDays(3), inherited.getFirstSeenAtUtc()); assertEquals(9L, inherited.getLastSeenRunId()); + var placeholder = candidate.profiles().stream().filter(p -> "salesOnly".equals(p.getSkuCode())).findFirst().orElseThrow(); + assertEquals("NONE", placeholder.getEvidenceType()); assertNull(placeholder.getEarliestEvidenceAtUtc()); + assertNull(placeholder.getEvidenceRunId()); assertNull(placeholder.getQuantityUnit()); + assertEquals(BigDecimal.TEN, previous.getAvailableQuantity()); assertEquals(9L, old.getProfileRunId()); + } + @Test void inventoryFirstSeenIsInheritedOnlyFromInventoryNotSales() { + var old = profile(9L, "sku"); old.setEvidenceType("NONE"); old.setFirstSeenAtUtc(time.minusDays(90)); + var candidate = build(run(1), List.of(stage(stock("c", "sku", 0, BigDecimal.ONE))), List.of(), List.of(old), List.of()); + assertEquals(time, candidate.profiles().get(0).getFirstSeenAtUtc()); + } + private SelfopSkuProfile profile(long runId, String sku) { + var profile = new SelfopSkuProfile(); profile.setProfileRunId(runId); profile.setSourceAccount("demo"); + profile.setWarehouseCode("A"); profile.setCustomerCode("c"); profile.setSkuCode(sku); return profile; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java new file mode 100644 index 000000000..6f3214f32 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java @@ -0,0 +1,256 @@ +package com.baiyue.lastleg.selfoperated.analytics.inventory; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.time.LocalDateTime; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class InventorySnapshotServiceTest { + private final SelfopCoordinationMapper coordination = mock(SelfopCoordinationMapper.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopInventorySnapshotMapper snapshots = mock(SelfopInventorySnapshotMapper.class); + private final SelfopSkuProfileMapper profiles = mock(SelfopSkuProfileMapper.class); + private final SelfopAnalyticsProperties properties = new SelfopAnalyticsProperties(); + private final InventorySnapshotService service = new InventorySnapshotService(coordination, scopes, runs, tasks, stages, snapshots, profiles, properties); + private SelfopSyncScope scope; + private SelfopSyncRun run; + private SelfopSyncTask task; + @BeforeEach void prepare() { + scope = new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); + scope.setDataFamily("INVENTORY"); scope.setScopeHash("hash"); scope.setLeaseOwner("owner"); + scope.setLeaseUntilUtc(LocalDateTime.now(java.time.ZoneOffset.UTC).plusHours(1)); scope.setFenceToken(4L); + scope.setRebuildRequired(1); scope.setFactRevision(9L); + run = new SelfopSyncRun(); run.setId(10L); run.setTaskId(20L); run.setScopeId(1L); + run.setSourceAccount("demo"); run.setWarehouseCode("A"); run.setRunRole("WAREHOUSE_SYNC"); + run.setExecutionPurpose("BUSINESS"); run.setScopeHash("hash"); run.setFenceToken(4L); run.setStatus("READY"); run.setCoverageStatus("COMPLETE"); + run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); + run.setSourceTotalCount(0L); run.setUniqueObjectCount(0L); run.setPageCount(1); + run.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 0, 0)); run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc().plusMinutes(1)); + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setId(20L); task.setScopeId(1L); task.setScopeHash("hash"); + task.setSourceAccount("demo"); task.setWarehouseCode("A"); task.setApplyState("NOT_APPLICABLE"); + when(coordination.lockScope(1L)).thenReturn(scope); when(coordination.lockRun(10L)).thenReturn(run); + when(coordination.lockTask(20L)).thenReturn(task); + when(stages.selectList(any())).thenReturn(List.of()); when(snapshots.selectList(any())).thenReturn(List.of()); + when(profiles.selectList(any())).thenReturn(List.of()); + properties.setQuantityUnit("PCS"); + } + @Test void inventoryPublicationDoesNotConsultSalesBarrierOrModifyOutboundRevision() { + when(coordination.barriers("demo", "A")).thenReturn(1L); + service.publish(1L, 10L, "owner", 4L); + assertEquals(10L, scope.getPublishedRunId()); assertEquals("PUBLISHED", run.getStatus()); + assertEquals(1, scope.getRebuildRequired()); assertEquals(9L, scope.getFactRevision()); + verify(coordination, never()).barriers(anyString(), anyString()); + verify(tasks).updateById(argThat((SelfopSyncTask t) -> "SUCCEEDED".equals(t.getStatus()) && Long.valueOf(10L).equals(t.getResultPublishedRunId()))); + assertEquals(10L, task.getResultPublishedRunId()); + } + @Test void oldFenceOrExpiredLeaseCannotMovePointer() { + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 3L)); + scope.setLeaseUntilUtc(LocalDateTime.now(java.time.ZoneOffset.UTC).minusSeconds(1)); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void newerInventoryPublicationCannotBeOverwrittenByOldCandidate() { + scope.setPublishedRunId(11L); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void unknownUnitFailsClosedAndUnreadyCandidateCannotPublish() { + run.setScopeSnapshotJson("{\"quantityUnit\":null}"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setStatus("FETCHING"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void readyAndPublishedRetryNeverChangesImmutableCandidateRows() { + service.build(1L, 10L, "owner", 4L); + verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class)); + verify(snapshots, never()).insert(anyCollection(), anyInt()); + verify(profiles, never()).insert(any(SelfopSkuProfile.class)); + verify(profiles, never()).insert(anyCollection(), anyInt()); + service.publish(1L, 10L, "owner", 4L); service.publish(1L, 10L, "owner", 4L); + verify(scopes, times(1)).updateById(any(SelfopSyncScope.class)); + } + @Test void malformedCompleteClaimFailsBeforeAnyCandidateIsWritten() { + run.setStatus("VALIDATED"); run.setSourceTotalCount(5741L); run.setUniqueObjectCount(5740L); + assertThrows(IllegalStateException.class, () -> service.build(1L, 10L, "owner", 4L)); + verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class)); + verify(snapshots, never()).insert(anyCollection(), anyInt()); + verify(runs, never()).updateById(any(SelfopSyncRun.class)); + } + @Test void emptyCompleteRunCanBuildAndPublishWithoutInventingSkuEvidence() { + run.setStatus("VALIDATED"); + service.build(1L, 10L, "owner", 4L); + assertEquals("READY", run.getStatus()); verify(runs).updateById(run); + verify(snapshots, never()).insert(any(SelfopInventorySnapshot.class)); + verify(snapshots, never()).insert(anyCollection(), anyInt()); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); + } + @Test void missingProfileRefusesPointerSwitch() { + var row = new SelfopInventorySnapshot(); row.setSnapshotRunId(10L); row.setSourceAccount("demo"); + row.setWarehouseCode("A"); row.setCustomerCode("c"); row.setSkuCode("sku"); + when(snapshots.selectList(any())).thenReturn(List.of(row)); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + @Test void validationPurposeCannotBuildOrPublishEvenWhenCompleteAndFenceValid() { + run.setExecutionPurpose("VALIDATION");run.setStatus("VALIDATED"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.build(1L,10L,"owner",4L)).getMessage()); + run.setStatus("READY"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.publish(1L,10L,"owner",4L)).getMessage()); + verify(snapshots, never()).insert(anyCollection(),anyInt());verify(profiles, never()).insert(anyCollection(),anyInt());verify(scopes,never()).updateById(any(SelfopSyncScope.class));assertNull(task.getResultPublishedRunId()); + } + @Test void validationTaskCannotPublishBusinessRunOrAcquireResultLink() { + task.setExecutionPurpose("VALIDATION");task.setValidationRootTaskId(20L); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED", assertThrows(IllegalStateException.class, () -> service.publish(1L,10L,"owner",4L)).getMessage()); + verify(scopes,never()).updateById(any(SelfopSyncScope.class));assertNull(task.getResultPublishedRunId()); + } + @Test void candidateBulkInsertHasIdsAndCapsConfiguredBatchSizeAt500() { + run.setStatus("VALIDATED");run.setSourceTotalCount(1L);run.setUniqueObjectCount(1L);properties.setPersistenceBatchSize(1000); + var payload=new SelfopPayloads.Stock("demo","c","A","sku",0,"Name",java.math.BigDecimal.TEN,java.math.BigDecimal.ONE,java.math.BigDecimal.ZERO,java.math.BigDecimal.ZERO,null); + var stage=new SelfopSyncStage();stage.setId(100L);stage.setRunId(10L);stage.setStageBatchId(10L);stage.setLastFetchRunId(10L);stage.setObjectType("PRODUCT_STOCK");stage.setWarehouseCode("A");stage.setValidationStatus("VALID");stage.setFirstPageNo(1);stage.setObservedAtUtc(run.getFetchStartedAtUtc());stage.setPayloadJson(SelfopSupport.json(payload));stage.setContentHash(SelfopSupport.hash(payload));stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","c","A","sku",0))); + when(stages.selectList(any())).thenReturn(List.of(stage));service.build(1L,10L,"owner",4L); + verify(snapshots).insert(argThat((java.util.Collection rows) -> rows.size()==1 && rows.iterator().next().getId()!=null && Long.valueOf(10L).equals(rows.iterator().next().getSnapshotRunId())),eq(500)); + verify(profiles).insert(argThat((java.util.Collection rows) -> rows.size()==1 && rows.iterator().next().getId()!=null),eq(500)); + verify(snapshots,never()).insert(any(SelfopInventorySnapshot.class));verify(profiles,never()).insert(any(SelfopSkuProfile.class));assertEquals("READY",run.getStatus()); + } + @Test void publishedReferenceMustAlsoBeBusinessPurpose() { + run.setStatus("VALIDATED");run.setBasePublishedRunId(9L);scope.setPublishedRunId(9L); + var ref=new SelfopSyncRun();ref.setId(9L);ref.setExecutionPurpose("VALIDATION");ref.setStatus("PUBLISHED");ref.setRunRole("WAREHOUSE_SYNC");ref.setSourceAccount("demo");ref.setWarehouseCode("A");when(runs.selectById(9L)).thenReturn(ref); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.build(1L,10L,"owner",4L)).getMessage()); + verify(snapshots,never()).insert(anyCollection(),anyInt()); + } + @Test void liveUnitConfigCannotOverrideFixedRunConfiguration() { + properties.setQuantityUnit(null); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); + prepare(); properties.setQuantityUnit("PCS"); run.setScopeSnapshotJson("{\"quantityUnit\":null}"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + } + + private void scheduledObservation() { + task.setTaskRole("WAREHOUSE_SYNC"); task.setTaskType("SCHEDULED"); + task.setPlannedAtUtc(run.getFetchStartedAtUtc()); + } + + @Test void firstScheduledSuccessSelectsBeijingDayInPublicationTransaction() { + scheduledObservation(); + // Beijing midnight is the previous UTC date; host timezone is irrelevant. + task.setPlannedAtUtc(LocalDateTime.of(2026, 10, 11, 16, 0)); + run.setFetchStartedAtUtc(task.getPlannedAtUtc().plusMinutes(1)); + run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc().plusMinutes(1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate()); + assertEquals("DAILY_SCHEDULED_FIRST_V1", run.getInventorySampleRuleVersion()); + var order = inOrder(coordination, runs, scopes, tasks); + order.verify(coordination).lockScope(1L); + order.verify(runs).dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12)); + order.verify(scopes).updateById(scope); + order.verify(runs).updateById(run); + order.verify(tasks).updateById(task); + } + + @Test void laterSameDaySuccessPublishesLatestWithoutReplacingSelectedRepresentative() { + scheduledObservation(); + var selected = new SelfopSyncRun(); selected.setId(9L); + selected.setStatus("PUBLISHED"); selected.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + selected.setInventoryDailySampleDate(LocalDate.of(2026, 10, 12)); + when(runs.dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12))).thenReturn(selected); + service.publish(1L, 10L, "owner", 4L); + assertEquals(10L, scope.getPublishedRunId()); + assertNull(run.getInventoryDailySampleDate()); assertNull(run.getInventorySampleRuleVersion()); + assertEquals(LocalDate.of(2026, 10, 12), selected.getInventoryDailySampleDate()); + } + + @Test void failedAttemptDoesNotOccupyDayAndSuccessfulRetryMaySelectIt() { + scheduledObservation(); run.setStatus("FAILED"); + assertThrows(IllegalStateException.class, () -> service.publish(1L, 10L, "owner", 4L)); + assertNull(run.getInventoryDailySampleDate()); + verify(runs, never()).dailyInventorySample(anyString(), anyString(), any()); + // A new attempt uses a new run; the failed attempt remains immutable. + var failed = run; + run = new SelfopSyncRun(); org.springframework.beans.BeanUtils.copyProperties(failed, run); + run.setId(11L); run.setStatus("READY"); run.setAttemptNo(2); + when(coordination.lockRun(11L)).thenReturn(run); + service.publish(1L, 11L, "owner", 4L); + assertEquals("FAILED", failed.getStatus()); assertNull(failed.getInventoryDailySampleDate()); + assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate()); + } + + @Test void crossMidnightAndOldSlotCatchupRemainCurrentInventoryButNeverDailySamples() { + scheduledObservation(); + run.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 15, 59)); + run.setFetchFinishedAtUtc(LocalDateTime.of(2026, 10, 12, 16, 1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); assertNull(run.getInventoryDailySampleDate()); + prepare(); scheduledObservation(); + task.setPlannedAtUtc(task.getPlannedAtUtc().minusDays(1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals("PUBLISHED", run.getStatus()); assertNull(run.getInventoryDailySampleDate()); + verify(runs, never()).dailyInventorySample(anyString(), anyString(), any()); + } + + @Test void manualAndIncompleteEvidenceCannotAcquireDailyMarker() { + scheduledObservation(); task.setTaskType("MANUAL"); + service.publish(1L, 10L, "owner", 4L); + assertNull(run.getInventoryDailySampleDate()); + prepare(); scheduledObservation(); run.setFetchFinishedAtUtc(null); + assertNull(InventorySnapshotService.eligibleSampleDate(task, run)); + run.setFetchFinishedAtUtc(run.getFetchStartedAtUtc()); run.setUniqueObjectCount(1L); + assertNull(InventorySnapshotService.eligibleSampleDate(task, run)); + run.setUniqueObjectCount(0L); run.setExecutionPurpose("VALIDATION"); + assertNull(InventorySnapshotService.eligibleSampleDate(task, run)); + } + + @Test void publishedRecoveryRetainsMarkerAndNeverRepublishesEvenWhenSuperseded() { + scheduledObservation(); service.publish(1L, 10L, "owner", 4L); + when(runs.dailyInventorySample("demo", "A", LocalDate.of(2026, 10, 12))).thenReturn(run); + scope.setPublishedRunId(11L); // Another successful run has become current. + service.publish(1L, 10L, "owner", 4L); + assertEquals(11L, scope.getPublishedRunId()); + assertEquals(LocalDate.of(2026, 10, 12), run.getInventoryDailySampleDate()); + verify(scopes, times(1)).updateById(scope); verify(runs, times(1)).updateById(run); + } + + @Test void unmarkedPublishedRecoveryCannotRetroactivelyElectOldRun() { + scheduledObservation(); + var previous = new SelfopSyncRun(); previous.setId(9L); + previous.setStatus("PUBLISHED"); previous.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + when(runs.dailyInventorySample(anyString(), anyString(), any())).thenReturn(previous); + service.publish(1L, 10L, "owner", 4L); + clearInvocations(runs); + when(runs.dailyInventorySample(anyString(), anyString(), any())).thenReturn(null); + service.publish(1L, 10L, "owner", 4L); + assertNull(run.getInventoryDailySampleDate()); + verify(runs, never()).dailyInventorySample(anyString(), anyString(), any()); + verify(runs, never()).updateById(any(SelfopSyncRun.class)); + } + + @Test void sameQuantityNextDayStillRecordsNewObservationAndRejectsPremarkedCandidate() { + scheduledObservation(); + task.setPlannedAtUtc(task.getPlannedAtUtc().plusDays(1)); + run.setFetchStartedAtUtc(run.getFetchStartedAtUtc().plusDays(1)); + run.setFetchFinishedAtUtc(run.getFetchFinishedAtUtc().plusDays(1)); + service.publish(1L, 10L, "owner", 4L); + assertEquals(LocalDate.of(2026, 10, 13), run.getInventoryDailySampleDate()); + prepare(); scheduledObservation(); run.setInventoryDailySampleDate(LocalDate.of(2026, 10, 12)); + assertEquals("SELFOP_INVENTORY_UNPUBLISHED_SAMPLE", assertThrows(IllegalStateException.class, + () -> service.publish(1L, 10L, "owner", 4L)).getMessage()); + } + + @Test void recoveryRejectsPartiallyPersistedSampleEvidence() { + service.publish(1L, 10L, "owner", 4L); + run.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + assertEquals("SELFOP_INVENTORY_SAMPLE_INVALID", assertThrows(IllegalStateException.class, + () -> service.publish(1L, 10L, "owner", 4L)).getMessage()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java new file mode 100644 index 000000000..5fc54a263 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.*; +import java.sql.*; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Actual mapper SQL against an offline synthetic database, not a mocked aggregate. */ +public class OutboundAggregateMapperTest { + JdbcDataSource data; SqlSession session; OutboundAggregateMapper mapper; + @BeforeEach void setup() throws Exception { + data=new JdbcDataSource(); data.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1"); + sql("CREATE TABLE ll_selfop_outbound_order (id BIGINT PRIMARY KEY,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sales_eligible INT,outbound_at_utc TIMESTAMP,business_date DATE,source_timezone VARCHAR(64),business_timezone VARCHAR(64),last_observation_sequence BIGINT)"); + sql("CREATE TABLE ll_selfop_outbound_item (id BIGINT PRIMARY KEY,order_id BIGINT,sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),actual_quantity DECIMAL(20,6))"); + sql("CREATE TABLE ll_selfop_sync_stage (id BIGINT PRIMARY KEY,run_id BIGINT,warehouse_code VARCHAR(64),object_type VARCHAR(32),validation_status VARCHAR(24),observation_sequence BIGINT,observed_at_utc TIMESTAMP)"); + sql("CREATE TABLE ll_selfop_sync_run (id BIGINT PRIMARY KEY,source_account VARCHAR(64),task_id BIGINT,execution_purpose VARCHAR(24))"); + sql("CREATE TABLE ll_selfop_sync_task (id BIGINT PRIMARY KEY,execution_purpose VARCHAR(24),validation_root_task_id BIGINT,scope_id BIGINT,scope_hash VARCHAR(64),apply_state VARCHAR(24),query_from_utc TIMESTAMP,query_until_utc TIMESTAMP,routing_manifest_json VARCHAR)"); + sql("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonExtract'"); + sql("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonUnquote'"); + sql("CREATE TABLE ll_selfop_sku_daily_sales (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,sales_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))"); + sql("CREATE TABLE ll_selfop_sku_profile (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,profile_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),first_seen_at_utc TIMESTAMP,earliest_evidence_at_utc TIMESTAMP,evidence_type VARCHAR(24),evidence_run_id BIGINT,last_seen_run_id BIGINT)"); + Configuration config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data)); + config.setMapUnderscoreToCamelCase(true); config.setLocalCacheScope(LocalCacheScope.STATEMENT); config.addMapper(OutboundAggregateMapper.class); + session=new SqlSessionFactoryBuilder().build(config).openSession(true); mapper=session.getMapper(OutboundAggregateMapper.class); + } + @AfterEach void close() { if(session!=null) session.close(); } + void sql(String query) throws Exception {try(Connection c=data.getConnection();Statement s=c.createStatement()){s.execute(query);}} + void order(long id,String account,String customer,String warehouse,String sku,int eligible,String time,int quantity) throws Exception { + sql("INSERT INTO ll_selfop_sync_task (id,execution_purpose,validation_root_task_id) VALUES ("+id+",'BUSINESS',NULL)"); + sql("INSERT INTO ll_selfop_sync_run VALUES ("+id+",'"+account+"',"+id+",'BUSINESS')"); + sql("INSERT INTO ll_selfop_sync_stage VALUES ("+id+","+id+",'"+warehouse+"','OUTBOUND_ORDER','VALID',"+id+",'2026-10-12 01:00:00')"); + sql("INSERT INTO ll_selfop_outbound_order VALUES ("+id+",'"+account+"','"+customer+"','"+warehouse+"',"+eligible+",'"+time+"','2026-10-01','Asia/Shanghai','Asia/Shanghai',"+id+")"); + sql("INSERT INTO ll_selfop_outbound_item VALUES ("+id+","+id+",'"+sku+"','item','PCS',"+quantity+")"); + } + @Test void aggregatesDistinctOwnersAndRetractsCancelledMigratedAndCorrectedEvidence() throws Exception { + order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2); + order(2,"demo","C1","A","SKU",1,"2026-10-02 00:00:00",3); + order(3,"demo","C2","A","SKU",1,"2026-10-01 00:00:00",7); + order(4,"demo","C1","A","OLD",0,"2026-09-01 00:00:00",99); + order(5,"demo","C1","B","MOVED",1,"2026-09-01 00:00:00",99); + assertEquals(0,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS")); + var daily=mapper.dailyBatch("demo","A",null,null,null); assertEquals(2,daily.size()); + assertEquals(0,new java.math.BigDecimal("5").compareTo(daily.get(0).getOutboundQuantity())); assertEquals(2L,daily.get(0).getOutboundOrderCount()); + var profiles=mapper.profileBatch("demo","A",null,null); assertEquals(2,profiles.size()); + assertEquals(LocalDateTime.of(2026,10,1,0,0),profiles.get(0).getEarliestEvidenceAtUtc()); assertEquals(1L,profiles.get(0).getEvidenceRunId()); + sql("UPDATE ll_selfop_outbound_order SET sales_eligible=0 WHERE id=1"); + var corrected=mapper.profileBatch("demo","A",null,null); assertEquals(LocalDateTime.of(2026,10,2,0,0),corrected.get(0).getEarliestEvidenceAtUtc()); assertEquals(2L,corrected.get(0).getEvidenceRunId()); + assertEquals(1,mapper.profileBatch("demo","A","C1","SKU").size()); + } + @Test void validationEvidenceAndAccountSequenceCollisionAreFailClosed() throws Exception { + order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2); + order(2,"other","C1","A","SKU",1,"2026-10-01 00:00:00",9); + sql("UPDATE ll_selfop_sync_stage SET observation_sequence=1 WHERE id=2"); + assertEquals(0,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS")); + sql("UPDATE ll_selfop_sync_run SET execution_purpose='VALIDATION' WHERE id=1"); + assertEquals(1,mapper.invalidFacts("demo","A","Asia/Shanghai","Asia/Shanghai","PCS")); + assertTrue(mapper.profileBatch("demo","A",null,null).isEmpty()); + } + @Test void databaseResultPagesAndMultiRowInsertsAreBounded() throws Exception { + // Insert synthetic local orders without any provider I/O. + try(Connection c=data.getConnection();PreparedStatement statement=c.prepareStatement("INSERT INTO ll_selfop_outbound_order VALUES (?,'demo','C1','A',1,'2026-10-01 00:00:00','2026-10-01','Asia/Shanghai','Asia/Shanghai',?)"); + PreparedStatement item=c.prepareStatement("INSERT INTO ll_selfop_outbound_item VALUES (?, ?, ?, 'item','PCS',1)")) { + for(long id=1;id<=501;id++) {statement.setLong(1,id); statement.setLong(2,id);statement.addBatch();item.setLong(1,id); item.setLong(2,id);item.setString(3,String.format("SKU%04d",id));item.addBatch();} + statement.executeBatch(); item.executeBatch(); + } + var first=mapper.dailyBatch("demo","A",null,null,null); assertEquals(500,first.size()); + var last=first.get(499); var second=mapper.dailyBatch("demo","A",last.getCustomerCode(),last.getSkuCode(),last.getBusinessDate());assertEquals(1,second.size()); + long id=1; for(var row:first) {row.setId(id++);row.setSalesRunId(900L);row.setSourceAccount("demo");row.setWarehouseCode("A");row.setCreatedAt(LocalDateTime.now());row.setUpdatedAt(row.getCreatedAt());} + assertEquals(500,mapper.insertDaily(first)); + try(Connection c=data.getConnection();ResultSet result=c.createStatement().executeQuery("SELECT COUNT(*) FROM ll_selfop_sku_daily_sales")){assertTrue(result.next());assertEquals(500,result.getLong(1));} + } + public static String jsonExtract(String value,String path) throws Exception { + if(value==null)return null; + return new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).path("directoryComplete").asText(); + } + public static String jsonUnquote(String value) {return value;} + @Test void databaseCoverageJoinsOverlapsAndStopsAtFirstUnprovedGap() throws Exception { + sql("INSERT INTO ll_selfop_sync_task (id,execution_purpose,scope_id,scope_hash,apply_state,query_from_utc,query_until_utc,routing_manifest_json) VALUES (1,'BUSINESS',1,'h','APPLIED','2026-10-01 00:00:00','2026-10-03 00:00:00','{\"directoryComplete\":true}'),(2,'BUSINESS',1,'h','APPLIED','2026-10-02 00:00:00','2026-10-04 00:00:00','{\"directoryComplete\":true}'),(3,'BUSINESS',1,'h','APPLIED','2026-10-05 00:00:00','2026-10-06 00:00:00','{\"directoryComplete\":true}'),(4,'VALIDATION',1,'h','APPLIED','2026-10-04 00:00:00','2026-10-05 00:00:00','{\"directoryComplete\":true}')"); + var coverage=mapper.contiguousCoverage(1L,"h",LocalDateTime.of(2026,10,1,0,0),null,null); + assertEquals(LocalDateTime.of(2026,10,1,0,0),coverage.getCoveredFromUtc()); + assertEquals(LocalDateTime.of(2026,10,4,0,0),coverage.getCoveredUntilUtc()); + sql("UPDATE ll_selfop_sync_task SET execution_purpose='BUSINESS' WHERE id=4"); + assertEquals(LocalDateTime.of(2026,10,6,0,0),mapper.contiguousCoverage(1L,"h",LocalDateTime.of(2026,10,1,0,0),null,null).getCoveredUntilUtc()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java new file mode 100644 index 000000000..4a8a2c886 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java @@ -0,0 +1,115 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class OutboundCandidateBuilderTest { + final OutboundLockMapper locks=mock(OutboundLockMapper.class); + final OutboundFactService facts=mock(OutboundFactService.class); + final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + final SelfopSkuDailySalesMapper daily=mock(SelfopSkuDailySalesMapper.class); + final SelfopSkuProfileMapper profiles=mock(SelfopSkuProfileMapper.class); + final OutboundAggregateMapper aggregates=mock(OutboundAggregateMapper.class); + final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class); + final OutboundCandidateTransaction transaction=new OutboundCandidateTransaction(locks,facts,runs,tasks,scopes,daily,profiles,aggregates,new SelfopAnalyticsProperties()); + final OutboundCandidateBuilder builder=new OutboundCandidateBuilder(transaction); + SelfopSyncScope scope; SelfopSyncRun run; SelfopSyncTask task; + @BeforeEach void setup() { + scope=new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); scope.setDataFamily("OUTBOUND"); + scope.setScopeHash("scope"); scope.setLeaseOwner("worker"); scope.setFenceToken(2L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); + scope.setFactRevision(5L); scope.setSourceTimezone("Asia/Shanghai"); scope.setBusinessTimezone("Asia/Shanghai"); scope.setScopeJson("{\"quantityUnit\":\"PCS\"}"); + scope.setInitialFromUtc(LocalDateTime.of(2026,9,12,0,0)); + run=new SelfopSyncRun(); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash("scope"); + run.setExecutionPurpose("BUSINESS"); run.setScopeSnapshotJson(scope.getScopeJson()); + run.setAttemptNo(1); + run.setFenceToken(2L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("FACTS_APPLIED"); + task=new SelfopSyncTask(); task.setId(20L); task.setExecutionPurpose("BUSINESS"); task.setApplyState("APPLIED"); + task.setScopeId(1L);task.setSourceAccount("demo");task.setWarehouseCode("A");task.setScopeHash("scope"); + task.setQueryFromUtc(scope.getInitialFromUtc()); task.setQueryUntilUtc(LocalDateTime.of(2026,10,12,0,0)); + SelfopRoutingManifest manifest=new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); task.setRoutingManifestJson(SelfopSupport.json(manifest)); + when(locks.scope(1L)).thenReturn(scope); when(locks.run(10L)).thenReturn(run); when(locks.task(20L)).thenReturn(task); + SelfopSyncRun coverage=new SelfopSyncRun();coverage.setCoveredFromUtc(task.getQueryFromUtc());coverage.setCoveredUntilUtc(task.getQueryUntilUtc()); + when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(coverage); + } + @Test void fullRebuildWithNoCurrentFactsRetractsAllRemovedEvidence() { + builder.build(1L,10L,"worker",2); + verify(profiles).delete(any()); verify(aggregates,never()).insertProfiles(any()); + verify(aggregates,never()).insertDaily(any()); verify(facts,atLeastOnce()).assertNoBarrier(scope); + assertEquals(5L,run.getCalculatedFactRevision()); assertEquals("READY",run.getStatus()); + assertNotNull(run.getBuildStartedAtUtc()); assertEquals(LocalDate.of(2026,9,13),run.getCompleteDayFrom()); + assertEquals(LocalDate.of(2026,10,12),run.getCompleteDayUntil()); assertNull(scope.getPublishedRunId()); + } + @Test void pagesAreKeysetBoundedAndWrittenInBulk() { + List rows=new ArrayList<>(); + for(int n=0;n<500;n++) { SelfopSkuDailySales row=new SelfopSkuDailySales(); row.setCustomerCode("c"); row.setSkuCode(String.format("%03d",n)); row.setBusinessDate(LocalDate.of(2026,10,1)); rows.add(row); } + SelfopSkuDailySales tail=new SelfopSkuDailySales(); tail.setCustomerCode("c"); tail.setSkuCode("500"); tail.setBusinessDate(LocalDate.of(2026,10,1)); + when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(rows); + when(aggregates.dailyBatch("demo","A","c","499",tail.getBusinessDate())).thenReturn(List.of(tail)); + when(aggregates.dailyBatch("demo","A","c","500",tail.getBusinessDate())).thenReturn(List.of()); + when(daily.selectCount(any())).thenReturn(501L); + builder.build(1L,10L,"worker",2); + verify(aggregates).insertDaily(rows); verify(aggregates).insertDaily(List.of(tail)); + verify(daily,never()).insert(any(SelfopSkuDailySales.class)); + assertTrue(rows.stream().allMatch(r->r.getSalesRunId().equals(10L) && r.getId()!=null)); + } + @Test void unchangedFactsButAdvancedCoverageCannotReuseZeroSales() { + scope.setPublishedRunId(9L); scope.setPublishedFactRevision(5L); + SelfopSyncRun published=new SelfopSyncRun(); published.setId(9L); published.setScopeId(1L); published.setScopeHash("scope"); + published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L); + published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1)); + published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc()); + published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published); + assertNull(builder.reusable(1L,10L,"worker",2)); + published.setCoveredUntilUtc(task.getQueryUntilUtc()); assertEquals(9L,builder.reusable(1L,10L,"worker",2)); + verifyNoInteractions(daily,profiles); + } + @Test void validationBuildAndInvalidEvidenceFailBeforeDeletingCandidate() { + run.setExecutionPurpose("VALIDATION"); assertThrows(IllegalStateException.class,()->builder.build(1L,10L,"worker",2)); + run.setExecutionPurpose("BUSINESS"); when(aggregates.invalidFacts(anyString(),anyString(),anyString(),anyString(),anyString())).thenReturn(1L); + assertThrows(IllegalStateException.class,()->builder.build(1L,10L,"worker",2)); verifyNoInteractions(daily,profiles); + } + @Test void partialBatchCommitsCheckpointAndRevisionChangePreventsReady() { + transaction.begin(1L,10L,"worker",2); + SelfopSkuDailySales row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("s");row.setBusinessDate(LocalDate.of(2026,10,1)); + when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(List.of(row)); + var checkpoint=transaction.batch(1L,10L,"worker",2); + assertEquals(1,checkpoint.dailyRows());assertEquals("BUILDING",run.getStatus());assertNotNull(task.getProgressUpdatedAtUtc());assertNotNull(run.getHeartbeatAtUtc()); + assertTrue(task.getProgressJson().contains("candidateBuild"));assertNull(scope.getPublishedRunId()); + Map progress=SelfopSupport.read(task.getProgressJson(),Map.class); + assertEquals("BUILD",progress.get("phase"));assertEquals("BATCH",progress.get("unit"));assertEquals(1L,((Number)progress.get("completed")).longValue()); + assertEquals("ATTEMPT_ONLY",progress.get("countBasis"));assertFalse(progress.containsKey("processedCount")); + assertDoesNotThrow(()->Instant.parse((String)progress.get("stageStartedAt")));assertTrue(progress.get("stages") instanceof List); + scope.setFactRevision(6L);assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2)); + assertEquals("BUILDING",run.getStatus());verify(aggregates,times(1)).insertDaily(any()); + } + @Test void crashResumesSameRunCursorWithoutDeletingCommittedRows() { + var started=transaction.begin(1L,10L,"worker",2);clearInvocations(daily,profiles); + assertEquals(started,transaction.begin(1L,10L,"worker",2));verifyNoInteractions(daily,profiles); + scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1)); + assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2)); + } + @Test void observationChangeWithoutRevisionStillAbortsCandidate() { + transaction.begin(1L,10L,"worker",2); + when(aggregates.observationsChangedSince(eq("demo"),eq("A"),any())).thenReturn(1L); + assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2)); + verify(aggregates,never()).insertDaily(any());assertEquals("BUILDING",run.getStatus()); + } + @Test void facadeHasNoLongTransactionAndWorkerMethodsDeclareOwnTransactions() throws Exception { + assertNull(OutboundCandidateBuilder.class.getMethod("build",Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class)); + for(String name:List.of("begin","batch","finish"))assertNotNull(OutboundCandidateTransaction.class.getMethod(name,Long.class,Long.class,String.class,long.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class)); + } + @Test void onlyRoutineFirstCollectionIsCoalesced() { + task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(1); LocalDateTime now=SelfopSupport.utcNow(); + assertFalse(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(29),now)); + assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now.minusMinutes(30),now)); + task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); + task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java new file mode 100644 index 000000000..9e25c2441 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java @@ -0,0 +1,130 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +class OutboundFactServiceTest { + final OutboundLockMapper locks=mock(OutboundLockMapper.class); + final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class); + final SelfopSyncStageMapper stages=mock(SelfopSyncStageMapper.class); + final SelfopOutboundOrderMapper orders=mock(SelfopOutboundOrderMapper.class); + final SelfopOutboundItemMapper items=mock(SelfopOutboundItemMapper.class); + final OutboundFactService service=new OutboundFactService(locks,tasks,runs,scopes,stages,orders,items); + SelfopSyncTask task; SelfopSyncRun run; SelfopSyncStage stage; SelfopSyncScope a,b; SelfopOutboundOrder old; + Map leases; + SelfopPayloads.Order payload; + @BeforeEach void setup() { + a=scope(1L,"A"); b=scope(2L,"B"); leases=Map.of("A",new OutboundFactService.Lease("worker",7),"B",new OutboundFactService.Lease("worker",7)); + task=new SelfopSyncTask(); task.setId(10L); task.setSourceAccount("demo"); task.setScopeId(2L); + task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("WAREHOUSE_SYNC"); + task.setWarehouseCode("B"); task.setScopeHash("scope"); task.setUpstreamTaskId(9L); task.setApplyState("NOT_STARTED"); + run=new SelfopSyncRun(); run.setId(20L); run.setTaskId(10L); run.setScopeId(2L); run.setScopeHash("scope"); run.setFenceToken(7L); + run.setExecutionPurpose("BUSINESS"); + run.setAttemptNo(1); + payload=new SelfopPayloads.Order("demo","owner","B","delivery",null,100,null,"2026-10-11 12:00:00",List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("3"),"piece"))); + stage=new SelfopSyncStage(); stage.setId(30L); stage.setStageBatchId(9L); stage.setObjectType("OUTBOUND_ORDER"); stage.setValidationStatus("VALID"); + stage.setRunId(8L); + SelfopSyncRun sourceRun=new SelfopSyncRun(); sourceRun.setId(8L); sourceRun.setTaskId(9L); sourceRun.setExecutionPurpose("BUSINESS"); + SelfopSyncTask sourceTask=new SelfopSyncTask(); sourceTask.setId(9L); sourceTask.setExecutionPurpose("BUSINESS"); + sourceRun.setSourceAccount("demo"); sourceTask.setSourceAccount("demo"); + when(runs.selectById(8L)).thenReturn(sourceRun); when(tasks.selectById(9L)).thenReturn(sourceTask); + stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","owner","delivery"))); stage.setWarehouseCode("B"); stage.setObservationSequence(2L); + stage.setPayloadJson(SelfopSupport.json(payload)); stage.setObservedAtUtc(SelfopSupport.utcNow()); + stage.setContentHash(SelfopSupport.hash(payload)); + SelfopRoutingManifest manifest=new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); + SelfopRoutingManifest.Entry entry=new SelfopRoutingManifest.Entry(); entry.setEffectiveStageId(30L); manifest.getObjects().put(stage.getObjectKeyHash(),entry); + task.setRoutingManifestJson(SelfopSupport.json(manifest)); + old=new SelfopOutboundOrder(); old.setId(40L); old.setWarehouseCode("A"); old.setSourceAccount("demo"); old.setCustomerCode("owner"); + old.setDeliveryNo("delivery"); old.setRevisionNo(1L); old.setLastObservationSequence(1L); old.setContentHash("old"); + old.setBusinessDate(LocalDate.of(2026,10,10)); old.setOutboundAtUtc(LocalDateTime.of(2026,10,10,12,0)); old.setSalesEligible(1); + when(tasks.selectById(10L)).thenReturn(task); when(locks.task(10L)).thenReturn(task); when(locks.run(20L)).thenReturn(run); + when(stages.selectById(30L)).thenReturn(stage); when(locks.stage(30L)).thenReturn(stage); + when(orders.selectOne(any())).thenReturn(old); when(locks.order("demo","owner","delivery")).thenReturn(old); + when(locks.warehouse("demo","A")).thenReturn(a); when(locks.warehouse("demo","B")).thenReturn(b); + when(locks.newestObservation("demo",stage.getObjectKeyHash())).thenReturn(2L); + when(items.selectList(any())).thenReturn(List.of()); when(tasks.selectCount(any())).thenReturn(0L); + } + private SelfopSyncScope scope(Long id,String warehouse) { + SelfopSyncScope s=new SelfopSyncScope(); s.setId(id); s.setWarehouseCode(warehouse); s.setSourceAccount("demo"); s.setDataFamily("OUTBOUND"); + s.setScopeHash("scope"); s.setSourceTimezone("UTC"); s.setBusinessTimezone("UTC"); s.setScopeJson("{\"quantityUnit\":\"piece\"}"); + s.setLeaseOwner("worker"); s.setFenceToken(7L); s.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); s.setFactRevision(0L); return s; + } + @Test void crossWarehouseRevisionInvalidatesBothAndLeavesBarrierUntilFinish() { + service.merge(10L,20L,List.of(30L),leases); + assertEquals("B",old.getWarehouseCode()); assertEquals(1L,a.getFactRevision()); assertEquals(1L,b.getFactRevision()); + assertEquals(1,a.getRebuildRequired()); assertEquals(1,b.getRebuildRequired()); assertEquals("APPLYING",task.getApplyState()); + verify(tasks,times(2)).insert(any(SelfopSyncTask.class)); + assertTrue(task.getAffectedWarehouseCodesJson().contains("A")); assertTrue(task.getAffectedWarehouseCodesJson().contains("B")); + verify(stages).update(isNull(),any()); + } + @Test void expiredOldWorkerCannotWriteFactOrBarrier() { + a.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1)); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); verify(tasks,never()).updateById(any(SelfopSyncTask.class)); + } + @Test void newerPersistedDetailForcesRecheckInsteadOfCompletingOldSlice() { + when(locks.newestObservation("demo",stage.getObjectKeyHash())).thenReturn(3L); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); + } + @Test void cancellationRemovesOldDateAndEligibility() { + SelfopPayloads.Order cancelled=new SelfopPayloads.Order("demo","owner","B","delivery",null,99,null,null,payload.items()); + stage.setPayloadJson(SelfopSupport.json(cancelled)); stage.setContentHash(SelfopSupport.hash(cancelled)); + service.merge(10L,20L,List.of(30L),leases); + assertNull(old.getOutboundAtUtc()); assertNull(old.getBusinessDate()); assertEquals(0,old.getSalesEligible()); + verify(items).delete(any()); + } + @Test void deadTaskRetainsCrossTaskPublicationBarrier() { + task.setApplyState("BLOCKED"); task.setStatus("DEAD"); + when(locks.barriers("demo","B")).thenReturn(List.of(task)); + assertThrows(IllegalStateException.class,()->service.assertNoBarrier(b)); + } + @Test void incompleteRoutingSetCannotClearBarrier() { + assertThrows(IllegalStateException.class,()->service.finish(10L,20L,List.of(),leases)); + verify(tasks,never()).updateById(any(SelfopSyncTask.class)); + } + @Test void validationCannotMergeOrCompleteFormalTask() { + task.setExecutionPurpose("VALIDATION"); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + assertThrows(IllegalStateException.class,()->service.finish(10L,20L,List.of(30L),leases)); + verifyNoInteractions(scopes); verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); + } + @Test void businessCannotReuseValidationDetailStage() { + SelfopSyncRun validation=new SelfopSyncRun(); validation.setExecutionPurpose("VALIDATION"); + when(runs.selectById(8L)).thenReturn(validation); + assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)); + verify(orders,never()).updateById(any(SelfopOutboundOrder.class)); verify(items,never()).delete(any()); + } + @Test void mergeCheckpointCountsPersistedEffectiveReferencesOnceAcrossReplay() { + service.merge(10L,20L,List.of(30L),leases); + SelfopRoutingManifest saved=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + assertEquals(30L,saved.getObjects().get(stage.getObjectKeyHash()).getAppliedStageId()); + Map progress=SelfopSupport.read(task.getProgressJson(),Map.class); + assertEquals("MERGE",progress.get("phase"));assertEquals("ORDER",progress.get("unit")); + assertEquals(1L,((Number)progress.get("completed")).longValue());assertEquals(1L,((Number)progress.get("total")).longValue()); + service.merge(10L,20L,List.of(30L),leases); + assertEquals(1L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue()); + assertEquals(1L,a.getFactRevision());assertEquals(1L,b.getFactRevision()); + } + @Test void priorAppliedReferencesCarryForwardButOutdatedReferencesAreNotCompleted() { + SelfopRoutingManifest manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); + SelfopRoutingManifest.Entry prior=new SelfopRoutingManifest.Entry();prior.setEffectiveStageId(31L);prior.setAppliedStageId(31L); + manifest.getObjects().put("other-key",prior);task.setRoutingManifestJson(SelfopSupport.json(manifest)); + service.merge(10L,20L,List.of(30L),leases); + assertEquals(2L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue()); + manifest=SelfopSupport.read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);manifest.getObjects().get("other-key").setEffectiveStageId(32L);task.setRoutingManifestJson(SelfopSupport.json(manifest)); + service.merge(10L,20L,List.of(30L),leases); + assertEquals(1L,((Number)SelfopSupport.read(task.getProgressJson(),Map.class).get("completed")).longValue()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java new file mode 100644 index 000000000..a7a0f039f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.selfoperated.analytics.outbound; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class OutboundPolicyTest { + private SelfopPayloads.Order order(String time,Integer status,List items) { + return new SelfopPayloads.Order("demo","owner","warehouse","delivery",null,status,"2020-01-01 00:00:00",time,items); + } + @Test void actualSkuLinesAreCombinedWithoutForecastOrPackageQuantities() { + SelfopPayloads.Order p=order("2026-10-11 09:00:00",100,List.of( + new SelfopPayloads.Item("sku","name",new BigDecimal("2"),"piece"), + new SelfopPayloads.Item("sku","name",new BigDecimal("3"),"piece"))); + assertEquals(new BigDecimal("5"),OutboundPolicy.normalize(p).items().get(0).actualQuantity()); + assertEquals(1,OutboundPolicy.normalize(p).items().size()); + } + @Test void missingQuantityAndMixedUnitCannotBecomeZero() { + assertThrows(IllegalStateException.class,()->OutboundPolicy.normalize(order(null,100,List.of(new SelfopPayloads.Item("sku",null,null,"piece"))))); + assertThrows(IllegalStateException.class,()->OutboundPolicy.normalize(order(null,100,List.of( + new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"piece"),new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"box"))))); + } + @Test void unknownSourceTimezoneNeverUsesWarehouseOrSystemDefault() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setBusinessTimezone("Asia/Shanghai"); + assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-10-11 09:00:00",100,List.of()),scope)); + } + @Test void dstOverlapAndGapAreExplicitlyRejected() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("America/New_York"); + assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-11-01 01:30:00",100,List.of()),scope)); + assertThrows(IllegalStateException.class,()->OutboundPolicy.outboundUtc(order("2026-03-08 02:30:00",100,List.of()),scope)); + } + @Test void cancellationWithQuantityAndNoOutboundDateHasNoSalesTime() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + assertNull(OutboundPolicy.outboundUtc(order(null,99,List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"piece"))),scope)); + } + @Test void rollingMonthDoesNotInventFirstPartialDayCoverage() { + var days=OutboundPolicy.completeDays(LocalDateTime.of(2026,9,12,0,0),LocalDateTime.of(2026,10,12,0,0),"Asia/Shanghai"); + assertEquals(LocalDate.of(2026,9,13),days.from()); assertEquals(LocalDate.of(2026,10,12),days.until()); + assertTrue(LocalDate.of(2026,10,11).isBefore(days.until())); + } + @Test void disconnectedBackfillDoesNotJumpOverCoverageGap() { + LocalDateTime start=LocalDateTime.of(2026,9,12,0,0); + var result=OutboundCandidateBuilder.contiguous(start,List.of( + new OutboundCandidateBuilder.Coverage(start,start.plusDays(1)), + new OutboundCandidateBuilder.Coverage(start.plusDays(2),start.plusDays(3)))); + assertEquals(start.plusDays(1),result.until()); + } + @Test void newDiscoverySliceUsesHalfOpenUtcBoundaries() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1); + assertFalse(OutboundPolicy.belongsToSlice(order("2026-10-11 11:59:59",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-11 12:00:00",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-11 12:59:59",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertFalse(OutboundPolicy.belongsToSlice(order("2026-10-11 13:00:00",100,List.of()),scope,from,until,false,"SCHEDULED")); + } + @Test void endpointReturnedByAdjacentSourceSlicesBelongsOnlyToTheSecondInternalSlice() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), middle=from.plusHours(1), until=middle.plusHours(1); + var p=order("2026-10-11 13:00:00",100,List.of()); + assertFalse(OutboundPolicy.belongsToSlice(p,scope,from,middle,false,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(p,scope,middle,until,false,"SCHEDULED")); + } + @Test void correctedKnownDateAndCancellationRemainApplicableOutsideTheSlice() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setSourceTimezone("UTC"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1); + var corrected=order("2026-10-01 00:00:00",100,List.of()); + assertTrue(OutboundPolicy.belongsToSlice(corrected,scope,from,until,true,"SCHEDULED")); + assertTrue(OutboundPolicy.belongsToSlice(corrected,scope,from,until,false,"RECHECK")); + assertTrue(OutboundPolicy.belongsToSlice(order(null,99,List.of()),scope,from,until,false,"SCHEDULED")); + } + @Test void unconfirmedTimezoneRetainsEvidenceWithoutAssumingUtc() { + SelfopSyncScope scope=new SelfopSyncScope(); scope.setBusinessTimezone("Asia/Shanghai"); + LocalDateTime from=LocalDateTime.of(2026,10,11,12,0), until=from.plusHours(1); + assertTrue(OutboundPolicy.belongsToSlice(order("2026-10-01 00:00:00",100,List.of()),scope,from,until,false,"SCHEDULED")); + assertNull(scope.getSourceTimezone()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java new file mode 100644 index 000000000..0310630cc --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java @@ -0,0 +1,270 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */ +class AnalyticsQueryServiceTest { + private static final String SH="a".repeat(64), IH="b".repeat(64); + private static final LocalDate D=LocalDate.of(2026,10,12); + private static final Instant NOW=Instant.parse("2026-10-12T04:00:00Z"); + private AnalyticsReadMapper mapper; + private AnalyticsQueryService service; + @BeforeEach void setUp(){ + mapper=mock(AnalyticsReadMapper.class); service=at(NOW); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); + when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); + when(mapper.count(any())).thenReturn(1L); + when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40))))); + when(mapper.inventoryCount(any(),any())).thenReturn(1L); + doAnswer(inv->List.of(inventory(true,"300",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),time(D.minusDays(40)),inv.getArgument(0,AnalyticsSqlRequest.class).complete30()?"150":null))).when(mapper).inventoryPage(any(),any()); + when(mapper.excluded(any())).thenReturn(List.of()); + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40)))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(35)))); + when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of()); + } + @Test void summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){ + when(mapper.count(any())).thenReturn(240L); + var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null)); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); + verify(mapper).count(cap.capture()); verify(mapper).page(cap.capture()); + assertEquals(cap.getAllValues().get(0),cap.getAllValues().get(1)); + var p=cap.getValue(); assertEquals("SUMMARY",p.kind()); assertEquals(11,p.salesRun()); assertEquals(22,p.inventoryRun()); + assertEquals(D.minusDays(2),p.from()); assertEquals(D.plusDays(1),p.until()); + assertEquals("demo",p.account()); assertEquals("W",p.warehouse()); assertNull(p.customer()); + assertEquals(20,p.size()); assertEquals(0,p.offset()); assertEquals(240,response.total()); assertEquals(12,response.pages()); + verify(mapper,never()).profile(anyLong(),anyString(),anyString(),anyString(),anyString()); + verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void twoZeroAndTodayThreeKeepFormalNullObservedFiveAndAscendingWholeAxis(){ + when(mapper.daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1))).thenReturn(List.of( + new Daily("C","SKU",D,n("3"),1L,"PCS"),new Daily("C","SKU",D.minusDays(2),n("2"),1L,"PCS"))); + var response=service.dailySales(chart("LAST_3_DAYS",null,null,"C","SKU")); + assertEquals(List.of(D.minusDays(2),D.minusDays(1),D),response.points().stream().map(AnalyticsResponses.Point::businessDate).toList()); + assertEquals(List.of("COMPLETE","COMPLETE","PARTIAL"),response.points().stream().map(AnalyticsResponses.Point::dataStatus).toList()); + assertEquals(n("2"),response.points().get(0).outboundQuantity()); assertEquals(BigDecimal.ZERO,response.points().get(1).outboundQuantity()); + assertNull(response.points().get(2).outboundQuantity()); assertEquals(n("3"),response.points().get(2).observedQuantity()); + assertNull(response.summary().outboundQuantity()); assertEquals(n("5"),response.summary().observedQuantity()); + assertEquals("PARTIAL",response.summary().dataStatus()); assertEquals(2,response.summary().completeDayCount()); + assertEquals(1,response.summary().partialDayCount()); assertEquals("PCS",response.summary().quantityUnit()); + verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1)); + verify(mapper,never()).page(any()); verify(mapper,never()).count(any()); + var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU")).records().get(0); + assertEquals(response.summary().observedQuantity(),summary.observedQuantity()); + assertEquals(response.summary().outboundQuantity(),summary.outboundQuantity()); assertEquals(response.summary().dataStatus(),summary.dataStatus()); + } + @Test void dailyLimitIsInclusive366AndRejects367BeforeReadingBusinessRows(){ + assertEquals(366,service.dailySales(chart("CUSTOM",D.minusDays(365),D,"C","SKU")).points().size()); + verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(365),D.plusDays(1)); clearInvocations(mapper); + assertThrows(BizException.class,()->service.dailySales(chart("CUSTOM",D.minusDays(366),D,"C","SKU"))); + verify(mapper,never()).profile(anyLong(),anyString(),anyString(),anyString(),anyString()); + verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void dailyRequiresExactOwnerSkuAndRejectsMissingCandidate(){ + assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,null,"SKU"))); + assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,"C",null))); + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(null); when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(null); + assertThrows(BizException.class,()->service.dailySales(chart("TODAY",null,null,"C","SKU"))); + verify(mapper,never()).daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void historyBeforeInventoryEvidenceStaysUnknownAndNoneCannotReviveIt(){ + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(null); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(1)).plusHours(16))); + var response=service.dailySales(chart("CUSTOM",D.minusDays(3),D.minusDays(2),"C","SKU")); + assertNull(response.summary().outboundQuantity()); assertNull(response.summary().observedQuantity()); + assertTrue(response.points().stream().allMatch(p->"UNKNOWN".equals(p.dataStatus())&&p.reasonCodes().contains("SKU_HISTORY_UNKNOWN"))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","NONE",time(D.minusDays(100)))); + assertNull(service.dailySales(chart("TODAY",null,null,"C","SKU")).summary().observedQuantity()); + } + @Test void foreignUnitsConflictsAndUnknownSourceCannotProduceSales(){ + for(String unit:List.of("BOX")){ + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile(unit,"VALID_OUTBOUND",time(D.minusDays(40)))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("BOX","INVENTORY_OBSERVATION",time(D.minusDays(40)))); + var r=service.dailySales(chart("TODAY",null,null,"C","SKU")); + assertNull(r.summary().observedQuantity()); assertTrue(r.summary().reasonCodes().contains("UNIT_UNKNOWN_OR_CONFLICT")); + } + when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40)))); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile(null,"INVENTORY_OBSERVATION",time(D.minusDays(40)))); + when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of(new Daily("C","SKU",D.minusDays(1),n("4"),1L,"PCS"))); + var independent=service.dailySales(chart("CUSTOM",D.minusDays(1),D.minusDays(1),"C","SKU")); + assertEquals(0,n("4").compareTo(independent.summary().outboundQuantity())); + when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(40)))); + when(mapper.daily(anyLong(),anyString(),anyString(),anyString(),anyString(),any(),any())).thenReturn(List.of()); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS",null)); + var r=service.dailySales(chart("TODAY",null,null,"C","SKU")); + assertEquals("SOURCE_TIMEZONE_UNKNOWN",r.context().salesStatus()); assertNull(r.summary().observedQuantity()); + } + @Test void contextExpiresAtThirtyMinutesAndOldSalesDeadlineClipsIt(){ + assertEquals(NOW.plusSeconds(1800),service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt()); + assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options())); + Instant deadline=NOW.plusSeconds(600); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),deadline.minus(Duration.ofDays(7)),"PUBLISHED","BUSINESS","Asia/Shanghai")); + assertEquals(deadline,service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt()); + assertThrows(BizException.class,()->at(deadline).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options())); + } + @Test void salesRetentionCannotBeConfiguredBelowSevenDays(){ + org.springframework.test.util.ReflectionTestUtils.setField(service,"retentionDays",1); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),NOW.minus(Duration.ofDays(2)),"PUBLISHED","BUSINESS","Asia/Shanghai")); + assertEquals(NOW.plusSeconds(1800),service.inventoryCoverage(metrics(null),options()).context().queryExpiresAt()); + } + @Test void missingOrReversedContinuousCoverageNeverAuthorizesFormalThirtyDayZeros(){ + Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"); + for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){ + when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose())); + var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d()); + } + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any()); + assertTrue(cap.getAllValues().stream().noneMatch(AnalyticsSqlRequest::complete30)); + } + @Test void pinnedDateSurvivesMidnightAndNewPublishedPointers(){ + Instant before=Instant.parse("2026-10-12T15:55:00Z"),after=before.plusSeconds(600); + var first=at(before).salesSummary(chart("TODAY",null,null,null,null)); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,33L),scope(2,"INVENTORY",IH,44L))); + var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2)); + assertEquals(D,second.context().anchorDate()); assertEquals(D,second.context().rangeStartDate()); + assertEquals(D.plusDays(1),second.context().rangeEndDateExclusive()); assertEquals(11L,second.context().salesRunId()); + verify(mapper,never()).publishedRun(33); verify(mapper,never()).publishedRun(44); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture()); assertEquals(20,cap.getValue().offset()); + } + @Test void missingVersionZeroStaysMissingAfterNewPointerAppears(){ + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,0L),scope(2,"INVENTORY",IH,0L))); + var first=service.salesSummary(chart("TODAY",null,null,null,null)); assertEquals(0L,first.context().salesRunId()); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2)); + assertEquals(0L,second.context().inventoryRunId()); assertEquals(0L,second.context().salesRunId()); verify(mapper,never()).publishedRun(anyLong()); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture()); + assertEquals(0,cap.getValue().salesRun()); assertEquals(0,cap.getValue().inventoryRun()); assertFalse(cap.getValue().salesKnown()); + } + @Test void hashScopePurposeAndPublicationMustMatch(){ + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,IH,IH,NOW,1),options())); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(9,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); + when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"READY","BUSINESS","Asia/Shanghai")); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); + when(mapper.publishedRun(11)).thenReturn(run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","VALIDATION","Asia/Shanghai")); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); verify(mapper,never()).page(any()); + } + @Test void warehouseMismatchAndMissingWarehouseAreRejected(){ + assertThrows(BizException.class,()->service.inventoryCoverage(new AnalyticsQuery("demo",null,null,null,null,null,null,null,null,1,20,null,null,null,null,null),options())); + var foreign=scope(1,"OUTBOUND",SH,11L); + when(mapper.scopes("demo","W")).thenReturn(List.of(new Scope(foreign.id(),"demo","OTHER",foreign.dataFamily(),SH,"Asia/Shanghai","Asia/Shanghai", + 93600,11L,1L,1L,false,foreign.knownOrderRoundFinishedAtUtc(),foreign.historyRoundFinishedAtUtc(),7),scope(2,"INVENTORY",IH,22L))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),options())); verify(mapper,never()).page(any()); + } + @Test void clockBaselineMustNotBeFutureOrBeforePublicationAndPagesNeedContext(){ + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.plusSeconds(1),1),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(7200),1),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(pinned(null,11,22,SH,IH,null,2),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(new AnalyticsQuery("demo","W",null,null,null,null,null,null,null,2,20,null,null,null,null,null),options())); + } + @Test void zeroWinsOverUnknownUnitButDynamicStaleZeroDoesNot(){ + doAnswer(inv->List.of(inventory(false,"0",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),null,null))).when(mapper).inventoryPage(any(),any()); + var row=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertEquals(n("0.0"),row.inventoryCoverageDays());assertEquals("UNIT_UNKNOWN_OR_CONFLICT",row.salesCoverage());assertFalse(row.observationEligible()); + when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93500),null,"PUBLISHED","BUSINESS","Asia/Shanghai")); + var later=at(NOW.plusSeconds(200)).inventoryCoverage(pinned(null,11,22,SH,IH,NOW,1),options()); + assertEquals("STALE",later.context().inventoryStatus());assertNull(later.records().get(0).inventoryCoverageDays());assertNull(later.records().get(0).availableQuantity()); + } + @Test void fixedMetricWindowsNeverFollowChartPeriods(){ + var response=service.inventoryCoverage(metrics(null),options()); var row=response.records().get(0); + assertEquals(n("60.0"),row.inventoryCoverageDays()); assertEquals(0,n("5").compareTo(row.averageDailyOutboundQuantity())); + assertEquals(n("300"),row.availableQuantity()); assertEquals(n("20"),row.frozenQuantity()); assertEquals(n("30"),row.transitQuantity()); + assertEquals(D.minusDays(40),row.observedHistoryStartDate()); + assertEquals(30,response.context().calculationWindowDays()); assertEquals(D.minusDays(30),response.context().rangeStartDate()); + var hot=service.hotSkus(metrics(7)); assertEquals(7,hot.context().calculationWindowDays()); assertEquals(D.minusDays(7),hot.context().rangeStartDate()); + assertThrows(BizException.class,()->service.hotSkus(metrics(15))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(30),options())); + assertThrows(BizException.class,()->service.slowSkus(chart("TODAY",null,null,null,null))); + assertThrows(BizException.class,()->service.hotSkus(chart("LAST_3_DAYS",null,null,null,null))); + } + @Test void unknownInventoryDoesNotReturnZeroCoverage(){ + doReturn(List.of(inventory(true,null,true,time(D.minusDays(40)),"150"))).when(mapper).inventoryPage(any(),any()); + var row=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertNull(row.inventoryCoverageDays());assertNull(row.availableQuantity());assertEquals("UNKNOWN",row.inventoryQuality()); + } + @Test void noObservationEvidenceCannotBecomeThirtyDayDenominatorAndNoSalesIsNotInfinity(){ + doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any()); + var unknown=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("OBSERVATION_INSUFFICIENT",unknown.status()); + doReturn(List.of(inventory(true,"300",true,time(D.minusDays(40)),"0"))).when(mapper).inventoryPage(any(),any()); + var zero=service.inventoryCoverage(metrics(null),options()).records().get(0); + assertEquals("NO_OUTBOUND_30_DAYS",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum()); + } + @Test void rebuildBarrierKeepsVersionButDisablesTrustedMetrics(){ + when(mapper.applyBarriers("demo","W")).thenReturn(1L); var result=service.inventoryCoverage(metrics(null),options()); + assertTrue(result.context().rebuildRequired()); assertEquals("REBUILD_REQUIRED",result.context().salesStatus()); + assertEquals(11L,result.context().salesRunId()); assertNull(result.records().get(0).inventoryCoverageDays()); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper).inventoryPage(cap.capture(),any()); + assertFalse(cap.getValue().completeWindow()); assertFalse(cap.getValue().complete30()); + } + @Test void topOneHundredClipsFinalSqlPageAndSkipsBeyondIt(){ + when(mapper.count(any())).thenReturn(100L); + service.hotSkus(new AnalyticsQuery("demo","W",null,null,null,null,null,null,30,2,75,11L,22L,SH,IH,NOW)); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper).page(cap.capture()); + assertEquals(75,cap.getValue().offset()); assertEquals(25,cap.getValue().size()); + assertTrue(service.hotSkus(new AnalyticsQuery("demo","W",null,null,null,null,null,null,30,3,50,11L,22L,SH,IH,NOW)).records().isEmpty()); + verify(mapper,times(1)).page(any()); + } + @Test void readRunsWithinConsistentSnapshot(){ + var t=AnalyticsQueryService.class.getAnnotation(Transactional.class); assertTrue(t.readOnly()); assertEquals(Isolation.REPEATABLE_READ,t.isolation()); + } + @Test void overviewPreservesUnknownDenominatorAndDoesNotReadCurrentPage(){ + Map totals=new HashMap<>();totals.put("sku_count",3L); + for(String f:List.of("physical_quantity","available_quantity","frozen_quantity","transit_quantity","defective_physical_quantity","owner_outbound")){ + totals.put(f+"_known",n("100"));totals.put(f+"_unknown",0L); + } + totals.put("physical_quantity_unknown",1L);totals.put("physical_sku_known",n("2")); + totals.put("no_outbound_count",1L);totals.put("no_outbound_physical",n("40"));totals.put("no_outbound_unknown",1L); + totals.put("no_outbound_unknown_physical",n("20"));totals.put("above_threshold_count",0L);totals.put("above_threshold_physical",n("0"));totals.put("above_threshold_unknown",3L); + when(mapper.ownerTotals(any(),any())).thenReturn(totals); + var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null); + var response=service.ownerOverview(q,options()); + assertEquals(3L,response.skuCount());assertNull(response.physicalQuantity().overallTotal());assertEquals(n("100"),response.physicalQuantity().knownSubtotal()); + assertNull(response.noOutbound30d().matchedSkuCount());assertNull(response.noOutbound30d().physicalQuantityRatio());assertEquals(1L,response.noOutbound30d().knownMatchedSkuCount()); + assertEquals(n("100"),response.outboundQuantity30d().overallTotal());assertEquals(List.of("THRESHOLD_NOT_SET"),response.coverageAboveThreshold().reasonCodes()); + verify(mapper,never()).page(any());verify(mapper,never()).inventoryPage(any(),any()); + } + @Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){ + var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null); + assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null))); + assertThrows(BizException.class,()->service.ownerOverview(metrics(null),options())); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1))); + assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0))); + verifyNoInteractions(mapper); + } + private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null);} + private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){ + BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales); + return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null, + fresh?n("0"):null,utc(NOW.minusSeconds(3600)),out,out,10L,D.minusDays(1),evidence!=null,out,null,0,0,out==null||stock==null); + } + private AnalyticsQueryService at(Instant now){return new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(now,ZoneOffset.UTC));} + private static AnalyticsQuery chart(String range,LocalDate from,LocalDate until,String owner,String sku){return new AnalyticsQuery("demo","W",owner,sku,range,from,until,"DAY",null,1,20,null,null,null,null,null);} + private static AnalyticsQuery metrics(Integer days){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,days,1,20,null,null,null,null,null);} + private static AnalyticsQuery pinned(String range,long sales,long inventory,String sh,String ih,Instant asOf,int page){return new AnalyticsQuery("demo","W",null,null,range,null,null,range==null?null:"DAY",null,page,20,sales,inventory,sh,ih,asOf);} + private static BigDecimal n(String v){return new BigDecimal(v);} + private static LocalDateTime utc(Instant t){return LocalDateTime.ofInstant(t,ZoneOffset.UTC);} + private static LocalDateTime time(LocalDate date){return utc(date.atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant());} + private static Scope scope(long id,String family,String hash,Long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,utc(NOW.minusSeconds(86400)),utc(NOW.minusSeconds(86400)),7);} + private static Run run(long id,long scope,String hash,Instant observed,Instant superseded,String status,String purpose,String source){ + String config="{\"businessTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600,\"sourceTimezone\":"+(source==null?"null":"\""+source+"\"")+"}"; + return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,config,status,"COMPLETE",time(D.minusDays(40)),utc(observed),D.minusDays(30),D, + utc(observed.minusSeconds(60)),utc(observed),utc(NOW.minusSeconds(3600)),1L,null,null,superseded==null?null:utc(superseded),purpose); + } + private static Profile profile(String unit,String type,LocalDateTime evidence){return new Profile("C","SKU","Product",unit,evidence,evidence,type,1L,1L);} + private static Aggregate aggregate(boolean unit,String available,String quality,LocalDateTime evidence){return new Aggregate("C","SKU","Product","PCS",evidence,unit,n("5"),2L,2,1,0,n("150"),10L,n("150"),n("3"),n("120"),n(available),n("20"),n("30"),utc(NOW.minusSeconds(3600)),quality,null);} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java new file mode 100644 index 000000000..c4c2a38a3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.SqlCommandType; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** MyBatis parses providers and binds parameters; no database or SQL execution. */ +class AnalyticsReadMapperTest { + private static final String NS=AnalyticsReadMapper.class.getName()+"."; + private static Configuration config(){var c=new Configuration(); c.setMapUnderscoreToCamelCase(true); c.addMapper(AnalyticsReadMapper.class); return c;} + private static AnalyticsSqlRequest request(String kind){ + LocalDate d=LocalDate.of(2026,10,12); + return new AnalyticsSqlRequest("demo","W","OWNER","Exact-SKU",11,22,d.minusDays(2),d.plusDays(1),d,d.minusDays(7), + d.minusDays(30),d,LocalDateTime.of(2026,9,11,16,0),LocalDateTime.of(2026,10,12,3,0),true,true,true,true,true,20,20,kind); + } + private static BoundSql bound(Configuration c,String method,Map parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);} + private static void immutableOnly(String sql){ + assertFalse(sql.contains("ll_selfop_outbound_order")); assertFalse(sql.contains("ll_selfop_outbound_item")); assertFalse(sql.contains("ll_selfop_sync_stage")); + assertFalse(sql.contains("${")); assertFalse(sql.contains("Exact-SKU")); assertFalse(sql.contains("OWNER")); + } + @Test void allMapperStatementsAreSelectAndProvidersBindTheNamedRecord(){ + var c=config(); assertNotNull(AnalyticsReadMapper.class.getAnnotation(Mapper.class)); + for(String method:List.of("scopes","publishedRun","page","count","excluded","profile","daily","applyBarriers")){ + var statement=c.getMappedStatement(NS+method); assertEquals(SqlCommandType.SELECT,statement.getSqlCommandType()); + Map params=new HashMap<>(); params.put("p",request("HOT")); params.put("id",11L); params.put("run",11L); + params.put("account","demo"); params.put("warehouse","W"); params.put("customer","OWNER"); params.put("sku","Exact-SKU"); + params.put("from",LocalDate.of(2026,10,10)); params.put("until",LocalDate.of(2026,10,13)); + immutableOnly(statement.getBoundSql(params).getSql()); + } + var b=bound(c,"page",Map.of("p",request("HOT"))); + var names=b.getParameterMappings().stream().map(org.apache.ibatis.mapping.ParameterMapping::getProperty).toList(); + for(String property:List.of("p.account","p.warehouse","p.customer","p.sku","p.salesRun","p.inventoryRun","p.from","p.until","p.size","p.offset"))assertTrue(names.contains(property),property); + assertFalse(b.getSql().contains("#{")); + assertEquals(AnalyticsReadRows.Aggregate.class,c.getMappedStatement(NS+"page").getResultMaps().get(0).getType()); + } + @Test void exactDailySqlFiltersEveryIdentityVersionAndHalfOpenDateBeforeAscendingAxis(){ + var c=config(); var b=bound(c,"daily",Map.of("run",11L,"account","demo","warehouse","W","customer","OWNER","sku","Exact-SKU", + "from",LocalDate.of(2026,10,10),"until",LocalDate.of(2026,10,13))); + String sql=b.getSql(); + for(String fragment:List.of("sales_run_id=?","source_account=?","warehouse_code=?","customer_code=?","sku_code=?","business_date>=?","business_dateof("p",request("SUMMARY")); + String count=bound(c,"count",parameters).getSql(); + assertTrue(count.contains(" UNION SELECT customer_code,sku_code")); assertFalse(count.contains("UNION ALL")); + assertFalse(count.contains("ll_selfop_sku_daily_sales")); assertFalse(count.contains("ll_selfop_inventory_snapshot")); + String page=bound(c,"page",parameters).getSql(); + assertTrue(page.contains("d.sales_run_id=?")); assertTrue(page.contains("d.business_date>=CAST(? AS DATE) AND d.business_date=DATE_SUB")); + assertFalse(page.contains("ll_selfop_inventory_snapshot")); + assertTrue(page.endsWith("ORDER BY observed_quantity IS NULL,observed_quantity DESC,sku_code ASC,customer_code ASC LIMIT ? OFFSET ?")); + assertTrue(page.indexOf("GROUP BY b.customer_code,b.sku_code")of("p",request(kind)); String sql=bound(c,"page",p).getSql(); + assertTrue(sql.contains("st.snapshot_run_id=?")); assertTrue(sql.contains("st.source_account=?")); assertTrue(sql.contains("st.warehouse_code=?")); + assertTrue(sql.contains("AND st.product_quality=0")); + assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date0 ORDER BY window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC LIMIT")); + String slow=bound(c,"page",Map.of("p",request("SLOW"))).getSql(); + assertTrue(slow.contains("inventory_quality='VALID' AND available_quantity>0")); + assertTrue(slow.contains("evidence_at_utc<=CONVERT_TZ")); + assertTrue(slow.contains("ORDER BY window_quantity ASC,available_quantity DESC,sku_code ASC,customer_code ASC LIMIT")); + for(String kind:List.of("HOT","SLOW")){ + String count=bound(c,"count",Map.of("p",request(kind))).getSql(); assertTrue(count.contains("SELECT LEAST(COUNT(*),100) FROM result")); assertFalse(count.contains(" LIMIT ")); + } + } + @Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){ + String sql=bound(config(),"page",Map.of("p",request("INVENTORY"))).getSql(); + assertTrue(sql.contains("s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL")); + assertTrue(sql.contains("i.evidence_type='INVENTORY_OBSERVATION' AND i.evidence_run_id IS NOT NULL")); + assertFalse(sql.contains("first_seen_at_utc")); assertTrue(sql.contains("COALESCE(s.quantity_unit,i.quantity_unit)<>'PCS'")); + assertTrue(sql.contains("s.quantity_unit<>i.quantity_unit")); + // Comparing timestamps before DATE projection prevents 16:00 evidence from + // turning an 08:00 covered-until on the same day into an observed zero. + assertTrue(sql.contains("AND GREATEST(evidence_at_utc,CAST(? AS DATETIME))0 THEN COALESCE(c.observed_sum,0) END observed_quantity")); + assertTrue(sql.contains("WHEN ?=0 THEN 'STALE'")); + assertTrue(sql.contains("st.available_quantity<0")); assertTrue(sql.contains("st.available_quantity IS NULL")); + } + @Test void publishedRunRejectsValidationAndAccountDiscoveryAndBarrierSurvivesDeadTask(){ + var c=config(); String run=bound(c,"publishedRun",Map.of("id",11L)).getSql(); + assertTrue(run.startsWith("SELECT id,task_id,scope_id,")); assertTrue(run.contains("status='PUBLISHED'")); + assertTrue(run.contains("run_role='WAREHOUSE_SYNC'")); assertTrue(run.contains("execution_purpose='BUSINESS'")); + assertEquals(AnalyticsReadRows.Run.class,c.getMappedStatement(NS+"publishedRun").getResultMaps().get(0).getType()); + String barrier=bound(c,"applyBarriers",Map.of("account","demo","warehouse","W")).getSql(); + assertTrue(barrier.contains("'APPLYING','BLOCKED'")); assertTrue(barrier.contains("JSON_CONTAINS")); assertFalse(barrier.contains("status=")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java new file mode 100644 index 000000000..db52e1525 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java @@ -0,0 +1,251 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.*; +import java.math.BigDecimal; +import java.sql.*; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Executes the production provider SQL on synthetic, process-local H2 tables only. + * H2's CONVERT_TZ is supplied with explicit offsets. Its repeatedly referenced CTE prepared + * plan loses parameter values, so page SQL uses typed, escaped fixture literals from the + * original BoundSql mappings. Candidate counts and separate binding contracts remain prepared. + * No external database, credentials or HTTP; MySQL plans/performance remain separate gates. */ +public class AnalyticsSqlSemanticTest { + private Connection schema; + private SqlSession session; + private AnalyticsReadMapper mapper; + private Configuration configuration; + private int statementSequence; + private long nextId=1; + private static final LocalDate D=LocalDate.of(2026,10,12); + @BeforeEach void setup() throws Exception { + String url="jdbc:h2:mem:selfop_query_"+UUID.randomUUID().toString().replace("-","")+";MODE=MySQL"; + var ds=new UnpooledDataSource("org.h2.Driver",url,"sa",""); + schema=ds.getConnection(); + try(var statement=schema.createStatement()) { + statement.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'"); + statement.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)"); + statement.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))"); + statement.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))"); + } + configuration=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds)); + configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AnalyticsReadMapper.class); + session=new SqlSessionFactoryBuilder().build(configuration).openSession();mapper=session.getMapper(AnalyticsReadMapper.class); + } + @AfterEach void close() throws Exception {if(session!=null)session.close();if(schema!=null)schema.close();} + public static LocalDateTime convertZone(LocalDateTime value,String from,String to) { + return value==null?null:value.atOffset(ZoneOffset.of(from)).withOffsetSameInstant(ZoneOffset.of(to)).toLocalDateTime(); + } + @Test void sqlReturnsTwoCompleteDaysAndTodayPartialObservedFive() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(35),0),"PCS"); + daily(11,"W","C","SKU",D.minusDays(2),"2");daily(11,"W","C","SKU",D,"3"); + var p=request("SUMMARY",D.minusDays(2),D.plusDays(1),null,null,utc(D,8),0,20); + assertEquals(1,mapper.count(p));var row=page(p).get(0); + assertEquals(0,new BigDecimal("5").compareTo(row.observedQuantity())); + assertEquals(2,row.completeDayCount());assertEquals(1,row.partialDayCount());assertEquals(0,row.unknownDayCount()); + } + @Test void evidenceAfterCoveredTimeOnSameDayDoesNotBecomeObservedZero() throws Exception { + profile(22,"C","LATE","INVENTORY_OBSERVATION",utc(D,16),"PCS"); + profile(22,"C","NO-EVIDENCE","NONE",null,"PCS"); + var rows=page(request("SUMMARY",D,D.plusDays(1),null,null,utc(D,8),0,20)); + var unknown=rows.stream().filter(r->"NO-EVIDENCE".equals(r.skuCode())).findFirst().orElseThrow(); + assertNull(unknown.observedQuantity());assertEquals(1,unknown.unknownDayCount()); + var row=rows.stream().filter(r->"LATE".equals(r.skuCode())).findFirst().orElseThrow(); + assertNull(row.observedQuantity());assertEquals(0,row.completeDayCount());assertEquals(0,row.partialDayCount());assertEquals(1,row.unknownDayCount()); + // A real confirmed row outside the continuous watermark is still observed, never complete today. + daily(11,"W","C","LATE",D,"3");session.clearCache(); + row=page(request("SUMMARY",D,D.plusDays(1),null,null,utc(D,8),0,20)).get(0); + assertEquals(0,new BigDecimal("3").compareTo(row.observedQuantity()));assertEquals(1,row.partialDayCount()); + } + @Test void intervalSortingIncludesZeroCandidatesBeforePagingAndNeverMergesOwners() throws Exception { + for(String owner:List.of("A","B","C"))profile(22,owner,"SAME","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","A","SAME",D.minusDays(1),"2");daily(11,"W","B","SAME",D.minusDays(1),"5"); + var first=request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),0,1); + assertEquals(3,mapper.count(first));assertEquals("B",page(first).get(0).customerCode()); + assertEquals("A",page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),1,1)).get(0).customerCode()); + var zero=page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),2,1)).get(0); + assertEquals("C",zero.customerCode());assertEquals(0,zero.observedQuantity().signum());assertEquals(1,zero.completeDayCount()); + } + @Test void exactFiltersExcludeOtherWarehouseVersionDatesAndQualityNeverMultipliesSales() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","C","SKU",D.minusDays(1),"150");daily(11,"OTHER","C","SKU",D.minusDays(1),"999"); + daily(33,"W","C","SKU",D.minusDays(1),"999");daily(11,"W","C","SKU",D.minusDays(31),"999"); + stock("C","SKU",0,"300");stock("C","SKU",1,"999"); + var row=page(request("INVENTORY",D.minusDays(30),D,"C","SKU",utc(D,8),0,20)).get(0); + assertEquals(0,new BigDecimal("150").compareTo(row.sales30Quantity())); + assertEquals(0,new BigDecimal("300").compareTo(row.availableQuantity()));assertEquals(1L,row.windowSkuOrderCount()); + assertEquals(0,mapper.count(request("SUMMARY",D.minusDays(1),D,"OTHER","SKU",utc(D,8),0,20))); + } + @Test void inventoryUnitConflictPreservesPureSalesButBlocksMixedMetricEligibility() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");daily(11,"W","C","SKU",D.minusDays(1),"5"); + profile(11,"C","UNKNOWN-UNIT","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","UNKNOWN-UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),null); + daily(11,"W","C","UNKNOWN-UNIT",D.minusDays(1),"5"); + var rows=page(request("SUMMARY",D.minusDays(1),D,null,null,utc(D,8),0,20)); + assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0)); + var row=rows.get(0); + assertTrue(row.unitValid());assertEquals(0,row.unknownDayCount()); + assertTrue(page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)).isEmpty()); + } + @Test void inventoryQualitiesAggregateBeforeSalesAndKeepOnlyGoodAvailable() throws Exception { + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","C","SKU",D.minusDays(1),"150"); + stockExact("SKU",0,"300","250","20","30","VALID",null); + stockExact("SKU",1,"60","50","5","10","VALID",null); + var row=inventory(null,null,null,0,20).get(0); + assertDecimal("360",row.physicalQuantity());assertDecimal("250",row.availableQuantity());assertDecimal("60",row.defectivePhysicalQuantity()); + assertDecimal("25",row.frozenQuantity());assertDecimal("40",row.transitQuantity());assertDecimal("150",row.outboundQuantity30d()); + assertEquals(D.minusDays(1),row.lastOutbound());assertEquals(1L,row.orderCount()); + } + @Test void quantityUnknownIsDependencySpecificAndUnknownQualityIsNotDefective() throws Exception { + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact("SKU",0,"20","10",null,"2","UNKNOWN","FIELD_MISSING"); + stockExact("SKU",7,"3","0","0","0","VALID",null); + var row=inventory(null,null,null,0,20).get(0); + assertDecimal("23",row.physicalQuantity());assertDecimal("10",row.availableQuantity());assertNull(row.frozenQuantity()); + assertNull(row.defectivePhysicalQuantity());assertEquals(1,row.noOutbound());assertNull(row.coverageDays()); + assertEquals(1,inventory("NO_OUTBOUND_30D",null,null,0,20).size()); + } + @Test void frozenDefectiveOnlyStockMatchesNoOutboundEvenWhenGoodAvailableIsZero() throws Exception { + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact("SKU",0,"0","0","0","0","VALID",null);stockExact("SKU",1,"40","0","40","0","VALID",null); + var row=inventory("NO_OUTBOUND_30D",null,null,0,20).get(0); + assertDecimal("0",row.coverageDays());assertEquals(1,row.noOutbound());assertEquals(1,inventory("FROZEN_STOCK",null,null,0,20).size()); + assertEquals(1,inventory("DEFECTIVE_STOCK",null,null,0,20).size());assertTrue(inventory("COVERAGE_GT_THRESHOLD",1,null,0,20).isEmpty()); + } + @Test void thresholdUsesExactCrossProductBeforePagingAndOwnerTotalsUseAllCandidates() throws Exception { + for(int i=0;i<105;i++){ + String sku=String.format("SKU-%03d",i);profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact(sku,0,"301","301","0","0","VALID",null);daily(11,"W","C",sku,D.minusDays(1),"30"); + } + profile(22,"C","BOUNDARY","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + stockExact("BOUNDARY",0,"300.000001","300.000001","0","0","VALID",null);daily(11,"W","C","BOUNDARY",D.minusDays(1),"30"); + var rows=inventory("COVERAGE_GT_THRESHOLD",300,"PHYSICAL_DESC",100,20);assertEquals(6,rows.size()); + assertEquals("BOUNDARY",rows.get(5).skuCode());assertDecimal("300.000001",rows.get(5).coverageDays()); + var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),100,20); + var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300); + assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue()); + Map totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue()); + assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known")); + } + @Test void ownerSalesCompleteDoesNotRequireEachSkuObservationAndRiskUnknownIsRetained() throws Exception { + profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null); + daily(11,"W","C","NEW",D.minusDays(1),"5"); + var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound()); + Map totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30)); + assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue()); + assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical")); + } + @Test void ownersAreCaseSensitiveDistinctAndPrefixWildcardsAreLiteral() throws Exception { + for(String owner:List.of("A%","A_test","ABC","abc")){ + profile(11,owner,"SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");profile(22,owner,"SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS"); + } + var p=request("OWNERS",D.minusDays(30),D,null,null,utc(D,8),0,20); + assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null))); + assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null))); + assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null))); + assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null)).get(0)).longValue()); + } + @Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX"); + daily(11,"W","C","SKU",D.minusDays(1),"12");stockExact("SKU",0,"30","30","0","0","VALID",null); + var row=inventory(null,30,null,0,20).get(0);assertDecimal("12",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.physicalQuantity()); + var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30)); + assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue()); + } + private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));} + @SuppressWarnings("unchecked") + private List inventory(String attention,Integer threshold,String sort,long offset,int size){ + return (List)(List)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold)); + } + @SuppressWarnings("unchecked") + private Map totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + Map result=new HashMap<>();((Map)literalQuery("ownerTotals",p,o).get(0)).forEach((k,v)->result.put(k.toLowerCase(Locale.ROOT),v));return result; + } + private List literalQuery(String method,AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + String namespace=AnalyticsReadMapper.class.getName()+"."+method;var original=configuration.getMappedStatement(namespace); + Map args=Map.of("p",p,"o",o);String literal=render(original.getBoundSql(args),args);String id=namespace+".fixture."+(++statementSequence); + configuration.addMappedStatement(new org.apache.ibatis.mapping.MappedStatement.Builder(configuration,id,new org.apache.ibatis.builder.StaticSqlSource(configuration,literal), + org.apache.ibatis.mapping.SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id); + } + private void stockExact(String sku,int quality,String physical,String available,String frozen,String transit,String status,String reason)throws Exception{ + try(var p=schema.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){ + p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,quality); + String[] quantities={physical,available,frozen,transit};for(int i=0;i<4;i++)p.setBigDecimal(7+i,quantities[i]==null?null:new BigDecimal(quantities[i])); + p.setObject(11,utc(D,8));p.setString(12,status);p.setString(13,reason);p.executeUpdate(); + } + } + private List page(AnalyticsSqlRequest request) { + String namespace=AnalyticsReadMapper.class.getName()+".page"; + var original=configuration.getMappedStatement(namespace); + Map parameters=Map.of("p",request); + var bound=original.getBoundSql(parameters); + String literal=render(bound,parameters); + String id=namespace+".syntheticLiteral."+(++statementSequence); + var source=new org.apache.ibatis.builder.StaticSqlSource(configuration,literal); + configuration.addMappedStatement(new org.apache.ibatis.mapping.MappedStatement.Builder(configuration,id,source, + org.apache.ibatis.mapping.SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build()); + return session.selectList(id); + } + private static String render(org.apache.ibatis.mapping.BoundSql bound,Map parameters) { + var meta=org.apache.ibatis.reflection.SystemMetaObject.forObject(parameters); + String sql=bound.getSql();StringBuilder result=new StringBuilder();int index=0;boolean quoted=false; + for(int i=0;iservice.tasks(q(2,null,null))); + assertThrows(BizException.class,()->service.tasks(q(1,now,null))); + verify(mapper,never()).tasks(any(),any()); + } + @Test void dailyInventoryCountsCompletedBeijingDaysAndPreservesGapsPerWarehouse() { + config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse()); + service=new CollectionQueryService(mapper,config,new ObjectMapper(),Clock.fixed(Instant.parse("2026-10-11T16:01:00Z"),ZoneOffset.UTC)); + LocalDate until=LocalDate.of(2026,10,12),from=until.minusDays(30); + when(mapper.inventoryDailySamples("primary",List.of("A","B"),from,until)).thenReturn(List.of( + new InventorySample("A",until.minusDays(1)),new InventorySample("A",until.minusDays(2)), + new InventorySample("B",until.minusDays(3)))); + when(mapper.inventoryAccumulationStarts("primary",List.of("A","B"))).thenReturn(List.of( + new InventorySample("A",until.minusDays(40)),new InventorySample("B",until.minusDays(3)))); + var rows=service.overview(new CollectionQuery("primary",null,null,null,null,null,null,null,null,null,null,null)).warehouses(); + var a=rows.stream().filter(w->w.warehouseCode().equals("A")&&w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples(); + var b=rows.stream().filter(w->w.warehouseCode().equals("B")&&w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples(); + assertEquals(from,a.windowFrom());assertEquals(until,a.windowUntil());assertEquals(30,a.expectedDays()); + assertEquals(2,a.sampleCount());assertEquals(28,a.missingDates().size());assertFalse(a.missingDates().contains(until.minusDays(1))); + assertEquals(until.minusDays(40),a.accumulationStartedOn());assertEquals(1,b.sampleCount()); + assertTrue(rows.stream().filter(w->w.dataFamily().equals("OUTBOUND")).allMatch(w->w.inventoryDailySamples()==null)); + verify(mapper,times(1)).inventoryDailySamples("primary",List.of("A","B"),from,until); + } + @Test void noInventorySamplesShowsThirtyGapsWithoutInventingHistory() { + var row=service.overview(q(null,null,null)).warehouses().stream() + .filter(w->w.dataFamily().equals("INVENTORY")).findFirst().orElseThrow().inventoryDailySamples(); + assertEquals(0,row.sampleCount());assertEquals(30,row.missingDates().size());assertNull(row.accumulationStartedOn()); + } + @Test void accountAndWarehouseAreValidatedBeforePersistenceRead() { + assertThrows(BizException.class,()->service.tasks(new CollectionQuery("other","A",null,null,null,null,null,null,null,null,null,null))); + assertThrows(BizException.class,()->service.tasks(new CollectionQuery("primary",null,null,null,null,null,null,null,null,null,null,null))); + verifyNoInteractions(mapper); + } + @Test void frozenBoundAndBeijingDateFiltersGoToSql() { + Bound upper=new Bound(now,99L); + when(mapper.tasks(any(),eq(upper))).thenReturn(List.of()); + var query=new CollectionQuery("primary","A","OUTBOUND",null,null,null,LocalDate.of(2026,10,12),LocalDate.of(2026,10,12),2,20,now.toInstant(ZoneOffset.UTC),99L); + var result=service.tasks(query); + ArgumentCaptor f=ArgumentCaptor.forClass(CollectionReadMapper.Filter.class); + verify(mapper).tasks(f.capture(),eq(upper)); + assertEquals(LocalDateTime.of(2026,10,11,16,0),f.getValue().from()); + assertEquals(LocalDateTime.of(2026,10,12,16,0),f.getValue().until()); + assertEquals(20,f.getValue().offset());assertEquals(99L,result.listUpperId()); + verify(mapper,never()).taskBound(any()); + } + @Test void runHistoryReadsFrozenTerminalProgressRatherThanTaskCurrentAttempt() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED","{\"completed\":2,\"totalKnown\":false,\"unit\":\"ORDER\"}"))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS","{\"completed\":99}",2))); + var result=service.runs(q(null,null,null)); + assertEquals(2L,result.records().get(0).progress().get("completed")); + assertEquals("LXCOL-TASK",result.records().get(0).taskNo());assertEquals(90L,result.records().get(0).runId()); + assertEquals("TERMINATED",result.records().get(0).executionHealth()); + } + @Test void activeRunFromDifferentAttemptDoesNotReadNewAttemptProgress() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FETCHING",null))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS","{\"completed\":99}",2))); + assertTrue(service.runs(q(null,null,null)).records().get(0).progress().isEmpty()); + } + @Test void progressWhitelistPreservesUnknownCountsAndNeverCreatesTotalPercentage() { + var result=service.progress("{\"completed\":3,\"total\":17,\"totalKnown\":false,\"unknownCount\":null,\"unit\":\"ORDER\",\"secret\":\"hidden\",\"percent\":90}"); + assertEquals(3L,result.get("completed"));assertNull(result.get("total"));assertNull(result.get("unknownCount")); + assertFalse(result.containsKey("secret"));assertFalse(result.containsKey("percent")); + assertTrue(service.progress("{\"x\":\""+"x".repeat(8192)+"\"}").isEmpty()); + assertTrue(service.progress("not json").isEmpty()); + } + @Test void unknownUnitsAndNegativeCountsAreNotTrusted() { + var result=service.progress("{\"completed\":-1,\"unit\":\"HTTP\",\"total\":3,\"totalKnown\":true}"); + assertNull(result.get("completed"));assertNull(result.get("unit"));assertEquals(3L,result.get("total")); + } + @Test void batchCheckpointsKeepReliableCountsWhileNonCountingStagesNeverPretendZeroProgress() { + var batch=service.progress("{\"unit\":\"BATCH\",\"completed\":3,\"total\":5,\"totalKnown\":true}"); + assertEquals("BATCH",batch.get("unit"));assertEquals(3L,batch.get("completed"));assertEquals(5L,batch.get("total")); + var waiting=service.progress("{\"unit\":\"NONE\",\"completed\":0,\"total\":0,\"totalKnown\":true}"); + assertEquals("NONE",waiting.get("unit"));assertNull(waiting.get("completed"));assertNull(waiting.get("total")); + assertEquals(false,waiting.get("totalKnown")); + } + @Test void validationTaskCanNeverAdvertiseBusinessRetry() { + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(task("VALIDATION",null,0)); + var result=service.taskDetail("LXCOL-TASK",q(null,null,null)); + assertTrue(result.task().allowedActions().isEmpty());assertEquals("VALIDATION_NOT_RETRYABLE",result.task().actionBlockReason()); + } + @Test void emptyPageReturnsFrozenEmptyUpperBound() { + when(mapper.taskBound(any())).thenReturn(null); + var result=service.tasks(q(null,null,null)); + assertEquals(0L,result.listUpperId());assertEquals(now.toInstant(ZoneOffset.UTC),result.listUpperCreatedAt());assertEquals(0,result.total()); + verify(mapper,never()).tasks(any(),any()); + } + @Test void missingTaskDoesNotLeakOtherWarehouseTask() { + assertThrows(BizException.class,()->service.taskDetail("LXCOL-OTHER",q(null,null,null))); + } + @Test void repeatedRowsUseOneBatchLookupAndNeverFetchPerTask() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED",null),run(2,"FETCHING",null))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS",null,2))); + service.runs(q(null,null,null)); + verify(mapper,times(1)).tasksByIds("primary",List.of(7L)); + } + @Test void errorPayloadIsNeverExposedToOperationsPage() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + var r=new Attempt(90L,7L,"LXCOL-TASK",1,"FAILED","DETAILS","BUSINESS",now.minusHours(1),null,null, + now,null,now,null,"PARTIAL",null,null,"SOURCE_ERROR","secret=credential; customer private data",now,"A",1L,"hash"); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(r)); + var result=service.runs(q(null,null,null)).records().get(0); + assertEquals("SOURCE_ERROR",result.errorCode());assertFalse(result.failureSummary().contains("credential")); + } + @Test void inventoryFreshnessUsesCompletedObservationAndBarrierBlocksInitialCoverage() { + Scope s=new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600, + now.minusMonths(2),now,now.minusMonths(2),now,0,90L,now.minusDays(8),now,7,null,null); + when(mapper.scopes("primary","A")).thenReturn(List.of(s)); + when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-TASK",1,"PUBLISHED","PUBLISH","BUSINESS", + now.minusDays(5),now.minusMinutes(5),null,now.minusMinutes(5),now.minusMinutes(5),null,null,"COMPLETE", + now.minusMonths(2),now,null,null,now.minusDays(5),"A",1L,"hash"))); + when(mapper.barrierWarehouses(eq("primary"),anyList())).thenReturn(List.of("A")); + var row=service.overview(q(null,null,null)).warehouses().get(0); + assertEquals("PARTIAL",row.initialCoverageStatus());assertEquals("FRESH",row.dataFreshness()); + assertTrue(row.rebuildRequired());assertEquals("OVERDUE",row.revisionReviewStatus()); + } + @Test void validationBudgetReadsActualFlatPersistedFieldsAndMasksDetails() { + Task t=new Task(7L,"LXCOL-TASK","primary","A","OUTBOUND","WAREHOUSE_SYNC","VALIDATION","VALIDATION", + "hash","SAMPLE_DONE","NOT_APPLICABLE",1,null,null,null,null,null,null,null,null, + "{\"maxPages\":10,\"pages\":3,\"http\":5,\"confirmedHttp\":4,\"detailKeys\":[\"secret-order\"],\"deadlineAtUtc\":\"2026-10-12T04:05:00\"}",now,now); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(t); + var result=service.taskDetail("LXCOL-TASK",q(null,null,null)).task().validation(); + assertEquals(10L,result.budgetLimits().get("maxPages"));assertEquals(3L,result.budgetConsumed().get("pages")); + assertEquals(5L,result.budgetConsumed().get("http"));assertEquals(1L,result.budgetConsumed().get("distinctDetails")); + assertNull(result.budgetConsumed().get("detailHttp"));assertFalse(result.budgetConsumed().containsKey("detailKeys")); + assertEquals(Instant.parse("2026-10-12T04:05:00Z"),result.deadlineAt()); + } + @Test void allMapperSqlParsesOfflineAndRunQueryOnlyReadsLightweightTables() { + var configuration=new org.apache.ibatis.session.Configuration(); + configuration.addMapper(CollectionReadMapper.class); + var f=new CollectionReadMapper.Filter("primary","A","OUTBOUND","SCHEDULED","FAILED","LXCOL-TASK",now.minusDays(1),now,20,20); + var parameters=new HashMap();parameters.put("f",f);parameters.put("b",new Bound(now,99L)); + parameters.put("account","primary");parameters.put("warehouse","A");parameters.put("taskNo","LXCOL-TASK"); + parameters.put("ids",List.of(1L,7L));parameters.put("warehouses",List.of("A")); + parameters.put("from",LocalDate.of(2026,9,12));parameters.put("until",LocalDate.of(2026,10,12)); + for(String method:List.of("taskBound","runBound","taskCount","tasks","runCount","runs","tasksByIds","latestAttempts", + "publishedRuns","currentTasks","lastSuccessfulTasks","sharedActiveAccount","barrierWarehouses", + "inventoryDailySamples","inventoryAccumulationStarts")) { + String sql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+"."+method).getBoundSql(parameters).getSql(); + assertFalse(sql.contains("routing_manifest_json"));assertFalse(sql.contains("ll_selfop_sku_daily_sales"));assertFalse(sql.contains("ll_selfop_inventory_snapshot")); + assertFalse(sql.contains("ll_selfop_sync_stage")); + } + String runSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".runs").getBoundSql(parameters).getSql(); + assertTrue(runSql.contains("r.created_at <"));assertTrue(runSql.contains("r.id <="));assertTrue(runSql.contains("LIMIT ? OFFSET ?")); + String samplesSql=configuration.getMappedStatement(CollectionReadMapper.class.getName()+".inventoryDailySamples").getBoundSql(parameters).getSql(); + assertTrue(samplesSql.contains("r.inventory_daily_sample_date >= ?")); + assertTrue(samplesSql.contains("r.inventory_daily_sample_date < ?")); + assertTrue(samplesSql.contains("r.status='PUBLISHED'"));assertTrue(samplesSql.contains("r.execution_purpose='BUSINESS'")); + assertTrue(samplesSql.contains("s.data_family='INVENTORY'")); + } + @Test void checkpointTimeIsNotInventedFromAttemptFailureTimeAndOffCannotAdvertiseRetry() { + Bound upper=new Bound(now,100L);when(mapper.runBound(any())).thenReturn(upper); + when(mapper.runs(any(),eq(upper))).thenReturn(List.of(run(1,"FAILED", + "{\"stageStartedAt\":\"2026-10-12T02:00:00Z\",\"summaryAsOf\":\"2026-10-12T02:30:00Z\"}"))); + when(mapper.tasksByIds(eq("primary"),anyList())).thenReturn(List.of(task("BUSINESS",null,1))); + var row=service.runs(q(null,null,null)).records().get(0); + assertEquals(Instant.parse("2026-10-12T02:00:00Z"),row.phaseStartedAt()); + assertEquals(Instant.parse("2026-10-12T02:30:00Z"),row.progressUpdatedAt());assertFalse(row.retryable()); + } + @Test void inventoryFullSnapshotDoesNotWaitForSalesInitialCoverageOrApplyBarrier() { + Scope inventory=new Scope(2L,"A","INVENTORY","hash","Asia/Shanghai","0 0 8 * * *",93600, + null,null,null,null,0,90L,null,null,7,null,null); + when(mapper.scopes("primary","A")).thenReturn(List.of(scope(),inventory)); + when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-STOCK",1, + "PUBLISHED","PUBLISH","BUSINESS",now.minusHours(1),now.minusMinutes(1),null,now,now,null,null, + "COMPLETE",null,null,null,null,now.minusHours(1),"A",2L,"hash"))); + when(mapper.barrierWarehouses(eq("primary"),anyList())).thenReturn(List.of("A")); + var row=service.overview(q(null,null,null)).warehouses().stream().filter(w->"INVENTORY".equals(w.dataFamily())).findFirst().orElseThrow(); + assertEquals("COMPLETE",row.initialCoverageStatus());assertFalse(row.rebuildRequired());assertEquals("FRESH",row.dataFreshness()); + } + @Test void anOldScopeHashCannotProveCurrentInitialWindowComplete() { + Scope scope=new Scope(1L,"A","OUTBOUND","NEW-HASH","Asia/Shanghai","0 0 8,20 * * *",93600, + now.minusMonths(2),now,now.minusMonths(2),now,0,90L,null,null,7,null,null); + when(mapper.scopes("primary","A")).thenReturn(List.of(scope)); + when(mapper.publishedRuns(eq("primary"),anyList())).thenReturn(List.of(new Attempt(90L,7L,"LXCOL-TASK",1, + "PUBLISHED","PUBLISH","BUSINESS",now.minusHours(1),now,null,now,now,null,null,"COMPLETE", + now.minusMonths(2),now,null,null,now.minusHours(1),"A",1L,"OLD-HASH"))); + assertEquals("PARTIAL",service.overview(q(null,null,null)).warehouses().get(0).initialCoverageStatus()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java new file mode 100644 index 000000000..df4467a3f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.*; +import static org.assertj.core.api.Assertions.*; +class SelfopEgressControllerTest { + @Test void invalidInternalTokenCannotReachAuthority(){ + var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service); + assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401)); + assertThatThrownBy(()->c.complete(null,null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401)); + verifyNoInteractions(service); + } + @Test void bothCallbacksUseGuardThenAuthority(){var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var r=new SelfopEgressDtos.AuthorizationRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64));c.authorize("synthetic",r);verify(s).authorize(r);var complete=new SelfopEgressDtos.CompletionRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64),1,null,200,200,false,null);assertThat(c.complete("synthetic",complete).data()).isTrue();verify(s).complete(complete);} + @Test void denialUsesExactB0001DataEnvelopeWithInstantOrExplicitNull() throws Exception { + var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s); + doThrow(new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,java.time.LocalDateTime.of(2026,10,12,0,0,5))).when(s).authorize(null); + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + var json=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",null))); + assertThat(json.path("code").asText()).isEqualTo("B0001");assertThat(json.path("data").path("errorCode").asText()).isEqualTo("ACCOUNT_INTERVAL_WAIT");assertThat(json.path("data").path("waitUntil").asText()).isEqualTo("2026-10-12T00:00:05Z");assertThat(json.path("data").path("sampleDone").booleanValue()).isFalse(); + doThrow(new EgressRejectedException("VALIDATION_HTTP_LIMIT",true,null)).when(s).authorize(null);json=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",null)));assertThat(json.path("data").has("waitUntil")).isTrue();assertThat(json.path("data").path("waitUntil").isNull()).isTrue();assertThat(json.path("data").path("sampleDone").booleanValue()).isTrue(); + } + @Test void authorityDtosRoundTripWithIsoInstantAndExplicitNullableOutcome() throws Exception { + var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var operation=SelfopEgressDtos.Operation.WAREHOUSES;String hash="1".repeat(64);var expiry=java.time.Instant.parse("2026-10-12T00:00:30Z"); + var permit=new SelfopEgressDtos.AttemptPermit(context,operation,hash,1,expiry,true,true);String permitJson=mapper.writeValueAsString(permit);assertThat(mapper.readTree(permitJson).path("expiresAt").asText()).isEqualTo("2026-10-12T00:00:30Z");assertThat(mapper.readValue(permitJson,SelfopEgressDtos.AttemptPermit.class)).isEqualTo(permit); + var request=new SelfopEgressDtos.AuthorizationRequest(context,operation,hash);assertThat(mapper.readValue(mapper.writeValueAsString(request),SelfopEgressDtos.AuthorizationRequest.class)).isEqualTo(request); + var complete=new SelfopEgressDtos.CompletionRequest(context,operation,hash,1,"LINGXING_WMS_SOURCE_RATE_LIMITED",200,31001,true,expiry);assertThat(mapper.readValue(mapper.writeValueAsString(complete),SelfopEgressDtos.CompletionRequest.class)).isEqualTo(complete); + var nullable=new SelfopEgressDtos.CompletionRequest(context,operation,hash,1,null,null,null,false,null);var node=mapper.readTree(mapper.writeValueAsString(nullable));assertThat(node.path("sourceCode").isNull()).isTrue();assertThat(node.path("upstreamStatus").isNull()).isTrue();assertThat(mapper.readValue(node.toString(),SelfopEgressDtos.CompletionRequest.class)).isEqualTo(nullable); + var denial=new SelfopEgressDtos.AuthorizationDenial("ACCOUNT_DAILY_BUDGET",false,expiry);assertThat(mapper.readValue(mapper.writeValueAsString(denial),SelfopEgressDtos.AuthorizationDenial.class)).isEqualTo(denial); + var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);when(s.authorize(request)).thenReturn(permit);var envelope=mapper.readTree(mapper.writeValueAsString(c.authorize("synthetic",request)));assertThat(envelope.path("data")).isEqualTo(mapper.readTree(permitJson));assertThat(envelope.path("data").path("budgetConsumed").booleanValue()).isTrue(); + } + @Test void conflictingCompletionPropagatesRejectionInsteadOfSuccessfulAcknowledgment(){ + var s=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),s);var context=new SelfopEgressDtos.EgressContext("synthetic","VALIDATION","1","selfop-1","attempt","grant");var completion=new SelfopEgressDtos.CompletionRequest(context,SelfopEgressDtos.Operation.WAREHOUSES,"1".repeat(64),1,null,200,200,false,null); + doThrow(new EgressRejectedException("EGRESS_COMPLETION_CONFLICT",false,null)).when(s).complete(completion);assertThatThrownBy(()->c.complete("synthetic",completion)).isInstanceOf(EgressRejectedException.class).hasMessage("EGRESS_COMPLETION_CONFLICT");verify(s).complete(completion); + } + @Test void durableQueriesUseCurrentReadsAndActualColumnNames()throws Exception{for(String name:new String[]{"lockAccount","lockTask","lockRun"}){var m=java.util.Arrays.stream(SelfopEgressMapper.class.getMethods()).filter(x->x.getName().equals(name)).findFirst().orElseThrow();assertThat(String.join(" ",m.getAnnotation(org.apache.ibatis.annotations.Select.class).value())).contains("FOR UPDATE");}assertThat(SelfopEgressMapper.class.isAnnotationPresent(org.apache.ibatis.annotations.Mapper.class)).isTrue();} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java new file mode 100644 index 000000000..418b13912 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -0,0 +1,170 @@ +package com.baiyue.lastleg.selfoperated.analytics.request; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopRequestContext; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressDtos.*; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class SelfopEgressServiceTest { + private final SelfopEgressMapper mapper=mock(SelfopEgressMapper.class); + private final SelfopAnalyticsProperties cfg=new SelfopAnalyticsProperties(); + private final MutableClock clock=new MutableClock(); + private final SelfopOutboundDiscoveryScope account=new SelfopOutboundDiscoveryScope(); + private final SelfopSyncTask task=new SelfopSyncTask();private final SelfopSyncRun run=new SelfopSyncRun(); + private final SelfopEgressService service=new SelfopEgressService(mapper,cfg,clock); + private static final String INTERNAL="1".repeat(64),PROVIDER="2".repeat(64); + SelfopEgressServiceTest(){ + cfg.setSourceAccount("synthetic");cfg.setCollectionMode("VALIDATION");cfg.setExclusiveGrantId("grant");cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2));cfg.setExclusiveHandoffVerified(true);cfg.setPriorEgressClosed(true);cfg.setInflightDrained(true);cfg.setTransferredBudgetVerified(true);cfg.setTransferredBudgetDate(now().plusHours(8).toLocalDate());cfg.setDailyRequestBudget(100);cfg.getWarehouses().put("A",new SelfopAnalyticsProperties.Warehouse());cfg.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse()); + account.setId(10L);account.setSourceAccount("synthetic");account.setActiveTaskId(1L);account.setFenceToken(7L);account.setLeaseUntilUtc(now().plusDays(2)); + task.setId(1L);task.setDiscoveryScopeId(10L);task.setSourceAccount("synthetic");task.setStatus("RUNNING");task.setTaskRole("ACCOUNT_DISCOVERY");task.setExecutionPurpose("VALIDATION");task.setValidationRootTaskId(1L);task.setValidationBudgetJson(SelfopValidationBudget.freeze(now())); + run.setId(2L);run.setTaskId(1L);run.setSourceAccount("synthetic");run.setStatus("FETCHING");run.setExecutionPurpose("VALIDATION");run.setFenceToken(7L); + when(mapper.lockAccount("synthetic")).thenReturn(account);when(mapper.lockTask(1L)).thenReturn(task);when(mapper.lockRun(2L)).thenReturn(run);when(mapper.saveAccount(any())).thenReturn(1);when(mapper.saveRoot(any())).thenReturn(1); + } + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);} + private SelfopRequestContext context(){return new SelfopRequestContext("synthetic",cfg.getCollectionMode(),1L,"selfop-1","grant",1L,2L);} + private EgressContext reserve(Operation op,int size,String wh,String key){return service.reserve(context(),op,size,wh,key,INTERNAL);} + private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);} + private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);} + private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);} + @Test void offUnknownModeNonProductionBusinessAndUnverifiedHandoffFailBeforeAnyLock(){ + cfg.setCollectionMode("OFF");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_MODE_REJECTED"); + cfg.setCollectionMode("BUSINESS");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED"); + cfg.setCollectionMode("VALIDATION");cfg.setPriorEgressClosed(false);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper); + } + @Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();} + @Test void allFamiliesAndFailuresConsumeOneRootAndAccountAcrossServiceRestart(){ + var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES); + c=reserve(Operation.PRODUCT_INVENTORY,2,"A",null);success(c,Operation.PRODUCT_INVENTORY); + c=reserve(Operation.OUTBOUND_LOCATE,2,null,null);success(c,Operation.OUTBOUND_LOCATE); + var restarted=new SelfopEgressService(mapper,cfg,clock);c=restarted.reserve(context(),Operation.OUTBOUND_DETAIL,0,"A","C/DO",INTERNAL); + restarted.authorize(new AuthorizationRequest(c,Operation.OUTBOUND_DETAIL,PROVIDER)); + restarted.complete(new CompletionRequest(c,Operation.OUTBOUND_DETAIL,PROVIDER,1,"LINGXING_WMS_INVALID_RESPONSE",502,null,false,null)); + assertThat(budget().reservedHttp).isEqualTo(4);assertThat(budget().confirmedHttp).isEqualTo(4);assertThat(rootBudget().pages).isEqualTo(3);assertThat(rootBudget().detailHttp).isEqualTo(1);assertThat(rootBudget().detailKeys).containsExactly("C/DO");assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(task.getNextRetryAtUtc()).isNull(); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,2,null,null)).hasMessage("EGRESS_TASK_NOT_ACTIVE"); + } + @Test void accountSingleFlightHasNoNewCounterWhilePendingAndReplayCannotConsumeTwice(){ + var c=reserve(Operation.WAREHOUSES,2,null,null);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,2,null,null)).hasMessage("ACCOUNT_REQUEST_INFLIGHT");assertThat(budget().reservedHttp).isEqualTo(1); + service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); + assertThatThrownBy(()->service.complete(new CompletionRequest(c,Operation.WAREHOUSES,INTERNAL,1,null,200,200,false,null))).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));assertThat(budget().confirmedHttp).isEqualTo(1); + } + @Test void preparedOperationCannotBeSubstitutedAndProviderHashIsBoundOnce(){ + var c=reserve(Operation.WAREHOUSES,2,null,null);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER))).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + var permit=service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));assertThat(permit.allowedAttempts()).isEqualTo(1);assertThat(permit.expiresAt()).isEqualTo(clock.instant().plusSeconds(30));assertThat(budget().pending.internalRequestHash).isEqualTo(INTERNAL);assertThat(budget().pending.requestHash).isEqualTo(PROVIDER); + } + @Test void pageLimitEndsSameRootAndDoesNotResetAcrossRestart(){ + for(int i=0;i<10;i++){var c=reserve(Operation.OUTBOUND_QUERY,1,null,null);success(c,Operation.OUTBOUND_QUERY);} + assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("VALIDATION_PAGE_LIMIT").satisfies(e->assertThat(((EgressRejectedException)e).sampleDone()).isTrue()); + assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");assertThat(rootBudget().http).isEqualTo(10);verify(mapper).terminateValidationRuns(eq(1L),eq("SAMPLE_DONE"),eq("VALIDATION_PAGE_LIMIT"),any()); + } + @Test void inventoryTwoPageLimitPerWarehouseSharesGlobalBudget(){ + success(reserve(Operation.PRODUCT_INVENTORY,1,"A",null),Operation.PRODUCT_INVENTORY);success(reserve(Operation.PRODUCT_INVENTORY,1,"B",null),Operation.PRODUCT_INVENTORY);success(reserve(Operation.PRODUCT_INVENTORY,1,"A",null),Operation.PRODUCT_INVENTORY); + assertThatThrownBy(()->reserve(Operation.PRODUCT_INVENTORY,1,"A",null)).hasMessage("VALIDATION_INVENTORY_LIMIT");assertThat(rootBudget().inventoryPages).containsEntry("A",2).containsEntry("B",1);assertThat(rootBudget().http).isEqualTo(3); + } + @Test void repeatedDetailUsesHttpLimitEvenForSameIdentity(){for(int i=0;i<20;i++)success(reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO"),Operation.OUTBOUND_DETAIL);assertThatThrownBy(()->reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO")).hasMessage("VALIDATION_DETAIL_HTTP_LIMIT");assertThat(rootBudget().detailKeys).hasSize(1);assertThat(rootBudget().detailHttp).isEqualTo(20);} + @Test void fiveMinuteDeadlineTerminalAndFrozenLimitsCannotIncrease(){clock.advance(300);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("VALIDATION_DEADLINE");assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");var b=rootBudget();b.maxHttp=41;assertThatThrownBy(b::validate).hasMessage("VALIDATION_BUDGET_INVALID");} + @Test void lostResponseRetainsChargeAndConservativelyCoolsAcrossRestart(){reserve(Operation.WAREHOUSES,1,null,null);clock.advance(121);assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT");assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero();assertThat(account.getApiPauseReason()).isEqualTo("REQUEST_OUTCOME_UNKNOWN");assertThat(account.getApiCooldownUntilUtc()).isAfter(now());} + @Test void rateLimitStopsRootAndAllFamiliesRetainCooldown(){var c=reserve(Operation.PRODUCT_INVENTORY,1,"A",null);service.authorize(new AuthorizationRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER));service.complete(new CompletionRequest(c,Operation.PRODUCT_INVENTORY,PROVIDER,1,"LINGXING_WMS_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300)));assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(rootBudget().stopReason).isEqualTo("RATE_LIMITED");assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(300));assertThat(budget().reservedHttp).isEqualTo(1);verify(mapper).terminateValidationRuns(eq(1L),eq("FAILED"),eq("RATE_LIMITED"),any());} + @Test void inheritedDailyBudgetIsNotResetByNewGrantOrDate(){cfg.setTransferredDailyHttpCount(99);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);assertThat(budget().reservedHttp).isEqualTo(100);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_DAILY_BUDGET");cfg.setExclusiveGrantId("grant2");cfg.setTransferredDailyHttpCount(3);assertThatThrownBy(()->service.reserve(new SelfopRequestContext("synthetic","VALIDATION",1L,"selfop-1","grant2",1L,2L),Operation.OUTBOUND_QUERY,1,null,null,INTERNAL)).hasMessage("ACCOUNT_DAILY_BUDGET");assertThat(budget().reservedHttp).isEqualTo(100);} + @Test void actualStartIntervalUsesAuthorizationTimeAndReturnsQueueDeadline(){var c=reserve(Operation.WAREHOUSES,1,null,null);clock.advance(4);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));clock.advance(1);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT").satisfies(e->assertThat(((EgressRejectedException)e).waitUntilUtc()).isEqualTo(now().plusSeconds(4)));} + @Test void obsoleteExecutionFenceCannotReserveOrConsume(){run.setFenceToken(6L);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_FENCE_LOST");run.setFenceToken(7L);var c=reserve(Operation.WAREHOUSES,1,null,null);account.setFenceToken(8L);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_FENCE_LOST");assertThat(budget().reservedHttp).isEqualTo(1);} + @Test void totalHttpCapCountsEveryOperationAndPreparedReservation(){var b=rootBudget();b.http=40;task.setValidationBudgetJson(SelfopSupport.json(b));assertThatThrownBy(()->reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO")).hasMessage("VALIDATION_HTTP_LIMIT");assertThat(task.getStatus()).isEqualTo("SAMPLE_DONE");} + @Test void warehouseRequestsShareAccountBudgetAndRejectScopeMismatch(){task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(100L);task.setWarehouseCode("A");run.setScopeId(100L);run.setWarehouseCode("A");when(mapper.warehouseFence(eq(100L),eq(7L),eq(1L),any())).thenReturn(1);var c=reserve(Operation.PRODUCT_INVENTORY,1,"A",null);success(c,Operation.PRODUCT_INVENTORY);assertThat(budget().reservedHttp).isEqualTo(1);run.setWarehouseCode("B");assertThatThrownBy(()->reserve(Operation.PRODUCT_INVENTORY,1,"A",null)).hasMessage("EGRESS_FENCE_LOST");} + @Test void unknownGrantTransferCooldownIsPreservedAndExpiredGrantCannotIssue(){cfg.setTransferredCooldownUntilUtc(now().plusSeconds(100));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT");cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");} + @Test void persistedDailyCapCannotBeRaisedMiddayToBypassBudget(){cfg.setDailyRequestBudget(1);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);cfg.setDailyRequestBudget(100);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_DAILY_BUDGET");assertThat(budget().dailyLimit).isEqualTo(1);} + @Test void oldAttemptCannotConsumeAfterNextPreparedRequest(){var c=reserve(Operation.WAREHOUSES,1,null,null);success(c,Operation.WAREHOUSES);reserve(Operation.OUTBOUND_QUERY,1,null,null);assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_BINDING_INVALID");} + @Test void authorizationsLockAccountBeforeRootAndTask(){var c=reserve(Operation.WAREHOUSES,1,null,null);clearInvocations(mapper);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));var order=inOrder(mapper);order.verify(mapper).lockAccount("synthetic");order.verify(mapper,times(2)).lockTask(1L);order.verify(mapper).lockRun(2L);} + @Test void completedPayloadIsIdempotentAcrossRestartAndBusinessDayWithoutChangingJournal(){ + var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null);service.complete(completion); + String journal=account.getApiBudgetJson(),rootJournal=task.getValidationBudgetJson();String rootStatus=task.getStatus(); + clearInvocations(mapper);var restarted=new SelfopEgressService(mapper,cfg,clock);restarted.complete(completion);clock.advance(86400);restarted.complete(completion); + assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(task.getValidationBudgetJson()).isEqualTo(rootJournal);assertThat(task.getStatus()).isEqualTo(rootStatus); + assertThat(budget().pending.completionHash).isEqualTo(SelfopSupport.hash(completion));verify(mapper,never()).saveAccount(any());verify(mapper,never()).saveRoot(any()); + } + @Test void everyChangedCompletionOutcomeConflictsWithoutOverwritingDurableResult(){ + var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + var completed=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,31001,true,clock.instant().plusSeconds(300));service.complete(completed); + var changed=List.of( + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,0,completed.errorCode(),429,31001,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"OTHER_ERROR",429,31001,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),200,31001,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,429,true,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,31001,false,completed.cooldownUntil()), + new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,completed.errorCode(),429,31001,true,completed.cooldownUntil().plusSeconds(1))); + String journal=account.getApiBudgetJson(),rootJournal=task.getValidationBudgetJson();var cooldown=account.getApiCooldownUntilUtc();String pause=account.getApiPauseReason(); + clearInvocations(mapper); + for(var candidate:changed){assertThatThrownBy(()->service.complete(candidate)).isInstanceOf(EgressRejectedException.class).hasMessage("EGRESS_COMPLETION_CONFLICT");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(task.getValidationBudgetJson()).isEqualTo(rootJournal);assertThat(task.getStatus()).isEqualTo("DEAD");assertThat(account.getApiCooldownUntilUtc()).isEqualTo(cooldown);assertThat(account.getApiPauseReason()).isEqualTo(pause);} + assertThat(budget().pending.completionHash).isEqualTo(SelfopSupport.hash(completed));assertThat(budget().confirmedHttp).isEqualTo(1);verify(mapper,never()).saveAccount(any());verify(mapper,never()).saveRoot(any()); + } + @Test void provider31001And429AreRateLimitsEvenWithHttp200AndNoPauseFlag(){ + for(int sourceCode:new int[]{31001,429}){ + var fixture=new SelfopEgressServiceTest();var c=fixture.reserve(Operation.WAREHOUSES,1,null,null);fixture.service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + fixture.service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,sourceCode,false,null)); + assertThat(fixture.task.getStatus()).isEqualTo("DEAD");assertThat(fixture.rootBudget().stopReason).isEqualTo("RATE_LIMITED");assertThat(fixture.budget().rateLimitFailures).isEqualTo(1);assertThat(fixture.account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN"); + } + } + private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");} + @Test void committedCompletionWithLostReplyStaysPausedAcrossIntegrationRestart(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); + String journal=account.getApiBudgetJson();var until=now().plusSeconds(600);account.setApiCooldownUntilUtc(until); + service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal); + assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);assertThat(budget().confirmedHttp).isEqualTo(1); + clock.advance(601);var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW"); + } + @Test void uncertainUncompletedAttemptCannotDowngradeManualPauseWhenItsLeaseExpires(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE");clock.advance(121); + var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(61); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero(); + } + @Test void uncertainPauseRequiresExactReservedContextAttemptAndOperation(){ + var c=reserve(Operation.WAREHOUSES,1,null,null);String journal=account.getApiBudgetJson();clearInvocations(mapper); + var wrong=new EgressContext(c.sourceAccount(),c.executionPurpose(),c.rootTaskId(),c.sessionId(),"different-attempt",c.exclusiveGrantId()); + assertThatThrownBy(()->service.pauseUncertain(wrong,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE")).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + assertThatThrownBy(()->service.pauseUncertain(c,Operation.OUTBOUND_DETAIL,"INTEGRATION_TRANSPORT_FAILURE")).hasMessage("EGRESS_PERMIT_BINDING_INVALID"); + assertThat(account.getApiPauseReason()).isNull();assertThat(account.getApiBudgetJson()).isEqualTo(journal);verify(mapper,never()).saveAccount(any()); + } + @Test void confirmedRatePauseIsNotUpgradedAndStrongerReviewIsPreserved(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300))); + String journal=account.getApiBudgetJson();var until=account.getApiCooldownUntilUtc();clearInvocations(mapper); + service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_HTTP_RATE_LIMITED"); + assertThat(account.getApiPauseReason()).isEqualTo("RATE_LIMIT_COOLDOWN");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until);verify(mapper,never()).saveAccount(any()); + account.setApiPauseReason("AUTHORIZATION_REQUIRES_REVIEW");service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE"); + assertThat(account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until); + } + @Test void rateCompletionCommittedButAckLostRequiresReviewDespitePersistedCooldown(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,clock.instant().plusSeconds(300))); + String journal=account.getApiBudgetJson();var until=account.getApiCooldownUntilUtc(); + service.pauseUncertain(c,Operation.WAREHOUSES,"LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED"); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");assertThat(account.getApiBudgetJson()).isEqualTo(journal);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(until); + clock.advance(301);assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);assertThat(budget().rateLimitFailures).isEqualTo(1); + } + @Test void nonRatePauseRequiresReviewAfterCooldownAndRestartWithoutIncreasingRateFailures(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_INVALID_RESPONSE",502,null,true,clock.instant().plusSeconds(60))); + assertThat(budget().rateLimitFailures).isZero();assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(61); + assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");assertThat(budget().reservedHttp).isEqualTo(1);assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW"); + } + @Test void httpAuthorizationFailuresRequireReviewAfterRestart(){ + for(int status:new int[]{401,403}){var fixture=new SelfopEgressServiceTest();fixture.business();var c=fixture.reserve(Operation.WAREHOUSES,1,null,null);fixture.service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));fixture.service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,status,null,false,null));assertThat(fixture.budget().rateLimitFailures).isZero();assertThat(fixture.account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");fixture.clock.advance(61);assertThatThrownBy(()->new SelfopEgressService(fixture.mapper,fixture.cfg,fixture.clock).reserve(fixture.context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");} + } + @Test void businessTrueRateLimitRetainsBoundedBackoffAndMayIssueFreshPermitAfterCooldown(){ + business();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null)); + assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(60));assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT");clock.advance(60); + var restarted=new SelfopEgressService(mapper,cfg,clock);var next=restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL);assertThat(next.attemptId()).isNotEqualTo(c.attemptId());assertThat(restarted.authorize(new AuthorizationRequest(next,Operation.WAREHOUSES,PROVIDER)).allowedAttempts()).isEqualTo(1);restarted.complete(new CompletionRequest(next,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_SOURCE_RATE_LIMITED",200,31001,false,null));assertThat(budget().rateLimitFailures).isEqualTo(2);assertThat(account.getApiCooldownUntilUtc()).isEqualTo(now().plusSeconds(120));assertThat(budget().reservedHttp).isEqualTo(2); + } + private static final class MutableClock extends Clock {private Instant instant=Instant.parse("2026-10-12T00:00:00Z");void advance(long seconds){instant=instant.plusSeconds(seconds);}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;}public Instant instant(){return instant;}} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java new file mode 100644 index 000000000..06a47c8ce --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.selfoperated.analytics.retention; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.*; +import org.mockito.InOrder; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SalesRetentionServiceTest { + final SalesRetentionMapper mapper=mock(SalesRetentionMapper.class); + final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties(); + final SalesRetentionService service=new SalesRetentionService(mapper,config); + SelfopSyncScope scope; SelfopSyncRun old; + @BeforeEach void setup() { + scope=new SelfopSyncScope(); scope.setId(1L);scope.setSourceAccount("demo");scope.setWarehouseCode("A");scope.setDataFamily("OUTBOUND");scope.setPublishedRunId(30L); + old=new SelfopSyncRun();old.setId(10L);old.setScopeId(1L);old.setSourceAccount("demo");old.setWarehouseCode("A");old.setRunRole("WAREHOUSE_SYNC"); + old.setStatus("PUBLISHED");old.setExecutionPurpose("BUSINESS");old.setPublishedAtUtc(SelfopSupport.utcNow().minusDays(9));old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8)); + when(mapper.scope(1L)).thenReturn(scope);when(mapper.run(10L)).thenReturn(old);when(mapper.previous(1L,30L)).thenReturn(20L); + } + @Test void deletionIsOffByDefault() { + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));verifyNoInteractions(mapper); + } + @Test void shortRetentionAndOversizedBatchCannotWeakenGuards() { + config.setRetentionEnabled(true);config.setSalesRetentionDays(1);config.setPersistenceBatchSize(1000); + old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(6));assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + clearInvocations(mapper); + old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8));when(mapper.deleteDaily(10L,500)).thenReturn(500);when(mapper.deleteProfiles(10L,500)).thenReturn(8); + assertEquals(new SalesRetentionService.Deleted(10L,500,8),service.deleteBatch(1L,10L)); + InOrder order=inOrder(mapper);order.verify(mapper).scope(1L);order.verify(mapper).run(10L); + order.verify(mapper).previous(1L,30L);order.verify(mapper).activeReferences("demo","A",10L); + order.verify(mapper).deleteDaily(10L,500);order.verify(mapper).deleteProfiles(10L,500); + // Actual deletion follows scope acquisition, current-pointer/reference checks and fixed bounded predicates. + verify(mapper).deleteDaily(10L,500);verify(mapper).deleteProfiles(10L,500); + } + @Test void currentPreviousActiveInputAndUnpublishedCandidatesAreProtected() { + config.setRetentionEnabled(true); + scope.setPublishedRunId(10L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + scope.setPublishedRunId(30L);when(mapper.previous(1L,30L)).thenReturn(10L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + when(mapper.previous(1L,30L)).thenReturn(20L);when(mapper.activeReferences("demo","A",10L)).thenReturn(1L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + when(mapper.activeReferences("demo","A",10L)).thenReturn(0L);old.setStatus("FAILED");assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + verify(mapper,never()).deleteDaily(anyLong(),anyInt());verify(mapper,never()).deleteProfiles(anyLong(),anyInt()); + } + @Test void pointerAndReferencesRecheckedForEveryBatch() { + config.setRetentionEnabled(true);service.deleteBatch(1L,10L); + when(mapper.activeReferences("demo","A",10L)).thenReturn(1L); + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + verify(mapper,times(2)).scope(1L);verify(mapper,times(2)).activeReferences("demo","A",10L); + verify(mapper,times(1)).deleteDaily(10L,500); + } + @Test void inventoryScopeAndDailyRepresentativeCannotBeCleanedAsSalesCopies() { + config.setRetentionEnabled(true); + scope.setDataFamily("INVENTORY"); + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + scope.setDataFamily("OUTBOUND"); + old.setInventoryDailySampleDate(java.time.LocalDate.of(2026,10,1)); + old.setInventorySampleRuleVersion("DAILY_SCHEDULED_FIRST_V1"); + assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L)); + verify(mapper,never()).deleteDaily(anyLong(),anyInt()); + verify(mapper,never()).deleteProfiles(anyLong(),anyInt()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java new file mode 100644 index 000000000..51a8b6a9f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class SalesBucketPolicyTest { + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final LocalDate DATE = LocalDate.of(2026, 10, 10); + private static final LocalDate ANCHOR = DATE.plusDays(2); + private static final Instant START = DATE.atStartOfDay(ZONE).toInstant(); + private static final Instant END = DATE.plusDays(1).atStartOfDay(ZONE).toInstant(); + @Test void actualCompleteDayNeedsNoThirtyDayObservation() { + var point = evaluate(DATE, START, END, null, new BigDecimal("2"), true, false, false); + assertEquals("COMPLETE", point.dataStatus()); + assertEquals(new BigDecimal("2"), point.outboundQuantity()); + assertEquals(START, point.provedFrom()); + assertEquals(END, point.provedUntil()); + } + @Test void emptyCompleteDayRequiresExistenceAtMidnightForReliableZero() { + var zero = evaluate(DATE, START, END, START, null, true, false, false); + assertEquals("COMPLETE", zero.dataStatus()); + assertEquals(BigDecimal.ZERO, zero.outboundQuantity()); + var partial = evaluate(DATE, START, END, START.plusSeconds(16 * 3600), null, true, false, false); + assertEquals("PARTIAL", partial.dataStatus()); + assertNull(partial.outboundQuantity()); + assertEquals(BigDecimal.ZERO, partial.observedQuantity()); + } + @Test void firstCollectionMissingMidnightIsPartialEvenWithEarlierExistence() { + var point = evaluate(DATE, START.plusSeconds(3600), END, START.minusSeconds(1), null, true, false, false); + assertEquals("PARTIAL", point.dataStatus()); + assertEquals(BigDecimal.ZERO, point.observedQuantity()); + assertNull(point.outboundQuantity()); + assertEquals(START.plusSeconds(3600), point.provedFrom()); + } + @Test void laterEvidenceDoesNotCreateZeroBeyondCoveredWatermark() { + var point = evaluate(DATE, START, START.plusSeconds(8 * 3600), START.plusSeconds(16 * 3600), null, true, false, false); + assertEquals("UNKNOWN", point.dataStatus()); + assertNull(point.observedQuantity()); + var exactBoundary = evaluate(DATE, START, START.plusSeconds(8 * 3600), START.plusSeconds(8 * 3600), null, true, false, false); + assertEquals("UNKNOWN", exactBoundary.dataStatus()); + } + @Test void actualRowOutsideContinuousCoverageStillShowsObservedFact() { + var point = evaluate(DATE, END, END.plusSeconds(3600), null, new BigDecimal("4"), true, true, true); + assertEquals("PARTIAL", point.dataStatus()); + assertEquals(new BigDecimal("4"), point.observedQuantity()); + assertNull(point.outboundQuantity()); + assertNull(point.provedFrom()); + assertTrue(point.reasonCodes().containsAll(List.of("SALES_REBUILD_REQUIRED", "SALES_STALE"))); + } + @Test void missingEvidenceMissingCoverageAndUnknownUnitRemainUnknown() { + assertEquals("UNKNOWN", evaluate(DATE, START, END, null, null, true, false, false).dataStatus()); + assertEquals("UNKNOWN", evaluate(DATE, null, null, START, null, true, false, false).dataStatus()); + var unit = evaluate(DATE, START, END, START, BigDecimal.TEN, false, false, false); + assertNull(unit.observedQuantity()); + assertTrue(unit.reasonCodes().contains("UNIT_UNKNOWN_OR_CONFLICT")); + var unavailableVersion = SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(DATE, ANCHOR, ZONE, + DATE, ANCHOR, START, END, START, BigDecimal.TEN, 1L, true, false, false, false)); + assertEquals("UNKNOWN", unavailableVersion.dataStatus()); + assertNull(unavailableVersion.observedQuantity()); + } + @Test void todayNeverCompleteAndThreeDayAggregatePreservesCollectedFive() { + var first = evaluate(DATE, START, END, START, new BigDecimal("2"), true, false, false); + var second = evaluate(DATE.plusDays(1), START, END.plusSeconds(86400), START, null, true, false, false); + var today = evaluate(ANCHOR, START, END.plusSeconds(2 * 86400), START, new BigDecimal("3"), true, false, false); + assertEquals("PARTIAL", today.dataStatus()); + assertTrue(today.reasonCodes().contains("TODAY_INCOMPLETE")); + var summary = SalesBucketPolicy.aggregate(List.of(first, second, today)); + assertNull(summary.outboundQuantity()); + assertEquals(new BigDecimal("5"), summary.observedQuantity()); + assertEquals(2, summary.completeDays()); + assertEquals(1, summary.partialDays()); + assertEquals("PARTIAL", summary.dataStatus()); + } + @Test void fullyUnknownAndEmptyRangesDoNotSumToZeroWhileCompleteZerosDo() { + var unknown = evaluate(DATE, null, null, null, null, true, false, false); + var summary = SalesBucketPolicy.aggregate(List.of(unknown)); + assertEquals("UNKNOWN", summary.dataStatus()); + assertNull(summary.observedQuantity()); + assertNull(SalesBucketPolicy.aggregate(List.of()).observedQuantity()); + var zero = evaluate(DATE, START, END, START, null, true, false, false); + assertEquals(BigDecimal.ZERO, SalesBucketPolicy.aggregate(List.of(zero)).outboundQuantity()); + } + private SalesBucketPolicy.Point evaluate(LocalDate date, Instant from, Instant until, Instant evidence, + BigDecimal quantity, boolean unit, boolean rebuild, boolean stale) { + return SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(date, ANCHOR, ZONE, DATE, + ANCHOR.plusDays(1), from, until, evidence, quantity, quantity == null ? null : 1L, + unit, true, rebuild, stale)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java new file mode 100644 index 000000000..86f5c2b29 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.selfoperated.analytics.statistics; + +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import static org.junit.jupiter.api.Assertions.*; + +class SalesRangePolicyTest { + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final Instant AS_OF = Instant.parse("2026-10-12T02:00:00Z"); + @Test void fiveNaturalPresetsKeepDailyPoints() { + String[] presets = {"TODAY", "LAST_3_DAYS", "THIS_WEEK", "LAST_15_DAYS", "THIS_MONTH"}; + String[] starts = {"2026-10-12", "2026-10-10", "2026-10-12", "2026-09-28", "2026-10-01"}; + int[] counts = {1, 3, 1, 15, 12}; + for (int i = 0; i < presets.length; i++) { + var range = SalesRangePolicy.resolve(presets[i], null, null, "DAY", AS_OF, ZONE, 366); + assertEquals(LocalDate.parse(starts[i]), range.startDate()); + assertEquals(counts[i], range.dayCount()); + assertEquals(LocalDate.of(2026, 10, 13), range.endDateExclusive()); + assertEquals(Instant.parse("2026-10-12T16:00:00Z"), range.endAtExclusive()); + } + } + @Test void weekMayCrossYearAndAnchorUsesExplicitZone() { + var range = SalesRangePolicy.resolve("THIS_WEEK", null, null, "DAY", + Instant.parse("2025-12-31T17:00:00Z"), ZONE, 366); + assertEquals(LocalDate.of(2026, 1, 1), range.anchorDate()); + assertEquals(LocalDate.of(2025, 12, 29), range.startDate()); + assertEquals(4, range.dayCount()); + } + @Test void customIncludesLeapDayAndHasHardMaximum() { + var asOf = Instant.parse("2024-12-31T04:00:00Z"); + var range = SalesRangePolicy.resolve("CUSTOM", LocalDate.of(2024, 1, 1), + LocalDate.of(2024, 12, 31), "DAY", asOf, ZONE, 366); + assertEquals(366, range.dayCount()); + assertThrows(IllegalArgumentException.class, () -> SalesRangePolicy.resolve("CUSTOM", + LocalDate.of(2023, 12, 31), LocalDate.of(2024, 12, 31), "DAY", asOf, ZONE, 366)); + } + @Test void explicitTestZoneUsesCalendarDaysAcrossDaylightSavingChange() { + var range = SalesRangePolicy.resolve("TODAY", null, null, "DAY", + Instant.parse("2026-03-08T12:00:00Z"), ZoneId.of("America/New_York"), 366); + assertEquals(1, range.dayCount()); + assertEquals(23 * 3600L, java.time.Duration.between(range.startAt(), range.endAtExclusive()).getSeconds()); + } + @Test void conflictsUnknownEnumsFutureReversedAndMissingDatesAreRejected() { + assertThrows(IllegalArgumentException.class, () -> resolve("WEEK", null, null, "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("TODAY", null, null, "WEEK")); + assertThrows(IllegalArgumentException.class, () -> resolve("TODAY", LocalDate.of(2026, 10, 1), null, "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", null, null, "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", LocalDate.of(2026, 10, 12), LocalDate.of(2026, 10, 13), "DAY")); + assertThrows(IllegalArgumentException.class, () -> resolve("CUSTOM", LocalDate.of(2026, 10, 12), LocalDate.of(2026, 10, 11), "DAY")); + assertThrows(IllegalArgumentException.class, () -> SalesRangePolicy.resolve("LAST_3_DAYS", null, null, "DAY", AS_OF, ZONE, 2)); + } + private SalesRangePolicy.Range resolve(String preset, LocalDate start, LocalDate end, String bucket) { + return SalesRangePolicy.resolve(preset, start, end, bucket, AS_OF, ZONE, 366); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java new file mode 100644 index 000000000..58877357d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.time.LocalDateTime; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.read; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SelfopCommandServiceTest { + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopCommandService service = new SelfopCommandService(taskService, scopes, accounts, config, tasks); + + private void configure() { + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSourceAccount("synthetic-account"); + config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse()); + } + + @Test void retryCannotResumeTaskBelongingToAnotherWarehouse() { + configure(); + var original = original("synthetic-account", "WH-B"); + when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; }); + assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH"); + verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString()); + verify(taskService, never()).create(any(SelfopSyncTask.class)); + assertThat(original.getStatus()).isEqualTo("DEAD"); + } + + @Test void retryCannotResumeTaskBelongingToAnotherSourceAccount() { + configure(); + var original = original("different-source-account", "WH-A"); + when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; }); + assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH"); + verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString()); + verify(taskService, never()).create(any(SelfopSyncTask.class)); + assertThat(original.getStatus()).isEqualTo("DEAD"); + } + + @Test void unknownSourceTimezoneAllowsRawBackfillTaskWithoutClaimingUtcCoverage() { + configure(); + var scope = new SelfopSyncScope(); scope.setId(10L); scope.setScopeHash("synthetic-scope-hash"); + var account = new SelfopOutboundDiscoveryScope(); account.setId(20L); + when(scopes.selectOne(any())).thenReturn(scope); + when(accounts.selectOne(any())).thenReturn(account); + when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(call -> call.getArgument(0)); + var from = LocalDateTime.of(2026, 10, 11, 8, 0); + var until = from.plusHours(12); + var command = new SelfopCommandService.Command("raw-slice", "WH-A", "BACKFILL", null, from, until, null, null, "offline sample"); + var task = service.request(command, "42"); + assertThat(config.getSourceQueryTimezone()).isNull(); + assertThat(config.isExecutionEnabled()).isTrue(); + assertThat(task.getQueryFromUtc()).isNull(); + assertThat(task.getQueryUntilUtc()).isNull(); + assertThat(task.getTaskRole()).isEqualTo("ACCOUNT_DISCOVERY"); + assertThat(task.getDiscoveryScopeId()).isEqualTo(20L); + var query = read(task.getQueryScopeJson(), SelfopDiscoveryService.Query.class); + assertThat(query.startTime()).isEqualTo(from.toString()); + assertThat(query.endTime()).isEqualTo(until.toString()); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(taskService).create(captured.capture()); + assertThat(captured.getValue()).isSameAs(task); + verifyNoInteractions(tasks); + } + + private SelfopSyncTask original(String sourceAccount, String warehouse) { + var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(100L); task.setSourceAccount(sourceAccount); + task.setWarehouseCode(warehouse); task.setTaskRole("WAREHOUSE_SYNC"); task.setStatus("DEAD"); + return task; + } + @Test void offModeAndNonProductionBusinessDoNotAcceptNewCollectionTask() { + configure();config.setCollectionMode("OFF"); + assertThat(config.isExecutionEnabled()).isFalse(); + var c=new SelfopCommandService.Command("disabled","WH-A","INVENTORY",null,null,null,null,null,"offline test"); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_PURPOSE_INVALID"); + config.setCollectionMode("BUSINESS");config.setEnvironment("test"); + assertThat(config.isExecutionEnabled()).isFalse(); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_BUSINESS_ENVIRONMENT_REJECTED"); + verify(taskService,never()).create(any(SelfopSyncTask.class)); + } + @Test void validationModeRejectsFormalInventoryEntry() { + configure();config.setCollectionMode("VALIDATION"); + var c=new SelfopCommandService.Command("wrong-purpose","WH-A","INVENTORY",null,null,null,null,null,"offline test"); + assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_VALIDATION_ENTRY_REQUIRED"); + verify(taskService,never()).create(any(SelfopSyncTask.class)); + } + private SelfopCommandService.Command retry() { + return new SelfopCommandService.Command("retry-key", "WH-A", "RETRY", "LXCOL-20261012-00000001", null, null, null, null, "offline retry"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java new file mode 100644 index 000000000..fd611b675 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java @@ -0,0 +1,213 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SelfopDiscoveryServiceTest { + private final LingxingAnalyticsClient client = mock(LingxingAnalyticsClient.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopOutboundOrderMapper orders = mock(SelfopOutboundOrderMapper.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(30L, "account-worker", 4L, true); + private final Map stored = new LinkedHashMap<>(); + private final Map children = new LinkedHashMap<>(); + private final LocalDateTime from = LocalDateTime.of(2026, 10, 11, 0, 0); + private final LocalDateTime until = from.plusDays(1); + private SelfopDiscoveryService service; + private SelfopSyncTask task; + private SelfopSyncRun run; + private SelfopSyncScope scope; + private SelfopOutboundDiscoveryScope account; + + @BeforeEach void prepare() { + doAnswer(inv -> { Consumer cb = inv.getArgument(0); cb.accept(mock(TransactionStatus.class)); return null; }) + .when(tx).executeWithoutResult(any()); + account = new SelfopOutboundDiscoveryScope(); account.setId(30L); account.setRequestBudget(10); account.setTimeBudgetSeconds(60); + when(accounts.selectById(30L)).thenReturn(account); + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setSourceAccount("demo"); task.setDiscoveryScopeId(30L); + task.setDiscoverySequence(17L); task.setObservationBaseline(90L); task.setTaskType("SCHEDULED"); task.setScopeHash("account-hash"); + task.setQueryFromUtc(from); task.setQueryUntilUtc(until); task.setStatus("RUNNING"); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from.toString(), until.toString(), null, null, null))); + run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(20L); run.setStatus("FETCHING"); + scope = new SelfopSyncScope(); scope.setId(10L); scope.setSourceAccount("demo"); scope.setWarehouseCode("A"); + scope.setDataFamily("OUTBOUND"); scope.setScopeHash("warehouse-hash"); scope.setScopeJson("{}"); scope.setSourceTimezone("UTC"); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setSourceQueryTimezone("UTC"); config.setQuantityUnit("PCS"); + when(scopes.selectList(any())).thenReturn(List.of(scope)); + when(leases.nextObservation(lease)).thenReturn(101L, 102L, 103L); + when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(inv -> { + SelfopSyncTask child = inv.getArgument(0); + child.setId(100L + children.size()); + return children.computeIfAbsent(child.getRequestKey(), ignored -> child); + }); + doAnswer(inv -> { SelfopSyncStage stage = inv.getArgument(0); stored.put(stage.getId(), stage); return 1; }) + .when(stages).insert(any(SelfopSyncStage.class)); + doAnswer(inv -> { SelfopSyncStage stage = inv.getArgument(0); stored.put(stage.getId(), stage); return 1; }) + .when(stages).updateById(any(SelfopSyncStage.class)); + when(stages.selectById(anyLong())).thenAnswer(inv -> stored.get((Long) inv.getArgument(0))); + when(stages.selectOne(any())).thenAnswer(inv -> { + QueryWrapper query = inv.getArgument(0); String sql = query.getSqlSegment(); + if (sql.contains("observation_sequence")) return null; + var values = query.getParamNameValuePairs().values(); + return stored.values().stream().filter(s -> values.contains(s.getStageBatchId()) && values.contains(s.getObjectKeyHash())).findFirst().orElse(null); + }); + when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString())).thenAnswer(inv -> detail(inv.getArgument(3), "2026-10-11 12:00:00")); + service = new SelfopDiscoveryService(client, tasks, runs, stages, orders, scopes, accounts, taskService, leases, config, tx); + } + private LingxingAnalyticsClient.Summary summary(String no, String time) { + return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time); + } + private SelfopPayloads.Order detail(String no, String time) { + return new SelfopPayloads.Order("demo", "customer", "A", no, null, 100, null, time, + List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS"))); + } + private LingxingAnalyticsClient.Page page(int current, int size, long total, int pages, + LingxingAnalyticsClient.Summary... rows) { return new LingxingAnalyticsClient.Page<>(List.of(rows), current, size, total, pages); } + private void directoryTwoOrders() { + when(client.discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2))).thenReturn(page(1, 2, 2, 1, + summary("one", "2026-10-11 12:00:00"), summary("two", "2026-10-11 12:00:00"))); + } + + @Test void paginatedLocateYieldsDurableCursorThenResumesWithoutFiniteFailure() { + config.setPageSize(1); account.setRequestBudget(1); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(null, null, "target", "customer", null))); + when(client.locate(any(SelfopRequestContext.class), eq("target"), eq(1), eq(1))).thenReturn(page(1, 1, 2, 2, summary("target", "2026-10-11 12:00:00"))); + when(client.locate(any(SelfopRequestContext.class), eq("target"), eq(2), eq(1))).thenReturn(page(2, 1, 2, 2, summary("other", "2026-10-11 12:00:00"))); + service.collect(task, run, lease); + var saved = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(2, saved.getNextPage()); assertEquals(1, saved.getObjects().size()); assertFalse(saved.isDirectoryComplete()); + verify(taskService).yield(1L, 20L, lease); verify(client, never()).detail(any(SelfopRequestContext.class), anyString(), anyString(), anyString()); + account.setRequestBudget(10); service.collect(task, run, lease); + verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("target"), eq(1), eq(1)); verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("target"), eq(2), eq(1)); + assertEquals("ROUTED", run.getStatus()); + verify(taskService, never()).fail(anyLong(), anyLong(), anyString(), anyBoolean(), any()); + } + @Test void stableDirectoryScansYieldAndResumeBeforeDetailsWithoutRepeatingFirstScan() { + directoryTwoOrders(); account.setRequestBudget(1); + service.collect(task, run, lease); + var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(2, cursor.getScan()); assertEquals(1, cursor.getNextPage()); assertEquals(2, cursor.getFirstScan().size()); + assertEquals(2, stored.size()); assertTrue(children.isEmpty()); + account.setRequestBudget(10); service.collect(task, run, lease); + verify(client, times(2)).discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2)); + verify(client, times(2)).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), anyString()); + assertEquals(2, stored.size()); assertTrue(stored.values().stream().allMatch(s -> "VALID".equals(s.getValidationStatus()))); + assertEquals("ROUTED", run.getStatus()); assertEquals(2L, run.getUniqueObjectCount()); + } + @Test void eachActualDetailGetsIndependentObservationNotTaskSequence() { + directoryTwoOrders(); service.collect(task, run, lease); + assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList())); + assertTrue(stored.values().stream().allMatch(s -> s.getStageBatchId().equals(1L) && !s.getObservationSequence().equals(task.getDiscoverySequence()))); + verify(leases, times(2)).nextObservation(lease); + assertEquals(17L, children.values().iterator().next().getDiscoverySequence()); + var contexts=org.mockito.ArgumentCaptor.forClass(SelfopRequestContext.class); + verify(client,times(2)).detail(contexts.capture(),eq("A"),eq("customer"),anyString()); + assertTrue(contexts.getAllValues().stream().allMatch(c->"demo".equals(c.sourceAccount())&&"BUSINESS".equals(c.executionPurpose()) + &&Objects.equals(c.taskId(),1L)&&Objects.equals(c.runId(),20L))); + } + @Test void unknownWarehouseSourceTimezoneKeepsChildWaitingAndNoSalesCoverage() { + directoryTwoOrders(); scope.setSourceTimezone(null); service.collect(task, run, lease); + var child = children.values().iterator().next(); assertEquals("WAITING_SOURCE", child.getStatus()); + assertEquals(2L, read(child.getRoutingManifestJson(), SelfopRoutingManifest.class).getTotal()); + assertNull(scope.getDiscoveryFromUtc()); assertNull(scope.getDiscoveryUntilUtc()); + assertNull(scope.getPublishedRunId()); verify(taskService, never()).begin(anyLong(), any()); + } + @Test void knownExclusiveEndpointRemainsAccountEvidenceButLeavesWarehouseSlice() { + when(client.discover(any(SelfopRequestContext.class), eq(from), eq(until), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, summary("end", "2026-10-12 00:00:00"))); + when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("end"))).thenReturn(detail("end", "2026-10-12 00:00:00")); + service.collect(task, run, lease); + assertEquals(1L, run.getSourceTotalCount()); assertEquals(1L, run.getUniqueObjectCount()); + var child = children.values().iterator().next(); var routed = read(child.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(0L, routed.getTotal()); assertTrue(routed.getObjects().isEmpty()); assertEquals("READY", child.getStatus()); + assertEquals(1, stored.size()); assertEquals("VALID", stored.values().iterator().next().getValidationStatus()); + } + + @Test void relocationBudgetYieldPersistsRefreshAndCurrentWarehouseThenResumesWithoutLocatingAgain() { + String key = completedParent("moved"); account.setRequestBudget(2); + when(tasks.selectById(anyLong())).thenAnswer(inv -> { + Long id = inv.getArgument(0); + return id.equals(task.getId()) ? task : children.values().stream().filter(t -> id.equals(t.getId())).findFirst().orElse(null); + }); + Map refreshRuns = new HashMap<>(); + when(taskService.begin(anyLong(), eq(lease))).thenAnswer(inv -> { + Long id = inv.getArgument(0); + return refreshRuns.computeIfAbsent(id, ignored -> { + var attempt = new SelfopSyncRun(); attempt.setExecutionPurpose("BUSINESS"); attempt.setSourceAccount("demo"); attempt.setId(1000L + id); attempt.setTaskId(id); + attempt.setAttemptNo(1); attempt.setFenceToken(lease.fence()); attempt.setStatus("FETCHING"); return attempt; + }); + }); + doAnswer(inv -> { + Long id = inv.getArgument(0); SelfopSyncTask yielded = id.equals(task.getId()) ? task + : children.values().stream().filter(t -> id.equals(t.getId())).findFirst().orElseThrow(); + yielded.setStatus("RETRY_WAIT"); return null; + }).when(taskService).yield(anyLong(), anyLong(), eq(lease)); + when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved"))) + .thenThrow(new AnalyticsClientException("LINGXING_WMS_SOURCE_REJECTED", false)); + var moved = new LingxingAnalyticsClient.Summary("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00"); + when(client.locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, moved)); + when(client.detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"))).thenReturn(new SelfopPayloads.Order("demo", "customer", "B", "moved", null, + 100, null, "2026-10-11 12:00:00", List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS")))); + + service.collect(task, run, lease); + var parentRef = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key); + assertEquals("B", parentRef.getWarehouseCode()); assertNotNull(parentRef.getRefreshTaskId()); assertNull(parentRef.getEffectiveStageId()); + var refresh = children.values().stream().filter(t -> parentRef.getRefreshTaskId().equals(t.getId())).findFirst().orElseThrow(); + assertEquals("RECHECK", refresh.getTaskType()); assertEquals("RETRY_WAIT", refresh.getStatus()); + assertTrue(read(refresh.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete()); + verify(client, never()).detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved")); + + account.setRequestBudget(10); service.collect(task, run, lease); + var finalRef = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key); + assertEquals(parentRef.getRefreshTaskId(), finalRef.getRefreshTaskId()); assertNotNull(finalRef.getEffectiveStageId()); + assertEquals("B", finalRef.getWarehouseCode()); assertEquals("SUCCEEDED", refresh.getStatus()); assertEquals("ROUTED", run.getStatus()); + var evidence = stored.get(finalRef.getEffectiveStageId()); assertEquals(102L, evidence.getObservationSequence()); + assertEquals(refresh.getId(), evidence.getStageBatchId()); + verify(client, times(1)).locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2)); + verify(client, times(1)).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved")); + verify(client, times(1)).detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved")); + } + + @Test void authenticationAndSignatureFailuresNeverFallbackToOrderRelocation() { + completedParent("protected"); + for (String code : List.of("LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED")) { + doThrow(new AnalyticsClientException(code, false)).when(client).detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("protected")); + assertEquals(code, assertThrows(AnalyticsClientException.class, () -> service.collect(task, run, lease)).code()); + } + verify(client, never()).locate(any(SelfopRequestContext.class), anyString(), anyInt(), anyInt()); + assertTrue(children.values().stream().noneMatch(t -> "RECHECK".equals(t.getTaskType()))); + } + + private String completedParent(String deliveryNo) { + var manifest = new SelfopRoutingManifest(); manifest.setDirectoryComplete(true); manifest.setPages(1); manifest.setTotal(1L); + var entry = new SelfopRoutingManifest.Entry(); entry.setCustomerCode("customer"); entry.setDeliveryNo(deliveryNo); entry.setWarehouseCode("A"); + String key = hash(List.of("demo", "customer", deliveryNo)); manifest.getObjects().put(key, entry); + task.setRoutingManifestJson(json(manifest)); return key; + } + @Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() { + task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage()); + assertNull(account.getCatalogUntilUtc());assertNull(scope.getDiscoveryUntilUtc());assertTrue(children.isEmpty()); + verifyNoInteractions(client); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java new file mode 100644 index 000000000..9a0914a9c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.outbound.*; +import com.baiyue.lastleg.selfoperated.analytics.inventory.InventorySnapshotService; +import com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.mockito.Mockito.*; + +class SelfopExecutionServiceTest { + @Test void unexpectedValidationFailureTerminatesSessionInsteadOfSchedulingRetry() { + executeValidationFailure(new IllegalStateException("VALIDATION_STRUCTURE_REJECTED"),"VALIDATION_STRUCTURE_REJECTED"); + } + @Test void escapingValidationWaitCannotUseBusinessYieldPath() { + executeValidationFailure(new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,LocalDateTime.now().plusMinutes(1)), + "ACCOUNT_INTERVAL_WAIT"); + } + private void executeValidationFailure(RuntimeException failure,String code) { + var config=new SelfopAnalyticsProperties();config.setCollectionMode("VALIDATION"); + var taskService=mock(SelfopTaskService.class);var leases=mock(SelfopLeaseService.class); + var validation=mock(SelfopValidationRunner.class);var tasks=mock(SelfopSyncTaskMapper.class); + var discovery=mock(SelfopDiscoveryService.class);var stock=mock(SelfopInventoryCollector.class); + var snapshots=mock(InventorySnapshotService.class);var facts=mock(OutboundFactService.class); + var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class); + var recovery=mock(SelfopReferenceRecovery.class); + var task=new SelfopSyncTask();task.setId(1L);task.setTaskRole("ACCOUNT_DISCOVERY"); + task.setDiscoveryScopeId(3L);task.setExecutionPurpose("VALIDATION");task.setStatus("READY");task.setValidationRootTaskId(1L); + var run=new SelfopSyncRun();run.setId(2L);run.setTaskId(1L);run.setExecutionPurpose("VALIDATION"); + var lease=new SelfopLeaseService.Lease(3L,"owner",8L,true); + when(tasks.selectById(1L)).thenReturn(task);when(leases.acquireDiscovery(eq(3L),anyString())).thenReturn(lease); + when(taskService.begin(1L,lease)).thenReturn(run);doThrow(failure).when(validation).run(task,run,lease); + var service=new SelfopExecutionService(config,taskService,leases,discovery,stock,snapshots,facts,candidates,publisher, + mock(SelfopSyncScopeMapper.class),tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class), + recovery,validation,mock(SelfopSyncRunMapper.class)); + service.execute(1L); + verify(validation).fail(task,run,lease,code);verify(leases).release(lease); + verify(taskService,never()).fail(anyLong(),anyLong(),anyString(),anyBoolean(),any()); + verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any()); + verifyNoInteractions(discovery,stock,snapshots,facts,candidates,publisher,recovery); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java new file mode 100644 index 000000000..6b54643e8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java @@ -0,0 +1,150 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Fully offline source pages; the transaction callback fake verifies orchestration, not DB rollback. */ +class SelfopInventoryCollectorTest { + private final LingxingAnalyticsClient client = mock(LingxingAnalyticsClient.class); + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(1L, "owner", 4L, false); + private SelfopInventoryCollector collector; + private SelfopSyncTask task; + private SelfopSyncRun run; + + @BeforeEach void prepare() { + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); return null; + }).when(tx).executeWithoutResult(any()); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setPageSize(2); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); + collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx); + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(20L); task.setSourceAccount("demo"); task.setWarehouseCode("A"); + run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setTaskId(task.getId()); run.setSourceAccount("demo"); run.setFenceToken(lease.fence()); run.setId(10L); run.setTaskId(20L); run.setStatus("FETCHING"); + run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); run.setFetchStartedAtUtc(utcNow()); + } + private SelfopPayloads.Stock stock(String sku, BigDecimal available) { + return new SelfopPayloads.Stock("demo", "customer", "A", sku, 0, "Name", + BigDecimal.TEN, available, BigDecimal.ZERO, BigDecimal.ZERO, null); + } + private LingxingAnalyticsClient.Page page(int current, long total, int pages, SelfopPayloads.Stock... rows) { + return new LingxingAnalyticsClient.Page<>(List.of(rows), current, 2, total, pages); + } + private void stableThreeRows() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO))); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenReturn(page(2, 3, 2, stock("c", null))); + } + + @Test void twoIdenticalScansValidateOneAttemptAndOnlyFirstScanWritesEvidence() { + stableThreeRows(); + assertTrue(collector.collect(task, run, lease)); + assertEquals("VALIDATED", run.getStatus()); assertEquals("COMPLETE", run.getCoverageStatus()); + assertEquals(3L, run.getSourceTotalCount()); assertEquals(3L, run.getUniqueObjectCount()); assertEquals(2, run.getPageCount()); + verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2)); + var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); + verify(stages, times(3)).insert(capture.capture()); + assertTrue(capture.getAllValues().stream().allMatch(s -> s.getRunId() == 10L && s.getStageBatchId() == 10L && s.getLastFetchRunId() == 10L)); + assertNotNull(run.getFetchFinishedAtUtc()); + var contexts=ArgumentCaptor.forClass(SelfopRequestContext.class); + verify(client,times(4)).inventory(contexts.capture(),eq("A"),anyInt(),eq(2)); + assertTrue(contexts.getAllValues().stream().allMatch(c->"demo".equals(c.sourceAccount())&&"BUSINESS".equals(c.executionPurpose()) + &&Objects.equals(c.taskId(),20L)&&Objects.equals(c.runId(),10L)&&Objects.equals(c.rootTaskId(),20L))); + } + @Test void duplicateKeyRejectsReportedTotalEvenIfNumberOfReturnedRowsMatches() { + // Scaled version of the observed 5741 rows / 5740 unique dimensions incident. + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO))); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenReturn(page(2, 3, 2, stock("a", BigDecimal.ONE))); + var error = assertThrows(IllegalStateException.class, () -> collector.collect(task, run, lease)); + assertEquals("SELFOP_STOCK_DUPLICATE", error.getMessage()); + assertIncompleteAttemptWithPersistedProgress(); + } + @Test void sourcePageFailureCannotMarkAttemptComplete() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 3, 2, stock("a", BigDecimal.ONE), stock("b", BigDecimal.ZERO))); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2))).thenThrow(new IllegalStateException("SOURCE_TIMEOUT")); + assertThrows(IllegalStateException.class, () -> collector.collect(task, run, lease)); + assertFalse(read(task.getRoutingManifestJson(), SelfopRoutingManifest.class).isDirectoryComplete()); + assertIncompleteAttemptWithPersistedProgress(); + } + @Test void leakedWarehouseIsRejectedWithoutCompletingRun() { + var leaked = new SelfopPayloads.Stock("demo", "customer", "B", "sku", 0, null, + BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 1, 1, leaked)); + assertEquals("SELFOP_STOCK_SCOPE_LEAK", assertThrows(IllegalStateException.class, + () -> collector.collect(task, run, lease)).getMessage()); + verify(stages, never()).insert(any(SelfopSyncStage.class)); verify(runs, never()).updateById(any(SelfopSyncRun.class)); + } + @Test void unknownAndExplicitZeroRemainDifferentInDurablePayloads() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 2, 1, stock("zero", BigDecimal.ZERO), stock("unknown", null))); + assertTrue(collector.collect(task, run, lease)); + var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages, times(2)).insert(capture.capture()); + var first = read(capture.getAllValues().get(0).getPayloadJson(), SelfopPayloads.Stock.class); + var second = read(capture.getAllValues().get(1).getPayloadJson(), SelfopPayloads.Stock.class); + assertEquals(BigDecimal.ZERO, first.availableQuantity()); assertNull(second.availableQuantity()); + } + @Test void budgetYieldPersistsNextPageAndResumeUsesOriginalAttempt() { + stableThreeRows(); config.setRequestBudget(1); + assertFalse(collector.collect(task, run, lease)); + var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(1, cursor.getScan()); assertEquals(2, cursor.getNextPage()); assertEquals(2, cursor.getFirstScan().size()); + verify(taskService).yield(20L, 10L, lease); + // Simulate restart by constructing a fresh collector and reading only the persisted task cursor. + var restarted = new SelfopInventoryCollector(client, stages, tasks, runs, leases, taskService, config, tx); + config.setRequestBudget(10); + assertTrue(restarted.collect(task, run, lease)); + var capture = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages, times(3)).insert(capture.capture()); + assertTrue(capture.getAllValues().stream().allMatch(s -> Objects.equals(s.getStageBatchId(), 10L))); + verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2)); + assertEquals("COMPLETE", run.getCoverageStatus()); + } + @Test void secondScanCanYieldAndResumeWithoutDuplicatingFirstScanStages() { + stableThreeRows(); config.setRequestBudget(3); + assertFalse(collector.collect(task, run, lease)); + var cursor = read(task.getRoutingManifestJson(), SelfopRoutingManifest.class); + assertEquals(2, cursor.getScan()); assertEquals(2, cursor.getNextPage()); assertEquals(2, cursor.getObjects().size()); + config.setRequestBudget(10); assertTrue(collector.collect(task, run, lease)); + verify(stages, times(3)).insert(any(SelfopSyncStage.class)); + verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(2), eq(2)); + } + @Test void secondScanChangedQuantityCannotPassByKeepingSameIdentitySet() { + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(page(1, 1, 1, stock("a", BigDecimal.ONE)), + page(1, 1, 1, stock("a", BigDecimal.TEN))); + assertEquals("SELFOP_STOCK_SCAN_UNSTABLE", assertThrows(IllegalStateException.class, + () -> collector.collect(task, run, lease)).getMessage()); + assertIncompleteAttemptWithPersistedProgress(); + } + @Test void validationPurposeCannotReachFormalCollector() { + task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION"); + assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage()); + verifyNoInteractions(client,stages,tasks,runs); + } + private void assertIncompleteAttemptWithPersistedProgress() { + assertEquals("FETCHING",run.getStatus()); + assertNull(run.getFetchFinishedAtUtc()); + assertNull(run.getCoverageStatus()); + assertNull(run.getSourceTotalCount()); + assertNull(run.getUniqueObjectCount()); + assertNotNull(run.getHeartbeatAtUtc()); + verify(runs,times(1)).updateById(argThat((SelfopSyncRun saved)-> + "FETCHING".equals(saved.getStatus()) && saved.getFetchFinishedAtUtc()==null + && saved.getCoverageStatus()==null && saved.getHeartbeatAtUtc()!=null)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java new file mode 100644 index 000000000..e5ecda812 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java @@ -0,0 +1,129 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SelfopInventoryRecoveryTest { + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(10L, "worker", 7L, false); + private final SelfopTaskService service = new SelfopTaskService(scopes, accounts, tasks, runs, locks, config, leases); + private SelfopSyncTask task; + private SelfopSyncRun original; + private SelfopSyncScope inventory; + + @BeforeEach void prepare() { + task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); task.setId(1L); task.setScopeId(10L); task.setSourceAccount("demo"); + task.setWarehouseCode("A"); task.setTaskRole("WAREHOUSE_SYNC"); task.setStatus("RUNNING"); + task.setApplyState("NOT_APPLICABLE"); task.setAttemptCount(1); task.setScopeHash("inventory-hash"); task.setQueryScopeJson("{}"); + var manifest = new SelfopRoutingManifest(); manifest.setNextPage(2); manifest.setTotal(3L); manifest.setPages(2); + manifest.getFirstScan().put("old-key", "old-value"); task.setRoutingManifestJson(json(manifest)); + original = new SelfopSyncRun(); original.setExecutionPurpose("BUSINESS"); original.setId(20L); original.setTaskId(1L); original.setScopeId(10L); + original.setSourceAccount("demo"); original.setWarehouseCode("A"); original.setAttemptNo(1); + original.setFenceToken(6L); original.setStatus("FETCHING"); original.setBasePublishedRunId(11L); + original.setReferenceSalesRunId(12L); original.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\"}"); + original.setFetchStartedAtUtc(LocalDateTime.of(2026, 10, 12, 0, 0)); + inventory = new SelfopSyncScope(); inventory.setId(10L); inventory.setDataFamily("INVENTORY"); + inventory.setSourceAccount("demo"); inventory.setWarehouseCode("A"); inventory.setScopeHash("inventory-hash"); + inventory.setScopeJson("{\"quantityUnit\":\"PCS\"}"); inventory.setPublishedRunId(90L); + inventory.setBusinessTimezone("UTC"); inventory.setStaleAfterSeconds(93600); + var sales = new SelfopSyncScope(); sales.setId(30L); sales.setDataFamily("OUTBOUND"); sales.setPublishedRunId(80L); + when(locks.lockTask(1L)).thenReturn(task); when(locks.lockRun(20L)).thenReturn(original); + when(locks.lockScope(30L)).thenReturn(sales); when(scopes.selectById(10L)).thenReturn(inventory); when(scopes.selectOne(any())).thenReturn(sales); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setRequestBudget(10); config.setTimeBudgetSeconds(60); config.setPageSize(2); + } + + @Test void budgetYieldResumesSameAttemptWithFixedReferencesAndOriginalObservationStart() { + String cursor = task.getRoutingManifestJson(); String snapshot = original.getScopeSnapshotJson(); + var started = original.getFetchStartedAtUtc(); + original.setFenceToken(lease.fence()); + service.yield(1L, 20L, lease); + assertEquals("YIELDED", original.getStatus()); assertEquals("RETRY_WAIT", task.getStatus()); + assertNotNull(original.getFinishedAtUtc()); + when(runs.selectOne(any())).thenReturn(null, original); + var resumed = service.begin(1L, lease); + assertSame(original, resumed); assertEquals(20L, resumed.getId()); assertEquals(1, resumed.getAttemptNo()); + assertEquals(11L, resumed.getBasePublishedRunId()); assertEquals(12L, resumed.getReferenceSalesRunId()); + assertEquals(snapshot, resumed.getScopeSnapshotJson()); assertEquals(started, resumed.getFetchStartedAtUtc()); + assertEquals(cursor, task.getRoutingManifestJson()); assertEquals(1, task.getAttemptCount()); + assertEquals(7L, resumed.getFenceToken()); assertEquals("FETCHING", resumed.getStatus()); assertNull(resumed.getFinishedAtUtc()); + verify(runs, never()).insert(any(SelfopSyncRun.class)); + } + + @Test void genuineFailureClearsCursorAndNewAttemptStartsAtPageOneWithItsOwnStageBatch() { + original.setFenceToken(lease.fence()); + when(runs.selectCount(any())).thenReturn(1L); + service.fail(1L, 20L, "SELFOP_STOCK_DUPLICATE", true, lease); + assertEquals("FAILED", original.getStatus()); assertNull(task.getRoutingManifestJson()); + assertEquals("RETRY_WAIT", task.getStatus()); + // Verifies the final updateById cannot overwrite the explicit SQL-null cursor reset. + verify(tasks).updateById(argThat((SelfopSyncTask t) -> t.getRoutingManifestJson() == null)); + when(runs.selectOne(any())).thenReturn(null); + var fresh = service.begin(1L, lease); + assertNotEquals(original.getId(), fresh.getId()); assertEquals(2, fresh.getAttemptNo()); + assertEquals(90L, fresh.getBasePublishedRunId()); assertEquals(80L, fresh.getReferenceSalesRunId()); + var client = mock(LingxingAnalyticsClient.class); var stages = mock(SelfopSyncStageMapper.class); + var stock = new SelfopPayloads.Stock("demo", "customer", "A", "sku", 0, null, + BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ZERO, BigDecimal.ZERO, null); + when(client.inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(stock), 1, 2, 1L, 1)); + var tx = mock(TransactionTemplate.class); + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); callback.accept(mock(TransactionStatus.class)); return null; + }).when(tx).executeWithoutResult(any()); + var collector = new SelfopInventoryCollector(client, stages, tasks, runs, leases, service, config, tx); + assertTrue(collector.collect(task, fresh, lease)); + var saved = ArgumentCaptor.forClass(SelfopSyncStage.class); verify(stages).insert(saved.capture()); + assertEquals(fresh.getId(), saved.getValue().getStageBatchId()); assertEquals(fresh.getId(), saved.getValue().getRunId()); + assertEquals(1, saved.getValue().getFirstPageNo()); verify(client, times(2)).inventory(any(SelfopRequestContext.class), eq("A"), eq(1), eq(2)); + } + + @Test void exhaustedFailuresRetainOldPublishedPointerAndCannotCreateAnotherAttempt() { + original.setFenceToken(lease.fence()); + when(runs.selectCount(any())).thenReturn((long) config.getMaxAttempts()); + service.fail(1L, 20L, "SOURCE_TIMEOUT", true, lease); + assertEquals("DEAD", task.getStatus()); assertEquals("FAILED", original.getStatus()); + assertEquals(90L, inventory.getPublishedRunId()); assertEquals(11L, original.getBasePublishedRunId()); + assertThrows(IllegalStateException.class, () -> service.begin(1L, lease)); + verify(runs, never()).insert(any(SelfopSyncRun.class)); verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + + @Test void expiredFetchingAttemptStartsFreshWithoutReusingCursorEvenIfFailNeverRan() { + // Process dies after accepted page one but before failure bookkeeping can clear its task cursor. + String oldSnapshot = original.getScopeSnapshotJson(); var oldStarted = original.getFetchStartedAtUtc(); + when(runs.selectOne(any())).thenReturn(original, null); + var replacement = service.begin(1L, lease); + assertNotEquals(original.getId(), replacement.getId()); assertEquals(2, replacement.getAttemptNo()); + assertNull(task.getRoutingManifestJson()); assertEquals("ABORTED", original.getStatus()); + assertEquals("LEASE_EXPIRED", original.getErrorCode()); assertNotNull(original.getFinishedAtUtc()); + assertEquals(oldSnapshot, original.getScopeSnapshotJson()); assertEquals(oldStarted, original.getFetchStartedAtUtc()); + assertEquals(11L, original.getBasePublishedRunId()); assertEquals(12L, original.getReferenceSalesRunId()); + assertEquals(90L, replacement.getBasePublishedRunId()); assertEquals(80L, replacement.getReferenceSalesRunId()); + // Clearing NULL needs explicit SQL because default updateById omits null-valued properties. + var update = ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(tasks).update(isNull(), update.capture()); + var wrapper = (com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper) update.getValue(); + assertTrue(wrapper.getSqlSet().contains("routing_manifest_json")); + assertTrue(wrapper.getParamNameValuePairs().values().stream().anyMatch(Objects::isNull)); + verify(runs).insert(replacement); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java new file mode 100644 index 000000000..fdb0608a9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.List; +import java.time.Instant; +import java.time.LocalDateTime; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class SelfopProgressServiceTest { + private SelfopSyncTask task(){var t=new SelfopSyncTask();t.setId(1L);t.setAttemptCount(1);t.setTaskRole("ACCOUNT_DISCOVERY");t.setDiscoveryScopeId(3L);return t;} + private SelfopSyncRun run(){var r=new SelfopSyncRun();r.setId(2L);r.setTaskId(1L);r.setAttemptNo(1);r.setFenceToken(8L);return r;} + @Test void repeatedCheckpointUsesCommittedCountAndReliablePhaseDenominatorOnly() { + var t=task();var r=run(); + SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",3,8L,1); + SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",3,8L,1); + var p=read(t.getProgressJson(),Map.class); + assertEquals(3,p.get("completed"));assertEquals("ACCOUNT_SHARED",p.get("progressScope"));assertEquals("TASK_CUMULATIVE",p.get("countBasis")); + assertEquals(37.5,((Number)p.get("phasePercent")).doubleValue());assertFalse(p.containsKey("totalPercent")); + assertNotNull(r.getHeartbeatAtUtc());assertTrue(t.getProgressJson().getBytes(StandardCharsets.UTF_8).length<=8192); + SelfopProgressProjection.applyCheckpoint(t,r,"DIRECTORY","PAGE",2,null,2); + p=read(t.getProgressJson(),Map.class);assertNull(p.get("phasePercent"));assertEquals(false,p.get("totalKnown"));assertEquals(2,p.get("scanRound")); + } + @Test void inventoryRowsAndEmptyResultDoNotBecomeSkuCountOrWholeJobSuccess() { + var t=task();t.setTaskRole("WAREHOUSE_SYNC");var r=run(); + SelfopProgressProjection.applyCheckpoint(t,r,"INVENTORY","INVENTORY_ROW",0,0L,1); + var p=read(t.getProgressJson(),Map.class);assertNull(p.get("phasePercent"));assertEquals("ATTEMPT_ONLY",p.get("countBasis")); + assertEquals("WAREHOUSE",p.get("progressScope"));assertNull(t.getStatus()); + assertThrows(IllegalStateException.class,()->SelfopProgressProjection.applyCheckpoint(t,r,"INVENTORY","SKU",1,1L,1)); + } + @Test void frozenAttemptDoesNotFollowLaterAttemptProgress() { + var t=task();var r=run();SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,10L,1); + SelfopProgressProjection.freezeRun(t,r);String frozen=r.getProgressSnapshotJson(); + var next=run();next.setId(4L);next.setAttemptNo(2); + SelfopProgressProjection.applyCheckpoint(t,next,"DETAIL","ORDER",7,10L,1); + SelfopProgressProjection.freezeRun(t,r);assertEquals(frozen,r.getProgressSnapshotJson()); + var unfrozen=run();assertThrows(IllegalStateException.class,()->SelfopProgressProjection.freezeRun(t,unfrozen)); + } + @Test void terminalPhaseKeepsReliableStageHistoryAndUtcStringsForReadContract() { + var t=task();t.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));t.setQueryUntilUtc(t.getQueryFromUtc().plusDays(1));var r=run(); + SelfopProgressProjection.applyCheckpoint(t,r,"DETAIL","ORDER",4,4L,1); + SelfopProgressProjection.applyCheckpoint(t,r,"SAMPLE_DONE","NONE",0,null,1); + var p=read(t.getProgressJson(),Map.class);assertFalse(p.containsKey("phases")); + var stages=(List)p.get("stages");assertEquals(2,stages.size());var detail=(Map)stages.get(0); + assertEquals("DETAIL",detail.get("phase"));assertEquals(4,detail.get("completed"));assertEquals(true,detail.get("totalKnown")); + assertEquals(Instant.parse("2026-10-11T16:00:00Z"),Instant.parse((String)detail.get("sliceFrom"))); + assertDoesNotThrow(()->Instant.parse((String)detail.get("stageStartedAt"))); + assertDoesNotThrow(()->Instant.parse((String)p.get("stageStartedAt"))); + } + @Test void staleFenceCannotChangeCheckpointOrHeartbeat() { + var locks=mock(SelfopCoordinationMapper.class);var tasks=mock(SelfopSyncTaskMapper.class);var runs=mock(SelfopSyncRunMapper.class);var leases=mock(SelfopLeaseService.class); + var lease=new SelfopLeaseService.Lease(3,"old",7,true);when(locks.lockTask(1)).thenReturn(task());when(locks.lockRun(2)).thenReturn(run()); + var service=new SelfopProgressService(locks,tasks,runs,leases); + assertThrows(IllegalStateException.class,()->service.checkpoint(1,2,lease,"DETAIL","ORDER",1,3L,1)); + assertThrows(IllegalStateException.class,()->service.heartbeat(1,2,lease)); + verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class)); + var order=inOrder(leases,locks);order.verify(leases).verify(lease);order.verify(locks).lockTask(1);order.verify(locks).lockRun(2); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java new file mode 100644 index 000000000..d946652fd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.function.Consumer; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class SelfopReferenceRecoveryTest { + private final SelfopSyncStageMapper stages = mock(SelfopSyncStageMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopDiscoveryService discovery = mock(SelfopDiscoveryService.class); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopReferenceRecovery recovery = new SelfopReferenceRecovery( + stages, tasks, scopes, accounts, locks, leases, taskService, discovery, tx); + private final String key = "b".repeat(64); + private final Map held = new LinkedHashMap<>(); + private final SelfopLeaseService.Lease accountLease = new SelfopLeaseService.Lease(50, "account-worker", 2, true); + private final SelfopSyncTask original = new SelfopSyncTask(); + private final SelfopSyncTask refresh = new SelfopSyncTask(); + private final SelfopSyncStage latest = new SelfopSyncStage(); + private String returnedWarehouse = "B"; + + @BeforeEach + void prepare() { + original.setId(1L); original.setSourceAccount("demo"); original.setWarehouseCode("A"); + original.setExecutionPurpose("BUSINESS"); + original.setScopeHash("fixed-scope"); original.setApplyState("BLOCKED"); + original.setAffectedWarehouseCodesJson(json(List.of("A", "B"))); + var manifest = new SelfopRoutingManifest(); + var ref = new SelfopRoutingManifest.Entry(); + ref.setEffectiveStageId(20L); ref.setCustomerCode("customer"); ref.setDeliveryNo("delivery"); ref.setWarehouseCode("A"); + manifest.getObjects().put(key, ref); original.setRoutingManifestJson(json(manifest)); + var current = new SelfopSyncStage(); current.setId(20L); current.setObservationSequence(1L); + latest.setId(21L); latest.setObservationSequence(2L); latest.setWarehouseCode("B"); + when(stages.selectById(20L)).thenReturn(current); when(stages.selectOne(any())).thenReturn(latest); + var account = new SelfopOutboundDiscoveryScope(); account.setId(50L); + when(accounts.selectOne(any())).thenReturn(account); + when(leases.acquireDiscovery(eq(50L), anyString())).thenReturn(accountLease); + for (long id : new long[]{10, 11}) { + held.put(id, new SelfopLeaseService.Lease(id, "warehouse-worker", 3, false)); + var scope = new SelfopSyncScope(); scope.setWarehouseCode(id == 10 ? "A" : "B"); + when(scopes.selectById(id)).thenReturn(scope); + } + refresh.setId(40L); refresh.setStatus("READY"); refresh.setExecutionPurpose("BUSINESS"); + when(taskService.create(any(SelfopSyncTask.class))).thenReturn(refresh); + when(tasks.selectById(40L)).thenReturn(refresh); + var run = new SelfopSyncRun(); run.setId(80L); run.setExecutionPurpose("BUSINESS"); + when(taskService.begin(40L, accountLease)).thenReturn(run); + when(locks.lockTask(1L)).thenReturn(original); + doAnswer(invocation -> { + Consumer callback = invocation.getArgument(0); + callback.accept(mock(TransactionStatus.class)); return null; + }).when(tx).executeWithoutResult(any()); + doAnswer(invocation -> { + var newManifest = new SelfopRoutingManifest(); var result = new SelfopRoutingManifest.Entry(); + result.setEffectiveStageId(22L); result.setWarehouseCode(returnedWarehouse); + newManifest.getObjects().put(key, result); + refresh.setRoutingManifestJson(json(newManifest)); refresh.setStatus("SUCCEEDED"); return null; + }).when(discovery).collect(refresh, run, accountLease); + } + + @Test + void newerObservationRequiresActualRecheckAndKeepsOriginalHalfApplyBarrier() { + recovery.refresh(original, held); + var reference = read(original.getRoutingManifestJson(), SelfopRoutingManifest.class).getObjects().get(key); + assertEquals(22L, reference.getEffectiveStageId()); assertEquals("B", reference.getWarehouseCode()); + assertEquals(40L, reference.getRefreshTaskId()); assertEquals(2L, reference.getNewestSeenSequence()); + assertEquals("BLOCKED", original.getApplyState()); + assertEquals(json(List.of("A", "B")), original.getAffectedWarehouseCodesJson()); + verify(taskService).create(argThat(t -> "ACCOUNT_DISCOVERY".equals(t.getTaskRole()) + && "RECHECK".equals(t.getTaskType()) + && Objects.equals(1L, read(t.getQueryScopeJson(), SelfopDiscoveryService.Query.class).parentTaskId()))); + verify(discovery).collect(eq(refresh), any(SelfopSyncRun.class), eq(accountLease)); + verify(tasks).updateById(original); verify(leases).release(accountLease); + } + + @Test + void unchangedObservationDoesNotRequestAnotherSourceRead() { + latest.setObservationSequence(1L); + var before = original.getRoutingManifestJson(); recovery.refresh(original, held); + assertEquals(before, original.getRoutingManifestJson()); + verifyNoInteractions(taskService, discovery, accounts, locks, leases); + } + + @Test + void budgetYieldCannotReplaceEffectiveReferenceOrClearBarrier() { + doAnswer(invocation -> { refresh.setStatus("RETRY_WAIT"); return null; }) + .when(discovery).collect(eq(refresh), any(SelfopSyncRun.class), eq(accountLease)); + var before = original.getRoutingManifestJson(); + var error = assertThrows(IllegalStateException.class, () -> recovery.refresh(original, held)); + assertEquals("SELFOP_RECHECK_YIELDED", error.getMessage()); + assertEquals(before, original.getRoutingManifestJson()); assertEquals("BLOCKED", original.getApplyState()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); verify(leases).release(accountLease); + } + + @Test + void recheckMovingToAnUnleasedWarehouseMustReacquireBeforeAnyParentWrite() { + returnedWarehouse = "C"; + var before = original.getRoutingManifestJson(); + var error = assertThrows(IllegalStateException.class, () -> recovery.refresh(original, held)); + assertEquals("LOCK_SET_CHANGED", error.getMessage()); + assertEquals(before, original.getRoutingManifestJson()); assertEquals("BLOCKED", original.getApplyState()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); verify(leases).release(accountLease); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java new file mode 100644 index 000000000..d65b78963 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncScopeMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncRunMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopCoordinationMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** Gate assertions are offline; actual transaction rollback/concurrency needs database acceptance. */ +class SelfopSalesPublisherTest { + final SelfopCoordinationMapper locks=mock(SelfopCoordinationMapper.class); + final SelfopSyncScopeMapper scopes=mock(SelfopSyncScopeMapper.class); + final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties(); + final OutboundAggregateMapper aggregates=mock(OutboundAggregateMapper.class); + final SelfopSalesPublisher publisher=new SelfopSalesPublisher(locks,scopes,runs,tasks,config,aggregates); + SelfopSyncScope scope; SelfopSyncRun run; SelfopSyncTask task; + + @BeforeEach void setup() { + scope=new SelfopSyncScope(); scope.setId(1L); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode("synthetic-warehouse"); + scope.setDataFamily("OUTBOUND"); scope.setScopeHash("confirmed-synthetic-scope"); scope.setSourceTimezone("UTC"); + scope.setLeaseOwner("worker"); scope.setFenceToken(3L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); + scope.setFactRevision(7L); scope.setPublishedFactRevision(6L); scope.setPublishedRunId(9L); scope.setRebuildRequired(1); + run=new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\",\"sourceTimezone\":\"UTC\"}"); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash(scope.getScopeHash()); + run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L); + run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2)); + run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0)); + task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED"); + when(locks.lockScope(1L)).thenReturn(scope); when(locks.lockRun(10L)).thenReturn(run); when(locks.lockTask(20L)).thenReturn(task); + } + private void enableSyntheticPublication() { config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSalesPublicationEnabled(true); config.setQuantityUnit("PCS"); } + private void noWrites() { verifyNoInteractions(scopes,runs,tasks); assertEquals(9L,scope.getPublishedRunId()); } + + @Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() { + assertFalse(config.isSalesPublicationEnabled()); + IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("SELFOP_SALES_PUBLICATION_DISABLED",error.getMessage()); verifyNoInteractions(locks); noWrites(); + } + @Test void expiredLeaseCannotSwitchPointer() { + enableSyntheticPublication(); scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1)); + assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites(); + } + @Test void deadHalfAppliedTaskStillBlocksUnrelatedReadyCandidate() { + enableSyntheticPublication(); task.setApplyState("BLOCKED"); task.setStatus("DEAD"); + when(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())).thenReturn(1L); + IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("HALF_APPLIED_FACTS",error.getMessage()); noWrites(); + verify(locks,never()).lockTask(20L); + } + @Test void revisionMismatchRejectsOtherwiseCompleteCandidate() { + enableSyntheticPublication(); scope.setFactRevision(8L); + IllegalStateException error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("SELFOP_CANDIDATE_OBSOLETE",error.getMessage()); noWrites(); + } + @Test void newObservationRejectsCandidateEvenWithoutFactRevisionChange() { + enableSyntheticPublication(); + when(aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())).thenReturn(1L); + var error=assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); + assertEquals("SELFOP_CANDIDATE_OBSERVATION_CHANGED",error.getMessage());noWrites(); + verify(locks,never()).lockTask(20L); + } + @Test void unconfirmedSourceTimezoneOrUnitCannotPublish() { + enableSyntheticPublication(); scope.setSourceTimezone(null); + assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites(); + scope.setSourceTimezone("UTC"); config.setQuantityUnit(null); + assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites(); + } + @Test void completeCandidatePublishesScopeRunAndTaskInsideOneTransactionBoundary() throws Exception { + enableSyntheticPublication(); + publisher.publish(1,10,"worker",3); + assertEquals(10L,scope.getPublishedRunId()); assertEquals(7L,scope.getPublishedFactRevision()); assertEquals(0,scope.getRebuildRequired()); + assertEquals(run.getCoveredFromUtc(),scope.getDiscoveryFromUtc()); assertEquals(run.getCoveredUntilUtc(),scope.getDiscoveryUntilUtc()); + assertEquals("PUBLISHED",run.getStatus()); assertNotNull(run.getPublishedAtUtc()); assertEquals("SUCCEEDED",task.getStatus()); + var ordered=inOrder(locks,scopes,runs,tasks); + ordered.verify(locks).lockScope(1L); ordered.verify(locks).lockRun(10L); + ordered.verify(locks).barriers(scope.getSourceAccount(),scope.getWarehouseCode()); ordered.verify(locks).lockTask(20L); + ordered.verify(runs).update(isNull(),any()); + ordered.verify(scopes).updateById(any(SelfopSyncScope.class)); + ordered.verify(runs).updateById(any(SelfopSyncRun.class)); ordered.verify(tasks).updateById(any(SelfopSyncTask.class)); + assertNotNull(SelfopSalesPublisher.class.getMethod("publish",long.class,long.class,String.class,long.class).getAnnotation(Transactional.class)); + assertEquals(10L,task.getResultPublishedRunId());assertNotNull(run.getProgressSnapshotJson()); + // Publisher constructor has no inventory repositories or inventory publication prerequisite. + verifyNoMoreInteractions(scopes,runs,tasks); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java new file mode 100644 index 000000000..39d8eefe2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java @@ -0,0 +1,240 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SelfopSchedulePlannerTest { + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopTaskService taskService = mock(SelfopTaskService.class); + private final SelfopExecutionService execution = mock(SelfopExecutionService.class); + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopOutboundOrderMapper orders = mock(SelfopOutboundOrderMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final TransactionTemplate tx = mock(TransactionTemplate.class); + private final SelfopSchedulePlanner planner = new SelfopSchedulePlanner(config, taskService, execution, + scopes, accounts, tasks, orders, locks, tx); + + @Test void disabledTickDoesNotInitializePlanPersistOrExecuteAnything() { + planner.tick(); + verifyNoInteractions(taskService, execution, scopes, accounts, tasks, orders, locks, tx); + } + + @Test void unknownSourceTimezoneStillPlansBothInventoriesAndExecutesDurableDueWork() { + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); + assertThat(config.getSourceQueryTimezone()).isNull(); + var a = scope(10L, "WH-A", "INVENTORY"); + var b = scope(11L, "WH-B", "INVENTORY"); + var outbound = scope(12L, "WH-A", "OUTBOUND"); + outbound.setSourceTimezone(null); + var dueA = new SelfopSyncTask(); dueA.setId(101L); + var dueB = new SelfopSyncTask(); dueB.setId(102L); + when(taskService.initialize()).thenReturn(List.of(a, b, outbound)); + when(taskService.due(10)).thenReturn(List.of(dueA, dueB)); + var subject = spy(planner); + doNothing().when(subject).plan(any(SelfopSyncScope.class), any(LocalDateTime.class)); + doNothing().when(subject).review(any(SelfopSyncScope.class), any(LocalDateTime.class)); + // A busy warehouse must not suppress the other warehouse's independent durable work. + doThrow(new IllegalStateException("SELFOP_LEASE_BUSY")).when(execution).execute(101L); + subject.tick(); + verify(subject).plan(eq(a), any(LocalDateTime.class)); + verify(subject).plan(eq(b), any(LocalDateTime.class)); + verify(subject, never()).plan(eq(outbound), any(LocalDateTime.class)); + verify(subject, never()).review(any(SelfopSyncScope.class), any(LocalDateTime.class)); + verify(execution).execute(101L); + verify(execution).execute(102L); + verifyNoInteractions(accounts, orders); + } + + @Test void firstInventoryCreatedAfterDailySlotSchedulesCurrentObservationInsteadOfYesterday() { + directTransactions(); + var scope = scope(10L, "WH-A", "INVENTORY"); + var now = LocalDateTime.of(2026, 10, 12, 14, 30); + scope.setCreatedAt(now.minusHours(1)); + when(locks.lockScope(10L)).thenReturn(scope); + planner.plan(scope, now); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(taskService).create(captured.capture()); + var task = captured.getValue(); + assertThat(task.getTaskType()).isEqualTo("SCHEDULED"); + assertThat(task.getPlannedAtUtc()).isEqualTo(now); + assertThat(task.getWarehouseCode()).isEqualTo("WH-A"); + assertThat(task.getScopeId()).isEqualTo(10L); + assertThat(task.getQueryFromUtc()).isNull(); + assertThat(task.getQueryUntilUtc()).isNull(); + assertThat(scope.getInitialFromUtc()).isNull(); + assertThat(scope.getInitialUntilUtc()).isNull(); + verifyNoInteractions(execution, accounts, orders); + } + + @Test void missedInventorySlotsPersistLatestDuePlanWithoutPretendingToObserveHistoricalStock() { + directTransactions(); + var scope = scope(10L, "WH-A", "INVENTORY"); + scope.setCreatedAt(LocalDateTime.of(2026, 10, 1, 0, 0)); + var previous = new SelfopSyncTask(); + previous.setPlannedAtUtc(LocalDateTime.of(2026, 10, 10, 8, 0)); + when(locks.lockScope(10L)).thenReturn(scope); + when(tasks.selectOne(any())).thenReturn(previous); + planner.plan(scope, LocalDateTime.of(2026, 10, 12, 14, 30)); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(taskService).create(captured.capture()); + var task = captured.getValue(); + assertThat(task.getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026, 10, 12, 8, 0)); + assertThat(task.getRequestKey()).contains("WH-A", "INVENTORY", "2026-10-12T08:00"); + assertThat(task.getQueryFromUtc()).isNull(); + assertThat(task.getQueryUntilUtc()).isNull(); + assertThat(task.getRoutingManifestJson()).isNull(); + // Planning creates no execution run or source observation; actual fetch records those later. + verifyNoInteractions(execution, accounts, orders); + verify(scopes, never()).updateById(any(SelfopSyncScope.class)); + } + + @Test void completedKnownOrderAndHistoryRoundsRemainClearedThroughFinalScopeWrite() { + directTransactions(); + var scope = scope(10L, "WH-A", "OUTBOUND"); + var now = LocalDateTime.of(2026, 10, 12, 14, 30); + scope.setKnownOrderCursorId(99L); + scope.setKnownOrderRoundMaxId(99L); + scope.setKnownOrderRoundStartedAtUtc(now.minusDays(1)); + scope.setInitialFromUtc(now.minusMonths(2)); + scope.setDiscoveryUntilUtc(now.minusHours(1)); + scope.setHistoryRoundFromUtc(scope.getInitialFromUtc()); + scope.setHistoryRoundUntilUtc(scope.getDiscoveryUntilUtc()); + scope.setHistorySliceCursorUtc(scope.getDiscoveryUntilUtc()); + when(locks.lockScope(10L)).thenReturn(scope); + // No further known order inside this fixed round; the history cursor is already at its end. + when(orders.selectOne(any())).thenReturn(null); + planner.review(scope, now); + var captured = ArgumentCaptor.forClass(SelfopSyncScope.class); + verify(scopes).updateById(captured.capture()); + var persisted = captured.getValue(); + assertThat(persisted.getKnownOrderCursorId()).isNull(); + assertThat(persisted.getKnownOrderRoundMaxId()).isNull(); + assertThat(persisted.getKnownOrderRoundFinishedAtUtc()).isEqualTo(now); + assertThat(persisted.getHistoryRoundUntilUtc()).isNull(); + assertThat(persisted.getHistorySliceCursorUtc()).isNull(); + assertThat(persisted.getHistoryRoundFinishedAtUtc()).isEqualTo(now); + verify(scopes, times(2)).update(isNull(), any()); + verifyNoInteractions(taskService, execution, accounts, tasks); + } + + @Test void unpublishedInitialBackfillIsNotRepeatedAcrossEightAndTwentySlots() { + var initial=LocalDateTime.of(2026,10,12,0,0); // Beijing 08:00 + var scope=outboundScope(initial);var state=persistPlans(scope); + planner.plan(scope,initial); + var history=state.created.stream().filter(t->"BACKFILL".equals(t.getTaskType())).toList(); + assertThat(history).isNotEmpty(); + assertThat(history.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,0,0)); + assertThat(history.get(history.size()-1).getQueryUntilUtc()).isEqualTo(initial); + assertThat(history).allSatisfy(t->assertThat(t.getRequestKey()).startsWith("initial:")); + int firstCount=state.created.size(); + var evening=initial.plusHours(12); + planner.plan(scope,evening); + var second=state.created.subList(firstCount,state.created.size()); + assertThat(second).noneSatisfy(t->assertThat(t.getTaskType()).isEqualTo("BACKFILL")); + var increments=second.stream().filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())).toList(); + assertThat(increments).hasSize(1); + assertThat(increments.get(0).getTaskType()).isEqualTo("SCHEDULED"); + assertThat(increments.get(0).getQueryFromUtc()).isEqualTo(initial); + assertThat(increments.get(0).getQueryUntilUtc()).isEqualTo(evening); + assertThat(scope.getDiscoveryUntilUtc()).isNull(); // No source execution or publication occurred. + assertThat(scope.getInitialUntilUtc()).isEqualTo(initial); + int afterEvening=state.created.size(); + planner.plan(scope,evening.plusMinutes(1)); + assertThat(state.created).hasSize(afterEvening); + assertThat(state.created.stream().map(SelfopSyncTask::getRequestKey).toList()).doesNotHaveDuplicates(); + } + + @Test void persistedRollingMonthWindowKeepsItsBoundaryWhenFirstPlanningIsDelayed() { + var initial=LocalDateTime.of(2026,3,31,0,0); + var scope=outboundScope(initial);scope.setInitialUntilUtc(initial); + scope.setInitialFromUtc(LocalDateTime.of(2026,1,31,0,0)); + var state=persistPlans(scope); + planner.plan(scope,initial.plusHours(12)); + var history=state.created.stream().filter(t->"BACKFILL".equals(t.getTaskType())).toList(); + assertThat(history).hasSize(59); // Calendar months, not a fixed sixty-day subtraction. + assertThat(history.get(0).getQueryFromUtc()).isEqualTo(scope.getInitialFromUtc()); + assertThat(history.get(58).getQueryUntilUtc()).isEqualTo(initial); + assertThat(history).allSatisfy(t->assertThat(t.getQueryUntilUtc()).isBeforeOrEqualTo(initial)); + var incremental=state.created.stream().filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())&&"SCHEDULED".equals(t.getTaskType())).toList(); + assertThat(incremental).hasSize(1); + assertThat(incremental.get(0).getQueryFromUtc()).isEqualTo(initial); + assertThat(incremental.get(0).getQueryUntilUtc()).isEqualTo(initial.plusHours(12)); + assertThat(scope.getInitialUntilUtc()).isEqualTo(initial); + } + + @Test void missedLaterSlotsQueueEveryIncrementalIntervalWithoutMovingInitialWindow() { + var initial=LocalDateTime.of(2026,10,12,0,0);var scope=outboundScope(initial); + var state=persistPlans(scope);planner.plan(scope,initial); + int before=state.created.size(); + planner.plan(scope,initial.plusDays(4).plusHours(12)); + var incremental=state.created.subList(before,state.created.size()).stream() + .filter(t->"ACCOUNT_DISCOVERY".equals(t.getTaskRole())).toList(); + assertThat(incremental).hasSize(5); + var cursor=initial; + for(var slice:incremental){ + assertThat(slice.getTaskType()).isEqualTo("SCHEDULED"); + assertThat(slice.getQueryFromUtc()).isEqualTo(cursor); + assertThat(slice.getQueryUntilUtc()).isAfter(cursor).isBeforeOrEqualTo(cursor.plusDays(1)); + cursor=slice.getQueryUntilUtc(); + } + assertThat(cursor).isEqualTo(initial.plusDays(4).plusHours(12)); + assertThat(scope.getInitialFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,0,0)); + assertThat(scope.getInitialUntilUtc()).isEqualTo(initial); + } + + private SelfopSyncScope outboundScope(LocalDateTime created) { + var scope=scope(10L,"WH-A","OUTBOUND");scope.setCreatedAt(created); + scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai"); + scope.setScheduleCron("0 0 8,20 * * *");return scope; + } + private static class PlannedState { + final List created=new ArrayList<>(); + final Map byKey=new HashMap<>(); + SelfopSyncTask latest; + } + private PlannedState persistPlans(SelfopSyncScope scope) { + directTransactions();config.setSourceQueryTimezone("Asia/Shanghai"); + config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + var account=new SelfopOutboundDiscoveryScope();account.setId(30L); + when(accounts.selectOne(any())).thenReturn(account);when(locks.lockScope(scope.getId())).thenReturn(scope); + var state=new PlannedState();when(tasks.selectOne(any())).thenAnswer(call->state.latest); + when(taskService.create(any(SelfopSyncTask.class))).thenAnswer(call->{ + SelfopSyncTask task=call.getArgument(0);var prior=state.byKey.get(task.getRequestKey()); + if(prior!=null){assertThat(task.getRequestHash()).isEqualTo(prior.getRequestHash());return prior;} + state.byKey.put(task.getRequestKey(),task);state.created.add(task); + if("WAREHOUSE_SYNC".equals(task.getTaskRole()))state.latest=task; + return task; + });return state; + } + + private SelfopSyncScope scope(long id, String warehouse, String family) { + var scope = new SelfopSyncScope(); + scope.setId(id); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode(warehouse); + scope.setDataFamily(family); scope.setBusinessTimezone("UTC"); scope.setScheduleCron("0 0 8 * * *"); + scope.setScopeHash("synthetic-scope-hash"); + scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\",\"qualities\":\"ALL\"}"); + return scope; + } + private void directTransactions() { + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); + doAnswer(call -> { + Consumer action = call.getArgument(0); + action.accept(mock(TransactionStatus.class)); + return null; + }).when(tx).executeWithoutResult(any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java new file mode 100644 index 000000000..cca66f8c9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java @@ -0,0 +1,346 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.dao.DuplicateKeyException; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SelfopTaskServiceTest { + private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); + private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs = mock(SelfopSyncRunMapper.class); + private final SelfopCoordinationMapper locks = mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases = mock(SelfopLeaseService.class); + private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); + private final SelfopTaskService service = new SelfopTaskService(scopes, accounts, tasks, runs, locks, config, leases); + private final SelfopLeaseService.Lease lease = new SelfopLeaseService.Lease(10L, "synthetic-worker", 7L, false); + + @BeforeEach void configureBusiness() {config.setCollectionMode("BUSINESS");config.setEnvironment("production");} + + @Test void beginFixesTopLevelTimezoneStalenessAndInitialWindowInVersionSnapshot() { + var task = task("READY"); + var scope = scope("OUTBOUND"); + scope.setInitialFromUtc(LocalDateTime.of(2026, 9, 12, 0, 0)); + scope.setInitialUntilUtc(LocalDateTime.of(2026, 10, 12, 0, 0)); + when(locks.lockTask(1L)).thenReturn(task); + when(scopes.selectById(10L)).thenReturn(scope); + + var run = service.begin(1L, lease); + Map snapshot = read(run.getScopeSnapshotJson(), Map.class); + assertThat(snapshot.get("businessTimezone")).isEqualTo("America/New_York"); + assertThat(snapshot.get("sourceTimezone")).isEqualTo("UTC"); + assertThat(snapshot.get("staleAfterSeconds")).isEqualTo(43200); + assertThat(snapshot.get("initialFromUtc")).isEqualTo("2026-09-12T00:00:00"); + assertThat(snapshot.get("initialUntilUtc")).isEqualTo("2026-10-12T00:00:00"); + assertThat(snapshot.get("customerMode")).isEqualTo("ALL_AUTHORIZED"); + assertThat(run.getBasePublishedRunId()).isEqualTo(90L); + assertThat(run.getFenceToken()).isEqualTo(7L); + assertThat(run.getAttemptNo()).isEqualTo(1); + var order = inOrder(leases, locks, runs, tasks); + order.verify(leases).verify(lease); + order.verify(locks).lockTask(1L); + order.verify(runs).selectOne(any()); + order.verify(runs).insert(run); + order.verify(tasks).updateById(task); + } + + @Test void unknownSourceTimezoneAndUnestablishedInitialWindowStayExplicitlyUnknown() { + var scope = scope("OUTBOUND"); + scope.setSourceTimezone(null); + when(locks.lockTask(1L)).thenReturn(task("READY")); + when(scopes.selectById(10L)).thenReturn(scope); + Map snapshot = read(service.begin(1L, lease).getScopeSnapshotJson(), Map.class); + assertThat(snapshot.containsKey("sourceTimezone")).isTrue(); + assertThat(snapshot.containsKey("initialFromUtc")).isTrue(); + assertThat(snapshot.containsKey("initialUntilUtc")).isTrue(); + assertThat(snapshot.get("sourceTimezone")).isNull(); + assertThat(snapshot.get("initialFromUtc")).isNull(); + assertThat(snapshot.get("initialUntilUtc")).isNull(); + } + + @Test void staleFenceRejectsBeginBeforeAnyTaskLockOrAttemptMutation() { + doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).verify(lease); + assertThatThrownBy(() -> service.begin(1L, lease)).hasMessage("SELFOP_FENCE_REJECTED"); + verifyNoInteractions(locks, tasks, runs, scopes, accounts); + } + + @Test void validLeaseForAnotherWarehouseScopeCannotBeginThisTask() { + when(locks.lockTask(1L)).thenReturn(task("READY")); + var wrongScope = new SelfopLeaseService.Lease(11L, "synthetic-worker", 7L, false); + assertThatThrownBy(() -> service.begin(1L, wrongScope)).hasMessage("SELFOP_TASK_LEASE_SCOPE_MISMATCH"); + verify(leases).verify(wrongScope); + verifyNoInteractions(tasks, runs, scopes, accounts); + } + + @Test void sameManualRetryCommandDoesNotResetRunningOrFailedOriginalTask() { + for (String status : List.of("RUNNING", "FAILED")) { + reset(locks, tasks, accounts, runs, scopes); + var original = task(status); + original.setApplyState("BLOCKED"); + original.setAttemptCount(8); + original.setRequestedBy("previous-operator"); + var retryAudit = task("SUCCEEDED"); + retryAudit.setRequestKey("retry:command-1"); + retryAudit.setRequestHash(hash(List.of(1L, "command-1", "offline retry"))); + when(tasks.selectById(1L)).thenReturn(original); + when(tasks.selectOne(any())).thenReturn(retryAudit); + + assertThat(service.retry(1L, "command-1", "new-operator", "offline retry")).isSameAs(original); + assertThat(original.getStatus()).isEqualTo(status); + assertThat(original.getApplyState()).isEqualTo("BLOCKED"); + assertThat(original.getAttemptCount()).isEqualTo(8); + assertThat(original.getRequestedBy()).isEqualTo("previous-operator"); + assertThat(original.getNextRetryAtUtc()).isNull(); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + } + + @Test void duplicateRetryKeyForAnotherTaskIsRefusedWithoutChangingOriginal() { + var original = task("RUNNING"); + var prior = task("SUCCEEDED"); + prior.setRequestHash(hash(List.of(2L, "command-1", "offline retry"))); + when(tasks.selectById(1L)).thenReturn(original); + when(tasks.selectOne(any())).thenReturn(prior); + assertThatThrownBy(() -> service.retry(1L, "command-1", "operator", "offline retry")) + .hasMessage("SELFOP_IDEMPOTENCY_CONFLICT"); + assertThat(original.getStatus()).isEqualTo("RUNNING"); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + + @Test void firstRetryLocksWarehouseScopeBeforeTaskAndPreservesDurableApplyBarrier() { + var original = task("DEAD"); + original.setApplyState("BLOCKED"); + original.setAttemptCount(5); + var unlockedScope = scope("OUTBOUND"); + when(tasks.selectById(1L)).thenReturn(original); + when(locks.lockScope(10L)).thenReturn(unlockedScope); + when(locks.lockTask(1L)).thenReturn(original); + assertThat(service.retry(1L, "first-command", "new-operator", "offline retry")).isSameAs(original); + var order = inOrder(locks); + order.verify(locks).lockScope(10L); + order.verify(locks).lockTask(1L); + assertThat(original.getStatus()).isEqualTo("READY"); + assertThat(original.getApplyState()).isEqualTo("BLOCKED"); + assertThat(original.getAttemptCount()).isEqualTo(5); + var audit = org.mockito.ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(tasks).insert(audit.capture()); + assertThat(audit.getValue().getRequestKey()).isEqualTo("retry:first-command"); + assertThat(audit.getValue().getStatus()).isEqualTo("SUCCEEDED"); + assertThat(audit.getValue().getUpstreamTaskId()).isEqualTo(1L); + assertThat(original.getTaskNo()).isEqualTo("LXCOL-20261012-00000001"); + assertThat(read(audit.getValue().getQueryScopeJson(),Map.class)).containsEntry("reason","offline retry") + .containsEntry("resumesTaskNo",original.getTaskNo()); + verify(tasks).updateById(original); + } + + @Test void firstRetryCannotResetTaskWhileScopeHasLiveExecutionLease() { + var original = task("RUNNING"); + original.setApplyState("APPLYING"); + var liveScope = scope("OUTBOUND"); + liveScope.setLeaseOwner("active-worker"); + liveScope.setLeaseUntilUtc(utcNow().plusDays(1)); + when(tasks.selectById(1L)).thenReturn(original); + when(locks.lockScope(10L)).thenReturn(liveScope); + assertThatThrownBy(() -> service.retry(1L, "first-command", "new-operator", "offline retry")) + .hasMessage("SELFOP_RETRY_LEASE_BUSY"); + assertThat(original.getStatus()).isEqualTo("RUNNING"); + assertThat(original.getApplyState()).isEqualTo("APPLYING"); + assertThat(original.getNextRetryAtUtc()).isNull(); + verify(locks, never()).lockTask(anyLong()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verifyNoInteractions(accounts, runs, scopes); + } + + @Test void concurrentRetryAuditAppearingAfterScopeLockUsesCurrentReadAndDoesNotResetTask() { + var original = task("RUNNING"); + original.setApplyState("APPLYING"); + var audit = task("SUCCEEDED"); + audit.setRequestHash(hash(List.of(1L, "raced-command", "offline retry"))); + when(tasks.selectById(1L)).thenReturn(original); + when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND")); + int[] reads = {0}; + when(tasks.selectOne(any())).thenAnswer(call -> { + QueryWrapper query = call.getArgument(0); + if (++reads[0] == 1) { + assertThat(query.getSqlSegment()).doesNotContain("FOR UPDATE"); + return null; + } + assertThat(query.getSqlSegment()).endsWith("FOR UPDATE"); + return audit; + }); + assertThat(service.retry(1L, "raced-command", "new-operator", "offline retry")).isSameAs(original); + assertThat(reads[0]).isEqualTo(2); + assertThat(original.getStatus()).isEqualTo("RUNNING"); + assertThat(original.getApplyState()).isEqualTo("APPLYING"); + assertThat(original.getNextRetryAtUtc()).isNull(); + verify(locks).lockScope(10L); + verify(locks, never()).lockTask(anyLong()); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + } + + @Test void duplicateInsertUsesCurrentReadAndReturnsExistingTaskWithoutResettingIt() { + var existing = task("RUNNING"); + existing.setApplyState("APPLYING"); + existing.setAttemptCount(8); + var incoming = task("READY"); + int[] reads = {0}; + when(tasks.selectOne(any())).thenAnswer(call -> { + QueryWrapper query = call.getArgument(0); + if (++reads[0] == 1) return null; + assertThat(query.getSqlSegment()).endsWith("FOR UPDATE"); + return existing; + }); + when(tasks.insert(any(SelfopSyncTask.class))).thenThrow(new DuplicateKeyException("synthetic duplicate")); + assertThat(service.create(incoming)).isSameAs(existing); + assertThat(reads[0]).isEqualTo(2); + assertThat(existing.getStatus()).isEqualTo("RUNNING"); + assertThat(existing.getApplyState()).isEqualTo("APPLYING"); + assertThat(existing.getAttemptCount()).isEqualTo(8); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + + @Test void createRefusesSameRequestKeyWithDifferentPayloadWithoutAllocatingOrWriting() { + var existing = task("RUNNING"); + existing.setRequestHash("existing-hash"); + var incoming = task("READY"); + incoming.setRequestHash("different-hash"); + when(tasks.selectOne(any())).thenReturn(existing); + assertThatThrownBy(() -> service.create(incoming)).hasMessage("SELFOP_IDEMPOTENCY_CONFLICT"); + assertThat(incoming.getAttemptCount()).isEqualTo(0); + verify(tasks, never()).insert(any(SelfopSyncTask.class)); + verify(tasks, never()).updateById(any(SelfopSyncTask.class)); + verifyNoInteractions(locks, accounts, runs, scopes); + } + + @Test void lostLeaseFailCannotTouchCurrentRunOrTask() { + doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).verify(lease); + assertThatThrownBy(() -> service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease)) + .hasMessage("SELFOP_FENCE_REJECTED"); + verifyNoInteractions(locks, tasks, runs, scopes, accounts); + } + + @Test void oldRunFenceCannotMutateWithOtherwiseValidCurrentScopeLease() { + var task = task("RUNNING"); + var oldRun = run("FETCHING"); + oldRun.setFenceToken(6L); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(oldRun); + assertThatThrownBy(() -> service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease)) + .hasMessage("SELFOP_RUN_LEASE_MISMATCH"); + verify(leases).verify(lease); + assertThat(task.getStatus()).isEqualTo("RUNNING"); + assertThat(oldRun.getStatus()).isEqualTo("FETCHING"); + assertThat(oldRun.getErrorCode()).isNull(); + verifyNoInteractions(tasks, runs, scopes, accounts); + } + + @Test void budgetYieldDoesNotBecomeFailedAttemptOrClearApplyBarrier() { + var task = task("RUNNING"); + task.setAttemptCount(99); + task.setApplyState("APPLYING"); + var run = run("FETCHING"); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(run); + service.yield(1L, 20L, lease); + assertThat(run.getStatus()).isEqualTo("YIELDED"); + assertThat(run.getErrorCode()).isNull(); + assertThat(task.getStatus()).isEqualTo("RETRY_WAIT"); + assertThat(task.getAttemptCount()).isEqualTo(99); + assertThat(task.getApplyState()).isEqualTo("APPLYING"); + assertThat(task.getNextRetryAtUtc()).isNotNull(); + verify(runs, never()).selectCount(any()); + verify(runs).updateById(run); + verify(tasks).updateById(task); + } + + @Test void finiteFailureBudgetCountsFailuresInsteadOfManyYieldedAttempts() { + config.setMaxAttempts(3); + var task = task("RUNNING"); + task.setAttemptCount(99); + task.setApplyState("APPLYING"); + var run = run("FETCHING"); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(run); + when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND")); + when(runs.selectCount(any())).thenAnswer(call -> { + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper query = call.getArgument(0); + query.getSqlSegment(); + assertThat(query.getParamNameValuePairs()).containsValue("FAILED").doesNotContainValue("YIELDED"); + return 2L; + }); + service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease); + assertThat(task.getStatus()).isEqualTo("RETRY_WAIT"); + assertThat(task.getApplyState()).isEqualTo("BLOCKED"); + assertThat(run.getStatus()).isEqualTo("FAILED"); + assertThat(run.getLastPhase()).isEqualTo("FETCHING"); + assertThat(run.getErrorCode()).isEqualTo("SOURCE_TEMPORARY"); + verify(runs).selectCount(any()); + } + + @Test void exhaustedFiniteFailuresBecomeDeadAndKeepBlockedApplyState() { + config.setMaxAttempts(3); + var task = task("RUNNING"); + task.setApplyState("APPLYING"); + when(locks.lockTask(1L)).thenReturn(task); + when(locks.lockRun(20L)).thenReturn(run("BUILDING")); + when(scopes.selectById(10L)).thenReturn(scope("OUTBOUND")); + when(runs.selectCount(any())).thenReturn(3L); + service.fail(1L, 20L, "SOURCE_TEMPORARY", true, lease); + assertThat(task.getStatus()).isEqualTo("DEAD"); + assertThat(task.getApplyState()).isEqualTo("BLOCKED"); + } + + @Test void validationChildCannotEnterBusinessFailureOrManualRetryQueue() { + var child=task("RUNNING");child.setExecutionPurpose("VALIDATION");child.setValidationRootTaskId(9L); + var attempt=run("FETCHING");attempt.setExecutionPurpose("VALIDATION"); + when(locks.lockTask(1L)).thenReturn(child);when(locks.lockRun(20L)).thenReturn(attempt); + when(tasks.selectById(1L)).thenReturn(child);when(locks.lockScope(10L)).thenReturn(scope("OUTBOUND")); + assertThatThrownBy(()->service.fail(1L,20L,"VALIDATION_STRUCTURE_REJECTED",true,lease)) + .hasMessage("SELFOP_BUSINESS_PURPOSE_REQUIRED"); + assertThatThrownBy(()->service.retry(1L,"validation-retry","operator","offline retry")) + .hasMessage("SELFOP_BUSINESS_PURPOSE_REQUIRED"); + assertThat(child.getStatus()).isEqualTo("RUNNING");assertThat(child.getNextRetryAtUtc()).isNull(); + assertThat(attempt.getStatus()).isEqualTo("FETCHING"); + verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(tasks,never()).insert(any(SelfopSyncTask.class)); + verify(runs,never()).updateById(any(SelfopSyncRun.class));verify(runs,never()).selectCount(any()); + } + + private SelfopSyncTask task(String status) { + var task = new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000001"); + task.setId(1L); task.setScopeId(10L); task.setSourceAccount("synthetic-account"); + task.setWarehouseCode("WH-A"); task.setTaskRole("WAREHOUSE_SYNC"); + task.setRequestKey("slot-1"); task.setRequestHash("request-hash"); task.setScopeHash("scope-hash"); + task.setQueryScopeJson("{\"warehouseCode\":\"WH-A\"}"); + task.setStatus(status); task.setApplyState("NOT_STARTED"); task.setAttemptCount(0); + return task; + } + private SelfopSyncScope scope(String family) { + var scope = new SelfopSyncScope(); + scope.setId(10L); scope.setSourceAccount("synthetic-account"); scope.setWarehouseCode("WH-A"); + scope.setDataFamily(family); scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\"}"); + scope.setBusinessTimezone("America/New_York"); scope.setSourceTimezone("UTC"); + scope.setStaleAfterSeconds(43200); scope.setPublishedRunId(90L); + return scope; + } + private SelfopSyncRun run(String status) { + var run = new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); + run.setId(20L); run.setTaskId(1L); run.setScopeId(10L); run.setFenceToken(7L); run.setStatus(status); + return run; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java new file mode 100644 index 000000000..5c44540a6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java @@ -0,0 +1,156 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baiyue.lastleg.selfoperated.analytics.client.*; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Consumer; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** Offline callbacks model durable checkpoints, without pretending to test MySQL transaction rollback. */ +class SelfopValidationRunnerTest { + private final LingxingAnalyticsClient client=mock(LingxingAnalyticsClient.class); + private final SelfopSyncStageMapper stages=mock(SelfopSyncStageMapper.class); + private final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class); + private final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); + private final SelfopCoordinationMapper locks=mock(SelfopCoordinationMapper.class); + private final SelfopLeaseService leases=mock(SelfopLeaseService.class); + private final SelfopEgressService egress=mock(SelfopEgressService.class); + private final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties(); + private final TransactionTemplate tx=mock(TransactionTemplate.class); + private final SelfopLeaseService.Lease lease=new SelfopLeaseService.Lease(3,"owner",8,true); + private SelfopSyncTask task; + private SelfopSyncRun run; + private SelfopValidationRunner runner; + @BeforeEach void setup() { + config.setCollectionMode("VALIDATION");config.setSourceAccount("demo");config.setExclusiveGrantId("test-grant"); + config.getWarehouses().put("A",new SelfopAnalyticsProperties.Warehouse());config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse()); + task=new SelfopSyncTask();task.setId(1L);task.setValidationRootTaskId(1L);task.setTaskRole("ACCOUNT_DISCOVERY");task.setExecutionPurpose("VALIDATION"); + task.setSourceAccount("demo");task.setDiscoveryScopeId(3L);task.setAttemptCount(1);task.setStatus("RUNNING"); + task.setQueryScopeJson("{\"startTime\":\"2026-10-12T08:00:00\",\"endTime\":\"2026-10-12T08:10:00\"}"); + task.setValidationBudgetJson(SelfopValidationBudget.freeze(utcNow())); + run=new SelfopSyncRun();run.setId(2L);run.setTaskId(1L);run.setSourceAccount("demo");run.setExecutionPurpose("VALIDATION");run.setAttemptNo(1);run.setFenceToken(8L);run.setStatus("FETCHING"); + when(locks.lockTask(1)).thenReturn(task);when(locks.lockRun(2)).thenReturn(run);when(tasks.selectById(1L)).thenReturn(task); + doAnswer(i->{Consumer callback=i.getArgument(0);callback.accept(mock(TransactionStatus.class));return null;}).when(tx).executeWithoutResult(any()); + when(client.warehouses(any(SelfopRequestContext.class),eq(1),eq(10))).thenReturn(page(1,0,0,List.of())); + when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,0,0,List.of())); + when(client.inventory(any(SelfopRequestContext.class),anyString(),eq(1),eq(10))).thenReturn(page(1,0,0,List.of())); + runner=new SelfopValidationRunner(client,stages,tasks,runs,locks,leases,egress,config,tx); + } + private LingxingAnalyticsClient.Page page(int current,long total,int pages,List records){return new LingxingAnalyticsClient.Page<>(records,current,10,total,pages);} + private SelfopPayloads.Stock stock(String warehouse,String sku,BigDecimal qty){return new SelfopPayloads.Stock("demo","customer",warehouse,sku,0,"Name",qty,qty,BigDecimal.ZERO,BigDecimal.ZERO,null);} + private LingxingAnalyticsClient.Summary summary(String number){return new LingxingAnalyticsClient.Summary("demo","customer","A",number,"source",100,null,"2026-10-12 08:02:00");} + + @Test void truncatedTwoWarehouseSampleOnlyStagesAndDoesNotClaimCompleteCoverage() { + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("A","one",BigDecimal.ZERO)))); + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(2),eq(10))).thenReturn(page(2,30,3,List.of(stock("A","two",null)))); + when(client.inventory(any(SelfopRequestContext.class),eq("B"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("B","one",BigDecimal.ONE)))); + when(client.inventory(any(SelfopRequestContext.class),eq("B"),eq(2),eq(10))).thenReturn(page(2,30,3,List.of(stock("B","two",BigDecimal.ONE)))); + runner.run(task,run,lease); + assertEquals("SAMPLE_DONE",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus());assertNull(run.getPublishedAtUtc());assertNull(run.getCoveredUntilUtc()); + verify(client,never()).inventory(any(SelfopRequestContext.class),anyString(),eq(3),anyInt()); + var capture=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(4)).insert(capture.capture()); + assertTrue(capture.getAllValues().stream().allMatch(s->s.getObjectType().equals("PRODUCT_STOCK")&&s.getObservationSequence()==null)); + assertTrue(capture.getAllValues().stream().anyMatch(s->"PENDING".equals(s.getValidationStatus()))); + verify(egress).finishSample(1L,"VALIDATION_SAMPLE_SUFFICIENT"); + verify(tasks,never()).updateById(any(SelfopSyncTask.class)); // quota root JSON cannot be overwritten by an earlier task snapshot + } + @Test void emptyCompletePagesStillEndSampleAndNeverBecomeFormalVersion() { + runner.run(task,run,lease);assertEquals("SAMPLE_DONE",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus()); + assertNull(run.getPublishedAtUtc());assertNull(run.getAppliedFactRevision());verify(stages,never()).insert(any(SelfopSyncStage.class)); + assertNotNull(run.getProgressSnapshotJson()); + } + @Test void requestBudgetStopIsSampleDoneNotYieldAndNoSubsequentRequests() { + when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenThrow(new EgressRejectedException("VALIDATION_PAGE_LIMIT",true,null)); + runner.run(task,run,lease);assertEquals("SAMPLE_DONE",run.getStatus());verify(egress).finishSample(1L,"VALIDATION_PAGE_LIMIT"); + verify(client,never()).inventory(any(SelfopRequestContext.class),anyString(),anyInt(),anyInt()); + } + @Test void rateLimitFailsEntireRootAndDoesNotResumeOtherWarehouse() { + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenThrow(new AnalyticsClientException("LINGXING_WMS_RATE_LIMITED",true)); + runner.run(task,run,lease);assertEquals("FAILED",run.getStatus());verify(egress).failValidation(1L,"LINGXING_WMS_RATE_LIMITED"); + verify(client,never()).inventory(any(SelfopRequestContext.class),eq("B"),anyInt(),anyInt()); + } + @Test void uniqueDetailsRetainSkuQuantitiesAndIdentityValidation() throws Exception { + when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,1,1,List.of(summary("order")))); + var detail=new SelfopPayloads.Order("demo","customer","A","order","source",100,null,"2026-10-12 08:02:00",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS"))); + when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("order"))).thenReturn(detail); + runner.run(task,run,lease); + var capture=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages,times(2)).insert(capture.capture()); + var row=capture.getAllValues().stream().filter(s->"OUTBOUND_ORDER".equals(s.getObjectType())&&"SAMPLE_VALIDATED".equals(s.getValidationCode())).findFirst().orElseThrow(); + assertEquals(BigDecimal.ONE,read(row.getPayloadJson(),SelfopPayloads.Order.class).items().get(0).actualQuantity()); + assertNull(row.getObservationSequence());assertEquals("SAMPLE_DONE",run.getStatus()); + assertEquals("PENDING",row.getValidationStatus()); + assertTrue(capture.getAllValues().stream().allMatch(s->s.getObjectType().length()<=16&& + (!"OUTBOUND_ORDER".equals(s.getObjectType())||!"VALID".equals(s.getValidationStatus())||s.getObservationSequence()!=null))); + try(var schema=getClass().getResourceAsStream("/db/lastleg.sql")) { + assertNotNull(schema); + String sql=new String(schema.readAllBytes(),java.nio.charset.StandardCharsets.UTF_8); + assertTrue(sql.contains("object_type VARCHAR(16)")); + assertTrue(sql.contains("object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL")); + } + } + @Test void restartUsesPersistedSampleCursorWithoutRepeatingCompletedPagesOrDetails() { + var saved=new SelfopValidationRunner.Sample();saved.setWarehousesRead(true);saved.setDirectoryRead(true);saved.setDetailIndex(1); + saved.getOrders().add(summary("order"));saved.getInventoryFinished().add("A");saved.getInventoryNextPage().put("A",3); + task.setRoutingManifestJson(json(saved));runner.run(task,run,lease); + verify(client,never()).warehouses(any(SelfopRequestContext.class),anyInt(),anyInt()); + verify(client,never()).detail(any(SelfopRequestContext.class),anyString(),anyString(),anyString()); + verify(client,never()).inventory(any(SelfopRequestContext.class),eq("A"),anyInt(),anyInt()); + verify(client).inventory(any(SelfopRequestContext.class),eq("B"),eq(1),eq(10)); + assertEquals("SAMPLE_DONE",run.getStatus()); + } + @Test void wrongPurposeCannotCallAnyClient() { + task.setExecutionPurpose("BUSINESS");assertThrows(IllegalStateException.class,()->runner.run(task,run,lease));verifyNoInteractions(client,egress); + } + @Test void malformedPersistedSampleTerminatesRootBeforeAnyProviderRequest() { + task.setRoutingManifestJson("{invalid"); + runner.run(task,run,lease); + assertEquals("FAILED",run.getStatus());assertEquals("UNKNOWN",run.getCoverageStatus()); + assertNull(run.getPublishedAtUtc());assertNull(run.getAppliedFactRevision()); + verify(egress).failValidation(1L,"VALIDATION_STRUCTURE_REJECTED"); + verifyNoInteractions(client,stages); + verify(tasks).update(isNull(),argThat((UpdateWrapper write)-> + write.getSqlSet().contains("next_retry_at_utc") + && write.getParamNameValuePairs().containsValue("DEAD") + && write.getParamNameValuePairs().containsValue(null))); + } + @Test void executionFailureOnChildTerminatesItsOriginalRootAndClearsChildRetry() { + task.setValidationRootTaskId(9L); + runner.fail(task,run,lease,"VALIDATION_STRUCTURE_REJECTED"); + assertEquals("FAILED",run.getStatus());assertNull(run.getPublishedAtUtc()); + verify(egress).failValidation(9L,"VALIDATION_STRUCTURE_REJECTED"); + verify(tasks).update(isNull(),argThat((UpdateWrapper write)-> + write.getSqlSet().contains("next_retry_at_utc") + && write.getParamNameValuePairs().containsValue("DEAD") + && write.getParamNameValuePairs().containsValue(null))); + verifyNoInteractions(client,stages); + } + @Test void oldExecutorCannotMarkNewAttemptFailed() { + doThrow(new IllegalStateException("SELFOP_FENCE_REJECTED")).when(leases).renew(lease); + assertThrows(IllegalStateException.class,()->runner.run(task,run,lease));verifyNoInteractions(egress);verifyNoInteractions(client); + } + @Test void warehouseEvidenceUsesRunDimensionsAndOnlyDeclaredStageTypesAreWritten() { + var warehouse=new LingxingAnalyticsClient.Warehouse("A","Warehouse",0,0,"America/New_York"); + when(client.warehouses(any(SelfopRequestContext.class),eq(1),eq(10))).thenReturn(page(1,11,2,List.of(warehouse))); + runner.run(task,run,lease); + var dimensions=read(run.getObservedDimensionsJson(),Map.class); + assertEquals(true,dimensions.get("truncated"));assertTrue(dimensions.containsKey("validationWarehouseSamples")); + verify(stages,never()).insert(any(SelfopSyncStage.class));assertEquals("SAMPLE_DONE",run.getStatus()); + } + @Test void overlongSkuCannotBeAcceptedAsStructurallyValidStock() { + when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,1,1,List.of(stock("A","x".repeat(129),BigDecimal.ONE)))); + runner.run(task,run,lease);assertEquals("FAILED",run.getStatus()); + verify(egress).failValidation(1L,"VALIDATION_STRUCTURE_REJECTED");verify(stages,never()).insert(any(SelfopSyncStage.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java new file mode 100644 index 000000000..da250a304 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopOutboundDiscoveryScopeMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncScopeMapper; +import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopSyncTaskMapper; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncScope; +import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.server.ResponseStatusException; +import java.util.Set; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SyncControllerTest { + private static final String SYNC = "platform:self-operated-analytics:sync"; + private final SelfopCommandService.Command command = new SelfopCommandService.Command( + "synthetic-command", "WH-A", "INVENTORY", null, null, null, null, null, "offline test"); + + @Test void anonymousAndCustomerCannotCreatePlatformTasksEvenWithSyncPermission() { + var commands = mock(SelfopCommandService.class); + var controller = new SyncController(commands); + assertForbidden(() -> controller.request(null, command)); + assertForbidden(() -> controller.request(user(100L, Set.of(SYNC)), command)); + verifyNoInteractions(commands); + } + + @Test void platformReadPermissionDoesNotAuthorizeManualSync() { + var commands = mock(SelfopCommandService.class); + var controller = new SyncController(commands); + assertForbidden(() -> controller.request(user(0L, Set.of("platform:self-operated-analytics:list")), command)); + assertForbidden(() -> controller.request(user(0L, Set.of()), command)); + verifyNoInteractions(commands); + } + + @Test void platformScopeFromClientAppAndImpersonatedUserAreDenied() { + var commands = mock(SelfopCommandService.class); + var controller = new SyncController(commands); + var client = new AuthenticatedUser(42L, 0L, 0L, 1L, "synthetic-user", "CLIENT", "WEB", Set.of(SYNC)); + var impersonated = new AuthenticatedUser(42L, 0L, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", Set.of(SYNC), + true, 77L, 2L, false); + assertForbidden(() -> controller.request(client, command)); + assertForbidden(() -> controller.request(impersonated, command)); + verifyNoInteractions(commands); + } + + @Test void authorizedPlatformCommandReturnsAcceptedWithoutPerformingCollection() { + var config = new SelfopAnalyticsProperties(); + assertThat(config.getCollectionMode()).isEqualTo("OFF"); + assertThat(config.isExecutionEnabled()).isFalse(); + config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSourceAccount("synthetic-account"); + config.getWarehouses().put("WH-A", new SelfopAnalyticsProperties.Warehouse()); + var tasks = mock(SelfopTaskService.class); + var scopes = mock(SelfopSyncScopeMapper.class); + var accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); + var scope = new SelfopSyncScope(); + scope.setId(10L); scope.setScopeHash("synthetic-scope-hash"); + scope.setScopeJson("{\"customerMode\":\"ALL_AUTHORIZED\",\"qualities\":\"ALL\"}"); + when(scopes.selectOne(any())).thenReturn(scope); + when(tasks.create(any(SelfopSyncTask.class))).thenAnswer(call -> { + SelfopSyncTask task = call.getArgument(0); task.setId(101L); task.setTaskNo("LXCOL-20261012-00000101"); return task; + }); + var taskRows = mock(SelfopSyncTaskMapper.class); + var commands = new SelfopCommandService(tasks, scopes, accounts, config, taskRows); + var response = new SyncController(commands).request(user(0L, Set.of(SYNC)), command); + assertThat(response.successful()).isTrue(); + assertThat(response.data().taskNo()).isEqualTo("LXCOL-20261012-00000101"); + assertThat(response.data().status()).isEqualTo("ACCEPTED"); + assertThat(response.data().warehouseCode()).isEqualTo("WH-A"); + assertThat(config.isExecutionEnabled()).isTrue(); + assertThat(config.isSalesPublicationEnabled()).isFalse(); + var captured = ArgumentCaptor.forClass(SelfopSyncTask.class); + verify(tasks).create(captured.capture()); + assertThat(captured.getValue().getRequestedBy()).isEqualTo("42"); + assertThat(captured.getValue().getRequestKey()).isEqualTo("manual:synthetic-command"); + assertThat(captured.getValue().getTaskRole()).isEqualTo("WAREHOUSE_SYNC"); + assertThat(captured.getValue().getWarehouseCode()).isEqualTo("WH-A"); + verify(tasks).initialize(); + verifyNoInteractions(accounts, taskRows); + } + + @Test void endpointDeclaresIndependentSyncAuthority() throws Exception { + var method = SyncController.class.getMethod("request", AuthenticatedUser.class, SelfopCommandService.Command.class); + assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('" + SYNC + "')"); + } + + private AuthenticatedUser user(Long customerScope, Set permissions) { + return new AuthenticatedUser(42L, customerScope, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", permissions); + } + private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable request) { + assertThatThrownBy(request).isInstanceOf(ResponseStatusException.class) + .satisfies(e -> assertThat(((ResponseStatusException)e).getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java index c56af968d..fbabbeccd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java @@ -1,7 +1,9 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.lastleg.service.reference.ReturnCustomerReferenceClient; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import org.junit.jupiter.api.Test; import java.util.List; @@ -11,11 +13,11 @@ import static org.mockito.Mockito.*; class ReturnOrderReferenceResolverTest { @Test void directoryFailureBlocksNewSnapshotButDoesNotHideHistoricalOrders() { - var customers = mock(FleetCustomerDirectory.class); + var customers = mock(ReturnCustomerReferenceClient.class); var warehouses = mock(WarehouseResourceGateway.class); - when(customers.find("1")).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE)); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE)); when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE")); - var resolver = new ReturnOrderReferenceResolver(customers, warehouses); + var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token"); org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> resolver.populate(List.of(order()))); var historical = order(); resolver.populateForRead(List.of(historical)); @@ -24,24 +26,55 @@ class ReturnOrderReferenceResolverTest { @Test void resolvesMissingReturnReferencesOncePerCustomerAndWarehouseAndPreservesSnapshots() { - var customers = mock(FleetCustomerDirectory.class); + var customers = mock(ReturnCustomerReferenceClient.class); var warehouses = mock(WarehouseResourceGateway.class); - when(customers.find("1")).thenReturn(new FleetCustomerDirectory.Customer("1", "C1", "测试货主", null, null)); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenReturn(ApiResponse.success(new PageResult<>( + List.of(new ReturnCustomerReferenceClient.Customer(1L, "C1", "测试货主", "ACTIVE", null, null)), 1, 30, 1, 1))); when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE")); LastlegOrder first = order(), second = order(), frozen = order(); frozen.setCustomerName("原货主快照"); frozen.setOverseasWarehouseCode("OLD"); frozen.setOverseasWarehouseName("原仓库快照"); - new ReturnOrderReferenceResolver(customers, warehouses).populate(List.of(first, second, frozen)); + new ReturnOrderReferenceResolver(customers, warehouses, "test-token").populate(List.of(first, second, frozen)); assertEquals("测试货主", first.getCustomerName()); assertEquals("USLAX01", second.getOverseasWarehouseCode()); assertEquals("洛杉矶仓", second.getOverseasWarehouseName()); assertEquals("原货主快照", frozen.getCustomerName()); assertEquals("原仓库快照", frozen.getOverseasWarehouseName()); - verify(customers, times(1)).find("1"); + verify(customers, times(1)).customers("test-token", 1, 30, null, "ACTIVE", 1L); verify(warehouses, times(1)).requireActiveSharedWarehouse(10L); } + @Test + void foreignOrInactiveCustomerCannotSupplyReturnSnapshot() { + var customers = mock(ReturnCustomerReferenceClient.class); + var warehouses = mock(WarehouseResourceGateway.class); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenReturn(ApiResponse.success(new PageResult<>(List.of( + new ReturnCustomerReferenceClient.Customer(2L, "C2", "其他客户", "ACTIVE", null, null), + new ReturnCustomerReferenceClient.Customer(1L, "C1", "停用客户", "DISABLED", null, null)), 1, 30, 2, 1))); + var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token"); + var error = org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> resolver.populate(List.of(order()))); + assertEquals(400, error.getStatusCode().value()); + verifyNoInteractions(warehouses); + } + + @Test + void unexpectedClientFailureDoesNotHideHistoricalReturnAndKeepsMissingNameUnknown() { + var customers = mock(ReturnCustomerReferenceClient.class); + var warehouses = mock(WarehouseResourceGateway.class); + when(customers.customers("test-token", 1, 30, null, "ACTIVE", 1L)).thenThrow(new IllegalStateException("transport failure")); + when(warehouses.requireActiveSharedWarehouse(10L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(10L, "USLAX01", "洛杉矶仓", "ACTIVE")); + var resolver = new ReturnOrderReferenceResolver(customers, warehouses, "test-token"); + var historical = order(); + resolver.populateForRead(List.of(historical)); + org.junit.jupiter.api.Assertions.assertNull(historical.getCustomerName()); + assertEquals("洛杉矶仓", historical.getOverseasWarehouseName()); + var error = org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, + () -> resolver.populate(List.of(order()))); + assertEquals(503, error.getStatusCode().value()); + } + private LastlegOrder order() { var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL"); order.setCustomerId("1"); order.setOverseasWarehouseId("10"); return order; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java index b8876ad70..839759ec8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java @@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; -import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.baiyue.lastleg.transfer.gateway.TransferWarehouseCatalogClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests; import com.baiyue.lastleg.transfer.entity.*; @@ -35,7 +35,7 @@ import static org.mockito.Mockito.*; class WarehouseTransferReceiptCoordinatorTest { JdbcTemplate jdbc; SqlSessionTemplate session; WarehouseTransferReceiptCoordinator coordinator; - WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; FleetWarehouseClient catalog; + WarehouseResourceGateway warehouse; DomesticWarehouseDirectoryClient directory; TransferWarehouseCatalogClient catalog; ObjectMapper json = new ObjectMapper().findAndRegisterModules(); AtomicInteger occupancies = new AtomicInteger(), reserveCalls = new AtomicInteger(), stockCalls = new AtomicInteger(); AtomicBoolean loseReserveResponse = new AtomicBoolean(), failLocal = new AtomicBoolean(); @@ -73,7 +73,7 @@ class WarehouseTransferReceiptCoordinatorTest { ReflectionTestUtils.setField(coordinator, "commands", session.getMapper(WarehouseTransferReceiptCommandMapper.class)); ReflectionTestUtils.setField(coordinator, "transactionManager", new DataSourceTransactionManager(ds)); ReflectionTestUtils.setField(coordinator, "objectMapper", json); - warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(FleetWarehouseClient.class); + warehouse = mock(WarehouseResourceGateway.class); directory = mock(DomesticWarehouseDirectoryClient.class); catalog = mock(TransferWarehouseCatalogClient.class); ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "directory", directory); ReflectionTestUtils.setField(coordinator, "catalog", catalog); ReflectionTestUtils.setField(coordinator, "internalToken", "test-token"); when(warehouse.requireActiveSharedWarehouse(9L)).thenReturn(new WarehouseResourceGateway.WarehouseReference(9, "NY", "NY warehouse", "ACTIVE")); @@ -81,7 +81,7 @@ class WarehouseTransferReceiptCoordinatorTest { when(warehouse.resolveLocation(eq(9L), anyLong())).thenAnswer(call -> new WarehouseResourceGateway.WarehouseLocationReference( call.getArgument(1), "LOC-" + call.getArgument(1), "STORAGE", new BigDecimal("1000"), "ACTIVE")); when(catalog.activeWarehouses("test-token")).thenReturn(ApiResponse.success(List.of( - new FleetWarehouseClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US")))); + new TransferWarehouseCatalogClient.Warehouse(30L, "ABE4", "ABE4 warehouse", "FBA", "US")))); when(directory.activeWarehouse(30L, "test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail( new DomesticWarehouseDirectoryClient.Warehouse(30L, 0L, "ABE4", "ABE4 warehouse", "FBA", "US", "ACTIVE", null, null), List.of()))); when(warehouse.reserve(anyString(), anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenAnswer(call -> { diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 295791f22..53a2a0df1 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -23,7 +23,6 @@ "inventory->inbound-pda", "inventory->shared-technical", "overseas-master-data->shared-technical", - "procurement-trucking-looseboard->overseas-master-data", "procurement-trucking-looseboard->shared-technical", "procurement-trucking-looseboard->tailleg-handoff", "relabel-transfer-discard->dispatch-delivery", @@ -55,6 +54,81 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService", + "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", + "reason": "退货换标合箱沿用PDA冻结仓身份作为操作人与仓库约束,不引用入仓业务服务或持久化。" + }, + { + "source": "com.baiyue.lastleg.transfer.service.WarehouseTransferReceiptCoordinator", + "target": "com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway", + "reason": "转仓实收复用仓储资源HTTP网关,校验及预留目标托盘库位;资源权威仍在Warehouse,不读取入仓持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteModels", + "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.quote.FleetQuoteService", + "reason": "平台车队报价导出仅组合公开分页查询和投影,沿用列表筛选,不访问车队实体或Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsModels", + "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.fleet.reporting.TripOperationsService", + "reason": "平台出车记录导出仅组合公开报表查询及返回投影,成本可见性仍由导出上下文传递,不访问车队持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests", + "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.inventory.query.service.InventoryQueryService", + "reason": "平台库存导出复用库存公开查询与筛选DTO,客户范围及业务投影与列表一致,不读取库存Mapper。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.exchange.dto.MonthlyExchangeRateDtos", + "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.exchange.service.MonthlyExchangeRateService", + "reason": "平台月度汇率导出复用既有分页查询和DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.quote.dto.OverseasQuoteRequests", + "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.quote.service.OverseasQuoteService", + "reason": "平台海外报价导出复用既有分页查询和筛选DTO,不直接访问主数据持久化。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels", + "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" + }, + { + "source": "com.baiyue.lastleg.export.LastlegExportProviderService", + "target": "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService", + "reason": "平台车辆导出仅使用车辆应用层公开分页查询及模型,禁止访问车辆持久化适配器。" + }, + { + "source": "com.baiyue.lastleg.selfoperated.analytics.request.SelfopEgressController", + "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", + "reason": "内部出口许可端点复用现行服务令牌校验,不复制认证实现;只允许该控制器依赖该基础校验器,不开放客户业务或共享技术切片的其他引用。" + }, { "source": "com.baiyue.lastleg.financefee.fleet.VehicleCareFinanceAdapter", "target": "com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway", @@ -214,7 +288,7 @@ }, "overseas-master-data": { "allowedOutgoing": ["shared-technical"], - "allowedIncoming": ["procurement-trucking-looseboard", "fleet"] + "allowedIncoming": ["fleet"] } } }