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
@@ -74,6 +74,21 @@ class RestLingxingAnalyticsClientTest {
assertThat(summary.warehouseCode()).isEqualTo("WH-B");
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() {
enable();
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) {
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) {
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(client.detail(any(SelfopRequestContext.class), eq("A"), eq("customer"), eq("moved")))
.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.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"))));
@@ -296,6 +296,85 @@ class SelfopDiscoveryServiceTest {
assertEquals("ROUTED",run.getStatus());assertEquals(500L,read(task.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().get(key).getEffectiveStageId());
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() {
task.setExecutionPurpose("VALIDATION");run.setExecutionPurpose("VALIDATION");
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 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() {
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);
}
@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.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()));