feat(self-operated): expose and sort transit stock in turnover views

This commit is contained in:
2026-10-14 14:08:52 +08:00
parent 5ace2fb584
commit 2b3b13913b
6 changed files with 35 additions and 14 deletions
@@ -66,7 +66,7 @@ public final class InventoryTurnoverPolicy {
if(count(r,"unassessable")>0)attention.add("UNASSESSABLE");
String last=text(r,"last_outbound");
return new Row(text(r,"customer_code"),null,owner?null:text(r,"sku_code"),owner?null:text(r,"product_name"),physical,available,
total(r,"frozen",owner),total(r,"defective",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO,
total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO,
windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing),
"VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention),
risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"));
@@ -42,7 +42,7 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod
if(!Set.of("PHYSICAL_ASC","PHYSICAL_DESC","AVAILABLE_ASC","AVAILABLE_DESC",
"OUTBOUND_7_ASC","OUTBOUND_7_DESC","OUTBOUND_15_ASC","OUTBOUND_15_DESC","OUTBOUND_30_ASC","OUTBOUND_30_DESC",
"AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC","COVERAGE_ASC","COVERAGE_DESC",
"FROZEN_ASC","FROZEN_DESC","DEFECTIVE_ASC","DEFECTIVE_DESC","AVERAGE_PHYSICAL_ASC","AVERAGE_PHYSICAL_DESC",
"TRANSIT_ASC","TRANSIT_DESC","FROZEN_ASC","FROZEN_DESC","DEFECTIVE_ASC","DEFECTIVE_DESC","AVERAGE_PHYSICAL_ASC","AVERAGE_PHYSICAL_DESC",
"TURNOVER_ASC","TURNOVER_DESC","NO_OUTBOUND_PHYSICAL_DESC").contains(sort())
|| "OWNER".equals(group())&&sort().startsWith("COVERAGE_")
|| "SKU".equals(group())&&"NO_OUTBOUND_PHYSICAL_DESC".equals(sort())) throw new BizException("排序不适用于当前分组");
@@ -13,7 +13,7 @@ public final class InventoryTurnoverResponses {
public record Total(BigDecimal value, BigDecimal knownSubtotal, int unknownSkuCount, List<String> reasonCodes) {}
public record Risk(BigDecimal matchedSkuCount, BigDecimal knownPhysicalQuantity, int unknownSkuCount, BigDecimal physicalQuantityRatio) {}
public record Row(String customerCode,String customerName,String sku,String productName,
Total physicalQuantity,Total availableQuantity,Total frozenQuantity,Total defectiveQuantity,
Total physicalQuantity,Total availableQuantity,Total frozenQuantity,Total defectiveQuantity,Total transitQuantity,
BigDecimal physicalSkuCount,List<Window> windows,BigDecimal averageDailyOutboundQuantity,
BigDecimal inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity,
BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays,
@@ -62,7 +62,7 @@ public final class InventoryTurnoverSql {
}
private static String cte(Input p) {
String stocks="SELECT snapshot_run_id,customer_code,sku_code,"+amount("physical_quantity")+" physical,"
+amount("frozen_quantity")+" frozen,"+AnalyticsInventorySql.availableQuantity(valid("available_quantity"))+" available,"
+amount("transit_quantity")+" transit,"+amount("frozen_quantity")+" frozen,"+AnalyticsInventorySql.availableQuantity(valid("available_quantity"))+" available,"
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) OR product_quality IS NULL THEN 0 WHEN product_quality=1 AND NOT ("+valid("physical_quantity")+") THEN 0 ELSE 1 END)=1 "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective "
+"FROM ll_selfop_inventory_snapshot WHERE "+scope("")+" AND (snapshot_run_id=#{p.inventoryRun} OR snapshot_run_id IN ("+sampleRuns(p)+")) "
@@ -80,7 +80,7 @@ public final class InventoryTurnoverSql {
sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid");
for(int n:p.query().windows()) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix);
sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound");
for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field);
for(String field:List.of("physical","available","frozen","defective","transit")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field);
sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ")
.append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,")
.append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,")
@@ -191,7 +191,7 @@ public final class InventoryTurnoverSql {
}
private static String totals(String key,boolean withFocus) {
var sql=new StringBuilder("SELECT ").append(key==null?"COUNT(*) sku_count":key);
for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field)
for(String field:List.of("physical","available","frozen","defective","transit")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field)
.append(",CASE WHEN COUNT(*)=0 THEN 0 ELSE SUM(").append(field).append(") END ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
sql.append(",MAX(last_outbound) last_outbound,CASE WHEN COUNT(physical)=COUNT(*) THEN SUM(CASE WHEN physical>0 THEN 1 ELSE 0 END) END physical_skus,COALESCE(SUM(unassessable),0) unassessable");
for(String risk:List.of("no_outbound","slow")) sql.append(",COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END),0) ").append(risk).append("_count,COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END),0) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown");
@@ -222,6 +222,7 @@ public final class InventoryTurnoverSql {
// The denominator is constant for the whole query; use the unrounded numerator.
case "AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC" -> "w"+p.query.days()+"_outbound";
case "COVERAGE_ASC","COVERAGE_DESC" -> "coverage_days";
case "TRANSIT_ASC","TRANSIT_DESC" -> "transit";
case "FROZEN_ASC","FROZEN_DESC" -> "frozen";
case "DEFECTIVE_ASC","DEFECTIVE_DESC" -> "defective";
case "AVERAGE_PHYSICAL_ASC","AVERAGE_PHYSICAL_DESC" -> "average_physical";
@@ -260,11 +261,11 @@ public final class InventoryTurnoverSql {
public static String report(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview();
String source=owner?"owner_result":"sku_result";
List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable"));
List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable"));
for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix);
if(owner) {
fields.add("physical_skus");
for(String field:List.of("physical","available","frozen","defective"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix);
for(String field:List.of("physical","available","frozen","defective","transit"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix);
for(String risk:List.of("no_outbound","slow"))for(String suffix:List.of("count","physical","unknown"))fields.add(risk+"_"+suffix);
} else fields.addAll(List.of("sku_code","product_name","coverage_days","no_outbound","slow"));
// The physical-stock chart needs owner totals, not owner sales/sample windows.
@@ -42,7 +42,7 @@ paths:
- name: sortBy
in: query
description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。全量筛选后按正式未舍入值排序再分页,未知值始终末尾,同值按SKU及货主升序。AVERAGE_DAILY使用baseWindowDays指定的日均窗口。'
schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_ASC, PHYSICAL_DESC, AVAILABLE_ASC, AVAILABLE_DESC, OUTBOUND_7_ASC, OUTBOUND_7_DESC, OUTBOUND_15_ASC, OUTBOUND_15_DESC, OUTBOUND_30_ASC, OUTBOUND_30_DESC, AVERAGE_DAILY_ASC, AVERAGE_DAILY_DESC, COVERAGE_ASC, COVERAGE_DESC, FROZEN_ASC, FROZEN_DESC, DEFECTIVE_ASC, DEFECTIVE_DESC, AVERAGE_PHYSICAL_ASC, AVERAGE_PHYSICAL_DESC, TURNOVER_ASC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]}
schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_ASC, PHYSICAL_DESC, AVAILABLE_ASC, AVAILABLE_DESC, OUTBOUND_7_ASC, OUTBOUND_7_DESC, OUTBOUND_15_ASC, OUTBOUND_15_DESC, OUTBOUND_30_ASC, OUTBOUND_30_DESC, AVERAGE_DAILY_ASC, AVERAGE_DAILY_DESC, COVERAGE_ASC, COVERAGE_DESC, TRANSIT_ASC, TRANSIT_DESC, FROZEN_ASC, FROZEN_DESC, DEFECTIVE_ASC, DEFECTIVE_DESC, AVERAGE_PHYSICAL_ASC, AVERAGE_PHYSICAL_DESC, TURNOVER_ASC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]}
- {name: current, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
- {name: includeOverview, in: query, schema: {type: boolean, default: true}}
@@ -133,7 +133,7 @@ components:
accumulationStartedOn: {type: string, format: date, nullable: true}
Row:
type: object
required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutboundInWindow, slowCoverage, unassessableSkuCount]
required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, transitQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutboundInWindow, slowCoverage, unassessableSkuCount]
properties:
customerCode: {type: string}
customerName: {type: string, nullable: true}
@@ -143,6 +143,9 @@ components:
availableQuantity: {$ref: '#/components/schemas/Total'}
frozenQuantity: {$ref: '#/components/schemas/Total'}
defectiveQuantity: {$ref: '#/components/schemas/Total'}
transitQuantity:
allOf: [{$ref: '#/components/schemas/Total'}]
description: 当前固定库存版本的全品质在途件数;独立展示,不计入实物、可售或周转分母;缺失、异常或过期保留未知。
physicalSkuCount: {$ref: '#/components/schemas/Quantity'}
windows: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/Window'}}
averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'}
@@ -20,7 +20,7 @@ class InventoryTurnoverSqlTest {
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),quality_status VARCHAR,quality_reason 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();}
static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) {
@@ -61,6 +61,23 @@ class InventoryTurnoverSqlTest {
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=NULL,quality_status='UNKNOWN',quality_reason='FIELD_MISSING' WHERE product_quality=0");
assertNull(query("page",input(q,30,0,true)).get(0).get("available"));
}
@Test void transitUsesBothQualitiesAndPropagatesUnknownWithoutChangingStockMetrics() throws Exception {
seed("A","KNOWN",0);seed("A","UNKNOWN",0);
execute("UPDATE ll_selfop_inventory_snapshot SET transit_quantity=CASE WHEN product_quality=0 THEN 5 ELSE 2 END WHERE snapshot_run_id=22");
execute("UPDATE ll_selfop_inventory_snapshot SET transit_quantity=NULL,quality_status='UNKNOWN',quality_reason='FIELD_MISSING' WHERE snapshot_run_id=22 AND sku_code='UNKNOWN' AND product_quality=0");
var q=request("SKU","ALL","TRANSIT_DESC",null,null,1,20);
var rows=reportRows(input(q,30,0,true));
assertEquals("KNOWN",rows.get(0).get("sku_code"));
var known=InventoryTurnoverPolicy.row(rows.get(0),q,InventoryTurnoverPolicyTest.context());
decimal("7",known.transitQuantity().value());decimal("610",known.physicalQuantity().value());
decimal("600",known.availableQuantity().value());decimal("60",known.inventoryCoverageDays());
assertNull(rows.get(1).get("transit"));decimal("0",rows.get(1).get("unassessable"));
var ownerQuery=request("OWNER","ALL","TRANSIT_DESC",null,null,1,20);
var owner=InventoryTurnoverPolicy.row(reportRows(input(ownerQuery,30,0,true)).get(0),ownerQuery,InventoryTurnoverPolicyTest.context());
assertNull(owner.transitQuantity().value());decimal("7",owner.transitQuantity().knownSubtotal());
assertEquals(1,owner.transitQuantity().unknownSkuCount());decimal("1220",owner.physicalQuantity().value());
assertTrue(query("page",input(q,30,0,false)).stream().allMatch(r->r.get("transit")==null));
}
@Test void threeWindowsAndDailyQualitiesAreNotMultiplied() throws Exception {
seed("A","SKU",30);
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
@@ -504,10 +521,10 @@ class InventoryTurnoverSqlTest {
return raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"PHYSICAL","AVAILABLE","FROZEN","DEFECTIVE","AVERAGE_PHYSICAL","TURNOVER","COVERAGE","OUTBOUND_7","OUTBOUND_15","OUTBOUND_30","AVERAGE_DAILY"})
@org.junit.jupiter.params.provider.ValueSource(strings={"PHYSICAL","AVAILABLE","TRANSIT","FROZEN","DEFECTIVE","AVERAGE_PHYSICAL","TURNOVER","COVERAGE","OUTBOUND_7","OUTBOUND_15","OUTBOUND_30","AVERAGE_DAILY"})
void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception {
seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END");
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,transit_quantity=CASE customer_code WHEN 'L' THEN 2 WHEN 'H' THEN 8 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END");
for(String group:List.of("SKU","OWNER")) {
if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue;
@@ -563,7 +580,7 @@ class InventoryTurnoverSqlTest {
execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')");
}
private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception {
execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,'VALID',NULL)");
execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,0,'VALID',NULL)");
}
private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}}
private List<Map<String,Object>> query(String method,InventoryTurnoverSql.Input p) throws Exception {