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,
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
@DefaultValue("5m") Duration cooldownFallback) {
@DefaultValue("5m") Duration cooldownFallback,
@DefaultValue("false") boolean continuousBusinessCollectionEnabled) {
public LingxingEgressProperties {
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
environmentLabel = environmentLabel == null ? "test" : environmentLabel;
@@ -62,7 +62,7 @@ public final class LingxingEgressGate {
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|| permit.expiresAt().isAfter(properties.externalExclusiveUntil())) {
|| (!continuousBusiness() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) {
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
}
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state.
@@ -106,10 +106,14 @@ public final class LingxingEgressGate {
if (context == null || context.executionPurpose() != properties.collectionMode()
|| !context.wellFormed()
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())
if (properties.externalExclusiveGrantId() == null || properties.externalExclusiveGrantId().isBlank()
|| !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| (!continuousBusiness() && (properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())))
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
private boolean continuousBusiness() {
return properties.collectionMode() == CollectionMode.BUSINESS && properties.continuousBusinessCollectionEnabled();
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
@@ -44,13 +44,14 @@ baiyue:
request-timeout: 15s
egress:
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
continuous-business-collection-enabled: ${LINGXING_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false}
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
min-start-interval: 5s
cooldown-fallback: 5m
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
# Finite sessions require external-exclusive-until; continuous BUSINESS still requires the exclusive grant ID and authority.
# Missing proof/authority fails closed even when the old enabled switch is true.
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
@@ -7,12 +7,23 @@ import java.util.List;
import static org.assertj.core.api.Assertions.*;
class LingxingEgressPropertiesTest {
@Test void continuousBusinessFlagBindsOnlyWhenExplicitlyEnabled() {
String prefix = "baiyue.integration.lingxing-wms.egress";
var input = new java.util.HashMap<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) {
return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown);
return new LingxingEgressProperties(mode, "test", null, null, null, null, interval, cooldown, false);
}
@Test void businessAcceptsPositiveMillisecondIntervalsIncludingProbeSteps() {
for (long millis : new long[]{1, 1500, 2000, 3000, 5000, 300000}) {
for (long millis : new long[]{1, 1200, 1500, 2000, 3000, 5000, 300000}) {
assertThat(properties(CollectionMode.BUSINESS, Duration.ofMillis(millis), null).minStartInterval())
.isEqualTo(Duration.ofMillis(millis));
}
@@ -54,7 +54,88 @@ class LingxingEgressGateTest {
private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); }
static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); }
static LingxingEgressProperties props(CollectionMode mode, String environment) {
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null);
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null, false);
}
private LingxingEgressGate continuousGate(CollectionMode mode, String grant, Instant until) {
return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", "demo-account", grant, until,
URI.create("http://127.0.0.1"), null, null, true), ledger, clock, clock::advanceMillis);
}
@Test void continuousBusinessCrossesOldGrantExpiryButKeepsFiniteSingleUsePermits() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", NOW.minusSeconds(1));
assertThat(gate.execute(businessContext("continuous"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
clock.advanceMillis(86400000);
var restarted = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThat(restarted.execute(businessContext("next-day"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
ledger.mutation = p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1,
clock.instant().plusSeconds(31), true, true);
assertThatThrownBy(() -> restarted.execute(businessContext("too-long"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("BUDGET_PERMIT_INVALID");
assertThat(ledger.completions).hasSize(2);
}
@Test void continuousFlagNeverReplacesMatchingGrantOrFiniteValidationWindow() {
for (String grant : Arrays.asList(null, "", "other-grant")) {
var gate = continuousGate(CollectionMode.BUSINESS, grant, null);
assertThatThrownBy(() -> gate.execute(businessContext("missing-grant"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
for (Instant until : Arrays.asList(null, NOW)) {
var gate = continuousGate(CollectionMode.VALIDATION, "demo-grant", until);
assertThatThrownBy(() -> gate.execute(context("validation"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
assertThat(ledger.authorizations).isZero();
}
@Test void continuousBusinessStillPausesOnUnconfirmedCompletion() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
ledger.failComplete = true;
assertThatThrownBy(() -> gate.execute(businessContext("lost"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return true; })).hasMessageContaining("BUDGET_COMPLETION_UNCONFIRMED");
ledger.failComplete = false; clock.advanceMillis(86400000);
assertThatThrownBy(() -> gate.execute(businessContext("after-lost"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("ACCOUNT_PAUSED");
assertThat(ledger.authorizations).isEqualTo(1);
}
@Test void disabledContinuousBusinessStillRequiresUnexpiredGrant() {
var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test",
"demo-account", "demo-grant", NOW, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, false),
ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> gate.execute(businessContext("disabled"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("EXCLUSIVE_WINDOW_UNCONFIRMED");
assertThat(ledger.authorizations).isZero();
}
@Test void continuousBusinessRetains1200MillisSpacingAndOneHttpPerPermit() {
var gate = new LingxingEgressGate(new LingxingEgressProperties(CollectionMode.BUSINESS, "test",
"demo-account", "demo-grant", null, URI.create("http://127.0.0.1"), Duration.ofMillis(1200), null, true),
ledger, clock, clock::advanceMillis);
gate.execute(businessContext("first-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; });
gate.execute(businessContext("second-spaced"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; });
assertThat(Duration.between(NOW, clock.instant()).toMillis()).isGreaterThanOrEqualTo(1200);
assertThatThrownBy(() -> gate.execute(businessContext("one-send"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE");
assertThat(ledger.completions).hasSize(3).allSatisfy(c -> assertThat(c.actualHttpAttempts()).isEqualTo(1));
}
@Test void continuousBusinessCannotSendWithExpiredPermitAfterSigning() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThatThrownBy(() -> gate.execute(businessContext("expired-permit"), Operation.OUTBOUND_DETAIL, HASH,
h -> { clock.advanceMillis(30000); h.startHttp(); return true; })).hasMessageContaining("ATTEMPT_NOT_SENDABLE");
assertThat(ledger.completions).singleElement().satisfies(c -> assertThat(c.actualHttpAttempts()).isZero());
}
@Test void continuousBusinessRateCooldownStillRequiresFreshAuthorityApproval() {
var gate = continuousGate(CollectionMode.BUSINESS, "demo-grant", null);
assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("continuous-rate"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); h.rateLimited(null); throw rateLimit(); }));
assertThatThrownBy(() -> gate.execute(businessContext("during-cooldown"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("ACCOUNT_PAUSED");
clock.advanceMillis(300000);ledger.denial = new AuthorizationDenial("ACCOUNT_REQUIRES_REVIEW", false, null);
assertThatThrownBy(() -> gate.execute(businessContext("authority-denied"), Operation.WAREHOUSES, HASH, h -> true))
.hasMessageContaining("BUDGET_AUTHORIZATION_DENIED");
assertThat(ledger.completions).hasSize(1);
ledger.denial = null;
assertThat(gate.execute(businessContext("fresh-approved"), Operation.WAREHOUSES, HASH,
h -> { h.startHttp(); return h.metadata(); }).accountPaused()).isFalse();
assertThat(ledger.completions).hasSize(2);
}
@Test void failureCompletionPreservesNullableTransportAndSourceCodes() {
for (Integer status : new Integer[] {null, 503}) {
@@ -72,8 +153,9 @@ class LingxingEgressGateTest {
}
}
@Test void defaultOffAndUnknownBusinessEnvironmentsNeverAuthorize() {
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null);
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null, false);
assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF);
assertThat(defaults.continuousBusinessCollectionEnabled()).isFalse();
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis);
assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF");
@@ -193,7 +275,7 @@ class LingxingEgressGateTest {
} finally { release.countDown(); executor.shutdownNow(); }
}
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() {
assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new LingxingEgressProperties(CollectionMode.VALIDATION, null, null, null, null, null, Duration.ofMillis(4999), null, false)).isInstanceOf(IllegalArgumentException.class);
assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
}
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {
@@ -43,7 +43,7 @@ class LingxingHttpBudgetAuthorityTest {
@AfterEach void stop() { server.stop(0); }
private LingxingHttpBudgetAuthority authority(String serviceToken) {
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null), mapper, serviceToken);
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false), mapper, serviceToken);
}
private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); }
private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1,
@@ -86,7 +86,7 @@ class LingxingHttpBudgetAuthorityTest {
}
@Test void missingServerConfiguredAuthorityPreventsHttp() {
var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null), mapper, "synthetic-token");
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null, false), mapper, "synthetic-token");
assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero();
}
@Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception {
@@ -254,7 +254,7 @@ class LingxingWmsClientTest {
var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
"synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2));
var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null,
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null);
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null, false);
var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) {
@Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); }
};
@@ -17,6 +17,8 @@ public interface SelfopCoordinationMapper {
SelfopSyncRun lockRun(long id);
@Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}")
List<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}))")
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')")
@@ -13,6 +13,8 @@ import java.time.LocalDateTime;
@ConfigurationProperties(prefix = "baiyue.self-operated-analytics")
public class SelfopAnalyticsProperties {
private String collectionMode = "OFF";
/** Explicit account-owner continuous BUSINESS planner/egress mode; off by default. */
private boolean continuousBusinessCollectionEnabled = false;
private String environment = "test";
private String exclusiveGrantId;
private LocalDateTime exclusiveGrantExpiresAtUtc;
@@ -28,14 +28,46 @@ public final class SelfopCollectionPolicy {
requireExecution(config,"BUSINESS");
require("production".equals(config.getEnvironment()),"SELFOP_AUTOMATIC_PLANNING_ENVIRONMENT_REJECTED");
}
public static final String CONTINUOUS_PLANNER="SELFOP_CONTINUOUS_PLANNER";
public static boolean continuousBusiness(SelfopAnalyticsProperties config){
return config.isContinuousBusinessCollectionEnabled()&&"BUSINESS".equals(config.getCollectionMode())&&businessEnvironment(config);
}
/** Strict persisted server identity; enabling continuous mode never admits old cron slots. */
public static boolean continuousBusinessRoot(SelfopSyncTask task){
if(task==null||!"BUSINESS".equals(task.getExecutionPurpose())||task.getUpstreamTaskId()!=null
||task.getValidationRootTaskId()!=null||!CONTINUOUS_PLANNER.equals(task.getRequestedBy())||task.getScopeHash()==null||!task.getScopeHash().matches("[a-f0-9]{64}"))return false;
String key=task.getRequestKey();if(key==null)return false;
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())&&"BACKFILL".equals(task.getTaskType())&&task.getDiscoveryScopeId()!=null
&&task.getQueryFromUtc()!=null&&task.getQueryUntilUtc()!=null&&task.getQueryUntilUtc().equals(task.getQueryFromUtc().plusDays(1))){
var local=task.getQueryFromUtc().plusHours(8);
if(!local.toLocalTime().equals(java.time.LocalTime.MIDNIGHT)||!key.equals("continuous:outbound:"+local.toLocalDate()+":"+task.getScopeHash().substring(0,12)))return false;
try{Map<?,?> q=read(task.getQueryScopeJson(),Map.class);
return q!=null&&q.get("parentTaskId")==null&&q.get("deliveryNo")==null&&q.get("customerCode")==null
&&local.toString().equals(q.get("startTime"))&&task.getQueryUntilUtc().plusHours(8).toString().equals(q.get("endTime"));
}catch(RuntimeException invalid){return false;}
}
if(!"WAREHOUSE_SYNC".equals(task.getTaskRole())||!"SCHEDULED".equals(task.getTaskType())||task.getScopeId()==null
||task.getDiscoveryScopeId()!=null||task.getWarehouseCode()==null||task.getPlannedAtUtc()==null
||task.getQueryFromUtc()!=null||task.getQueryUntilUtc()!=null
||!key.equals("continuous:inventory:"+task.getWarehouseCode()+":"+task.getPlannedAtUtc()+":"+task.getScopeHash().substring(0,12)))return false;
try{Map<?,?> q=read(task.getQueryScopeJson(),Map.class);
return q!=null&&"INVENTORY".equals(q.get("dataFamily"))&&"Asia/Shanghai".equals(q.get("businessTimezone"))
&&java.util.Objects.equals(task.getSourceAccount(),q.get("sourceAccount"))&&java.util.Objects.equals(task.getWarehouseCode(),q.get("warehouseCode"));
}catch(RuntimeException invalid){return false;}
}
/** TEST may poll only persisted manual roots and their existing routing/refresh descendants. */
public static boolean controlledBusinessTask(SelfopSyncTask task,java.util.function.Function<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;
String account=task.getSourceAccount();var seen=new java.util.HashSet<Long>();
for(int depth=0;task!=null&&depth<64;depth++) {
if(!"BUSINESS".equals(task.getExecutionPurpose())||task.getValidationRootTaskId()!=null
||!java.util.Objects.equals(account,task.getSourceAccount())||"SCHEDULED".equals(task.getTaskType()))return false;
||!java.util.Objects.equals(account,task.getSourceAccount()))return false;
if(task.getId()!=null&&!seen.add(task.getId()))return false;
if(allowContinuous&&continuousBusinessRoot(task))return true;
if("SCHEDULED".equals(task.getTaskType())&&(!allowContinuous||task.getUpstreamTaskId()==null))return false;
String key=task.getRequestKey();
if(key!=null&&key.startsWith("manual:")&&task.getRequestedBy()!=null&&!task.getRequestedBy().isBlank())return true;
Long parent=task.getUpstreamTaskId();
@@ -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 {
private SelfopStageEstimate() { }
public static final int WINDOW_SECONDS=180, MIN_SECONDS=60, MIN_ADVANCES=3,
BUCKET_SECONDS=10, MAX_POINTS=20, STALE_SECONDS=60, VALID_SECONDS=10, MAX_BYTES=1024;
BUCKET_SECONDS=10, MAX_POINTS=20, STALE_SECONDS=60, VALID_SECONDS=30, MAX_BYTES=1024;
public record Estimate(String status,String progressIdentity,Long completed,Long total,
Instant sampleFrom,Instant sampleUntil,Long completedDelta,BigDecimal ratePerSecond,
Long remainingSeconds,Instant estimatedAt,Instant validUntil,String unavailableReason) { }
@@ -23,7 +23,7 @@ public final class SelfopStageEstimate {
return hash(values);
}
/** A reuse/uncertain checkpoint resets the baseline, never contributes throughput. */
/** Reused count increases reset the baseline; unchanged bookkeeping is not new work. */
public static void checkpoint(Map<String,Object> p,Instant now,boolean realWork) {
try {
Long completed=number(p.get("completed")),total=number(p.get("total"));
@@ -32,7 +32,8 @@ public final class SelfopStageEstimate {
}
String id=identity(p);long second=now.getEpochSecond();
var old=map(p.get("etaSamples"));var points=points(old.get("p"));
if(!id.equals(old.get("i"))||!realWork||points.isEmpty()
if(!id.equals(old.get("i"))||points.isEmpty()
||!realWork&&!completed.equals(points.get(points.size()-1).get(1))
||second<points.get(points.size()-1).get(0)
||completed<points.get(points.size()-1).get(1)
||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) {
long second=now.getEpochSecond(),lastAdvance=numberOr(sample.get("a"),0);
points.removeIf(point->second-point.get(0)>WINDOW_SECONDS);
// Select an anchor that stays inside the window through the read validity
// period, instead of expiring each time the oldest 10-second bucket rolls out.
points.removeIf(point->second-point.get(0)>WINDOW_SECONDS-VALID_SECONDS);
if(points.isEmpty()||second-lastAdvance>=STALE_SECONDS)reason="NO_RECENT_PROGRESS";
else {
var first=points.get(0);var last=points.get(points.size()-1);
@@ -9,6 +9,22 @@ import java.util.List;
/** Database aggregation, with bounded result and insert batches. No raw historical rows enter Java. */
@Mapper
public interface OutboundAggregateMapper {
@Select("""
SELECT MIN(from_at) AS covered_from_utc,MAX(until_at) AS covered_until_utc FROM (
SELECT from_at,until_at,SUM(CASE WHEN previous_until IS NULL OR from_at>previous_until THEN 1 ELSE 0 END)
OVER (ORDER BY from_at,until_at ROWS UNBOUNDED PRECEDING) AS island FROM (
SELECT from_at,until_at,MAX(until_at) OVER
(ORDER BY from_at,until_at ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS previous_until FROM (
SELECT query_from_utc AS from_at,query_until_utc AS until_at FROM ll_selfop_sync_task
WHERE scope_id=#{scopeId} AND scope_hash=#{scopeHash} AND execution_purpose='BUSINESS'
AND validation_root_task_id IS NULL AND task_role='WAREHOUSE_SYNC' AND apply_state='APPLIED'
AND apply_completed_at_utc IS NOT NULL AND query_from_utc IS NOT NULL AND query_until_utc>query_from_utc
AND JSON_UNQUOTE(JSON_EXTRACT(routing_manifest_json,'$.directoryComplete'))='true'
) accepted
) ordered_intervals
) islands GROUP BY island ORDER BY MIN(from_at) LIMIT 501
""")
List<SelfopSyncRun> provenCoverageIslands(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash);
@Select("""
SELECT COUNT(*) FROM ll_selfop_outbound_order o
LEFT JOIN ll_selfop_outbound_item i ON i.order_id=o.id
@@ -53,6 +53,7 @@ public class OutboundCandidateTransaction {
OutboundPolicy.DayCoverage days=OutboundPolicy.completeDays(coverage.from(),coverage.until(),scope.getBusinessTimezone());
run.setCompleteDayFrom(days.from());run.setCompleteDayUntil(days.until());run.setCoverageStatus(coverage.until()==null?"UNKNOWN":"COMPLETE");
run.setCalculatedFactRevision(scope.getFactRevision());run.setBuildStartedAtUtc(SelfopSupport.utcNow());run.setStatus("BUILDING");
SelfopSalesCoverage.freeze(run,provenIslands(scope));
Checkpoint result=new Checkpoint(runId,"DAILY",null,null,null,0,0,0);save(held,result);return result;
}
@@ -106,13 +107,20 @@ public class OutboundCandidateTransaction {
if(published==null || !Boolean.TRUE.equals(published.getWarehouseDailyReady()) || !"PUBLISHED".equals(published.getStatus()) || !"BUSINESS".equals(published.getExecutionPurpose())
|| published.getBuildStartedAtUtc()==null || !Objects.equals(scopeId,published.getScopeId()) || !Objects.equals(scope.getScopeHash(),published.getScopeHash())
|| !Objects.equals(scope.getFactRevision(),published.getCalculatedFactRevision()) || !Objects.equals(scope.getFactRevision(),scope.getPublishedFactRevision())
|| !Objects.equals(SelfopSupport.read(held.run().getScopeSnapshotJson(),Map.class),SelfopSupport.read(published.getScopeSnapshotJson(),Map.class)))return null;
|| !Objects.equals(SelfopSalesCoverage.settings(held.run().getScopeSnapshotJson()),SelfopSalesCoverage.settings(published.getScopeSnapshotJson())))return null;
var proof=SelfopSalesCoverage.evidence(published);
if(proof==null||!Objects.equals(proof.intervals(),provenIslands(scope)))return null;
OutboundCandidateBuilder.Coverage next=coverage(scope);
if(!Objects.equals(next.from(),published.getCoveredFromUtc()) || !Objects.equals(next.until(),published.getCoveredUntilUtc()))return null;
// Content-identical accepted details still change receipt/run evidence; conservatively rebuild them.
return aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),published.getBuildStartedAtUtc())==0?published.getId():null;
}
private List<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) {
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)
@@ -48,11 +48,14 @@ public class AnalyticsQueryService {
if(!Objects.equals(unit,row.quantityUnit()))valid=false;
}
Instant evidence=earliest(sp,ip);
var coverage=SalesQueryCoverage.intervals(s.sales);
List<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)){
Daily row=daily.get(day);
var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,s.fullFrom,s.fullUntil,
s.context.salesCoveredFrom(),s.context.salesCoveredUntil(),evidence,row==null?null:row.outboundQuantity(),
var covered=SalesQueryCoverage.at(row!=null||evidence==null?coverage:
coverage.stream().filter(i->i.until().toInstant(ZoneOffset.UTC).isAfter(evidence)).toList(),day);
var point=SalesBucketPolicy.evaluate(new SalesBucketPolicy.Input(day,s.range.anchorDate(),BUSINESS_ZONE,SalesQueryCoverage.fullFrom(covered),SalesQueryCoverage.fullUntil(covered),
covered==null?null:utc(covered.from()),covered==null?null:utc(covered.until()),evidence,row==null?null:row.outboundQuantity(),
row==null?null:row.outboundOrderCount(),valid,s.sourceKnown,s.context.rebuildRequired(),"STALE".equals(s.context.salesStatus())));
evaluated.add(point);
points.add(new AnalyticsResponses.Point(day,point.outboundQuantity(),point.observedQuantity(),point.dataStatus(),
@@ -113,7 +116,8 @@ public class AnalyticsQueryService {
null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses));
}
private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;}
private static LocalDate historyStart(Snapshot s){return s.context.salesCoveredFrom()==null?null:s.context.salesCoveredFrom().atZone(BUSINESS_ZONE).toLocalDate();}
private static LocalDate historyStart(Snapshot s){return SalesQueryCoverage.intervals(s.sales).stream()
.map(i->utc(i.from()).atZone(BUSINESS_ZONE).toLocalDate()).min(LocalDate::compareTo).orElse(null);}
private static BigDecimal divide30(BigDecimal n){return n==null?null:n.divide(BigDecimal.valueOf(30),12,java.math.RoundingMode.HALF_UP);}
private static BigDecimal decimal(Map<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();}
@@ -162,11 +166,12 @@ public class AnalyticsQueryService {
if(!"VALID".equals(result.status()))reasons.add(result.status());
if(r.inventoryReason()!=null)reasons.add(r.inventoryReason());
BigDecimal today=r.todayQuantity();
if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null&&s.context.salesCoveredUntil()!=null){
if(!"HOT".equals(kind)&&today==null&&s.sourceKnown&&Boolean.TRUE.equals(r.unitValid())&&evidence!=null){
Instant start=s.range.anchorDate().atStartOfDay(BUSINESS_ZONE).toInstant();
Instant end=s.range.anchorDate().plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant();
Instant lower=evidence.isAfter(start)?evidence:start;
if(s.context.salesCoveredFrom()!=null&&s.context.salesCoveredFrom().isAfter(lower))lower=s.context.salesCoveredFrom();
if(lower.isBefore(s.context.salesCoveredUntil()))today=BigDecimal.ZERO;
if(lower.isBefore(end)&&SalesQueryCoverage.intervals(s.sales).stream()
.anyMatch(i->utc(i.from()).isBefore(end)&&utc(i.until()).isAfter(lower)))today=BigDecimal.ZERO;
}
return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),r.quantityUnit(),
r.windowQuantity(),r.windowSkuOrderCount(),today,stockValid?r.physicalQuantity():null,stockValid?r.availableQuantity():null,
@@ -207,7 +212,8 @@ public class AnalyticsQueryService {
boolean rebuild=salesScope!=null&&(Boolean.TRUE.equals(salesScope.rebuildRequired())
||!Objects.equals(salesScope.factRevision(),salesScope.publishedFactRevision())||mapper.applyBarriers(q.sourceAccount(),q.warehouseCode())>0);
boolean known=sales!=null&&BUSINESS_ZONE.getId().equals(sc.path("sourceTimezone").asText());
String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(sales.coveredUntilUtc(),threshold(sc))?"STALE":"VALID";
LocalDateTime latestCoverage=SalesQueryCoverage.intervals(sales).stream().map(i->i.until()).max(LocalDateTime::compareTo).orElse(null);
String salesState=sales==null?"UNKNOWN":!known?"SOURCE_TIMEZONE_UNKNOWN":stale(latestCoverage,threshold(sc))?"STALE":"VALID";
if(rebuild)salesState="REBUILD_REQUIRED";
String inventoryState=inventory==null?"UNKNOWN":stale(inventory.fetchFinishedAtUtc(),threshold(ic))?"STALE":"VALID";
LocalDate fullFrom=sales==null?null:sales.completeDayFrom(),fullUntil=sales==null?null:sales.completeDayUntil();
@@ -240,7 +246,7 @@ public class AnalyticsQueryService {
s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()),
s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown,
s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()),
s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind);
s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind,SalesQueryCoverage.intervals(s.sales));
}
Instant inventoryFreshUntil(Snapshot s){
return s.inventory()==null||s.inventory().fetchFinishedAtUtc()==null?null:
@@ -255,12 +261,12 @@ public class AnalyticsQueryService {
||!scope.warehouseCode().equals(run.warehouseCode())||!scope.scopeHash().equals(run.scopeHash())
||requested!=null&&!run.scopeHash().equals(hash)||!"BUSINESS".equals(run.executionPurpose())||!"PUBLISHED".equals(run.status()))
throw new BizException("版本不可用或范围不符,请刷新整页");
if(!"COMPLETE".equals(run.coverageStatus()))throw new BizException("发布版本缺少完整覆盖证明");
if(!"COMPLETE".equals(run.coverageStatus())&&SalesQueryCoverage.intervals(run).isEmpty())throw new BizException("发布版本缺少完整覆盖证明");
return run;
}
private Profile profile(Snapshot s,Run run){return run==null?null:mapper.profile(run.id(),s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku());}
private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&s.sales!=null&&s.sales.coveredFromUtc()!=null&&s.sales.coveredUntilUtc()!=null
&&s.sales.coveredFromUtc().isBefore(s.sales.coveredUntilUtc())&&s.fullFrom!=null&&s.fullUntil!=null&&!from.isBefore(s.fullFrom)&&!until.isAfter(s.fullUntil);}
private static boolean complete(Snapshot s,LocalDate from,LocalDate until){return s.sourceKnown&&!until.isAfter(s.range.anchorDate())
&&SalesQueryCoverage.complete(SalesQueryCoverage.intervals(s.sales),from,until);}
private JsonNode config(Run r){if(r==null)return json.createObjectNode();try{JsonNode n=json.readTree(r.scopeSnapshotJson());if(n==null||!n.isObject())throw new IllegalArgumentException();return n;}catch(Exception e){throw new BizException("版本配置快照无效");}}
private static int threshold(JsonNode c){int n=c.path("staleAfterSeconds").asInt();if(n<=0)throw new BizException("版本过期阈值无效");return n;}
private boolean stale(LocalDateTime t,int seconds){return t==null||utc(t).isAfter(clock.instant())||!clock.instant().isBefore(utc(t).plusSeconds(seconds));}
@@ -30,6 +30,7 @@ public final class AnalyticsReadSql {
}
private static String cte(AnalyticsSqlRequest request){
String kind=request.kind();
if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request);
if("HOT".equals(kind))return hotSalesCte();
if("SLOW".equals(kind))return rankingCte();
String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')";
@@ -152,6 +153,35 @@ public final class AnalyticsReadSql {
}
return sql;
}
private static String coveredSummary(AnalyticsSqlRequest p){
var intervals=new java.util.ArrayList<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
* window is contained in this scan, so the same unit proof covers both windows. */
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,
LocalDate from,LocalDate until,LocalDate anchor,LocalDate windowFrom,LocalDate completeFrom,LocalDate completeUntil,
LocalDateTime coveredFrom,LocalDateTime coveredUntil,boolean salesKnown,boolean formalSales,boolean completeWindow,
boolean complete30,boolean stockFresh,long offset,int size,String kind) {}
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"
:observation==null?"UNKNOWN":!clock.instant().isBefore(observation.plusSeconds(scope.staleAfterSeconds()))?"STALE":"FRESH";
rows.add(new CollectionResponses.Warehouse(warehouse,warehouse,family,enabled,publication,blocked,
enabled&&"production".equals(config.getEnvironment())?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()),
enabled&&("production".equals(config.getEnvironment())||continuousBusiness())?nextSchedule(warehouse,family):null,clock.instant(),scope==null?null:utc(scope.initialFromUtc()),
scope==null?null:utc(scope.initialUntilUtc()),initial,published==null?null:utc(published.coveredFromUtc()),
published==null?null:utc(published.coveredUntilUtc()),last==null?null:last.taskNo(),last==null||context.latest().get(last.id())==null?null:utc(context.latest().get(last.id()).finishedAtUtc()),
published==null?null:utc(published.publishedAtUtc()),"INVENTORY".equals(family)&&published!=null?utc(published.fetchStartedAtUtc()):null,
@@ -234,7 +234,12 @@ public class CollectionQueryService {
resultState,t.resultPublishedRunId(),utc(t.nextRetryAtUtc()),
blocked==null?List.of("RETRY"):List.of(),blocked,utc(t.progressUpdatedAtUtc()),progress(t.progressJson()),
validation(root==null?t:root),attempt,dependency,waiting,
"WAITING_CONFIRMED".equals(dependency)?parent.taskNo():t.taskNo(),stageEstimate(t,latest,attempt,waiting));
"WAITING_CONFIRMED".equals(dependency)?parent.taskNo():t.taskNo(),stageEstimate(t,latest,attempt,waiting),utc(t.createdAt()),taskElapsed(t));
}
private Long taskElapsed(Task task) {
Instant start=utc(task.createdAt());
Instant end=ACTIVE.contains(task.status())?clock.instant():utc(task.updatedAt());
return start==null||end==null||end.isBefore(start)?null:Duration.between(start,end).getSeconds();
}
private String dependencyState(Task task,Task parent) {
if(task.upstreamTaskId()==null)return "WAITING_SOURCE".equals(task.status())?"WAITING_UNCONFIRMED":"NONE";
@@ -280,7 +285,7 @@ public class CollectionQueryService {
if(!"WAREHOUSE_SYNC".equals(task.taskRole()))return "ACCOUNT_SHARED_TASK";
String disabled=executionBlock(task.warehouseCode(),protection);if(disabled!=null)return disabled;
if(!"BUSINESS".equals(config.getCollectionMode()))return "COLLECTION_MODE_NOT_BUSINESS";
if("test".equals(config.getEnvironment())&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED";
if("test".equals(config.getEnvironment())&&!continuousBusiness()&&"SCHEDULED".equals(task.taskType()))return "TEST_MANUAL_TASK_REQUIRED";
if("test".equals(config.getEnvironment())) {
String budgetBlock=businessBudgetBlock(task);if(budgetBlock!=null)return budgetBlock;
}
@@ -295,13 +300,16 @@ public class CollectionQueryService {
JsonNode budget=boundedObject(task.businessRootBudgetJson());
Long maximum=number(budget,"maxHttp"),reserved=number(budget,"http"),confirmed=number(budget,"confirmedHttp");
Instant started=budgetInstant(budget,"startedAtUtc"),deadline=budgetInstant(budget,"deadlineAtUtc");
boolean continuous=budget!=null&&budget.path("continuous").isBoolean()&&budget.path("continuous").booleanValue();
if(continuous&&!continuousBusiness())return "CONTINUOUS_COLLECTION_DISABLED";
if(budget==null||maximum==null||maximum<0||maximum>Integer.MAX_VALUE||reserved==null||reserved>Integer.MAX_VALUE
||confirmed==null||confirmed>reserved||started==null||deadline==null||!deadline.isAfter(started))
||confirmed==null||confirmed>reserved||started==null
||(continuous?budget.hasNonNull("deadlineAtUtc"):deadline==null||!deadline.isAfter(started)))
return "BUSINESS_SESSION_BUDGET_MISSING";
JsonNode stopped=budget.get("stopReason");
if(stopped!=null&&!stopped.isNull())
return "BUSINESS_SESSION_STOPPED";
if(!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE";
if(!continuous&&!clock.instant().isBefore(deadline)&&!Boolean.TRUE.equals(task.businessLocalWorkReady()))return "BUSINESS_SESSION_DEADLINE";
return null;
}
private CollectionResponses.Attempt attemptView(Attempt run,Task task,boolean retryAllowed) {
@@ -346,14 +354,21 @@ public class CollectionQueryService {
if(protection.accountPauseReason()!=null&&!protection.accountPauseReason().isBlank())return "ACCOUNT_PAUSED";
if(protection.accountCooldownUntil()!=null&&clock.instant().isBefore(protection.accountCooldownUntil()))return "ACCOUNT_COOLDOWN";
if(!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified()
||config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))return "EXCLUSIVE_GRANT_NOT_VALID";
||(!continuousBusiness()&&(config.getExclusiveGrantExpiresAtUtc()==null||!clock.instant().isBefore(utc(config.getExclusiveGrantExpiresAtUtc())))))return "EXCLUSIVE_GRANT_NOT_VALID";
if("BUSINESS".equals(config.getCollectionMode()))return null;
if(config.getDailyRequestBudget()<=0)return "DAILY_BUDGET_NOT_CONFIGURED";
if(protection.reservedHttpRequests()!=null&&clock.instant().atZone(BUSINESS_ZONE).toLocalDate().toString().equals(protection.budgetDate())
&&protection.reservedHttpRequests()>=config.getDailyRequestBudget())return "DAILY_BUDGET_EXHAUSTED";
return null;
}
private boolean continuousBusiness() {
return com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy.continuousBusiness(config);
}
private Instant nextSchedule(String warehouse,String family) {
if(continuousBusiness()&&"OUTBOUND".equals(family)) {
var slot=clock.instant().atZone(BUSINESS_ZONE).toLocalDate().atStartOfDay(BUSINESS_ZONE).plusMinutes(5);
return (slot.toInstant().isAfter(clock.instant())?slot:slot.plusDays(1)).toInstant();
}
var w=config.getWarehouses().get(warehouse);
try {var next=CronExpression.parse("OUTBOUND".equals(family)?w.getOutboundCron():w.getInventoryCron()).next(clock.instant().atZone(BUSINESS_ZONE));return next==null?null:next.toInstant();}
catch(IllegalArgumentException ex){return null;}
@@ -31,7 +31,7 @@ public final class CollectionResponses {
List<String> allowedActions, String actionBlockReason, Instant progressUpdatedAt,
Map<String,Object> progress, Validation validation, Attempt latestAttempt,
String dependencyState, String blockingReason, String progressSourceTaskNo,
SelfopStageEstimate.Estimate stageEstimate) {}
SelfopStageEstimate.Estimate stageEstimate, Instant createdAt, Long elapsedSeconds) {}
public record TaskDetail(Task task, Task sharedUpstream, Protection protection, Instant observedAt) {}
public record Attempt(Long runId, String taskNo, Integer attemptNo, String status, String phase,
String executionPurpose, Instant startedAt, Instant finishedAt, Instant phaseStartedAt,
@@ -22,18 +22,18 @@ public class DashboardQueryService {
throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询");
var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate();
var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom,
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD");
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD",base.coverage());
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);
var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive());
var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList();
Map<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 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;
if(!c.rangeStartDate().equals(fixedFrom)||!c.rangeEndDateExclusive().equals(fixedUntil)||!base.complete30()){
var hotRequest=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),c.rangeStartDate(),c.rangeEndDateExclusive(),base.anchor(),fixedFrom,
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD");
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.completeWindow(),base.complete30(),base.stockFresh(),0,10,"HOT_DASHBOARD",base.coverage());
var hotReport=mapper.report(hotRequest,o).stream().map(DashboardQueryService::normalize).toList();
hotStats=hotReport.get(0);hotRanked=hotReport.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList();
}
@@ -8,20 +8,23 @@ import java.util.*;
/** Warehouse coverage is independent of SKU existence and inventory observation eligibility. */
public final class DashboardSalesPolicy {
private DashboardSalesPolicy(){}
public static List<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);
List<SalesBucketPolicy.Point> result=new ArrayList<>();ZoneId zone=ZoneId.of("Asia/Shanghai");
for(LocalDate day=from;day.isBefore(until);day=day.plusDays(1)){
var actual=days.get(day);Instant start=day.atStartOfDay(zone).toInstant(),end=day.plusDays(1).atStartOfDay(zone).toInstant();
Instant lower=c.salesCoveredFrom()==null?null:(c.salesCoveredFrom().isAfter(start)?c.salesCoveredFrom():start);
Instant upper=c.salesCoveredUntil()==null?null:(c.salesCoveredUntil().isBefore(end)?c.salesCoveredUntil():end);
var interval=SalesQueryCoverage.at(intervals,day);
Instant lower=interval==null?null:(interval.from().toInstant(ZoneOffset.UTC).isAfter(start)?interval.from().toInstant(ZoneOffset.UTC):start);
Instant upper=interval==null?null:(interval.until().toInstant(ZoneOffset.UTC).isBefore(end)?interval.until().toInstant(ZoneOffset.UTC):end);
boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper);
boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0;
// Global recency describes how far collection has progressed, not whether an
// already published historical day has complete coverage. Keep STALE on context.
boolean trusted=("VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus()))&&!c.rebuildRequired();
boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&c.completeDayFrom()!=null&&c.completeDayUntil()!=null
&&!day.isBefore(c.completeDayFrom())&&day.isBefore(c.completeDayUntil())&&intersects&&lower.equals(start)&&upper.equals(end);
boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&SalesQueryCoverage.complete(intervals,day,day.plusDays(1));
BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null;
if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO;
String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List<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. */
public int maxHttp;
public int http, confirmedHttp;
public boolean continuous;
public LocalDateTime startedAtUtc, deadlineAtUtc;
public String stopReason;
@@ -20,9 +21,15 @@ public final class SelfopBusinessBudget {
return SelfopSupport.json(budget);
}
public static String freezeContinuous(LocalDateTime now) {
if(now==null)throw new IllegalArgumentException("BUSINESS_SESSION_BUDGET_INVALID");
var budget=new SelfopBusinessBudget();budget.startedAtUtc=now;budget.continuous=true;
return SelfopSupport.json(budget);
}
public void validate() {
if (http < 0 || confirmedHttp < 0 || confirmedHttp > http
|| startedAtUtc == null || deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc)
|| startedAtUtc == null || (continuous ? deadlineAtUtc != null : deadlineAtUtc == null || !deadlineAtUtc.isAfter(startedAtUtc))
|| stopReason != null && !stopReason.matches("[A-Z0-9_]{1,64}"))
throw new EgressRejectedException("BUSINESS_SESSION_BUDGET_INVALID", false, null);
}
@@ -30,7 +37,7 @@ public final class SelfopBusinessBudget {
public String limit(LocalDateTime now) {
validate();
if (stopReason != null) return stopReason;
if (!now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE";
if (!continuous && !now.isBefore(deadlineAtUtc)) return "BUSINESS_SESSION_DEADLINE";
return null;
}
}
@@ -18,9 +18,9 @@ public class SelfopEgressService {
if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID");
LocalDateTime now=now();gate(context.sourceAccount(),context.executionPurpose(),context.exclusiveGrantId(),now);
if(operation==null||requestHash==null||!requestHash.matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_INVALID");
var account=account(context.sourceAccount());var budget=budget(account,now);save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount());
var account=account(context.sourceAccount());var budget=budget(account,now,context.executionPurpose());save(account,budget);var root=root(context.rootTaskId(),context.executionPurpose(),context.sourceAccount());
var task=mapper.lockTask(context.taskId());var run=mapper.lockRun(context.runId());
SelfopBusinessBudget business=testBusiness(context.executionPurpose())?business(root):null;
SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null;
if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}}
verify(task,run,root,account,now);
checkAccount(account,budget,now);
@@ -42,9 +42,9 @@ public class SelfopEgressService {
if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8));
var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId());
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds())));
if(pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
if(!continuousBusiness(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc;
if(business!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.deadlineAtUtc;
if(business!=null&&business.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(business.deadlineAtUtc))pending.expiresAtUtc=business.deadlineAtUtc;
budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now;
if(validation!=null){validation.consume(operation,warehouseCode,detailKey);root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);}
if(business!=null){business.http++;saveBusiness(root,business);}
@@ -53,15 +53,15 @@ public class SelfopEgressService {
@Transactional(noRollbackFor=EgressRejectedException.class)
public AttemptPermit authorize(AuthorizationRequest request){
requireRequest(request);var c=request.context();var now=now();gate(c.sourceAccount(),c.executionPurpose(),c.exclusiveGrantId(),now);
var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pendingForAuthorization(budget,c,request.operation());
var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pendingForAuthorization(budget,c,request.operation());
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
var task=mapper.lockTask(pending.taskId);var run=mapper.lockRun(pending.runId);
if(testBusiness(c.executionPurpose())){
if(hasBusinessSession(root)){
var b=business(root);verifyBusinessTask(task,root);
// Authorization preserves the root time/stop fence independently of request accounting.
String limit=b.stopReason!=null?b.stopReason:!now.isBefore(b.deadlineAtUtc)?"BUSINESS_SESSION_DEADLINE":null;
String limit=b.limit(now);
if(limit!=null){stopBusiness(root,b,limit,now);throw reject(limit);}
if(pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc;
if(b.deadlineAtUtc!=null&&pending.expiresAtUtc.isAfter(b.deadlineAtUtc))pending.expiresAtUtc=b.deadlineAtUtc;
}
verify(task,run,root,account,now);
if(pending.consumed||pending.completed||!now.isBefore(pending.expiresAtUtc))throw reject("EGRESS_PERMIT_REPLAY_OR_EXPIRED");
@@ -74,7 +74,7 @@ public class SelfopEgressService {
@Transactional(noRollbackFor=EgressRejectedException.class)
public void complete(CompletionRequest request){
if(request==null||request.context()==null||request.actualHttpAttempts()<0||request.actualHttpAttempts()>1)throw reject("EGRESS_COMPLETION_INVALID");
var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now);var pending=pending(budget,c,request.operation(),request.requestHash());
var c=request.context();var now=now();var account=account(c.sourceAccount());var budget=budget(account,now,c.executionPurpose());var pending=pending(budget,c,request.operation(),request.requestHash());
String completionHash=SelfopSupport.hash(request);
if(pending.completed&&!pending.abandoned){
if(!Objects.equals(pending.completionHash,completionHash))throw reject("EGRESS_COMPLETION_CONFLICT");
@@ -85,7 +85,8 @@ public class SelfopEgressService {
var root=root(parseId(c.rootTaskId()),c.executionPurpose(),c.sourceAccount());
SelfopValidationBudget validation="VALIDATION".equals(c.executionPurpose())?validation(root):null;
if(validation!=null){validation.confirmedHttp+=request.actualHttpAttempts();root.setValidationBudgetJson(SelfopSupport.json(validation));mapper.saveRoot(root);}
if(testBusiness(c.executionPurpose())){var b=business(root);b.confirmedHttp+=request.actualHttpAttempts();b.validate();saveBusiness(root,b);}
// An already authorized response remains accountable after continuous mode is disabled.
if(hasBusinessSession(root)){var b=business(root);b.confirmedHttp+=request.actualHttpAttempts();b.validate();saveBusiness(root,b);}
boolean auth=Integer.valueOf(401).equals(request.upstreamStatus())||Integer.valueOf(403).equals(request.upstreamStatus())
||"LINGXING_WMS_AUTHORIZATION_REJECTED".equals(request.errorCode())||"LINGXING_WMS_SIGNATURE_REJECTED".equals(request.errorCode());
boolean rate=!auth&&(Integer.valueOf(429).equals(request.upstreamStatus())||Integer.valueOf(31001).equals(request.sourceCode())
@@ -126,16 +127,21 @@ public class SelfopEgressService {
if("OFF".equals(config.getCollectionMode())||!Objects.equals(config.getCollectionMode(),purpose)||!("VALIDATION".equals(purpose)||"BUSINESS".equals(purpose)))throw reject("EGRESS_MODE_REJECTED");
if("BUSINESS".equals(purpose)&&!("production".equals(config.getEnvironment())||"test".equals(config.getEnvironment())))throw reject("BUSINESS_ENVIRONMENT_REJECTED");
if(!Objects.equals(config.getSourceAccount(),account)||account==null||account.isBlank())throw reject("SOURCE_ACCOUNT_MISMATCH");
if(grant==null||!Objects.equals(grant,config.getExclusiveGrantId())||config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED");
if(grant==null||grant.isBlank()||!Objects.equals(grant,config.getExclusiveGrantId())||(!continuousBusiness(purpose)&&(config.getExclusiveGrantExpiresAtUtc()==null||!now.isBefore(config.getExclusiveGrantExpiresAtUtc())))||!config.isExclusiveHandoffVerified()||!config.isPriorEgressClosed()||!config.isInflightDrained()||!config.isTransferredBudgetVerified())throw reject("EXCLUSIVE_HANDOFF_UNCONFIRMED");
if(("VALIDATION".equals(purpose)&&config.getDailyRequestBudget()<1)||config.getBusinessMinRequestIntervalMs()<1||config.getRequestBudget()<1||config.getTimeBudgetSeconds()<1||config.getMaxRateLimitFailures()<1||config.getCooldownSeconds()<1)throw reject("ACCOUNT_BUDGET_UNCONFIGURED");
}
private SelfopOutboundDiscoveryScope account(String id){var a=mapper.lockAccount(id);if(a==null)throw reject("EGRESS_ACCOUNT_MISSING");return a;}
private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now){
private SelfopApiBudget budget(SelfopOutboundDiscoveryScope account,LocalDateTime now,String purpose){
var b=account.getApiBudgetJson()==null?new SelfopApiBudget():SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);
if(b.reservedHttp<0||b.confirmedHttp<0||b.confirmedHttp>b.reservedHttp)throw reject("ACCOUNT_BUDGET_INVALID");
LocalDate day=now.plusHours(8).toLocalDate();
if(!Objects.equals(day,b.budgetDate)){/* unknown in-flight is charged again on new day; cooling survives */b.budgetDate=day;b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;}
if("BUSINESS".equals(config.getCollectionMode()))b.dailyLimit=0;
if(!Objects.equals(day,b.budgetDate)){
b.budgetDate=day;
// BUSINESS records cumulative consumption, including transferred and uncertain attempts.
// Only the finite VALIDATION daily quota rolls over; cooling/pending always survive.
if(!"BUSINESS".equals(purpose)){b.dailyLimit=config.getDailyRequestBudget();b.reservedHttp=b.pending!=null&&!b.pending.completed?1:0;b.confirmedHttp=0;}
}
if("BUSINESS".equals(purpose))b.dailyLimit=0;
else {if(b.dailyLimit<1)b.dailyLimit=config.getDailyRequestBudget();b.dailyLimit=Math.min(b.dailyLimit,config.getDailyRequestBudget());}
b.minIntervalMs=Math.max(b.minIntervalMs,config.getBusinessMinRequestIntervalMs());
b.configHash=SelfopSupport.hash(Arrays.asList(b.dailyLimit,b.minIntervalMs,config.getRequestBudget(),config.getTimeBudgetSeconds()));
@@ -161,20 +167,23 @@ public class SelfopEgressService {
}
private SelfopSyncTask root(Long id,String purpose,String account){var t=mapper.lockTask(id);if(t==null||!Objects.equals(t.getSourceAccount(),account)||!Objects.equals(t.getExecutionPurpose(),purpose))throw reject("EGRESS_ROOT_INVALID");if("VALIDATION".equals(purpose)&&!Objects.equals(id,t.getValidationRootTaskId()))throw reject("VALIDATION_ROOT_INVALID");return t;}
private void verify(SelfopSyncTask task,SelfopSyncRun run,SelfopSyncTask root,SelfopOutboundDiscoveryScope account,LocalDateTime now){
if(testBusiness(root.getExecutionPurpose())&&"DEAD".equals(root.getStatus()))throw reject("BUSINESS_SESSION_ROOT_STOPPED");
if(hasBusinessSession(root)&&"DEAD".equals(root.getStatus()))throw reject("BUSINESS_SESSION_ROOT_STOPPED");
if(task==null||run==null||!Objects.equals(run.getTaskId(),task.getId())||!Objects.equals(run.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!Objects.equals(task.getExecutionPurpose(),root.getExecutionPurpose())||!Objects.equals(run.getExecutionPurpose(),task.getExecutionPurpose())||!"RUNNING".equals(task.getStatus())||!Set.of("FETCHING","VALIDATED").contains(run.getStatus()))throw reject("EGRESS_TASK_NOT_ACTIVE");
if("VALIDATION".equals(root.getExecutionPurpose())&&(!Objects.equals(task.getValidationRootTaskId(),root.getId())||Set.of("DEAD","SAMPLE_DONE").contains(root.getStatus())))throw reject("VALIDATION_ROOT_STOPPED");
if("ACCOUNT_DISCOVERY".equals(task.getTaskRole())){if(!Objects.equals(task.getDiscoveryScopeId(),account.getId())||!Objects.equals(account.getFenceToken(),run.getFenceToken())||account.getLeaseUntilUtc()==null||!now.isBefore(account.getLeaseUntilUtc()))throw reject("EGRESS_FENCE_LOST");}
else if(!Objects.equals(task.getScopeId(),run.getScopeId())||!Objects.equals(task.getWarehouseCode(),run.getWarehouseCode())||mapper.warehouseFence(task.getScopeId(),run.getFenceToken(),task.getId(),now)!=1)throw reject("EGRESS_FENCE_LOST");
}
private SelfopValidationBudget validation(SelfopSyncTask root){if(root==null||!"VALIDATION".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getValidationRootTaskId())||root.getValidationBudgetJson()==null)throw reject("VALIDATION_ROOT_INVALID");var b=SelfopSupport.read(root.getValidationBudgetJson(),SelfopValidationBudget.class);b.validate();return b;}
private boolean continuousBusiness(String purpose){return "BUSINESS".equals(purpose)&&config.isContinuousBusinessCollectionEnabled();}
private boolean testBusiness(String purpose){return "test".equals(config.getEnvironment())&&"BUSINESS".equals(purpose);}
private boolean hasBusinessSession(SelfopSyncTask root){return "BUSINESS".equals(root.getExecutionPurpose())&&(testBusiness(root.getExecutionPurpose())||root.getBusinessBudgetJson()!=null||SelfopCollectionPolicy.continuousBusinessRoot(root));}
private SelfopBusinessBudget business(SelfopSyncTask root){
if(root==null||!"BUSINESS".equals(root.getExecutionPurpose())||!Objects.equals(root.getId(),root.getBusinessRootTaskId())||root.getBusinessBudgetJson()==null)throw reject("BUSINESS_SESSION_ROOT_INVALID");
SelfopBusinessBudget b;try{b=SelfopSupport.read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);}catch(RuntimeException e){throw reject("BUSINESS_SESSION_BUDGET_INVALID");}
if(b==null)throw reject("BUSINESS_SESSION_BUDGET_INVALID");b.validate();return b;
if(b==null)throw reject("BUSINESS_SESSION_BUDGET_INVALID");b.validate();
if(b.continuous&&!SelfopCollectionPolicy.continuousBusinessRoot(root))throw reject("BUSINESS_SESSION_ROOT_INVALID");return b;
}
private void verifyBusinessTask(SelfopSyncTask task,SelfopSyncTask root){if(task==null||!Objects.equals(task.getBusinessRootTaskId(),root.getId())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!"BUSINESS".equals(task.getExecutionPurpose()))throw reject("BUSINESS_SESSION_ROOT_INVALID");}
private void verifyBusinessTask(SelfopSyncTask task,SelfopSyncTask root){if(task==null||!Objects.equals(task.getBusinessRootTaskId(),root.getId())||!Objects.equals(task.getSourceAccount(),root.getSourceAccount())||!"BUSINESS".equals(task.getExecutionPurpose())||business(root).continuous&&!continuousBusiness(root.getExecutionPurpose()))throw reject("BUSINESS_SESSION_ROOT_INVALID");}
private void stopBusiness(SelfopSyncTask root,SelfopBusinessBudget b,String reason,LocalDateTime now){
b.stopReason=reason;root.setStatus("DEAD");root.setNextRetryAtUtc(null);saveBusiness(root,b);
mapper.terminateBusinessTasks(root.getId(),root.getSourceAccount());mapper.terminateBusinessRuns(root.getId(),root.getSourceAccount(),reason,now);
@@ -37,6 +37,7 @@ public class SelfopExecutionService {
}
public void execute(long taskId){
var task=tasks.selectById(taskId);require(task!=null,"SELFOP_TASK_MISSING");SelfopCollectionPolicy.requireExecution(config,task.getExecutionPurpose());
if(SelfopCollectionPolicy.continuousBusiness(config))require(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
if("SUCCEEDED".equals(task.getStatus())||"DEAD".equals(task.getStatus()))return;
String owner=UUID.randomUUID().toString();Map<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);}
@@ -34,9 +34,9 @@ public class SelfopSalesPublisher {
require(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"HALF_APPLIED_FACTS");
require(Objects.equals(run.getCalculatedFactRevision(),scope.getFactRevision()),"SELFOP_CANDIDATE_OBSOLETE");
require(run.getBuildStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getBuildStartedAtUtc())==0,"SELFOP_CANDIDATE_OBSERVATION_CHANGED");
require("COMPLETE".equals(run.getCoverageStatus())&&run.getCoveredFromUtc()!=null&&run.getCoveredUntilUtc()!=null,"SELFOP_COVERAGE_UNPROVEN");
var task=locks.lockTask(run.getTaskId());require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED");
SelfopCollectionPolicy.requireBusiness(task);
SelfopSalesCoverage.requireTask(run,task);
if(scope.getPublishedRunId()!=null)runs.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncRun>()
.eq("id",scope.getPublishedRunId()).isNull("superseded_at_utc").set("superseded_at_utc",now).set("updated_at",now));
scope.setPublishedRunId(runId);scope.setPublishedFactRevision(run.getCalculatedFactRevision());scope.setRebuildRequired(0);
@@ -59,6 +59,7 @@ public class SelfopSalesPublisher {
require(Objects.equals(scope.getPublishedRunId(),publishedId)&&"PUBLISHED".equals(published.getStatus())&&Objects.equals(published.getCalculatedFactRevision(),scope.getFactRevision())
&&locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())==0,"SELFOP_REUSE_OBSOLETE");
require("APPLIED".equals(task.getApplyState())||"REBUILD".equals(task.getTaskType()),"SELFOP_TASK_NOT_APPLIED");
SelfopSalesCoverage.requireTask(published,task);
require(run.getFetchStartedAtUtc()!=null&&aggregates.observationsChangedSince(scope.getSourceAccount(),scope.getWarehouseCode(),run.getFetchStartedAtUtc())==0,"SELFOP_REUSE_OBSERVATION_CHANGED");
var now=utcNow();run.setCalculatedFactRevision(published.getCalculatedFactRevision());SelfopProgressProjection.freezeRun(task,run);run.setStatus("REUSED");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
task.setResultPublishedRunId(publishedId);task.setStatus("SUCCEEDED");task.setUpdatedAt(now);tasks.update(null,new com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper<SelfopSyncTask>()
@@ -32,6 +32,15 @@ public class SelfopSchedulePlanner {
public void tick(){
if("OFF".equals(config.getCollectionMode()))return;
if("BUSINESS".equals(config.getCollectionMode()))SelfopCollectionPolicy.requireExecution(config,"BUSINESS");
if(SelfopCollectionPolicy.continuousBusiness(config)){
planContinuous(utcNow());
// One dispatch per tick: after a safe yield, re-plan and re-read priority.
for(var task:taskService.due(1)){
if(!SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true))continue;
try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Durable lease owner continues. */}
}
return;
}
List<SelfopSyncScope> configured;
if("VALIDATION".equals(config.getCollectionMode())){taskService.initializeAccount();configured=List.of();}
else if("test".equals(config.getEnvironment()))configured=List.of();
@@ -48,6 +57,80 @@ public class SelfopSchedulePlanner {
try{execution.execute(task.getId());}catch(IllegalStateException leaseBusy){/* Another worker owns the durable lease. */}
}
}
public void planContinuous(LocalDateTime now){
require(SelfopCollectionPolicy.continuousBusiness(config),"SELFOP_CONTINUOUS_COLLECTION_DISABLED");
require("Asia/Shanghai".equals(config.getSourceQueryTimezone()),"SELFOP_SOURCE_TIMEZONE_REQUIRED");
require(!config.getWarehouses().isEmpty(),"SELFOP_WAREHOUSE_UNCONFIGURED");
var account=taskService.initializeAccount();
tx.executeWithoutResult(s->{
locks.lockDiscovery(account.getId());
// Read existing scopes only. Pool creation must never move the published coverage anchor.
var configured=scopes.selectList(new QueryWrapper<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){
SelfopCollectionPolicy.requireAutomaticPlanning(config);
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)) {
require(task.getBusinessRootTaskId()==null&&task.getBusinessBudgetJson()==null,"BUSINESS_SESSION_IDENTITY_INVALID");
if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) {
require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
if(continuousBusiness(config)&&continuousBusinessRoot(task)){
if("WAREHOUSE_SYNC".equals(task.getTaskRole())){
var scope=scopes.selectById(task.getScopeId());
require(scope!=null&&"INVENTORY".equals(scope.getDataFamily())&&Objects.equals(scope.getSourceAccount(),task.getSourceAccount())
&&Objects.equals(scope.getWarehouseCode(),task.getWarehouseCode())&&Objects.equals(scope.getScopeHash(),task.getScopeHash())
&&Objects.equals(scope.getScopeJson(),task.getQueryScopeJson()),"SELFOP_INVENTORY_SCOPE_INVALID");
}
task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now));
}else if(task.getRequestKey().startsWith("manual:")&&task.getUpstreamTaskId()==null) {
require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
task.setBusinessRootTaskId(task.getId());
task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now,config.getTimeBudgetSeconds()));
}else {
var parent=businessParent(task);
require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
require(parent!=null&&Objects.equals(parent.getSourceAccount(),task.getSourceAccount())&&controlledBusinessTask(parent,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
if("MANUAL_RETRY".equals(task.getTaskType()))require("SUCCEEDED".equals(task.getStatus())&&task.getRequestKey().startsWith("retry:")&&task.getUpstreamTaskId()!=null,"SELFOP_TEST_MANUAL_TASK_REQUIRED");
require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
require("MANUAL_RETRY".equals(task.getTaskType())||controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
businessGuard(parent,!"WAREHOUSE_SYNC".equals(task.getTaskRole()));task.setBusinessRootTaskId(parent.getBusinessRootTaskId());
}
}
@@ -136,7 +144,7 @@ public class SelfopTaskService {
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
public SelfopSyncRun begin(long taskId,SelfopLeaseService.Lease lease) {
lockBusinessAccount(taskId);leases.verify(lease);
if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}
if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}
var task=locks.lockTask(taskId);require(task!=null&&!"SUCCEEDED".equals(task.getStatus())&&!"DEAD".equals(task.getStatus()),"SELFOP_TASK_NOT_EXECUTABLE");
checkTaskLease(task,lease);
requireExecution(config,task.getExecutionPurpose());
@@ -190,6 +198,7 @@ public class SelfopTaskService {
run.setErrorMessage("自营同步失败,需核对运行记录");run.setFinishedAtUtc(now);run.setUpdatedAt(now);runs.updateById(run);
// A yielded execution is not a failed request; finite failures are counted separately.
long failures=runs.selectCount(new QueryWrapper<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");
if("APPLYING".equals(task.getApplyState())) task.setApplyState("BLOCKED");
if(task.getScopeId()!=null&&"INVENTORY".equals(scopes.selectById(task.getScopeId()).getDataFamily())) {
@@ -200,7 +209,7 @@ public class SelfopTaskService {
task.setRoutingManifestJson(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)
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.setQueryScopeJson(json(Map.of("resumesTaskNo",original.getTaskNo(),"reason",reason)));audit.setStatus("SUCCEEDED");audit.setApplyState("NOT_APPLICABLE");audit.setRequestedBy(operator);
create(audit);
original.setStatus("READY");original.setNextRetryAtUtc(utcNow());original.setRequestedBy(operator);original.setUpdatedAt(utcNow());tasks.updateById(original);
original.setStatus("READY");original.setNextRetryAtUtc(utcNow());
if(!continuousBusinessRoot(original))original.setRequestedBy(operator);
original.setUpdatedAt(utcNow());tasks.updateById(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)
public void candidateFinished(long taskId,long runId,SelfopLeaseService.Lease lease) {
lockBusinessAccount(taskId);leases.verify(lease);guardBeforeTaskLock(taskId,false);var task=locks.lockTask(taskId);var run=locks.lockRun(runId);
@@ -243,7 +254,7 @@ public class SelfopTaskService {
}
@Transactional(noRollbackFor=BusinessSessionStoppedException.class)
public void waitForBudget(long taskId,long runId,LocalDateTime until,SelfopLeaseService.Lease lease) {
lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);
lockBusinessAccount(taskId);leases.verify(lease);if("test".equals(config.getEnvironment())||continuousBusiness(config)){var preview=tasks.selectById(taskId);businessGuard(preview,!localBusinessWork(preview));}var task=locks.lockTask(taskId);var run=locks.lockRun(runId);checkRunLease(task,run,lease);requireBusiness(task);requireBusiness(run);
SelfopProgressProjection.freezeRun(task,run);run.setLastPhase(run.getStatus());run.setStatus("YIELDED");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow());runs.updateById(run);
task.setStatus("RETRY_WAIT");task.setNextRetryAtUtc(until);task.setUpdatedAt(utcNow());tasks.updateById(task);
}
@@ -252,7 +263,7 @@ public class SelfopTaskService {
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public CollectionCheckpoint checkpoint(SelfopSyncTask observedTask,SelfopSyncRun observedRun,SelfopLeaseService.Lease lease) {
long taskId=observedTask.getId();lockBusinessAccount(taskId);leases.verify(lease);
if("test".equals(config.getEnvironment())) {
if("test".equals(config.getEnvironment())||continuousBusiness(config)) {
var preview=tasks.selectById(taskId);require(preview!=null,"SELFOP_TASK_MISSING");businessGuard(preview,false,false);
}
var task=locks.lockTask(taskId);var run=locks.lockRun(observedRun.getId());checkRunLease(task,run,lease);
@@ -266,21 +277,21 @@ public class SelfopTaskService {
BusinessSessionStoppedException(String reason){super("本次测试采集会话已结束或预算无效,请核对记录后新建有界任务");this.reason=reason;}
public String reason(){return reason;}
}
private boolean testBusiness(SelfopSyncTask task){return task!=null&&"test".equals(config.getEnvironment())&&"BUSINESS".equals(task.getExecutionPurpose());}
private boolean testBusiness(SelfopSyncTask task){return task!=null&&("test".equals(config.getEnvironment())||continuousBusiness(config))&&"BUSINESS".equals(task.getExecutionPurpose());}
private SelfopSyncTask businessParent(SelfopSyncTask task){
Long id=task.getUpstreamTaskId();
if(id==null&&task.getQueryScopeJson()!=null){var query=read(task.getQueryScopeJson(),Map.class);if(query.get("parentTaskId") instanceof Number n)id=n.longValue();}
return id==null?null:tasks.selectById(id);
}
private void lockBusinessAccount(long id){
if(!"test".equals(config.getEnvironment()))return;
if(!"test".equals(config.getEnvironment())&&!continuousBusiness(config))return;
var task=tasks.selectById(id);if(!testBusiness(task))return;
require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED");
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount()));
require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId());
}
private void guardBeforeTaskLock(long id,boolean checkDeadline){
if(!"test".equals(config.getEnvironment()))return;
if(!"test".equals(config.getEnvironment())&&!continuousBusiness(config))return;
var task=tasks.selectById(id);businessGuard(task,checkDeadline);
}
private boolean localBusinessWork(SelfopSyncTask task){
@@ -301,22 +312,22 @@ public class SelfopTaskService {
private void businessGuard(SelfopSyncTask task,boolean checkDeadline){businessGuard(task,checkDeadline,true);}
private void businessGuard(SelfopSyncTask task,boolean checkDeadline,boolean terminate){
if(!testBusiness(task))return;
require(controlledBusinessTask(task,tasks::selectById),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
require(controlledBusinessTask(task,tasks::selectById,continuousBusiness(config)),"SELFOP_TEST_MANUAL_TASK_REQUIRED");
require(task.getBusinessRootTaskId()!=null,"BUSINESS_SESSION_ROOT_REQUIRED");
var ancestor=task;
while(!ancestor.getRequestKey().startsWith("manual:"))ancestor=businessParent(ancestor);
while(!ancestor.getRequestKey().startsWith("manual:")&&!continuousBusinessRoot(ancestor))ancestor=businessParent(ancestor);
require(Objects.equals(ancestor.getId(),task.getBusinessRootTaskId()),"BUSINESS_SESSION_ROOT_INVALID");
// Serialize with egress in account -> root -> task order, before taking any task row lock.
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",task.getSourceAccount()));
require(account!=null,"SELFOP_SCOPE_MISSING");locks.lockDiscovery(account.getId());
var root=locks.lockTask(task.getBusinessRootTaskId());
require(root!=null&&Objects.equals(root.getId(),root.getBusinessRootTaskId())&&Objects.equals(root.getSourceAccount(),task.getSourceAccount())
&&root.getRequestKey()!=null&&root.getRequestKey().startsWith("manual:")&&controlledBusinessTask(root,tasks::selectById),"BUSINESS_SESSION_ROOT_INVALID");
&&root.getRequestKey()!=null&&(root.getRequestKey().startsWith("manual:")||continuousBusinessRoot(root))&&controlledBusinessTask(root,tasks::selectById,continuousBusiness(config)),"BUSINESS_SESSION_ROOT_INVALID");
require(root.getBusinessBudgetJson()!=null,"BUSINESS_SESSION_BUDGET_INVALID");
var budget=read(root.getBusinessBudgetJson(),SelfopBusinessBudget.class);budget.validate();
// HTTP exhaustion is enforced at egress, allowing the final permitted response to finish local work.
require(!budget.continuous||(continuousBusiness(config)&&continuousBusinessRoot(root)),"SELFOP_CONTINUOUS_COLLECTION_DISABLED");
String reason=budget.stopReason;
if(reason==null&&checkDeadline&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE";
if(reason==null&&checkDeadline&&!budget.continuous&&!utcNow().isBefore(budget.deadlineAtUtc))reason="BUSINESS_SESSION_DEADLINE";
if(reason==null&&"DEAD".equals(root.getStatus()))reason="BUSINESS_SESSION_STOPPED";
if(reason==null)return;
if(!terminate)throw new BusinessSessionStoppedException(reason);
@@ -48,6 +48,7 @@ lastleg:
baiyue:
self-operated-analytics:
collection-mode: ${SELFOP_COLLECTION_MODE:OFF}
continuous-business-collection-enabled: ${SELFOP_CONTINUOUS_BUSINESS_COLLECTION_ENABLED:false}
environment: ${DEPLOY_ENV:test}
daily-request-budget: ${SELFOP_DAILY_REQUEST_BUDGET:0}
business-min-request-interval-ms: ${SELFOP_MIN_REQUEST_INTERVAL_MS:5000}
@@ -732,6 +732,8 @@ components:
Task:
type: object
properties:
createdAt: {type: string, format: date-time, nullable: true, description: 当前任务创建时间,重试和阶段切换不重置}
elapsedSeconds: {type: string, nullable: true, description: 自创建起的累计秒数,含排队和重试;活动任务截止本次读取,终态任务截止任务最后更新时间;不是单次执行耗时}
taskNo: {type: string, nullable: true}
taskType: {type: string, nullable: true}
dataFamily: {type: string, nullable: true}
@@ -807,7 +809,7 @@ components:
executionEnabled: {type: boolean, nullable: true}
publicationEnabled: {type: boolean, nullable: true}
disabledReason: {type: string, nullable: true}
nextScheduledAt: {type: string, format: date-time, nullable: true}
nextScheduledAt: {type: string, format: date-time, nullable: true, description: 自动采集下一计划时刻;显式连续BUSINESS模式下销量为下个北京时间00点05分,库存沿仓库计划;执行被禁用时为空,不代表当前任务预计完成时间}
observedAt: {type: string, format: date-time, nullable: true}
initialFrom: {type: string, format: date-time, nullable: true}
initialUntil: {type: string, format: date-time, nullable: true}
@@ -24,7 +24,7 @@ class SelfopStageEstimateTest {
@Test void sufficientRealAdvancesEstimateCurrentStageAndIncludeIdleTime(){
var p=ready();var e=estimate(p,60);assertEquals("AVAILABLE",e.status());assertEquals(60L,e.completedDelta());
assertEquals(0,new BigDecimal("1").compareTo(e.ratePerSecond()));assertEquals(939L,e.remainingSeconds());
assertEquals(start,e.sampleFrom());assertEquals(start.plusSeconds(60),e.sampleUntil());assertEquals(start.plusSeconds(70),e.validUntil());
assertEquals(start,e.sampleFrom());assertEquals(start.plusSeconds(60),e.sampleUntil());assertEquals(start.plusSeconds(90),e.validUntil());
var idle=estimate(p,90);assertEquals("AVAILABLE",idle.status());assertEquals(new BigDecimal("0.666666"),idle.ratePerSecond());
assertEquals(1409L,idle.remainingSeconds());assertEquals("NO_RECENT_PROGRESS",estimate(p,120).unavailableReason());
}
@@ -33,6 +33,26 @@ class SelfopStageEstimateTest {
assertEquals("SAMPLING",estimate(p,59).unavailableReason());assertEquals("SAMPLING",estimate(p,60).unavailableReason());
commit(p,60,4,true);assertEquals("AVAILABLE",estimate(p,60).status());
}
@Test void unchangedBookkeepingDoesNotRestartSamplingOrInventAnAdvance(){
var p=ready();commit(p,65,61,false);
var e=estimate(p,65);
assertEquals("AVAILABLE",e.status());assertEquals(start,e.sampleFrom());
assertEquals(60L,e.completedDelta());assertEquals(new BigDecimal("0.923076"),e.ratePerSecond());
commit(p,119,61,false);
assertEquals("NO_RECENT_PROGRESS",estimate(p,120).unavailableReason());
}
@Test void rollingWindowKeepsAValidityMarginAcrossTenSecondPollingBoundaries(){
var p=progress();p.put("total",100000L);
for(int second=0;second<=600;second++){
commit(p,second,second,true);
if(second>=80&&second%10==0){
var e=estimate(p,second);
assertEquals("AVAILABLE",e.status(),"second="+second);
assertEquals(start.plusSeconds(second+30),e.validUntil());
assertFalse(e.sampleFrom().isBefore(e.validUntil().minusSeconds(SelfopStageEstimate.WINDOW_SECONDS)));
}
}
}
@Test void reuseBeforeInsideAndAfterWindowRestartsBaselineInsteadOfInflatingRate(){
var p=progress();commit(p,0,500,false);commit(p,20,501,true);commit(p,40,502,true);commit(p,60,503,true);
assertEquals(3L,estimate(p,60).completedDelta());
@@ -160,7 +160,7 @@ class OutboundCandidateBuildMySqlAcceptanceTest {
LocalDateTime now=utcNow().minusSeconds(1);String scopeJson=json(Map.of("quantityUnit","PCS","businessTimezone","Asia/Shanghai","sourceTimezone","Asia/Shanghai","fixtureOrigin","SYNTHETIC_BUILD_ACCEPTANCE"));String hash=hash(scopeJson);
transactions.executeWithoutResult(s->{
insert("ll_selfop_sync_scope",fields("id",scopeId,"created_at",now,"updated_at",now,"source_account",account,"warehouse_code",warehouse,"data_family","OUTBOUND","scope_json",scopeJson,"scope_hash",hash,"business_timezone","Asia/Shanghai","source_timezone","Asia/Shanghai","schedule_cron","0 0 0 1 1 *","stale_after_seconds",93600,"review_request_budget",1,"review_time_budget_seconds",1,"review_target_days",7,"fact_revision",1,"published_fact_revision",0,"fence_token",1,"lease_owner",owner,"lease_until_utc",utcNow().plusHours(1)));
for(long id:List.of(originTask,taskId))insert("ll_selfop_sync_task",fields("id",id,"created_at",now,"updated_at",now,"task_no","SYNTH-BUILD-"+id,"execution_purpose","BUSINESS","source_account",account,"warehouse_code",warehouse,"task_role","WAREHOUSE_SYNC","scope_id",scopeId,"request_key","build-"+id,"request_hash",hash,"task_type","REBUILD","query_scope_json",scopeJson,"scope_hash",hash,"status",id==taskId?"RUNNING":"SUCCEEDED","apply_state","APPLIED","attempt_count",1,"query_from_utc",utc(from),"query_until_utc",utc(until),"routing_manifest_json","{\"directoryComplete\":true}"));
for(long id:List.of(originTask,taskId))insert("ll_selfop_sync_task",fields("id",id,"created_at",now,"updated_at",now,"task_no","SYNTH-BUILD-"+id,"execution_purpose","BUSINESS","source_account",account,"warehouse_code",warehouse,"task_role","WAREHOUSE_SYNC","scope_id",scopeId,"request_key","build-"+id,"request_hash",hash,"task_type","REBUILD","query_scope_json",scopeJson,"scope_hash",hash,"status",id==taskId?"RUNNING":"SUCCEEDED","apply_state","APPLIED","apply_completed_at_utc",now,"attempt_count",1,"query_from_utc",utc(from),"query_until_utc",utc(until),"routing_manifest_json","{\"directoryComplete\":true}"));
for(long id:List.of(originRun,runId))insert("ll_selfop_sync_run",fields("id",id,"created_at",now,"updated_at",now,"task_id",id==runId?taskId:originTask,"execution_purpose","BUSINESS","scope_id",scopeId,"source_account",account,"warehouse_code",warehouse,"run_role","WAREHOUSE_SYNC","attempt_no",1,"scope_hash",hash,"scope_snapshot_json",scopeJson,"fence_token",1,"status","FACTS_APPLIED","last_phase","BUILD","coverage_status","UNKNOWN"));
});
for(int offset=0;offset<skus*days;offset+=500) {
@@ -10,6 +10,47 @@ import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
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 OutboundFactService facts=mock(OutboundFactService.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);
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.provenCoverageIslands(1L,"scope")).thenReturn(List.of(coverage));
}
@Test void manualFirstSliceUsesOnlyProvenIntervalWithoutInventingFullDays() {
scope.setInitialFromUtc(null);
@@ -90,6 +132,7 @@ class OutboundCandidateBuilderTest {
published.setWarehouseDailyReady(true);published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L);
published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1));
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);
assertNull(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. */
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 LocalDate D=LocalDate.of(2026,10,12);
private static final Instant NOW=Instant.parse("2026-10-12T04:00:00Z");
@@ -16,7 +16,7 @@ class AnalyticsReadMapperTest {
private static AnalyticsSqlRequest request(String kind){
LocalDate d=LocalDate.of(2026,10,12);
return new AnalyticsSqlRequest("demo","W","OWNER","Exact-SKU",11,22,d.minusDays(2),d.plusDays(1),d,d.minusDays(7),
d.minusDays(30),d,LocalDateTime.of(2026,9,11,16,0),LocalDateTime.of(2026,10,12,3,0),true,true,true,true,true,20,20,kind);
d.minusDays(30),d,LocalDateTime.of(2026,9,11,16,0),LocalDateTime.of(2026,10,12,3,0),true,true,true,true,true,20,20,kind,java.util.List.of());
}
private static BoundSql bound(Configuration c,String method,Map<String,Object> parameters){return c.getMappedStatement(NS+method).getBoundSql(parameters);}
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.
* No external database, credentials or HTTP; MySQL plans/performance remain separate gates. */
public class AnalyticsSqlSemanticTest {
@Test void frozenDisconnectedDaysPreserveZeroDayAndUnknownGapInSummarySql() throws Exception {
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
daily(11,"W","C","SKU",D,"12");
var coverage=List.of(new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(utc(D.minusDays(5),0),utc(D.minusDays(4),0)),
new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(utc(D,0),utc(D.plusDays(1),0)));
var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(5),D.plusDays(1),D.plusDays(1),D.minusDays(30),D.minusDays(5),D.minusDays(4),
utc(D.minusDays(5),0),utc(D.minusDays(4),0),true,true,false,false,false,0,20,"SUMMARY",coverage);
var result=page(p).get(0);assertEquals(2,result.completeDayCount());assertEquals(0,result.partialDayCount());assertEquals(4,result.unknownDayCount());
assertEquals(0,new BigDecimal("12").compareTo(result.observedQuantity()));
}
private Connection schema;
private SqlSession session;
private AnalyticsReadMapper mapper;
@@ -68,7 +78,7 @@ public class AnalyticsSqlSemanticTest {
daily(11,"W","C","SOLD",from.minusDays(1),"999");daily(11,"W","C","SOLD",until,"999");
daily(33,"W","C","SOLD",from,"999");daily(11,"OTHER","C","SOLD",from,"999");
var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,from,until,D,D.minusDays(30),from,D,
utc(from,0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY");
utc(from,0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY",java.util.List.of());
assertEquals(3,mapper.count(p));var rows=page(p);assertEquals(3,rows.size());
var sold=rows.get(0);assertEquals("SOLD",sold.skuCode());assertDecimal("5",sold.observedQuantity());
assertEquals(2L,sold.skuOrderCount());assertEquals(365,sold.completeDayCount());
@@ -147,15 +157,15 @@ public class AnalyticsSqlSemanticTest {
new LocalDateTime[]{utc(D.plusDays(1),0),utc(D.plusDays(2),0)},
new LocalDateTime[]{null,utc(D,8)})){
var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null,
span[0],span[1],true,false,false,false,true,0,1,"SUMMARY");
span[0],span[1],true,false,false,false,true,0,1,"SUMMARY",java.util.List.of());
var first=page(p).get(0);assertEquals("A-UNKNOWN",first.skuCode());assertNull(first.observedQuantity());
var last=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null,
span[0],span[1],true,false,false,false,true,1,1,"SUMMARY");
span[0],span[1],true,false,false,false,true,1,1,"SUMMARY",java.util.List.of());
assertNull(page(last).get(0).observedQuantity());
}
daily(11,"W","C","Z-EVIDENCE",D,"99");
var unavailable=new AnalyticsSqlRequest("demo","W",null,null,11,22,D,D.plusDays(1),D,D.minusDays(30),null,null,
utc(D,0),utc(D,8),false,false,false,false,true,0,1,"SUMMARY");
utc(D,0),utc(D,8),false,false,false,false,true,0,1,"SUMMARY",java.util.List.of());
var first=page(unavailable).get(0);assertEquals("A-UNKNOWN",first.skuCode());assertNull(first.observedQuantity());
}
@Test void evidenceAfterCoveredTimeOnSameDayDoesNotBecomeObservedZero() throws Exception {
@@ -307,7 +317,7 @@ public class AnalyticsSqlSemanticTest {
daily(11,"W","A","SAME",D.minusDays(1),"2");daily(11,"W","A","SAME",D,"5");
daily(11,"W","B","SAME",D.minusDays(2),"9");
var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(365),D.plusDays(1),D,D.minusDays(30),
D.minusDays(365),D,utc(D.minusDays(365),0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY");
D.minusDays(365),D,utc(D.minusDays(365),0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY",java.util.List.of());
assertEquals(4,mapper.count(p));var rows=page(p);assertEquals(4,rows.size());
for(var row:rows)if("SAME".equals(row.skuCode())){
assertDecimal("A".equals(row.customerCode())?"8":"9",row.observedQuantity());
@@ -432,11 +442,11 @@ public class AnalyticsSqlSemanticTest {
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");stock("C","SKU",0,"10");daily(11,"W","C","SKU",D.minusDays(1),"3");
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null,null,null);
var stale=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,
utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY");
utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY",java.util.List.of());
var row=inventoryRows(stale,options).get(0);assertNull(row.physicalQuantity());assertNull(row.availableQuantity());assertDecimal("3",row.ownerOutbound());assertNull(row.coverageDays());
var noSales=new AnalyticsSqlRequest("demo","W",null,null,0,22,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,true,0,20,"INVENTORY");
var noSales=new AnalyticsSqlRequest("demo","W",null,null,0,22,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,true,0,20,"INVENTORY",java.util.List.of());
row=inventoryRows(noSales,options).get(0);assertDecimal("10",row.availableQuantity());assertNull(row.ownerOutbound());assertNull(row.observedOutbound());assertNull(row.outboundQuantity30d());assertNull(row.lastOutbound());
var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY");
var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY",java.util.List.of());
assertTrue(inventoryRows(empty,options).isEmpty());assertEquals(0L,((Number)literalQuery("inventoryCount",empty,options).get(0)).longValue());
}
@Test void tenDayInventoryUsesItsOwnCompleteCoverageAndUnroundedThresholdWithoutLeakingScope()throws Exception {
@@ -449,7 +459,7 @@ public class AnalyticsSqlSemanticTest {
daily(99,"W","C","TEN",D.minusDays(1),"700");daily(11,"OTHER","C","TEN",D.minusDays(1),"600");
daily(11,"W","OTHER","TEN",D.minusDays(1),"500");
var p=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(10),D,
utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY");
utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY",java.util.List.of());
var o=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1));
var rows=inventoryRows(p,o);var ten=rows.stream().filter(r->r.skuCode().equals("TEN")).findFirst().orElseThrow();
assertDecimal("2",ten.outboundQuantity30d());assertDecimal("30.04",ten.coverageDays());assertEquals(1,ten.aboveThreshold());
@@ -464,7 +474,7 @@ public class AnalyticsSqlSemanticTest {
assertEquals(1L,((Number)literalQuery("inventoryCount",p,filtered).get(0)).longValue());
assertEquals(List.of("TEN"),inventoryRows(p,filtered).stream().map(AnalyticsReadRows.Inventory::skuCode).toList());
var partial=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(9),D,
utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY");
utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY",java.util.List.of());
assertTrue(inventoryRows(partial,o).stream().allMatch(r->r.outboundQuantity30d()==null&&r.coverageDays()==null));
}
@SuppressWarnings("unchecked") private List<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) {
return new AnalyticsSqlRequest("demo","W",owner,sku,11,22,from,until,D,D.minusDays(30),D.minusDays(30),D,
utc(D.minusDays(30),0),coveredUntil,true,true,true,true,true,offset,size,kind);
utc(D.minusDays(30),0),coveredUntil,true,true,true,true,true,offset,size,kind,java.util.List.of());
}
private static LocalDateTime utc(LocalDate d,int hour){return d.atTime(hour,0).atZone(ZoneId.of("Asia/Shanghai")).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();}
private void profile(long run,String owner,String sku,String evidence,LocalDateTime at,String unit)throws Exception {
@@ -29,6 +29,15 @@ class CollectionQueryServiceTest {
return new Task(id,"LXCOL-"+id,"primary",account?null:"A","OUTBOUND",account?"ACCOUNT_DISCOVERY":"WAREHOUSE_SYNC","BACKFILL","BUSINESS",
"hash",status,"NOT_STARTED",1,now.minusDays(1),now,null,upstream,null,null,progress,now,null,null,false,created,created);
}
@Test void taskElapsedIncludesWaitingAndRetriesAndStopsAtTerminalTaskUpdate() {
for(String status:List.of("RUNNING","RETRY_WAIT","WAITING_SOURCE","SUCCEEDED")) {
var task=activity(7,status,null,false,now.minusMinutes(10),null);
when(mapper.task("primary","A",task.taskNo())).thenReturn(task);
var result=service.taskDetail(task.taskNo(),q(null,null,null)).task();
assertEquals(now.minusMinutes(10).toInstant(ZoneOffset.UTC),result.createdAt());
assertEquals("SUCCEEDED".equals(status)?0L:600L,result.elapsedSeconds());
}
}
@Test void currentActivityWinsOverNewerHistoryAndIsBoundedToEight() {
var candidates=new ArrayList<Task>();
candidates.add(activity(99,"SUCCEEDED",null,false,now,null));
@@ -132,6 +141,37 @@ class CollectionQueryServiceTest {
config.setEnvironment("development");
for(var row:service.overview(q(null,null,null)).warehouses()){assertFalse(row.executionEnabled());assertEquals("BUSINESS_ENVIRONMENT_REJECTED",row.disabledReason());}
}
@Test void continuousTestBusinessShowsDailyPlanDespiteExpiredGrantButKeepsSafetyGates() {
enableBusiness("test");config.setContinuousBusinessCollectionEnabled(true);
config.setExclusiveGrantExpiresAtUtc(now.minusDays(1));
var rows=service.overview(q(null,null,null)).warehouses();
var outbound=rows.stream().filter(r->"OUTBOUND".equals(r.dataFamily())).findFirst().orElseThrow();
assertTrue(outbound.executionEnabled());
assertEquals(Instant.parse("2026-10-12T16:05:00Z"),outbound.nextScheduledAt());
var atSlot=new CollectionQueryService(mapper,config,new ObjectMapper(),Clock.fixed(Instant.parse("2026-10-12T16:05:00Z"),ZoneOffset.UTC));
assertEquals(Instant.parse("2026-10-13T16:05:00Z"),atSlot.overview(q(null,null,null)).warehouses().stream()
.filter(r->"OUTBOUND".equals(r.dataFamily())).findFirst().orElseThrow().nextScheduledAt());
config.setPriorEgressClosed(false);
for(var row:service.overview(q(null,null,null)).warehouses()) {
assertFalse(row.executionEnabled());assertNull(row.nextScheduledAt());
assertEquals("EXCLUSIVE_GRANT_NOT_VALID",row.disabledReason());
}
config.setPriorEgressClosed(true);config.setContinuousBusinessCollectionEnabled(false);
assertFalse(service.overview(q(null,null,null)).warehouses().get(0).executionEnabled());
}
@Test void continuousBudgetRequiresEnabledModeAndNoDeadlineWhileBoundedBudgetsStillExpire() {
enableBusiness("test");config.setContinuousBusinessCollectionEnabled(true);
String continuous="{\"continuous\":true,\"startedAtUtc\":\""+now.minusDays(2)+"\",\"maxHttp\":0,\"http\":10,\"confirmedHttp\":10,\"deadlineAtUtc\":null,\"stopReason\":null}";
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous));
assertEquals(List.of("RETRY"),service.taskDetail("LXCOL-TASK",q(null,null,null)).task().allowedActions());
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous.replace("\"deadlineAtUtc\":null","\"deadlineAtUtc\":\""+now.plusDays(1)+"\"")));
assertEquals("BUSINESS_SESSION_BUDGET_MISSING",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason());
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(businessBudget(now.minusMinutes(1).toString(),null,10)));
assertEquals("BUSINESS_SESSION_DEADLINE",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason());
config.setContinuousBusinessCollectionEnabled(false);
when(mapper.task("primary","A","LXCOL-TASK")).thenReturn(manualBusinessTask(continuous));
assertEquals("CONTINUOUS_COLLECTION_DISABLED",service.taskDetail("LXCOL-TASK",q(null,null,null)).task().actionBlockReason());
}
Scope scope() {
return new Scope(1L,"A","OUTBOUND","hash","Asia/Shanghai","0 0 8,20 * * *",93600,
now.minusMonths(2),now,null,null,0,null,null,null,7,null,null);
@@ -9,43 +9,52 @@ import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class DashboardPolicyTest {
@Test void frozenIslandsProveLatestEmptyDayWithoutFillingInterveningDates(){
var first=DAY.minusDays(6).atStartOfDay().minusHours(8);var last=DAY.minusDays(1).atStartOfDay().minusHours(8);
var coverage=List.of(new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(first,first.plusDays(1)),
new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(last,last.plusDays(1)));
var c=context(first.toInstant(ZoneOffset.UTC),first.plusDays(1).toInstant(ZoneOffset.UTC),true);
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(6),DAY,List.of(),coverage);
assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN","UNKNOWN","COMPLETE"),points.stream().map(p->p.dataStatus()).toList());
assertEquals(BigDecimal.ZERO,points.get(5).outboundQuantity());assertNull(SalesBucketPolicy.aggregate(points).outboundQuantity());
}
static final LocalDate DAY=LocalDate.of(2026,10,13);
static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z");
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,DAY.minusDays(14),DAY.plusDays(1),null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());}
@Test void warehouseObservedTotalPreservesOutsideWatermarkAndUnknownGap(){
var c=context(null,null,true);var rows=List.of(new DashboardReadMapper.Day(DAY.minusDays(3),new BigDecimal("2"),0L),new DashboardReadMapper.Day(DAY.minusDays(1),new BigDecimal("5"),0L),new DashboardReadMapper.Day(DAY,BigDecimal.ONE,0L));
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows);var summary=SalesBucketPolicy.aggregate(points);
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows,java.util.List.of());var summary=SalesBucketPolicy.aggregate(points);
assertEquals(new BigDecimal("8"),summary.observedQuantity());assertNull(summary.outboundQuantity());assertNull(points.get(1).observedQuantity());assertEquals("PARTIAL",points.get(3).dataStatus());
}
@Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){
var c=context(DAY.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),NOW,true);
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of());
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of(),java.util.List.of());
assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(BigDecimal.ZERO,points.get(0).outboundQuantity());
assertEquals("PARTIAL",points.get(1).dataStatus());assertEquals(BigDecimal.ZERO,points.get(1).observedQuantity());assertNull(points.get(1).outboundQuantity());
var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of()));assertNull(unknown.observedQuantity());
var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of(),java.util.List.of()));assertNull(unknown.observedQuantity());
}
@Test void staleCollectionDoesNotDowngradePublishedHistoricalDayOrInventOtherFourteenDays(){
LocalDate published=DAY.minusDays(3);var c=publishedDayContext("STALE",false);
for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))) {
var rows=amount.signum()==0?List.<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();
assertEquals("COMPLETE",point.dataStatus());assertEquals(amount,point.outboundQuantity());
assertFalse(point.reasonCodes().contains("SALES_DAY_INCOMPLETE"));assertFalse(point.reasonCodes().contains("SALES_STALE"));
var summary=SalesBucketPolicy.aggregate(points);
assertEquals(1,summary.completeDays());assertEquals(14,summary.unknownDays());assertEquals(0,summary.partialDays());
assertEquals("PARTIAL",summary.dataStatus());assertNull(summary.outboundQuantity());assertEquals(amount,summary.observedQuantity());
var single=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(c,true,published,published.plusDays(1),rows));
var single=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(c,true,published,published.plusDays(1),rows,java.util.List.of()));
assertEquals("COMPLETE",single.dataStatus());assertEquals(amount,single.outboundQuantity());
assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE"));
}
}
@Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){
LocalDate day=DAY.minusDays(3);var rows=List.of(new DashboardReadMapper.Day(day,new BigDecimal("2251"),0L));
assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows).get(0).dataStatus());
var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L))).get(0);
assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows,java.util.List.of()).get(0).dataStatus());
var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L)),java.util.List.of()).get(0);
assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());assertTrue(invalid.reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID"));
var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows).get(0);
var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows,java.util.List.of()).get(0);
assertEquals("PARTIAL",rebuilding.dataStatus());assertNull(rebuilding.outboundQuantity());
}
private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){
@@ -20,7 +20,7 @@ class DashboardQueryServiceTest {
var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
var snapshot=new AnalyticsQueryService.Snapshot(q,salesRun(true),null,true,true,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot);
var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD");
var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD",java.util.List.of());
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p);
when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L)));
var reportRow=new HashMap<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);
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot);
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD"));
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD",java.util.List.of()));
var result=service.dashboard(q);
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
@@ -44,12 +44,12 @@ class DashboardSqlSemanticTest {
daily("C","RECENT",D,"7","PCS");daily("C","OLD",D.minusDays(20),"100","PCS");
var b=request(false,true);
var p=new AnalyticsSqlRequest(b.account(),b.warehouse(),null,null,b.salesRun(),b.inventoryRun(),D.minusDays(2),D.plusDays(1),D,D.minusDays(2),
b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD");
b.completeFrom(),b.completeUntil(),b.coveredFrom(),b.coveredUntil(),true,true,false,false,true,0,10,"HOT_DASHBOARD",java.util.List.of());
var rows=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()));
assertEquals(1,rows.size());assertEquals("RECENT",((Map<?,?>)rows.get(0)).get("SKU_CODE"));
assertEquals(0,new BigDecimal("10").compareTo((BigDecimal)((Map<?,?>)rows.get(0)).get("MAIN_VALUE")));
var top=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),D,p.from(),
p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT");
p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,false,false,true,0,100,"HOT",java.util.List.of());
var topRows=query(AnalyticsReadMapper.class,"page",Map.of("p",top));
assertEquals(1,topRows.size());var item=(AnalyticsReadRows.Aggregate)topRows.get(0);
assertEquals("RECENT",item.skuCode());assertEquals(0,new BigDecimal("10").compareTo(item.windowQuantity()));assertNull(item.sales30Quantity());
@@ -162,7 +162,7 @@ class DashboardSqlSemanticTest {
}
daily("C","SKU-199",D,"999","BOX");
assertEquals(200,count(stats(),"hot_eligible"));var top=ranks().stream().filter(r->r.rankCode().equals("HOT")).sorted(Comparator.comparing(DashboardReadMapper.Ranked::mainValue).reversed()).toList();assertEquals(10,top.size());
var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT");
var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT",java.util.List.of());
List<?> page=query(AnalyticsReadMapper.class,"page",Map.of("p",hot));assertEquals(10,page.size());
for(int i=0;i<10;i++){var item=(AnalyticsReadRows.Aggregate)page.get(i);assertEquals(top.get(i).skuCode(),item.skuCode());assertEquals(0,top.get(i).mainValue().compareTo(item.windowQuantity()));}
}
@@ -199,13 +199,13 @@ class DashboardSqlSemanticTest {
.stream().map(DashboardReadMapper.Day.class::cast).toList();
assertEquals(originalDailyOracle(D.minusDays(3),D.plusDays(1)),rows,"Published summaries must retain the former SKU aggregation's valid subtotal and invalid count");
var covered=DashboardPolicyTest.context(D.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DashboardPolicyTest.NOW,true);
var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows);
var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows,java.util.List.of());
assertEquals("PARTIAL",points.get(0).dataStatus());assertEquals(0,new BigDecimal("7").compareTo(points.get(0).observedQuantity()));assertNull(points.get(0).outboundQuantity());
assertTrue(points.get(0).reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID"));
assertEquals("UNKNOWN",points.get(1).dataStatus());assertNull(points.get(1).observedQuantity());
assertEquals("COMPLETE",points.get(2).dataStatus());assertEquals(BigDecimal.ZERO,points.get(2).outboundQuantity());
assertEquals("PARTIAL",points.get(3).dataStatus());assertEquals(0,new BigDecimal("4").compareTo(points.get(3).observedQuantity()));assertNull(points.get(3).outboundQuantity());
var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows);
var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows,java.util.List.of());
assertTrue(unproved.stream().allMatch(point->point.outboundQuantity()==null));
assertNull(unproved.get(2).observedQuantity());assertEquals("UNKNOWN",unproved.get(2).dataStatus());
}
@@ -238,9 +238,9 @@ class DashboardSqlSemanticTest {
private void stock(String sku,String physical,String available)throws Exception{stock("C",sku,physical,available);}
private void stock(String owner,String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}}
private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");}
private AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY");}
private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind());}
private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD",java.util.List.of());}
private AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY",java.util.List.of());}
private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind(),java.util.List.of());}
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);}
@SuppressWarnings("unchecked") private List<Map<String,Object>> report(){
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.*;
class SelfopBusinessBudgetTest {
@Test void continuousSessionHasNoDeadlineButKeepsStopAndAccountingGuards(){
var start=LocalDateTime.of(2026,10,13,0,0);
var budget=SelfopSupport.read(SelfopBusinessBudget.freezeContinuous(start),SelfopBusinessBudget.class);
assertTrue(budget.continuous);assertNull(budget.deadlineAtUtc);
budget.http=100000;budget.confirmedHttp=99999;
assertNull(budget.limit(start.plusYears(1)));
budget.stopReason="EGRESS_RESULT_UNKNOWN";assertEquals(budget.stopReason,budget.limit(start));
budget.deadlineAtUtc=start.plusDays(1);assertThrows(EgressRejectedException.class,budget::validate);
}
private final LocalDateTime start=LocalDateTime.of(2026,10,13,0,0);
@Test void serializedConsumptionAndDeadlineSurviveReload() {
@@ -44,6 +44,46 @@ class SelfopEgressServiceTest {
assertThat(budget().reservedHttp).isEqualTo(1);
}
@Test void frozenPageSizeCannotBeExpanded(){assertThatThrownBy(()->reserve(Operation.WAREHOUSES,11,null,null)).hasMessage("VALIDATION_PAGE_SIZE");assertThat(budget().reservedHttp).isZero();}
@Test void continuousBusinessGrantExpiryDoesNotClipShortPendingOrPermitAndSwitchKeepsCounters(){
testBusiness(5,300);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);
cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setEgressLeaseSeconds(120);
var c=reserve(Operation.WAREHOUSES,1,null,null);
assertThat(budget().pending.expiresAtUtc).isEqualTo(now().plusSeconds(120));
var restarted=new SelfopEgressService(mapper,cfg,clock);
assertThat(restarted.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER)).expiresAt()).isEqualTo(clock.instant().plusSeconds(30));
restarted.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));
assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);
cfg.setContinuousBusinessCollectionEnabled(false);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");
}
@Test void continuousFlagNeverBypassesValidationWindowGrantIdentityOrHandoff(){
cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");
testBusiness(5,300);
for(String grant:Arrays.asList(null,"","other")){cfg.setExclusiveGrantId(grant);assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");}
cfg.setExclusiveGrantId("grant");cfg.setPriorEgressClosed(false);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EXCLUSIVE_HANDOFF_UNCONFIRMED");verifyNoInteractions(mapper);
}
@Test void continuousGrantDoesNotRenewFiniteManualRootOrRelaxFence(){
testBusiness(5,10);cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null);
account.setFenceToken(8L);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_FENCE_LOST");
account.setFenceToken(7L);var c=reserve(Operation.WAREHOUSES,1,null,null);
assertThat(budget().pending.expiresAtUtc).isEqualTo(businessBudget().deadlineAtUtc);
clock.advance(10);
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_SESSION_DEADLINE");
assertThat(budget().pending.consumed).isFalse();
}
@Test void continuousBusinessAcrossMidnightRetainsUnknownPendingAndReviewAcrossRestart(){
business();cfg.setContinuousBusinessCollectionEnabled(true);cfg.setExclusiveGrantExpiresAtUtc(null);
var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
service.pauseUncertain(c,Operation.WAREHOUSES,"INTEGRATION_TRANSPORT_FAILURE");clock.advance(86400);
var restarted=new SelfopEgressService(mapper,cfg,clock);
assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT");
assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isZero();assertThat(budget().pending.abandoned).isTrue();
assertThat(account.getApiPauseReason()).isEqualTo("EGRESS_REQUIRES_REVIEW");clock.advance(cfg.getCooldownSeconds()+1);
assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");
}
@Test void allFamiliesAndFailuresConsumeOneRootAndAccountAcrossServiceRestart(){
var c=reserve(Operation.WAREHOUSES,2,null,null);success(c,Operation.WAREHOUSES);
c=reserve(Operation.PRODUCT_INVENTORY,2,"A",null);success(c,Operation.PRODUCT_INVENTORY);
@@ -150,6 +190,99 @@ class SelfopEgressServiceTest {
private void business(){cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("production");task.setExecutionPurpose("BUSINESS");run.setExecutionPurpose("BUSINESS");}
private void testBusiness(int http,int seconds){business();cfg.setEnvironment("test");task.setBusinessRootTaskId(1L);task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now(),seconds));}
private SelfopBusinessBudget businessBudget(){return SelfopSupport.read(task.getBusinessBudgetJson(),SelfopBusinessBudget.class);}
private void continuousBusinessRoot(){
business();cfg.setEnvironment("test");cfg.setContinuousBusinessCollectionEnabled(true);
cfg.setExclusiveGrantExpiresAtUtc(now().minusSeconds(1));cfg.setBusinessMinRequestIntervalMs(1200);
task.setValidationRootTaskId(null);task.setValidationBudgetJson(null);task.setUpstreamTaskId(null);
task.setTaskType("BACKFILL");task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash("a".repeat(64));
var from=LocalDate.of(2026,10,11).atStartOfDay();task.setQueryFromUtc(from.minusHours(8));task.setQueryUntilUtc(from.plusDays(1).minusHours(8));
task.setQueryScopeJson(SelfopSupport.json(Map.of("startTime",from.toString(),"endTime",from.plusDays(1).toString())));
task.setRequestKey("continuous:outbound:2026-10-11:"+task.getScopeHash().substring(0,12));
task.setBusinessRootTaskId(task.getId());task.setBusinessBudgetJson(SelfopBusinessBudget.freezeContinuous(now()));
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue();
}
@Test void continuousRootWithNoDeadlineReservesAuthorizesAcrossDayWithoutResettingAccumulated8820(){
continuousBusinessRoot();cfg.setEgressLeaseSeconds(999);cfg.setDailyRequestBudget(1);
var inherited=new SelfopApiBudget();inherited.budgetDate=now().plusHours(8).toLocalDate();inherited.importedGrantId="grant";
inherited.reservedHttp=8820;inherited.confirmedHttp=8820;inherited.minIntervalMs=1200;account.setApiBudgetJson(SelfopSupport.json(inherited));
var b=businessBudget();b.http=8820;b.confirmedHttp=8820;task.setBusinessBudgetJson(SelfopSupport.json(b));var started=b.startedAtUtc;
var c=reserve(Operation.OUTBOUND_QUERY,100,null,null);
assertThat(budget().pending.expiresAtUtc).isEqualTo(now().plusSeconds(120));
var restarted=new SelfopEgressService(mapper,cfg,clock);
var permit=restarted.authorize(new AuthorizationRequest(c,Operation.OUTBOUND_QUERY,PROVIDER));
assertThat(permit.expiresAt()).isEqualTo(clock.instant().plusSeconds(30));assertThat(permit.allowedAttempts()).isEqualTo(1);
var completion=new CompletionRequest(c,Operation.OUTBOUND_QUERY,PROVIDER,1,null,200,200,false,null);
restarted.complete(completion);restarted.complete(completion);
clock.advance(86400);account.setLeaseUntilUtc(now().plusMinutes(5));
var next=new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.OUTBOUND_DETAIL,0,"A","C/DO",INTERNAL);success(next,Operation.OUTBOUND_DETAIL);
assertThat(budget().reservedHttp).isEqualTo(8822);assertThat(budget().confirmedHttp).isEqualTo(8822);assertThat(budget().dailyLimit).isZero();
assertThat(businessBudget().http).isEqualTo(8822);assertThat(businessBudget().confirmedHttp).isEqualTo(8822);
assertThat(businessBudget().continuous).isTrue();assertThat(businessBudget().deadlineAtUtc).isNull();assertThat(businessBudget().startedAtUtc).isEqualTo(started);
verify(mapper,never()).terminateBusinessTasks(any(),any());
}
@Test void continuousRootSwitchOffRejectsNewReserveAndPreparedAuthorizeButAccountsLateCompletion(){
continuousBusinessRoot();cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2));
cfg.setContinuousBusinessCollectionEnabled(false);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
assertThat(budget().reservedHttp).isZero();
cfg.setContinuousBusinessCollectionEnabled(true);var c=reserve(Operation.WAREHOUSES,1,null,null);
cfg.setContinuousBusinessCollectionEnabled(false);
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
assertThat(budget().pending.consumed).isFalse();
cfg.setContinuousBusinessCollectionEnabled(true);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
cfg.setContinuousBusinessCollectionEnabled(false);cfg.setCollectionMode("OFF");clock.advance(86400);
var completion=new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null);
var restarted=new SelfopEgressService(mapper,cfg,clock);restarted.complete(completion);restarted.complete(completion);
assertThat(budget().reservedHttp).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);
assertThat(businessBudget().http).isEqualTo(1);assertThat(businessBudget().confirmedHttp).isEqualTo(1);
}
@Test void continuousRootInProductionAlsoRequiresSwitchAndValidPersistedPlannerIdentity(){
continuousBusinessRoot();cfg.setEnvironment("production");cfg.setExclusiveGrantExpiresAtUtc(now().plusDays(2));
cfg.setContinuousBusinessCollectionEnabled(false);
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
cfg.setContinuousBusinessCollectionEnabled(true);task.setRequestedBy("manual-operator");
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_INVALID");
task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);success(reserve(Operation.WAREHOUSES,1,null,null),Operation.WAREHOUSES);
assertThat(businessBudget().confirmedHttp).isEqualTo(1);
task.setStatus("DEAD");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_SESSION_ROOT_STOPPED");
}
@Test void continuousRootAuthorizeStillChecksFenceStopAndPendingExpiry(){
continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null);
account.setFenceToken(8L);
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_FENCE_LOST");
account.setFenceToken(7L);clock.advance(121);
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("EGRESS_PERMIT_REPLAY_OR_EXPIRED");
assertThat(budget().pending.consumed).isFalse();assertThat(businessBudget().confirmedHttp).isZero();
var b=businessBudget();b.stopReason="BUSINESS_MANUAL_STOP";task.setBusinessBudgetJson(SelfopSupport.json(b));
assertThatThrownBy(()->service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER))).hasMessage("BUSINESS_MANUAL_STOP");
assertThat(task.getStatus()).isEqualTo("DEAD");
}
@Test void continuousRootStillPreservesIntervalSingleFlightAndUnknownOutcomeAfterRestart(){
continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null);
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_REQUEST_INFLIGHT");
assertThat(budget().reservedHttp).isEqualTo(1);
service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,null,200,200,false,null));
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT");
clock.advance(1);assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,1,null,null)).hasMessage("ACCOUNT_INTERVAL_WAIT");
clock.advance(1);var pending=reserve(Operation.OUTBOUND_QUERY,1,null,null);
service.authorize(new AuthorizationRequest(pending,Operation.OUTBOUND_QUERY,PROVIDER));clock.advance(86400);
account.setLeaseUntilUtc(now().plusMinutes(5));var restarted=new SelfopEgressService(mapper,cfg,clock);
assertThatThrownBy(()->restarted.reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_COOLDOWN_WAIT");
assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(1);assertThat(budget().pending.abandoned).isTrue();
assertThat(account.getApiPauseReason()).isEqualTo("REQUEST_OUTCOME_UNKNOWN");
}
@Test void continuousRootActual429AndAuthorizationFailureKeepAccountProtections(){
continuousBusinessRoot();var c=reserve(Operation.WAREHOUSES,1,null,null);service.authorize(new AuthorizationRequest(c,Operation.WAREHOUSES,PROVIDER));
service.complete(new CompletionRequest(c,Operation.WAREHOUSES,PROVIDER,1,"LINGXING_WMS_HTTP_RATE_LIMITED",429,null,true,null));
assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("ACCOUNT_COOLDOWN_WAIT");
clock.advance(cfg.getCooldownSeconds()+1);var next=reserve(Operation.OUTBOUND_DETAIL,0,"A","C/DO");service.authorize(new AuthorizationRequest(next,Operation.OUTBOUND_DETAIL,PROVIDER));
service.complete(new CompletionRequest(next,Operation.OUTBOUND_DETAIL,PROVIDER,1,"LINGXING_WMS_AUTHORIZATION_REJECTED",401,null,true,null));
clock.advance(86400);account.setLeaseUntilUtc(now().plusMinutes(5));
assertThatThrownBy(()->new SelfopEgressService(mapper,cfg,clock).reserve(context(),Operation.WAREHOUSES,1,null,null,INTERNAL)).hasMessage("ACCOUNT_REQUIRES_REVIEW");
assertThat(account.getApiPauseReason()).isEqualTo("AUTHORIZATION_REQUIRES_REVIEW");assertThat(budget().rateLimitFailures).isEqualTo(1);
assertThat(budget().reservedHttp).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);
}
@Test void businessSurpassesPriorDailyAndRootHttpCapsAcrossRestartWithoutRenewingDeadline(){
testBusiness(5000,300);cfg.setDailyRequestBudget(12000);cfg.setTransferredDailyHttpCount(12000);
var rootBudget=businessBudget();rootBudget.maxHttp=5000;rootBudget.http=5000;rootBudget.confirmedHttp=5000;task.setBusinessBudgetJson(SelfopSupport.json(rootBudget));
@@ -33,10 +33,40 @@ class SelfopSalesPublisherTest {
run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);run.setWarehouseDailyReady(true);
run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2));
run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0));
SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(run.getCoveredFromUtc(),run.getCoveredUntilUtc())));
task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED");
when(locks.lockScope(1L)).thenReturn(scope); when(locks.lockRun(10L)).thenReturn(run); when(locks.lockTask(20L)).thenReturn(task);
}
private void enableSyntheticPublication() { config.setCollectionMode("BUSINESS"); config.setEnvironment("production"); config.setSalesPublicationEnabled(true); config.setQuantityUnit("PCS"); }
@Test void disconnectedTargetPublishesOnlyWithFrozenProofAndKeepsGapUnknown(){
enableSyntheticPublication();var first=LocalDateTime.of(2026,10,6,16,0);var latest=LocalDateTime.of(2026,10,11,16,0);
run.setCoveredFromUtc(first);run.setCoveredUntilUtc(first.plusDays(1));
task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1));
SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1))));
assertEquals("SELFOP_TASK_COVERAGE_UNPROVEN",assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)).getMessage());
noWrites();
SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1)),new SelfopSalesCoverage.Interval(latest,latest.plusDays(1))));
publisher.publish(1,10,"worker",3);assertEquals("SUCCEEDED",task.getStatus());
assertEquals(first.plusDays(1),scope.getDiscoveryUntilUtc());
assertFalse(SelfopSalesCoverage.contains(SelfopSalesCoverage.evidence(run),first,latest.plusDays(1)));
}
@Test void latestDayCanPublishBeforeOldPlannedAnchorIsFilled(){
enableSyntheticPublication();var latest=LocalDateTime.of(2026,10,11,16,0);
scope.setInitialFromUtc(latest.minusMonths(2));run.setCoveredFromUtc(null);run.setCoveredUntilUtc(null);run.setCoverageStatus("UNKNOWN");
task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1));
SelfopSalesCoverage.freeze(run,java.util.List.of(new SelfopSalesCoverage.Interval(latest,latest.plusDays(1))));
publisher.publish(1,10,"worker",3);assertEquals("SUCCEEDED",task.getStatus());
assertEquals(latest.minusMonths(2),scope.getInitialFromUtc());assertNull(run.getCoveredUntilUtc());
}
@Test void reuseCannotCompleteLatestDayUsingOnlyOldIsland(){
enableSyntheticPublication();var first=LocalDateTime.of(2026,10,6,16,0);var latest=LocalDateTime.of(2026,10,11,16,0);
var published=new SelfopSyncRun();published.setId(9L);published.setScopeId(1L);published.setScopeHash(scope.getScopeHash());
published.setExecutionPurpose("BUSINESS");published.setStatus("PUBLISHED");published.setWarehouseDailyReady(true);published.setCalculatedFactRevision(7L);
published.setScopeSnapshotJson("{}");SelfopSalesCoverage.freeze(published,java.util.List.of(new SelfopSalesCoverage.Interval(first,first.plusDays(1))));
when(runs.selectById(9L)).thenReturn(published);task.setQueryFromUtc(latest);task.setQueryUntilUtc(latest.plusDays(1));
assertEquals("SELFOP_TASK_COVERAGE_UNPROVEN",assertThrows(IllegalStateException.class,()->publisher.reuse(1,10,9,"worker",3)).getMessage());
assertEquals("RUNNING",task.getStatus());verify(tasks,never()).update(any(),any());
}
private void noWrites() { verifyNoInteractions(scopes,runs,tasks); assertEquals(9L,scope.getPublishedRunId()); }
@Test void salesPublicationIsDisabledByDefaultBeforeAnyDatabaseRead() {
@@ -16,6 +16,95 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class SelfopSchedulePlannerTest {
@Test void continuousInventoryWaitsForTodaysSlotAndNeverBackfillsPreviousDays(){
var state=continuousState();
var manual7=dayRoot("RUNNING",7,"manual:7");var manual6=dayRoot("READY",6,"manual:6");
state.add(manual7);state.add(manual6);
planner.planContinuous(LocalDateTime.of(2026,10,12,23,59));
assertThat(state).noneMatch(t->"WAREHOUSE_SYNC".equals(t.getTaskRole()));
planner.planContinuous(LocalDateTime.of(2026,10,13,0,0));
var inventory=state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())).toList();
assertThat(inventory).hasSize(1);assertThat(inventory.get(0).getPlannedAtUtc()).isEqualTo(LocalDateTime.of(2026,10,13,0,0));
assertThat(inventory.get(0).getNextRetryAtUtc()).isEqualTo(inventory.get(0).getPlannedAtUtc());
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(inventory.get(0))).isTrue();
planner.planContinuous(LocalDateTime.of(2026,10,13,12,0));
planner.planContinuous(LocalDateTime.of(2026,10,13,16,0));
assertThat(state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole()))).hasSize(1);
planner.planContinuous(LocalDateTime.of(2026,10,14,0,0));
assertThat(state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole()))).hasSize(2);
assertThat(manual7.getStatus()).isEqualTo("RUNNING");assertThat(manual6.getStatus()).isEqualTo("READY");
verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(tasks,never()).updateById(any(SelfopSyncTask.class));
}
@Test void continuousInventoryRegistersOneOverdueSlotPerConfiguredWarehouse(){
var state=continuousState();config.getWarehouses().put("WH-B",new SelfopAnalyticsProperties.Warehouse());
var outboundA=scope(10L,"WH-A","OUTBOUND");outboundA.setBusinessTimezone("Asia/Shanghai");outboundA.setSourceTimezone("Asia/Shanghai");
var outboundB=scope(20L,"WH-B","OUTBOUND");outboundB.setBusinessTimezone("Asia/Shanghai");outboundB.setSourceTimezone("Asia/Shanghai");
doAnswer(call->{
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<?> query=call.getArgument(0);query.getSqlSegment();
return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(inventoryScope(11L,"WH-A"),inventoryScope(21L,"WH-B")):List.of(outboundA,outboundB);}).when(scopes).selectList(any());
planner.planContinuous(LocalDateTime.of(2026,10,13,12,0));planner.planContinuous(LocalDateTime.of(2026,10,13,12,1));
var inventory=state.stream().filter(t->"WAREHOUSE_SYNC".equals(t.getTaskRole())).toList();
assertThat(inventory).hasSize(2);assertThat(inventory).extracting(SelfopSyncTask::getWarehouseCode).containsExactlyInAnyOrder("WH-A","WH-B");
assertThat(inventory).allMatch(t->t.getPlannedAtUtc().equals(LocalDateTime.of(2026,10,13,0,0))&&SelfopCollectionPolicy.continuousBusinessRoot(t));
}
@Test void continuousPoolPersistsCompleteDaysNewestFirstAndNeverChangesCoverageAnchor(){
var state=continuousState();
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
assertThat(state).hasSize(62);
assertThat(state.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,11,16,0));
assertThat(state.get(60).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,16,0));
assertThat(state).allMatch(SelfopCollectionPolicy::continuousBusinessRoot);
verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(taskService,never()).initialize();
planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(62);
planner.planContinuous(LocalDateTime.of(2026,10,13,16,4));assertThat(state).hasSize(62);
planner.planContinuous(LocalDateTime.of(2026,10,13,16,5));assertThat(state).hasSize(63);
assertThat(state.get(62).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,12,16,0));
assertThat(state).anyMatch(t->LocalDateTime.of(2026,8,12,16,0).equals(t.getQueryFromUtc()));
}
@Test void deadOldManualRootDoesNotMaskSuccessfulOrActiveDayAndContinuousFailureIsNotRecreated(){
var state=continuousState();
state.add(dayRoot("DEAD",12,"manual:old"));state.add(dayRoot("SUCCEEDED",12,"manual:success"));
state.add(dayRoot("RUNNING",11,"manual:running"));
state.add(dayRoot("DEAD",10,"manual:failed-only"));
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).hasSize(60);
assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).noneMatch(t->LocalDateTime.of(2026,10,11,16,0).equals(t.getQueryFromUtc()));
var root=state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot).findFirst().orElseThrow();root.setStatus("DEAD");
int count=state.size();planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(count);
}
@Test void continuousTickReReadsOneTaskAfterEverySafeDispatch(){
continuousState();var subject=spy(planner);doNothing().when(subject).planContinuous(any());
var old=dayRoot("RETRY_WAIT",10,"manual:old");old.setId(10L);old.setRequestedBy("operator");
var fresh=dayRoot("READY",12,"manual:new");fresh.setId(12L);fresh.setRequestedBy("operator");
when(taskService.due(1)).thenReturn(List.of(old),List.of(fresh));
subject.tick();subject.tick();
var order=inOrder(taskService,execution);order.verify(taskService).due(1);order.verify(execution).execute(10L);
order.verify(taskService).due(1);order.verify(execution).execute(12L);verify(taskService,never()).due(10);
}
private List<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 SelfopTaskService taskService = mock(SelfopTaskService.class);
private final SelfopExecutionService execution = mock(SelfopExecutionService.class);
@@ -14,6 +14,37 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class SelfopTaskServiceTest {
@Test void automaticInventoryRootRequiresFrozenInventoryScopeAndDoesNotAdmitOldScheduledTasks(){
config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true);
var scope=scope("INVENTORY");scope.setSourceAccount("synthetic-account");scope.setWarehouseCode("WH-A");scope.setScopeHash("b".repeat(64));
scope.setScopeJson(json(Map.of("sourceAccount","synthetic-account","warehouseCode","WH-A","dataFamily","INVENTORY","businessTimezone","Asia/Shanghai")));
when(scopes.selectById(10L)).thenReturn(scope);
var task=task("READY");task.setTaskType("SCHEDULED");task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);
task.setScopeHash(scope.getScopeHash());task.setQueryScopeJson(scope.getScopeJson());task.setPlannedAtUtc(LocalDateTime.of(2026,10,14,0,0));
task.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12));
service.create(task);assertThat(task.getBusinessRootTaskId()).isEqualTo(task.getId());
assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true)).isTrue();
assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,false)).isFalse();
task.setRequestKey("slot:old");assertThat(SelfopCollectionPolicy.controlledBusinessTask(task,tasks::selectById,true)).isFalse();
task.setRequestKey("continuous:inventory:WH-A:2026-10-14T00:00:"+"b".repeat(12));
task.setQueryScopeJson(json(Map.of("dataFamily","OUTBOUND")));assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isFalse();
}
@Test void continuousRootCreationFreezesUnboundedSessionAndUsesAccountQueue(){
config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true);config.setSourceAccount("synthetic-account");
var account=new SelfopOutboundDiscoveryScope();account.setId(20L);account.setNextSequence(1L);account.setNextObservationSequence(1L);
when(locks.lockDiscovery(20L)).thenReturn(account);
var task=new SelfopSyncTask();task.setSourceAccount("synthetic-account");task.setExecutionPurpose("BUSINESS");
task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType("BACKFILL");task.setDiscoveryScopeId(20L);
task.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);task.setScopeHash("a".repeat(64));
task.setRequestKey("continuous:outbound:2026-10-12:"+"a".repeat(12));task.setRequestHash("digest");
task.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));task.setQueryUntilUtc(LocalDateTime.of(2026,10,12,16,0));
task.setQueryScopeJson(json(new SelfopDiscoveryService.Query("2026-10-12T00:00","2026-10-13T00:00",null,null,null)));
service.create(task);assertThat(task.getBusinessRootTaskId()).isEqualTo(task.getId());
var budget=read(task.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);
assertThat(budget.continuous).isTrue();assertThat(budget.deadlineAtUtc).isNull();assertThat(budget.limit(utcNow().plusYears(1))).isNull();
service.due(1);verify(locks).dueContinuous(any(),eq(1),eq("synthetic-account"));verify(locks,never()).due(any(),anyInt(),anyString());
task.setScopeHash("invalid-hash");assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isFalse();
}
private final SelfopSyncScopeMapper scopes = mock(SelfopSyncScopeMapper.class);
private final SelfopOutboundDiscoveryScopeMapper accounts = mock(SelfopOutboundDiscoveryScopeMapper.class);
private final SelfopSyncTaskMapper tasks = mock(SelfopSyncTaskMapper.class);
@@ -414,6 +445,19 @@ class SelfopTaskServiceTest {
assertThatThrownBy(()->service.waitForBudget(1L,20L,utcNow().plusDays(1),lease)).isInstanceOf(SelfopTaskService.BusinessSessionStoppedException.class);
verify(tasks,never()).updateById(any(SelfopSyncTask.class));verify(runs,never()).updateById(any(SelfopSyncRun.class));
}
@Test void continuousFailureStopsInsteadOfBecomingAnotherScheduledRetry(){
var root=boundedRoot(0,utcNow().plusMinutes(3));config.setContinuousBusinessCollectionEnabled(true);
root.setTaskRole("ACCOUNT_DISCOVERY");root.setTaskType("BACKFILL");root.setScopeId(null);root.setWarehouseCode(null);root.setDiscoveryScopeId(99L);
root.setRequestedBy(SelfopCollectionPolicy.CONTINUOUS_PLANNER);root.setScopeHash("a".repeat(64));
root.setRequestKey("continuous:outbound:2026-10-12:"+"a".repeat(12));root.setStatus("RUNNING");
root.setQueryFromUtc(LocalDateTime.of(2026,10,11,16,0));root.setQueryUntilUtc(LocalDateTime.of(2026,10,12,16,0));
root.setQueryScopeJson(json(new SelfopDiscoveryService.Query("2026-10-12T00:00","2026-10-13T00:00",null,null,null)));
root.setBusinessBudgetJson(com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.freezeContinuous(utcNow()));
var run=run("FETCHING");when(locks.lockRun(20L)).thenReturn(run);
service.fail(1L,20L,"LINGXING_WMS_RATE_LIMITED",true,new SelfopLeaseService.Lease(99L,"owner",7L,true));
assertThat(root.getStatus()).isEqualTo("DEAD");assertThat(root.getNextRetryAtUtc()).isNull();
assertThat(run.getStatus()).isEqualTo("FAILED");assertThat(run.getErrorCode()).isEqualTo("LINGXING_WMS_RATE_LIMITED");
}
@Test void egressStopReasonSurvivesExecutorFailureHandling() {
var root=boundedRoot(7,utcNow().plusMinutes(3));
var budget=read(root.getBusinessBudgetJson(),com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget.class);