feat(self-operated): persist continuous collection and frozen coverage
This commit is contained in:
+21
-1
@@ -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());
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+43
@@ -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));
|
||||
|
||||
+65
@@ -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");
|
||||
|
||||
+1
-1
@@ -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){
|
||||
|
||||
+21
-11
@@ -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 {
|
||||
|
||||
+40
@@ -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);
|
||||
|
||||
+17
-8
@@ -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){
|
||||
|
||||
+2
-2
@@ -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());}
|
||||
|
||||
+8
-8
@@ -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->{
|
||||
|
||||
+9
@@ -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() {
|
||||
|
||||
+133
@@ -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));
|
||||
|
||||
+30
@@ -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() {
|
||||
|
||||
+89
@@ -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);
|
||||
|
||||
+44
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user