feat(selfop): add warehouse dashboard and exact inventory drilldowns
This commit is contained in:
+16
-6
@@ -23,7 +23,7 @@ class PlatformAnalyticsControllerTest {
|
||||
private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));}
|
||||
@Test void rejectsCustomerEvenWithReadPermission(){
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var controller=new PlatformAnalyticsController(service,collection);
|
||||
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
|
||||
var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
|
||||
assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null));
|
||||
@@ -31,7 +31,7 @@ class PlatformAnalyticsControllerTest {
|
||||
}
|
||||
@Test void writePermissionAloneDoesNotAuthorizeAnyRead(){
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var controller=new PlatformAnalyticsController(service,collection);
|
||||
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
|
||||
var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
|
||||
assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null));
|
||||
@@ -40,7 +40,7 @@ class PlatformAnalyticsControllerTest {
|
||||
}
|
||||
@Test void allElevenReadEndpointsShareTheIndependentGuard(){
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var c=new PlatformAnalyticsController(service,collection);var user=platform();
|
||||
var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform();
|
||||
c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null);
|
||||
c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null);
|
||||
verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null);
|
||||
@@ -56,7 +56,7 @@ class PlatformAnalyticsControllerTest {
|
||||
}
|
||||
@Test void httpBindsCanonicalChartContextAndCustomInclusiveDates() throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary")
|
||||
.param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","CUSTOM").param("startDate","2026-10-01")
|
||||
.param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22")
|
||||
@@ -68,7 +68,7 @@ class PlatformAnalyticsControllerTest {
|
||||
}
|
||||
@Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks/LXCOL-DEMO")
|
||||
.param("sourceAccount","demo").param("warehouseCode","W")).andExpect(status().isOk());
|
||||
var cap=ArgumentCaptor.forClass(CollectionQuery.class);verify(collection).taskDetail(eq("LXCOL-DEMO"),cap.capture());
|
||||
@@ -76,7 +76,7 @@ class PlatformAnalyticsControllerTest {
|
||||
}
|
||||
@Test void inventoryHttpBindsAttentionSortAndThresholdIndependentlyFromVersionIdentity()throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection)).setCustomArgumentResolvers(identity()).build();
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage").param("sourceAccount","demo").param("warehouseCode","W")
|
||||
.param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")).andExpect(status().isOk());
|
||||
var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class);
|
||||
@@ -84,4 +84,14 @@ class PlatformAnalyticsControllerTest {
|
||||
assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold());
|
||||
verifyNoInteractions(collection);
|
||||
}
|
||||
@Test void dashboardUsesIndependentReadGuardAndRejectsArbitraryTopN()throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);var dashboard=mock(DashboardQueryService.class);
|
||||
var controller=new PlatformAnalyticsController(service,collection,dashboard);
|
||||
var customer=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
|
||||
assertThrows(ResponseStatusException.class,()->controller.dashboard(customer,null,java.util.Map.of()));verifyNoInteractions(dashboard);
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,()->controller.dashboard(platform(),null,java.util.Map.of("topN","500")));
|
||||
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/dashboard").param("sourceAccount","demo").param("warehouseCode","W").param("rangePreset","TODAY")).andExpect(status().isOk());
|
||||
var q=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(dashboard).dashboard(q.capture());assertEquals("TODAY",q.getValue().rangePreset());assertNull(q.getValue().size());
|
||||
}
|
||||
}
|
||||
|
||||
+15
-5
@@ -247,14 +247,24 @@ class AnalyticsQueryServiceTest {
|
||||
}
|
||||
@Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){
|
||||
var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null);
|
||||
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null)));
|
||||
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.ownerOverview(metrics(null),options()));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null)));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null);}
|
||||
@Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD");
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o));
|
||||
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
var q=pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1);
|
||||
assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->service.inventoryCoverage(q,o)).getMessage());
|
||||
verify(mapper,never()).inventoryCount(any(),any());verify(mapper,never()).inventoryPage(any(),any());
|
||||
// Normal inventory diagnostics still return the same fixed but stale version.
|
||||
assertEquals("STALE",service.inventoryCoverage(q,options()).context().inventoryStatus());
|
||||
}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);}
|
||||
private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){
|
||||
BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales);
|
||||
return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null,
|
||||
|
||||
+17
-2
@@ -77,7 +77,8 @@ class AnalyticsReadMapperTest {
|
||||
assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))"));
|
||||
assertTrue(sql.contains("GROUP BY customer_code,sku_code"));
|
||||
assertTrue(sql.indexOf("GROUP BY customer_code,sku_code")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
|
||||
assertTrue(sql.endsWith("LIMIT ? OFFSET ?")); immutableOnly(sql);
|
||||
if("HOT".equals(kind))assertTrue(sql.indexOf("LIMIT ? OFFSET ?")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
|
||||
else assertTrue(sql.endsWith("LIMIT ? OFFSET ?")); immutableOnly(sql);
|
||||
}
|
||||
String hot=bound(c,"page",Map.of("p",request("HOT"))).getSql();
|
||||
assertTrue(hot.contains("FROM result WHERE window_quantity>0 ORDER BY window_quantity DESC,window_sku_order_count DESC,sku_code ASC,customer_code ASC LIMIT"));
|
||||
@@ -90,10 +91,11 @@ class AnalyticsReadMapperTest {
|
||||
assertTrue(stats.contains("LEAST(SUM(CASE WHEN eligible=1 THEN amount ELSE 0 END) OVER (),100) total"));
|
||||
assertTrue(stats.contains("excluded_count"));assertFalse(stats.contains(" LIMIT "));
|
||||
assertFalse(stats.contains("ranged AS"));assertFalse(stats.contains("TIMESTAMPDIFF"));
|
||||
if("HOT".equals(kind))assertFalse(stats.contains("ll_selfop_inventory_snapshot"));
|
||||
}
|
||||
}
|
||||
@Test void inventoryHistoryOnlyEnrichesQualifiedPageAndNeverCountsOrOwnerTotals(){
|
||||
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90);
|
||||
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90,null,null,null,null);
|
||||
String page=bound(c,"inventoryPage",Map.of("p",p,"o",o)).getSql();
|
||||
assertTrue(page.contains("paged AS (SELECT * FROM result WHERE above_threshold=1 ORDER BY"));
|
||||
assertTrue(page.indexOf("LIMIT ? OFFSET ?")<page.indexOf("last_sales AS"));
|
||||
@@ -103,6 +105,19 @@ class AnalyticsReadMapperTest {
|
||||
assertFalse(bound(c,"ownerTotals",Map.of("p",p,"o",o)).getSql().contains("last_sales"));
|
||||
immutableOnly(page);
|
||||
}
|
||||
@Test void distributionCountCannotUseUnfilteredKeysAndStockOnlyCountSkipsSales(){
|
||||
var c=config();var p=request("INVENTORY");
|
||||
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"DAYS_0_30","COVERAGE_DISTRIBUTION_V1",null,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null))){
|
||||
String count=bound(c,"inventoryCount",Map.of("p",p,"o",o)).getSql();
|
||||
assertTrue(count.contains("FROM result WHERE"));assertTrue(count.contains("ll_selfop_sku_daily_sales"));
|
||||
}
|
||||
for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){
|
||||
String count=bound(c,"inventoryCount",Map.of("p",p,"o",new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null))).getSql();
|
||||
assertFalse(count.contains("ll_selfop_sku_daily_sales"));assertTrue(count.contains("i.quantity_unit='PCS'"));
|
||||
assertTrue(count.contains("snapshot_run_id=?"));assertTrue(count.contains("st.amount>0"));
|
||||
}
|
||||
}
|
||||
@Test void observationAndUnitsRequireImmutableProofAndPcsNotFirstSeenOrStockPlaceholders(){
|
||||
String sql=bound(config(),"page",Map.of("p",request("INVENTORY"))).getSql();
|
||||
assertTrue(sql.contains("s.evidence_type='VALID_OUTBOUND' AND s.evidence_run_id IS NOT NULL"));
|
||||
|
||||
+41
-11
@@ -94,7 +94,9 @@ public class AnalyticsSqlSemanticTest {
|
||||
assertTrue(rows.stream().allMatch(r->r.unitValid()&&r.observedQuantity().compareTo(new BigDecimal("5"))==0));
|
||||
var row=rows.get(0);
|
||||
assertTrue(row.unitValid());assertEquals(0,row.unknownDayCount());
|
||||
assertTrue(page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20)).isEmpty());
|
||||
var hot=page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20));
|
||||
assertEquals(2,hot.size());assertTrue(hot.stream().allMatch(r->r.windowQuantity().compareTo(new BigDecimal("5"))==0));
|
||||
assertTrue(hot.stream().allMatch(r->"UNKNOWN".equals(r.inventoryQuality())));
|
||||
}
|
||||
@Test void inventoryQualitiesAggregateBeforeSalesAndKeepOnlyGoodAvailable() throws Exception {
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
@@ -106,6 +108,30 @@ public class AnalyticsSqlSemanticTest {
|
||||
assertDecimal("25",row.frozenQuantity());assertDecimal("40",row.transitQuantity());assertDecimal("150",row.outboundQuantity30d());
|
||||
assertEquals(D.minusDays(1),row.lastOutbound());assertEquals(1L,row.orderCount());
|
||||
}
|
||||
@Test void hotCompleteWindowIgnoresTodayUnitButTodayValueRemainsUnknown() throws Exception {
|
||||
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
daily(11,"W","C","SKU",D.minusDays(1),"12");daily(11,"W","C","SKU",D,"500");
|
||||
try(var sql=schema.createStatement()){sql.executeUpdate("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='BOX' WHERE business_date=DATE '2026-10-12'");}
|
||||
var rows=page(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20));
|
||||
assertEquals(1,rows.size());assertDecimal("12",rows.get(0).windowQuantity());
|
||||
assertDecimal("12",rows.get(0).sales30Quantity());assertNull(rows.get(0).todayQuantity());
|
||||
var stats=rankingStats(request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20));
|
||||
assertEquals(1L,stats.get(0).total());
|
||||
}
|
||||
@Test void stockOnlyCountMatchesPageForFrozenAndDefectiveWithoutSalesDependency() throws Exception {
|
||||
profile(22,"C","VALID","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","BAD-UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");
|
||||
profile(22,"C","MISSING","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
stockExact("VALID",0,"5","1","4","0","VALID",null);stockExact("VALID",1,"8","0","8","0","VALID",null);
|
||||
stockExact("BAD-UNIT",1,"8","0","8","0","VALID",null);
|
||||
stockExact("MISSING",1,null,"0",null,"0","UNKNOWN","FIELD_MISSING");
|
||||
var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20);
|
||||
for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){
|
||||
var o=new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null);
|
||||
assertEquals(1L,((Number)literalQuery("inventoryCount",p,o).get(0)).longValue());
|
||||
assertEquals(List.of("VALID"),inventory(attention,null,null,0,20).stream().map(Inventory->Inventory.skuCode()).toList());
|
||||
}
|
||||
}
|
||||
@Test void quantityUnknownIsDependencySpecificAndUnknownQualityIsNotDefective() throws Exception {
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
stockExact("SKU",0,"20","10",null,"2","UNKNOWN","FIELD_MISSING");
|
||||
@@ -132,7 +158,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
var rows=inventory("COVERAGE_GT_THRESHOLD",300,"PHYSICAL_DESC",100,20);assertEquals(6,rows.size());
|
||||
assertEquals("BOUNDARY",rows.get(5).skuCode());assertDecimal("300.000001",rows.get(5).coverageDays());
|
||||
var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),100,20);
|
||||
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300);
|
||||
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300,null,null,null,null);
|
||||
assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue());
|
||||
Map<String,Object> totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue());
|
||||
assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known"));
|
||||
@@ -141,7 +167,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null);
|
||||
daily(11,"W","C","NEW",D.minusDays(1),"5");
|
||||
var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound());
|
||||
Map<String,Object> totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30));
|
||||
Map<String,Object> totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null));
|
||||
assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
|
||||
assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical"));
|
||||
}
|
||||
@@ -150,17 +176,17 @@ public class AnalyticsSqlSemanticTest {
|
||||
profile(11,owner,"SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");profile(22,owner,"SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
}
|
||||
var p=request("OWNERS",D.minusDays(30),D,null,null,utc(D,8),0,20);
|
||||
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null)));
|
||||
assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null)));
|
||||
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null)));
|
||||
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null)).get(0)).longValue());
|
||||
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null,null,null,null,null)));
|
||||
assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null,null,null,null,null)));
|
||||
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null,null,null,null,null)));
|
||||
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)).get(0)).longValue());
|
||||
}
|
||||
@Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception {
|
||||
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");
|
||||
daily(11,"W","C","SKU",D.minusDays(1),"12");stockExact("SKU",0,"30","30","0","0","VALID",null);
|
||||
var row=inventory(null,30,null,0,20).get(0);assertDecimal("12",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.physicalQuantity());
|
||||
var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30));
|
||||
var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null));
|
||||
assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
|
||||
}
|
||||
@Test void yearlySparseSummaryPreservesOwnerIdentityAndCompletePartialUnknownSpanCounts() throws Exception {
|
||||
@@ -196,9 +222,13 @@ public class AnalyticsSqlSemanticTest {
|
||||
profile(22,"C","ZERO","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");stock("C","ZERO",0,"0");
|
||||
profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stock("C","NEW",0,"10");
|
||||
profile(22,"C","UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),null);stock("C","UNIT",0,"10");
|
||||
// Stock's missing unit must not create a sales-unit exclusion: the complete
|
||||
// sales window still proves no outbound for UNIT. A broken sales profile is different.
|
||||
profile(11,"C","SALES-UNIT","VALID_OUTBOUND",utc(D.minusDays(40),0),null);
|
||||
daily(11,"W","C","SALES-UNIT",D.minusDays(1),"1000");
|
||||
var hot=request("HOT",D.minusDays(30),D,null,null,utc(D,8),0,20);
|
||||
var hotStats=rankingStats(hot);assertTrue(hotStats.stream().allMatch(s->s.total()==100L));
|
||||
assertEquals(2L,hotStats.stream().filter(s->"ZERO_OUTBOUND".equals(s.reason())).findFirst().orElseThrow().excludedCount());
|
||||
assertEquals(3L,hotStats.stream().filter(s->"ZERO_OUTBOUND".equals(s.reason())).findFirst().orElseThrow().excludedCount());
|
||||
assertEquals(1L,hotStats.stream().filter(s->"UNIT_UNKNOWN_OR_CONFLICT".equals(s.reason())).findFirst().orElseThrow().excludedCount());
|
||||
assertEquals("B",page(hot).get(0).customerCode());
|
||||
var slowStats=rankingStats(request("SLOW",D.minusDays(30),D,null,null,utc(D,8),0,20));
|
||||
@@ -218,12 +248,12 @@ public class AnalyticsSqlSemanticTest {
|
||||
assertEquals(D.minusDays(60),second.get(0).lastOutbound());
|
||||
}
|
||||
@SuppressWarnings("unchecked") private List<AnalyticsReadMapper.RankingStat> rankingStats(AnalyticsSqlRequest p){
|
||||
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null));
|
||||
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null));
|
||||
}
|
||||
private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));}
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<AnalyticsReadRows.Inventory> inventory(String attention,Integer threshold,String sort,long offset,int size){
|
||||
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold));
|
||||
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold,null,null,null,null));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String,Object> totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesBucketPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class DashboardPolicyTest {
|
||||
static final LocalDate DAY=LocalDate.of(2026,10,13);
|
||||
static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z");
|
||||
static AnalyticsResponses.Context context(Instant coveredFrom,Instant coveredUntil,boolean inventory){return new AnalyticsResponses.Context(11L,inventory?22L:0L,"a".repeat(64),inventory?"b".repeat(64):null,NOW,NOW.plusSeconds(1800),"Asia/Shanghai",DAY,DAY.minusDays(14),DAY.plusDays(1),null,DAY.minusDays(30),DAY,coveredFrom,coveredUntil,NOW.minusSeconds(60),inventory?NOW.minusSeconds(60):null,inventory?NOW.minusSeconds(30):null,"VALID",inventory?"VALID":"UNKNOWN",false,"CURRENT",List.of());}
|
||||
@Test void warehouseObservedTotalPreservesOutsideWatermarkAndUnknownGap(){
|
||||
var c=context(null,null,true);var rows=List.of(new DashboardReadMapper.Day(DAY.minusDays(3),new BigDecimal("2"),0L),new DashboardReadMapper.Day(DAY.minusDays(1),new BigDecimal("5"),0L),new DashboardReadMapper.Day(DAY,BigDecimal.ONE,0L));
|
||||
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(3),DAY.plusDays(1),rows);var summary=SalesBucketPolicy.aggregate(points);
|
||||
assertEquals(new BigDecimal("8"),summary.observedQuantity());assertNull(summary.outboundQuantity());assertNull(points.get(1).observedQuantity());assertEquals("PARTIAL",points.get(3).dataStatus());
|
||||
}
|
||||
@Test void completeWarehouseZeroAndPartialWarehouseZeroNeedNoSkuObservation(){
|
||||
var c=context(DAY.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),NOW,true);
|
||||
var points=DashboardSalesPolicy.points(c,true,DAY.minusDays(1),DAY.plusDays(1),List.of());
|
||||
assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(BigDecimal.ZERO,points.get(0).outboundQuantity());
|
||||
assertEquals("PARTIAL",points.get(1).dataStatus());assertEquals(BigDecimal.ZERO,points.get(1).observedQuantity());assertNull(points.get(1).outboundQuantity());
|
||||
var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of()));assertNull(unknown.observedQuantity());
|
||||
}
|
||||
@Test void strictInventoryDrilldownExpiresBeforeContextWhileOrdinaryInventoryRemainsAllowed(){
|
||||
var c=context(NOW.minusSeconds(86400),NOW,true);var q=query(true);var strict=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,"DASHBOARD");
|
||||
Instant fresh=NOW.plusSeconds(60);assertEquals(fresh,CoverageDistributionPolicy.expiresAt(c,fresh));
|
||||
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh.minusSeconds(1)));
|
||||
assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh)).getMessage());
|
||||
assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(query(false),strict,c,fresh,NOW));
|
||||
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null),c,fresh,fresh.plusSeconds(1)));
|
||||
assertNull(CoverageDistributionPolicy.expiresAt(context(null,null,false),fresh));
|
||||
}
|
||||
@Test void bucketSelectionAndAttentionCannotSilentlyBroadenToWarehouse(){
|
||||
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null),
|
||||
new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null),
|
||||
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",null)))assertThrows(BizException.class,()->o.validate("INVENTORY",query(true)));
|
||||
assertThrows(BizException.class,()->DashboardRequest.validateParameterNames(Set.of("sourceAccount","warehouseCode","topN")));
|
||||
assertEquals("LAST_15_DAYS",DashboardRequest.normalize(query(false)).rangePreset());
|
||||
}
|
||||
private static AnalyticsQuery query(boolean pinned){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,null,null,null,pinned?11L:null,pinned?22L:null,pinned?"a".repeat(64):null,pinned?"b".repeat(64):null,pinned?NOW:null);}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesRangePolicy;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DashboardQueryServiceTest {
|
||||
@Test void oneContextKeepsThirtyDaySalesAndHotAvailableWhenInventoryUnavailable()throws Exception{
|
||||
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper);
|
||||
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
|
||||
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
|
||||
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
|
||||
var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
|
||||
var snapshot=new AnalyticsQueryService.Snapshot(q,null,null,true,true,day.minusDays(30),day,range,context);
|
||||
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
|
||||
var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD");
|
||||
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p);
|
||||
when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L)));
|
||||
when(mapper.statistics(any(),any())).thenReturn(Map.of("total_skus",200L,"hot_eligible",200L,"hot_unknown",0L));
|
||||
when(mapper.rankings(any(),any())).thenReturn(List.of(new DashboardReadMapper.Ranked("HOT","A","X","Product",new BigDecimal("30"),new BigDecimal("30"),1L,null,null,null,null,null,null)));
|
||||
var result=service.dashboard(q);
|
||||
assertEquals(0,BigDecimal.ONE.compareTo(result.kpis().averageDailyOutboundQuantity30d().value()));
|
||||
assertEquals(200L,result.hotSkus().eligibleCount());assertEquals(1,result.hotSkus().items().size());assertEquals("A",result.hotSkus().items().get(0).customerCode());
|
||||
assertNull(result.coverageDistribution().candidateSkuCount());assertTrue(result.coverageDistribution().buckets().stream().allMatch(b->b.skuCount()==null));
|
||||
assertNull(result.context().inventoryDrilldownExpiresAt());assertEquals(day.minusDays(30),result.context().fixedWindowStartDate());
|
||||
verify(mapper).daily(any(),any(),any());verify(mapper).statistics(any(),any());verify(mapper).rankings(any(),any());verifyNoMoreInteractions(mapper);
|
||||
var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext"));
|
||||
assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly());
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
|
||||
import org.apache.ibatis.mapping.*;
|
||||
import org.apache.ibatis.session.*;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Production SQL on process-local synthetic H2, never a real environment or outbound HTTP. */
|
||||
class DashboardSqlSemanticTest {
|
||||
private Connection connection;private SqlSession session;private Configuration config;private int sequence;
|
||||
private static final LocalDate D=LocalDate.of(2026,10,13);
|
||||
@BeforeEach void setup()throws Exception{
|
||||
var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:dashboard_"+UUID.randomUUID().toString().replace("-","")+";MODE=MySQL","sa","");connection=ds.getConnection();
|
||||
try(var s=connection.createStatement()){
|
||||
s.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
|
||||
s.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
|
||||
s.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
|
||||
s.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))");
|
||||
}
|
||||
config=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DashboardReadMapper.class);config.addMapper(AnalyticsReadMapper.class);
|
||||
session=new SqlSessionFactoryBuilder().build(config).openSession();
|
||||
}
|
||||
@AfterEach void close()throws Exception{if(session!=null)session.close();if(connection!=null)connection.close();}
|
||||
@Test void sevenExclusiveBucketsConserveCandidatesAndEveryInventoryDrilldownMatches()throws Exception{
|
||||
fixture("NOOUT","100","100",null);fixture("FROZEN","50","0",null);fixture("UNKNOWN",null,null,null);
|
||||
fixture("THIRTY","30","30","30");fixture("THIRTY04","30.04","30.04","30");fixture("ZERO","0","0",null);
|
||||
var stats=stats();assertEquals(5,count(stats,"candidate_skus"));assertEquals(1,count(stats,"excluded_zero_skus"));
|
||||
assertEquals(1,count(stats,"bucket_days_0_30"));assertEquals(1,count(stats,"bucket_days_30_60"));assertEquals(1,count(stats,"bucket_no_outbound_30d"));
|
||||
assertEquals(1,count(stats,"bucket_no_available"));assertEquals(1,count(stats,"bucket_unknown"));
|
||||
long sum=0;for(String bucket:CoverageDistributionPolicy.BUCKETS){long n=count(stats,"bucket_"+bucket.toLowerCase(Locale.ROOT));sum+=n;
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null);
|
||||
assertEquals(n,((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",o)).get(0)).longValue(),bucket);
|
||||
}assertEquals(5,sum);
|
||||
var finite=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",null,null,null,"FINITE_POSITIVE",null);
|
||||
assertEquals(count(stats,"long_eligible"),((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",finite)).get(0)).longValue());
|
||||
assertEquals(2,count(stats,"no_outbound_eligible"));
|
||||
var ranked=ranks();assertEquals("THIRTY04",ranked.stream().filter(r->r.rankCode().equals("LONG")).max(Comparator.comparing(DashboardReadMapper.Ranked::mainValue)).orElseThrow().skuCode());
|
||||
assertTrue(ranked.stream().anyMatch(r->r.rankCode().equals("NO_OUTBOUND")&&r.skuCode().equals("FROZEN")));
|
||||
}
|
||||
@Test void exactNinetyBoundaryAndUnknownInventoryRemainDistinct()throws Exception{
|
||||
fixture("NINETY","90","90","30");fixture("NINETY04","90.04","90.04","30");fixture("INFINITE","999","999",null);
|
||||
var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_30d"));
|
||||
assertEquals(2,count(s,"long_eligible"));assertTrue(ranks().stream().filter(r->r.rankCode().equals("LONG")).noneMatch(r->r.skuCode().equals("INFINITE")));
|
||||
}
|
||||
@Test void twoHundredHotCandidatesCountBeyondTopHundredAndKeepTopTenIdentical()throws Exception{
|
||||
for(int i=0;i<200;i++){
|
||||
String sku=String.format("SKU-%03d",i);profile(11,"C",sku,"PCS");if(i%3!=0)profile(22,"C",sku,i%3==1?"BOX":null);
|
||||
daily("C",sku,D.minusDays(1),Integer.toString(i+1),"PCS");
|
||||
}
|
||||
daily("C","SKU-199",D,"999","BOX");
|
||||
assertEquals(200,count(stats(),"hot_eligible"));var top=ranks().stream().filter(r->r.rankCode().equals("HOT")).sorted(Comparator.comparing(DashboardReadMapper.Ranked::mainValue).reversed()).toList();assertEquals(10,top.size());
|
||||
var p=request();var hot=new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),true,true,true,true,true,0,10,"HOT");
|
||||
List<?> page=query(AnalyticsReadMapper.class,"page",Map.of("p",hot));assertEquals(10,page.size());
|
||||
for(int i=0;i<10;i++){var item=(AnalyticsReadRows.Aggregate)page.get(i);assertEquals(top.get(i).skuCode(),item.skuCode());assertEquals(0,top.get(i).mainValue().compareTo(item.windowQuantity()));}
|
||||
}
|
||||
@Test void warehouseDailyIncludesBothOwnersWithoutSkuObservationAndNoQualityMultiplication()throws Exception{
|
||||
profile(11,"A","SAME","PCS");profile(11,"B","SAME","PCS");daily("A","SAME",D.minusDays(1),"2","PCS");daily("B","SAME",D.minusDays(1),"5","PCS");
|
||||
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(2),"chartUntil",D.plusDays(1)));
|
||||
assertEquals(1,rows.size());var row=(DashboardReadMapper.Day)rows.get(0);assertEquals(0,new BigDecimal("7").compareTo(row.observedQuantity()));assertEquals(0L,row.invalidRowCount());
|
||||
}
|
||||
private void fixture(String sku,String physical,String available,String sold)throws Exception{profile(11,"C",sku,"PCS");profile(22,"C",sku,"PCS");stock(sku,physical,available);if(sold!=null)daily("C",sku,D.minusDays(1),sold,"PCS");}
|
||||
private void profile(long run,String owner,String sku,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_profile VALUES(?,?,?,?,?,?,?,?,?,?)")){
|
||||
p.setLong(1,run);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setString(6,sku);p.setString(7,unit);p.setString(8,run==11?"VALID_OUTBOUND":"INVENTORY_OBSERVATION");p.setLong(9,run);p.setObject(10,D.minusDays(40).atStartOfDay().minusHours(8));p.executeUpdate();}}
|
||||
private void daily(String owner,String sku,LocalDate day,String value,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_daily_sales VALUES(?,?,?,?,?,?,?,?,?,?)")){
|
||||
p.setLong(1,++sequence);p.setLong(2,11);p.setString(3,"demo");p.setString(4,"W");p.setString(5,owner);p.setString(6,sku);p.setObject(7,day);p.setBigDecimal(8,new BigDecimal(value));p.setLong(9,1);p.setString(10,unit);p.executeUpdate();}}
|
||||
private void stock(String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
|
||||
p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}}
|
||||
private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);}
|
||||
@SuppressWarnings("unchecked") private Map<String,Object> stats(){Map<String,Object> result=new HashMap<>();((Map<String,Object>)query(DashboardReadMapper.class,"statistics",Map.of("p",request(),"o",options())).get(0)).forEach((k,v)->result.put(k.toLowerCase(Locale.ROOT),v));return result;}
|
||||
@SuppressWarnings("unchecked") private List<DashboardReadMapper.Ranked> ranks(){return (List<DashboardReadMapper.Ranked>)(List<?>)query(DashboardReadMapper.class,"rankings",Map.of("p",request(),"o",options()));}
|
||||
private static long count(Map<String,Object> stats,String key){return ((Number)stats.get(key)).longValue();}
|
||||
private List<?> query(Class<?> mapper,String method,Map<String,Object> args){
|
||||
var original=config.getMappedStatement(mapper.getName()+"."+method);var bound=original.getBoundSql(args);var meta=org.apache.ibatis.reflection.SystemMetaObject.forObject(args);
|
||||
StringBuilder sql=new StringBuilder();int index=0;boolean quoted=false;
|
||||
for(int i=0;i<bound.getSql().length();i++){char ch=bound.getSql().charAt(i);if(ch=='\'')quoted=!quoted;
|
||||
if(ch!='?'||quoted){sql.append(ch);continue;}String key=bound.getParameterMappings().get(index++).getProperty();Object v=meta.getValue(key);
|
||||
if(v==null)sql.append("NULL");else if(v instanceof Boolean b)sql.append(b?1:0);else if(v instanceof Number)sql.append(v);else sql.append("'").append(v.toString().replace("'","''")).append("'");
|
||||
}
|
||||
String id="dashboard.literal."+(++sequence);config.addMappedStatement(new MappedStatement.Builder(config,id,new org.apache.ibatis.builder.StaticSqlSource(config,sql.toString()),SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id);
|
||||
}
|
||||
}
|
||||
+32
@@ -127,6 +127,38 @@ class SelfopDiscoveryServiceTest {
|
||||
assertEquals(2, stored.size()); assertTrue(stored.values().stream().allMatch(s -> "VALID".equals(s.getValidationStatus())));
|
||||
assertEquals("ROUTED", run.getStatus()); assertEquals(2L, run.getUniqueObjectCount());
|
||||
}
|
||||
@Test void emptyWarehouseWaitsForAllAccountDetailsAcrossBudgetYieldAndReceivesRelocatedOrder() {
|
||||
var destination=new SelfopSyncScope();destination.setId(11L);destination.setSourceAccount("demo");destination.setWarehouseCode("B");
|
||||
destination.setDataFamily("OUTBOUND");destination.setScopeHash("warehouse-b-hash");destination.setScopeJson("{}");destination.setSourceTimezone("UTC");
|
||||
config.getWarehouses().put("B",new SelfopAnalyticsProperties.Warehouse());when(scopes.selectList(any())).thenReturn(List.of(scope,destination));
|
||||
directoryTwoOrders();account.setRequestBudget(3); // Two stable directory scans and only the first detail.
|
||||
var previous=new SelfopOutboundOrder();previous.setWarehouseCode("A");
|
||||
when(orders.selectOne(any())).thenAnswer(inv->{QueryWrapper<SelfopOutboundOrder> query=inv.getArgument(0);query.getSqlSegment();
|
||||
return query.getParamNameValuePairs().values().contains("two")?previous:null;});
|
||||
when(client.detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two"))).thenReturn(new SelfopPayloads.Order("demo","customer","B","two",null,
|
||||
100,null,"2026-10-11 12:00:00",List.of(new SelfopPayloads.Item("sku",null,BigDecimal.ONE,"PCS"))));
|
||||
List<String> readiness=new ArrayList<>();doAnswer(inv->{SelfopSyncTask child=inv.getArgument(0);if("WAREHOUSE_SYNC".equals(child.getTaskRole()))readiness.add(child.getWarehouseCode()+":"+child.getStatus());return 1;}).when(tasks).updateById(any(SelfopSyncTask.class));
|
||||
|
||||
service.collect(task,run,lease);
|
||||
var warehouseA=children.get("route:1:A");var warehouseB=children.get("route:1:B");
|
||||
assertEquals("WAITING_SOURCE",warehouseA.getStatus());assertEquals("WAITING_SOURCE",warehouseB.getStatus());
|
||||
assertTrue(read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().isEmpty());
|
||||
var partial=read(task.getRoutingManifestJson(),SelfopRoutingManifest.class);
|
||||
assertTrue(partial.isDirectoryComplete());assertEquals(1,partial.getObjects().values().stream().filter(e->e.getEffectiveStageId()!=null).count());
|
||||
assertTrue(readiness.stream().noneMatch(value->value.endsWith(":READY")));verify(taskService).yield(1L,20L,lease);
|
||||
verify(client,never()).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two"));
|
||||
|
||||
account.setRequestBudget(10);service.collect(task,run,lease);
|
||||
assertEquals("READY",warehouseA.getStatus());assertEquals("READY",warehouseB.getStatus());
|
||||
var destinationManifest=read(warehouseB.getRoutingManifestJson(),SelfopRoutingManifest.class);
|
||||
String movedKey=hash(List.of("demo","customer","two"));assertEquals(Set.of(movedKey),destinationManifest.getObjects().keySet());
|
||||
var moved=destinationManifest.getObjects().get(movedKey);assertNotNull(moved.getEffectiveStageId());assertEquals("B",moved.getWarehouseCode());
|
||||
assertEquals("B",read(stored.get(moved.getEffectiveStageId()).getPayloadJson(),SelfopPayloads.Order.class).warehouseCode());
|
||||
assertTrue(read(warehouseA.getRoutingManifestJson(),SelfopRoutingManifest.class).getObjects().containsKey(movedKey),"Original warehouse must retain the known-order correction reference");
|
||||
assertEquals("ROUTED",run.getStatus());verify(client,times(2)).discover(any(SelfopRequestContext.class),eq(from),eq(until),eq(1),eq(2));
|
||||
verify(client,times(1)).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("one"));
|
||||
verify(client,times(1)).detail(any(SelfopRequestContext.class),eq("A"),eq("customer"),eq("two"));
|
||||
}
|
||||
@Test void eachActualDetailGetsIndependentObservationNotTaskSequence() {
|
||||
directoryTwoOrders(); service.collect(task, run, lease);
|
||||
assertEquals(Set.of(101L, 102L), new HashSet<>(stored.values().stream().map(SelfopSyncStage::getObservationSequence).toList()));
|
||||
|
||||
Reference in New Issue
Block a user