mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
feat(epc): provenance signals + effective-baseline portfolio table
Theme 1 — EPC card provenance. New resolveProvenanceSignal (pure, unit-
tested) + resolveProvenance expose provenanceSignal ("estimated" /
"remodelled" / "none") and the lodged headline on property-meta. The
building-passport card shows a "Lodged EPC" badge on re-modelled
properties (even within-band), never on predicted ones; the existing
"estimated from nearby homes" banner already covers predicted.
Theme 2 — portfolio table. "Current EPC" + reporting now read the
effective baseline (effectiveSapSql/effectiveEpcBandSql); a dedicated
"Lodged EPC" column reads the source-gated lodged value (blank for
predicted); a "Predicted" pill marks estimated rows; a provenance filter
is added and the broken currentEpc/lodgedEpc filters (on null row
columns) point at the effective/lodged baselines. Expected EPC now uses
the default plan's post rating (expectedEpcRating, unit-tested) instead
of current + Σ rec SAP, which double-counted across plans (732385: A→C);
the same rec aggregate is scoped to the default plan, fixing the Cost
column's 2x double-count.
See ADR-0002. Pure logic covered by provenance.test.ts + expectedEpc.test.ts;
SQL/query/UI verified against live properties 732385 and 729529.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ec88dadae2
commit
ea988cfe52
14 changed files with 353 additions and 39 deletions
|
|
@ -5,6 +5,9 @@ import { LodgedEpcTooltip } from "./LodgedEpcTooltip";
|
|||
interface CurrentEfficiencyCardProps {
|
||||
epcRating: string | null;
|
||||
sapPoints: number;
|
||||
provenanceSignal?: "estimated" | "remodelled" | "none";
|
||||
lodgedEpcRating?: string | null;
|
||||
lodgedSapPoints?: number | null;
|
||||
originalSapPoints?: number | null;
|
||||
}
|
||||
|
||||
|
|
@ -58,11 +61,24 @@ function getEpcDescription(letter: string | null | undefined): string {
|
|||
export function CurrentEfficiencyCard({
|
||||
epcRating,
|
||||
sapPoints,
|
||||
provenanceSignal = "none",
|
||||
lodgedEpcRating,
|
||||
lodgedSapPoints,
|
||||
originalSapPoints,
|
||||
}: CurrentEfficiencyCardProps) {
|
||||
const epcHex = getEpcHex(epcRating);
|
||||
const lodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null;
|
||||
const showLodgedBadge = lodgedLetter !== null && lodgedLetter !== epcRating;
|
||||
// New-approach: show the lodged certificate whenever the property was
|
||||
// re-modelled (effective diverged from lodged), even within the same band.
|
||||
// Legacy: fall back to the row's originalSapPoints, only when the band differs.
|
||||
const legacyLodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null;
|
||||
const remodelled = provenanceSignal === "remodelled";
|
||||
const lodgedLetter = remodelled
|
||||
? lodgedEpcRating ?? null
|
||||
: legacyLodgedLetter !== epcRating
|
||||
? legacyLodgedLetter
|
||||
: null;
|
||||
const lodgedBadgeSap = remodelled ? lodgedSapPoints : originalSapPoints;
|
||||
const showLodgedBadge = lodgedLetter !== null;
|
||||
const lodgedHex = getEpcHex(lodgedLetter);
|
||||
|
||||
return (
|
||||
|
|
@ -87,9 +103,9 @@ export function CurrentEfficiencyCard({
|
|||
>
|
||||
{lodgedLetter}
|
||||
</span>
|
||||
{originalSapPoints != null && (
|
||||
{lodgedBadgeSap != null && (
|
||||
<span className="text-xs font-bold text-gray-400">
|
||||
/ {Math.round(originalSapPoints)}
|
||||
/ {Math.round(lodgedBadgeSap)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ export interface PropertyMeta {
|
|||
currentEpcRating: string;
|
||||
currentSapPoints: number;
|
||||
originalSapPoints: number | null;
|
||||
// Provenance of the displayed "current" figures (see ADR-0002 / provenance.ts).
|
||||
// "estimated" = no certificate (predicted); "remodelled" = lodged but
|
||||
// rebaselined; "none" = lodged == effective, or legacy.
|
||||
provenanceSignal: "estimated" | "remodelled" | "none";
|
||||
// Lodged (gov-register) headline, only when a real certificate exists; feeds
|
||||
// the "Lodged EPC" badge alongside the effective "current".
|
||||
lodgedEpcRating: string | null;
|
||||
lodgedSapPoints: number | null;
|
||||
updatedAt: string;
|
||||
currentValuation: number | null;
|
||||
detailsEpc: {
|
||||
|
|
@ -378,6 +386,13 @@ export interface PropertyWithRelations extends Record<string, unknown> {
|
|||
// New fields
|
||||
landlordPropertyId: string | null;
|
||||
originalSapPoints: number | null;
|
||||
// Effective "current" (ADR-0002) is in currentEpcRating/currentSapPoints; these
|
||||
// carry the separate lodged certificate + the default plan's post-retrofit
|
||||
// rating + the provenance signal that drives the "Predicted" pill.
|
||||
lodgedEpcRating: string | null;
|
||||
postEpcRating: string | null;
|
||||
postSapPoints: number | null;
|
||||
provenanceSignal: "estimated" | "remodelled" | "none";
|
||||
epcLodgementDate: string | null;
|
||||
epcIsExpired: boolean | null;
|
||||
// Optional columns (hidden by default)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import {
|
|||
energyConsumptionSql,
|
||||
estimatedSql,
|
||||
isExpiredSql,
|
||||
sapSql,
|
||||
epcBandSql,
|
||||
effectiveSapSql,
|
||||
effectiveEpcBandSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
|
||||
import type {
|
||||
|
|
@ -38,7 +38,7 @@ export async function getAverages(
|
|||
): Promise<AverageMetrics> {
|
||||
const result = await db.execute<AverageMetrics>(sql`
|
||||
SELECT
|
||||
AVG(${sapSql})::float AS avg_sap,
|
||||
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
|
||||
|
|
@ -99,7 +99,7 @@ export async function getCountByEpcBand(
|
|||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE((${epcBandSql})::text, 'Unknown') AS epc,
|
||||
COALESCE((${effectiveEpcBandSql})::text, 'Unknown') AS epc,
|
||||
COUNT(*) FILTER (
|
||||
WHERE ${estimatedSql(sql`e`)} = false
|
||||
)::int AS actual,
|
||||
|
|
|
|||
|
|
@ -238,6 +238,9 @@ export default async function PreAssessmentReport(props: {
|
|||
<CurrentEfficiencyCard
|
||||
epcRating={propertyMeta.currentEpcRating ?? null}
|
||||
sapPoints={propertyMeta.currentSapPoints ?? 0}
|
||||
provenanceSignal={propertyMeta.provenanceSignal}
|
||||
lodgedEpcRating={propertyMeta.lodgedEpcRating}
|
||||
lodgedSapPoints={propertyMeta.lodgedSapPoints}
|
||||
originalSapPoints={propertyMeta.originalSapPoints}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ export default async function BuildingPassportHome(props: {
|
|||
<CurrentEfficiencyCard
|
||||
epcRating={propertyMeta.currentEpcRating ?? null}
|
||||
sapPoints={propertyMeta.currentSapPoints ?? 0}
|
||||
provenanceSignal={propertyMeta.provenanceSignal}
|
||||
lodgedEpcRating={propertyMeta.lodgedEpcRating}
|
||||
lodgedSapPoints={propertyMeta.lodgedSapPoints}
|
||||
originalSapPoints={propertyMeta.originalSapPoints}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
TENURE_OPTIONS,
|
||||
YEAR_BUILT_OPTIONS,
|
||||
MAINFUEL_OPTIONS,
|
||||
PROVENANCE_OPTIONS,
|
||||
} from "@/app/utils/propertyFilters";
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
|
|
@ -33,6 +34,7 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
|
|||
{ value: "builtForm", label: "Built Form" },
|
||||
{ value: "tenure", label: "Tenure" },
|
||||
{ value: "yearBuilt", label: "Year Built" },
|
||||
{ value: "provenance", label: "Provenance" },
|
||||
{ value: "floorArea", label: "Floor Area (m²)" },
|
||||
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
|
||||
{ value: "mainfuel", label: "Main Fuel" },
|
||||
|
|
@ -74,6 +76,7 @@ const ENUM_FIELD_OPTIONS: Record<string, EnumOption[]> = {
|
|||
builtForm: BUILT_FORM_OPTIONS,
|
||||
tenure: TENURE_OPTIONS,
|
||||
yearBuilt: YEAR_BUILT_OPTIONS,
|
||||
provenance: PROVENANCE_OPTIONS,
|
||||
mainfuel: MAINFUEL_OPTIONS,
|
||||
};
|
||||
|
||||
|
|
|
|||
20
src/app/portfolio/[slug]/components/expectedEpc.test.ts
Normal file
20
src/app/portfolio/[slug]/components/expectedEpc.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { expectedEpcRating } from "./expectedEpc";
|
||||
|
||||
describe("expectedEpcRating", () => {
|
||||
it("uses the default plan's post EPC rating when present", () => {
|
||||
expect(
|
||||
expectedEpcRating({ postEpcRating: "C", postSapPoints: 71.4 }),
|
||||
).toBe("C");
|
||||
});
|
||||
|
||||
it("derives the band from post SAP when the plan has no stored rating", () => {
|
||||
expect(
|
||||
expectedEpcRating({ postEpcRating: null, postSapPoints: 64 }),
|
||||
).toBe("D");
|
||||
});
|
||||
|
||||
it("is blank when the property has no default plan", () => {
|
||||
expect(expectedEpcRating(null)).toBe("");
|
||||
});
|
||||
});
|
||||
19
src/app/portfolio/[slug]/components/expectedEpc.ts
Normal file
19
src/app/portfolio/[slug]/components/expectedEpc.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { sapToEpc } from "@/app/utils";
|
||||
|
||||
/**
|
||||
* The portfolio "Expected EPC" — the post-retrofit band of a property's default
|
||||
* plan. The plan engine already models interactions, SAP 10 and the effective
|
||||
* baseline, so we trust `plan.post_epc_rating` (falling back to deriving it from
|
||||
* `post_sap_points`) rather than adding raw recommendation SAP onto the current
|
||||
* score, which double-counts across plans and overshoots. See ADR-0002 and the
|
||||
* grilling notes for property 732385 (read A, should read C).
|
||||
*
|
||||
* Returns "" when there is no default plan — the column renders blank, not the
|
||||
* current rating.
|
||||
*/
|
||||
export function expectedEpcRating(
|
||||
plan: { postEpcRating: string | null; postSapPoints: number | null } | null,
|
||||
): string {
|
||||
if (!plan) return "";
|
||||
return plan.postEpcRating ?? sapToEpc(plan.postSapPoints);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { FunnelIcon } from "@heroicons/react/24/outline";
|
|||
import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PropertyWithRelations } from "@/app/db/schema/property";
|
||||
import { expectedEpcRating } from "./expectedEpc";
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
EnumOption,
|
||||
|
|
@ -72,6 +73,20 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks a property whose EPC was estimated from nearby homes (no certificate) —
|
||||
* the "trust this less" signal. Re-modelled properties aren't marked (the
|
||||
* Lodged-EPC column differing from Current already conveys that). See ADR-0002.
|
||||
*/
|
||||
const PredictedPill = () => (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-amber-700 border border-amber-200"
|
||||
title="Estimated from nearby homes — no EPC certificate on record"
|
||||
>
|
||||
Predicted
|
||||
</span>
|
||||
);
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Column header with dropdown filter
|
||||
------------------------------------------------------------------------ */
|
||||
|
|
@ -229,19 +244,27 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
<div className="text-gray-700 font-medium flex items-center justify-center gap-1.5">
|
||||
<EpcLetterBubble letter={row.original.currentEpcRating || ""} />
|
||||
{row.original.provenanceSignal === "estimated" && <PredictedPill />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "originalSapPoints",
|
||||
accessorKey: "lodgedEpcRating",
|
||||
header: () => (
|
||||
<div className="flex justify-center text-xs">Lodged EPC</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const originalSap = row.original.originalSapPoints;
|
||||
const letter = originalSap ? sapToEpc(originalSap) : null;
|
||||
// Effective is the "current"; this column shows the real lodged
|
||||
// certificate, blank when there is none (predicted properties). For legacy
|
||||
// rows the query returns the row's rating; new-approach returns lodged or
|
||||
// NULL. originalSapPoints remains a fallback for legacy rows lacking a band.
|
||||
const letter =
|
||||
row.original.lodgedEpcRating ??
|
||||
(row.original.originalSapPoints
|
||||
? sapToEpc(row.original.originalSapPoints)
|
||||
: null);
|
||||
return (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
<EpcLetterBubble letter={letter || ""} />
|
||||
|
|
@ -255,21 +278,17 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
|
|||
<div className="flex justify-center text-xs">Expected EPC</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const currentSapPoints = row.original.currentSapPoints || 0;
|
||||
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
|
||||
|
||||
if (currentSapPoints + expectedSapPoints === 0) {
|
||||
return (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
<EpcLetterBubble letter="" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints);
|
||||
|
||||
const expectedEpc = expectedEpcRating(
|
||||
row.original.postEpcRating != null || row.original.postSapPoints != null
|
||||
? {
|
||||
postEpcRating: row.original.postEpcRating,
|
||||
postSapPoints: row.original.postSapPoints,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return (
|
||||
<div className="text-gray-700 font-medium flex justify-center">
|
||||
<EpcLetterBubble letter={expectedEpc || ""} />
|
||||
<EpcLetterBubble letter={expectedEpc} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,8 +26,11 @@ import {
|
|||
lodgementDateSql,
|
||||
isExpiredSql,
|
||||
mainfuelSql,
|
||||
sapSql,
|
||||
epcBandSql,
|
||||
effectiveSapSql,
|
||||
effectiveEpcBandSql,
|
||||
lodgedEpcBandSql,
|
||||
lodgedSapSql,
|
||||
provenanceSignalSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
import {
|
||||
FilterGroups,
|
||||
|
|
@ -37,6 +40,7 @@ import {
|
|||
TENURE_OPTIONS,
|
||||
YEAR_BUILT_OPTIONS,
|
||||
MAINFUEL_OPTIONS,
|
||||
PROVENANCE_OPTIONS,
|
||||
EnumOption,
|
||||
} from "@/app/utils/propertyFilters";
|
||||
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
|
||||
|
|
@ -46,6 +50,7 @@ const ENUM_FIELD_DB_OPTIONS: Record<string, EnumOption[]> = {
|
|||
builtForm: BUILT_FORM_OPTIONS,
|
||||
tenure: TENURE_OPTIONS,
|
||||
yearBuilt: YEAR_BUILT_OPTIONS,
|
||||
provenance: PROVENANCE_OPTIONS,
|
||||
mainfuel: MAINFUEL_OPTIONS,
|
||||
};
|
||||
|
||||
|
|
@ -523,10 +528,11 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
return null;
|
||||
|
||||
case "currentEpc":
|
||||
return buildEpcSapCondition(sql`p.current_sap_points`, filter.operator, filter.value);
|
||||
// "Current" is the effective baseline (ADR-0002), not the null row column.
|
||||
return buildEpcSapCondition(effectiveSapSql, filter.operator, filter.value);
|
||||
|
||||
case "lodgedEpc":
|
||||
return buildEpcSapCondition(sql`p.original_sap_points`, filter.operator, filter.value);
|
||||
return buildEpcSapCondition(lodgedSapSql, filter.operator, filter.value);
|
||||
|
||||
case "expectedEpc": {
|
||||
if (filter.operator === "epc_at_least") {
|
||||
|
|
@ -574,6 +580,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
case "builtForm":
|
||||
case "tenure":
|
||||
case "yearBuilt":
|
||||
case "provenance":
|
||||
case "mainfuel": {
|
||||
if (filter.operator !== "enum_one_of") return null;
|
||||
|
||||
|
|
@ -591,6 +598,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
builtForm: sql`p.built_form`,
|
||||
tenure: sql`p.tenure`,
|
||||
yearBuilt: sql`p.year_built`,
|
||||
provenance: provenanceSignalSql,
|
||||
// GAP: new-approach properties have no main-fuel string → NULL, so they
|
||||
// only match the "no value" filter branch. See epcSources.ts.
|
||||
mainfuel: mainfuelSql(sql`epc`),
|
||||
|
|
@ -718,15 +726,20 @@ export async function getProperties(
|
|||
p.postcode AS postcode,
|
||||
p.status AS status,
|
||||
p.creation_status AS "creationStatus",
|
||||
-- Current EPC/SAP: new-approach properties have null headline columns on
|
||||
-- the property row, so source from the lodged baseline. The "Expected EPC"
|
||||
-- column is currentSapPoints + recommendation SAP, so this also fixes it.
|
||||
${epcBandSql} AS "currentEpcRating",
|
||||
${sapSql} AS "currentSapPoints",
|
||||
-- property_targets is no longer read (the "Expected EPC" column derives
|
||||
-- from currentSapPoints + recommendation SAP). Kept as NULL to preserve
|
||||
-- the PropertyWithRelations shape.
|
||||
-- "Current" is the effective (re-baselined) figure — the canonical current
|
||||
-- per ADR-0002. The lodged certificate is a separate column. New-approach
|
||||
-- properties have null headline columns on the property row, so both source
|
||||
-- from property_baseline_performance.
|
||||
${effectiveEpcBandSql} AS "currentEpcRating",
|
||||
${effectiveSapSql} AS "currentSapPoints",
|
||||
${lodgedEpcBandSql} AS "lodgedEpcRating",
|
||||
${provenanceSignalSql} AS "provenanceSignal",
|
||||
-- "Expected EPC" is the default plan's modelled post-retrofit rating, not
|
||||
-- current + Σ recommendation SAP (which double-counts across plans and
|
||||
-- overshoots). See expectedEpc.ts / ADR-0002.
|
||||
NULL AS "targetEpc",
|
||||
pl.post_epc_rating AS "postEpcRating",
|
||||
pl.post_sap_points AS "postSapPoints",
|
||||
pl.id AS "planId",
|
||||
-- funding_package is no longer read here (the proposals table fetches it
|
||||
-- separately). Kept as NULL to preserve the PropertyWithRelations shape.
|
||||
|
|
@ -749,7 +762,7 @@ export async function getProperties(
|
|||
-- LATERAL one-row lookups keep this query at "N properties × index probe"
|
||||
-- instead of hash-joining the multi-million-row plan/recommendation tables.
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, post_sap_points FROM plan
|
||||
SELECT id, post_sap_points, post_epc_rating FROM plan
|
||||
WHERE property_id = p.id
|
||||
AND portfolio_id = p.portfolio_id
|
||||
AND is_default = true
|
||||
|
|
@ -768,6 +781,11 @@ export async function getProperties(
|
|||
COALESCE(SUM(estimated_cost), 0) AS cost
|
||||
FROM recommendation
|
||||
WHERE property_id = p.id
|
||||
-- Scope to the default plan. property_id is the indexed access path, so
|
||||
-- this only filters the property's own (tiny) rec set in memory — no
|
||||
-- unindexed plan_id scan — while stopping the SUM double-counting a
|
||||
-- measure once per plan when a property has several plans.
|
||||
AND plan_id = pl.id
|
||||
AND "default" = true
|
||||
AND already_installed = false
|
||||
) rec ON true
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type FilterField =
|
|||
| "builtForm"
|
||||
| "tenure"
|
||||
| "yearBuilt"
|
||||
| "provenance"
|
||||
| "floorArea"
|
||||
| "co2Emissions"
|
||||
| "mainfuel";
|
||||
|
|
@ -89,6 +90,14 @@ export const TENURE_OPTIONS: EnumOption[] = [
|
|||
{ label: "Not Recorded", dbValues: ["__null__"] },
|
||||
];
|
||||
|
||||
// Provenance signal (ADR-0002). dbValues are the raw signal strings emitted by
|
||||
// provenanceSignalSql / resolveProvenanceSignal.
|
||||
export const PROVENANCE_OPTIONS: EnumOption[] = [
|
||||
{ label: "Predicted", dbValues: ["estimated"] },
|
||||
{ label: "Re-modelled", dbValues: ["remodelled"] },
|
||||
{ label: "Standard", dbValues: ["none"] },
|
||||
];
|
||||
|
||||
export const YEAR_BUILT_OPTIONS: EnumOption[] = [
|
||||
"1900","1930","1950","1967","1976","1983","1991","1996",
|
||||
"2003","2007","2008","2009","2010","2011","2012","2013",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
*/
|
||||
import { db } from "@/app/db/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { resolveProvenanceSignal, type ProvenanceSignal } from "./provenance";
|
||||
import {
|
||||
epcProperty,
|
||||
epcEnergyElement,
|
||||
|
|
@ -98,6 +99,44 @@ export const sapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_sap_score
|
|||
/** Baseline EPC band. New: lodged band from property_baseline_performance. Legacy: property.current_epc_rating. */
|
||||
export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_band ELSE p.current_epc_rating END`;
|
||||
|
||||
/**
|
||||
* Effective (re-baselined) SAP score / EPC band — the canonical "current" for
|
||||
* display and reporting (ADR-0002). New: bp.effective_*; legacy: the property
|
||||
* row. These replaced the lodged `sapSql`/`epcBandSql` above as the table +
|
||||
* reporting source; those lodged fragments are intentionally retained (unused
|
||||
* for now) for a possible future register-fidelity reporting view (ADR-0002).
|
||||
* The "Lodged EPC" column itself uses the source-gated `lodgedEpcBandSql` below.
|
||||
*/
|
||||
export const effectiveSapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_sap_score ELSE p.current_sap_points END`;
|
||||
export const effectiveEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_epc_band ELSE p.current_epc_rating END`;
|
||||
|
||||
/**
|
||||
* Lodged EPC band for the "Lodged EPC" column — only when a real certificate
|
||||
* exists (`source = lodged`, i.e. the lodged epc_property row `epl`). A
|
||||
* predicted property carries lodged_* (mirrored estimates) that must not
|
||||
* surface, so it renders NULL. Legacy: derive from the property row's
|
||||
* original_sap_points (handled in the column, kept as the row value here).
|
||||
*/
|
||||
export const lodgedEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN bp.lodged_epc_band ELSE NULL END) ELSE p.current_epc_rating END`;
|
||||
|
||||
/** Lodged SAP score for the "Lodged EPC" filter — same source/gate as lodgedEpcBandSql. Legacy: the row's original_sap_points. */
|
||||
export const lodgedSapSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN bp.lodged_sap_score ELSE NULL END) ELSE p.original_sap_points END`;
|
||||
|
||||
/**
|
||||
* Provenance signal for the portfolio table (mirrors resolveProvenanceSignal /
|
||||
* provenance.ts). New-approach only; legacy → 'none'.
|
||||
*/
|
||||
export const provenanceSignalSql = sql`CASE
|
||||
WHEN ${isNewApproachSql} THEN (
|
||||
CASE
|
||||
WHEN epp.id IS NOT NULL AND epl.id IS NULL THEN 'estimated'
|
||||
WHEN epl.id IS NOT NULL AND bp.rebaseline_reason IS NOT NULL AND bp.rebaseline_reason <> 'none' THEN 'remodelled'
|
||||
ELSE 'none'
|
||||
END
|
||||
)
|
||||
ELSE 'none'
|
||||
END`;
|
||||
|
||||
/**
|
||||
* Annual energy bill (£). New: sum of the individual cost columns on
|
||||
* property_baseline_performance (NULL when no baseline row exists, so it's
|
||||
|
|
@ -438,6 +477,57 @@ export async function resolvePropertyHeadline(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance signal + the lodged (gov-register) headline for the EPC card.
|
||||
* `provenanceSignal` drives the "estimated"/"re-modelled" UI signals; the lodged
|
||||
* band/SAP feed the "Lodged EPC" badge and are only meaningful when a real
|
||||
* certificate exists (`source = lodged`). Legacy properties get `none` and null
|
||||
* lodged figures — the card falls back to the row's `originalSapPoints`.
|
||||
* See provenance.ts (pure precedence), CONTEXT.md and ADR-0002.
|
||||
*/
|
||||
export async function resolveProvenance(
|
||||
propertyId: bigint,
|
||||
updatedAt: Date | string | null | undefined,
|
||||
): Promise<{
|
||||
provenanceSignal: ProvenanceSignal;
|
||||
lodgedEpcRating: string | null;
|
||||
lodgedSapPoints: number | null;
|
||||
}> {
|
||||
if (!isNewApproach(updatedAt)) {
|
||||
return { provenanceSignal: "none", lodgedEpcRating: null, lodgedSapPoints: null };
|
||||
}
|
||||
const [lodged, predicted, baseline] = await Promise.all([
|
||||
db.query.epcProperty.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(eq(epcProperty.propertyId, propertyId), eq(epcProperty.source, "lodged")),
|
||||
}),
|
||||
db.query.epcProperty.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(eq(epcProperty.propertyId, propertyId), eq(epcProperty.source, "predicted")),
|
||||
}),
|
||||
db.query.propertyBaselinePerformance.findFirst({
|
||||
columns: { rebaselineReason: true, lodgedSapScore: true, lodgedEpcBand: true },
|
||||
where: eq(propertyBaselinePerformance.propertyId, propertyId),
|
||||
}),
|
||||
]);
|
||||
|
||||
const hasLodgedEpc = !!lodged;
|
||||
const provenanceSignal = resolveProvenanceSignal({
|
||||
isNewApproach: true,
|
||||
hasLodgedEpc,
|
||||
hasPredictedEpc: !!predicted,
|
||||
rebaselineReason: baseline?.rebaselineReason,
|
||||
});
|
||||
|
||||
// Lodged figures only when a real certificate exists — a predicted property
|
||||
// still carries lodged_* (mirrored estimates) which must not surface.
|
||||
return {
|
||||
provenanceSignal,
|
||||
lodgedEpcRating: hasLodgedEpc ? baseline?.lodgedEpcBand ?? null : null,
|
||||
lodgedSapPoints: hasLodgedEpc ? baseline?.lodgedSapScore ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */
|
||||
export interface DetailsEpcMeta {
|
||||
currentEnergyDemand: number | null;
|
||||
|
|
@ -654,16 +744,20 @@ export async function resolvePropertyMeta(propertyId: string) {
|
|||
if (!propertyMeta) return null;
|
||||
|
||||
const newApproach = isNewApproach(propertyMeta.updatedAt);
|
||||
const [detailsEpc, headline, descriptors] = await Promise.all([
|
||||
const [detailsEpc, headline, descriptors, provenance] = await Promise.all([
|
||||
resolveDetailsEpcMeta(propertyMeta.id, propertyMeta.updatedAt),
|
||||
resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt),
|
||||
newApproach
|
||||
? resolvePropertyDescriptors(propertyMeta.id)
|
||||
: Promise.resolve(null),
|
||||
resolveProvenance(propertyMeta.id, propertyMeta.updatedAt),
|
||||
]);
|
||||
|
||||
return {
|
||||
...propertyMeta,
|
||||
provenanceSignal: provenance.provenanceSignal,
|
||||
lodgedEpcRating: provenance.lodgedEpcRating,
|
||||
lodgedSapPoints: provenance.lodgedSapPoints,
|
||||
propertyType: descriptors?.propertyType ?? propertyMeta.propertyType,
|
||||
builtForm: descriptors?.builtForm ?? propertyMeta.builtForm,
|
||||
yearBuilt: descriptors?.yearBuilt ?? propertyMeta.yearBuilt,
|
||||
|
|
|
|||
59
src/lib/services/provenance.test.ts
Normal file
59
src/lib/services/provenance.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProvenanceSignal } from "./provenance";
|
||||
|
||||
describe("resolveProvenanceSignal", () => {
|
||||
it("flags a predicted-only property as estimated, even when rebaselined", () => {
|
||||
expect(
|
||||
resolveProvenanceSignal({
|
||||
isNewApproach: true,
|
||||
hasLodgedEpc: false,
|
||||
hasPredictedEpc: true,
|
||||
rebaselineReason: "pre_sap10",
|
||||
}),
|
||||
).toBe("estimated");
|
||||
});
|
||||
|
||||
it("flags a lodged, rebaselined property as remodelled", () => {
|
||||
expect(
|
||||
resolveProvenanceSignal({
|
||||
isNewApproach: true,
|
||||
hasLodgedEpc: true,
|
||||
hasPredictedEpc: false,
|
||||
rebaselineReason: "pre_sap10",
|
||||
}),
|
||||
).toBe("remodelled");
|
||||
});
|
||||
|
||||
it("returns none for a lodged property that was not rebaselined", () => {
|
||||
expect(
|
||||
resolveProvenanceSignal({
|
||||
isNewApproach: true,
|
||||
hasLodgedEpc: true,
|
||||
hasPredictedEpc: false,
|
||||
rebaselineReason: "none",
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("prefers the real certificate when both lodged and predicted EPCs exist", () => {
|
||||
expect(
|
||||
resolveProvenanceSignal({
|
||||
isNewApproach: true,
|
||||
hasLodgedEpc: true,
|
||||
hasPredictedEpc: true,
|
||||
rebaselineReason: "pre_sap10",
|
||||
}),
|
||||
).toBe("remodelled");
|
||||
});
|
||||
|
||||
it("returns none for legacy properties regardless of EPC inputs", () => {
|
||||
expect(
|
||||
resolveProvenanceSignal({
|
||||
isNewApproach: false,
|
||||
hasLodgedEpc: false,
|
||||
hasPredictedEpc: true,
|
||||
rebaselineReason: "pre_sap10",
|
||||
}),
|
||||
).toBe("none");
|
||||
});
|
||||
});
|
||||
36
src/lib/services/provenance.ts
Normal file
36
src/lib/services/provenance.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Provenance signal — what the user is told about an EPC's trustworthiness.
|
||||
* See CONTEXT.md ("EPC provenance", "Provenance signal") and ADR-0002.
|
||||
*
|
||||
* Pure decision over a property's EPC provenance (`epc_property.source`) and its
|
||||
* Rebaseline (`property_baseline_performance.rebaseline_reason`). The DB-reading
|
||||
* resolver gathers the inputs; this function owns the precedence.
|
||||
*/
|
||||
export type ProvenanceSignal = "estimated" | "remodelled" | "none";
|
||||
|
||||
export function resolveProvenanceSignal(input: {
|
||||
isNewApproach: boolean;
|
||||
hasLodgedEpc: boolean;
|
||||
hasPredictedEpc: boolean;
|
||||
rebaselineReason: string | null | undefined;
|
||||
}): ProvenanceSignal {
|
||||
// Legacy (pre-cutoff) properties have no effective/lodged split and no
|
||||
// provenance concept — they read the property row directly.
|
||||
if (!input.isNewApproach) {
|
||||
return "none";
|
||||
}
|
||||
// No EPC certificate exists — the picture was estimated from nearby homes.
|
||||
// Dominant: wins even when the baseline was also rebaselined.
|
||||
if (input.hasPredictedEpc && !input.hasLodgedEpc) {
|
||||
return "estimated";
|
||||
}
|
||||
// A real certificate exists but the modelling baseline diverged from it.
|
||||
if (
|
||||
input.hasLodgedEpc &&
|
||||
input.rebaselineReason != null &&
|
||||
input.rebaselineReason !== "none"
|
||||
) {
|
||||
return "remodelled";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue