mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(reporting): apply the tag filter across the data layer
Threads the tag filter through every reporting query so the whole view reflects the tagged subset: baseline aggregate, age-band + property-type counts, likely downgrades, both scenario-metrics routes (all three property scans each), the drill-down list, and the measures allocation. Each query ANDs tagFilterCondition() into its WHERE; routes parse the filter from the URL via the shared parseReportingViewState so they read exactly what the client wrote. Defaults to the empty (no-op) filter, so existing callers/tests are unchanged. Verified on #796/scenario 1292: include tag → n_units = the tag's membership (607), exclude → the complement (30705), and the two EPC distributions reconcile to the full portfolio total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
77e5fcde20
commit
e80332fa64
6 changed files with 53 additions and 16 deletions
|
|
@ -13,6 +13,8 @@ import {
|
|||
provenanceSignalSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
import { EPC_BANDS } from "@/lib/scenarios/model";
|
||||
import { tagFilterCondition } from "@/lib/reporting/tagFilterSql";
|
||||
import { parseReportingViewState } from "@/lib/reporting/viewState";
|
||||
|
||||
/**
|
||||
* Drill-down: the homes behind a reporting figure (PRD #370, Screen C).
|
||||
|
|
@ -117,6 +119,7 @@ export async function GET(
|
|||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
AND ${tagFilterCondition(parseReportingViewState(params).tags)}
|
||||
AND ${filterSql}
|
||||
ORDER BY address NULLS LAST, p.id
|
||||
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
} from "@/lib/services/epcSources";
|
||||
import { computeLedger, DEFAULT_GOAL_BAND } from "@/lib/reporting/model";
|
||||
import { EPC_BANDS } from "@/lib/scenarios/model";
|
||||
import { tagFilterCondition } from "@/lib/reporting/tagFilterSql";
|
||||
import { parseReportingViewState } from "@/lib/reporting/viewState";
|
||||
|
||||
/* =======================
|
||||
Types
|
||||
|
|
@ -109,6 +111,12 @@ export async function GET(
|
|||
const useLodgedBaseline =
|
||||
request.nextUrl.searchParams.get("useLodgedBaseline") === "true";
|
||||
|
||||
// Tag filter (view-wide): scope every property scan below to the tagged
|
||||
// subset. Parsed via the shared view-state parser so the route reads the URL
|
||||
// exactly as the client serialised it.
|
||||
const tags = parseReportingViewState(request.nextUrl.searchParams).tags;
|
||||
const tagScope = tagFilterCondition(tags);
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Compliance window — ADR-0010. complianceDate activates the
|
||||
filter; complianceBand defaults to C.
|
||||
|
|
@ -250,7 +258,8 @@ export async function GET(
|
|||
-- string in application code (which would require string concatenation and risks
|
||||
-- SQL injection). The OR short-circuits left-to-right: if the first or second
|
||||
-- condition is true, the third is never evaluated, so all rows pass through.
|
||||
WHERE (
|
||||
WHERE ${tagScope}
|
||||
AND (
|
||||
${useLodgedBaseline} = false -- toggle off → include everything
|
||||
OR ${minSap}::float IS NULL -- no EPC target → nothing to filter on
|
||||
OR COALESCE((${lodgedSapSql}) < ${minSap}::float, true) -- actual filter; unknown lodged → keep
|
||||
|
|
@ -292,7 +301,8 @@ export async function GET(
|
|||
-- Portfolio-scoped legacy join (see Query 1) — index lookup, not seq scan.
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${pid}
|
||||
LEFT JOIN funding_package fp ON fp.plan_id = lp.id
|
||||
WHERE lp.cost_of_works > 0
|
||||
WHERE ${tagScope}
|
||||
AND lp.cost_of_works > 0
|
||||
-- TEMP (demo): exclude plans whose post SAP is below the effective baseline
|
||||
-- (target-level post_sap on already-compliant homes) — not real upgrades.
|
||||
-- COALESCE keeps rows we can't compare. See ADR-0002.
|
||||
|
|
@ -382,7 +392,7 @@ export async function GET(
|
|||
LIMIT 1
|
||||
) lp ON true
|
||||
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
WHERE p.portfolio_id = ${pid} AND ${tagScope}
|
||||
) q;
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
billsSql,
|
||||
effectiveSapSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
import { tagFilterCondition } from "@/lib/reporting/tagFilterSql";
|
||||
import { parseReportingViewState } from "@/lib/reporting/viewState";
|
||||
|
||||
/* =======================
|
||||
Types
|
||||
|
|
@ -96,6 +98,10 @@ export async function GET(
|
|||
const hideNonCompliant =
|
||||
request.nextUrl.searchParams.get("hideNonCompliant") === "true";
|
||||
|
||||
// Tag filter (view-wide): scope every property scan to the tagged subset.
|
||||
const tags = parseReportingViewState(request.nextUrl.searchParams).tags;
|
||||
const tagScope = tagFilterCondition(tags);
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
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
|
||||
|
|
@ -135,7 +141,8 @@ export async function GET(
|
|||
)::float AS total_sap_uplift
|
||||
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;
|
||||
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id
|
||||
WHERE ${tagScope};
|
||||
`;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
|
|
@ -162,7 +169,8 @@ export async function GET(
|
|||
JOIN property p ON p.id = lp.property_id
|
||||
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id
|
||||
LEFT JOIN funding_package fp ON fp.plan_id = lp.id
|
||||
WHERE lp.cost_of_works > 0
|
||||
WHERE ${tagScope}
|
||||
AND lp.cost_of_works > 0
|
||||
-- TEMP (demo): a plan whose post SAP is below the effective baseline isn't
|
||||
-- a real upgrade (target-level post_sap on already-compliant homes), so
|
||||
-- exclude it from the count + cost. COALESCE keeps rows we can't compare
|
||||
|
|
@ -235,7 +243,7 @@ export async function GET(
|
|||
LIMIT 1
|
||||
) lp ON true
|
||||
|
||||
WHERE p.portfolio_id = ${pid}
|
||||
WHERE p.portfolio_id = ${pid} AND ${tagScope}
|
||||
) q;
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@
|
|||
|
||||
import { queryScenarioMeasures } from "./measuresQuery";
|
||||
import type { Measure } from "@/lib/reporting/measures";
|
||||
import { emptyTagFilter, type TagFilter } from "@/lib/reporting/viewState";
|
||||
|
||||
/**
|
||||
* Server action behind the "where the money goes" panel. Runs the grouped
|
||||
* measures query directly on the server — no API round-trip — so the panel
|
||||
* background-loads while the rest of the page is interactive.
|
||||
* background-loads while the rest of the page is interactive. Honours the
|
||||
* view-wide tag filter so the spend breakdown matches the filtered figures.
|
||||
*/
|
||||
export async function loadScenarioMeasures(
|
||||
portfolioId: number,
|
||||
scenarioId: number | "default",
|
||||
tags: TagFilter = emptyTagFilter(),
|
||||
): Promise<Measure[]> {
|
||||
return queryScenarioMeasures(portfolioId, scenarioId);
|
||||
return queryScenarioMeasures(portfolioId, scenarioId, tags);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import {
|
|||
toReportingScenario,
|
||||
type ReportingScenario,
|
||||
} from "./reportingScenarios";
|
||||
import { tagFilterCondition } from "@/lib/reporting/tagFilterSql";
|
||||
import { emptyTagFilter, type TagFilter } from "@/lib/reporting/viewState";
|
||||
|
||||
export type { ReportingScenario };
|
||||
|
||||
|
|
@ -52,7 +54,10 @@ const BASELINE_EPC_BANDS = [
|
|||
* 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<{
|
||||
export async function getBaselineAggregates(
|
||||
portfolioId: number,
|
||||
tags: TagFilter = emptyTagFilter(),
|
||||
): Promise<{
|
||||
total: number;
|
||||
averages: AverageMetrics;
|
||||
totals: TotalMetrics;
|
||||
|
|
@ -100,7 +105,7 @@ export async function getBaselineAggregates(portfolioId: number): Promise<{
|
|||
-- the whole 600k-row table; migrated portfolios have ~0 rows here.
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id AND e.portfolio_id = ${portfolioId}
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)};
|
||||
`);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
|
@ -139,6 +144,7 @@ export async function getBaselineAggregates(portfolioId: number): Promise<{
|
|||
|
||||
export async function getCountByAgeBand(
|
||||
portfolioId: number,
|
||||
tags: TagFilter = emptyTagFilter(),
|
||||
): Promise<AgeBandCount[]> {
|
||||
const result = await db.execute<AgeBandCount>(sql`
|
||||
SELECT
|
||||
|
|
@ -156,7 +162,7 @@ export async function getCountByAgeBand(
|
|||
SELECT ${constructionYearSql} AS built_year
|
||||
FROM property p
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)}
|
||||
) q
|
||||
GROUP BY age_band
|
||||
ORDER BY age_band;
|
||||
|
|
@ -167,12 +173,13 @@ export async function getCountByAgeBand(
|
|||
|
||||
export async function getCountByPropertyType(
|
||||
portfolioId: number,
|
||||
tags: TagFilter = emptyTagFilter(),
|
||||
): Promise<PropertyTypeCount[]> {
|
||||
const result = await db.execute<PropertyTypeCount>(sql`
|
||||
SELECT ${propertyTypeSql} AS type, COUNT(*)::int AS count
|
||||
FROM property p
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)}
|
||||
GROUP BY type
|
||||
ORDER BY count DESC;
|
||||
`);
|
||||
|
|
@ -182,6 +189,7 @@ export async function getCountByPropertyType(
|
|||
|
||||
export async function getLikelyDowngrades(
|
||||
portfolioId: number,
|
||||
tags: TagFilter = emptyTagFilter(),
|
||||
): Promise<number> {
|
||||
const result = await db.execute<{ downgrades: number }>(sql`
|
||||
SELECT
|
||||
|
|
@ -189,7 +197,7 @@ export async function getLikelyDowngrades(
|
|||
FROM property p
|
||||
JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
WHERE p.portfolio_id = ${portfolioId} AND ${tagFilterCondition(tags)}
|
||||
-- GAP: the SAP-05 "likely downgrade" signal has no equivalent in the new
|
||||
-- pipeline (handled there via Rebaseline). Restrict to legacy properties.
|
||||
AND p.updated_at < ${NEW_APPROACH_CUTOFF}::date
|
||||
|
|
@ -204,14 +212,15 @@ export async function getLikelyDowngrades(
|
|||
|
||||
export async function loadBaselineMetrics(
|
||||
portfolioId: number,
|
||||
tags: TagFilter = emptyTagFilter(),
|
||||
): Promise<BaselineMetrics> {
|
||||
// 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),
|
||||
getLikelyDowngrades(portfolioId),
|
||||
getBaselineAggregates(portfolioId, tags),
|
||||
getCountByAgeBand(portfolioId, tags),
|
||||
getLikelyDowngrades(portfolioId, tags),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import "server-only";
|
|||
import { db } from "@/app/db/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { Measure } from "@/lib/reporting/measures";
|
||||
import { tagFilterCondition } from "@/lib/reporting/tagFilterSql";
|
||||
import { emptyTagFilter, type TagFilter } from "@/lib/reporting/viewState";
|
||||
|
||||
/**
|
||||
* Grouped measure spend for a scenario's latest plans — the data behind the
|
||||
|
|
@ -17,6 +19,7 @@ import type { Measure } from "@/lib/reporting/measures";
|
|||
export async function queryScenarioMeasures(
|
||||
portfolioId: number,
|
||||
scenarioId: number | "default",
|
||||
tags: TagFilter = emptyTagFilter(),
|
||||
): Promise<Measure[]> {
|
||||
const pid = BigInt(portfolioId);
|
||||
|
||||
|
|
@ -44,6 +47,7 @@ export async function queryScenarioMeasures(
|
|||
FROM plan
|
||||
WHERE portfolio_id = ${pid}
|
||||
AND ${planScope}
|
||||
AND ${tagFilterCondition(tags, sql`plan.property_id`)}
|
||||
ORDER BY property_id, created_at DESC
|
||||
) lp
|
||||
JOIN recommendation r
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue