fix(selfop): restore unique same-second inventory chains
This commit is contained in:
+2
-2
@@ -109,12 +109,12 @@ public class InventoryHistoryBuilder {
|
||||
var sales=jdbc.queryForList("SELECT published_run_id FROM ll_selfop_sync_scope WHERE source_account=? AND warehouse_code=? AND data_family='OUTBOUND'",account,warehouse);
|
||||
Long salesRun=sales.isEmpty()||sales.get(0).get("published_run_id")==null?null:((Number)sales.get(0).get("published_run_id")).longValue();
|
||||
String inputHash=versions.factHash(account,warehouse);
|
||||
String signature=hash(Arrays.asList(inputHash,anchor,reference,from,until,salesRun,coverage));
|
||||
String signature=hash(Arrays.asList(RULE,CALCULATION_REVISION,inputHash,anchor,reference,from,until,salesRun,coverage));
|
||||
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=? AND status='SUCCEEDED'",Long.class,account,TYPE,signature)>0)return null;
|
||||
// Failed identical inputs get bounded attempts, instead of an infinite hot loop.
|
||||
if(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE source_account=? AND task_type=? AND request_hash=?",Long.class,account,TYPE,signature)>=3)return null;
|
||||
long taskId=IdWorker.getId(),runId=IdWorker.getId(),group=IdWorker.getId();var now=utcNow();
|
||||
String evidence=json(Map.of("warehouse",warehouse,"anchor",anchor,"reference",reference,"coverageRuns",coverageRuns,"inputStagesRun",runId,"rule",RULE));
|
||||
String evidence=json(Map.of("warehouse",warehouse,"anchor",anchor,"reference",reference,"coverageRuns",coverageRuns,"inputStagesRun",runId,"rule",RULE,"calculationRevision",CALCULATION_REVISION));
|
||||
var task=new SelfopSyncTask();task.setId(taskId);task.setTaskNo("LXRESTORE-"+UUID.randomUUID().toString().replace("-",""));
|
||||
task.setSourceAccount(account);task.setDiscoveryScopeId(jdbc.queryForObject("SELECT id FROM ll_selfop_outbound_discovery_scope WHERE source_account=?",Long.class,account));task.setExecutionPurpose("BUSINESS");
|
||||
task.setTaskRole("ACCOUNT_DISCOVERY");task.setTaskType(TYPE);task.setRequestKey("history-build:"+UUID.randomUUID());task.setRequestHash(signature);
|
||||
|
||||
+64
-13
@@ -7,6 +7,7 @@ import java.util.*;
|
||||
/** Pure reconstruction over frozen, complete source intervals. Never invents absent stock. */
|
||||
public final class InventoryHistoryCalculator {
|
||||
public static final String RULE = "FLOW_EOD_V1";
|
||||
public static final int CALCULATION_REVISION = 2;
|
||||
private static final Set<String> VERIFIED_TYPES = Set.of("DO", "PA", "IMO", "IAT", "RI");
|
||||
private static final ZoneOffset BUSINESS = ZoneOffset.ofHours(8);
|
||||
private InventoryHistoryCalculator() { }
|
||||
@@ -97,12 +98,9 @@ public final class InventoryHistoryCalculator {
|
||||
for(Flow f:chain) seconds.computeIfAbsent(f.at(),k->new ArrayList<>()).add(f);
|
||||
BigDecimal prior=null;
|
||||
for(var group:seconds.values()) {
|
||||
if(group.size()>8) return "CHAIN_ORDER_AMBIGUOUS";
|
||||
var endings=new ArrayList<BigDecimal>();
|
||||
paths(group,new boolean[group.size()],prior,0,endings);
|
||||
if(endings.isEmpty()) return "CHAIN_DISCONTINUITY";
|
||||
if(endings.size()!=1) return "CHAIN_ORDER_AMBIGUOUS";
|
||||
prior=endings.get(0);
|
||||
var resolved=resolveChain(group,prior);
|
||||
if(resolved.reason()!=null) return resolved.reason();
|
||||
prior=resolved.ending();
|
||||
}
|
||||
}
|
||||
var deltas=new TreeMap<Instant,BigDecimal>(Comparator.reverseOrder());
|
||||
@@ -114,14 +112,67 @@ public final class InventoryHistoryCalculator {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static void paths(List<Flow> group,boolean[] used,BigDecimal balance,int depth,List<BigDecimal> endings) {
|
||||
if(endings.size()>1) return;
|
||||
if(depth==group.size()) { endings.add(balance); return; }
|
||||
for(int i=0;i<group.size();i++) if(!used[i]) {
|
||||
Flow f=group.get(i);
|
||||
if(balance!=null && balance.add(f.delta()).compareTo(f.closing())!=0) continue;
|
||||
used[i]=true;paths(group,used,f.closing(),depth+1,endings);used[i]=false;
|
||||
private record ChainResult(BigDecimal ending,String reason) { }
|
||||
private static ChainResult resolveChain(List<Flow> group,BigDecimal prior) {
|
||||
// Every flow is a distinct directed edge: opening balance -> closing balance.
|
||||
// A valid order is an Euler trail, using every edge exactly once.
|
||||
var degree=new TreeMap<BigDecimal,Integer>();
|
||||
for(var f:group) {
|
||||
degree.merge(f.closing().subtract(f.delta()),1,Integer::sum);
|
||||
degree.merge(f.closing(),-1,Integer::sum);
|
||||
}
|
||||
BigDecimal start=null;int starts=0,ends=0;
|
||||
for(var entry:degree.entrySet()) {
|
||||
int difference=entry.getValue();
|
||||
if(difference==1){start=entry.getKey();starts++;}
|
||||
else if(difference==-1)ends++;
|
||||
else if(difference!=0)return new ChainResult(null,"CHAIN_DISCONTINUITY");
|
||||
}
|
||||
if(!((starts==1&&ends==1)||(starts==0&&ends==0)))return new ChainResult(null,"CHAIN_DISCONTINUITY");
|
||||
if(prior!=null) {
|
||||
if(start!=null&&start.compareTo(prior)!=0)return new ChainResult(null,"CHAIN_DISCONTINUITY");
|
||||
start=prior;
|
||||
} else if(start==null)start=group.get(0).closing().subtract(group.get(0).delta());
|
||||
var first=orderedTrail(group,start,false);
|
||||
if(first==null)return new ChainResult(null,"CHAIN_DISCONTINUITY");
|
||||
// Without a prior balance a circuit with multiple edges admits rotated orders.
|
||||
if(prior==null&&starts==0&&group.size()>1)return new ChainResult(null,"CHAIN_ORDER_AMBIGUOUS");
|
||||
// Hierholzer with opposite edge priorities yields the lexicographically first
|
||||
// and last complete trails. Equality proves uniqueness, including parallel edges.
|
||||
// Input indices only define opposing priorities; neither IDs nor response order
|
||||
// are accepted as chronology. Two iterative passes avoid factorial search/recursion.
|
||||
var last=orderedTrail(group,start,true);
|
||||
if(!Arrays.equals(first,last))return new ChainResult(null,"CHAIN_ORDER_AMBIGUOUS");
|
||||
return new ChainResult(group.get(first[first.length-1]).closing(),null);
|
||||
}
|
||||
private static int[] orderedTrail(List<Flow> group,BigDecimal start,boolean reverse) {
|
||||
var outgoing=new TreeMap<BigDecimal,ArrayDeque<Integer>>();
|
||||
for(int i=0;i<group.size();i++) {
|
||||
var f=group.get(i);
|
||||
outgoing.computeIfAbsent(f.closing().subtract(f.delta()),k->new ArrayDeque<>()).addLast(i);
|
||||
}
|
||||
var stack=new ArrayDeque<Integer>();
|
||||
int[] trail=new int[group.size()];int position=trail.length;
|
||||
BigDecimal balance=start;
|
||||
while(true) {
|
||||
var edges=outgoing.get(balance);
|
||||
if(edges!=null&&!edges.isEmpty()) {
|
||||
int edge=reverse?edges.removeLast():edges.removeFirst();
|
||||
stack.push(edge);balance=group.get(edge).closing();
|
||||
} else {
|
||||
if(stack.isEmpty())break;
|
||||
int edge=stack.pop();trail[--position]=edge;
|
||||
var f=group.get(edge);balance=f.closing().subtract(f.delta());
|
||||
}
|
||||
}
|
||||
if(position!=0)return null; // Disconnected edges cannot be silently discarded.
|
||||
balance=start;
|
||||
for(int edge:trail) {
|
||||
var f=group.get(edge);
|
||||
if(balance.add(f.delta()).compareTo(f.closing())!=0)return null;
|
||||
balance=f.closing();
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
public static boolean covered(List<Interval> intervals,Instant from,Instant until) {
|
||||
Instant cursor=from;
|
||||
|
||||
Reference in New Issue
Block a user