From 242a76221ea9b0afa84330cb07db30401b57cc61 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 21:13:31 +0800 Subject: [PATCH] feat(self-operated): persist continuous collection and frozen coverage --- .../config/LingxingEgressProperties.java | 3 +- .../provider/lingxing/LingxingEgressGate.java | 10 +- .../src/main/resources/application.yml | 3 +- .../config/LingxingEgressPropertiesTest.java | 15 +- .../lingxing/LingxingEgressGateTest.java | 88 +++++++++++- .../LingxingHttpBudgetAuthorityTest.java | 4 +- .../lingxing/LingxingWmsClientTest.java | 2 +- .../mapper/SelfopCoordinationMapper.java | 2 + .../model/SelfopAnalyticsProperties.java | 2 + .../model/SelfopCollectionPolicy.java | 34 ++++- .../analytics/model/SelfopSalesCoverage.java | 46 ++++++ .../analytics/model/SelfopStageEstimate.java | 11 +- .../outbound/OutboundAggregateMapper.java | 16 +++ .../OutboundCandidateTransaction.java | 10 +- .../query/AnalyticsQueryService.java | 28 ++-- .../analytics/query/AnalyticsReadSql.java | 30 ++++ .../analytics/query/AnalyticsSqlRequest.java | 3 +- .../query/CollectionQueryService.java | 27 +++- .../analytics/query/CollectionResponses.java | 2 +- .../query/DashboardQueryService.java | 8 +- .../analytics/query/DashboardSalesPolicy.java | 13 +- .../analytics/query/SalesQueryCoverage.java | 33 +++++ .../request/SelfopBusinessBudget.java | 11 +- .../request/SelfopEgressService.java | 43 +++--- .../sync/SelfopExecutionService.java | 1 + .../analytics/sync/SelfopSalesPublisher.java | 3 +- .../analytics/sync/SelfopSchedulePlanner.java | 83 +++++++++++ .../analytics/sync/SelfopTaskService.java | 47 ++++--- .../src/main/resources/application.yml | 1 + .../openapi/self-operated-analytics-v1.yaml | 4 +- .../model/SelfopStageEstimateTest.java | 22 ++- ...oundCandidateBuildMySqlAcceptanceTest.java | 2 +- .../OutboundCandidateBuilderTest.java | 43 ++++++ .../query/AnalyticsQueryServiceTest.java | 65 +++++++++ .../query/AnalyticsReadMapperTest.java | 2 +- .../query/AnalyticsSqlSemanticTest.java | 32 +++-- .../query/CollectionQueryServiceTest.java | 40 ++++++ .../analytics/query/DashboardPolicyTest.java | 25 ++-- .../query/DashboardQueryServiceTest.java | 4 +- .../query/DashboardSqlSemanticTest.java | 16 +-- .../request/SelfopBusinessBudgetTest.java | 9 ++ .../request/SelfopEgressServiceTest.java | 133 ++++++++++++++++++ .../sync/SelfopSalesPublisherTest.java | 30 ++++ .../sync/SelfopSchedulePlannerTest.java | 89 ++++++++++++ .../analytics/sync/SelfopTaskServiceTest.java | 44 ++++++ 45 files changed, 1021 insertions(+), 118 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java 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 index b59ace7b4..dbcdeeb46 100644 --- a/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingEgressProperties.java @@ -12,7 +12,8 @@ import java.time.Instant; 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) { + @DefaultValue("5m") Duration cooldownFallback, + @DefaultValue("false") boolean continuousBusinessCollectionEnabled) { public LingxingEgressProperties { collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode; environmentLabel = environmentLabel == null ? "test" : environmentLabel; 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 index e01f5be97..aafb52dd6 100644 --- 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 @@ -62,7 +62,7 @@ public final class LingxingEgressGate { || !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())) { + || (!continuousBusiness() && 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. @@ -106,10 +106,14 @@ public final class LingxingEgressGate { 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()) + if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank() + || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId()) + || (!continuousBusiness() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()))) || properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED"); } + private boolean continuousBusiness() { + return properties.collectionMode() == CollectionMode.BUSINESS && properties.continuousBusinessCollectionEnabled(); + } private static boolean identifier(String value, int maximum) { return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); } diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 4264c372f..51fa8a5d3 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -44,13 +44,14 @@ baiyue: request-timeout: 15s egress: collection-mode: ${LINGXING_COLLECTION_MODE:OFF} + continuous-business-collection-enabled: ${LINGXING_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false} 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. + # Finite sessions require external-exclusive-until; continuous BUSINESS still requires the exclusive grant ID and authority. # Missing proof/authority fails closed even when the old enabled switch is true. baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} diff --git a/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java index 1da7522cc..ad3667d9b 100644 --- a/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/config/LingxingEgressPropertiesTest.java @@ -7,12 +7,23 @@ import java.util.List; import static org.assertj.core.api.Assertions.*; class LingxingEgressPropertiesTest { + @Test void continuousBusinessFlagBindsOnlyWhenExplicitlyEnabled() { + String prefix = "baiyue.integration.lingxing-wms.egress"; + var input = new java.util.HashMap(); + input.put(prefix + ".collection-mode", "BUSINESS"); + var source = new org.springframework.boot.context.properties.source.MapConfigurationPropertySource(input); + var binder = new org.springframework.boot.context.properties.bind.Binder(source); + var target = org.springframework.boot.context.properties.bind.Bindable.of(LingxingEgressProperties.class); + assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isFalse(); + source.put(prefix + ".continuous-business-collection-enabled", "true"); + assertThat(binder.bind(prefix, target).get().continuousBusinessCollectionEnabled()).isTrue(); + } private LingxingEgressProperties properties(CollectionMode mode, Duration interval, Duration cooldown) { - return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown); + return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown, false); } @Test void businessAcceptsPositiveMillisecondIntervalsIncludingProbeSteps() { - for (long millis : new long[]{1, 1500, 2000, 3000, 5000, 300000}) { + for (long millis : new long[]{1, 1200, 1500, 2000, 3000, 5000, 300000}) { assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval()) .isEqualTo(Duration.ofMillis(millis)); } 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 index c7a2da739..92ae145ba 100644 --- 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 @@ -54,7 +54,88 @@ class LingxingEgressGateTest { 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); + return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null, false); + } + private LingxingEgressGate continuousGate(CollectionMode mode, String grant, Instant until) { + return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", "demo-account", grant, until, + URI.create("http://127.0.0.1"), null, null, true), ledger, clock, clock::advanceMillis); + } + @Test void continuousBusinessCrossesOldGrantExpiryButKeepsFiniteSingleUsePermits() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", NOW.minusSeconds(1)); + assertThat(gate.execute(businessContext("continuous"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + clock.advanceMillis(86400000); + var restarted = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThat(restarted.execute(businessContext("next-day"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1); + ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, + clock.instant().plusSeconds(31), true, true); + assertThatThrownBy(() -> restarted.execute(businessContext("too-long"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("BUDGET_PERMIT_INVALID"); + assertThat(ledger.completions).hasSize(2); + } + @Test void continuousFlagNeverReplacesMatchingGrantOrFiniteValidationWindow() { + for (String grant : Arrays.asList(null, "", "other-grant")) { + var gate = continuousGate(CollectionMode.BUSINESS, grant, null); + assertThatThrownBy(() -> gate.execute(businessContext("missing-grant"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + for (Instant until : Arrays.asList(null, NOW)) { + var gate = continuousGate(CollectionMode.VALIDATION, "demo-grant", until); + assertThatThrownBy(() -> gate.execute(context("validation"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + } + assertThat(ledger.authorizations).isZero(); + } + @Test void continuousBusinessStillPausesOnUnconfirmedCompletion() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + ledger.failComplete = true; + assertThatThrownBy(() -> gate.execute(businessContext("lost"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_COMPLETION_UNCONFIRMED"); + ledger.failComplete = false; clock.advanceMillis(86400000); + assertThatThrownBy(() -> gate.execute(businessContext("after-lost"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("ACCOUNT_PAUSED"); + assertThat(ledger.authorizations).isEqualTo(1); + } + @Test void disabledContinuousBusinessStillRequiresUnexpiredGrant() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", NOW, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false), + ledger, clock, clock::advanceMillis); + assertThatThrownBy(() -> gate.execute(businessContext("disabled"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED"); + assertThat(ledger.authorizations).isZero(); + } + @Test void continuousBusinessRetains1200MillisSpacingAndOneHttpPerPermit() { + var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test", + "demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, true), + ledger, clock, clock::advanceMillis); + gate.execute(businessContext("first-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }); + gate.execute(businessContext("second-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }); + assertThat(Duration.between(NOW, clock.instant()).toMillis()).isGreaterThanOrEqualTo(1200); + assertThatThrownBy(() -> gate.execute(businessContext("one-send"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).hasSize(3).allSatisfy(c -> assertThat(c.actualHttpAttempts()).isEqualTo(1)); + } + @Test void continuousBusinessCannotSendWithExpiredPermitAfterSigning() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThatThrownBy(() -> gate.execute(businessContext("expired-permit"), Operation.OUTBOUND_DETAIL, HASH, + h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE"); + assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero()); + } + @Test void continuousBusinessRateCooldownStillRequiresFreshAuthorityApproval() { + var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null); + assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("continuous-rate"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); h.rateLimited(null); throw rateLimit(); })); + assertThatThrownBy(() -> gate.execute(businessContext("during-cooldown"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("ACCOUNT_PAUSED"); + clock.advanceMillis(300000);ledger.denial = new AuthorizationDenial("ACCOUNT_REQUIRES_REVIEW", false, null); + assertThatThrownBy(() -> gate.execute(businessContext("authority-denied"), Operation.WAREHOUSES, HASH, h -> true)) + .hasMessageContaining("BUDGET_AUTHORIZATION_DENIED"); + assertThat(ledger.completions).hasSize(1); + ledger.denial = null; + assertThat(gate.execute(businessContext("fresh-approved"), Operation.WAREHOUSES, HASH, + h -> { h.startHttp(); return h.metadata(); }).accountPaused()).isFalse(); + assertThat(ledger.completions).hasSize(2); } @Test void failureCompletionPreservesNullableTransportAndSourceCodes() { for (Integer status : new Integer[] {null, 503}) { @@ -72,8 +153,9 @@ class LingxingEgressGateTest { } } @Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() { - var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null); + var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null, false); assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF); + assertThat(defaults.continuousBusinessCollectionEnabled()).isFalse(); 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"); @@ -193,7 +275,7 @@ class LingxingEgressGateTest { } finally { release.countDown(); executor.shutdownNow(); } } @Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() { - assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null, false)).isInstanceOf(IllegalArgumentException.class); assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1"); } @Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() { 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 index 729101ff9..1df6dc50d 100644 --- 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 @@ -43,7 +43,7 @@ class LingxingHttpBudgetAuthorityTest { @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); + LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false), 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, @@ -86,7 +86,7 @@ class LingxingHttpBudgetAuthorityTest { } @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"); + LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null, false), mapper, "synthetic-token"); assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero(); } @Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception { 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 cc0a27698..e2d42ba2c 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 @@ -254,7 +254,7 @@ class LingxingWmsClientTest { 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); + NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null, false); var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) { @Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); } }; 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 index f1ded1371..2415972b9 100644 --- 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 @@ -17,6 +17,8 @@ public interface SelfopCoordinationMapper { 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 t.* FROM ll_selfop_sync_task t JOIN ll_selfop_sync_task r ON r.id=t.business_root_task_id AND r.business_root_task_id=r.id AND r.source_account=t.source_account AND r.execution_purpose='BUSINESS' AND r.validation_root_task_id IS NULL AND r.status<>'DEAD' WHERE t.source_account=#{account} AND t.execution_purpose='BUSINESS' AND t.validation_root_task_id IS NULL AND (r.request_key LIKE 'manual:%' OR (r.requested_by='SELFOP_CONTINUOUS_PLANNER' AND r.request_key LIKE 'continuous:%')) AND t.status IN ('READY','RETRY_WAIT','RUNNING') AND (t.next_retry_at_utc IS NULL OR t.next_retry_at_utc<=#{now}) AND (r.request_key NOT LIKE 'continuous:inventory:%' OR DATE(DATE_ADD(r.planned_at_utc,INTERVAL 8 HOUR))=DATE(DATE_ADD(#{now},INTERVAL 8 HOUR))) ORDER BY CASE WHEN r.task_role='ACCOUNT_DISCOVERY' AND r.query_until_utc>=DATE_SUB(DATE(DATE_ADD(#{now},INTERVAL 8 HOUR)),INTERVAL 8 HOUR) THEN 0 WHEN r.request_key LIKE 'continuous:inventory:%' THEN 1 WHEN t.task_role='WAREHOUSE_SYNC' THEN 2 WHEN t.task_type IN ('RECHECK','REBUILD') THEN 3 ELSE 4 END,t.query_until_utc DESC,t.id LIMIT #{limit}") + List dueContinuous(@Param("now") LocalDateTime now,@Param("limit") int limit,@Param("account") String account); @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')") 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 index 35d33217e..adebbd356 100644 --- 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 @@ -13,6 +13,8 @@ import java.time.LocalDateTime; @ConfigurationProperties(prefix = "baiyue.self-operated-analytics") public class SelfopAnalyticsProperties { private String collectionMode = "OFF"; + /** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */ + private boolean continuousBusinessCollectionEnabled = false; private String environment = "test"; private String exclusiveGrantId; private LocalDateTime exclusiveGrantExpiresAtUtc; 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 index 435f6e8ea..1998b865c 100644 --- 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 @@ -28,14 +28,46 @@ public final class SelfopCollectionPolicy { requireExecution(config,"BUSINESS"); require("production".equals(config.getEnvironment()),"SELFOP_AUTOMATIC_PLANNING_ENVIRONMENT_REJECTED"); } + public static final String CONTINUOUS_PLANNER="SELFOP_CONTINUOUS_PLANNER"; + public static boolean continuousBusiness(SelfopAnalyticsProperties config){ + return config.isContinuousBusinessCollectionEnabled()&&"BUSINESS".equals(config.getCollectionMode())&&businessEnvironment(config); + } + /** Strict persisted server identity; enabling continuous mode never admits old cron slots. */ + public static boolean continuousBusinessRoot(SelfopSyncTask task){ + if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null + ||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false; + String key=task.getRequestKey();if(key==null)return false; + if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&"BACKFILL".equals(task.getTaskType())&&task.getDiscoveryScopeId()!=null + &&task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&&task.getQueryUntilUtc().equals(task.getQueryFromUtc().plusDays(1))){ + var local=task.getQueryFromUtc().plusHours(8); + if(!local.toLocalTime().equals(java.time.LocalTime.MIDNIGHT)||!key.equals("continuous:outbound:"+local.toLocalDate()+":"+task.getScopeHash().substring(0,12)))return false; + try{Map q=read(task.getQueryScopeJson(),Map.class); + return q!=null&&q.get("parentTaskId")==null&&q.get("deliveryNo")==null&&q.get("customerCode")==null + &&local.toString().equals(q.get("startTime"))&&task.getQueryUntilUtc().plusHours(8).toString().equals(q.get("endTime")); + }catch(RuntimeException invalid){return false;} + } + if(!"WAREHOUSE_SYNC".equals(task.getTaskRole())||!"SCHEDULED".equals(task.getTaskType())||task.getScopeId()==null + ||task.getDiscoveryScopeId()!=null||task.getWarehouseCode()==null||task.getPlannedAtUtc()==null + ||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null + ||!key.equals("continuous:inventory:"+task.getWarehouseCode()+":"+task.getPlannedAtUtc()+":"+task.getScopeHash().substring(0,12)))return false; + try{Map q=read(task.getQueryScopeJson(),Map.class); + return q!=null&&"INVENTORY".equals(q.get("dataFamily"))&&"Asia/Shanghai".equals(q.get("businessTimezone")) + &&java.util.Objects.equals(task.getSourceAccount(),q.get("sourceAccount"))&&java.util.Objects.equals(task.getWarehouseCode(),q.get("warehouseCode")); + }catch(RuntimeException invalid){return false;} + } /** TEST may poll only persisted manual roots and their existing routing/refresh descendants. */ public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function lookup) { + return controlledBusinessTask(task,lookup,false); + } + public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function lookup,boolean allowContinuous) { if(task==null)return false; String account=task.getSourceAccount();var seen=new java.util.HashSet(); for(int depth=0;task!=null&&depth<64;depth++) { if(!"BUSINESS".equals(task.getExecutionPurpose())||task.getValidationRootTaskId()!=null - ||!java.util.Objects.equals(account,task.getSourceAccount())||"SCHEDULED".equals(task.getTaskType()))return false; + ||!java.util.Objects.equals(account,task.getSourceAccount()))return false; if(task.getId()!=null&&!seen.add(task.getId()))return false; + if(allowContinuous&&continuousBusinessRoot(task))return true; + if("SCHEDULED".equals(task.getTaskType())&&(!allowContinuous||task.getUpstreamTaskId()==null))return false; String key=task.getRequestKey(); if(key!=null&&key.startsWith("manual:")&&task.getRequestedBy()!=null&&!task.getRequestedBy().isBlank())return true; Long parent=task.getUpstreamTaskId(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java new file mode 100644 index 000000000..3a78b484a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** Immutable union captured under the warehouse scope lock in the candidate's first transaction. */ +public final class SelfopSalesCoverage { + public static final String KEY="salesCoverageV1"; + private SelfopSalesCoverage() { } + public record Interval(LocalDateTime from,LocalDateTime until) { } + public record Evidence(Long runId,Long scopeId,String scopeHash,List intervals) { } + public static Map settings(String snapshot){ + Map result=new LinkedHashMap<>(); + if(snapshot!=null)read(snapshot,Map.class).forEach((k,v)->result.put(String.valueOf(k),v)); + result.remove(KEY);return result; + } + public static void freeze(SelfopSyncRun run,List intervals){ + validate(intervals);var snapshot=settings(run.getScopeSnapshotJson()); + snapshot.put(KEY,new Evidence(run.getId(),run.getScopeId(),run.getScopeHash(),List.copyOf(intervals))); + run.setScopeSnapshotJson(json(snapshot)); + } + public static Evidence evidence(SelfopSyncRun run){ + if(run==null||run.getScopeSnapshotJson()==null)return null; + Object value=read(run.getScopeSnapshotJson(),Map.class).get(KEY);if(value==null)return null; + var evidence=read(json(value),Evidence.class); + require(Objects.equals(run.getId(),evidence.runId())&&Objects.equals(run.getScopeId(),evidence.scopeId()) + &&Objects.equals(run.getScopeHash(),evidence.scopeHash()),"SELFOP_COVERAGE_VERSION_INVALID"); + validate(evidence.intervals());return evidence; + } + public static boolean contains(Evidence evidence,LocalDateTime from,LocalDateTime until){ + return evidence!=null&&from!=null&&until!=null&&from.isBefore(until)&&evidence.intervals().stream() + .anyMatch(i->!i.from().isAfter(from)&&!i.until().isBefore(until)); + } + public static void requireTask(SelfopSyncRun run,SelfopSyncTask task){ + var proof=evidence(run);require(proof!=null&&!proof.intervals().isEmpty(),"SELFOP_COVERAGE_UNPROVEN"); + if(task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null) + require(contains(proof,task.getQueryFromUtc(),task.getQueryUntilUtc()),"SELFOP_TASK_COVERAGE_UNPROVEN"); + } + private static void validate(List intervals){ + require(intervals!=null&&intervals.size()<=500,"SELFOP_COVERAGE_INTERVAL_LIMIT"); + LocalDateTime last=null; + for(var i:intervals){require(i!=null&&i.from()!=null&&i.until()!=null&&i.from().isBefore(i.until()) + &&(last==null||last.isBefore(i.from())),"SELFOP_COVERAGE_INTERVAL_INVALID");last=i.until();} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java index 276066eb3..c2911d5f1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java @@ -11,7 +11,7 @@ import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; public final class SelfopStageEstimate { private SelfopStageEstimate() { } public static final int WINDOW_SECONDS=180, MIN_SECONDS=60, MIN_ADVANCES=3, - BUCKET_SECONDS=10, MAX_POINTS=20, STALE_SECONDS=60, VALID_SECONDS=10, MAX_BYTES=1024; + BUCKET_SECONDS=10, MAX_POINTS=20, STALE_SECONDS=60, VALID_SECONDS=30, MAX_BYTES=1024; public record Estimate(String status,String progressIdentity,Long completed,Long total, Instant sampleFrom,Instant sampleUntil,Long completedDelta,BigDecimal ratePerSecond, Long remainingSeconds,Instant estimatedAt,Instant validUntil,String unavailableReason) { } @@ -23,7 +23,7 @@ public final class SelfopStageEstimate { return hash(values); } - /** A reuse/uncertain checkpoint resets the baseline, never contributes throughput. */ + /** Reused count increases reset the baseline; unchanged bookkeeping is not new work. */ public static void checkpoint(Map p,Instant now,boolean realWork) { try { Long completed=number(p.get("completed")),total=number(p.get("total")); @@ -32,7 +32,8 @@ public final class SelfopStageEstimate { } String id=identity(p);long second=now.getEpochSecond(); var old=map(p.get("etaSamples"));var points=points(old.get("p")); - if(!id.equals(old.get("i"))||!realWork||points.isEmpty() + if(!id.equals(old.get("i"))||points.isEmpty() + ||!realWork&&!completed.equals(points.get(points.size()-1).get(1)) ||second=STALE_SECONDS)points.clear(); @@ -66,7 +67,9 @@ public final class SelfopStageEstimate { if(reason==null&&(!id.equals(sample.get("i"))||points.isEmpty()))reason="SAMPLING"; if(reason==null) { long second=now.getEpochSecond(),lastAdvance=numberOr(sample.get("a"),0); - points.removeIf(point->second-point.get(0)>WINDOW_SECONDS); + // Select an anchor that stays inside the window through the read validity + // period, instead of expiring each time the oldest 10-second bucket rolls out. + points.removeIf(point->second-point.get(0)>WINDOW_SECONDS-VALID_SECONDS); if(points.isEmpty()||second-lastAdvance>=STALE_SECONDS)reason="NO_RECENT_PROGRESS"; else { var first=points.get(0);var last=points.get(points.size()-1); 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 index a95b1f0e9..210966b74 100644 --- 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 @@ -9,6 +9,22 @@ import java.util.List; /** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */ @Mapper public interface OutboundAggregateMapper { + @Select(""" + SELECT MIN(from_at) AS covered_from_utc,MAX(until_at) AS covered_until_utc FROM ( + SELECT from_at,until_at,SUM(CASE WHEN previous_until IS NULL OR from_at>previous_until THEN 1 ELSE 0 END) + OVER (ORDER BY from_at,until_at ROWS UNBOUNDED PRECEDING) AS island FROM ( + SELECT from_at,until_at,MAX(until_at) OVER + (ORDER BY from_at,until_at ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS previous_until FROM ( + SELECT query_from_utc AS from_at,query_until_utc AS until_at FROM ll_selfop_sync_task + WHERE scope_id=#{scopeId} AND scope_hash=#{scopeHash} AND execution_purpose='BUSINESS' + AND validation_root_task_id IS NULL AND task_role='WAREHOUSE_SYNC' AND apply_state='APPLIED' + AND apply_completed_at_utc IS NOT NULL AND query_from_utc IS NOT NULL AND query_until_utc>query_from_utc + AND JSON_UNQUOTE(JSON_EXTRACT(routing_manifest_json,'$.directoryComplete'))='true' + ) accepted + ) ordered_intervals + ) islands GROUP BY island ORDER BY MIN(from_at) LIMIT 501 + """) + List provenCoverageIslands(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash); @Select(""" SELECT COUNT(*) FROM ll_selfop_outbound_order o LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id 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 index a4e0e95ae..3c9802f5f 100644 --- 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 @@ -53,6 +53,7 @@ public class OutboundCandidateTransaction { 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"); + SelfopSalesCoverage.freeze(run,provenIslands(scope)); Checkpoint result=new Checkpoint(runId,"DAILY",null,null,null,0,0,0);save(held,result);return result; } @@ -106,13 +107,20 @@ public class OutboundCandidateTransaction { if(published==null || !Boolean.TRUE.equals(published.getWarehouseDailyReady()) || !"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; + || !Objects.equals(SelfopSalesCoverage.settings(held.run().getScopeSnapshotJson()),SelfopSalesCoverage.settings(published.getScopeSnapshotJson())))return null; + var proof=SelfopSalesCoverage.evidence(published); + if(proof==null||!Objects.equals(proof.intervals(),provenIslands(scope)))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 List provenIslands(SelfopSyncScope scope){ + var rows=aggregates.provenCoverageIslands(scope.getId(),scope.getScopeHash()); + SelfopSupport.require(rows.size()<=500,"SELFOP_COVERAGE_INTERVAL_LIMIT"); + return rows.stream().map(r->new SelfopSalesCoverage.Interval(r.getCoveredFromUtc(),r.getCoveredUntilUtc())).toList(); + } 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) 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 index 80a10caac..af36ee5a3 100644 --- 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 @@ -48,11 +48,14 @@ public class AnalyticsQueryService { if(!Objects.equals(unit,row.quantityUnit()))valid=false; } Instant evidence=earliest(sp,ip); + var coverage=SalesQueryCoverage.intervals(s.sales); 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(), + var covered=SalesQueryCoverage.at(row!=null||evidence==null?coverage: + coverage.stream().filter(i->i.until().toInstant(ZoneOffset.UTC).isAfter(evidence)).toList(),day); + var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,SalesQueryCoverage.fullFrom(covered),SalesQueryCoverage.fullUntil(covered), + covered==null?null:utc(covered.from()),covered==null?null:utc(covered.until()),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(), @@ -113,7 +116,8 @@ public class AnalyticsQueryService { 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 LocalDate historyStart(Snapshot s){return SalesQueryCoverage.intervals(s.sales).stream() + .map(i->utc(i.from()).atZone(BUSINESS_ZONE).toLocalDate()).min(LocalDate::compareTo).orElse(null);} 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();} @@ -162,11 +166,12 @@ public class AnalyticsQueryService { if(!"VALID".equals(result.status()))reasons.add(result.status()); if(r.inventoryReason()!=null)reasons.add(r.inventoryReason()); BigDecimal today=r.todayQuantity(); - if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){ + if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null){ Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant(); + Instant end=s.range.anchorDate().plusDays(1).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; + if(lower.isBefore(end)&&SalesQueryCoverage.intervals(s.sales).stream() + .anyMatch(i->utc(i.from()).isBefore(end)&&utc(i.until()).isAfter(lower)))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, @@ -207,7 +212,8 @@ public class AnalyticsQueryService { 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"; + LocalDateTime latestCoverage=SalesQueryCoverage.intervals(sales).stream().map(i->i.until()).max(LocalDateTime::compareTo).orElse(null); + String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(latestCoverage,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(); @@ -240,7 +246,7 @@ public class AnalyticsQueryService { 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); + s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind,SalesQueryCoverage.intervals(s.sales)); } Instant inventoryFreshUntil(Snapshot s){ return s.inventory()==null||s.inventory().fetchFinishedAtUtc()==null?null: @@ -255,12 +261,12 @@ public class AnalyticsQueryService { ||!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("发布版本缺少完整覆盖证明"); + if(!"COMPLETE".equals(run.coverageStatus())&&SalesQueryCoverage.intervals(run).isEmpty())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 static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&!until.isAfter(s.range.anchorDate()) + &&SalesQueryCoverage.complete(SalesQueryCoverage.intervals(s.sales),from,until);} 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));} 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 index 61ef17203..221c70bf1 100644 --- 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 @@ -30,6 +30,7 @@ public final class AnalyticsReadSql { } private static String cte(AnalyticsSqlRequest request){ String kind=request.kind(); + if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request); if("HOT".equals(kind))return hotSalesCte(); if("SLOW".equals(kind))return rankingCte(); String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; @@ -152,6 +153,35 @@ public final class AnalyticsReadSql { } return sql; } + private static String coveredSummary(AnalyticsSqlRequest p){ + var intervals=new java.util.ArrayList(); + for(int i=0;i(); + for(var day=p.from();day.isBefore(p.until());day=day.plusDays(1))dates.add("SELECT CAST('"+day+"' AS DATE) business_date"); + String start="TIMESTAMPADD(HOUR,-8,CAST(calendar.business_date AS DATETIME))",end="TIMESTAMPADD(DAY,1,"+start+")"; + String full="EXISTS (SELECT 1 FROM coverage v WHERE v.lo<="+start+" AND v.hi>="+end+")"; + String observed="EXISTS (SELECT 1 FROM coverage v WHERE GREATEST(v.lo,b.evidence_at_utc,"+start+")=#{p.from} AND business_date<#{p.until} GROUP BY customer_code,sku_code)," + +" rankable AS (SELECT e.*,CASE WHEN e.profile_unit_valid=1 AND (s.min_unit IS NULL OR (s.min_unit=e.quantity_unit AND s.max_unit=e.quantity_unit AND s.min_qty>=0)) THEN 1 ELSE 0 END unit_valid," + +"CASE WHEN #{p.salesKnown}=1 AND e.profile_unit_valid=1 AND (s.min_unit IS NULL OR (s.min_unit=e.quantity_unit AND s.max_unit=e.quantity_unit AND s.min_qty>=0))" + +" AND (s.observed_sum IS NOT NULL OR EXISTS (SELECT 1 FROM coverage v WHERE GREATEST(v.lo,e.evidence_at_utc,TIMESTAMPADD(HOUR,-8,CAST(#{p.from} AS DATETIME))) coverage) {} 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 index 7c59b955f..02af43091 100644 --- 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 @@ -105,7 +105,7 @@ public class CollectionQueryService { 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&&"production".equals(config.getEnvironment())?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()), + enabled&&("production".equals(config.getEnvironment())||continuousBusiness())?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, @@ -234,7 +234,12 @@ public class CollectionQueryService { 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),attempt,dependency,waiting, - "WAITING_CONFIRMED".equals(dependency)?parent.taskNo():t.taskNo(),stageEstimate(t,latest,attempt,waiting)); + "WAITING_CONFIRMED".equals(dependency)?parent.taskNo():t.taskNo(),stageEstimate(t,latest,attempt,waiting),utc(t.createdAt()),taskElapsed(t)); + } + private Long taskElapsed(Task task) { + Instant start=utc(task.createdAt()); + Instant end=ACTIVE.contains(task.status())?clock.instant():utc(task.updatedAt()); + return start==null||end==null||end.isBefore(start)?null:Duration.between(start,end).getSeconds(); } private String dependencyState(Task task,Task parent) { if(task.upstreamTaskId()==null)return "WAITING_SOURCE".equals(task.status())?"WAITING_UNCONFIRMED":"NONE"; @@ -280,7 +285,7 @@ public class CollectionQueryService { if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK"; String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled; if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS"; - if("test".equals(config.getEnvironment())&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED"; + if("test".equals(config.getEnvironment())&&!continuousBusiness()&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED"; if("test".equals(config.getEnvironment())) { String budgetBlock=businessBudgetBlock(task);if(budgetBlock!=null)return budgetBlock; } @@ -295,13 +300,16 @@ public class CollectionQueryService { JsonNode budget=boundedObject(task.businessRootBudgetJson()); Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp"); Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc"); + boolean continuous=budget!=null&&budget.path("continuous").isBoolean()&&budget.path("continuous").booleanValue(); + if(continuous&&!continuousBusiness())return "CONTINUOUS_COLLECTION_DISABLED"; if(budget==null||maximum==null||maximum<0||maximum>Integer.MAX_VALUE||reserved==null||reserved>Integer.MAX_VALUE - ||confirmed==null||confirmed>reserved||started==null||deadline==null||!deadline.isAfter(started)) + ||confirmed==null||confirmed>reserved||started==null + ||(continuous?budget.hasNonNull("deadlineAtUtc"):deadline==null||!deadline.isAfter(started))) return "BUSINESS_SESSION_BUDGET_MISSING"; JsonNode stopped=budget.get("stopReason"); if(stopped!=null&&!stopped.isNull()) return "BUSINESS_SESSION_STOPPED"; - if(!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE"; + if(!continuous&&!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE"; return null; } private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) { @@ -346,14 +354,21 @@ public class CollectionQueryService { 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"; + ||(!continuousBusiness()&&(config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))))return "EXCLUSIVE_GRANT_NOT_VALID"; if("BUSINESS".equals(config.getCollectionMode()))return null; 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 boolean continuousBusiness() { + return com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.continuousBusiness(config); + } private Instant nextSchedule(String warehouse,String family) { + if(continuousBusiness()&&"OUTBOUND".equals(family)) { + var slot=clock.instant().atZone(BUSINESS_ZONE).toLocalDate().atStartOfDay(BUSINESS_ZONE).plusMinutes(5); + return (slot.toInstant().isAfter(clock.instant())?slot:slot.plusDays(1)).toInstant(); + } 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;} 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 index 8bc333f9a..7d09b63ee 100644 --- 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 @@ -31,7 +31,7 @@ public final class CollectionResponses { List allowedActions, String actionBlockReason, Instant progressUpdatedAt, Map progress, Validation validation, Attempt latestAttempt, String dependencyState, String blockingReason, String progressSourceTaskNo, - SelfopStageEstimate.Estimate stageEstimate) {} + SelfopStageEstimate.Estimate stageEstimate, Instant createdAt, Long elapsedSeconds) {} 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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java index 968fccded..6bfeadc03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java @@ -22,18 +22,18 @@ public class DashboardQueryService { throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询"); var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate(); var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom, - base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD"); + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD",base.coverage()); var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null); var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive()); var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList(); Map stats=report.get(0);var ranked=report.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); - var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days); + var trendPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),c.rangeStartDate(),c.rangeEndDateExclusive(),days,SalesQueryCoverage.intervals(snapshot.sales())); var trendSummary=SalesBucketPolicy.aggregate(trendPoints); - var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days);var fixed=SalesBucketPolicy.aggregate(fixedPoints); + var fixedPoints=DashboardSalesPolicy.points(c,snapshot.sourceKnown(),fixedFrom,fixedUntil,days,SalesQueryCoverage.intervals(snapshot.sales()));var fixed=SalesBucketPolicy.aggregate(fixedPoints); Map hotStats=stats;List hotRanked=ranked; if(!c.rangeStartDate().equals(fixedFrom)||!c.rangeEndDateExclusive().equals(fixedUntil)||!base.complete30()){ var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),fixedFrom, - base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD"); + base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD",base.coverage()); var hotReport=mapper.report(hotRequest,o).stream().map(DashboardQueryService::normalize).toList(); hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java index 721dbff5c..69a62064e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java @@ -8,20 +8,23 @@ import java.util.*; /** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */ public final class DashboardSalesPolicy { private DashboardSalesPolicy(){} - public static List points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List rows){ + public static List points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List rows, + List coverage){ + var intervals=coverage.isEmpty()?SalesQueryCoverage.scalar(c.salesCoveredFrom()==null?null:LocalDateTime.ofInstant(c.salesCoveredFrom(),ZoneOffset.UTC), + c.salesCoveredUntil()==null?null:LocalDateTime.ofInstant(c.salesCoveredUntil(),ZoneOffset.UTC)):coverage; Map days=new HashMap<>();for(var row:rows)days.put(row.businessDate(),row); List result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai"); for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1)){ var actual=days.get(day);Instant start=day.atStartOfDay(zone).toInstant(),end=day.plusDays(1).atStartOfDay(zone).toInstant(); - Instant lower=c.salesCoveredFrom()==null?null:(c.salesCoveredFrom().isAfter(start)?c.salesCoveredFrom():start); - Instant upper=c.salesCoveredUntil()==null?null:(c.salesCoveredUntil().isBefore(end)?c.salesCoveredUntil():end); + var interval=SalesQueryCoverage.at(intervals,day); + Instant lower=interval==null?null:(interval.from().toInstant(ZoneOffset.UTC).isAfter(start)?interval.from().toInstant(ZoneOffset.UTC):start); + Instant upper=interval==null?null:(interval.until().toInstant(ZoneOffset.UTC).isBefore(end)?interval.until().toInstant(ZoneOffset.UTC):end); boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper); boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0; // Global recency describes how far collection has progressed, not whether an // already published historical day has complete coverage. Keep STALE on context. boolean trusted=("VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus()))&&!c.rebuildRequired(); - boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&c.completeDayFrom()!=null&&c.completeDayUntil()!=null - &&!day.isBefore(c.completeDayFrom())&&day.isBefore(c.completeDayUntil())&&intersects&&lower.equals(start)&&upper.equals(end); + boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&SalesQueryCoverage.complete(intervals,day,day.plusDays(1)); BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null; if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO; String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List reasons=new ArrayList<>(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java new file mode 100644 index 000000000..763feb99d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import java.time.*; +import java.util.*; + +/** Reads only immutable evidence already stored in the selected published run. */ +public final class SalesQueryCoverage { + private SalesQueryCoverage() { } + public static List intervals(AnalyticsReadRows.Run selected){ + if(selected==null)return List.of(); + var run=new SelfopSyncRun();run.setId(selected.id());run.setScopeId(selected.scopeId());run.setScopeHash(selected.scopeHash());run.setScopeSnapshotJson(selected.scopeSnapshotJson()); + var evidence=SelfopSalesCoverage.evidence(run); + if(evidence!=null)return evidence.intervals(); + // The existing scalar is itself a frozen continuous proof; never extend it from live tasks. + return scalar(selected.coveredFromUtc(),selected.coveredUntilUtc()); + } + public static List scalar(LocalDateTime from,LocalDateTime until){ + return from!=null&&until!=null&&from.isBefore(until)?List.of(new SelfopSalesCoverage.Interval(from,until)):List.of(); + } + public static SelfopSalesCoverage.Interval at(List intervals,LocalDate date){ + var start=date.atStartOfDay().minusHours(8);var end=start.plusDays(1); + return intervals.stream().filter(i->i.from().isBefore(end)&&i.until().isAfter(start)).findFirst().orElse(null); + } + public static boolean complete(List intervals,LocalDate from,LocalDate until){ + var start=from.atStartOfDay().minusHours(8);var end=until.atStartOfDay().minusHours(8); + return start.isBefore(end)&&intervals.stream().anyMatch(i->!i.from().isAfter(start)&&!i.until().isBefore(end)); + } + public static LocalDate fullFrom(SelfopSalesCoverage.Interval i){ + if(i==null)return null;var local=i.from().plusHours(8);return local.toLocalTime().equals(LocalTime.MIDNIGHT)?local.toLocalDate():local.toLocalDate().plusDays(1); + } + public static LocalDate fullUntil(SelfopSalesCoverage.Interval i){return i==null?null:i.until().plusHours(8).toLocalDate();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java index eea884254..ec352f292 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java @@ -8,6 +8,7 @@ public final class SelfopBusinessBudget { /** Recorded field only; BUSINESS has no HTTP-count ceiling. New sessions write zero. */ public int maxHttp; public int http, confirmedHttp; + public boolean continuous; public LocalDateTime startedAtUtc, deadlineAtUtc; public String stopReason; @@ -20,9 +21,15 @@ public final class SelfopBusinessBudget { return SelfopSupport.json(budget); } + public static String freezeContinuous(LocalDateTime now) { + if(now==null)throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID"); + var budget=new SelfopBusinessBudget();budget.startedAtUtc=now;budget.continuous=true; + return SelfopSupport.json(budget); + } + public void validate() { if (http < 0 || confirmedHttp < 0 || confirmedHttp > http - || startedAtUtc == null || deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc) + || startedAtUtc == null || (continuous ? deadlineAtUtc != null : deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc)) || stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}")) throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null); } @@ -30,7 +37,7 @@ public final class SelfopBusinessBudget { public String limit(LocalDateTime now) { validate(); if (stopReason != null) return stopReason; - if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; + if (!continuous && !now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; return null; } } 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 index 5e27e99c5..ebf61f6ca 100644 --- 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 @@ -18,9 +18,9 @@ public class SelfopEgressService { 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 account=account(context.sourceAccount());var budget=budget(account,now,context.executionPurpose());save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount()); var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId()); - SelfopBusinessBudget business=testBusiness(context.executionPurpose())?business(root):null; + SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null; if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}} verify(task,run,root,account,now); checkAccount(account,budget,now); @@ -42,9 +42,9 @@ public class SelfopEgressService { if("VALIDATION".equals(context.executionPurpose())&&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(!continuousBusiness(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc(); if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc; - if(business!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.deadlineAtUtc; + if(business!=null&&business.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.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);} if(business!=null){business.http++;saveBusiness(root,business);} @@ -53,15 +53,15 @@ public class SelfopEgressService { @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 account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());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); - if(testBusiness(c.executionPurpose())){ + if(hasBusinessSession(root)){ var b=business(root);verifyBusinessTask(task,root); // Authorization preserves the root time/stop fence independently of request accounting. - String limit=b.stopReason!=null?b.stopReason:!now.isBefore(b.deadlineAtUtc)?"BUSINESS_SESSION_DEADLINE":null; + String limit=b.limit(now); if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);} - if(pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc; + if(b.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc; } verify(task,run,root,account,now); if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); @@ -74,7 +74,7 @@ public class SelfopEgressService { @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()); + var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());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"); @@ -85,7 +85,8 @@ public class SelfopEgressService { 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);} - if(testBusiness(c.executionPurpose())){var b=business(root);b.confirmedHttp+=request.actualHttpAttempts();b.validate();saveBusiness(root,b);} + // An already authorized response remains accountable after continuous mode is disabled. + if(hasBusinessSession(root)){var b=business(root);b.confirmedHttp+=request.actualHttpAttempts();b.validate();saveBusiness(root,b);} 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()) @@ -126,16 +127,21 @@ public class SelfopEgressService { 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())||"test".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(grant==null||grant.isBlank()||!Objects.equals(grant,config.getExclusiveGrantId())||(!continuousBusiness(purpose)&&(config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())))||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED"); if(("VALIDATION".equals(purpose)&&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){ + private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now,String purpose){ 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("BUSINESS".equals(config.getCollectionMode()))b.dailyLimit=0; + if(!Objects.equals(day,b.budgetDate)){ + b.budgetDate=day; + // BUSINESS records cumulative consumption, including transferred and uncertain attempts. + // Only the finite VALIDATION daily quota rolls over; cooling/pending always survive. + if(!"BUSINESS".equals(purpose)){b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;} + } + if("BUSINESS".equals(purpose))b.dailyLimit=0; else {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())); @@ -161,20 +167,23 @@ public class SelfopEgressService { } 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(testBusiness(root.getExecutionPurpose())&&"DEAD".equals(root.getStatus()))throw reject("BUSINESS_SESSION_ROOT_STOPPED"); + if(hasBusinessSession(root)&&"DEAD".equals(root.getStatus()))throw reject("BUSINESS_SESSION_ROOT_STOPPED"); 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 boolean continuousBusiness(String purpose){return "BUSINESS".equals(purpose)&&config.isContinuousBusinessCollectionEnabled();} private boolean testBusiness(String purpose){return "test".equals(config.getEnvironment())&&"BUSINESS".equals(purpose);} + private boolean hasBusinessSession(SelfopSyncTask root){return "BUSINESS".equals(root.getExecutionPurpose())&&(testBusiness(root.getExecutionPurpose())||root.getBusinessBudgetJson()!=null||SelfopCollectionPolicy.continuousBusinessRoot(root));} private SelfopBusinessBudget business(SelfopSyncTask root){ if(root==null||!"BUSINESS".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getBusinessRootTaskId())||root.getBusinessBudgetJson()==null)throw reject("BUSINESS_SESSION_ROOT_INVALID"); SelfopBusinessBudget b;try{b=SelfopSupport.read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);}catch(RuntimeException e){throw reject("BUSINESS_SESSION_BUDGET_INVALID");} - if(b==null)throw reject("BUSINESS_SESSION_BUDGET_INVALID");b.validate();return b; + if(b==null)throw reject("BUSINESS_SESSION_BUDGET_INVALID");b.validate(); + if(b.continuous&&!SelfopCollectionPolicy.continuousBusinessRoot(root))throw reject("BUSINESS_SESSION_ROOT_INVALID");return b; } - private void verifyBusinessTask(SelfopSyncTask task,SelfopSyncTask root){if(task==null||!Objects.equals(task.getBusinessRootTaskId(),root.getId())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!"BUSINESS".equals(task.getExecutionPurpose()))throw reject("BUSINESS_SESSION_ROOT_INVALID");} + private void verifyBusinessTask(SelfopSyncTask task,SelfopSyncTask root){if(task==null||!Objects.equals(task.getBusinessRootTaskId(),root.getId())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!"BUSINESS".equals(task.getExecutionPurpose())||business(root).continuous&&!continuousBusiness(root.getExecutionPurpose()))throw reject("BUSINESS_SESSION_ROOT_INVALID");} private void stopBusiness(SelfopSyncTask root,SelfopBusinessBudget b,String reason,LocalDateTime now){ b.stopReason=reason;root.setStatus("DEAD");root.setNextRetryAtUtc(null);saveBusiness(root,b); mapper.terminateBusinessTasks(root.getId(),root.getSourceAccount());mapper.terminateBusinessRuns(root.getId(),root.getSourceAccount(),reason,now); 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 index d6038f892..b176d0e8d 100644 --- 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 @@ -37,6 +37,7 @@ public class SelfopExecutionService { } public void execute(long taskId){ var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose()); + if(SelfopCollectionPolicy.continuousBusiness(config))require(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); 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);} 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 index dbf480ded..90f8ec677 100644 --- 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 @@ -34,9 +34,9 @@ public class SelfopSalesPublisher { 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); + SelfopSalesCoverage.requireTask(run,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); @@ -59,6 +59,7 @@ public class SelfopSalesPublisher { 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"); + SelfopSalesCoverage.requireTask(published,task); 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() 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 index 627f230d9..3503cc907 100644 --- 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 @@ -32,6 +32,15 @@ public class SelfopSchedulePlanner { public void tick(){ if("OFF".equals(config.getCollectionMode()))return; if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); + if(SelfopCollectionPolicy.continuousBusiness(config)){ + planContinuous(utcNow()); + // One dispatch per tick: after a safe yield, re-plan and re-read priority. + for(var task:taskService.due(1)){ + if(!SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true))continue; + try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Durable lease owner continues. */} + } + return; + } List configured; if("VALIDATION".equals(config.getCollectionMode())){taskService.initializeAccount();configured=List.of();} else if("test".equals(config.getEnvironment()))configured=List.of(); @@ -48,6 +57,80 @@ public class SelfopSchedulePlanner { try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */} } } + public void planContinuous(LocalDateTime now){ + require(SelfopCollectionPolicy.continuousBusiness(config),"SELFOP_CONTINUOUS_COLLECTION_DISABLED"); + require("Asia/Shanghai".equals(config.getSourceQueryTimezone()),"SELFOP_SOURCE_TIMEZONE_REQUIRED"); + require(!config.getWarehouses().isEmpty(),"SELFOP_WAREHOUSE_UNCONFIGURED"); + var account=taskService.initializeAccount(); + tx.executeWithoutResult(s->{ + locks.lockDiscovery(account.getId()); + // Read existing scopes only. Pool creation must never move the published coverage anchor. + var configured=scopes.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("data_family","OUTBOUND").in("warehouse_code",config.getWarehouses().keySet())); + require(configured.size()==config.getWarehouses().size()&&configured.stream().allMatch(scope-> + "Asia/Shanghai".equals(scope.getSourceTimezone())&&"Asia/Shanghai".equals(scope.getBusinessTimezone())),"SELFOP_SCOPE_MISSING"); + var local=now.plusHours(8);var today=local.toLocalDate(); + var last=today.minusDays(local.toLocalTime().isBefore(LocalTime.of(0,5))?2:1); + var first=today.minusMonths(2); + String digest=hash(config.getWarehouses()); + var roots=tasks.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("execution_purpose","BUSINESS").eq("task_role","ACCOUNT_DISCOVERY").eq("task_type","BACKFILL") + .eq("discovery_scope_id",account.getId()).isNull("upstream_task_id").isNull("validation_root_task_id") + .ge("query_from_utc",first.atStartOfDay().minusHours(8)).le("query_until_utc",last.plusDays(1).atStartOfDay().minusHours(8))); + var acceptedHashes=new HashSet();acceptedHashes.add(digest); + configured.forEach(scope->acceptedHashes.add(scope.getScopeHash())); + for(var date=last;!date.isBefore(first);date=date.minusDays(1)){ + var from=date.atStartOfDay().minusHours(8);var until=from.plusDays(1); + String key="continuous:outbound:"+date+":"+digest.substring(0,12); + // Examine every root: an old DEAD attempt must not mask a later successful/manual root. + boolean exists=roots.stream().anyMatch(root->key.equals(root.getRequestKey())|| + (from.equals(root.getQueryFromUtc())&&until.equals(root.getQueryUntilUtc())&&acceptedHashes.contains(root.getScopeHash()) + &&Set.of("READY","RUNNING","RETRY_WAIT","SUCCEEDED").contains(root.getStatus())&&wholeDayQuery(root,from))); + if(exists)continue; + var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType("BACKFILL");task.setDiscoveryScopeId(account.getId()); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash(digest);task.setRequestKey(key); + task.setQueryFromUtc(from);task.setQueryUntilUtc(until); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query(from.plusHours(8).toString(),until.plusHours(8).toString(),null,null,null))); + task.setRequestHash(hash(Arrays.asList(task.getQueryScopeJson(),digest)));taskService.create(task); + } + planContinuousInventory(now,today); + }); + } + private void planContinuousInventory(LocalDateTime now,LocalDate today){ + var inventory=scopes.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("data_family","INVENTORY").in("warehouse_code",config.getWarehouses().keySet())); + require(inventory.size()==config.getWarehouses().size(),"SELFOP_SCOPE_MISSING"); + for(var scope:inventory){ + require("INVENTORY".equals(scope.getDataFamily())&&"Asia/Shanghai".equals(scope.getBusinessTimezone()),"SELFOP_INVENTORY_SCOPE_INVALID"); + var midnight=today.atStartOfDay(ZoneId.of("Asia/Shanghai")); + var slot=CronExpression.parse(config.getWarehouses().get(scope.getWarehouseCode()).getInventoryCron()).next(midnight.minusNanos(1)); + if(slot==null||!slot.toLocalDate().equals(today))continue; + var planned=slot.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + if(planned.isAfter(now))continue; + String key="continuous:inventory:"+scope.getWarehouseCode()+":"+planned+":"+scope.getScopeHash().substring(0,12); + var existing=tasks.selectList(new QueryWrapper().eq("source_account",config.getSourceAccount()) + .eq("scope_id",scope.getId()).eq("scope_hash",scope.getScopeHash()).eq("execution_purpose","BUSINESS") + .eq("task_role","WAREHOUSE_SYNC").eq("task_type","SCHEDULED") + .ge("planned_at_utc",midnight.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()) + .lt("planned_at_utc",midnight.plusDays(1).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime())); + if(existing.stream().anyMatch(t->key.equals(t.getRequestKey())|| + (Objects.equals(t.getScopeId(),scope.getId())&&Objects.equals(t.getScopeHash(),scope.getScopeHash()) + &&t.getPlannedAtUtc()!=null&&t.getPlannedAtUtc().plusHours(8).toLocalDate().equals(today) + &&SelfopCollectionPolicy.continuousBusinessRoot(t))))continue; + var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("WAREHOUSE_SYNC");task.setTaskType("SCHEDULED");task.setScopeId(scope.getId());task.setWarehouseCode(scope.getWarehouseCode()); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash(scope.getScopeHash());task.setRequestKey(key); + task.setPlannedAtUtc(planned);task.setNextRetryAtUtc(planned);task.setQueryScopeJson(scope.getScopeJson()); + task.setRequestHash(hash(Arrays.asList(scope.getScopeHash(),planned,task.getQueryScopeJson())));taskService.create(task); + } + } + private static boolean wholeDayQuery(SelfopSyncTask root,LocalDateTime from){ + try{var q=read(root.getQueryScopeJson(),SelfopDiscoveryService.Query.class); + return q!=null&&q.deliveryNo()==null&&q.customerCode()==null&&q.parentTaskId()==null + &&from.plusHours(8).equals(LocalDateTime.parse(q.startTime()))&&from.plusDays(1).plusHours(8).equals(LocalDateTime.parse(q.endTime())); + }catch(RuntimeException invalid){return false;} + } public void plan(SelfopSyncScope preview,LocalDateTime now){ SelfopCollectionPolicy.requireAutomaticPlanning(config); tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone()); 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 index 9206dfb77..8effe516c 100644 --- 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 @@ -110,15 +110,23 @@ public class SelfopTaskService { } if(testBusiness(task)) { require(task.getBusinessRootTaskId()==null&&task.getBusinessBudgetJson()==null,"BUSINESS_SESSION_IDENTITY_INVALID"); - if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { - require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + if(continuousBusiness(config)&&continuousBusinessRoot(task)){ + if("WAREHOUSE_SYNC".equals(task.getTaskRole())){ + var scope=scopes.selectById(task.getScopeId()); + require(scope!=null&&"INVENTORY".equals(scope.getDataFamily())&&Objects.equals(scope.getSourceAccount(),task.getSourceAccount()) + &&Objects.equals(scope.getWarehouseCode(),task.getWarehouseCode())&&Objects.equals(scope.getScopeHash(),task.getScopeHash()) + &&Objects.equals(scope.getScopeJson(),task.getQueryScopeJson()),"SELFOP_INVENTORY_SCOPE_INVALID"); + } + task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now)); + }else if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { + require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); task.setBusinessRootTaskId(task.getId()); task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds())); }else { var parent=businessParent(task); - require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); if("MANUAL_RETRY".equals(task.getTaskType()))require("SUCCEEDED".equals(task.getStatus())&&task.getRequestKey().startsWith("retry:")&&task.getUpstreamTaskId()!=null,"SELFOP_TEST_MANUAL_TASK_REQUIRED"); - require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); businessGuard(parent,!"WAREHOUSE_SYNC".equals(task.getTaskRole()));task.setBusinessRootTaskId(parent.getBusinessRootTaskId()); } } @@ -136,7 +144,7 @@ public class SelfopTaskService { @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) { lockBusinessAccount(taskId);leases.verify(lease); - if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));} + if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));} 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()); @@ -190,6 +198,7 @@ public class SelfopTaskService { 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")); + if(task.getBusinessRootTaskId()!=null&&continuousBusinessRoot(tasks.selectById(task.getBusinessRootTaskId())))retryable=false; task.setStatus(retryable&&failures().eq("id",taskId).set("routing_manifest_json",null)); } - task.setNextRetryAtUtc(now.plusSeconds(Math.min(3600,5L< due(int limit){return locks.due(utcNow(),limit,config.getCollectionMode());} + public List due(int limit){return continuousBusiness(config)?locks.dueContinuous(utcNow(),limit,config.getSourceAccount()):locks.due(utcNow(),limit,config.getCollectionMode());} @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) { lockBusinessAccount(taskId);leases.verify(lease);guardBeforeTaskLock(taskId,false);var task=locks.lockTask(taskId);var run=locks.lockRun(runId); @@ -243,7 +254,7 @@ public class SelfopTaskService { } @Transactional(noRollbackFor=BusinessSessionStoppedException.class) public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) { - lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run); + lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}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); } @@ -252,7 +263,7 @@ public class SelfopTaskService { @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public CollectionCheckpoint checkpoint(SelfopSyncTask observedTask,SelfopSyncRun observedRun,SelfopLeaseService.Lease lease) { long taskId=observedTask.getId();lockBusinessAccount(taskId);leases.verify(lease); - if("test".equals(config.getEnvironment())) { + if("test".equals(config.getEnvironment())||continuousBusiness(config)) { var preview=tasks.selectById(taskId);require(preview!=null,"SELFOP_TASK_MISSING");businessGuard(preview,false,false); } var task=locks.lockTask(taskId);var run=locks.lockRun(observedRun.getId());checkRunLease(task,run,lease); @@ -266,21 +277,21 @@ public class SelfopTaskService { BusinessSessionStoppedException(String reason){super("本次测试采集会话已结束或预算无效,请核对记录后新建有界任务");this.reason=reason;} public String reason(){return reason;} } - private boolean testBusiness(SelfopSyncTask task){return task!=null&&"test".equals(config.getEnvironment())&&"BUSINESS".equals(task.getExecutionPurpose());} + private boolean testBusiness(SelfopSyncTask task){return task!=null&&("test".equals(config.getEnvironment())||continuousBusiness(config))&&"BUSINESS".equals(task.getExecutionPurpose());} private SelfopSyncTask businessParent(SelfopSyncTask task){ Long id=task.getUpstreamTaskId(); if(id==null&&task.getQueryScopeJson()!=null){var query=read(task.getQueryScopeJson(),Map.class);if(query.get("parentTaskId") instanceof Number n)id=n.longValue();} return id==null?null:tasks.selectById(id); } private void lockBusinessAccount(long id){ - if(!"test".equals(config.getEnvironment()))return; + if(!"test".equals(config.getEnvironment())&&!continuousBusiness(config))return; var task=tasks.selectById(id);if(!testBusiness(task))return; require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED"); var account=accounts.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount())); require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId()); } private void guardBeforeTaskLock(long id,boolean checkDeadline){ - if(!"test".equals(config.getEnvironment()))return; + if(!"test".equals(config.getEnvironment())&&!continuousBusiness(config))return; var task=tasks.selectById(id);businessGuard(task,checkDeadline); } private boolean localBusinessWork(SelfopSyncTask task){ @@ -301,22 +312,22 @@ public class SelfopTaskService { private void businessGuard(SelfopSyncTask task,boolean checkDeadline){businessGuard(task,checkDeadline,true);} private void businessGuard(SelfopSyncTask task,boolean checkDeadline,boolean terminate){ if(!testBusiness(task))return; - require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); + require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED"); var ancestor=task; - while(!ancestor.getRequestKey().startsWith("manual:"))ancestor=businessParent(ancestor); + while(!ancestor.getRequestKey().startsWith("manual:")&&!continuousBusinessRoot(ancestor))ancestor=businessParent(ancestor); require(Objects.equals(ancestor.getId(),task.getBusinessRootTaskId()),"BUSINESS_SESSION_ROOT_INVALID"); // Serialize with egress in account -> root -> task order, before taking any task row lock. var account=accounts.selectOne(new QueryWrapper().eq("source_account",task.getSourceAccount())); require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId()); var root=locks.lockTask(task.getBusinessRootTaskId()); require(root!=null&&Objects.equals(root.getId(),root.getBusinessRootTaskId())&&Objects.equals(root.getSourceAccount(),task.getSourceAccount()) - &&root.getRequestKey()!=null&&root.getRequestKey().startsWith("manual:")&&controlledBusinessTask(root,tasks::selectById),"BUSINESS_SESSION_ROOT_INVALID"); + &&root.getRequestKey()!=null&&(root.getRequestKey().startsWith("manual:")||continuousBusinessRoot(root))&&controlledBusinessTask(root,tasks::selectById,continuousBusiness(config)),"BUSINESS_SESSION_ROOT_INVALID"); require(root.getBusinessBudgetJson()!=null,"BUSINESS_SESSION_BUDGET_INVALID"); var budget=read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);budget.validate(); - // HTTP exhaustion is enforced at egress, allowing the final permitted response to finish local work. + require(!budget.continuous||(continuousBusiness(config)&&continuousBusinessRoot(root)),"SELFOP_CONTINUOUS_COLLECTION_DISABLED"); String reason=budget.stopReason; - if(reason==null&&checkDeadline&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE"; + if(reason==null&&checkDeadline&&!budget.continuous&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE"; if(reason==null&&"DEAD".equals(root.getStatus()))reason="BUSINESS_SESSION_STOPPED"; if(reason==null)return; if(!terminate)throw new BusinessSessionStoppedException(reason); diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index b7d66926e..98981b1d6 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -48,6 +48,7 @@ lastleg: baiyue: self-operated-analytics: collection-mode: ${SELFOP_COLLECTION_MODE:OFF} + continuous-business-collection-enabled: ${SELFOP_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false} environment: ${DEPLOY_ENV:test} daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0} business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000} 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 index a585486bd..37711e475 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -732,6 +732,8 @@ components: Task: type: object properties: + createdAt: {type: string, format: date-time, nullable: true, description: 当前任务创建时间,重试和阶段切换不重置} + elapsedSeconds: {type: string, nullable: true, description: 自创建起的累计秒数,含排队和重试;活动任务截止本次读取,终态任务截止任务最后更新时间;不是单次执行耗时} taskNo: {type: string, nullable: true} taskType: {type: string, nullable: true} dataFamily: {type: string, nullable: true} @@ -807,7 +809,7 @@ components: executionEnabled: {type: boolean, nullable: true} publicationEnabled: {type: boolean, nullable: true} disabledReason: {type: string, nullable: true} - nextScheduledAt: {type: string, format: date-time, nullable: true} + nextScheduledAt: {type: string, format: date-time, nullable: true, description: 自动采集下一计划时刻;显式连续BUSINESS模式下销量为下个北京时间00点05分,库存沿仓库计划;执行被禁用时为空,不代表当前任务预计完成时间} observedAt: {type: string, format: date-time, nullable: true} initialFrom: {type: string, format: date-time, nullable: true} initialUntil: {type: string, format: date-time, nullable: true} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java index bf712d1fd..f568f0ef5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java @@ -24,7 +24,7 @@ class SelfopStageEstimateTest { @Test void sufficientRealAdvancesEstimateCurrentStageAndIncludeIdleTime(){ var p=ready();var e=estimate(p,60);assertEquals("AVAILABLE",e.status());assertEquals(60L,e.completedDelta()); assertEquals(0,new BigDecimal("1").compareTo(e.ratePerSecond()));assertEquals(939L,e.remainingSeconds()); - assertEquals(start,e.sampleFrom());assertEquals(start.plusSeconds(60),e.sampleUntil());assertEquals(start.plusSeconds(70),e.validUntil()); + assertEquals(start,e.sampleFrom());assertEquals(start.plusSeconds(60),e.sampleUntil());assertEquals(start.plusSeconds(90),e.validUntil()); var idle=estimate(p,90);assertEquals("AVAILABLE",idle.status());assertEquals(new BigDecimal("0.666666"),idle.ratePerSecond()); assertEquals(1409L,idle.remainingSeconds());assertEquals("NO_RECENT_PROGRESS",estimate(p,120).unavailableReason()); } @@ -33,6 +33,26 @@ class SelfopStageEstimateTest { assertEquals("SAMPLING",estimate(p,59).unavailableReason());assertEquals("SAMPLING",estimate(p,60).unavailableReason()); commit(p,60,4,true);assertEquals("AVAILABLE",estimate(p,60).status()); } + @Test void unchangedBookkeepingDoesNotRestartSamplingOrInventAnAdvance(){ + var p=ready();commit(p,65,61,false); + var e=estimate(p,65); + assertEquals("AVAILABLE",e.status());assertEquals(start,e.sampleFrom()); + assertEquals(60L,e.completedDelta());assertEquals(new BigDecimal("0.923076"),e.ratePerSecond()); + commit(p,119,61,false); + assertEquals("NO_RECENT_PROGRESS",estimate(p,120).unavailableReason()); + } + @Test void rollingWindowKeepsAValidityMarginAcrossTenSecondPollingBoundaries(){ + var p=progress();p.put("total",100000L); + for(int second=0;second<=600;second++){ + commit(p,second,second,true); + if(second>=80&&second%10==0){ + var e=estimate(p,second); + assertEquals("AVAILABLE",e.status(),"second="+second); + assertEquals(start.plusSeconds(second+30),e.validUntil()); + assertFalse(e.sampleFrom().isBefore(e.validUntil().minusSeconds(SelfopStageEstimate.WINDOW_SECONDS))); + } + } + } @Test void reuseBeforeInsideAndAfterWindowRestartsBaselineInsteadOfInflatingRate(){ var p=progress();commit(p,0,500,false);commit(p,20,501,true);commit(p,40,502,true);commit(p,60,503,true); assertEquals(3L,estimate(p,60).completedDelta()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java index 538c72b02..6fd4d095d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java @@ -160,7 +160,7 @@ class OutboundCandidateBuildMySqlAcceptanceTest { LocalDateTime now=utcNow().minusSeconds(1);String scopeJson=json(Map.of("quantityUnit","PCS","businessTimezone","Asia/Shanghai","sourceTimezone","Asia/Shanghai","fixtureOrigin","SYNTHETIC_BUILD_ACCEPTANCE"));String hash=hash(scopeJson); transactions.executeWithoutResult(s->{ insert("ll_selfop_sync_scope",fields("id",scopeId,"created_at",now,"updated_at",now,"source_account",account,"warehouse_code",warehouse,"data_family","OUTBOUND","scope_json",scopeJson,"scope_hash",hash,"business_timezone","Asia/Shanghai","source_timezone","Asia/Shanghai","schedule_cron","0 0 0 1 1 *","stale_after_seconds",93600,"review_request_budget",1,"review_time_budget_seconds",1,"review_target_days",7,"fact_revision",1,"published_fact_revision",0,"fence_token",1,"lease_owner",owner,"lease_until_utc",utcNow().plusHours(1))); - for(long id:List.of(originTask,taskId))insert("ll_selfop_sync_task",fields("id",id,"created_at",now,"updated_at",now,"task_no","SYNTH-BUILD-"+id,"execution_purpose","BUSINESS","source_account",account,"warehouse_code",warehouse,"task_role","WAREHOUSE_SYNC","scope_id",scopeId,"request_key","build-"+id,"request_hash",hash,"task_type","REBUILD","query_scope_json",scopeJson,"scope_hash",hash,"status",id==taskId?"RUNNING":"SUCCEEDED","apply_state","APPLIED","attempt_count",1,"query_from_utc",utc(from),"query_until_utc",utc(until),"routing_manifest_json","{\"directoryComplete\":true}")); + for(long id:List.of(originTask,taskId))insert("ll_selfop_sync_task",fields("id",id,"created_at",now,"updated_at",now,"task_no","SYNTH-BUILD-"+id,"execution_purpose","BUSINESS","source_account",account,"warehouse_code",warehouse,"task_role","WAREHOUSE_SYNC","scope_id",scopeId,"request_key","build-"+id,"request_hash",hash,"task_type","REBUILD","query_scope_json",scopeJson,"scope_hash",hash,"status",id==taskId?"RUNNING":"SUCCEEDED","apply_state","APPLIED","apply_completed_at_utc",now,"attempt_count",1,"query_from_utc",utc(from),"query_until_utc",utc(until),"routing_manifest_json","{\"directoryComplete\":true}")); for(long id:List.of(originRun,runId))insert("ll_selfop_sync_run",fields("id",id,"created_at",now,"updated_at",now,"task_id",id==runId?taskId:originTask,"execution_purpose","BUSINESS","scope_id",scopeId,"source_account",account,"warehouse_code",warehouse,"run_role","WAREHOUSE_SYNC","attempt_no",1,"scope_hash",hash,"scope_snapshot_json",scopeJson,"fence_token",1,"status","FACTS_APPLIED","last_phase","BUILD","coverage_status","UNKNOWN")); }); for(int offset=0;offset"UNKNOWN".equals(p.dataStatus())&&p.observedQuantity()==null)); + var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture());assertFalse(cap.getValue().complete30()); + } + private static Run frozenRun(LocalDateTime from,LocalDateTime until, + com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval... intervals){ + Run original=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"); + var frozen=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun(); + frozen.setId(11L);frozen.setScopeId(1L);frozen.setScopeHash(SH);frozen.setScopeSnapshotJson(original.scopeSnapshotJson()); + com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.freeze(frozen,List.of(intervals)); + var json=new ObjectMapper().findAndRegisterModules();var fields=json.convertValue(original,Map.class); + fields.put("scopeSnapshotJson",frozen.getScopeSnapshotJson());fields.put("coveredFromUtc",from);fields.put("coveredUntilUtc",until); + return json.convertValue(fields,Run.class); + } + @Test void selectedRunFrozenIslandsDriveDailyAndWindowEligibility(){ + Run original=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"); + var frozen=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncRun();frozen.setId(11L);frozen.setScopeId(1L);frozen.setScopeHash(SH);frozen.setScopeSnapshotJson(original.scopeSnapshotJson()); + com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.freeze(frozen,List.of( + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(5)),time(D.minusDays(4))), + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D),time(D.plusDays(1))))); + var json=new ObjectMapper().findAndRegisterModules();var fields=json.convertValue(original,Map.class);fields.put("scopeSnapshotJson",frozen.getScopeSnapshotJson()); + fields.put("coveredFromUtc",time(D.minusDays(5)));fields.put("coveredUntilUtc",time(D.minusDays(4))); + when(mapper.publishedRun(11)).thenReturn(json.convertValue(fields,Run.class));service=at(NOW.plusSeconds(86400)); + var response=service.dailySales(chart("CUSTOM",D.minusDays(5),D,"C","SKU")); + assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN","UNKNOWN","COMPLETE"),response.points().stream().map(AnalyticsResponses.Point::dataStatus).toList()); + assertNull(response.summary().outboundQuantity());assertEquals(BigDecimal.ZERO,response.points().get(5).outboundQuantity()); + var snapshot=service.context(chart("CUSTOM",D,D,null,null),true,false,false,null); + assertTrue(service.request(snapshot,"SUMMARY").completeWindow());assertFalse(service.request(snapshot,"SUMMARY").complete30()); + } 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"); 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 index a6d46fcb5..f7b696925 100644 --- 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 @@ -16,7 +16,7 @@ class AnalyticsReadMapperTest { 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); + 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,java.util.List.of()); } private static BoundSql bound(Configuration c,String method,Map parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);} private static void immutableOnly(String sql){ 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 index 4bdb3b348..be6937aa7 100644 --- 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 @@ -17,6 +17,16 @@ import static org.junit.jupiter.api.Assertions.*; * 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 { + @Test void frozenDisconnectedDaysPreserveZeroDayAndUnknownGapInSummarySql() throws Exception { + profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); + daily(11,"W","C","SKU",D,"12"); + var coverage=List.of(new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(utc(D.minusDays(5),0),utc(D.minusDays(4),0)), + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(utc(D,0),utc(D.plusDays(1),0))); + var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(5),D.plusDays(1),D.plusDays(1),D.minusDays(30),D.minusDays(5),D.minusDays(4), + utc(D.minusDays(5),0),utc(D.minusDays(4),0),true,true,false,false,false,0,20,"SUMMARY",coverage); + var result=page(p).get(0);assertEquals(2,result.completeDayCount());assertEquals(0,result.partialDayCount());assertEquals(4,result.unknownDayCount()); + assertEquals(0,new BigDecimal("12").compareTo(result.observedQuantity())); + } private Connection schema; private SqlSession session; private AnalyticsReadMapper mapper; @@ -68,7 +78,7 @@ public class AnalyticsSqlSemanticTest { daily(11,"W","C","SOLD",from.minusDays(1),"999");daily(11,"W","C","SOLD",until,"999"); daily(33,"W","C","SOLD",from,"999");daily(11,"OTHER","C","SOLD",from,"999"); var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,from,until,D,D.minusDays(30),from,D, - utc(from,0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY"); + utc(from,0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY",java.util.List.of()); assertEquals(3,mapper.count(p));var rows=page(p);assertEquals(3,rows.size()); var sold=rows.get(0);assertEquals("SOLD",sold.skuCode());assertDecimal("5",sold.observedQuantity()); assertEquals(2L,sold.skuOrderCount());assertEquals(365,sold.completeDayCount()); @@ -147,15 +157,15 @@ public class AnalyticsSqlSemanticTest { new LocalDateTime[]{utc(D.plusDays(1),0),utc(D.plusDays(2),0)}, new LocalDateTime[]{null,utc(D,8)})){ var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null, - span[0],span[1],true,false,false,false,true,0,1,"SUMMARY"); + span[0],span[1],true,false,false,false,true,0,1,"SUMMARY",java.util.List.of()); var first=page(p).get(0);assertEquals("A-UNKNOWN",first.skuCode());assertNull(first.observedQuantity()); var last=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null, - span[0],span[1],true,false,false,false,true,1,1,"SUMMARY"); + span[0],span[1],true,false,false,false,true,1,1,"SUMMARY",java.util.List.of()); assertNull(page(last).get(0).observedQuantity()); } daily(11,"W","C","Z-EVIDENCE",D,"99"); var unavailable=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null, - utc(D,0),utc(D,8),false,false,false,false,true,0,1,"SUMMARY"); + utc(D,0),utc(D,8),false,false,false,false,true,0,1,"SUMMARY",java.util.List.of()); var first=page(unavailable).get(0);assertEquals("A-UNKNOWN",first.skuCode());assertNull(first.observedQuantity()); } @Test void evidenceAfterCoveredTimeOnSameDayDoesNotBecomeObservedZero() throws Exception { @@ -307,7 +317,7 @@ public class AnalyticsSqlSemanticTest { daily(11,"W","A","SAME",D.minusDays(1),"2");daily(11,"W","A","SAME",D,"5"); daily(11,"W","B","SAME",D.minusDays(2),"9"); var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(365),D.plusDays(1),D,D.minusDays(30), - D.minusDays(365),D,utc(D.minusDays(365),0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY"); + D.minusDays(365),D,utc(D.minusDays(365),0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY",java.util.List.of()); assertEquals(4,mapper.count(p));var rows=page(p);assertEquals(4,rows.size()); for(var row:rows)if("SAME".equals(row.skuCode())){ assertDecimal("A".equals(row.customerCode())?"8":"9",row.observedQuantity()); @@ -432,11 +442,11 @@ public class AnalyticsSqlSemanticTest { profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");stock("C","SKU",0,"10");daily(11,"W","C","SKU",D.minusDays(1),"3"); var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null,null,null); var stale=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D, - utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY"); + utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY",java.util.List.of()); var row=inventoryRows(stale,options).get(0);assertNull(row.physicalQuantity());assertNull(row.availableQuantity());assertDecimal("3",row.ownerOutbound());assertNull(row.coverageDays()); - var noSales=new AnalyticsSqlRequest("demo","W",null,null,0,22,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,true,0,20,"INVENTORY"); + var noSales=new AnalyticsSqlRequest("demo","W",null,null,0,22,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,true,0,20,"INVENTORY",java.util.List.of()); row=inventoryRows(noSales,options).get(0);assertDecimal("10",row.availableQuantity());assertNull(row.ownerOutbound());assertNull(row.observedOutbound());assertNull(row.outboundQuantity30d());assertNull(row.lastOutbound()); - var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY"); + var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY",java.util.List.of()); assertTrue(inventoryRows(empty,options).isEmpty());assertEquals(0L,((Number)literalQuery("inventoryCount",empty,options).get(0)).longValue()); } @Test void tenDayInventoryUsesItsOwnCompleteCoverageAndUnroundedThresholdWithoutLeakingScope()throws Exception { @@ -449,7 +459,7 @@ public class AnalyticsSqlSemanticTest { daily(99,"W","C","TEN",D.minusDays(1),"700");daily(11,"OTHER","C","TEN",D.minusDays(1),"600"); daily(11,"W","OTHER","TEN",D.minusDays(1),"500"); var p=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(10),D, - utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY"); + utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY",java.util.List.of()); var o=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1)); var rows=inventoryRows(p,o);var ten=rows.stream().filter(r->r.skuCode().equals("TEN")).findFirst().orElseThrow(); assertDecimal("2",ten.outboundQuantity30d());assertDecimal("30.04",ten.coverageDays());assertEquals(1,ten.aboveThreshold()); @@ -464,7 +474,7 @@ public class AnalyticsSqlSemanticTest { assertEquals(1L,((Number)literalQuery("inventoryCount",p,filtered).get(0)).longValue()); assertEquals(List.of("TEN"),inventoryRows(p,filtered).stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); var partial=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(9),D, - utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY"); + utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY",java.util.List.of()); assertTrue(inventoryRows(partial,o).stream().allMatch(r->r.outboundQuantity30d()==null&&r.coverageDays()==null)); } @SuppressWarnings("unchecked") private List inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ @@ -536,7 +546,7 @@ public class AnalyticsSqlSemanticTest { } private AnalyticsSqlRequest request(String kind,LocalDate from,LocalDate until,String owner,String sku,LocalDateTime coveredUntil,long offset,int size) { return new AnalyticsSqlRequest("demo","W",owner,sku,11,22,from,until,D,D.minusDays(30),D.minusDays(30),D, - utc(D.minusDays(30),0),coveredUntil,true,true,true,true,true,offset,size,kind); + utc(D.minusDays(30),0),coveredUntil,true,true,true,true,true,offset,size,kind,java.util.List.of()); } private static LocalDateTime utc(LocalDate d,int hour){return d.atTime(hour,0).atZone(ZoneId.of("Asia/Shanghai")).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();} private void profile(long run,String owner,String sku,String evidence,LocalDateTime at,String unit)throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java index 9480f74a8..6ffad22d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java @@ -29,6 +29,15 @@ class CollectionQueryServiceTest { return new Task(id,"LXCOL-"+id,"primary",account?null:"A","OUTBOUND",account?"ACCOUNT_DISCOVERY":"WAREHOUSE_SYNC","BACKFILL","BUSINESS", "hash",status,"NOT_STARTED",1,now.minusDays(1),now,null,upstream,null,null,progress,now,null,null,false,created,created); } + @Test void taskElapsedIncludesWaitingAndRetriesAndStopsAtTerminalTaskUpdate() { + for(String status:List.of("RUNNING","RETRY_WAIT","WAITING_SOURCE","SUCCEEDED")) { + var task=activity(7,status,null,false,now.minusMinutes(10),null); + when(mapper.task("primary","A",task.taskNo())).thenReturn(task); + var result=service.taskDetail(task.taskNo(),q(null,null,null)).task(); + assertEquals(now.minusMinutes(10).toInstant(ZoneOffset.UTC),result.createdAt()); + assertEquals("SUCCEEDED".equals(status)?0L:600L,result.elapsedSeconds()); + } + } @Test void currentActivityWinsOverNewerHistoryAndIsBoundedToEight() { var candidates=new ArrayList(); candidates.add(activity(99,"SUCCEEDED",null,false,now,null)); @@ -132,6 +141,37 @@ class CollectionQueryServiceTest { config.setEnvironment("development"); for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("BUSINESS_ENVIRONMENT_REJECTED",row.disabledReason());} } + @Test void continuousTestBusinessShowsDailyPlanDespiteExpiredGrantButKeepsSafetyGates() { + enableBusiness("test");config.setContinuousBusinessCollectionEnabled(true); + config.setExclusiveGrantExpiresAtUtc(now.minusDays(1)); + var rows=service.overview(q(null,null,null)).warehouses(); + var outbound=rows.stream().filter(r->"OUTBOUND".equals(r.dataFamily())).findFirst().orElseThrow(); + assertTrue(outbound.executionEnabled()); + assertEquals(Instant.parse("2026-10-12T16:05:00Z"),outbound.nextScheduledAt()); + var atSlot=new CollectionQueryService(mapper,config,new ObjectMapper(),Clock.fixed(Instant.parse("2026-10-12T16:05:00Z"),ZoneOffset.UTC)); + assertEquals(Instant.parse("2026-10-13T16:05:00Z"),atSlot.overview(q(null,null,null)).warehouses().stream() + .filter(r->"OUTBOUND".equals(r.dataFamily())).findFirst().orElseThrow().nextScheduledAt()); + config.setPriorEgressClosed(false); + for(var row:service.overview(q(null,null,null)).warehouses()) { + assertFalse(row.executionEnabled());assertNull(row.nextScheduledAt()); + assertEquals("EXCLUSIVE_GRANT_NOT_VALID",row.disabledReason()); + } + config.setPriorEgressClosed(true);config.setContinuousBusinessCollectionEnabled(false); + assertFalse(service.overview(q(null,null,null)).warehouses().get(0).executionEnabled()); + } + @Test void continuousBudgetRequiresEnabledModeAndNoDeadlineWhileBoundedBudgetsStillExpire() { + enableBusiness("test");config.setContinuousBusinessCollectionEnabled(true); + String continuous="{\"continuous\":true,\"startedAtUtc\":\""+now.minusDays(2)+"\",\"maxHttp\":0,\"http\":10,\"confirmedHttp\":10,\"deadlineAtUtc\":null,\"stopReason\":null}"; + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous)); + assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions()); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous.replace("\"deadlineAtUtc\":null","\"deadlineAtUtc\":\""+now.plusDays(1)+"\""))); + assertEquals("BUSINESS_SESSION_BUDGET_MISSING",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.minusMinutes(1).toString(),null,10))); + assertEquals("BUSINESS_SESSION_DEADLINE",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + config.setContinuousBusinessCollectionEnabled(false); + when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous)); + assertEquals("CONTINUOUS_COLLECTION_DISABLED",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason()); + } Scope scope() { return new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600, now.minusMonths(2),now,null,null,0,null,null,null,7,null,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java index 9a15b4252..585c2628d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java @@ -9,43 +9,52 @@ import java.util.*; import static org.junit.jupiter.api.Assertions.*; class DashboardPolicyTest { + @Test void frozenIslandsProveLatestEmptyDayWithoutFillingInterveningDates(){ + var first=DAY.minusDays(6).atStartOfDay().minusHours(8);var last=DAY.minusDays(1).atStartOfDay().minusHours(8); + var coverage=List.of(new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(first,first.plusDays(1)), + new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(last,last.plusDays(1))); + var c=context(first.toInstant(ZoneOffset.UTC),first.plusDays(1).toInstant(ZoneOffset.UTC),true); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(6),DAY,List.of(),coverage); + assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN","UNKNOWN","COMPLETE"),points.stream().map(p->p.dataStatus()).toList()); + assertEquals(BigDecimal.ZERO,points.get(5).outboundQuantity());assertNull(SalesBucketPolicy.aggregate(points).outboundQuantity()); + } static final LocalDate DAY=LocalDate.of(2026,10,13); static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z"); static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,DAY.minusDays(14),DAY.plusDays(1),null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());} @Test void warehouseObservedTotalPreservesOutsideWatermarkAndUnknownGap(){ var c=context(null,null,true);var rows=List.of(new DashboardReadMapper.Day(DAY.minusDays(3),new BigDecimal("2"),0L),new DashboardReadMapper.Day(DAY.minusDays(1),new BigDecimal("5"),0L),new DashboardReadMapper.Day(DAY,BigDecimal.ONE,0L)); - var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows);var summary=SalesBucketPolicy.aggregate(points); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows,java.util.List.of());var summary=SalesBucketPolicy.aggregate(points); assertEquals(new BigDecimal("8"),summary.observedQuantity());assertNull(summary.outboundQuantity());assertNull(points.get(1).observedQuantity());assertEquals("PARTIAL",points.get(3).dataStatus()); } @Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){ var c=context(DAY.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),NOW,true); - var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of()); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of(),java.util.List.of()); assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(BigDecimal.ZERO,points.get(0).outboundQuantity()); assertEquals("PARTIAL",points.get(1).dataStatus());assertEquals(BigDecimal.ZERO,points.get(1).observedQuantity());assertNull(points.get(1).outboundQuantity()); - var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of()));assertNull(unknown.observedQuantity()); + var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of(),java.util.List.of()));assertNull(unknown.observedQuantity()); } @Test void staleCollectionDoesNotDowngradePublishedHistoricalDayOrInventOtherFourteenDays(){ LocalDate published=DAY.minusDays(3);var c=publishedDayContext("STALE",false); for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))) { var rows=amount.signum()==0?List.of():List.of(new DashboardReadMapper.Day(published,amount,0L)); - var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(14),DAY.plusDays(1),rows); + var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(14),DAY.plusDays(1),rows,java.util.List.of()); var point=points.stream().filter(p->p.date().equals(published)).findFirst().orElseThrow(); assertEquals("COMPLETE",point.dataStatus());assertEquals(amount,point.outboundQuantity()); assertFalse(point.reasonCodes().contains("SALES_DAY_INCOMPLETE"));assertFalse(point.reasonCodes().contains("SALES_STALE")); var summary=SalesBucketPolicy.aggregate(points); assertEquals(1,summary.completeDays());assertEquals(14,summary.unknownDays());assertEquals(0,summary.partialDays()); assertEquals("PARTIAL",summary.dataStatus());assertNull(summary.outboundQuantity());assertEquals(amount,summary.observedQuantity()); - var single=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(c,true,published,published.plusDays(1),rows)); + var single=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(c,true,published,published.plusDays(1),rows,java.util.List.of())); assertEquals("COMPLETE",single.dataStatus());assertEquals(amount,single.outboundQuantity()); assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE")); } } @Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){ LocalDate day=DAY.minusDays(3);var rows=List.of(new DashboardReadMapper.Day(day,new BigDecimal("2251"),0L)); - assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows).get(0).dataStatus()); - var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L))).get(0); + assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows,java.util.List.of()).get(0).dataStatus()); + var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L)),java.util.List.of()).get(0); assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());assertTrue(invalid.reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID")); - var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows).get(0); + var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows,java.util.List.of()).get(0); assertEquals("PARTIAL",rebuilding.dataStatus());assertNull(rebuilding.outboundQuantity()); } private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java index d42ba3586..23a3c6784 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java @@ -20,7 +20,7 @@ class DashboardQueryServiceTest { var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366); var snapshot=new AnalyticsQueryService.Snapshot(q,salesRun(true),null,true,true,day.minusDays(30),day,range,context); when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot); - var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD"); + var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD",java.util.List.of()); when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p); when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L))); var reportRow=new HashMap(Map.of("total_skus",200L,"hot_eligible",200L,"hot_unknown",0L)); @@ -54,7 +54,7 @@ class DashboardQueryServiceTest { var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context); when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot); when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day, - day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD")); + day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD",java.util.List.of())); var result=service.dashboard(q); if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());} else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index f152bf4c0..dff6165bf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -44,12 +44,12 @@ class DashboardSqlSemanticTest { daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS"); var b=request(false,true); var p=new AnalyticsSqlRequest(b.account(),b.warehouse(),null,null,b.salesRun(),b.inventoryRun(),D.minusDays(2),D.plusDays(1),D,D.minusDays(2), - b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD"); + b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD",java.util.List.of()); var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options())); assertEquals(1,rows.size());assertEquals("RECENT",((Map)rows.get(0)).get("SKU_CODE")); assertEquals(0,new BigDecimal("10").compareTo((BigDecimal)((Map)rows.get(0)).get("MAIN_VALUE"))); var top=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),D,p.from(), - p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT"); + p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT",java.util.List.of()); var topRows=query(AnalyticsReadMapper.class,"page",Map.of("p",top)); assertEquals(1,topRows.size());var item=(AnalyticsReadRows.Aggregate)topRows.get(0); assertEquals("RECENT",item.skuCode());assertEquals(0,new BigDecimal("10").compareTo(item.windowQuantity()));assertNull(item.sales30Quantity()); @@ -162,7 +162,7 @@ class DashboardSqlSemanticTest { } daily("C","SKU-199",D,"999","BOX"); assertEquals(200,count(stats(),"hot_eligible"));var top=ranks().stream().filter(r->r.rankCode().equals("HOT")).sorted(Comparator.comparing(DashboardReadMapper.Ranked::mainValue).reversed()).toList();assertEquals(10,top.size()); - var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT"); + var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT",java.util.List.of()); List page=query(AnalyticsReadMapper.class,"page",Map.of("p",hot));assertEquals(10,page.size()); for(int i=0;i<10;i++){var item=(AnalyticsReadRows.Aggregate)page.get(i);assertEquals(top.get(i).skuCode(),item.skuCode());assertEquals(0,top.get(i).mainValue().compareTo(item.windowQuantity()));} } @@ -199,13 +199,13 @@ class DashboardSqlSemanticTest { .stream().map(DashboardReadMapper.Day.class::cast).toList(); assertEquals(originalDailyOracle(D.minusDays(3),D.plusDays(1)),rows,"Published summaries must retain the former SKU aggregation's valid subtotal and invalid count"); var covered=DashboardPolicyTest.context(D.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DashboardPolicyTest.NOW,true); - var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows); + var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows,java.util.List.of()); assertEquals("PARTIAL",points.get(0).dataStatus());assertEquals(0,new BigDecimal("7").compareTo(points.get(0).observedQuantity()));assertNull(points.get(0).outboundQuantity()); assertTrue(points.get(0).reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID")); assertEquals("UNKNOWN",points.get(1).dataStatus());assertNull(points.get(1).observedQuantity()); assertEquals("COMPLETE",points.get(2).dataStatus());assertEquals(BigDecimal.ZERO,points.get(2).outboundQuantity()); assertEquals("PARTIAL",points.get(3).dataStatus());assertEquals(0,new BigDecimal("4").compareTo(points.get(3).observedQuantity()));assertNull(points.get(3).outboundQuantity()); - var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows); + var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows,java.util.List.of()); assertTrue(unproved.stream().allMatch(point->point.outboundQuantity()==null)); assertNull(unproved.get(2).observedQuantity());assertEquals("UNKNOWN",unproved.get(2).dataStatus()); } @@ -238,9 +238,9 @@ class DashboardSqlSemanticTest { private void stock(String sku,String physical,String available)throws Exception{stock("C",sku,physical,available);} private void stock(String owner,String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){ p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}} - private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");} - private AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY");} - private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind());} + private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD",java.util.List.of());} + private AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY",java.util.List.of());} + private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind(),java.util.List.of());} private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);} @SuppressWarnings("unchecked") private List> report(){ return ((List>)(List)query(DashboardReadMapper.class,"report",Map.of("p",request(),"o",options()))).stream().map(row->{ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java index 5ebd87176..576a0ca4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java @@ -6,6 +6,15 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class SelfopBusinessBudgetTest { + @Test void continuousSessionHasNoDeadlineButKeepsStopAndAccountingGuards(){ + var start=LocalDateTime.of(2026,10,13,0,0); + var budget=SelfopSupport.read(SelfopBusinessBudget.freezeContinuous(start),SelfopBusinessBudget.class); + assertTrue(budget.continuous);assertNull(budget.deadlineAtUtc); + budget.http=100000;budget.confirmedHttp=99999; + assertNull(budget.limit(start.plusYears(1))); + budget.stopReason="EGRESS_RESULT_UNKNOWN";assertEquals(budget.stopReason,budget.limit(start)); + budget.deadlineAtUtc=start.plusDays(1);assertThrows(EgressRejectedException.class,budget::validate); + } private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0); @Test void serializedConsumptionAndDeadlineSurviveReload() { 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 index 912feb18e..c8ccc609e 100644 --- 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 @@ -44,6 +44,46 @@ class SelfopEgressServiceTest { assertThat(budget().reservedHttp).isEqualTo(1); } @Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();} + @Test void continuousBusinessGrantExpiryDoesNotClipShortPendingOrPermitAndSwitchKeepsCounters(){ + testBusiness(5,300);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); + cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setEgressLeaseSeconds(120); + var c=reserve(Operation.WAREHOUSES,1,null,null); + assertThat(budget().pending.expiresAtUtc).isEqualTo(now().plusSeconds(120)); + var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThat(restarted.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)).expiresAt()).isEqualTo(clock.instant().plusSeconds(30)); + restarted.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); + assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + } + @Test void continuousFlagNeverBypassesValidationWindowGrantIdentityOrHandoff(){ + cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED"); + testBusiness(5,300); + for(String grant:Arrays.asList(null,"","other")){cfg.setExclusiveGrantId(grant);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");} + cfg.setExclusiveGrantId("grant");cfg.setPriorEgressClosed(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper); + } + @Test void continuousGrantDoesNotRenewFiniteManualRootOrRelaxFence(){ + testBusiness(5,10);cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); + account.setFenceToken(8L); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_FENCE_LOST"); + account.setFenceToken(7L);var c=reserve(Operation.WAREHOUSES,1,null,null); + assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc); + clock.advance(10); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_SESSION_DEADLINE"); + assertThat(budget().pending.consumed).isFalse(); + } + @Test void continuousBusinessAcrossMidnightRetainsUnknownPendingAndReviewAcrossRestart(){ + business();cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null); + 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(86400); + var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero();assertThat(budget().pending.abandoned).isTrue(); + assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(cfg.getCooldownSeconds()+1); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + } @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); @@ -150,6 +190,99 @@ class SelfopEgressServiceTest { private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");} private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),seconds));} private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);} + private void continuousBusinessRoot(){ + business();cfg.setEnvironment("test");cfg.setContinuousBusinessCollectionEnabled(true); + cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setBusinessMinRequestIntervalMs(1200); + task.setValidationRootTaskId(null);task.setValidationBudgetJson(null);task.setUpstreamTaskId(null); + task.setTaskType("BACKFILL");task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash("a".repeat(64)); + var from=LocalDate.of(2026,10,11).atStartOfDay();task.setQueryFromUtc(from.minusHours(8));task.setQueryUntilUtc(from.plusDays(1).minusHours(8)); + task.setQueryScopeJson(SelfopSupport.json(Map.of("startTime",from.toString(),"endTime",from.plusDays(1).toString()))); + task.setRequestKey("continuous:outbound:2026-10-11:"+task.getScopeHash().substring(0,12)); + task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now())); + assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue(); + } + @Test void continuousRootWithNoDeadlineReservesAuthorizesAcrossDayWithoutResettingAccumulated8820(){ + continuousBusinessRoot();cfg.setEgressLeaseSeconds(999);cfg.setDailyRequestBudget(1); + var inherited=new SelfopApiBudget();inherited.budgetDate=now().plusHours(8).toLocalDate();inherited.importedGrantId="grant"; + inherited.reservedHttp=8820;inherited.confirmedHttp=8820;inherited.minIntervalMs=1200;account.setApiBudgetJson(SelfopSupport.json(inherited)); + var b=businessBudget();b.http=8820;b.confirmedHttp=8820;task.setBusinessBudgetJson(SelfopSupport.json(b));var started=b.startedAtUtc; + var c=reserve(Operation.OUTBOUND_QUERY,100,null,null); + assertThat(budget().pending.expiresAtUtc).isEqualTo(now().plusSeconds(120)); + var restarted=new SelfopEgressService(mapper,cfg,clock); + var permit=restarted.authorize(new AuthorizationRequest(c,Operation.OUTBOUND_QUERY,PROVIDER)); + assertThat(permit.expiresAt()).isEqualTo(clock.instant().plusSeconds(30));assertThat(permit.allowedAttempts()).isEqualTo(1); + var completion=new CompletionRequest(c,Operation.OUTBOUND_QUERY,PROVIDER,1,null,200,200,false,null); + restarted.complete(completion);restarted.complete(completion); + clock.advance(86400);account.setLeaseUntilUtc(now().plusMinutes(5)); + var next=new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_DETAIL,0,"A","C/DO",INTERNAL);success(next,Operation.OUTBOUND_DETAIL); + assertThat(budget().reservedHttp).isEqualTo(8822);assertThat(budget().confirmedHttp).isEqualTo(8822);assertThat(budget().dailyLimit).isZero(); + assertThat(businessBudget().http).isEqualTo(8822);assertThat(businessBudget().confirmedHttp).isEqualTo(8822); + assertThat(businessBudget().continuous).isTrue();assertThat(businessBudget().deadlineAtUtc).isNull();assertThat(businessBudget().startedAtUtc).isEqualTo(started); + verify(mapper,never()).terminateBusinessTasks(any(),any()); + } + @Test void continuousRootSwitchOffRejectsNewReserveAndPreparedAuthorizeButAccountsLateCompletion(){ + continuousBusinessRoot();cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2)); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + assertThat(budget().reservedHttp).isZero(); + cfg.setContinuousBusinessCollectionEnabled(true);var c=reserve(Operation.WAREHOUSES,1,null,null); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + assertThat(budget().pending.consumed).isFalse(); + cfg.setContinuousBusinessCollectionEnabled(true);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + cfg.setContinuousBusinessCollectionEnabled(false);cfg.setCollectionMode("OFF");clock.advance(86400); + var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null); + var restarted=new SelfopEgressService(mapper,cfg,clock);restarted.complete(completion);restarted.complete(completion); + assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1); + assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isEqualTo(1); + } + @Test void continuousRootInProductionAlsoRequiresSwitchAndValidPersistedPlannerIdentity(){ + continuousBusinessRoot();cfg.setEnvironment("production");cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2)); + cfg.setContinuousBusinessCollectionEnabled(false); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + cfg.setContinuousBusinessCollectionEnabled(true);task.setRequestedBy("manual-operator"); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID"); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES); + assertThat(businessBudget().confirmedHttp).isEqualTo(1); + task.setStatus("DEAD");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_STOPPED"); + } + @Test void continuousRootAuthorizeStillChecksFenceStopAndPendingExpiry(){ + continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null); + account.setFenceToken(8L); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_FENCE_LOST"); + account.setFenceToken(7L);clock.advance(121); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); + assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().confirmedHttp).isZero(); + var b=businessBudget();b.stopReason="BUSINESS_MANUAL_STOP";task.setBusinessBudgetJson(SelfopSupport.json(b)); + assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_MANUAL_STOP"); + assertThat(task.getStatus()).isEqualTo("DEAD"); + } + @Test void continuousRootStillPreservesIntervalSingleFlightAndUnknownOutcomeAfterRestart(){ + continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_REQUEST_INFLIGHT"); + assertThat(budget().reservedHttp).isEqualTo(1); + service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null)); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT"); + clock.advance(1);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT"); + clock.advance(1);var pending=reserve(Operation.OUTBOUND_QUERY,1,null,null); + service.authorize(new AuthorizationRequest(pending,Operation.OUTBOUND_QUERY,PROVIDER));clock.advance(86400); + account.setLeaseUntilUtc(now().plusMinutes(5));var restarted=new SelfopEgressService(mapper,cfg,clock); + assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(1);assertThat(budget().pending.abandoned).isTrue(); + assertThat(account.getApiPauseReason()).isEqualTo("REQUEST_OUTCOME_UNKNOWN"); + } + @Test void continuousRootActual429AndAuthorizationFailureKeepAccountProtections(){ + continuousBusinessRoot();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)); + assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT"); + clock.advance(cfg.getCooldownSeconds()+1);var next=reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO");service.authorize(new AuthorizationRequest(next,Operation.OUTBOUND_DETAIL,PROVIDER)); + service.complete(new CompletionRequest(next,Operation.OUTBOUND_DETAIL,PROVIDER,1,"LINGXING_WMS_AUTHORIZATION_REJECTED",401,null,true,null)); + clock.advance(86400);account.setLeaseUntilUtc(now().plusMinutes(5)); + assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW"); + assertThat(account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");assertThat(budget().rateLimitFailures).isEqualTo(1); + assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2); + } @Test void businessSurpassesPriorDailyAndRootHttpCapsAcrossRestartWithoutRenewingDeadline(){ testBusiness(5000,300);cfg.setDailyRequestBudget(12000);cfg.setTransferredDailyHttpCount(12000); var rootBudget=businessBudget();rootBudget.maxHttp=5000;rootBudget.http=5000;rootBudget.confirmedHttp=5000;task.setBusinessBudgetJson(SelfopSupport.json(rootBudget)); 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 index 63bb5f6eb..9364a17bc 100644 --- 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 @@ -33,10 +33,40 @@ class SelfopSalesPublisherTest { run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);run.setWarehouseDailyReady(true); 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)); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(run.getCoveredFromUtc(),run.getCoveredUntilUtc()))); 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"); } + @Test void disconnectedTargetPublishesOnlyWithFrozenProofAndKeepsGapUnknown(){ + enableSyntheticPublication();var first=LocalDateTime.of(2026,10,6,16,0);var latest=LocalDateTime.of(2026,10,11,16,0); + run.setCoveredFromUtc(first);run.setCoveredUntilUtc(first.plusDays(1)); + task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1)); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1)))); + assertEquals("SELFOP_TASK_COVERAGE_UNPROVEN",assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)).getMessage()); + noWrites(); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1)),new SelfopSalesCoverage.Interval(latest,latest.plusDays(1)))); + publisher.publish(1,10,"worker",3);assertEquals("SUCCEEDED",task.getStatus()); + assertEquals(first.plusDays(1),scope.getDiscoveryUntilUtc()); + assertFalse(SelfopSalesCoverage.contains(SelfopSalesCoverage.evidence(run),first,latest.plusDays(1))); + } + @Test void latestDayCanPublishBeforeOldPlannedAnchorIsFilled(){ + enableSyntheticPublication();var latest=LocalDateTime.of(2026,10,11,16,0); + scope.setInitialFromUtc(latest.minusMonths(2));run.setCoveredFromUtc(null);run.setCoveredUntilUtc(null);run.setCoverageStatus("UNKNOWN"); + task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1)); + SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(latest,latest.plusDays(1)))); + publisher.publish(1,10,"worker",3);assertEquals("SUCCEEDED",task.getStatus()); + assertEquals(latest.minusMonths(2),scope.getInitialFromUtc());assertNull(run.getCoveredUntilUtc()); + } + @Test void reuseCannotCompleteLatestDayUsingOnlyOldIsland(){ + enableSyntheticPublication();var first=LocalDateTime.of(2026,10,6,16,0);var latest=LocalDateTime.of(2026,10,11,16,0); + var published=new SelfopSyncRun();published.setId(9L);published.setScopeId(1L);published.setScopeHash(scope.getScopeHash()); + published.setExecutionPurpose("BUSINESS");published.setStatus("PUBLISHED");published.setWarehouseDailyReady(true);published.setCalculatedFactRevision(7L); + published.setScopeSnapshotJson("{}");SelfopSalesCoverage.freeze(published,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1)))); + when(runs.selectById(9L)).thenReturn(published);task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1)); + assertEquals("SELFOP_TASK_COVERAGE_UNPROVEN",assertThrows(IllegalStateException.class,()->publisher.reuse(1,10,9,"worker",3)).getMessage()); + assertEquals("RUNNING",task.getStatus());verify(tasks,never()).update(any(),any()); + } private void noWrites() { verifyNoInteractions(scopes,runs,tasks); assertEquals(9L,scope.getPublishedRunId()); } @Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() { 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 index 2d9e99d34..91b1f9089 100644 --- 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 @@ -16,6 +16,95 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class SelfopSchedulePlannerTest { + @Test void continuousInventoryWaitsForTodaysSlotAndNeverBackfillsPreviousDays(){ + var state=continuousState(); + var manual7=dayRoot("RUNNING",7,"manual:7");var manual6=dayRoot("READY",6,"manual:6"); + state.add(manual7);state.add(manual6); + planner.planContinuous(LocalDateTime.of(2026,10,12,23,59)); + assertThat(state).noneMatch(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())); + planner.planContinuous(LocalDateTime.of(2026,10,13,0,0)); + var inventory=state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())).toList(); + assertThat(inventory).hasSize(1);assertThat(inventory.get(0).getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026,10,13,0,0)); + assertThat(inventory.get(0).getNextRetryAtUtc()).isEqualTo(inventory.get(0).getPlannedAtUtc()); + assertThat(SelfopCollectionPolicy.continuousBusinessRoot(inventory.get(0))).isTrue(); + planner.planContinuous(LocalDateTime.of(2026,10,13,12,0)); + planner.planContinuous(LocalDateTime.of(2026,10,13,16,0)); + assertThat(state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole()))).hasSize(1); + planner.planContinuous(LocalDateTime.of(2026,10,14,0,0)); + assertThat(state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole()))).hasSize(2); + assertThat(manual7.getStatus()).isEqualTo("RUNNING");assertThat(manual6.getStatus()).isEqualTo("READY"); + verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(tasks,never()).updateById(any(SelfopSyncTask.class)); + } + @Test void continuousInventoryRegistersOneOverdueSlotPerConfiguredWarehouse(){ + var state=continuousState();config.getWarehouses().put("WH-B",new SelfopAnalyticsProperties.Warehouse()); + var outboundA=scope(10L,"WH-A","OUTBOUND");outboundA.setBusinessTimezone("Asia/Shanghai");outboundA.setSourceTimezone("Asia/Shanghai"); + var outboundB=scope(20L,"WH-B","OUTBOUND");outboundB.setBusinessTimezone("Asia/Shanghai");outboundB.setSourceTimezone("Asia/Shanghai"); + doAnswer(call->{ + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper query=call.getArgument(0);query.getSqlSegment(); + return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(inventoryScope(11L,"WH-A"),inventoryScope(21L,"WH-B")):List.of(outboundA,outboundB);}).when(scopes).selectList(any()); + planner.planContinuous(LocalDateTime.of(2026,10,13,12,0));planner.planContinuous(LocalDateTime.of(2026,10,13,12,1)); + var inventory=state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())).toList(); + assertThat(inventory).hasSize(2);assertThat(inventory).extracting(SelfopSyncTask::getWarehouseCode).containsExactlyInAnyOrder("WH-A","WH-B"); + assertThat(inventory).allMatch(t->t.getPlannedAtUtc().equals(LocalDateTime.of(2026,10,13,0,0))&&SelfopCollectionPolicy.continuousBusinessRoot(t)); + } + @Test void continuousPoolPersistsCompleteDaysNewestFirstAndNeverChangesCoverageAnchor(){ + var state=continuousState(); + planner.planContinuous(LocalDateTime.of(2026,10,13,4,0)); + assertThat(state).hasSize(62); + assertThat(state.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,11,16,0)); + assertThat(state.get(60).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,16,0)); + assertThat(state).allMatch(SelfopCollectionPolicy::continuousBusinessRoot); + verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(taskService,never()).initialize(); + planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(62); + planner.planContinuous(LocalDateTime.of(2026,10,13,16,4));assertThat(state).hasSize(62); + planner.planContinuous(LocalDateTime.of(2026,10,13,16,5));assertThat(state).hasSize(63); + assertThat(state.get(62).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,12,16,0)); + assertThat(state).anyMatch(t->LocalDateTime.of(2026,8,12,16,0).equals(t.getQueryFromUtc())); + } + @Test void deadOldManualRootDoesNotMaskSuccessfulOrActiveDayAndContinuousFailureIsNotRecreated(){ + var state=continuousState(); + state.add(dayRoot("DEAD",12,"manual:old"));state.add(dayRoot("SUCCEEDED",12,"manual:success")); + state.add(dayRoot("RUNNING",11,"manual:running")); + state.add(dayRoot("DEAD",10,"manual:failed-only")); + planner.planContinuous(LocalDateTime.of(2026,10,13,4,0)); + assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).hasSize(60); + assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).noneMatch(t->LocalDateTime.of(2026,10,11,16,0).equals(t.getQueryFromUtc())); + var root=state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot).findFirst().orElseThrow();root.setStatus("DEAD"); + int count=state.size();planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(count); + } + @Test void continuousTickReReadsOneTaskAfterEverySafeDispatch(){ + continuousState();var subject=spy(planner);doNothing().when(subject).planContinuous(any()); + var old=dayRoot("RETRY_WAIT",10,"manual:old");old.setId(10L);old.setRequestedBy("operator"); + var fresh=dayRoot("READY",12,"manual:new");fresh.setId(12L);fresh.setRequestedBy("operator"); + when(taskService.due(1)).thenReturn(List.of(old),List.of(fresh)); + subject.tick();subject.tick(); + var order=inOrder(taskService,execution);order.verify(taskService).due(1);order.verify(execution).execute(10L); + order.verify(taskService).due(1);order.verify(execution).execute(12L);verify(taskService,never()).due(10); + } + private List continuousState(){ + directTransactions();config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true); + config.setSourceAccount("synthetic");config.setSourceQueryTimezone("Asia/Shanghai"); + config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse()); + var scope=scope(10L,"WH-A","OUTBOUND");scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai"); + scope.setScopeHash("a".repeat(64));scope.setInitialFromUtc(LocalDateTime.of(2026,10,7,16,0)); + var stock=inventoryScope(11L,"WH-A"); + when(scopes.selectList(any())).thenAnswer(call->{ + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper query=call.getArgument(0);query.getSqlSegment(); + return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(stock):List.of(scope);}); + var account=new SelfopOutboundDiscoveryScope();account.setId(30L);when(taskService.initializeAccount()).thenReturn(account); + var state=new ArrayList();when(tasks.selectList(any())).thenAnswer(call->new ArrayList<>(state)); + when(taskService.create(any())).thenAnswer(call->{SelfopSyncTask task=call.getArgument(0);state.add(task);return task;});return state; + } + private SelfopSyncTask dayRoot(String status,int day,String key){ + var root=controlled(100L+day,"ACCOUNT_DISCOVERY","BACKFILL",key);root.setDiscoveryScopeId(30L);root.setScopeHash("a".repeat(64));root.setStatus(status); + var from=LocalDateTime.of(2026,10,day,0,0);root.setQueryFromUtc(from.minusHours(8));root.setQueryUntilUtc(from.plusDays(1).minusHours(8)); + root.setQueryScopeJson(com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.json(new SelfopDiscoveryService.Query(from.toString(),from.plusDays(1).toString(),null,null,null)));return root; + } + private SelfopSyncScope inventoryScope(long id,String warehouse){ + var scope=scope(id,warehouse,"INVENTORY");scope.setSourceAccount("synthetic");scope.setScopeHash("b".repeat(64)); + scope.setBusinessTimezone("Asia/Shanghai");scope.setSourceTimezone("Asia/Shanghai"); + scope.setScopeJson(com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.json(Map.of("sourceAccount","synthetic","warehouseCode",warehouse,"dataFamily","INVENTORY","businessTimezone","Asia/Shanghai")));return scope; + } private final SelfopAnalyticsProperties config = new SelfopAnalyticsProperties(); private final SelfopTaskService taskService = mock(SelfopTaskService.class); private final SelfopExecutionService execution = mock(SelfopExecutionService.class); 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 index c9895aede..760009638 100644 --- 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 @@ -14,6 +14,37 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class SelfopTaskServiceTest { + @Test void automaticInventoryRootRequiresFrozenInventoryScopeAndDoesNotAdmitOldScheduledTasks(){ + config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true); + var scope=scope("INVENTORY");scope.setSourceAccount("synthetic-account");scope.setWarehouseCode("WH-A");scope.setScopeHash("b".repeat(64)); + scope.setScopeJson(json(Map.of("sourceAccount","synthetic-account","warehouseCode","WH-A","dataFamily","INVENTORY","businessTimezone","Asia/Shanghai"))); + when(scopes.selectById(10L)).thenReturn(scope); + var task=task("READY");task.setTaskType("SCHEDULED");task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER); + task.setScopeHash(scope.getScopeHash());task.setQueryScopeJson(scope.getScopeJson());task.setPlannedAtUtc(LocalDateTime.of(2026,10,14,0,0)); + task.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12)); + service.create(task);assertThat(task.getBusinessRootTaskId()).isEqualTo(task.getId()); + assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true)).isTrue(); + assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,false)).isFalse(); + task.setRequestKey("slot:old");assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true)).isFalse(); + task.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12)); + task.setQueryScopeJson(json(Map.of("dataFamily","OUTBOUND")));assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isFalse(); + } + @Test void continuousRootCreationFreezesUnboundedSessionAndUsesAccountQueue(){ + config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true);config.setSourceAccount("synthetic-account"); + var account=new SelfopOutboundDiscoveryScope();account.setId(20L);account.setNextSequence(1L);account.setNextObservationSequence(1L); + when(locks.lockDiscovery(20L)).thenReturn(account); + var task=new SelfopSyncTask();task.setSourceAccount("synthetic-account");task.setExecutionPurpose("BUSINESS"); + task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType("BACKFILL");task.setDiscoveryScopeId(20L); + task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash("a".repeat(64)); + task.setRequestKey("continuous:outbound:2026-10-12:"+"a".repeat(12));task.setRequestHash("digest"); + task.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));task.setQueryUntilUtc(LocalDateTime.of(2026,10,12,16,0)); + task.setQueryScopeJson(json(new SelfopDiscoveryService.Query("2026-10-12T00:00","2026-10-13T00:00",null,null,null))); + service.create(task);assertThat(task.getBusinessRootTaskId()).isEqualTo(task.getId()); + var budget=read(task.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); + assertThat(budget.continuous).isTrue();assertThat(budget.deadlineAtUtc).isNull();assertThat(budget.limit(utcNow().plusYears(1))).isNull(); + service.due(1);verify(locks).dueContinuous(any(),eq(1),eq("synthetic-account"));verify(locks,never()).due(any(),anyInt(),anyString()); + task.setScopeHash("invalid-hash");assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isFalse(); + } private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class); private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class); @@ -414,6 +445,19 @@ class SelfopTaskServiceTest { assertThatThrownBy(()->service.waitForBudget(1L,20L,utcNow().plusDays(1),lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class); verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class)); } + @Test void continuousFailureStopsInsteadOfBecomingAnotherScheduledRetry(){ + var root=boundedRoot(0,utcNow().plusMinutes(3));config.setContinuousBusinessCollectionEnabled(true); + root.setTaskRole("ACCOUNT_DISCOVERY");root.setTaskType("BACKFILL");root.setScopeId(null);root.setWarehouseCode(null);root.setDiscoveryScopeId(99L); + root.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);root.setScopeHash("a".repeat(64)); + root.setRequestKey("continuous:outbound:2026-10-12:"+"a".repeat(12));root.setStatus("RUNNING"); + root.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));root.setQueryUntilUtc(LocalDateTime.of(2026,10,12,16,0)); + root.setQueryScopeJson(json(new SelfopDiscoveryService.Query("2026-10-12T00:00","2026-10-13T00:00",null,null,null))); + root.setBusinessBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.freezeContinuous(utcNow())); + var run=run("FETCHING");when(locks.lockRun(20L)).thenReturn(run); + service.fail(1L,20L,"LINGXING_WMS_RATE_LIMITED",true,new SelfopLeaseService.Lease(99L,"owner",7L,true)); + assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull(); + assertThat(run.getStatus()).isEqualTo("FAILED");assertThat(run.getErrorCode()).isEqualTo("LINGXING_WMS_RATE_LIMITED"); + } @Test void egressStopReasonSurvivesExecutorFailureHandling() { var root=boundedRoot(7,utcNow().plusMinutes(3)); var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);