fix(selfop): stabilize inventory and order quantity fingerprints
This commit is contained in:
+12
-1
@@ -25,7 +25,7 @@ class InventorySnapshotAssemblerTest {
|
||||
private SelfopSyncStage stage(SelfopPayloads.Stock stock) {
|
||||
var stage = new SelfopSyncStage(); stage.setRunId(10L); stage.setStageBatchId(10L); stage.setLastFetchRunId(10L);
|
||||
stage.setWarehouseCode(stock.warehouseCode()); stage.setObjectType("PRODUCT_STOCK"); stage.setValidationStatus("VALID");
|
||||
stage.setFirstPageNo(1); stage.setObservedAtUtc(time); stage.setPayloadJson(json(stock)); stage.setContentHash(hash(stock));
|
||||
stage.setFirstPageNo(1); stage.setObservedAtUtc(time); stage.setPayloadJson(json(stock)); stage.setContentHash(SelfopStockFingerprint.of(stock));
|
||||
stage.setObjectKeyHash(hash(List.of(stock.sourceAccount(), stock.customerCode(), stock.warehouseCode(), stock.skuCode(), stock.productQuality())));
|
||||
return stage;
|
||||
}
|
||||
@@ -44,6 +44,17 @@ class InventorySnapshotAssemblerTest {
|
||||
assertEquals("VALID", regular.getQualityStatus());
|
||||
assertEquals(BigDecimal.ZERO, candidate.snapshots().get(2).getAvailableQuantity());
|
||||
}
|
||||
@Test void persistedNumericNotationDoesNotChangeContentButChangedQuantityStillFails() {
|
||||
var original=stock("c","sku",0,new BigDecimal("1E+2"));
|
||||
var staged=stage(original);
|
||||
var persisted=stock("c","sku",0,new BigDecimal("100.0"));
|
||||
assertNotEquals(hash(original),hash(persisted));
|
||||
staged.setPayloadJson(json(persisted));
|
||||
assertEquals(0,new BigDecimal("100").compareTo(build(run(1),List.of(staged),List.of(),List.of(),List.of()).snapshots().get(0).getAvailableQuantity()));
|
||||
staged.setPayloadJson(json(stock("c","sku",0,new BigDecimal("101.0"))));
|
||||
assertEquals("SELFOP_INVENTORY_CONTENT_CHANGED",assertThrows(IllegalStateException.class,
|
||||
()->build(run(1),List.of(staged),List.of(),List.of(),List.of())).getMessage());
|
||||
}
|
||||
@Test void missingAndNegativeQuantitiesNeverTurnIntoZero() {
|
||||
var candidate = build(run(2), List.of(stage(stock("c", "unknown", 0, null)),
|
||||
stage(stock("c", "negative", 0, BigDecimal.ONE.negate()))), List.of(), List.of(), List.of());
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ class InventorySnapshotServiceTest {
|
||||
@Test void candidateBulkInsertHasIdsAndCapsConfiguredBatchSizeAt500() {
|
||||
run.setStatus("VALIDATED");run.setSourceTotalCount(1L);run.setUniqueObjectCount(1L);properties.setPersistenceBatchSize(1000);
|
||||
var payload=new SelfopPayloads.Stock("demo","c","A","sku",0,"Name",java.math.BigDecimal.TEN,java.math.BigDecimal.ONE,java.math.BigDecimal.ZERO,java.math.BigDecimal.ZERO,null);
|
||||
var stage=new SelfopSyncStage();stage.setId(100L);stage.setRunId(10L);stage.setStageBatchId(10L);stage.setLastFetchRunId(10L);stage.setObjectType("PRODUCT_STOCK");stage.setWarehouseCode("A");stage.setValidationStatus("VALID");stage.setFirstPageNo(1);stage.setObservedAtUtc(run.getFetchStartedAtUtc());stage.setPayloadJson(SelfopSupport.json(payload));stage.setContentHash(SelfopSupport.hash(payload));stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","c","A","sku",0)));
|
||||
var stage=new SelfopSyncStage();stage.setId(100L);stage.setRunId(10L);stage.setStageBatchId(10L);stage.setLastFetchRunId(10L);stage.setObjectType("PRODUCT_STOCK");stage.setWarehouseCode("A");stage.setValidationStatus("VALID");stage.setFirstPageNo(1);stage.setObservedAtUtc(run.getFetchStartedAtUtc());stage.setPayloadJson(SelfopSupport.json(payload));stage.setContentHash(SelfopStockFingerprint.of(payload));stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","c","A","sku",0)));
|
||||
when(stages.selectList(any())).thenReturn(List.of(stage));service.build(1L,10L,"owner",4L);
|
||||
verify(snapshots).insert(argThat((java.util.Collection<SelfopInventorySnapshot> rows) -> rows.size()==1 && rows.iterator().next().getId()!=null && Long.valueOf(10L).equals(rows.iterator().next().getSnapshotRunId())),eq(500));
|
||||
verify(profiles).insert(argThat((java.util.Collection<SelfopSkuProfile> rows) -> rows.size()==1 && rows.iterator().next().getId()!=null),eq(500));
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfopOutboundFingerprintTest {
|
||||
private SelfopPayloads.Order order(BigDecimal quantity) {
|
||||
return new SelfopPayloads.Order("source","owner","A","order",null,100,null,"2026-10-12 08:00:00",
|
||||
List.of(new SelfopPayloads.Item("sku",null,quantity,"PCS")));
|
||||
}
|
||||
@Test void scaleNegativeScaleAndScientificNotationDoNotChangeOrderOrFactFingerprint() {
|
||||
var baseline=order(new BigDecimal("1000"));
|
||||
for(String representation:List.of("1000.000000","1E+3","1.0E+3","10E+2")) {
|
||||
var equivalent=order(new BigDecimal(representation));
|
||||
assertEquals(SelfopOutboundFingerprint.order(baseline),SelfopOutboundFingerprint.order(equivalent));
|
||||
assertEquals(SelfopOutboundFingerprint.fact(baseline,"Asia/Shanghai","Asia/Shanghai","PCS"),
|
||||
SelfopOutboundFingerprint.fact(equivalent,"Asia/Shanghai","Asia/Shanghai","PCS"));
|
||||
}
|
||||
assertEquals(SelfopOutboundFingerprint.order(order(new BigDecimal("1.2300"))),SelfopOutboundFingerprint.order(order(new BigDecimal("1.23"))));
|
||||
}
|
||||
@Test void reorderedJsonAndDecimalRepresentationRoundTripKeepSameFingerprint() {
|
||||
var baseline=order(new BigDecimal("1000.000"));
|
||||
String persisted="{\"items\":[{\"quantityUnit\":\"PCS\",\"actualQuantity\":1.0e3,\"productName\":null,\"skuCode\":\"sku\"}],"
|
||||
+"\"sourceOutboundTime\":\"2026-10-12 08:00:00\",\"sourceCreatedTime\":null,\"sourceStatus\":100,\"sourceNo\":null,"
|
||||
+"\"deliveryNo\":\"order\",\"warehouseCode\":\"A\",\"customerCode\":\"owner\",\"sourceAccount\":\"source\"}";
|
||||
var loaded=SelfopSupport.read(persisted,SelfopPayloads.Order.class);
|
||||
assertEquals(SelfopOutboundFingerprint.order(baseline),SelfopOutboundFingerprint.order(loaded));
|
||||
assertNotEquals(SelfopSupport.hash(baseline),SelfopSupport.hash(loaded)); // generic hash remains unchanged
|
||||
}
|
||||
@Test void realQuantityChangesAndUnknownAreNotCollapsed() {
|
||||
assertNotEquals(SelfopOutboundFingerprint.order(order(BigDecimal.ONE)),SelfopOutboundFingerprint.order(order(new BigDecimal("1.000001"))));
|
||||
assertNotEquals(SelfopOutboundFingerprint.order(order(null)),SelfopOutboundFingerprint.order(order(BigDecimal.ZERO)));
|
||||
assertEquals(SelfopOutboundFingerprint.order(order(new BigDecimal("0.000"))),SelfopOutboundFingerprint.order(order(BigDecimal.ZERO)));
|
||||
}
|
||||
@Test void identitiesTextNullAndRawItemOrderStaySensitive() {
|
||||
var original=order(BigDecimal.ONE);
|
||||
var changed=new SelfopPayloads.Order("source","other-owner","A","order",null,100,null,"2026-10-12 08:00:00",original.items());
|
||||
assertNotEquals(SelfopOutboundFingerprint.order(original),SelfopOutboundFingerprint.order(changed));
|
||||
var emptySourceNo=new SelfopPayloads.Order("source","owner","A","order","",100,null,"2026-10-12 08:00:00",original.items());
|
||||
assertNotEquals(SelfopOutboundFingerprint.order(original),SelfopOutboundFingerprint.order(emptySourceNo));
|
||||
var a=new SelfopPayloads.Item("a",null,BigDecimal.ONE,"PCS");var b=new SelfopPayloads.Item("b",null,BigDecimal.ONE,"PCS");
|
||||
var ab=new SelfopPayloads.Order("source","owner","A","order",null,100,null,null,List.of(a,b));
|
||||
var ba=new SelfopPayloads.Order("source","owner","A","order",null,100,null,null,List.of(b,a));
|
||||
assertNotEquals(SelfopOutboundFingerprint.order(ab),SelfopOutboundFingerprint.order(ba));
|
||||
assertNotEquals(SelfopOutboundFingerprint.fact(original,"Asia/Shanghai","UTC","PCS"),SelfopOutboundFingerprint.fact(original,"Asia/Shanghai","Asia/Shanghai","PCS"));
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SelfopStockFingerprintTest {
|
||||
private SelfopPayloads.Stock stock(String owner,String sku,BigDecimal quantity) {
|
||||
return new SelfopPayloads.Stock("demo",owner,"warehouse",sku,0,"Name",quantity,quantity,quantity,quantity,"source-key");
|
||||
}
|
||||
@Test void equivalentNumbersAndJsonRoundTripHaveStableFingerprint() {
|
||||
var original=stock("owner","sku",new BigDecimal("1E+2"));
|
||||
var stored=SelfopSupport.read(SelfopSupport.json(stock("owner","sku",new BigDecimal("100.00"))),SelfopPayloads.Stock.class);
|
||||
assertNotEquals(SelfopSupport.hash(original),SelfopSupport.hash(stored));
|
||||
assertEquals(SelfopStockFingerprint.of(original),SelfopStockFingerprint.of(stored));
|
||||
assertEquals(SelfopStockFingerprint.of(stock("owner","sku",new BigDecimal("0.000"))),SelfopStockFingerprint.of(stock("owner","sku",BigDecimal.ZERO)));
|
||||
}
|
||||
@Test void unknownZeroRealQuantityAndIdentityRemainDistinct() {
|
||||
var base=stock("owner","sku",BigDecimal.ZERO);
|
||||
for(var different:new SelfopPayloads.Stock[]{stock("owner","sku",null),stock("owner","sku",new BigDecimal("0.001")),stock("other","sku",BigDecimal.ZERO),stock("owner","other",BigDecimal.ZERO)})
|
||||
assertNotEquals(SelfopStockFingerprint.of(base),SelfopStockFingerprint.of(different));
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -41,7 +41,7 @@ class OutboundFactServiceTest {
|
||||
when(runs.selectById(8L)).thenReturn(sourceRun); when(tasks.selectById(9L)).thenReturn(sourceTask);
|
||||
stage.setObjectKeyHash(SelfopSupport.hash(List.of("demo","owner","delivery"))); stage.setWarehouseCode("B"); stage.setObservationSequence(2L);
|
||||
stage.setPayloadJson(SelfopSupport.json(payload)); stage.setObservedAtUtc(SelfopSupport.utcNow());
|
||||
stage.setContentHash(SelfopSupport.hash(payload));
|
||||
stage.setContentHash(SelfopOutboundFingerprint.order(payload));
|
||||
SelfopRoutingManifest manifest=new SelfopRoutingManifest(); manifest.setDirectoryComplete(true);
|
||||
SelfopRoutingManifest.Entry entry=new SelfopRoutingManifest.Entry(); entry.setEffectiveStageId(30L); manifest.getObjects().put(stage.getObjectKeyHash(),entry);
|
||||
task.setRoutingManifestJson(SelfopSupport.json(manifest));
|
||||
@@ -60,6 +60,32 @@ class OutboundFactServiceTest {
|
||||
s.setScopeHash("scope"); s.setSourceTimezone("UTC"); s.setBusinessTimezone("UTC"); s.setScopeJson("{\"quantityUnit\":\"piece\"}");
|
||||
s.setLeaseOwner("worker"); s.setFenceToken(7L); s.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1)); s.setFactRevision(0L); return s;
|
||||
}
|
||||
private SelfopPayloads.Order withQuantity(String quantity) {
|
||||
return new SelfopPayloads.Order(payload.sourceAccount(),payload.customerCode(),payload.warehouseCode(),payload.deliveryNo(),payload.sourceNo(),
|
||||
payload.sourceStatus(),payload.sourceCreatedTime(),payload.sourceOutboundTime(),List.of(new SelfopPayloads.Item("sku",null,new BigDecimal(quantity),"piece")));
|
||||
}
|
||||
@Test void persistedScientificQuantityKeepsStageIntegrityAndCanMerge() {
|
||||
var observed=withQuantity("3000.000000");stage.setContentHash(SelfopOutboundFingerprint.order(observed));
|
||||
stage.setPayloadJson(SelfopSupport.json(withQuantity("3E+3")));
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
var captured=ArgumentCaptor.forClass(SelfopOutboundItem.class);verify(items).insert(captured.capture());
|
||||
assertEquals(0,new BigDecimal("3000").compareTo(captured.getValue().getActualQuantity()));
|
||||
assertEquals("APPLYING",task.getApplyState());
|
||||
}
|
||||
@Test void equivalentQuantityReobservationDoesNotIncrementFactRevisionOrRewriteItems() {
|
||||
var observed=withQuantity("3000.000000");stage.setContentHash(SelfopOutboundFingerprint.order(observed));stage.setPayloadJson(SelfopSupport.json(withQuantity("3.0E+3")));
|
||||
old.setWarehouseCode("B");old.setContentHash(SelfopOutboundFingerprint.fact(OutboundPolicy.normalize(withQuantity("3000")),"UTC","UTC","piece"));
|
||||
old.setBusinessDate(LocalDate.of(2026,10,11));old.setOutboundAtUtc(LocalDateTime.of(2026,10,11,12,0));
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertEquals(1L,old.getRevisionNo());assertEquals(0L,a.getFactRevision());assertEquals(0L,b.getFactRevision());
|
||||
verify(items,never()).delete(any());verify(items,never()).insert(any(SelfopOutboundItem.class));
|
||||
assertEquals(2L,old.getLastObservationSequence());
|
||||
}
|
||||
@Test void realQuantityTamperingStillRejectsBeforeWritingOrderOrItems() {
|
||||
stage.setContentHash(SelfopOutboundFingerprint.order(withQuantity("3000")));stage.setPayloadJson(SelfopSupport.json(withQuantity("3001")));
|
||||
assertEquals("STAGE_CONTENT_CONFLICT",assertThrows(IllegalStateException.class,()->service.merge(10L,20L,List.of(30L),leases)).getMessage());
|
||||
verify(orders,never()).updateById(any(SelfopOutboundOrder.class));verify(items,never()).insert(any(SelfopOutboundItem.class));
|
||||
}
|
||||
@Test void crossWarehouseRevisionInvalidatesBothAndLeavesBarrierUntilFinish() {
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertEquals("B",old.getWarehouseCode()); assertEquals(1L,a.getFactRevision()); assertEquals(1L,b.getFactRevision());
|
||||
@@ -80,7 +106,7 @@ class OutboundFactServiceTest {
|
||||
}
|
||||
@Test void cancellationRemovesOldDateAndEligibility() {
|
||||
SelfopPayloads.Order cancelled=new SelfopPayloads.Order("demo","owner","B","delivery",null,99,null,null,payload.items());
|
||||
stage.setPayloadJson(SelfopSupport.json(cancelled)); stage.setContentHash(SelfopSupport.hash(cancelled));
|
||||
stage.setPayloadJson(SelfopSupport.json(cancelled)); stage.setContentHash(SelfopOutboundFingerprint.order(cancelled));
|
||||
service.merge(10L,20L,List.of(30L),leases);
|
||||
assertNull(old.getOutboundAtUtc()); assertNull(old.getBusinessDate()); assertEquals(0,old.getSalesEligible());
|
||||
verify(items).delete(any());
|
||||
|
||||
+16
@@ -204,6 +204,22 @@ class SelfopDiscoveryServiceTest {
|
||||
String key = hash(List.of("demo", "customer", deliveryNo)); manifest.getObjects().put(key, entry);
|
||||
task.setRoutingManifestJson(json(manifest)); return key;
|
||||
}
|
||||
@Test void persistedScaleRewriteAndEquivalentFreshDetailKeepImmutableStageFingerprint() {
|
||||
String key=completedParent("decimal");
|
||||
var original=new SelfopPayloads.Order("demo","customer","A","decimal",null,100,null,"2026-10-11 12:00:00",
|
||||
List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("1000.000000"),"PCS")));
|
||||
var loaded=new SelfopPayloads.Order("demo","customer","A","decimal",null,100,null,"2026-10-11 12:00:00",
|
||||
List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("1E+3"),"PCS")));
|
||||
var stage=new SelfopSyncStage();stage.setId(500L);stage.setStageBatchId(1L);stage.setRunId(20L);stage.setObjectKeyHash(key);
|
||||
stage.setWarehouseCode("A");stage.setObservationSequence(101L);stage.setValidationStatus("VALID");stage.setFirstPageNo(1);
|
||||
stage.setContentHash(SelfopOutboundFingerprint.order(original));stage.setPayloadJson(json(loaded));stored.put(500L,stage);
|
||||
when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("decimal")))
|
||||
.thenReturn(new SelfopPayloads.Order("demo","customer","A","decimal",null,100,null,"2026-10-11 12:00:00",
|
||||
List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("1000.0"),"PCS"))));
|
||||
service.collect(task,run,lease);
|
||||
assertEquals("ROUTED",run.getStatus());assertEquals(500L,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(key).getEffectiveStageId());
|
||||
assertEquals(SelfopOutboundFingerprint.order(loaded),stored.get(500L).getContentHash());
|
||||
}
|
||||
@Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() {
|
||||
task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION");
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage());
|
||||
|
||||
+8
@@ -131,6 +131,14 @@ class SelfopInventoryCollectorTest {
|
||||
() -> collector.collect(task, run, lease)).getMessage());
|
||||
assertIncompleteAttemptWithPersistedProgress();
|
||||
}
|
||||
@Test void equivalentScientificAndDecimalQuantitiesPassBothScans() {
|
||||
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(2))).thenReturn(
|
||||
page(1,1,1,stock("a",new BigDecimal("1E+2"))),page(1,1,1,stock("a",new BigDecimal("100.0"))));
|
||||
assertTrue(collector.collect(task,run,lease));
|
||||
assertEquals("COMPLETE",run.getCoverageStatus());
|
||||
var captured=ArgumentCaptor.forClass(SelfopSyncStage.class);verify(stages).insert(captured.capture());
|
||||
assertEquals(SelfopStockFingerprint.of(stock("a",new BigDecimal("100.000"))),captured.getValue().getContentHash());
|
||||
}
|
||||
@Test void validationPurposeCannotReachFormalCollector() {
|
||||
task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION");
|
||||
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage());
|
||||
|
||||
Reference in New Issue
Block a user