fix: finalize inventory turnover queries and reuse snapshot aggregation

This commit is contained in:
2026-10-14 01:51:51 +08:00
parent d029f9a19e
commit 16d0b9c80f
10 changed files with 527 additions and 28 deletions
@@ -42,13 +42,15 @@ public class InventoryTurnoverContext {
sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf);
// Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway.
var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null);
String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null);
var mapping=new TreeMap<LocalDate,Long>();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),utc(asOf),end.minusDays(29),end.plusDays(1))) {
if(sample.sampleDate()==null||sample.runId()==null||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(29),end.plusDays(1))) {
if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(29))||sample.sampleDate().isAfter(end)
||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh();
}
List<Sample> samples=new ArrayList<>();
for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date)));
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),utc(asOf));
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf));
var c=snapshot.context();
var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(),
c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began);
@@ -69,8 +69,13 @@ public final class InventoryTurnoverPolicy {
"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"));
}
static Overview overview(Map<String,Object> raw) {
static Overview overview(Map<String,Object> raw,Context context) {
var r=normalized(raw);int noUnknown=count(r,"no_outbound_unknown");var noKnown=decimal(r,"no_outbound_count");
if(count(r,"sku_count")==0&&!"VALID".equals(context.analyticsContext().inventoryStatus())) {
var unknown=new Total(null,null,0,List.of("INVENTORY_UNKNOWN_OR_STALE"));
return new Overview(unknown,unknown,new Total(null,null,0,List.of("SALES_OR_STOCK_UNASSESSABLE")),
new Total(null,null,0,List.of("SALES_OR_STOCK_UNASSESSABLE")));
}
return new Overview(total(r,"physical",true),total(r,"available",true),
new Total(noUnknown==0?noKnown:null,noKnown,noUnknown,noUnknown==0?List.of():List.of("SALES_OR_STOCK_UNASSESSABLE")),
new Total(decimal(r,"unassessable"),decimal(r,"unassessable"),0,List.of()));
@@ -1,6 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.*;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
@@ -12,22 +15,31 @@ import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverP
public class InventoryTurnoverQueryService {
private final InventoryTurnoverContext contexts;
private final InventoryTurnoverReadMapper mapper;
public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper){this.contexts=contexts;this.mapper=mapper;}
private final ObjectMapper json;
public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper,ObjectMapper json){this.contexts=contexts;this.mapper=mapper;this.json=json;}
public Response query(InventoryTurnoverRequest request) {
var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response();
long total=mapper.count(input);
var rows=request.offset()>=total?List.<Row>of():mapper.page(input).stream().map(r->row(r,request,context)).toList();
Overview summary=null;Charts charts=null;
if(request.overview()) {
summary=overview(mapper.overview(input));
List<OwnerPhysical> owners=new ArrayList<>();int unknown=0;
for(var raw:mapper.owners(input)) {
var r=normalized(raw);
if("META".equals(text(r,"kind")))unknown=count(r,"unknown_count");
else owners.add(new OwnerPhysical(text(r,"customer_code"),null,decimal(r,"physical")));
long total=0;boolean hasTotal=false;Overview summary=null;Charts charts=null;
List<Row> rows=new ArrayList<>();List<OwnerPhysical> owners=new ArrayList<>();int unknown=0;
for(var part:mapper.report(input)) {
var envelope=normalized(part);Map<String,Object> value;
try {
value=json.readerFor(new TypeReference<Map<String,Object>>(){}).with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.readValue(text(envelope,"payload"));
} catch(Exception e){throw new IllegalStateException("Invalid turnover query projection",e);}
switch(text(envelope,"section")) {
case "ROW" -> rows.add(row(value,request,context));
case "TOTAL" -> {total=decimal(value,"total").longValueExact();hasTotal=true;}
case "OVERVIEW" -> summary=overview(value,context);
case "OWNER" -> owners.add(new OwnerPhysical(text(value,"customer_code"),null,decimal(value,"physical")));
case "OWNER_META" -> unknown=count(value,"unknown_count");
default -> throw new IllegalStateException("Unknown turnover query section");
}
}
if(!hasTotal||request.overview()&&summary==null)throw new IllegalStateException("Missing turnover query section");
if(request.overview()) {
charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(input)),context.endDate().plusDays(1)));
}
return new Response(context,summary,charts,rows,total);
return new Response(context,summary,charts,List.copyOf(rows),total);
}
}
@@ -15,11 +15,12 @@ public interface InventoryTurnoverReadMapper {
AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
// The immutable first-daily marker is set only by publication. Recheck its original schedule evidence.
String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} "
String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} "
+"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' "
+"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' "
+"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' "
+"AND t.task_role='WAREHOUSE_SYNC' AND r.page_count>=1 AND r.source_total_count>=0 "
+"AND t.task_role='WAREHOUSE_SYNC' AND t.scope_hash=r.scope_hash "
+"AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code AND r.page_count>=1 AND r.source_total_count>=0 "
+"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc "
+"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
@@ -27,10 +28,10 @@ public interface InventoryTurnoverReadMapper {
@Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r "
+"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE
+"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date")
List<SampleRun> samples(@Param("account") String account,@Param("warehouse") String warehouse,
List<SampleRun> samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,
@Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until);
@Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE)
LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("asOf") LocalDateTime asOf);
LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
@SelectProvider(type=InventoryTurnoverSql.class,method="page")
List<Map<String,Object>> page(@Param("p") InventoryTurnoverSql.Input input);
@@ -42,4 +43,7 @@ public interface InventoryTurnoverReadMapper {
List<Map<String,Object>> owners(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="chartWindows")
Map<String,Object> chartWindows(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="report")
List<Map<String,Object>> report(@Param("p") InventoryTurnoverSql.Input input);
}
@@ -30,7 +30,7 @@ public final class InventoryTurnoverSql {
return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}"
+" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})";
}
private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
private static String valid(String field){return "product_quality IN (0,1) AND "+field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";}
private static String calendar(Input p) {
var parts=new ArrayList<String>();
@@ -50,7 +50,8 @@ public final class InventoryTurnoverSql {
.append("), stocks AS (").append(stocks).append("), sales_days AS (SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE ")
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date<CAST(#{p.until} AS DATE) GROUP BY customer_code,sku_code,business_date), candidates AS (")
.append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days), profiles AS (")
.append("SELECT c.customer_code,c.sku_code,MAX(f.product_name) product_name,MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END) unit_valid,")
.append("SELECT c.customer_code,c.sku_code,COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN f.product_name END),MAX(f.product_name)) product_name,")
.append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END)) unit_valid,")
.append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ")
.append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code), daily_values AS (")
.append("SELECT c.customer_code,c.sku_code,d.business_date,d.coverage,c.unit_valid,c.evidence,sd.quantity,sd.unit_valid sales_unit,")
@@ -60,7 +61,8 @@ public final class InventoryTurnoverSql {
.append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), day_result AS (")
.append("SELECT v.*,CASE WHEN #{p.salesKnown}=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 AND evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00') THEN 0 END END observed,")
.append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 AND (quantity IS NOT NULL OR evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00')) THEN 1 ELSE 0 END complete,")
.append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete FROM daily_values v), sku_windows AS (")
.append("CASE WHEN #{p.salesKnown}=1 AND coverage=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete,")
.append("CASE WHEN #{p.salesKnown}=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 THEN 0 END END owner_observed FROM daily_values v), sku_windows AS (")
.append("SELECT customer_code,sku_code").append(windowAggregates(false)).append(",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,")
.append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,")
.append("GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates ")
@@ -94,7 +96,7 @@ public final class InventoryTurnoverSql {
}
private static String ownerCte(Input p) {
return ", owner_days AS (SELECT customer_code,business_date,MIN(owner_complete) complete,"
+"CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed,"
+"SUM(owner_observed) observed,"
+"CASE WHEN COUNT(sample_physical)=COUNT(*) THEN SUM(sample_physical) END sample_physical FROM day_result GROUP BY customer_code,business_date), owner_windows AS (SELECT customer_code"
+windowAggregates(true)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,"
+"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,"
@@ -107,9 +109,9 @@ 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)
.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
.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(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown");
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");
if(withFocus)sql.append(",MAX(CASE WHEN available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END) fast_count,MAX(CASE WHEN frozen>0 THEN 1 ELSE 0 END) frozen_count,MAX(CASE WHEN defective>0 THEN 1 ELSE 0 END) defective_count");
return sql.toString();
}
@@ -150,9 +152,40 @@ public final class InventoryTurnoverSql {
public static String owners(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+ownerCte(p)
+", ranked_owners AS (SELECT customer_code,physical FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10) "
+"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL";
+"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code";
}
public static String chartWindows(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT business_date,MIN(owner_complete) complete,CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed FROM day_result GROUP BY business_date) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days";
Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT d.business_date,"
+"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete,"
+"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed "
+"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days";
}
private static String jsonFields(List<String> fields) {
return "JSON_OBJECT("+String.join(",",fields.stream().map(f->"'"+f+"',"+f).toList())+")";
}
/** One statement shares the materialized stock/day/window relations across all first-page sections. */
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"));
for(int n:List.of(7,15,30))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 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"));
String sql=cte(p)+(owner||overview?ownerCte(p):"")
+", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})";
if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)";
sql+=" SELECT 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_result"
+" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner);
if(overview) {
List<String> summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown"));
for(String field:List.of("physical","available")){summary.add(field);summary.add(field+"_known");summary.add(field+"_unknown");}
sql+=" UNION ALL SELECT 'OVERVIEW' section,0 ordinal,"+jsonFields(summary)+" payload FROM summary_result"
+" UNION ALL SELECT 'OWNER' section,ordinal,JSON_OBJECT('customer_code',customer_code,'physical',physical) payload FROM top_owners"
+" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_result WHERE physical IS NULL";
}
return sql+" ORDER BY section,ordinal";
}
}