feat(self-operated): persist continuous collection and frozen coverage

This commit is contained in:
2026-10-13 21:13:31 +08:00
parent c2bb769676
commit 242a76221e
45 changed files with 1021 additions and 118 deletions
@@ -12,7 +12,8 @@ import java.time.Instant;
public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode, public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode,
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId, @DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval, Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
@DefaultValue("5m") Duration cooldownFallback) { @DefaultValue("5m") Duration cooldownFallback,
@DefaultValue("false") boolean continuousBusinessCollectionEnabled) {
public LingxingEgressProperties { public LingxingEgressProperties {
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode; collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
environmentLabel = environmentLabel == null ? "test" : environmentLabel; environmentLabel = environmentLabel == null ? "test" : environmentLabel;
@@ -62,7 +62,7 @@ public final class LingxingEgressGate {
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed() || !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt()) || !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30)) || 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"); recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
} }
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state. // 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() if (context == null || context.executionPurpose() != properties.collectionMode()
|| !context.wellFormed() || !context.wellFormed()
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID"); || !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId()) if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank()
|| properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil()) || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| (!continuousBusiness() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())))
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED"); || 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) { private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*"); return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
} }
@@ -44,13 +44,14 @@ baiyue:
request-timeout: 15s request-timeout: 15s
egress: egress:
collection-mode: ${LINGXING_COLLECTION_MODE:OFF} collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
continuous-business-collection-enabled: ${LINGXING_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false}
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test} environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary} source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:} external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303} budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
min-start-interval: 5s min-start-interval: 5s
cooldown-fallback: 5m 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. # Missing proof/authority fails closed even when the old enabled switch is true.
baidu-ocr: baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false} enabled: ${BAIDU_OCR_ENABLED:false}
@@ -7,12 +7,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class LingxingEgressPropertiesTest { class LingxingEgressPropertiesTest {
@Test void continuousBusinessFlagBindsOnlyWhenExplicitlyEnabled() {
String prefix = "baiyue.integration.lingxing-wms.egress";
var input = new java.util.HashMap<String, String>();
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) { 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() { @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()) assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval())
.isEqualTo(Duration.ofMillis(millis)); .isEqualTo(Duration.ofMillis(millis));
} }
@@ -54,7 +54,88 @@ class LingxingEgressGateTest {
private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); } 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 EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); }
static LingxingEgressProperties props(CollectionMode mode, String environment) { 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() { @Test void failureCompletionPreservesNullableTransportAndSourceCodes() {
for (Integer status : new Integer[] {null, 503}) { for (Integer status : new Integer[] {null, 503}) {
@@ -72,8 +153,9 @@ class LingxingEgressGateTest {
} }
} }
@Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() { @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.collectionMode()).isEqualTo(CollectionMode.OFF);
assertThat(defaults.continuousBusinessCollectionEnabled()).isFalse();
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5)); assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis); 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"); 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(); } } finally { release.countDown(); executor.shutdownNow(); }
} }
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() { @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"); assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
} }
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() { @Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {
@@ -43,7 +43,7 @@ class LingxingHttpBudgetAuthorityTest {
@AfterEach void stop() { server.stop(0); } @AfterEach void stop() { server.stop(0); }
private LingxingHttpBudgetAuthority authority(String serviceToken) { private LingxingHttpBudgetAuthority authority(String serviceToken) {
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", 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 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, 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() { @Test void missingServerConfiguredAuthorityPreventsHttp() {
var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant", 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(); assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero();
} }
@Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception { @Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception {
@@ -254,7 +254,7 @@ class LingxingWmsClientTest {
var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), 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)); "synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2));
var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null, 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) { var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) {
@Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); } @Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); }
}; };
@@ -17,6 +17,8 @@ public interface SelfopCoordinationMapper {
SelfopSyncRun lockRun(long id); 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}") @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<SelfopSyncTask> due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); List<SelfopSyncTask> 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<SelfopSyncTask> 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}))") @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); 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')") @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')")
@@ -13,6 +13,8 @@ import java.time.LocalDateTime;
@ConfigurationProperties(prefix = "baiyue.self-operated-analytics") @ConfigurationProperties(prefix = "baiyue.self-operated-analytics")
public class SelfopAnalyticsProperties { public class SelfopAnalyticsProperties {
private String collectionMode = "OFF"; 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 environment = "test";
private String exclusiveGrantId; private String exclusiveGrantId;
private LocalDateTime exclusiveGrantExpiresAtUtc; private LocalDateTime exclusiveGrantExpiresAtUtc;
@@ -28,14 +28,46 @@ public final class SelfopCollectionPolicy {
requireExecution(config,"BUSINESS"); requireExecution(config,"BUSINESS");
require("production".equals(config.getEnvironment()),"SELFOP_AUTOMATIC_PLANNING_ENVIRONMENT_REJECTED"); 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. */ /** TEST may poll only persisted manual roots and their existing routing/refresh descendants. */
public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function<Long,SelfopSyncTask> lookup) { public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function<Long,SelfopSyncTask> lookup) {
return controlledBusinessTask(task,lookup,false);
}
public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function<Long,SelfopSyncTask> lookup,boolean allowContinuous) {
if(task==null)return false; if(task==null)return false;
String account=task.getSourceAccount();var seen=new java.util.HashSet<Long>(); String account=task.getSourceAccount();var seen=new java.util.HashSet<Long>();
for(int depth=0;task!=null&&depth<64;depth++) { for(int depth=0;task!=null&&depth<64;depth++) {
if(!"BUSINESS".equals(task.getExecutionPurpose())||task.getValidationRootTaskId()!=null 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(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(); String key=task.getRequestKey();
if(key!=null&&key.startsWith("manual:")&&task.getRequestedBy()!=null&&!task.getRequestedBy().isBlank())return true; if(key!=null&&key.startsWith("manual:")&&task.getRequestedBy()!=null&&!task.getRequestedBy().isBlank())return true;
Long parent=task.getUpstreamTaskId(); Long parent=task.getUpstreamTaskId();
@@ -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<Interval> intervals) { }
public static Map<String,Object> settings(String snapshot){
Map<String,Object> 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<Interval> 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<Interval> 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();}
}
}
@@ -11,7 +11,7 @@ import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
public final class SelfopStageEstimate { public final class SelfopStageEstimate {
private SelfopStageEstimate() { } private SelfopStageEstimate() { }
public static final int WINDOW_SECONDS=180, MIN_SECONDS=60, MIN_ADVANCES=3, 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, public record Estimate(String status,String progressIdentity,Long completed,Long total,
Instant sampleFrom,Instant sampleUntil,Long completedDelta,BigDecimal ratePerSecond, Instant sampleFrom,Instant sampleUntil,Long completedDelta,BigDecimal ratePerSecond,
Long remainingSeconds,Instant estimatedAt,Instant validUntil,String unavailableReason) { } Long remainingSeconds,Instant estimatedAt,Instant validUntil,String unavailableReason) { }
@@ -23,7 +23,7 @@ public final class SelfopStageEstimate {
return hash(values); 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<String,Object> p,Instant now,boolean realWork) { public static void checkpoint(Map<String,Object> p,Instant now,boolean realWork) {
try { try {
Long completed=number(p.get("completed")),total=number(p.get("total")); 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(); String id=identity(p);long second=now.getEpochSecond();
var old=map(p.get("etaSamples"));var points=points(old.get("p")); 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<points.get(points.size()-1).get(0) ||second<points.get(points.size()-1).get(0)
||completed<points.get(points.size()-1).get(1) ||completed<points.get(points.size()-1).get(1)
||second-numberOr(old.get("a"),second)>=STALE_SECONDS)points.clear(); ||second-numberOr(old.get("a"),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&&(!id.equals(sample.get("i"))||points.isEmpty()))reason="SAMPLING";
if(reason==null) { if(reason==null) {
long second=now.getEpochSecond(),lastAdvance=numberOr(sample.get("a"),0); 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"; if(points.isEmpty()||second-lastAdvance>=STALE_SECONDS)reason="NO_RECENT_PROGRESS";
else { else {
var first=points.get(0);var last=points.get(points.size()-1); var first=points.get(0);var last=points.get(points.size()-1);
@@ -9,6 +9,22 @@ import java.util.List;
/** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */ /** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */
@Mapper @Mapper
public interface OutboundAggregateMapper { 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<SelfopSyncRun> provenCoverageIslands(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash);
@Select(""" @Select("""
SELECT COUNT(*) FROM ll_selfop_outbound_order o SELECT COUNT(*) FROM ll_selfop_outbound_order o
LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id
@@ -53,6 +53,7 @@ public class OutboundCandidateTransaction {
OutboundPolicy.DayCoverage days=OutboundPolicy.completeDays(coverage.from(),coverage.until(),scope.getBusinessTimezone()); 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.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"); 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; 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()) 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()) || 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(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); OutboundCandidateBuilder.Coverage next=coverage(scope);
if(!Objects.equals(next.from(),published.getCoveredFromUtc()) || !Objects.equals(next.until(),published.getCoveredUntilUtc()))return null; 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. // 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; return aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),published.getBuildStartedAtUtc())==0?published.getId():null;
} }
private List<SelfopSalesCoverage.Interval> 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) { private Held held(Long scopeId,Long runId,String owner,long fence,boolean building) {
SelfopSyncScope scope=locks.scope(scopeId);LocalDateTime now=SelfopSupport.utcNow(); 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) SelfopSupport.require(scope!=null && "OUTBOUND".equals(scope.getDataFamily()) && Objects.equals(scope.getLeaseOwner(),owner) && Objects.equals(scope.getFenceToken(),fence)
@@ -48,11 +48,14 @@ public class AnalyticsQueryService {
if(!Objects.equals(unit,row.quantityUnit()))valid=false; if(!Objects.equals(unit,row.quantityUnit()))valid=false;
} }
Instant evidence=earliest(sp,ip); Instant evidence=earliest(sp,ip);
var coverage=SalesQueryCoverage.intervals(s.sales);
List<SalesBucketPolicy.Point> evaluated=new ArrayList<>(); List<AnalyticsResponses.Point> points=new ArrayList<>(); List<SalesBucketPolicy.Point> evaluated=new ArrayList<>(); List<AnalyticsResponses.Point> points=new ArrayList<>();
for(LocalDate day=s.range.startDate();day.isBefore(s.range.endDateExclusive());day=day.plusDays(1)){ for(LocalDate day=s.range.startDate();day.isBefore(s.range.endDateExclusive());day=day.plusDays(1)){
Daily row=daily.get(day); Daily row=daily.get(day);
var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,s.fullFrom,s.fullUntil, var covered=SalesQueryCoverage.at(row!=null||evidence==null?coverage:
s.context.salesCoveredFrom(),s.context.salesCoveredUntil(),evidence,row==null?null:row.outboundQuantity(), 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()))); row==null?null:row.outboundOrderCount(),valid,s.sourceKnown,s.context.rebuildRequired(),"STALE".equals(s.context.salesStatus())));
evaluated.add(point); evaluated.add(point);
points.add(new AnalyticsResponses.Point(day,point.outboundQuantity(),point.observedQuantity(),point.dataStatus(), 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)); 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 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 divide30(BigDecimal n){return n==null?null:n.divide(BigDecimal.valueOf(30),12,java.math.RoundingMode.HALF_UP);}
private static BigDecimal decimal(Map<String,Object> row,String key){Object n=row.get(key);return n==null?null:n instanceof BigDecimal d?d:new BigDecimal(n.toString());} private static BigDecimal decimal(Map<String,Object> 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<String,Object> row,String key){BigDecimal n=decimal(row,key);return n==null?0:n.longValueExact();} private static long number(Map<String,Object> 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(!"VALID".equals(result.status()))reasons.add(result.status());
if(r.inventoryReason()!=null)reasons.add(r.inventoryReason()); if(r.inventoryReason()!=null)reasons.add(r.inventoryReason());
BigDecimal today=r.todayQuantity(); 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 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; Instant lower=evidence.isAfter(start)?evidence:start;
if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom(); if(lower.isBefore(end)&&SalesQueryCoverage.intervals(s.sales).stream()
if(lower.isBefore(s.context.salesCoveredUntil()))today=BigDecimal.ZERO; .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(), 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, 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()) boolean rebuild=salesScope!=null&&(Boolean.TRUE.equals(salesScope.rebuildRequired())
||!Objects.equals(salesScope.factRevision(),salesScope.publishedFactRevision())||mapper.applyBarriers(q.sourceAccount(),q.warehouseCode())>0); ||!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()); 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"; if(rebuild)salesState="REBUILD_REQUIRED";
String inventoryState=inventory==null?"UNKNOWN":stale(inventory.fetchFinishedAtUtc(),threshold(ic))?"STALE":"VALID"; 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(); 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.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.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.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){ Instant inventoryFreshUntil(Snapshot s){
return s.inventory()==null||s.inventory().fetchFinishedAtUtc()==null?null: 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()) ||!scope.warehouseCode().equals(run.warehouseCode())||!scope.scopeHash().equals(run.scopeHash())
||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status())) ||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status()))
throw new BizException("版本不可用或范围不符,请刷新整页"); 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; 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 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 private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&!until.isAfter(s.range.anchorDate())
&&s.sales.coveredFromUtc().isBefore(s.sales.coveredUntilUtc())&&s.fullFrom!=null&&s.fullUntil!=null&&!from.isBefore(s.fullFrom)&&!until.isAfter(s.fullUntil);} &&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 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 static int threshold(JsonNode c){int n=c.path("staleAfterSeconds").asInt();if(n<=0)throw new BizException("版本过期阈值无效");return n;}
private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));} private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));}
@@ -30,6 +30,7 @@ public final class AnalyticsReadSql {
} }
private static String cte(AnalyticsSqlRequest request){ private static String cte(AnalyticsSqlRequest request){
String kind=request.kind(); String kind=request.kind();
if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request);
if("HOT".equals(kind))return hotSalesCte(); if("HOT".equals(kind))return hotSalesCte();
if("SLOW".equals(kind))return rankingCte(); if("SLOW".equals(kind))return rankingCte();
String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')";
@@ -152,6 +153,35 @@ public final class AnalyticsReadSql {
} }
return sql; return sql;
} }
private static String coveredSummary(AnalyticsSqlRequest p){
var intervals=new java.util.ArrayList<String>();
for(int i=0;i<p.coverage().size();i++)intervals.add("SELECT CAST(#{p.coverage["+i+"].from} AS DATETIME) lo,CAST(#{p.coverage["+i+"].until} AS DATETIME) hi");
var dates=new java.util.ArrayList<String>();
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+")<LEAST(v.hi,"+end+"))";
String whole="calendar.business_date<#{p.anchor} AND "+full+" AND (d.id IS NOT NULL OR b.evidence_at_utc<="+start+")";
return candidates(true)+" coverage AS ("+String.join(" UNION ALL ",intervals)+"),calendar AS ("+String.join(" UNION ALL ",dates)+"),"
+" sort_sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_sum,MIN(quantity_unit) min_unit,MAX(quantity_unit) max_unit,MIN(outbound_quantity) min_qty"
+" FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER+" AND business_date>=#{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)))<LEAST(v.hi,TIMESTAMPADD(HOUR,-8,CAST(#{p.until} AS DATETIME))))) THEN COALESCE(s.observed_sum,0) END observed_quantity"
+" FROM evidence e LEFT JOIN sort_sales s ON s.customer_code=e.customer_code AND s.sku_code=e.sku_code),"
+" paged_keys AS (SELECT * FROM rankable ORDER BY observed_quantity IS NULL,observed_quantity DESC,sku_code,customer_code LIMIT #{p.size} OFFSET #{p.offset}),"
+" day_counts AS (SELECT b.customer_code,b.sku_code,SUM(d.outbound_order_count) sku_order_count,"
+"SUM(CASE WHEN #{p.formalSales}=1 AND "+whole+" THEN 1 ELSE 0 END) complete_count,"
+"SUM(CASE WHEN #{p.salesKnown}=1 AND (d.id IS NOT NULL OR "+observed+") THEN 1 ELSE 0 END) observed_count"
+" FROM paged_keys b CROSS JOIN calendar LEFT JOIN ll_selfop_sku_daily_sales d ON d.sales_run_id=#{p.salesRun} AND d.source_account=#{p.account} AND d.warehouse_code=#{p.warehouse}"
+" AND d.customer_code=b.customer_code AND d.sku_code=b.sku_code AND d.business_date=calendar.business_date GROUP BY b.customer_code,b.sku_code),"
+" result AS (SELECT b.customer_code,b.sku_code,b.product_name,b.quantity_unit,b.evidence_at_utc,b.unit_valid,b.observed_quantity,n.sku_order_count,"
+"CASE WHEN b.unit_valid=1 THEN n.complete_count ELSE 0 END complete_day_count,"
+"CASE WHEN b.unit_valid=1 THEN n.observed_count-n.complete_count ELSE 0 END partial_day_count,"
+"TIMESTAMPDIFF(DAY,#{p.from},#{p.until})-CASE WHEN b.unit_valid=1 THEN n.observed_count ELSE 0 END unknown_day_count,"
+"NULL window_quantity,NULL window_sku_order_count,NULL sales30_quantity,NULL today_quantity,NULL physical_quantity,NULL available_quantity,NULL frozen_quantity,NULL transit_quantity,"
+"NULL inventory_observed_at_utc,'UNKNOWN' inventory_quality,NULL inventory_reason FROM paged_keys b JOIN day_counts n ON n.customer_code=b.customer_code AND n.sku_code=b.sku_code) ";
}
/** Ranking consumes the 30-day aggregate, not chart day-span counts. Its 7/30-day /** Ranking consumes the 30-day aggregate, not chart day-span counts. Its 7/30-day
* window is contained in this scan, so the same unit proof covers both windows. */ * window is contained in this scan, so the same unit proof covers both windows. */
private static String rankingCte(){ private static String rankingCte(){
@@ -7,4 +7,5 @@ import java.time.LocalDateTime;
public record AnalyticsSqlRequest(String account,String warehouse,String customer,String sku,long salesRun,long inventoryRun, public record AnalyticsSqlRequest(String account,String warehouse,String customer,String sku,long salesRun,long inventoryRun,
LocalDate from,LocalDate until,LocalDate anchor,LocalDate windowFrom,LocalDate completeFrom,LocalDate completeUntil, LocalDate from,LocalDate until,LocalDate anchor,LocalDate windowFrom,LocalDate completeFrom,LocalDate completeUntil,
LocalDateTime coveredFrom,LocalDateTime coveredUntil,boolean salesKnown,boolean formalSales,boolean completeWindow, LocalDateTime coveredFrom,LocalDateTime coveredUntil,boolean salesKnown,boolean formalSales,boolean completeWindow,
boolean complete30,boolean stockFresh,long offset,int size,String kind) {} boolean complete30,boolean stockFresh,long offset,int size,String kind,
java.util.List<com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval> coverage) {}
@@ -105,7 +105,7 @@ public class CollectionQueryService {
String freshness=published==null?"NOT_COLLECTED":scope.staleAfterSeconds()==null||scope.staleAfterSeconds()<=0?"UNKNOWN" 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"; :observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH";
rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked, 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()), 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.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, 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()), resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()),
blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()), blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()),
validation(root==null?t:root),attempt,dependency,waiting, 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) { private String dependencyState(Task task,Task parent) {
if(task.upstreamTaskId()==null)return "WAITING_SOURCE".equals(task.status())?"WAITING_UNCONFIRMED":"NONE"; 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"; if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK";
String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled; String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled;
if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS"; 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())) { if("test".equals(config.getEnvironment())) {
String budgetBlock=businessBudgetBlock(task);if(budgetBlock!=null)return budgetBlock; String budgetBlock=businessBudgetBlock(task);if(budgetBlock!=null)return budgetBlock;
} }
@@ -295,13 +300,16 @@ public class CollectionQueryService {
JsonNode budget=boundedObject(task.businessRootBudgetJson()); JsonNode budget=boundedObject(task.businessRootBudgetJson());
Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp"); Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp");
Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc"); 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 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"; return "BUSINESS_SESSION_BUDGET_MISSING";
JsonNode stopped=budget.get("stopReason"); JsonNode stopped=budget.get("stopReason");
if(stopped!=null&&!stopped.isNull()) if(stopped!=null&&!stopped.isNull())
return "BUSINESS_SESSION_STOPPED"; 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; return null;
} }
private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) { 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.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED";
if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN"; if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN";
if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified() 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("BUSINESS".equals(config.getCollectionMode()))return null;
if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED"; if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED";
if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate()) if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate())
&&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED"; &&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED";
return null; return null;
} }
private boolean continuousBusiness() {
return com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.continuousBusiness(config);
}
private Instant nextSchedule(String warehouse,String family) { 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); 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();} 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;} catch(IllegalArgumentException ex){return null;}
@@ -31,7 +31,7 @@ public final class CollectionResponses {
List<String> allowedActions, String actionBlockReason, Instant progressUpdatedAt, List<String> allowedActions, String actionBlockReason, Instant progressUpdatedAt,
Map<String,Object> progress, Validation validation, Attempt latestAttempt, Map<String,Object> progress, Validation validation, Attempt latestAttempt,
String dependencyState, String blockingReason, String progressSourceTaskNo, 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 TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {}
public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase, public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase,
String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt, String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt,
@@ -22,18 +22,18 @@ public class DashboardQueryService {
throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询"); throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询");
var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate(); 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, 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 o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);
var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive()); var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive());
var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList(); var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList();
Map<String,Object> stats=report.get(0);var ranked=report.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList(); Map<String,Object> 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 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<String,Object> hotStats=stats;List<DashboardReadMapper.Ranked> hotRanked=ranked; Map<String,Object> hotStats=stats;List<DashboardReadMapper.Ranked> hotRanked=ranked;
if(!c.rangeStartDate().equals(fixedFrom)||!c.rangeEndDateExclusive().equals(fixedUntil)||!base.complete30()){ 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, 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(); 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(); hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList();
} }
@@ -8,20 +8,23 @@ import java.util.*;
/** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */ /** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */
public final class DashboardSalesPolicy { public final class DashboardSalesPolicy {
private DashboardSalesPolicy(){} private DashboardSalesPolicy(){}
public static List<SalesBucketPolicy.Point> points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List<DashboardReadMapper.Day> rows){ public static List<SalesBucketPolicy.Point> points(AnalyticsResponses.Context c,boolean sourceKnown,LocalDate from,LocalDate until,List<DashboardReadMapper.Day> rows,
List<com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval> 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<LocalDate,DashboardReadMapper.Day> days=new HashMap<>();for(var row:rows)days.put(row.businessDate(),row); Map<LocalDate,DashboardReadMapper.Day> days=new HashMap<>();for(var row:rows)days.put(row.businessDate(),row);
List<SalesBucketPolicy.Point> result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai"); List<SalesBucketPolicy.Point> result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai");
for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1)){ 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(); 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); var interval=SalesQueryCoverage.at(intervals,day);
Instant upper=c.salesCoveredUntil()==null?null:(c.salesCoveredUntil().isBefore(end)?c.salesCoveredUntil():end); 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 intersects=lower!=null&&upper!=null&&lower.isBefore(upper);
boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0; boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0;
// Global recency describes how far collection has progressed, not whether an // Global recency describes how far collection has progressed, not whether an
// already published historical day has complete coverage. Keep STALE on context. // already published historical day has complete coverage. Keep STALE on context.
boolean trusted=("VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus()))&&!c.rebuildRequired(); 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 boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&SalesQueryCoverage.complete(intervals,day,day.plusDays(1));
&&!day.isBefore(c.completeDayFrom())&&day.isBefore(c.completeDayUntil())&&intersects&&lower.equals(start)&&upper.equals(end);
BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null; BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null;
if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO; if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO;
String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List<String> reasons=new ArrayList<>(); String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List<String> reasons=new ArrayList<>();
@@ -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<SelfopSalesCoverage.Interval> 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<SelfopSalesCoverage.Interval> 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<SelfopSalesCoverage.Interval> 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<SelfopSalesCoverage.Interval> 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();}
}
@@ -8,6 +8,7 @@ public final class SelfopBusinessBudget {
/** Recorded field only; BUSINESS has no HTTP-count ceiling. New sessions write zero. */ /** Recorded field only; BUSINESS has no HTTP-count ceiling. New sessions write zero. */
public int maxHttp; public int maxHttp;
public int http, confirmedHttp; public int http, confirmedHttp;
public boolean continuous;
public LocalDateTime startedAtUtc, deadlineAtUtc; public LocalDateTime startedAtUtc, deadlineAtUtc;
public String stopReason; public String stopReason;
@@ -20,9 +21,15 @@ public final class SelfopBusinessBudget {
return SelfopSupport.json(budget); 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() { public void validate() {
if (http < 0 || confirmedHttp < 0 || confirmedHttp > http 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}")) || stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}"))
throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null); throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null);
} }
@@ -30,7 +37,7 @@ public final class SelfopBusinessBudget {
public String limit(LocalDateTime now) { public String limit(LocalDateTime now) {
validate(); validate();
if (stopReason != null) return stopReason; if (stopReason != null) return stopReason;
if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE"; if (!continuous && !now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE";
return null; return null;
} }
} }
@@ -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"); 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); 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"); 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()); 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);}} 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); verify(task,run,root,account,now);
checkAccount(account,budget,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)); 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 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()))); 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(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; 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(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);}
if(business!=null){business.http++;saveBusiness(root,business);} if(business!=null){business.http++;saveBusiness(root,business);}
@@ -53,15 +53,15 @@ public class SelfopEgressService {
@Transactional(noRollbackFor=EgressRejectedException.class) @Transactional(noRollbackFor=EgressRejectedException.class)
public AttemptPermit authorize(AuthorizationRequest request){ public AttemptPermit authorize(AuthorizationRequest request){
requireRequest(request);var c=request.context();var now=now();gate(c.sourceAccount(),c.executionPurpose(),c.exclusiveGrantId(),now); 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 root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId); 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); var b=business(root);verifyBusinessTask(task,root);
// Authorization preserves the root time/stop fence independently of request accounting. // 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(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); verify(task,run,root,account,now);
if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED"); 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) @Transactional(noRollbackFor=EgressRejectedException.class)
public void complete(CompletionRequest request){ public void complete(CompletionRequest request){
if(request==null||request.context()==null||request.actualHttpAttempts()<0||request.actualHttpAttempts()>1)throw reject("EGRESS_COMPLETION_INVALID"); 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); String completionHash=SelfopSupport.hash(request);
if(pending.completed&&!pending.abandoned){ if(pending.completed&&!pending.abandoned){
if(!Objects.equals(pending.completionHash,completionHash))throw reject("EGRESS_COMPLETION_CONFLICT"); 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()); var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
SelfopValidationBudget validation="VALIDATION".equals(c.executionPurpose())?validation(root):null; 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(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()) 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()); ||"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()) 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("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("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(!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"); 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 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); 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"); if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID");
LocalDate day=now.plusHours(8).toLocalDate(); 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(!Objects.equals(day,b.budgetDate)){
if("BUSINESS".equals(config.getCollectionMode()))b.dailyLimit=0; 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());} 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.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs());
b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds())); 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 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){ 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(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("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");} 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"); 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 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 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){ 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"); 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");} 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){ private void stopBusiness(SelfopSyncTask root,SelfopBusinessBudget b,String reason,LocalDateTime now){
b.stopReason=reason;root.setStatus("DEAD");root.setNextRetryAtUtc(null);saveBusiness(root,b); 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); mapper.terminateBusinessTasks(root.getId(),root.getSourceAccount());mapper.terminateBusinessRuns(root.getId(),root.getSourceAccount(),reason,now);
@@ -37,6 +37,7 @@ public class SelfopExecutionService {
} }
public void execute(long taskId){ public void execute(long taskId){
var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose()); 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; if("SUCCEEDED".equals(task.getStatus())||"DEAD".equals(task.getStatus()))return;
String owner=UUID.randomUUID().toString();Map<Long,SelfopLeaseService.Lease> held=new LinkedHashMap<>();SelfopLeaseService.Lease primary; String owner=UUID.randomUUID().toString();Map<Long,SelfopLeaseService.Lease> held=new LinkedHashMap<>();SelfopLeaseService.Lease primary;
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){primary=leases.acquireDiscovery(task.getDiscoveryScopeId(),owner);held.put(primary.scopeId(),primary);} if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){primary=leases.acquireDiscovery(task.getDiscoveryScopeId(),owner);held.put(primary.scopeId(),primary);}
@@ -34,9 +34,9 @@ public class SelfopSalesPublisher {
require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"HALF_APPLIED_FACTS"); require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"HALF_APPLIED_FACTS");
require(Objects.equals(run.getCalculatedFactRevision(),scope.getFactRevision()),"SELFOP_CANDIDATE_OBSOLETE"); 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(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"); var task=locks.lockTask(run.getTaskId());require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED");
SelfopCollectionPolicy.requireBusiness(task); SelfopCollectionPolicy.requireBusiness(task);
SelfopSalesCoverage.requireTask(run,task);
if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncRun>() if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncRun>()
.eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now)); .eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now));
scope.setPublishedRunId(runId);scope.setPublishedFactRevision(run.getCalculatedFactRevision());scope.setRebuildRequired(0); scope.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()) 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"); &&locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"SELFOP_REUSE_OBSOLETE");
require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED"); 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"); 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); 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<SelfopSyncTask>() task.setResultPublishedRunId(publishedId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncTask>()
@@ -32,6 +32,15 @@ public class SelfopSchedulePlanner {
public void tick(){ public void tick(){
if("OFF".equals(config.getCollectionMode()))return; if("OFF".equals(config.getCollectionMode()))return;
if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS"); 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<SelfopSyncScope> configured; List<SelfopSyncScope> configured;
if("VALIDATION".equals(config.getCollectionMode())){taskService.initializeAccount();configured=List.of();} if("VALIDATION".equals(config.getCollectionMode())){taskService.initializeAccount();configured=List.of();}
else if("test".equals(config.getEnvironment()))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. */} 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<SelfopSyncScope>().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<SelfopSyncTask>().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<String>();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<SelfopSyncScope>().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<SelfopSyncTask>().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){ public void plan(SelfopSyncScope preview,LocalDateTime now){
SelfopCollectionPolicy.requireAutomaticPlanning(config); SelfopCollectionPolicy.requireAutomaticPlanning(config);
tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone()); tx.executeWithoutResult(s->{var scope=locks.lockScope(preview.getId());ZoneId business=ZoneId.of(scope.getBusinessTimezone());
@@ -110,15 +110,23 @@ public class SelfopTaskService {
} }
if(testBusiness(task)) { if(testBusiness(task)) {
require(task.getBusinessRootTaskId()==null&&task.getBusinessBudgetJson()==null,"BUSINESS_SESSION_IDENTITY_INVALID"); require(task.getBusinessRootTaskId()==null&&task.getBusinessBudgetJson()==null,"BUSINESS_SESSION_IDENTITY_INVALID");
if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) { if(continuousBusiness(config)&&continuousBusinessRoot(task)){
require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED"); 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.setBusinessRootTaskId(task.getId());
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds())); task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds()));
}else { }else {
var parent=businessParent(task); 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"); 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()); businessGuard(parent,!"WAREHOUSE_SYNC".equals(task.getTaskRole()));task.setBusinessRootTaskId(parent.getBusinessRootTaskId());
} }
} }
@@ -136,7 +144,7 @@ public class SelfopTaskService {
@Transactional(noRollbackFor=BusinessSessionStoppedException.class) @Transactional(noRollbackFor=BusinessSessionStoppedException.class)
public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) { public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) {
lockBusinessAccount(taskId);leases.verify(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"); var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE");
checkTaskLease(task,lease); checkTaskLease(task,lease);
requireExecution(config,task.getExecutionPurpose()); requireExecution(config,task.getExecutionPurpose());
@@ -190,6 +198,7 @@ public class SelfopTaskService {
run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run); run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
// A yielded execution is not a failed request; finite failures are counted separately. // A yielded execution is not a failed request; finite failures are counted separately.
long failures=runs.selectCount(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","FAILED")); long failures=runs.selectCount(new QueryWrapper<SelfopSyncRun>().eq("task_id",taskId).eq("status","FAILED"));
if(task.getBusinessRootTaskId()!=null&&continuousBusinessRoot(tasks.selectById(task.getBusinessRootTaskId())))retryable=false;
task.setStatus(retryable&&failures<config.getMaxAttempts()?"RETRY_WAIT":"DEAD"); task.setStatus(retryable&&failures<config.getMaxAttempts()?"RETRY_WAIT":"DEAD");
if("APPLYING".equals(task.getApplyState())) task.setApplyState("BLOCKED"); if("APPLYING".equals(task.getApplyState())) task.setApplyState("BLOCKED");
if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) { if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) {
@@ -200,7 +209,7 @@ public class SelfopTaskService {
task.setRoutingManifestJson(null); task.setRoutingManifestJson(null);
tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",taskId).set("routing_manifest_json",null)); tasks.update(null,new UpdateWrapper<SelfopSyncTask>().eq("id",taskId).set("routing_manifest_json",null));
} }
task.setNextRetryAtUtc(now.plusSeconds(Math.min(3600,5L<<Math.min(9,(int)failures))));task.setUpdatedAt(now);tasks.updateById(task); task.setNextRetryAtUtc("DEAD".equals(task.getStatus())?null:now.plusSeconds(Math.min(3600,5L<<Math.min(9,(int)failures))));task.setUpdatedAt(now);tasks.updateById(task);
} }
@Transactional(noRollbackFor=BusinessSessionStoppedException.class) @Transactional(noRollbackFor=BusinessSessionStoppedException.class)
public SelfopSyncTask retry(long id,String requestKey,String operator,String reason) { public SelfopSyncTask retry(long id,String requestKey,String operator,String reason) {
@@ -228,10 +237,12 @@ public class SelfopTaskService {
audit.setRequestKey("retry:"+requestKey);audit.setRequestHash(hash(List.of(id,requestKey,reason)));audit.setTaskType("MANUAL_RETRY");audit.setScopeHash(original.getScopeHash()); audit.setRequestKey("retry:"+requestKey);audit.setRequestHash(hash(List.of(id,requestKey,reason)));audit.setTaskType("MANUAL_RETRY");audit.setScopeHash(original.getScopeHash());
audit.setQueryScopeJson(json(Map.of("resumesTaskNo",original.getTaskNo(),"reason",reason)));audit.setStatus("SUCCEEDED");audit.setApplyState("NOT_APPLICABLE");audit.setRequestedBy(operator); audit.setQueryScopeJson(json(Map.of("resumesTaskNo",original.getTaskNo(),"reason",reason)));audit.setStatus("SUCCEEDED");audit.setApplyState("NOT_APPLICABLE");audit.setRequestedBy(operator);
create(audit); create(audit);
original.setStatus("READY");original.setNextRetryAtUtc(utcNow());original.setRequestedBy(operator);original.setUpdatedAt(utcNow());tasks.updateById(original); original.setStatus("READY");original.setNextRetryAtUtc(utcNow());
if(!continuousBusinessRoot(original))original.setRequestedBy(operator);
original.setUpdatedAt(utcNow());tasks.updateById(original);
return original; return original;
} }
public List<SelfopSyncTask> due(int limit){return locks.due(utcNow(),limit,config.getCollectionMode());} public List<SelfopSyncTask> due(int limit){return continuousBusiness(config)?locks.dueContinuous(utcNow(),limit,config.getSourceAccount()):locks.due(utcNow(),limit,config.getCollectionMode());}
@Transactional(noRollbackFor=BusinessSessionStoppedException.class) @Transactional(noRollbackFor=BusinessSessionStoppedException.class)
public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) { 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); 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) @Transactional(noRollbackFor=BusinessSessionStoppedException.class)
public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) { 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); 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); 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) @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public CollectionCheckpoint checkpoint(SelfopSyncTask observedTask,SelfopSyncRun observedRun,SelfopLeaseService.Lease lease) { public CollectionCheckpoint checkpoint(SelfopSyncTask observedTask,SelfopSyncRun observedRun,SelfopLeaseService.Lease lease) {
long taskId=observedTask.getId();lockBusinessAccount(taskId);leases.verify(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 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); 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;} BusinessSessionStoppedException(String reason){super("本次测试采集会话已结束或预算无效,请核对记录后新建有界任务");this.reason=reason;}
public String reason(){return 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){ private SelfopSyncTask businessParent(SelfopSyncTask task){
Long id=task.getUpstreamTaskId(); 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();} 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); return id==null?null:tasks.selectById(id);
} }
private void lockBusinessAccount(long 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; var task=tasks.selectById(id);if(!testBusiness(task))return;
require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED"); require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED");
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount())); var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount()));
require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId()); require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId());
} }
private void guardBeforeTaskLock(long id,boolean checkDeadline){ 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); var task=tasks.selectById(id);businessGuard(task,checkDeadline);
} }
private boolean localBusinessWork(SelfopSyncTask task){ 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){businessGuard(task,checkDeadline,true);}
private void businessGuard(SelfopSyncTask task,boolean checkDeadline,boolean terminate){ private void businessGuard(SelfopSyncTask task,boolean checkDeadline,boolean terminate){
if(!testBusiness(task))return; 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"); require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED");
var ancestor=task; 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"); 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. // Serialize with egress in account -> root -> task order, before taking any task row lock.
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount())); var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount()));
require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId()); require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId());
var root=locks.lockTask(task.getBusinessRootTaskId()); var root=locks.lockTask(task.getBusinessRootTaskId());
require(root!=null&&Objects.equals(root.getId(),root.getBusinessRootTaskId())&&Objects.equals(root.getSourceAccount(),task.getSourceAccount()) 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"); require(root.getBusinessBudgetJson()!=null,"BUSINESS_SESSION_BUDGET_INVALID");
var budget=read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);budget.validate(); 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; 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&&"DEAD".equals(root.getStatus()))reason="BUSINESS_SESSION_STOPPED";
if(reason==null)return; if(reason==null)return;
if(!terminate)throw new BusinessSessionStoppedException(reason); if(!terminate)throw new BusinessSessionStoppedException(reason);
@@ -48,6 +48,7 @@ lastleg:
baiyue: baiyue:
self-operated-analytics: self-operated-analytics:
collection-mode: ${SELFOP_COLLECTION_MODE:OFF} collection-mode: ${SELFOP_COLLECTION_MODE:OFF}
continuous-business-collection-enabled: ${SELFOP_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false}
environment: ${DEPLOY_ENV:test} environment: ${DEPLOY_ENV:test}
daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0} daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0}
business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000} business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000}
@@ -732,6 +732,8 @@ components:
Task: Task:
type: object type: object
properties: properties:
createdAt: {type: string, format: date-time, nullable: true, description: 当前任务创建时间,重试和阶段切换不重置}
elapsedSeconds: {type: string, nullable: true, description: 自创建起的累计秒数,含排队和重试;活动任务截止本次读取,终态任务截止任务最后更新时间;不是单次执行耗时}
taskNo: {type: string, nullable: true} taskNo: {type: string, nullable: true}
taskType: {type: string, nullable: true} taskType: {type: string, nullable: true}
dataFamily: {type: string, nullable: true} dataFamily: {type: string, nullable: true}
@@ -807,7 +809,7 @@ components:
executionEnabled: {type: boolean, nullable: true} executionEnabled: {type: boolean, nullable: true}
publicationEnabled: {type: boolean, nullable: true} publicationEnabled: {type: boolean, nullable: true}
disabledReason: {type: string, 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} observedAt: {type: string, format: date-time, nullable: true}
initialFrom: {type: string, format: date-time, nullable: true} initialFrom: {type: string, format: date-time, nullable: true}
initialUntil: {type: string, format: date-time, nullable: true} initialUntil: {type: string, format: date-time, nullable: true}
@@ -24,7 +24,7 @@ class SelfopStageEstimateTest {
@Test void sufficientRealAdvancesEstimateCurrentStageAndIncludeIdleTime(){ @Test void sufficientRealAdvancesEstimateCurrentStageAndIncludeIdleTime(){
var p=ready();var e=estimate(p,60);assertEquals("AVAILABLE",e.status());assertEquals(60L,e.completedDelta()); 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(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()); 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()); 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()); assertEquals("SAMPLING",estimate(p,59).unavailableReason());assertEquals("SAMPLING",estimate(p,60).unavailableReason());
commit(p,60,4,true);assertEquals("AVAILABLE",estimate(p,60).status()); 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(){ @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); 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()); assertEquals(3L,estimate(p,60).completedDelta());
@@ -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); 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->{ 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))); 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(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<skus*days;offset+=500) { for(int offset=0;offset<skus*days;offset+=500) {
@@ -10,6 +10,47 @@ import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class OutboundCandidateBuilderTest { class OutboundCandidateBuilderTest {
@Test void disconnectedCompleteDayIsFrozenWithoutFillingGapOrChangingAnchor(){
var first=island(7);var latest=island(12);scope.setInitialFromUtc(first.getCoveredFromUtc());
task.setQueryFromUtc(latest.getCoveredFromUtc());task.setQueryUntilUtc(latest.getCoveredUntilUtc());
when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(first);
when(aggregates.provenCoverageIslands(1L,"scope")).thenReturn(List.of(first,latest));
builder.build(1L,10L,"worker",2);
var proof=SelfopSalesCoverage.evidence(run);
assertTrue(SelfopSalesCoverage.contains(proof,latest.getCoveredFromUtc(),latest.getCoveredUntilUtc()));
assertFalse(SelfopSalesCoverage.contains(proof,first.getCoveredFromUtc(),latest.getCoveredUntilUtc()));
assertEquals(first.getCoveredUntilUtc(),run.getCoveredUntilUtc());assertEquals(first.getCoveredFromUtc(),scope.getInitialFromUtc());
var frozen=run.getScopeSnapshotJson();when(aggregates.provenCoverageIslands(1L,"scope")).thenReturn(List.of(first,latest,island(14)));
assertEquals(frozen,run.getScopeSnapshotJson());
assertFalse(SelfopSalesCoverage.contains(SelfopSalesCoverage.evidence(run),island(14).getCoveredFromUtc(),island(14).getCoveredUntilUtc()));
}
@Test void disconnectedZeroSalesDayForcesNewCandidateEvenWithUnchangedRevisionAndContinuousWatermark(){
var first=island(7);var latest=island(12);scope.setInitialFromUtc(first.getCoveredFromUtc());
scope.setPublishedRunId(9L);scope.setPublishedFactRevision(5L);
var published=new SelfopSyncRun();published.setId(9L);published.setScopeId(1L);published.setScopeHash("scope");
published.setStatus("PUBLISHED");published.setExecutionPurpose("BUSINESS");published.setCalculatedFactRevision(5L);
published.setWarehouseDailyReady(true);published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1));
published.setCoveredFromUtc(first.getCoveredFromUtc());published.setCoveredUntilUtc(first.getCoveredUntilUtc());published.setScopeSnapshotJson(scope.getScopeJson());
SelfopSalesCoverage.freeze(published,List.of(new SelfopSalesCoverage.Interval(first.getCoveredFromUtc(),first.getCoveredUntilUtc())));
when(runs.selectById(9L)).thenReturn(published);
when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(first);
when(aggregates.provenCoverageIslands(1L,"scope")).thenReturn(List.of(first,latest));
assertNull(builder.reusable(1L,10L,"worker",2));
assertEquals(1,SelfopSalesCoverage.evidence(published).intervals().size());
}
private SelfopSyncRun island(int day){
var r=new SelfopSyncRun();r.setCoveredFromUtc(LocalDateTime.of(2026,10,day,0,0).minusHours(8));r.setCoveredUntilUtc(r.getCoveredFromUtc().plusDays(1));return r;
}
@Test void fillingMiddleDaysMergesFrozenProofAndResumedBuildDoesNotReadLaterProof(){
var merged=island(7);merged.setCoveredUntilUtc(island(12).getCoveredUntilUtc());scope.setInitialFromUtc(merged.getCoveredFromUtc());
when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(merged);
when(aggregates.provenCoverageIslands(1L,"scope")).thenReturn(List.of(merged));
var checkpoint=transaction.begin(1L,10L,"worker",2);var frozen=run.getScopeSnapshotJson();
when(aggregates.provenCoverageIslands(1L,"scope")).thenReturn(List.of(merged,island(14)));
assertEquals(checkpoint,transaction.begin(1L,10L,"worker",2));assertEquals(frozen,run.getScopeSnapshotJson());
assertTrue(SelfopSalesCoverage.contains(SelfopSalesCoverage.evidence(run),merged.getCoveredFromUtc(),merged.getCoveredUntilUtc()));
verify(aggregates,times(1)).provenCoverageIslands(1L,"scope");
}
final OutboundLockMapper locks=mock(OutboundLockMapper.class); final OutboundLockMapper locks=mock(OutboundLockMapper.class);
final OutboundFactService facts=mock(OutboundFactService.class); final OutboundFactService facts=mock(OutboundFactService.class);
final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class); final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class);
@@ -37,6 +78,7 @@ class OutboundCandidateBuilderTest {
when(locks.scope(1L)).thenReturn(scope); when(locks.run(10L)).thenReturn(run); when(locks.task(20L)).thenReturn(task); when(locks.scope(1L)).thenReturn(scope); when(locks.run(10L)).thenReturn(run); when(locks.task(20L)).thenReturn(task);
SelfopSyncRun coverage=new SelfopSyncRun();coverage.setCoveredFromUtc(task.getQueryFromUtc());coverage.setCoveredUntilUtc(task.getQueryUntilUtc()); SelfopSyncRun coverage=new SelfopSyncRun();coverage.setCoveredFromUtc(task.getQueryFromUtc());coverage.setCoveredUntilUtc(task.getQueryUntilUtc());
when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(coverage); when(aggregates.contiguousCoverage(1L,"scope",scope.getInitialFromUtc(),null,null)).thenReturn(coverage);
when(aggregates.provenCoverageIslands(1L,"scope")).thenReturn(List.of(coverage));
} }
@Test void manualFirstSliceUsesOnlyProvenIntervalWithoutInventingFullDays() { @Test void manualFirstSliceUsesOnlyProvenIntervalWithoutInventingFullDays() {
scope.setInitialFromUtc(null); scope.setInitialFromUtc(null);
@@ -90,6 +132,7 @@ class OutboundCandidateBuilderTest {
published.setWarehouseDailyReady(true);published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L); published.setWarehouseDailyReady(true);published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L);
published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1)); published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1));
published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc()); published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc());
SelfopSalesCoverage.freeze(published,List.of(new SelfopSalesCoverage.Interval(task.getQueryFromUtc(),task.getQueryUntilUtc())));
published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published); published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published);
assertNull(builder.reusable(1L,10L,"worker",2)); assertNull(builder.reusable(1L,10L,"worker",2));
published.setCoveredUntilUtc(task.getQueryUntilUtc()); assertEquals(9L,builder.reusable(1L,10L,"worker",2)); published.setCoveredUntilUtc(task.getQueryUntilUtc()); assertEquals(9L,builder.reusable(1L,10L,"worker",2));
@@ -16,6 +16,71 @@ import static org.mockito.Mockito.*;
/** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */ /** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */
class AnalyticsQueryServiceTest { class AnalyticsQueryServiceTest {
@Test void frozenHistoryStartExistsBeforeContinuousAnchorAndIncludesEarlierDisconnectedDays(){
when(mapper.publishedRun(11)).thenReturn(frozenRun(null,null,
new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(1)),time(D))));
assertEquals(D.minusDays(1),service.inventoryCoverage(metrics(null),options()).records().get(0).observedHistoryStartDate());
assertEquals(D.minusDays(1),service.slowSkus(metrics(null)).records().get(0).observedHistoryStartDate());
when(mapper.publishedRun(11)).thenReturn(frozenRun(time(D.minusDays(5)),time(D.minusDays(4)),
new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(10)),time(D.minusDays(9))),
new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(5)),time(D.minusDays(4)))));
assertEquals(D.minusDays(10),service.inventoryCoverage(metrics(null),options()).records().get(0).observedHistoryStartDate());
assertEquals(D.minusDays(10),service.slowSkus(metrics(null)).records().get(0).observedHistoryStartDate());
}
@Test void slowTodayZeroUsesFrozenIslandAndMatchesTrendWithoutAuthorizingThirtyDays(){
var json=new ObjectMapper().findAndRegisterModules();
var row=json.convertValue(aggregate(true,"300","VALID",time(D.minusDays(40))),Map.class);row.put("todayQuantity",null);
when(mapper.page(any())).thenReturn(List.of(json.convertValue(row,Aggregate.class)));
when(mapper.publishedRun(11)).thenReturn(frozenRun(time(D.minusDays(5)),time(D.minusDays(4)),
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),utc(NOW.minusSeconds(3600)))));
var trend=service.dailySales(chart("TODAY",null,null,"C","SKU")).points().get(0);
assertEquals("PARTIAL",trend.dataStatus());assertEquals(BigDecimal.ZERO,trend.observedQuantity());
assertEquals(trend.observedQuantity(),service.slowSkus(metrics(null)).records().get(0).todayObservedQuantity());
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture());
assertFalse(cap.getValue().complete30());assertFalse(cap.getValue().completeWindow());
row.put("evidenceAtUtc",utc(NOW));when(mapper.page(any())).thenReturn(List.of(json.convertValue(row,Aggregate.class)));
assertNull(service.slowSkus(metrics(null)).records().get(0).todayObservedQuantity());
}
@Test void slowUncoveredTodayRemainsUnknownAndHistoricalGapIsNotFilled(){
var json=new ObjectMapper().findAndRegisterModules();
var row=json.convertValue(aggregate(true,"300","VALID",time(D.minusDays(40))),Map.class);row.put("todayQuantity",null);
when(mapper.page(any())).thenReturn(List.of(json.convertValue(row,Aggregate.class)));
when(mapper.publishedRun(11)).thenReturn(frozenRun(time(D.minusDays(5)),time(D.minusDays(4)),
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.minusDays(1)),time(D))));
var trend=service.dailySales(chart("TODAY",null,null,"C","SKU")).points().get(0);
assertEquals("UNKNOWN",trend.dataStatus());assertNull(trend.observedQuantity());
assertNull(service.slowSkus(metrics(null)).records().get(0).todayObservedQuantity());
var gap=service.dailySales(chart("CUSTOM",D.minusDays(3),D.minusDays(2),"C","SKU"));
assertTrue(gap.points().stream().allMatch(p->"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 String SH="a".repeat(64), IH="b".repeat(64);
private static final LocalDate D=LocalDate.of(2026,10,12); private static final LocalDate D=LocalDate.of(2026,10,12);
private static final Instant NOW=Instant.parse("2026-10-12T04:00:00Z"); private static final Instant NOW=Instant.parse("2026-10-12T04:00:00Z");
@@ -16,7 +16,7 @@ class AnalyticsReadMapperTest {
private static AnalyticsSqlRequest request(String kind){ private static AnalyticsSqlRequest request(String kind){
LocalDate d=LocalDate.of(2026,10,12); 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), 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<String,Object> parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);} private static BoundSql bound(Configuration c,String method,Map<String,Object> parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);}
private static void immutableOnly(String sql){ private static void immutableOnly(String sql){
@@ -17,6 +17,16 @@ import static org.junit.jupiter.api.Assertions.*;
* original BoundSql mappings. Candidate counts and separate binding contracts remain prepared. * original BoundSql mappings. Candidate counts and separate binding contracts remain prepared.
* No external database, credentials or HTTP; MySQL plans/performance remain separate gates. */ * No external database, credentials or HTTP; MySQL plans/performance remain separate gates. */
public class AnalyticsSqlSemanticTest { 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 Connection schema;
private SqlSession session; private SqlSession session;
private AnalyticsReadMapper mapper; 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(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"); 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, 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()); 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()); var sold=rows.get(0);assertEquals("SOLD",sold.skuCode());assertDecimal("5",sold.observedQuantity());
assertEquals(2L,sold.skuOrderCount());assertEquals(365,sold.completeDayCount()); 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[]{utc(D.plusDays(1),0),utc(D.plusDays(2),0)},
new LocalDateTime[]{null,utc(D,8)})){ 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, 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 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, 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()); assertNull(page(last).get(0).observedQuantity());
} }
daily(11,"W","C","Z-EVIDENCE",D,"99"); 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, 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()); var first=page(unavailable).get(0);assertEquals("A-UNKNOWN",first.skuCode());assertNull(first.observedQuantity());
} }
@Test void evidenceAfterCoveredTimeOnSameDayDoesNotBecomeObservedZero() throws Exception { @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","A","SAME",D.minusDays(1),"2");daily(11,"W","A","SAME",D,"5");
daily(11,"W","B","SAME",D.minusDays(2),"9"); 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), 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()); assertEquals(4,mapper.count(p));var rows=page(p);assertEquals(4,rows.size());
for(var row:rows)if("SAME".equals(row.skuCode())){ for(var row:rows)if("SAME".equals(row.skuCode())){
assertDecimal("A".equals(row.customerCode())?"8":"9",row.observedQuantity()); 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"); 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 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, 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 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()); 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()); assertTrue(inventoryRows(empty,options).isEmpty());assertEquals(0L,((Number)literalQuery("inventoryCount",empty,options).get(0)).longValue());
} }
@Test void tenDayInventoryUsesItsOwnCompleteCoverageAndUnroundedThresholdWithoutLeakingScope()throws Exception { @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(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"); 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, 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 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(); 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()); 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(1L,((Number)literalQuery("inventoryCount",p,filtered).get(0)).longValue());
assertEquals(List.of("TEN"),inventoryRows(p,filtered).stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); 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, 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)); assertTrue(inventoryRows(partial,o).stream().allMatch(r->r.outboundQuantity30d()==null&&r.coverageDays()==null));
} }
@SuppressWarnings("unchecked") private List<AnalyticsReadRows.Inventory> inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ @SuppressWarnings("unchecked") private List<AnalyticsReadRows.Inventory> 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) { 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, 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 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 { private void profile(long run,String owner,String sku,String evidence,LocalDateTime at,String unit)throws Exception {
@@ -29,6 +29,15 @@ class CollectionQueryServiceTest {
return new Task(id,"LXCOL-"+id,"primary",account?null:"A","OUTBOUND",account?"ACCOUNT_DISCOVERY":"WAREHOUSE_SYNC","BACKFILL","BUSINESS", 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); "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() { @Test void currentActivityWinsOverNewerHistoryAndIsBoundedToEight() {
var candidates=new ArrayList<Task>(); var candidates=new ArrayList<Task>();
candidates.add(activity(99,"SUCCEEDED",null,false,now,null)); candidates.add(activity(99,"SUCCEEDED",null,false,now,null));
@@ -132,6 +141,37 @@ class CollectionQueryServiceTest {
config.setEnvironment("development"); config.setEnvironment("development");
for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("BUSINESS_ENVIRONMENT_REJECTED",row.disabledReason());} 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() { Scope scope() {
return new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600, 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); now.minusMonths(2),now,null,null,0,null,null,null,7,null,null);
@@ -9,43 +9,52 @@ import java.util.*;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class DashboardPolicyTest { 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 LocalDate DAY=LocalDate.of(2026,10,13);
static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z"); 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());} 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(){ @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 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()); assertEquals(new BigDecimal("8"),summary.observedQuantity());assertNull(summary.outboundQuantity());assertNull(points.get(1).observedQuantity());assertEquals("PARTIAL",points.get(3).dataStatus());
} }
@Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){ @Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){
var c=context(DAY.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),NOW,true); 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("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()); 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(){ @Test void staleCollectionDoesNotDowngradePublishedHistoricalDayOrInventOtherFourteenDays(){
LocalDate published=DAY.minusDays(3);var c=publishedDayContext("STALE",false); LocalDate published=DAY.minusDays(3);var c=publishedDayContext("STALE",false);
for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))) { for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))) {
var rows=amount.signum()==0?List.<DashboardReadMapper.Day>of():List.of(new DashboardReadMapper.Day(published,amount,0L)); var rows=amount.signum()==0?List.<DashboardReadMapper.Day>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(); var point=points.stream().filter(p->p.date().equals(published)).findFirst().orElseThrow();
assertEquals("COMPLETE",point.dataStatus());assertEquals(amount,point.outboundQuantity()); assertEquals("COMPLETE",point.dataStatus());assertEquals(amount,point.outboundQuantity());
assertFalse(point.reasonCodes().contains("SALES_DAY_INCOMPLETE"));assertFalse(point.reasonCodes().contains("SALES_STALE")); assertFalse(point.reasonCodes().contains("SALES_DAY_INCOMPLETE"));assertFalse(point.reasonCodes().contains("SALES_STALE"));
var summary=SalesBucketPolicy.aggregate(points); var summary=SalesBucketPolicy.aggregate(points);
assertEquals(1,summary.completeDays());assertEquals(14,summary.unknownDays());assertEquals(0,summary.partialDays()); assertEquals(1,summary.completeDays());assertEquals(14,summary.unknownDays());assertEquals(0,summary.partialDays());
assertEquals("PARTIAL",summary.dataStatus());assertNull(summary.outboundQuantity());assertEquals(amount,summary.observedQuantity()); 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("COMPLETE",single.dataStatus());assertEquals(amount,single.outboundQuantity());
assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE")); assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE"));
} }
} }
@Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){ @Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){
LocalDate day=DAY.minusDays(3);var rows=List.of(new DashboardReadMapper.Day(day,new BigDecimal("2251"),0L)); 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()); 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))).get(0); 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")); 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()); assertEquals("PARTIAL",rebuilding.dataStatus());assertNull(rebuilding.outboundQuantity());
} }
private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){ private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){
@@ -20,7 +20,7 @@ class DashboardQueryServiceTest {
var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366); 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); 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); 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(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))); when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L)));
var reportRow=new HashMap<String,Object>(Map.of("total_skus",200L,"hot_eligible",200L,"hot_unknown",0L)); var reportRow=new HashMap<String,Object>(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); 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.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, 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); var result=service.dashboard(q);
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());} 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());} else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
@@ -44,12 +44,12 @@ class DashboardSqlSemanticTest {
daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS"); daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS");
var b=request(false,true); 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), 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())); 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(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"))); 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(), 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)); var topRows=query(AnalyticsReadMapper.class,"page",Map.of("p",top));
assertEquals(1,topRows.size());var item=(AnalyticsReadRows.Aggregate)topRows.get(0); 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()); 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"); 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()); 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()); 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()));} 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(); .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"); 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 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()); 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")); assertTrue(points.get(0).reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID"));
assertEquals("UNKNOWN",points.get(1).dataStatus());assertNull(points.get(1).observedQuantity()); 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("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()); 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)); assertTrue(unproved.stream().allMatch(point->point.outboundQuantity()==null));
assertNull(unproved.get(2).observedQuantity());assertEquals("UNKNOWN",unproved.get(2).dataStatus()); 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 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(?,?,?,?,?,?,?,?,?,?,?,?,?)")){ 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();}} 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 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");} 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());} 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);} private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);}
@SuppressWarnings("unchecked") private List<Map<String,Object>> report(){ @SuppressWarnings("unchecked") private List<Map<String,Object>> report(){
return ((List<Map<String,Object>>)(List<?>)query(DashboardReadMapper.class,"report",Map.of("p",request(),"o",options()))).stream().map(row->{ return ((List<Map<String,Object>>)(List<?>)query(DashboardReadMapper.class,"report",Map.of("p",request(),"o",options()))).stream().map(row->{
@@ -6,6 +6,15 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class SelfopBusinessBudgetTest { 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); private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0);
@Test void serializedConsumptionAndDeadlineSurviveReload() { @Test void serializedConsumptionAndDeadlineSurviveReload() {
@@ -44,6 +44,46 @@ class SelfopEgressServiceTest {
assertThat(budget().reservedHttp).isEqualTo(1); 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 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(){ @Test void allFamiliesAndFailuresConsumeOneRootAndAccountAcrossServiceRestart(){
var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES); var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES);
c=reserve(Operation.PRODUCT_INVENTORY,2,"A",null);success(c,Operation.PRODUCT_INVENTORY); c=reserve(Operation.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 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 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 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(){ @Test void businessSurpassesPriorDailyAndRootHttpCapsAcrossRestartWithoutRenewingDeadline(){
testBusiness(5000,300);cfg.setDailyRequestBudget(12000);cfg.setTransferredDailyHttpCount(12000); 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)); var rootBudget=businessBudget();rootBudget.maxHttp=5000;rootBudget.http=5000;rootBudget.confirmedHttp=5000;task.setBusinessBudgetJson(SelfopSupport.json(rootBudget));
@@ -33,10 +33,40 @@ class SelfopSalesPublisherTest {
run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);run.setWarehouseDailyReady(true); 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.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)); 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"); 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); when(locks.lockScope(1L)).thenReturn(scope); when(locks.lockRun(10L)).thenReturn(run); when(locks.lockTask(20L)).thenReturn(task);
} }
private void enableSyntheticPublication() { config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSalesPublicationEnabled(true); config.setQuantityUnit("PCS"); } private void 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()); } private void noWrites() { verifyNoInteractions(scopes,runs,tasks); assertEquals(9L,scope.getPublishedRunId()); }
@Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() { @Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() {
@@ -16,6 +16,95 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class SelfopSchedulePlannerTest { 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<SelfopSyncTask> 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<SelfopSyncTask>();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 SelfopAnalyticsProperties config = new SelfopAnalyticsProperties();
private final SelfopTaskService taskService = mock(SelfopTaskService.class); private final SelfopTaskService taskService = mock(SelfopTaskService.class);
private final SelfopExecutionService execution = mock(SelfopExecutionService.class); private final SelfopExecutionService execution = mock(SelfopExecutionService.class);
@@ -14,6 +14,37 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class SelfopTaskServiceTest { 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 SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class); private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.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); 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)); 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() { @Test void egressStopReasonSurvivesExecutorFailureHandling() {
var root=boundedRoot(7,utcNow().plusMinutes(3)); var root=boundedRoot(7,utcNow().plusMinutes(3));
var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class); var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);