mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
perf(reporting): collapse baseline + scenario queries, parallelize
Reporting was slow on large portfolios (e.g. #796) because every metric re-scanned property ⋈ property_details_epc ⋈ newApproachJoins, and the scenario overlay ran five queries strictly serially. Baseline (initial load): the six queries that were the identical scan — total, averages, totals, EPC-band distribution, estimated/actual split and expired count — collapse into one conditional-aggregation pass. Age bands (correlated subqueries) and likely downgrades (legacy-only join) stay separate and run in parallel. 8 queries → 3; ~1945ms → ~787ms on #796, results byte-identical. Scenario overlay: Query 2 (portfolio-after aggregates) and Query 3 (EPC distribution) were near-identical per-property LATERAL scans, and Q3 shipped one row per property to Node just to bucket bands in JS. Merged into a single scan that buckets bands in SQL (thresholds matched to sapToEpc exactly), and the three data queries now run in one Promise.all wave instead of serially. Applied to both the [scenarioId] and default routes. Verified identical aggregates + band counts on #796's ~31k-home scenario. All ADR-0002 / ADR-0010 semantics (effective baselines, compliance window, lodged toggle) preserved unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d0fdb6ad72
commit
c3530e7e5c
4 changed files with 285 additions and 258 deletions
|
|
@ -28,7 +28,13 @@ function makeProps(scenarioId = "12") {
|
|||
return { params: Promise.resolve({ portfolioId: "785", scenarioId }) };
|
||||
}
|
||||
|
||||
/** Queue the five route queries: scenario def, plans metrics, upgraded, portfolio, epc rows. */
|
||||
/**
|
||||
* Queue the route's queries. The scenario definition resolves first; the three
|
||||
* data queries (plans metrics, upgraded, portfolio-after) then run in one
|
||||
* Promise.all wave, so their mocks are consumed in that array order. The
|
||||
* portfolio-after query now also carries the EPC-band buckets (band_*), folded
|
||||
* in from the old separate distribution scan.
|
||||
*/
|
||||
function queueHappyPath({
|
||||
goal = "Increasing EPC",
|
||||
goalValue = "C",
|
||||
|
|
@ -63,10 +69,17 @@ function queueHappyPath({
|
|||
avg_bills: 941,
|
||||
total_carbon: 593,
|
||||
total_bills: 294_000,
|
||||
band_a: 0,
|
||||
band_b: 10,
|
||||
band_c: 60,
|
||||
band_d: 20,
|
||||
band_e: 8,
|
||||
band_f: 2,
|
||||
band_g: 0,
|
||||
band_unknown: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ rows: [] });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -105,6 +118,25 @@ describe("GET scenario metrics", () => {
|
|||
expect(body.gross_per_unit).toBe(14_000);
|
||||
});
|
||||
|
||||
it("passes the SQL-bucketed EPC-band counts straight through", async () => {
|
||||
queueHappyPath();
|
||||
const res = await GET(makeRequest(), makeProps());
|
||||
const body = await res.json();
|
||||
|
||||
// The band_* columns from the merged portfolio-after query map onto the
|
||||
// scenario_epc_counts the ladder consumes — no JS re-bucketing.
|
||||
expect(body.scenario_epc_counts).toEqual({
|
||||
A: 0,
|
||||
B: 10,
|
||||
C: 60,
|
||||
D: 20,
|
||||
E: 8,
|
||||
F: 2,
|
||||
G: 0,
|
||||
Unknown: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Compliance window — ADR-0010: report-view parameter, configurable band + date.
|
||||
|
||||
it("rejects a compliance band outside A–G", async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
|
||||
import {
|
||||
newApproachJoins,
|
||||
|
|
@ -41,11 +40,34 @@ type PortfolioAggregates = {
|
|||
avg_bills: number | null;
|
||||
total_carbon: number | null;
|
||||
total_bills: number | null;
|
||||
// EPC-band buckets, computed in SQL (see BAND_BUCKETS_SQL) rather than shipped
|
||||
// one row per property to Node.
|
||||
band_a: number;
|
||||
band_b: number;
|
||||
band_c: number;
|
||||
band_d: number;
|
||||
band_e: number;
|
||||
band_f: number;
|
||||
band_g: number;
|
||||
band_unknown: number;
|
||||
};
|
||||
|
||||
type EpcRow = {
|
||||
effective_sap: number | null;
|
||||
};
|
||||
/**
|
||||
* EPC-band buckets by SAP, matching sapToEpc's thresholds exactly (A≥92, B≥81,
|
||||
* C≥69, D≥55, E≥39, F≥21, else G; NULL → Unknown). Bucketing in SQL keeps the
|
||||
* scenario band distribution to a single aggregate row instead of returning
|
||||
* every property's SAP for the client to loop over.
|
||||
*/
|
||||
const BAND_BUCKETS_SQL = sql`
|
||||
COUNT(*) FILTER (WHERE band_sap >= 92)::int AS band_a,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 81 AND band_sap < 92)::int AS band_b,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 69 AND band_sap < 81)::int AS band_c,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 55 AND band_sap < 69)::int AS band_d,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 39 AND band_sap < 55)::int AS band_e,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 21 AND band_sap < 39)::int AS band_f,
|
||||
COUNT(*) FILTER (WHERE band_sap IS NOT NULL AND band_sap < 21)::int AS band_g,
|
||||
COUNT(*) FILTER (WHERE band_sap IS NULL)::int AS band_unknown
|
||||
`;
|
||||
|
||||
/* =======================
|
||||
Constants
|
||||
|
|
@ -169,10 +191,17 @@ export async function GET(
|
|||
? EPC_MIN_SAP[scenario.goal_value]
|
||||
: null;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
The three data queries below are independent — they only need `minSap`
|
||||
(from the scenario definition above), not each other's results — so they
|
||||
run in one parallel wave instead of four serial round-trips. Query 2 folds
|
||||
the old per-property EPC-distribution scan into its own aggregate.
|
||||
---------------------------------------------------------- */
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 1 — Scenario metrics (PLANS ONLY)
|
||||
---------------------------------------------------------- */
|
||||
const scenarioMetricsResult = await db.execute(sql`
|
||||
const scenarioMetricsSql = sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
|
|
@ -223,14 +252,12 @@ export async function GET(
|
|||
OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true) -- actual filter; unknown lodged → keep
|
||||
)
|
||||
AND ${passesComplianceWindowSql};
|
||||
`);
|
||||
|
||||
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
|
||||
`;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 1b — Upgrade costs (PLANS ONLY)
|
||||
---------------------------------------------------------- */
|
||||
const upgradedResult = await db.execute(sql`
|
||||
const upgradedCostsSql = sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
|
|
@ -271,20 +298,23 @@ export async function GET(
|
|||
OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true)
|
||||
)
|
||||
AND ${passesComplianceWindowSql};
|
||||
`);
|
||||
|
||||
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
|
||||
`;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 2 — Portfolio AFTER scenario (ALL properties)
|
||||
Folds the former EPC-distribution scan (Query 3) in: the same
|
||||
per-property LATERAL pass yields the average/total aggregates AND the
|
||||
band buckets (band_sap → BAND_BUCKETS_SQL), so it's one scan, not two,
|
||||
and no per-property rows cross the wire.
|
||||
---------------------------------------------------------- */
|
||||
const portfolioMetricsResult = await db.execute(sql`
|
||||
const portfolioMetricsSql = sql`
|
||||
SELECT
|
||||
AVG(effective_sap)::float AS avg_sap,
|
||||
AVG(effective_carbon)::float AS avg_carbon,
|
||||
AVG(effective_bills)::float AS avg_bills,
|
||||
SUM(effective_carbon)::float AS total_carbon,
|
||||
SUM(effective_bills)::float AS total_bills
|
||||
SUM(effective_bills)::float AS total_bills,
|
||||
${BAND_BUCKETS_SQL}
|
||||
FROM (
|
||||
SELECT
|
||||
/* ---------- SAP ---------- */
|
||||
|
|
@ -303,7 +333,20 @@ export async function GET(
|
|||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
|
||||
ELSE ${billsSql(sql`e`)}
|
||||
END AS effective_bills
|
||||
END AS effective_bills,
|
||||
|
||||
/* ---------- Band SAP (EPC distribution) ---------- */
|
||||
CASE
|
||||
-- A retrofit scenario can't make a property worse. The engine writes a
|
||||
-- target-level post_sap (e.g. ~C) even for properties already above the
|
||||
-- target, so post_sap can sit BELOW the baseline — which made the chart
|
||||
-- show e.g. B properties "improving" down to C. Clamp to the baseline.
|
||||
WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points)
|
||||
-- No qualifying plan → unchanged property. Use the effective
|
||||
-- (re-baselined) baseline to match the "before" distribution; NOT
|
||||
-- p.current_sap_points (NULL for new-approach → "Unknown"). See ADR-0002.
|
||||
ELSE ${effectiveSapSql}
|
||||
END AS band_sap
|
||||
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
|
|
@ -335,71 +378,31 @@ export async function GET(
|
|||
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
) q;
|
||||
`);
|
||||
`;
|
||||
|
||||
// One parallel wave: scenario metrics, upgrade costs, portfolio-after.
|
||||
const [scenarioMetricsResult, upgradedResult, portfolioMetricsResult] =
|
||||
await Promise.all([
|
||||
db.execute(scenarioMetricsSql),
|
||||
db.execute(upgradedCostsSql),
|
||||
db.execute(portfolioMetricsSql),
|
||||
]);
|
||||
|
||||
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
|
||||
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
|
||||
const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 3 — EPC band distribution (ALL properties)
|
||||
---------------------------------------------------------- */
|
||||
const epcRows = await db.execute(sql`
|
||||
SELECT
|
||||
CASE
|
||||
-- A retrofit scenario can't make a property worse. The engine writes a
|
||||
-- target-level post_sap (e.g. ~C) even for properties already above the
|
||||
-- target, so post_sap can sit BELOW the baseline — which made the chart
|
||||
-- show e.g. B properties "improving" down to C. Clamp to the baseline.
|
||||
WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points)
|
||||
-- No qualifying plan → unchanged property. Use the effective
|
||||
-- (re-baselined) baseline to match the "before" distribution; NOT
|
||||
-- p.current_sap_points (NULL for new-approach → "Unknown"). See ADR-0002.
|
||||
ELSE ${effectiveSapSql}
|
||||
END AS effective_sap
|
||||
FROM property p
|
||||
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id
|
||||
LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged'
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM plan
|
||||
WHERE plan.property_id = p.id
|
||||
AND plan.portfolio_id = ${pid}
|
||||
AND plan.scenario_id = ${sid}
|
||||
AND (
|
||||
${hideNonCompliant} = false
|
||||
OR (
|
||||
${minSap}::float IS NOT NULL
|
||||
AND plan.post_sap_points >= ${minSap}::float
|
||||
)
|
||||
)
|
||||
AND (
|
||||
${useLodgedBaseline} = false
|
||||
OR ${minSap}::float IS NULL
|
||||
OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true)
|
||||
)
|
||||
AND ${passesComplianceWindowSql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) lp ON true
|
||||
WHERE p.portfolio_id = ${pid};
|
||||
`);
|
||||
|
||||
const scenario_epc_counts: Record<string, number> = {
|
||||
A: 0,
|
||||
B: 0,
|
||||
C: 0,
|
||||
D: 0,
|
||||
E: 0,
|
||||
F: 0,
|
||||
G: 0,
|
||||
Unknown: 0,
|
||||
A: portfolioAgg.band_a,
|
||||
B: portfolioAgg.band_b,
|
||||
C: portfolioAgg.band_c,
|
||||
D: portfolioAgg.band_d,
|
||||
E: portfolioAgg.band_e,
|
||||
F: portfolioAgg.band_f,
|
||||
G: portfolioAgg.band_g,
|
||||
Unknown: portfolioAgg.band_unknown,
|
||||
};
|
||||
|
||||
for (const row of epcRows.rows as EpcRow[]) {
|
||||
const band = sapToEpc(row.effective_sap);
|
||||
scenario_epc_counts[band] += 1;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
RESPONSE
|
||||
---------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
|
||||
import {
|
||||
newApproachJoins,
|
||||
|
|
@ -37,11 +36,33 @@ type PortfolioAggregates = {
|
|||
avg_bills: number | null;
|
||||
total_carbon: number | null;
|
||||
total_bills: number | null;
|
||||
// EPC-band buckets, computed in SQL rather than shipped one row per property.
|
||||
band_a: number;
|
||||
band_b: number;
|
||||
band_c: number;
|
||||
band_d: number;
|
||||
band_e: number;
|
||||
band_f: number;
|
||||
band_g: number;
|
||||
band_unknown: number;
|
||||
};
|
||||
|
||||
type EpcRow = {
|
||||
effective_sap: number | null;
|
||||
};
|
||||
/**
|
||||
* EPC-band buckets by SAP, matching sapToEpc's thresholds exactly (A≥92, B≥81,
|
||||
* C≥69, D≥55, E≥39, F≥21, else G; NULL → Unknown). Bucketing in SQL keeps the
|
||||
* band distribution to a single aggregate row instead of returning every
|
||||
* property's SAP for the client to loop over.
|
||||
*/
|
||||
const BAND_BUCKETS_SQL = sql`
|
||||
COUNT(*) FILTER (WHERE band_sap >= 92)::int AS band_a,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 81 AND band_sap < 92)::int AS band_b,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 69 AND band_sap < 81)::int AS band_c,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 55 AND band_sap < 69)::int AS band_d,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 39 AND band_sap < 55)::int AS band_e,
|
||||
COUNT(*) FILTER (WHERE band_sap >= 21 AND band_sap < 39)::int AS band_f,
|
||||
COUNT(*) FILTER (WHERE band_sap IS NOT NULL AND band_sap < 21)::int AS band_g,
|
||||
COUNT(*) FILTER (WHERE band_sap IS NULL)::int AS band_unknown
|
||||
`;
|
||||
|
||||
/* =======================
|
||||
Constants
|
||||
|
|
@ -75,10 +96,16 @@ export async function GET(
|
|||
const hideNonCompliant =
|
||||
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
The three data queries below are independent (they share no results), so
|
||||
they run in one parallel wave. Query 2 folds the old per-property
|
||||
EPC-distribution scan into its own aggregate.
|
||||
---------------------------------------------------------- */
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 1 — Scenario metrics (PLANS ONLY)
|
||||
---------------------------------------------------------- */
|
||||
const scenarioMetricsResult = await db.execute(sql`
|
||||
const scenarioMetricsSql = sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
|
|
@ -109,14 +136,12 @@ export async function GET(
|
|||
FROM latest_plans lp
|
||||
JOIN property p ON p.id = lp.property_id
|
||||
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id;
|
||||
`);
|
||||
|
||||
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
|
||||
`;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 1b — Upgrade costs (PLANS ONLY)
|
||||
---------------------------------------------------------- */
|
||||
const upgradedResult = await db.execute(sql`
|
||||
const upgradedCostsSql = sql`
|
||||
WITH latest_plans AS (
|
||||
SELECT DISTINCT ON (property_id)
|
||||
*
|
||||
|
|
@ -143,20 +168,21 @@ export async function GET(
|
|||
-- exclude it from the count + cost. COALESCE keeps rows we can't compare
|
||||
-- (NULL post or NULL baseline). See ADR-0002.
|
||||
AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true);
|
||||
`);
|
||||
|
||||
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
|
||||
`;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 2 — Portfolio AFTER scenario (ALL properties)
|
||||
Folds the former EPC-distribution scan (Query 3) in: one per-property
|
||||
LATERAL pass yields both the aggregates and the band buckets.
|
||||
---------------------------------------------------------- */
|
||||
const portfolioMetricsResult = await db.execute(sql`
|
||||
const portfolioMetricsSql = sql`
|
||||
SELECT
|
||||
AVG(effective_sap)::float AS avg_sap,
|
||||
AVG(effective_carbon)::float AS avg_carbon,
|
||||
AVG(effective_bills)::float AS avg_bills,
|
||||
SUM(effective_carbon)::float AS total_carbon,
|
||||
SUM(effective_bills)::float AS total_bills
|
||||
SUM(effective_bills)::float AS total_bills,
|
||||
${BAND_BUCKETS_SQL}
|
||||
FROM (
|
||||
SELECT
|
||||
/* ---------- SAP ---------- */
|
||||
|
|
@ -175,7 +201,21 @@ export async function GET(
|
|||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
|
||||
ELSE ${billsSql(sql`e`)}
|
||||
END AS effective_bills
|
||||
END AS effective_bills,
|
||||
|
||||
/* ---------- Band SAP (EPC distribution) ---------- */
|
||||
CASE
|
||||
-- A retrofit scenario can't make a property worse. The engine writes a
|
||||
-- target-level post_sap (e.g. ~C) even for properties already above the
|
||||
-- target, so post_sap can sit BELOW the baseline — which made the chart
|
||||
-- show e.g. B properties "improving" down to C. Clamp to the baseline so
|
||||
-- the post-scenario band is never worse than before.
|
||||
WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points)
|
||||
-- No plan → unchanged property. Use the effective (re-baselined) baseline
|
||||
-- so this matches the "before" distribution — NOT p.current_sap_points,
|
||||
-- which is NULL for new-approach properties. See ADR-0002.
|
||||
ELSE ${effectiveSapSql}
|
||||
END AS band_sap
|
||||
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
|
|
@ -194,57 +234,31 @@ export async function GET(
|
|||
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
) q;
|
||||
`);
|
||||
`;
|
||||
|
||||
// One parallel wave: scenario metrics, upgrade costs, portfolio-after.
|
||||
const [scenarioMetricsResult, upgradedResult, portfolioMetricsResult] =
|
||||
await Promise.all([
|
||||
db.execute(scenarioMetricsSql),
|
||||
db.execute(upgradedCostsSql),
|
||||
db.execute(portfolioMetricsSql),
|
||||
]);
|
||||
|
||||
const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates;
|
||||
const upgraded = upgradedResult.rows[0] as UpgradedAggregates;
|
||||
const portfolioAgg = portfolioMetricsResult.rows[0] as PortfolioAggregates;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
QUERY 3 — EPC band distribution (ALL properties)
|
||||
---------------------------------------------------------- */
|
||||
const epcRows = await db.execute(sql`
|
||||
SELECT
|
||||
CASE
|
||||
-- A retrofit scenario can't make a property worse. The engine writes a
|
||||
-- target-level post_sap (e.g. ~C) even for properties already above the
|
||||
-- target, so post_sap can sit BELOW the baseline — which made the chart
|
||||
-- show e.g. B properties "improving" down to C. Clamp to the baseline so
|
||||
-- the post-scenario band is never worse than before.
|
||||
WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points)
|
||||
-- No plan → unchanged property. Use the effective (re-baselined) baseline
|
||||
-- so this matches the "before" distribution — NOT p.current_sap_points,
|
||||
-- which is NULL for new-approach properties. See ADR-0002.
|
||||
ELSE ${effectiveSapSql}
|
||||
END AS effective_sap
|
||||
FROM property p
|
||||
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM plan
|
||||
WHERE plan.property_id = p.id
|
||||
AND plan.portfolio_id = ${pid}
|
||||
AND plan.is_default = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) lp ON true
|
||||
WHERE p.portfolio_id = ${pid};
|
||||
`);
|
||||
|
||||
const scenario_epc_counts: Record<string, number> = {
|
||||
A: 0,
|
||||
B: 0,
|
||||
C: 0,
|
||||
D: 0,
|
||||
E: 0,
|
||||
F: 0,
|
||||
G: 0,
|
||||
Unknown: 0,
|
||||
A: portfolioAgg.band_a,
|
||||
B: portfolioAgg.band_b,
|
||||
C: portfolioAgg.band_c,
|
||||
D: portfolioAgg.band_d,
|
||||
E: portfolioAgg.band_e,
|
||||
F: portfolioAgg.band_f,
|
||||
G: portfolioAgg.band_g,
|
||||
Unknown: portfolioAgg.band_unknown,
|
||||
};
|
||||
|
||||
for (const row of epcRows.rows as EpcRow[]) {
|
||||
const band = sapToEpc(row.effective_sap);
|
||||
scenario_epc_counts[band] += 1;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
RESPONSE
|
||||
---------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -31,46 +31,107 @@ import {
|
|||
|
||||
export type { ReportingScenario };
|
||||
|
||||
export async function getPortfolioCounts(portfolioId: number): Promise<number> {
|
||||
const result = await db.execute<{ total: number }>(sql`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId};
|
||||
`);
|
||||
/** Bands the single-pass baseline query buckets into, in display order. */
|
||||
const BASELINE_EPC_BANDS = [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"Unknown",
|
||||
] as const;
|
||||
|
||||
return result.rows[0].total;
|
||||
}
|
||||
/**
|
||||
* One-pass baseline aggregate. Total, averages, totals, EPC-band distribution,
|
||||
* estimated/actual split and expired count all share the *identical*
|
||||
* `property ⋈ property_details_epc ⋈ newApproachJoins` scan, so they collapse
|
||||
* into a single conditional-aggregation query rather than six separate full
|
||||
* scans of the same portfolio — which was the bulk of the reporting page's
|
||||
* load time on large portfolios. Age bands (correlated subqueries) and likely
|
||||
* downgrades (a different, legacy-only join) stay separate and run in parallel.
|
||||
*/
|
||||
export async function getBaselineAggregates(portfolioId: number): Promise<{
|
||||
total: number;
|
||||
averages: AverageMetrics;
|
||||
totals: TotalMetrics;
|
||||
epcBands: EpcBandCount[];
|
||||
estimatedCounts: EstimatedCounts;
|
||||
expiredEpcs: number;
|
||||
}> {
|
||||
const est = estimatedSql(sql`e`);
|
||||
const bandExpr = sql`COALESCE((${effectiveEpcBandSql})::text, 'Unknown')`;
|
||||
|
||||
export async function getAverages(
|
||||
portfolioId: number,
|
||||
): Promise<AverageMetrics> {
|
||||
const result = await db.execute<AverageMetrics>(sql`
|
||||
// Per-band actual/estimated counts as FILTER columns — replaces the GROUP BY
|
||||
// in the old getCountByEpcBand. Aliases derive from the constant band list.
|
||||
const bandCols = sql.join(
|
||||
BASELINE_EPC_BANDS.flatMap((b) => {
|
||||
const k = b.toLowerCase();
|
||||
return [
|
||||
sql`COUNT(*) FILTER (WHERE ${bandExpr} = ${b} AND ${est} = false)::int AS ${sql.raw(`epc_${k}_actual`)}`,
|
||||
sql`COUNT(*) FILTER (WHERE ${bandExpr} = ${b} AND ${est} = true)::int AS ${sql.raw(`epc_${k}_estimated`)}`,
|
||||
];
|
||||
}),
|
||||
sql`, `,
|
||||
);
|
||||
|
||||
const result = await db.execute<Record<string, number | null>>(sql`
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
AVG(${effectiveSapSql})::float AS avg_sap,
|
||||
AVG(${carbonSql(sql`e`)})::float AS avg_carbon,
|
||||
AVG(${billsSql(sql`e`)})::float AS avg_bills,
|
||||
AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
|
||||
const result = await db.execute<TotalMetrics>(sql`
|
||||
SELECT
|
||||
AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption,
|
||||
SUM(${carbonSql(sql`e`)})::float AS total_carbon,
|
||||
SUM(${billsSql(sql`e`)})::float AS total_bills
|
||||
SUM(${billsSql(sql`e`)})::float AS total_bills,
|
||||
SUM(CASE WHEN ${est} = true THEN 1 ELSE 0 END)::int AS estimated,
|
||||
SUM(CASE WHEN ${est} = false THEN 1 ELSE 0 END)::int AS actual,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN ${isExpiredSql(sql`e`)} = true AND ${est} = false THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
)::int AS expired,
|
||||
${bandCols}
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
const row = result.rows[0];
|
||||
|
||||
// Omit zero-count bands to mirror the old GROUP BY (which only emitted bands
|
||||
// that were present); the ladder + toBandCounts both tolerate the absence.
|
||||
const epcBands: EpcBandCount[] = BASELINE_EPC_BANDS.map((b) => {
|
||||
const k = b.toLowerCase();
|
||||
return {
|
||||
epc: b,
|
||||
actual: Number(row[`epc_${k}_actual`] ?? 0),
|
||||
estimated: Number(row[`epc_${k}_estimated`] ?? 0),
|
||||
};
|
||||
}).filter((band) => band.actual + band.estimated > 0);
|
||||
|
||||
return {
|
||||
total: Number(row.total ?? 0),
|
||||
averages: {
|
||||
avg_sap: row.avg_sap as number | null,
|
||||
avg_carbon: row.avg_carbon as number | null,
|
||||
avg_bills: row.avg_bills as number | null,
|
||||
avg_energy_consumption: row.avg_energy_consumption as number | null,
|
||||
},
|
||||
totals: {
|
||||
total_carbon: row.total_carbon as number | null,
|
||||
total_bills: row.total_bills as number | null,
|
||||
},
|
||||
epcBands,
|
||||
estimatedCounts: {
|
||||
estimated: Number(row.estimated ?? 0),
|
||||
actual: Number(row.actual ?? 0),
|
||||
},
|
||||
expiredEpcs: Number(row.expired ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCountByAgeBand(
|
||||
|
|
@ -101,59 +162,6 @@ export async function getCountByAgeBand(
|
|||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getCountByEpcBand(
|
||||
portfolioId: number,
|
||||
): Promise<EpcBandCount[]> {
|
||||
const result = await db.execute<EpcBandCount>(sql`
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE((${effectiveEpcBandSql})::text, 'Unknown') AS epc,
|
||||
COUNT(*) FILTER (
|
||||
WHERE ${estimatedSql(sql`e`)} = false
|
||||
)::int AS actual,
|
||||
COUNT(*) FILTER (
|
||||
WHERE ${estimatedSql(sql`e`)} = true
|
||||
)::int AS estimated
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
GROUP BY epc
|
||||
) q
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN q.epc = 'A' THEN 1
|
||||
WHEN q.epc = 'B' THEN 2
|
||||
WHEN q.epc = 'C' THEN 3
|
||||
WHEN q.epc = 'D' THEN 4
|
||||
WHEN q.epc = 'E' THEN 5
|
||||
WHEN q.epc = 'F' THEN 6
|
||||
WHEN q.epc = 'G' THEN 7
|
||||
ELSE 8
|
||||
END;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getEstimatedCounts(
|
||||
portfolioId: number,
|
||||
): Promise<EstimatedCounts> {
|
||||
const result = await db.execute<EstimatedCounts>(sql`
|
||||
SELECT
|
||||
SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated,
|
||||
SUM(CASE WHEN ${estimatedSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS actual
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function getCountByPropertyType(
|
||||
portfolioId: number,
|
||||
): Promise<PropertyTypeCount[]> {
|
||||
|
|
@ -169,25 +177,6 @@ export async function getCountByPropertyType(
|
|||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
|
||||
const result = await db.execute<{ expired: number }>(sql`
|
||||
SELECT
|
||||
SUM(
|
||||
CASE
|
||||
WHEN ${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
)::int AS expired
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0].expired;
|
||||
}
|
||||
|
||||
export async function getLikelyDowngrades(
|
||||
portfolioId: number,
|
||||
): Promise<number> {
|
||||
|
|
@ -213,34 +202,23 @@ export async function getLikelyDowngrades(
|
|||
export async function loadBaselineMetrics(
|
||||
portfolioId: number,
|
||||
): Promise<BaselineMetrics> {
|
||||
const [
|
||||
total,
|
||||
averages,
|
||||
totals,
|
||||
ageBands,
|
||||
epcBands,
|
||||
estimatedCounts,
|
||||
expiredEpcs,
|
||||
likelyDowngrades,
|
||||
] = await Promise.all([
|
||||
getPortfolioCounts(portfolioId),
|
||||
getAverages(portfolioId),
|
||||
getTotals(portfolioId),
|
||||
// Three parallel scans, not eight: the single-pass aggregate carries total,
|
||||
// averages, totals, EPC bands, estimated split and expired count; age bands
|
||||
// and likely downgrades keep their own (differently-shaped) queries.
|
||||
const [aggregates, ageBands, likelyDowngrades] = await Promise.all([
|
||||
getBaselineAggregates(portfolioId),
|
||||
getCountByAgeBand(portfolioId),
|
||||
getCountByEpcBand(portfolioId),
|
||||
getEstimatedCounts(portfolioId),
|
||||
getExpiredEpcCount(portfolioId),
|
||||
getLikelyDowngrades(portfolioId),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
averages,
|
||||
totals,
|
||||
total: aggregates.total,
|
||||
averages: aggregates.averages,
|
||||
totals: aggregates.totals,
|
||||
ageBands,
|
||||
epcBands,
|
||||
estimatedCounts,
|
||||
expiredEpcs,
|
||||
epcBands: aggregates.epcBands,
|
||||
estimatedCounts: aggregates.estimatedCounts,
|
||||
expiredEpcs: aggregates.expiredEpcs,
|
||||
likelyDowngrades,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue