feat(selfop): calculate inventory and outbound volume by owner and SKU

This commit is contained in:
2026-10-14 17:48:52 +08:00
parent 814eb11d2f
commit 8bc8518496
6 changed files with 205 additions and 26 deletions
@@ -17,22 +17,83 @@ class InventoryTurnoverSqlTest {
@BeforeEach void setup() throws Exception {
db=DriverManager.getConnection("jdbc:h2:mem:turnover_"+UUID.randomUUID()+";MODE=MySQL","sa","");
config.addMapper(InventoryTurnoverReadMapper.class);
execute("CREATE TABLE ll_selfop_sync_task(id BIGINT,source_account VARCHAR,task_type VARCHAR,task_role VARCHAR,execution_purpose VARCHAR)");
execute("CREATE TABLE ll_selfop_sync_run(id BIGINT,task_id BIGINT,source_account VARCHAR,execution_purpose VARCHAR,status VARCHAR,coverage_status VARCHAR,published_at_utc TIMESTAMP)");
execute("CREATE TABLE ll_selfop_product(run_id BIGINT,source_account VARCHAR,customer_code VARCHAR,sku_code VARCHAR,declared_length DECIMAL(24,6),declared_width DECIMAL(24,6),declared_height DECIMAL(24,6),declared_size_unit VARCHAR,measured_length DECIMAL(24,6),measured_width DECIMAL(24,6),measured_height DECIMAL(24,6),measured_size_unit VARCHAR)");
execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
}
@AfterEach void close() throws Exception {db.close();}
private void master() throws Exception {
execute("INSERT INTO ll_selfop_sync_task VALUES(900,'demo','MASTER_DATA','ACCOUNT_DISCOVERY','BUSINESS')");
execute("INSERT INTO ll_selfop_sync_run VALUES(901,900,'demo','BUSINESS','PUBLISHED','COMPLETE',TIMESTAMP '2026-10-12 00:00:00')");
}
private void product(String owner,String sku,String declared,String measured) throws Exception {
execute("INSERT INTO ll_selfop_product VALUES(901,'demo','"+owner+"','"+sku+"',"+declared+","+measured+")");
}
@Test void emptyUncoveredOutboundVolumeIsUnknownAndCompleteEmptyVolumesAreZero() throws Exception {
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20);
var unknown=query("overview",input(q,0,0,true)).get(0);
decimal("0",unknown.get("physical_volume"));assertNull(unknown.get("w30_outbound_volume"));
var complete=query("overview",input(q,30,0,true)).get(0);
decimal("0",complete.get("physical_volume"));decimal("0",complete.get("w30_outbound_volume"));
}
@Test void volumesUseWholeMeasuredTripletAndConvertInchesWithoutMergingOwnersOrPages() throws Exception {
master();seed("A","SAME",0);seed("B","SAME",0);
product("A","SAME","1,1,1,'in'","100,100,100,'cm'");
product("B","SAME","1,1,1,'in'","100,NULL,100,'cm'");
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,1);
var a=reportRows(input(q,30,0,true)).get(0);
decimal("1",a.get("unit_volume"));decimal("610",a.get("physical_volume"));decimal("300",a.get("w30_outbound_volume"));
assertEquals("WAREHOUSE_MEASURED",a.get("dimension_source"));
var b=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,2,1),30,0,true)).get(0);
decimal("0.000016387064",b.get("unit_volume"));decimal("0.00999610904",b.get("physical_volume"));
assertEquals("PRODUCT_DECLARED",b.get("dimension_source"));decimal("610",b.get("physical_estimated_quantity"));
var summary=query("overview",input(q,30,0,true)).get(0);decimal("610.00999610904",summary.get("physical_volume"));
var owners=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,0,true));
assertEquals(2,owners.size());decimal("300",owners.get(0).get("w30_outbound_volume"));
decimal("0.0049161192",owners.get(1).get("w30_outbound_volume"));
}
@Test void missingDimensionsAndUnknownQuantityRemainSeparateAndZeroNeedsNoDimensions() throws Exception {
master();seed("A","KNOWN",0);seed("A","MISSING",0);
product("A","KNOWN","10,10,10,'cm'","0,0,0,'cm'");
product("A","MISSING","10,10,10,'unsupported'","10,-1,10,'cm'");
var q=request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20);
var row=reportRows(input(q,30,0,true)).get(0);
assertNull(row.get("physical_volume"));decimal("0.61",row.get("physical_volume_known"));
decimal("610",row.get("physical_missing_dimension_quantity"));decimal("1",row.get("physical_missing_dimension_skus"));
assertNull(row.get("w30_outbound_volume"));decimal("0.3",row.get("w30_outbound_volume_known"));
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL");
row=reportRows(input(q,7,0,true)).get(0);
assertNull(row.get("physical_volume"));decimal("2",row.get("physical_unknown_quantity_skus"));
assertNull(row.get("w30_outbound_volume"));decimal("2",row.get("w30_outbound_unknown_quantity_skus"));
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN sku_code='MISSING' THEN 0 WHEN product_quality=0 THEN 600 ELSE 10 END");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=0 WHERE sku_code='MISSING'");
row=reportRows(input(q,30,0,true)).get(0);
decimal("0.61",row.get("physical_volume"));decimal("0.3",row.get("w30_outbound_volume"));
decimal("0",row.get("physical_missing_dimension_skus"));
}
@Test void volumeMasterVersionIsFrozenAndFailedFutureOrOtherAccountRowsCannotLeak() throws Exception {
master();seed("A","SKU",0);product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL");
execute("INSERT INTO ll_selfop_sync_run VALUES(902,900,'demo','BUSINESS','PUBLISHED','COMPLETE',TIMESTAMP '2026-10-14 00:00:00'),(903,900,'demo','BUSINESS','FAILED','COMPLETE',TIMESTAMP '2026-10-12 10:00:00')");
execute("INSERT INTO ll_selfop_product VALUES(902,'demo','A','SKU',100,100,100,'cm',NULL,NULL,NULL,NULL),(903,'demo','A','SKU',200,200,200,'cm',NULL,NULL,NULL,NULL),(901,'other','A','SKU',300,300,300,'cm',NULL,NULL,NULL,NULL)");
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,0,true);
decimal("0.001",reportRows(p).get(0).get("unit_volume"));
var later=new InventoryTurnoverSql.Input(p.query(),11,22,true,true,p.days(),LocalDateTime.of(2026,10,15,0,0));
decimal("1",reportRows(later).get(0).get("unit_volume"));
}
static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) {
return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null);
}
static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) {
List<InventoryTurnoverSql.Day> days=new ArrayList<>();
for(int i=0;i<30;i++){LocalDate date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),i>=30-covered?1:0,i<samples?100L+i:null));}
return new InventoryTurnoverSql.Input(q,11,22,fresh,true,List.copyOf(days));
return new InventoryTurnoverSql.Input(q,11,22,fresh,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0));
}
@Test void selectedCustomWindowsUseEveryDayIncludingBeyondThirtyAndKeepSampleGaps() throws Exception {
seed("A","SKU",1);
seed("A","SKU",1);master();product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL");
execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2025-01-01 00:00:00'");
for(int n:List.of(3,45,366)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
@@ -42,9 +103,10 @@ class InventoryTurnoverSqlTest {
var date=END.minusDays(horizon-1-i);
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,date.equals(END)?null:100L));
}
var input=new InventoryTurnoverSql.Input(q,11,22,true,true,dates);
var input=new InventoryTurnoverSql.Input(q,11,22,true,true,dates,LocalDateTime.of(2026,10,13,0,0));
var row=query("page",input).get(0);
decimal(Integer.toString(Math.min(30,n)*10),row.get("w"+n+"_outbound"));
decimal(new BigDecimal("0.01").multiply(BigDecimal.valueOf(Math.min(30,n))).toPlainString(),row.get("w"+n+"_outbound_volume"));
decimal(Integer.toString(n-1),row.get("sample_count"));
assertEquals(END.toString(),row.get("missing_dates"));
assertNull(row.get("turnover"));
@@ -235,7 +297,7 @@ class InventoryTurnoverSqlTest {
if(i>=30-n&&!present)missing.add(date.toString());
}
var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null);
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates));
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates),LocalDateTime.of(2026,10,13,0,0));
var row=query("page",p).get(0);
assertNotNull(row.get("sample_mask"));
decimal(String.valueOf(onlyLast?1:n-1),row.get("sample_count"));
@@ -278,7 +340,7 @@ class InventoryTurnoverSqlTest {
for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));}
for(int n:List.of(7,15,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days));
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0));
var row=reportRows(p).get(0);
decimal(String.valueOf(n),row.get("sample_count"));
decimal(n==7?"270":n==15?"230":"155",row.get("average_physical"));
@@ -286,7 +348,7 @@ class InventoryTurnoverSqlTest {
assertNotNull(row.get("turnover"));
}
var missing=days.get(14);days.set(14,new InventoryTurnoverSql.Day(missing.date(),missing.startUtc(),1,null));
var row=reportRows(new InventoryTurnoverSql.Input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),11,22,true,true,List.copyOf(days))).get(0);
var row=reportRows(new InventoryTurnoverSql.Input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0))).get(0);
decimal("29",row.get("sample_count"));assertNull(row.get("turnover"));
assertEquals(missing.date().toString(),row.get("missing_dates"));
}
@@ -314,7 +376,7 @@ class InventoryTurnoverSqlTest {
var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true);
var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed"));
var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.sampleRun())).toList();
var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays)).get(0);
var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0);
assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial"));
var complete=reportRows(input(p.query(),30,30,true)).get(0);
decimal("0",complete.get("w30_outbound"));decimal("30",complete.get("w30_complete"));