fix(selfop): stabilize inventory and order quantity fingerprints
This commit is contained in:
+1
-1
@@ -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);
|
||||
|
||||
+30
@@ -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<Item> 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<Item> 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();
|
||||
}
|
||||
}
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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");
|
||||
|
||||
+2
-2
@@ -145,7 +145,7 @@ public class SelfopDiscoveryService {
|
||||
var existing=stages.selectOne(new QueryWrapper<SelfopSyncStage>().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<SelfopSyncRun>().eq("task_id",task.getId()).eq("status","FETCHING").orderByDesc("attempt_no").last("LIMIT 1"));
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+3
-2
@@ -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<SelfopSyncStage>().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) {
|
||||
|
||||
Reference in New Issue
Block a user