fix(selfop): restore unique same-second inventory chains
This commit is contained in:
+90
@@ -69,6 +69,96 @@ class InventoryHistoryCalculatorTest {
|
||||
var rows=List.of(flow("1",GOOD,"DO","-1","38","2026-10-15T01:00:00"),flow("2",GOOD,"DO","-1","35","2026-10-15T02:00:00"));
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"35")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
|
||||
}
|
||||
@Test void restoresHundredSameSecondDecrementsAcrossSixtyDaysRegardlessOfSourceOrder() {
|
||||
var group=new ArrayList<Flow>();
|
||||
for(int i=0;i<100;i++)group.add(flow("F"+(100-i),GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00"));
|
||||
for(int seed=0;seed<10;seed++) {
|
||||
Collections.shuffle(group,new Random(seed));
|
||||
for(boolean hasPrior:List.of(false,true)) {
|
||||
var rows=new ArrayList<>(group);
|
||||
if(hasPrior)rows.add(flow("prior",GOOD,"DO","-1","727","2026-10-14T23:59:59"));
|
||||
var i=input(rows,List.of(anchor(GOOD,"627")));
|
||||
var result=reconstruct(new Input(DAY.minusDays(59),i.until(),i.candidates(),i.flows(),i.anchors(),
|
||||
List.of(new Interval(DAY.minusDays(59).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),at("2026-10-15T08:00:02")))));
|
||||
assertThat(result).hasSize(60).allMatch(d->"VALID".equals(d.status()));
|
||||
assertThat(result.get(59).quantity()).isEqualByComparingTo("727");
|
||||
assertThat(result.get(0).quantity()).isEqualByComparingTo(hasPrior?"728":"727");
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test void forcedCycleBeforeExitIsUniqueDespiteMultipleOutgoingEdgesAndDecimalScales() {
|
||||
// Taking the exit first is a dead end, not a second complete order.
|
||||
var rows=List.of(flow("exit",GOOD,"DO","-1.00","0.0","2026-10-15T01:00:00"),
|
||||
flow("up",GOOD,"PA","1","2.000","2026-10-15T01:00:00"),
|
||||
flow("down",GOOD,"DO","-1","1.000000","2026-10-15T01:00:00"));
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).status()).isEqualTo("VALID");
|
||||
}
|
||||
@Test void twoCyclesWithSameFinalBalanceRemainAmbiguous() {
|
||||
var rows=List.of(flow("prior",GOOD,"PA","0","2","2026-10-15T00:59:59"),
|
||||
flow("a",GOOD,"PA","1","3","2026-10-15T01:00:00"),flow("b",GOOD,"DO","-1","2","2026-10-15T01:00:00"),
|
||||
flow("c",GOOD,"PA","2","4","2026-10-15T01:00:00"),flow("d",GOOD,"DO","-2","2","2026-10-15T01:00:00"));
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS");
|
||||
}
|
||||
@Test void distinctIdsWithDuplicateEdgesAreNotDeduplicatedIntoAUniqueOrder() {
|
||||
var rows=List.of(flow("a",GOOD,"PA","1","2","2026-10-15T01:00:00"),
|
||||
flow("b",GOOD,"PA","1","2","2026-10-15T01:00:00"),flow("return",GOOD,"DO","-1","1","2026-10-15T01:00:00"));
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS");
|
||||
}
|
||||
@Test void disconnectedCycleCannotBeIgnoredEvenWhenDegreesMatch() {
|
||||
var rows=List.of(flow("path",GOOD,"DO","-1","1","2026-10-15T01:00:00"),
|
||||
flow("loop",GOOD,"PA","0","3","2026-10-15T01:00:00"));
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
|
||||
}
|
||||
@Test void longChainWithMissingEdgeOrWrongPriorRemainsUnknown() {
|
||||
var rows=new ArrayList<Flow>();
|
||||
for(int i=0;i<100;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00"));
|
||||
rows.add(flow("prior",GOOD,"PA","0","728","2026-10-15T00:59:59"));
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
|
||||
rows.remove(rows.size()-1);rows.remove(50);
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
|
||||
}
|
||||
@Test void largeUniqueChainUsesIterativeTraversal() {
|
||||
var rows=new ArrayList<Flow>();
|
||||
for(int i=0;i<20_000;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(20_000-i),"2026-10-15T01:00:00"));
|
||||
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"1")))).get(0).quantity()).isEqualByComparingTo("20001");
|
||||
}
|
||||
@Test void graphSolverMatchesExhaustiveOrdersForAllSmallMultigraphs() {
|
||||
// Independent permutation oracle: all multisets of up to five directed edges
|
||||
// on three balances, including loops/parallel edges, with every possible prior.
|
||||
for(int size=1;size<=5;size++)checkGraphs(new ArrayList<>(),0,size);
|
||||
}
|
||||
private void checkGraphs(List<Integer> edges,int minimum,int remaining) {
|
||||
if(remaining>0) {
|
||||
for(int edge=minimum;edge<9;edge++) {
|
||||
edges.add(edge);checkGraphs(edges,edge,remaining-1);edges.remove(edges.size()-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var group=new ArrayList<Flow>();
|
||||
for(int i=0;i<edges.size();i++) {
|
||||
int edge=edges.get(i),opening=edge/3,closing=edge%3;
|
||||
group.add(flow("F"+i,GOOD,"PA",String.valueOf(closing-opening),String.valueOf(closing),"2026-10-15T01:00:00"));
|
||||
}
|
||||
for(int prior=-1;prior<3;prior++) {
|
||||
var rows=new ArrayList<>(group);
|
||||
if(prior>=0)rows.add(flow("prior",GOOD,"PA","0",String.valueOf(prior),"2026-10-15T00:59:59"));
|
||||
int paths=countOrders(group,new boolean[group.size()],prior<0?null:BigDecimal.valueOf(prior),0);
|
||||
String reason=paths==0?"CHAIN_DISCONTINUITY":paths==1?null:"CHAIN_ORDER_AMBIGUOUS";
|
||||
var actual=reconstruct(input(rows,List.of(anchor(GOOD,"100")))).get(0);
|
||||
assertThat(actual.reason()).as("edges=%s prior=%s",edges,prior).isEqualTo(reason);
|
||||
}
|
||||
}
|
||||
private int countOrders(List<Flow> group,boolean[] used,BigDecimal balance,int depth) {
|
||||
if(depth==group.size())return 1;
|
||||
int count=0;
|
||||
for(int i=0;i<group.size();i++)if(!used[i]) {
|
||||
var f=group.get(i);
|
||||
if(balance!=null&&balance.add(f.delta()).compareTo(f.closing())!=0)continue;
|
||||
used[i]=true;count+=countOrders(group,used,f.closing(),depth+1);used[i]=false;
|
||||
if(count>1)return 2;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@Test void laterAnchorRevisionChangesEarlierDay() {
|
||||
var a=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
|
||||
var b=reconstruct(input(List.of(flow("f",GOOD,"DO","-2","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
|
||||
|
||||
+38
@@ -118,6 +118,44 @@ public class InventoryHistoryPipelineTest {
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class));
|
||||
builder.build("demo","W",30);assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class));
|
||||
}
|
||||
@Test void calculationRevisionRebuildsLegacyUnknownFromSavedFlowsExactlyOnce() {
|
||||
stock(100,DAY,727);stock(101,DAY.plusDays(1),627);
|
||||
var rows=new ArrayList<JsonNode>();
|
||||
for(int i=0;i<100;i++)rows.add(flow("F"+i,"-1",String.valueOf(726-i)));
|
||||
Collections.shuffle(rows,new Random(42));
|
||||
collect(slice(),DAY,page(1,100,rows));
|
||||
collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of()));
|
||||
builder.build("demo","W",30);
|
||||
// Seed the pre-fix published state, including its exact former dedup signature.
|
||||
var coverage=List.of(new InventoryHistoryCalculator.Interval(
|
||||
DAY.atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DAY.plusDays(1).atTime(8,0,2).toInstant(ZoneOffset.ofHours(8))));
|
||||
String legacyHash=hash(Arrays.asList(versions.factHash("demo","W"),builder.anchor("demo","W",0),
|
||||
builder.anchor("demo","W",1),DAY,DAY.plusDays(1),null,coverage));
|
||||
var legacyTask=jdbc.queryForMap("SELECT id,request_hash FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD'");
|
||||
assertNotEquals(legacyHash,legacyTask.get("request_hash"));
|
||||
jdbc.update("UPDATE ll_selfop_sync_task SET request_hash=? WHERE id=?",legacyHash,legacyTask.get("id"));
|
||||
jdbc.update("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='CHAIN_ORDER_AMBIGUOUS'");
|
||||
jdbc.update("UPDATE ll_selfop_inventory_day_version SET evidence_json=?",json(Map.of("rule","FLOW_EOD_V1")));
|
||||
var flowHash=versions.factHash("demo","W");
|
||||
clearInvocations(client);
|
||||
builder.build("demo","W",30);
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD' AND status='SUCCEEDED'",Integer.class));
|
||||
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class));
|
||||
var current=jdbc.queryForMap("SELECT d.physical_quantity,d.quality_status,v.rule_version,v.evidence_json FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.head_slot=1");
|
||||
assertEquals(0,new BigDecimal("627").compareTo((BigDecimal)current.get("physical_quantity")));
|
||||
assertEquals("VALID",current.get("quality_status"));
|
||||
assertEquals("FLOW_EOD_V1",current.get("rule_version"));
|
||||
var evidence=read(current.get("evidence_json").toString(),JsonNode.class);
|
||||
assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,evidence.path("calculationRevision").asInt());
|
||||
var runEvidence=read(jdbc.queryForObject("SELECT scope_snapshot_json FROM ll_selfop_sync_run WHERE status='FACTS_APPLIED' AND last_phase='HISTORY_PUBLISHED' ORDER BY id DESC LIMIT 1",String.class),JsonNode.class);
|
||||
assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,runEvidence.path("calculationRevision").asInt());
|
||||
builder.build("demo","W",30);
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class));
|
||||
assertEquals(flowHash,versions.factHash("demo","W"));
|
||||
assertEquals(100,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
|
||||
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class));
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
void stock(long id,LocalDate day,int quantity) {
|
||||
var now=day.atTime(0,0);var r=new SelfopSyncRun();r.setId(id);r.setTaskId(id);r.setAttemptNo(1);r.setSourceAccount("demo");r.setWarehouseCode("W");r.setScopeId(2L);r.setRunRole("WAREHOUSE_SYNC");r.setExecutionPurpose("BUSINESS");r.setScopeHash("b".repeat(64));r.setScopeSnapshotJson("{}");r.setFenceToken(0L);r.setStatus("PUBLISHED");r.setLastPhase("PUBLISHED");r.setCoverageStatus("COMPLETE");r.setSourceTotalCount(1L);r.setUniqueObjectCount(1L);r.setPageCount(1);r.setCreatedAt(now);r.setUpdatedAt(now);r.setFetchStartedAtUtc(now);r.setFetchFinishedAtUtc(now.plusSeconds(1));r.setPublishedAtUtc(now.plusSeconds(2));runs.insert(r);
|
||||
jdbc.update("INSERT INTO ll_selfop_inventory_snapshot(id,created_at,updated_at,snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality,physical_quantity,observed_at_utc,quality_status) VALUES(?,?,?,?,'demo','C','W','SKU',0,?,?,'VALID')",id,now,now,id,quantity,now.plusSeconds(1));
|
||||
|
||||
Reference in New Issue
Block a user