diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java index 76fab4f36..a59997a1e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java @@ -45,7 +45,7 @@ public final class InventorySnapshotAssembler { && value.productQuality() <= Short.MAX_VALUE, "SELFOP_INVENTORY_QUALITY_INVALID"); require(value.productName() == null || value.productName().length() <= 255, "SELFOP_INVENTORY_NAME_INVALID"); require(value.sourceUniqueKey() == null || value.sourceUniqueKey().length() <= 512, "SELFOP_INVENTORY_SOURCE_KEY_INVALID"); - require(Objects.equals(hash(value), stage.getContentHash()), "SELFOP_INVENTORY_CONTENT_CHANGED"); + require(Objects.equals(SelfopStockFingerprint.of(value), stage.getContentHash()), "SELFOP_INVENTORY_CONTENT_CHANGED"); var key = new StockKey(value.customerCode(), value.skuCode(), value.productQuality()); require(!stocks.containsKey(key), "SELFOP_INVENTORY_DUPLICATE_KEY"); var row = snapshot(scope, run, key, now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprint.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprint.java new file mode 100644 index 000000000..cd87a64cb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprint.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +/** Outbound quantities are numeric values; MySQL JSON decimal scale is not business content. */ +public final class SelfopOutboundFingerprint { + private SelfopOutboundFingerprint() { } + private record Item(String skuCode,String productName,String actualQuantity,String quantityUnit) { } + private record Order(String sourceAccount,String customerCode,String warehouseCode,String deliveryNo, + String sourceNo,Integer sourceStatus,String sourceCreatedTime,String sourceOutboundTime,List items) { } + + public static String order(SelfopPayloads.Order value) { + return SelfopSupport.hash(canonical(value)); + } + public static String fact(SelfopPayloads.Order value,String businessTimezone,String sourceTimezone,String quantityUnit) { + return SelfopSupport.hash(Arrays.asList(canonical(value),businessTimezone,sourceTimezone,quantityUnit)); + } + private static Order canonical(SelfopPayloads.Order value) { + SelfopSupport.require(value!=null,"SELFOP_OUTBOUND_FINGERPRINT_MISSING"); + List items=value.items()==null?null:value.items().stream().map(item->item==null?null: + new Item(item.skuCode(),item.productName(),quantity(item.actualQuantity()),item.quantityUnit())).toList(); + return new Order(value.sourceAccount(),value.customerCode(),value.warehouseCode(),value.deliveryNo(),value.sourceNo(), + value.sourceStatus(),value.sourceCreatedTime(),value.sourceOutboundTime(),items); + } + private static String quantity(BigDecimal value) { + return value==null?null:value.stripTrailingZeros().toPlainString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprint.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprint.java new file mode 100644 index 000000000..8fea6a868 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprint.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.selfoperated.analytics.model; + +import java.math.BigDecimal; + +/** JSON storage may change numeric notation without changing inventory content. */ +public final class SelfopStockFingerprint { + private SelfopStockFingerprint() { } + private record Stock(String sourceAccount, String customerCode, String warehouseCode, + String skuCode, Integer productQuality, String productName, String physicalQuantity, + String availableQuantity, String frozenQuantity, String transitQuantity, String sourceUniqueKey) { } + + public static String of(SelfopPayloads.Stock value) { + SelfopSupport.require(value != null, "SELFOP_STOCK_FINGERPRINT_MISSING"); + return SelfopSupport.hash(new Stock(value.sourceAccount(), value.customerCode(), value.warehouseCode(), + value.skuCode(), value.productQuality(), value.productName(), quantity(value.physicalQuantity()), + quantity(value.availableQuantity()), quantity(value.frozenQuantity()), quantity(value.transitQuantity()), + value.sourceUniqueKey())); + } + + private static String quantity(BigDecimal value) { + return value == null ? null : value.stripTrailingZeros().toPlainString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java index 82f6c33f1..70f6c8c98 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java @@ -182,7 +182,7 @@ public class OutboundFactService { SelfopSupport.require(Objects.equals(task.getSourceAccount(),sourceRun.getSourceAccount()) && Objects.equals(task.getSourceAccount(),sourceTask.getSourceAccount()),"STAGE_IDENTITY_CONFLICT"); SelfopPayloads.Order raw = SelfopSupport.read(stage.getPayloadJson(),SelfopPayloads.Order.class); - SelfopSupport.require(Objects.equals(stage.getContentHash(),SelfopSupport.hash(raw)),"STAGE_CONTENT_CONFLICT"); + SelfopSupport.require(Objects.equals(stage.getContentHash(),SelfopOutboundFingerprint.order(raw)),"STAGE_CONTENT_CONFLICT"); SelfopPayloads.Order p = OutboundPolicy.normalize(raw); SelfopRoutingManifest.Entry reference = manifest.getObjects().get(stage.getObjectKeyHash()); SelfopSupport.require(reference != null && Objects.equals(reference.getEffectiveStageId(),stage.getId()),"STAGE_NOT_ROUTED"); @@ -208,7 +208,7 @@ public class OutboundFactService { } private String factHash(SelfopPayloads.Order p,SelfopSyncScope scope) { SelfopSupport.require(scope != null,"LOCK_SET_CHANGED"); - return SelfopSupport.hash(List.of(p,scope.getBusinessTimezone(),scope.getSourceTimezone(),unit(scope))); + return SelfopOutboundFingerprint.fact(p,scope.getBusinessTimezone(),scope.getSourceTimezone(),unit(scope)); } private String unit(SelfopSyncScope scope) { Map json = SelfopSupport.read(scope.getScopeJson(),Map.class); Object value = json.get("quantityUnit"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java index f2aec1186..fa0c1b1b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java @@ -145,7 +145,7 @@ public class SelfopDiscoveryService { var existing=stages.selectOne(new QueryWrapper().eq("stage_batch_id",finalFetchTask.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",entry.getKey())); SelfopSyncStage full; if(existing!=null&&"VALID".equals(existing.getValidationStatus())){ - require(existing.getContentHash().equals(hash(acceptedDetail)),"SELFOP_VALID_STAGE_IMMUTABLE");full=existing; + require(existing.getContentHash().equals(SelfopOutboundFingerprint.order(acceptedDetail)),"SELFOP_VALID_STAGE_IMMUTABLE");full=existing; }else{ full=stage(finalFetchTask,finalFetchRun,acceptedDetail,existing==null?1:existing.getFirstPageNo(),acceptedObservation,"VALID"); if(existing!=null){full.setId(existing.getId());full.setRunId(existing.getRunId());full.setCreatedAt(existing.getCreatedAt());stages.updateById(full);}else stages.insert(full); @@ -194,7 +194,7 @@ public class SelfopDiscoveryService { var s=new SelfopSyncStage();s.setId(IdWorker.getId());s.setRunId(run.getId());s.setStageBatchId(task.getId());s.setLastFetchRunId(run.getId()); s.setObservationSequence(observation);s.setWarehouseCode(payload.warehouseCode());s.setObjectType("OUTBOUND_ORDER"); s.setObjectKeyHash(hash(List.of(payload.sourceAccount(),payload.customerCode(),payload.deliveryNo())));s.setFirstPageNo(page);s.setObservedAtUtc(utcNow()); - s.setContentHash(hash(payload));s.setPayloadJson(json(payload));s.setValidationStatus(status);s.setCreatedAt(utcNow());s.setUpdatedAt(utcNow());return s; + s.setContentHash(SelfopOutboundFingerprint.order(payload));s.setPayloadJson(json(payload));s.setValidationStatus(status);s.setCreatedAt(utcNow());s.setUpdatedAt(utcNow());return s; } private void saveManifest(SelfopSyncTask task,SelfopRoutingManifest manifest){task.setRoutingManifestJson(json(manifest));task.setUpdatedAt(utcNow()); var active=runs.selectOne(new QueryWrapper().eq("task_id",task.getId()).eq("status","FETCHING").orderByDesc("attempt_no").last("LIMIT 1")); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java index bd8a04975..e8a8441c1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java @@ -40,11 +40,11 @@ public class SelfopInventoryCollector { require(task.getSourceAccount().equals(row.sourceAccount())&&task.getWarehouseCode().equals(row.warehouseCode()),"SELFOP_STOCK_SCOPE_LEAK"); identity(row.customerCode(),64);identity(row.skuCode(),128);require(row.productQuality()!=null,"SELFOP_STOCK_IDENTITY_MISSING"); String key=hash(List.of(row.sourceAccount(),row.customerCode(),row.warehouseCode(),row.skuCode(),row.productQuality())); - if(manifest.getScan()==1){require(!manifest.getFirstScan().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getFirstScan().put(key,hash(row)); + if(manifest.getScan()==1){require(!manifest.getFirstScan().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getFirstScan().put(key,SelfopStockFingerprint.of(row)); var stage=new SelfopSyncStage();stage.setId(IdWorker.getId());stage.setRunId(run.getId());stage.setStageBatchId(run.getId());stage.setLastFetchRunId(run.getId()); stage.setWarehouseCode(row.warehouseCode());stage.setObjectType("PRODUCT_STOCK");stage.setObjectKeyHash(key);stage.setFirstPageNo(manifest.getNextPage()); - stage.setObservedAtUtc(utcNow());stage.setContentHash(hash(row));stage.setPayloadJson(json(row));stage.setValidationStatus("VALID");stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage); - }else{require(Objects.equals(manifest.getFirstScan().get(key),hash(row)),"SELFOP_STOCK_SCAN_UNSTABLE"); + stage.setObservedAtUtc(utcNow());stage.setContentHash(SelfopStockFingerprint.of(row));stage.setPayloadJson(json(row));stage.setValidationStatus("VALID");stage.setCreatedAt(utcNow());stage.setUpdatedAt(utcNow());stages.insert(stage); + }else{require(Objects.equals(manifest.getFirstScan().get(key),SelfopStockFingerprint.of(row)),"SELFOP_STOCK_SCAN_UNSTABLE"); require(!manifest.getObjects().containsKey(key),"SELFOP_STOCK_DUPLICATE");manifest.getObjects().put(key,new SelfopRoutingManifest.Entry());} } manifest.setNextPage(manifest.getNextPage()+1); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java index 2fc728238..4a2d84d10 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java @@ -151,11 +151,12 @@ public class SelfopValidationRunner { // acquire a formal sequence: structure success remains explicit pending sample evidence. if("VALID".equals(status)){status="PENDING";code="SAMPLE_VALIDATED";} String keyHash=hash(key); + String contentHash=payload instanceof SelfopPayloads.Stock stock ? SelfopStockFingerprint.of(stock) : hash(payload); var prior=stages.selectOne(new QueryWrapper().eq("stage_batch_id",task.getId()).eq("object_type",type).eq("object_key_hash",keyHash)); - if(prior!=null){require(prior.getContentHash().equals(hash(payload)),"SELFOP_SAMPLE_STAGE_CHANGED");return;} + if(prior!=null){require(prior.getContentHash().equals(contentHash),"SELFOP_SAMPLE_STAGE_CHANGED");return;} var row=new SelfopSyncStage();row.setId(IdWorker.getId());row.setStageBatchId(task.getId());row.setRunId(run.getId());row.setLastFetchRunId(run.getId()); row.setObjectType(type);row.setObjectKeyHash(keyHash);row.setWarehouseCode(warehouse);row.setFirstPageNo(page);row.setObservedAtUtc(utcNow()); - row.setContentHash(hash(payload));row.setPayloadJson(json(payload));row.setValidationStatus(status);row.setValidationCode(code); + row.setContentHash(contentHash);row.setPayloadJson(json(payload));row.setValidationStatus(status);row.setValidationCode(code); row.setCreatedAt(utcNow());row.setUpdatedAt(utcNow());stages.insert(row); } private void save(SelfopSyncTask task,SelfopSyncRun run,Sample state,String phase,String unit,long completed,Long total) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java index f11428b44..1f784401b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java @@ -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()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java index 6f3214f32..120b2e566 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java @@ -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 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 rows) -> rows.size()==1 && rows.iterator().next().getId()!=null),eq(500)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprintTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprintTest.java new file mode 100644 index 000000000..45364b688 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprintTest.java @@ -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")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprintTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprintTest.java new file mode 100644 index 000000000..ec34e04b6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprintTest.java @@ -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)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java index 9e25c2441..9ede6a31e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java @@ -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()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java index fd611b675..deb6fab33 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java @@ -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()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java index 6b54643e8..a4716df43 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java @@ -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());