feat(selfop): prefer list SKU quantities with targeted detail fallback

This commit is contained in:
2026-10-14 08:15:16 +08:00
parent 4ae158c93e
commit c640fa2d0d
14 changed files with 220 additions and 31 deletions
@@ -45,8 +45,9 @@ public final class LingxingWmsDtos {
} }
// Preserve source strings; the collector interprets confirmed Asia/Shanghai source time. // Preserve source strings; the collector interprets confirmed Asia/Shanghai source time.
public record ListSku(String productSku, String productName, BigDecimal qty) { }
public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode, public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String createTime, String outboundTime) { } Integer status, String createTime, String outboundTime, List<ListSku> productList) { }
public record PageResult(List<OrderSummary> records, Long total, int current, int size, public record PageResult(List<OrderSummary> records, Long total, int current, int size,
int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) { int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
@@ -304,12 +304,23 @@ public final class LingxingWmsClient {
int status = requiredInt(record.get("status"), "STATUS"); int status = requiredInt(record.get("status"), "STATUS");
if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200); if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200);
return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64), return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64),
requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime")); requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"), listSkus(record.get("productList")));
}); });
return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(), return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(),
page.sourceSize(), 200, page.observedAt(), metadata); page.sourceSize(), 200, page.observedAt(), metadata);
} }
private List<ListSku> listSkus(JsonNode products) {
if (products == null || products.isNull()) return null;
if (!products.isArray()) throw invalidResponse("PRODUCT_LIST_TYPE_INVALID", 200);
List<ListSku> items = new ArrayList<>();
for (JsonNode item : products) {
if (!item.isObject()) throw invalidResponse("PRODUCT_LIST_ROW_INVALID", 200);
items.add(new ListSku(optionalText(item, "productSku"), optionalText(item, "productName"), quantity(item.get("qty"))));
}
return List.copyOf(items);
}
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField, private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) { String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) {
JsonNode records = data.get("records"); JsonNode records = data.get("records");
@@ -349,6 +349,16 @@ components:
status: {type: integer} status: {type: integer}
createTime: {type: string, nullable: true} createTime: {type: string, nullable: true}
outboundTime: {type: string, nullable: true} outboundTime: {type: string, nullable: true}
productList:
type: array
nullable: true
description: Per-SKU list quantities; missing items, SKU or qty require one detail supplement for this order. Zero is a known quantity.
items:
type: object
properties:
productSku: {type: string, nullable: true}
productName: {type: string, nullable: true}
qty: {type: number, nullable: true}
Warehouse: Warehouse:
type: object type: object
required: [whCode, warehouseType, status] required: [whCode, warehouseType, status]
@@ -131,7 +131,7 @@ class LingxingInternalControllerTest {
void authenticatedReadReturnsOnlyOutboundSummary() throws Exception { void authenticatedReadReturnsOnlyOutboundSummary() throws Exception {
when(client.queryPage(any(), any())).thenReturn(new PageResult( when(client.queryPage(any(), any())).thenReturn(new PageResult(
List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100, List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100,
"2026-10-01 08:00:00", "2026-10-02 08:00:00")), "2026-10-01 08:00:00", "2026-10-02 08:00:00", null)),
1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null)); 1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT) mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
.contentType(MediaType.APPLICATION_JSON).content(BODY)) .contentType(MediaType.APPLICATION_JSON).content(BODY))
@@ -81,6 +81,17 @@ class LingxingWmsClientTest {
assertThat(result.observedAt()).isEqualTo(NOW); assertThat(result.observedAt()).isEqualTo(NOW);
assertThat(result.pages()).isEqualTo(1); assertThat(result.pages()).isEqualTo(1);
} }
@Test void listProductsPreserveDecimalsZeroAndMissingQuantity() {
response.set(page("current","size",1,1,1,1,ORDER.strip().replace("}","""
,"productList":[{"productSku":"SKU","qty":2.123456},{"productSku":"ZERO","qty":0},
{"productSku":"UNKNOWN"},{"qty":4}]}""")));
var products=client().queryPage(query(1,1),context()).records().get(0).productList();
assertThat(products).hasSize(4);
assertThat(products.get(0).qty()).isEqualByComparingTo("2.123456");
assertThat(products.get(1).qty()).isEqualByComparingTo("0");
assertThat(products.get(2).qty()).isNull();assertThat(products.get(3).productSku()).isNull();
assertThat(calls.get()).isEqualTo(1);
}
@Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception { @Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception {
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", ""))); response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", "")));
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class); var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
@@ -21,5 +21,15 @@ public interface LingxingAnalyticsClient {
Integer status, String timeZone) { } Integer status, String timeZone) { }
record Summary(String sourceAccount, String customerCode, String warehouseCode, record Summary(String sourceAccount, String customerCode, String warehouseCode,
String deliveryNo, String sourceNo, Integer sourceStatus, String deliveryNo, String sourceNo, Integer sourceStatus,
String sourceCreatedTime, String sourceOutboundTime) { } String sourceCreatedTime, String sourceOutboundTime, List<SelfopPayloads.Item> items) {
public Order order() {
return new Order(sourceAccount, customerCode, warehouseCode, deliveryNo, sourceNo, sourceStatus,
sourceCreatedTime, sourceOutboundTime, items);
}
}
static boolean needsDetail(Order order) {
return order.items() == null || order.items().isEmpty() || order.items().stream().anyMatch(item ->
item == null || item.skuCode() == null || item.skuCode().isBlank() || item.actualQuantity() == null);
}
} }
@@ -110,9 +110,20 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient {
} }
private Summary summary(String account, JsonNode n) { private Summary summary(String account, JsonNode n) {
List<SelfopPayloads.Item> items = null;
JsonNode products = n.get("productList");
if (products != null && !products.isNull()) {
if (!products.isArray()) throw failure("LIST_ITEMS_INVALID");
items = new ArrayList<>();
for (JsonNode item : products) {
if (!item.isObject()) throw failure("LIST_ITEM_INVALID");
items.add(new SelfopPayloads.Item(optional(item, "productSku"), optional(item, "productName"),
quantity(item, "qty"), properties.getQuantityUnit()));
}
}
return new Summary(account, required(n, "customerCode", 64), required(n, "whCode", 64), return new Summary(account, required(n, "customerCode", 64), required(n, "whCode", 64),
required(n, "deliveryNo", 128), optional(n, "sourceNo"), integer(n, "status"), required(n, "deliveryNo", 128), optional(n, "sourceNo"), integer(n, "status"),
optional(n, "createTime"), optional(n, "outboundTime")); optional(n, "createTime"), optional(n, "outboundTime"), items);
} }
private void gate(SelfopRequestContext context) { private void gate(SelfopRequestContext context) {
@@ -7,6 +7,7 @@ import java.util.Map;
/** Durable directory cursor and effective immutable stage references. */ /** Durable directory cursor and effective immutable stage references. */
@Data @Data
public class SelfopRoutingManifest { public class SelfopRoutingManifest {
private int listPayloadVersion;
private int scan = 1; private int scan = 1;
private int nextPage = 1; private int nextPage = 1;
private Integer pages; private Integer pages;
@@ -12,6 +12,7 @@ public final class SelfopBudget {
if(limit<=0||seconds<=0)throw new IllegalArgumentException("SELFOP_BUDGET_INVALID"); if(limit<=0||seconds<=0)throw new IllegalArgumentException("SELFOP_BUDGET_INVALID");
this.limit=limit;this.deadline=Instant.now().plusSeconds(seconds); this.limit=limit;this.deadline=Instant.now().plusSeconds(seconds);
} }
public boolean take() {if(requests>=limit||!Instant.now().isBefore(deadline))return false;requests++;return true;} public boolean hasTime() {return Instant.now().isBefore(deadline);}
public boolean take() {if(requests>=limit||!hasTime())return false;requests++;return true;}
public int used(){return requests;} public int used(){return requests;}
} }
@@ -44,6 +44,13 @@ public class SelfopDiscoveryService {
private void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget) { private void collect(SelfopSyncTask task,SelfopSyncRun run,SelfopLeaseService.Lease lease,SelfopBudget budget) {
Query query=read(task.getQueryScopeJson(),Query.class); Query query=read(task.getQueryScopeJson(),Query.class);
var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class); var manifest=task.getRoutingManifestJson()==null?new SelfopRoutingManifest():read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
if(manifest.getListPayloadVersion()==0) {
if(!manifest.isDirectoryComplete()) {
manifest.setScan(1);manifest.setNextPage(1);manifest.setPages(null);manifest.setTotal(null);
manifest.getObjects().clear();manifest.getFirstScan().clear();
}
manifest.setListPayloadVersion(1);
}
if(query.deliveryNo()!=null) {if(!collectLocated(task,run,lease,budget,query,manifest))return;} if(query.deliveryNo()!=null) {if(!collectLocated(task,run,lease,budget,query,manifest))return;}
else { else {
while(!manifest.isDirectoryComplete()) { while(!manifest.isDirectoryComplete()) {
@@ -60,7 +67,7 @@ public class SelfopDiscoveryService {
String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));String content=hash(summary); String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));String content=hash(summary);
if(manifest.getScan()==1){ if(manifest.getScan()==1){
require(!manifest.getFirstScan().containsKey(key),"SELFOP_DIRECTORY_DUPLICATE");manifest.getFirstScan().put(key,content); require(!manifest.getFirstScan().containsKey(key),"SELFOP_DIRECTORY_DUPLICATE");manifest.getFirstScan().put(key,content);
var payload=new SelfopPayloads.Order(task.getSourceAccount(),summary.customerCode(),summary.warehouseCode(),summary.deliveryNo(),summary.sourceNo(),summary.sourceStatus(),summary.sourceCreatedTime(),summary.sourceOutboundTime(),null); var payload=new SelfopPayloads.Order(task.getSourceAccount(),summary.customerCode(),summary.warehouseCode(),summary.deliveryNo(),summary.sourceNo(),summary.sourceStatus(),summary.sourceCreatedTime(),summary.sourceOutboundTime(),summary.items());
var stage=stage(task,run,payload,manifest.getNextPage(),null,"PENDING"); var stage=stage(task,run,payload,manifest.getNextPage(),null,"PENDING");
var prior=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key)); var prior=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key));
if(prior==null)stages.insert(stage);else if("PENDING".equals(prior.getValidationStatus())){ if(prior==null)stages.insert(stage);else if("PENDING".equals(prior.getValidationStatus())){
@@ -87,6 +94,7 @@ public class SelfopDiscoveryService {
} }
route(task,run,manifest,lease,false); route(task,run,manifest,lease,false);
for(var entry:manifest.getObjects().entrySet()) { for(var entry:manifest.getObjects().entrySet()) {
if(!budget.hasTime()){taskService.yield(task.getId(),run.getId(),lease);return;}
var ref=entry.getValue(); var ref=entry.getValue();
if(ref.getRefreshTaskId()!=null){ if(ref.getRefreshTaskId()!=null){
var refresh=tasks.selectById(ref.getRefreshTaskId());require(refresh!=null,"SELFOP_REFRESH_TASK_MISSING"); var refresh=tasks.selectById(ref.getRefreshTaskId());require(refresh!=null,"SELFOP_REFRESH_TASK_MISSING");
@@ -105,7 +113,6 @@ public class SelfopDiscoveryService {
var effective=ref.getEffectiveStageId()==null?null:stages.selectById(ref.getEffectiveStageId()); var effective=ref.getEffectiveStageId()==null?null:stages.selectById(ref.getEffectiveStageId());
boolean newer=latest!=null&&(effective==null?latest.getObservationSequence()>task.getObservationBaseline():latest.getObservationSequence()>effective.getObservationSequence()); boolean newer=latest!=null&&(effective==null?latest.getObservationSequence()>task.getObservationBaseline():latest.getObservationSequence()>effective.getObservationSequence());
if(effective!=null&&!newer) continue; if(effective!=null&&!newer) continue;
if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;}
leases.renew(lease); leases.renew(lease);
SelfopSyncTask fetchTask=task;SelfopSyncRun fetchRun=run; SelfopSyncTask fetchTask=task;SelfopSyncRun fetchRun=run;
if(newer){ if(newer){
@@ -118,25 +125,47 @@ public class SelfopDiscoveryService {
fetchRun=taskService.begin(fetchTask.getId(),lease); fetchRun=taskService.begin(fetchTask.getId(),lease);
fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING"); fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING");
} }
long observation=leases.nextObservation(lease); if(newer){
SelfopPayloads.Order detail;
try {detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());}
catch(AnalyticsClientException staleIdentity){
if(!Set.of("DETAIL_IDENTITY_MISMATCH","INTEGRATION_DATA_MISSING","LINGXING_WMS_SOURCE_REJECTED","LINGXING_WMS_INVALID_RESPONSE").contains(staleIdentity.code())||staleIdentity.retryable())throw staleIdentity;
var relocate=new SelfopSyncTask();relocate.setSourceAccount(task.getSourceAccount());relocate.setTaskRole("ACCOUNT_DISCOVERY");relocate.setDiscoveryScopeId(task.getDiscoveryScopeId());
relocate.setExecutionPurpose(task.getExecutionPurpose());
relocate.setTaskType("RECHECK");relocate.setScopeHash(task.getScopeHash());relocate.setRequestKey("relocate:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+observation);
var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson()));
fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId());
fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING");
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
var located=new SelfopRoutingManifest(); collect(fetchTask,fetchRun,lease,budget);
if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});taskService.yield(task.getId(),run.getId(),lease);return;} var refreshedTask=tasks.selectById(fetchTask.getId());
ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode()); if(!"SUCCEEDED".equals(refreshedTask.getStatus())){taskService.yield(task.getId(),run.getId(),lease);return;}
var refreshed=read(refreshedTask.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(entry.getKey());
require(refreshed!=null&&refreshed.getEffectiveStageId()!=null,"SELFOP_REFRESH_REFERENCE_MISSING");
ref.setEffectiveStageId(refreshed.getEffectiveStageId());ref.setWarehouseCode(refreshed.getWarehouseCode());
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;} continue;
observation=leases.nextObservation(lease);detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());
} }
var directoryStage=ref.getDirectoryStageId()==null?null:stages.selectById(ref.getDirectoryStageId());
SelfopPayloads.Order detail=directoryStage==null?null:read(directoryStage.getPayloadJson(),SelfopPayloads.Order.class);
long observation;
if(detail==null||LingxingAnalyticsClient.needsDetail(detail)) {
if(!budget.take()){taskService.yield(task.getId(),run.getId(),lease);return;}
observation=leases.nextObservation(lease);
try {detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());}
catch(AnalyticsClientException staleIdentity){
if(!Set.of("DETAIL_IDENTITY_MISMATCH","INTEGRATION_DATA_MISSING","LINGXING_WMS_SOURCE_REJECTED","LINGXING_WMS_INVALID_RESPONSE").contains(staleIdentity.code())||staleIdentity.retryable())throw staleIdentity;
var relocate=new SelfopSyncTask();relocate.setSourceAccount(task.getSourceAccount());relocate.setTaskRole("ACCOUNT_DISCOVERY");relocate.setDiscoveryScopeId(task.getDiscoveryScopeId());
relocate.setExecutionPurpose(task.getExecutionPurpose());
relocate.setTaskType("RECHECK");relocate.setScopeHash(task.getScopeHash());relocate.setRequestKey("relocate:"+task.getId()+":"+entry.getKey().substring(0,24)+":"+observation);
var locateQuery=new Query(null,null,ref.getDeliveryNo(),ref.getCustomerCode(),task.getId());relocate.setQueryScopeJson(json(locateQuery));relocate.setRequestHash(hash(relocate.getQueryScopeJson()));
fetchTask=taskService.create(relocate);fetchRun=taskService.begin(fetchTask.getId(),lease);ref.setRefreshTaskId(fetchTask.getId());
fetchTask.setAttemptCount(fetchRun.getAttemptNo());fetchTask.setStatus("RUNNING");
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
var located=new SelfopRoutingManifest();located.setListPayloadVersion(1);
if(!collectLocated(fetchTask,fetchRun,lease,budget,locateQuery,located)){tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});taskService.yield(task.getId(),run.getId(),lease);return;}
ref.setWarehouseCode(located.getObjects().values().iterator().next().getWarehouseCode());
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
var locatedRef=located.getObjects().values().iterator().next();
var locatedStage=stages.selectById(locatedRef.getDirectoryStageId());
detail=read(locatedStage.getPayloadJson(),SelfopPayloads.Order.class);
if(LingxingAnalyticsClient.needsDetail(detail)) {
if(!budget.take()){taskService.yield(fetchTask.getId(),fetchRun.getId(),lease);taskService.yield(task.getId(),run.getId(),lease);return;}
detail=client.detail(SelfopRequestContext.forTask(fetchTask,fetchRun,config),ref.getWarehouseCode(),ref.getCustomerCode(),ref.getDeliveryNo());
}
observation=leases.nextObservation(lease);
}
} else observation=leases.nextObservation(lease);
require(Objects.equals(detail.sourceAccount(),task.getSourceAccount())&&Objects.equals(detail.customerCode(),ref.getCustomerCode()) require(Objects.equals(detail.sourceAccount(),task.getSourceAccount())&&Objects.equals(detail.customerCode(),ref.getCustomerCode())
&&Objects.equals(detail.deliveryNo(),ref.getDeliveryNo()),"SELFOP_DETAIL_IDENTITY_MISMATCH"); &&Objects.equals(detail.deliveryNo(),ref.getDeliveryNo()),"SELFOP_DETAIL_IDENTITY_MISMATCH");
require(detail.items()!=null&&detail.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID"); require(detail.items()!=null&&detail.sourceStatus()!=null,"SELFOP_DETAIL_STRUCTURE_INVALID");
@@ -163,7 +192,7 @@ public class SelfopDiscoveryService {
}); });
route(task,run,manifest,lease,false); route(task,run,manifest,lease,false);
} }
// A directory warehouse is provisional until every account detail (including resumed // A directory warehouse is provisional until every account order (including resumed
// revision checks) has resolved. An empty warehouse must not publish a false zero early. // revision checks) has resolved. An empty warehouse must not publish a false zero early.
route(task,run,manifest,lease,true); route(task,run,manifest,lease,true);
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);var currentTask=checkpoint.task();var currentRun=checkpoint.run();var now=utcNow(); tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);var currentTask=checkpoint.task();var currentRun=checkpoint.run();var now=utcNow();
@@ -191,7 +220,13 @@ public class SelfopDiscoveryService {
require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED"); require(Objects.equals(manifest.getTotal(),page.total())&&Objects.equals(manifest.getPages(),page.pages()),"SELFOP_PAGE_TOTAL_CHANGED");
for(var summary:page.records())if(query.deliveryNo().equals(summary.deliveryNo())&&query.customerCode().equals(summary.customerCode())){ for(var summary:page.records())if(query.deliveryNo().equals(summary.deliveryNo())&&query.customerCode().equals(summary.customerCode())){
String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));require(!manifest.getObjects().containsKey(key),"SELFOP_LOCATE_AMBIGUOUS"); String key=hash(List.of(task.getSourceAccount(),summary.customerCode(),summary.deliveryNo()));require(!manifest.getObjects().containsKey(key),"SELFOP_LOCATE_AMBIGUOUS");
var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode());manifest.getObjects().put(key,e);} var e=new SelfopRoutingManifest.Entry();e.setCustomerCode(summary.customerCode());e.setDeliveryNo(summary.deliveryNo());e.setWarehouseCode(summary.warehouseCode());
tx.executeWithoutResult(s->{taskService.checkpoint(task,run,lease);
var pending=stage(task,run,summary.order(),manifest.getNextPage(),null,"PENDING");
var prior=stages.selectOne(new QueryWrapper<SelfopSyncStage>().eq("stage_batch_id",task.getId()).eq("object_type","OUTBOUND_ORDER").eq("object_key_hash",key));
if(prior==null)stages.insert(pending);else pending=prior;
e.setDirectoryStageId(pending.getId());});
manifest.getObjects().put(key,e);}
manifest.setNextPage(manifest.getNextPage()+1);if(page.pages()==0||manifest.getNextPage()>page.pages()){ manifest.setNextPage(manifest.getNextPage()+1);if(page.pages()==0||manifest.getNextPage()>page.pages()){
require(manifest.getObjects().size()==1,"SELFOP_LOCATE_UNRESOLVED");manifest.setDirectoryComplete(true);} require(manifest.getObjects().size()==1,"SELFOP_LOCATE_UNRESOLVED");manifest.setDirectoryComplete(true);}
tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);}); tx.executeWithoutResult(s->{var checkpoint=taskService.checkpoint(task,run,lease);saveManifest(checkpoint.task(),checkpoint.run(),manifest);});
@@ -88,7 +88,11 @@ public class SelfopValidationRunner {
while(state.detailIndex<state.orders.size()) { while(state.detailIndex<state.orders.size()) {
var summary=state.orders.get(state.detailIndex);leases.renew(lease); var summary=state.orders.get(state.detailIndex);leases.renew(lease);
require(selectedWarehouse.equals(summary.warehouseCode()),"SELFOP_SAMPLE_WAREHOUSE_MISMATCH"); require(selectedWarehouse.equals(summary.warehouseCode()),"SELFOP_SAMPLE_WAREHOUSE_MISMATCH");
var detail=request(task,run,lease,()->client.detail(context,summary.warehouseCode(),summary.customerCode(),summary.deliveryNo())); SelfopPayloads.Order selected=summary.order();
if(LingxingAnalyticsClient.needsDetail(selected)) {
selected=request(task,run,lease,()->client.detail(context,summary.warehouseCode(),summary.customerCode(),summary.deliveryNo()));
}
var detail=selected;
validateOrder(task,summary,detail); validateOrder(task,summary,detail);
tx.executeWithoutResult(s->{check(task,run,lease); tx.executeWithoutResult(s->{check(task,run,lease);
boolean unknown=detail.items().stream().anyMatch(i->i.actualQuantity()==null); boolean unknown=detail.items().stream().anyMatch(i->i.actualQuantity()==null);
@@ -74,6 +74,21 @@ class RestLingxingAnalyticsClientTest {
assertThat(summary.warehouseCode()).isEqualTo("WH-B"); assertThat(summary.warehouseCode()).isEqualTo("WH-B");
server.verify(); server.verify();
} }
@Test void listItemsUseQtyAndKeepUnknownSeparateFromZero() {
enable();properties.setQuantityUnit("PCS");
expect("/outbound-orders/locate", "{\"deliveryNo\":\"DO-1\",\"current\":1,\"size\":100}", """
{"code":"200","data":{"current":1,"size":100,"pages":1,"total":1,"records":[
{"deliveryNo":"DO-1","customerCode":"C","whCode":"WH-A","status":100,
"productList":[{"productSku":"SKU","qty":2.123456,"actualDeliveryQty":99},
{"productSku":"ZERO","qty":0},{"productSku":"UNKNOWN"}]}]}}
""");
var summary=client.locate(context("synthetic-account"),"DO-1",1,100).records().get(0);
assertThat(summary.items().get(0).actualQuantity()).isEqualByComparingTo("2.123456");
assertThat(summary.items().get(1).actualQuantity()).isEqualByComparingTo("0");
assertThat(summary.items().get(2).actualQuantity()).isNull();
assertThat(summary.items().get(0).quantityUnit()).isEqualTo("PCS");
assertThat(LingxingAnalyticsClient.needsDetail(summary.order())).isTrue();server.verify();
}
@Test void detailProjectsActualLinesOnlyAndDoesNotInventUnitOrTimestamp() { @Test void detailProjectsActualLinesOnlyAndDoesNotInventUnitOrTimestamp() {
enable(); enable();
expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """ expect("/outbound-orders/detail", "{\"warehouseCode\":\"WH-A\",\"customerCode\":\"C\",\"deliveryNo\":\"DO-1\"}", """
@@ -102,7 +102,7 @@ class SelfopDiscoveryServiceTest {
} }
private LingxingAnalyticsClient.Summary summary(String no, String time) { private LingxingAnalyticsClient.Summary summary(String no, String time) {
return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time); return new LingxingAnalyticsClient.Summary("demo", "customer", "A", no, null, 100, null, time, null);
} }
private SelfopPayloads.Order detail(String no, String time) { private SelfopPayloads.Order detail(String no, String time) {
return new SelfopPayloads.Order("demo", "customer", "A", no, null, 100, null, time, return new SelfopPayloads.Order("demo", "customer", "A", no, null, 100, null, time,
@@ -240,7 +240,7 @@ class SelfopDiscoveryServiceTest {
}).when(taskService).yield(anyLong(), anyLong(), eq(lease)); }).when(taskService).yield(anyLong(), anyLong(), eq(lease));
when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved"))) when(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved")))
.thenThrow(new AnalyticsClientException("LINGXING_WMS_SOURCE_REJECTED", false)); .thenThrow(new AnalyticsClientException("LINGXING_WMS_SOURCE_REJECTED", false));
var moved = new LingxingAnalyticsClient.Summary("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00"); var moved = new LingxingAnalyticsClient.Summary("demo", "customer", "B", "moved", null, 100, null, "2026-10-11 12:00:00", null);
when(client.locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, moved)); when(client.locate(any(SelfopRequestContext.class), eq("moved"), eq(1), eq(2))).thenReturn(page(1, 2, 1, 1, moved));
when(client.detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"))).thenReturn(new SelfopPayloads.Order("demo", "customer", "B", "moved", null, when(client.detail(any(SelfopRequestContext.class), eq("B"), eq("customer"), eq("moved"))).thenReturn(new SelfopPayloads.Order("demo", "customer", "B", "moved", null,
100, null, "2026-10-11 12:00:00", List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS")))); 100, null, "2026-10-11 12:00:00", List.of(new SelfopPayloads.Item("sku", null, BigDecimal.ONE, "PCS"))));
@@ -296,6 +296,85 @@ class SelfopDiscoveryServiceTest {
assertEquals("ROUTED",run.getStatus());assertEquals(500L,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(key).getEffectiveStageId()); assertEquals("ROUTED",run.getStatus());assertEquals(500L,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(key).getEffectiveStageId());
assertEquals(SelfopOutboundFingerprint.order(loaded),stored.get(500L).getContentHash()); assertEquals(SelfopOutboundFingerprint.order(loaded),stored.get(500L).getContentHash());
} }
private LingxingAnalyticsClient.Summary listed(String no, List<SelfopPayloads.Item> items) {
var s=summary(no,"2026-10-11 12:00:00");
return new LingxingAnalyticsClient.Summary(s.sourceAccount(),s.customerCode(),s.warehouseCode(),s.deliveryNo(),
s.sourceNo(),s.sourceStatus(),s.sourceCreatedTime(),s.sourceOutboundTime(),items);
}
@Test void completeListAndRealZeroNeedNoDetailsEvenWhenRequestBudgetIsExhausted() {
account.setRequestBudget(2);
when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1,
listed("one",List.of(new SelfopPayloads.Item("sku",null,new BigDecimal("2.123456"),"PCS"))),
listed("zero",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ZERO,"PCS")))));
service.collect(task,run,lease);
verify(client,never()).detail(any(),anyString(),anyString(),anyString());
assertEquals("ROUTED",run.getStatus());assertEquals(2,stored.size());
assertTrue(stored.values().stream().allMatch(s->"VALID".equals(s.getValidationStatus())));
var quantities=stored.values().stream().map(s->read(s.getPayloadJson(),SelfopPayloads.Order.class).items().get(0).actualQuantity()).toList();
assertTrue(quantities.stream().anyMatch(q->q.compareTo(new BigDecimal("2.123456"))==0));
assertTrue(quantities.stream().anyMatch(q->q.signum()==0));
}
@Test void completeListYieldsAtTimeLimitAndResumesWithoutRequests() {
when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1,
listed("one",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS"))),
listed("two",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.TEN,"PCS")))));
try(var budgets=mockConstruction(SelfopBudget.class,(budget,context)->{
when(budget.take()).thenReturn(true);
when(budget.hasTime()).thenReturn(true,false);
})) {
service.collect(task,run,lease);
verify(budgets.constructed().get(0),times(2)).take();
}
verify(taskService).yield(task.getId(),run.getId(),lease);
assertEquals(1,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count());
assertTrue(children.values().stream().allMatch(s->"WAITING_SOURCE".equals(s.getStatus())));
service.collect(task,run,lease);
verify(client,times(2)).discover(any(),eq(from),eq(until),eq(1),eq(2));
verify(client,never()).detail(any(),anyString(),anyString(),anyString());
assertEquals(2,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count());
assertEquals("ROUTED",run.getStatus());
}
@Test void onlyIncompleteOrderFallsBackAndRetryDoesNotRefetchCompletedOrders() {
when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1,
listed("complete",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.TEN,"PCS"))),
listed("missing",List.of(new SelfopPayloads.Item("sku",null,null,"PCS")))));
when(client.detail(any(),eq("A"),eq("customer"),eq("missing")))
.thenThrow(new AnalyticsClientException("INTEGRATION_PROVIDER_FAILURE",true))
.thenReturn(detail("missing","2026-10-11 12:00:00"));
assertThrows(AnalyticsClientException.class,()->service.collect(task,run,lease));
assertEquals(1,stored.values().stream().filter(s->"VALID".equals(s.getValidationStatus())).count());
service.collect(task,run,lease);
verify(client,never()).detail(any(),eq("A"),eq("customer"),eq("complete"));
verify(client,times(2)).detail(any(),eq("A"),eq("customer"),eq("missing"));
verify(client,times(2)).discover(any(),eq(from),eq(until),eq(1),eq(2));
assertEquals("ROUTED",run.getStatus());
}
@Test void missingSkuOrEmptyItemsFallsBackPerOrder() {
when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,2,1,
listed("blank",List.of(new SelfopPayloads.Item(" ",null,BigDecimal.ONE,"PCS"))),listed("empty",List.of())));
service.collect(task,run,lease);
verify(client).detail(any(),eq("A"),eq("customer"),eq("blank"));
verify(client).detail(any(),eq("A"),eq("customer"),eq("empty"));
assertEquals("ROUTED",run.getStatus());
}
@Test void genuinelyEmptyDirectoryDoesNotFetchDetails() {
when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,0,0));
service.collect(task,run,lease);
verify(client,never()).detail(any(),anyString(),anyString(),anyString());
assertEquals("ROUTED",run.getStatus());assertTrue(stored.isEmpty());
}
@Test void partialOldDirectoryRestartsScansWithListItems() {
var old=new SelfopRoutingManifest();old.setScan(2);old.setNextPage(1);old.setPages(1);old.setTotal(1L);
old.getFirstScan().put(hash(List.of("demo","customer","one")),"old-summary-without-items");
task.setRoutingManifestJson(json(old));
when(client.discover(any(),eq(from),eq(until),eq(1),eq(2))).thenReturn(page(1,2,1,1,
listed("one",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS")))));
service.collect(task,run,lease);
verify(client,times(2)).discover(any(),eq(from),eq(until),eq(1),eq(2));
verify(client,never()).detail(any(),anyString(),anyString(),anyString());
assertEquals("ROUTED",run.getStatus());
assertEquals(1,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getListPayloadVersion());
}
@Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() { @Test void validationPurposeCannotReachAccountRoutingOrFormalCoverage() {
task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION"); task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION");
assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage()); assertEquals("SELFOP_BUSINESS_PURPOSE_REQUIRED",assertThrows(IllegalStateException.class,()->service.collect(task,run,lease)).getMessage());
@@ -50,7 +50,7 @@ class SelfopValidationRunnerTest {
} }
private <T> LingxingAnalyticsClient.Page<T> page(int current,long total,int pages,List<T> records){return new LingxingAnalyticsClient.Page<>(records,current,10,total,pages);} private <T> LingxingAnalyticsClient.Page<T> page(int current,long total,int pages,List<T> records){return new LingxingAnalyticsClient.Page<>(records,current,10,total,pages);}
private SelfopPayloads.Stock stock(String warehouse,String sku,BigDecimal qty){return new SelfopPayloads.Stock("demo","customer",warehouse,sku,0,"Name",qty,qty,BigDecimal.ZERO,BigDecimal.ZERO,null);} private SelfopPayloads.Stock stock(String warehouse,String sku,BigDecimal qty){return new SelfopPayloads.Stock("demo","customer",warehouse,sku,0,"Name",qty,qty,BigDecimal.ZERO,BigDecimal.ZERO,null);}
private LingxingAnalyticsClient.Summary summary(String number){return new LingxingAnalyticsClient.Summary("demo","customer","A",number,"source",100,null,"2026-10-12 08:02:00");} private LingxingAnalyticsClient.Summary summary(String number){return new LingxingAnalyticsClient.Summary("demo","customer","A",number,"source",100,null,"2026-10-12 08:02:00", null);}
@Test void selectedWarehouseSampleOnlyStagesItsOwnRowsAndDoesNotClaimCompleteCoverage() { @Test void selectedWarehouseSampleOnlyStagesItsOwnRowsAndDoesNotClaimCompleteCoverage() {
when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("A","one",BigDecimal.ZERO)))); when(client.inventory(any(SelfopRequestContext.class),eq("A"),eq(1),eq(10))).thenReturn(page(1,30,3,List.of(stock("A","one",BigDecimal.ZERO))));
@@ -186,7 +186,7 @@ class SelfopValidationRunnerTest {
verify(client,never()).discover(any(SelfopRequestContext.class),any(),any(),anyInt(),anyInt());verifyNoInteractions(stages); verify(client,never()).discover(any(SelfopRequestContext.class),any(),any(),anyInt(),anyInt());verifyNoInteractions(stages);
} }
@Test void mixedAccountDirectoryOnlyFetchesDetailsOfFrozenWarehouse() { @Test void mixedAccountDirectoryOnlyFetchesDetailsOfFrozenWarehouse() {
var other=new LingxingAnalyticsClient.Summary("demo","customer","B","other","source",100,null,"2026-10-12 08:02:00"); var other=new LingxingAnalyticsClient.Summary("demo","customer","B","other","source",100,null,"2026-10-12 08:02:00", null);
when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,2,1,List.of(other,summary("selected")))); when(client.discover(any(SelfopRequestContext.class),any(),any(),eq(1),eq(10))).thenReturn(page(1,2,1,List.of(other,summary("selected"))));
when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("selected"))) when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("selected")))
.thenReturn(new SelfopPayloads.Order("demo","customer","A","selected","source",100,null,"2026-10-12 08:02:00",List.of())); .thenReturn(new SelfopPayloads.Order("demo","customer","A","selected","source",100,null,"2026-10-12 08:02:00",List.of()));