Overlay PasHub as-surveyed performance onto saved Site-Notes property 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-14 13:47:49 +00:00
parent 43fac40595
commit 377c030c77
2 changed files with 68 additions and 11 deletions

View file

@ -1,6 +1,7 @@
import os
from dataclasses import replace
from datetime import datetime, timezone
from typing import Callable, List, NamedTuple, Optional, cast
from typing import Any, Callable, Dict, List, NamedTuple, Optional, cast
from backend.app.db.connection import db_session
from infrastructure.postgres.uploaded_file_table import (
@ -15,11 +16,13 @@ from backend.pashub_fetcher.pashub_client import (
DownloadedFile,
DownloadedFiles,
PashubClient,
RdSapSummary,
UnauthorizedError,
)
from backend.pashub_fetcher.pashub_to_ara_trigger_request import (
PashubToAraTriggerRequest,
)
from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from utils.logger import setup_logger
from utils.s3 import upload_file_to_s3
@ -28,6 +31,31 @@ from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient
logger = setup_logger()
def _overlay_lodged_performance(
epc_data: EpcPropertyData, summary: RdSapSummary
) -> EpcPropertyData:
"""Fill the as-surveyed current-performance block PasHub carries but the
Site Notes PDF does not. Best-effort per field: values PasHub returns as
null are left empty. The EPC Band is derived from the SAP score via
`Epc.from_sap_score` rather than PasHub's opaque band code."""
updates: Dict[str, Any] = {}
if summary.pre_sap_rating is not None:
score = int(summary.pre_sap_rating)
updates["energy_rating_current"] = score
updates["current_energy_efficiency_band"] = Epc.from_sap_score(score)
if summary.pre_current_co2_emissions is not None:
updates["co2_emissions_current"] = summary.pre_current_co2_emissions
if summary.pre_current_energy_consumption is not None:
updates["energy_consumption_current"] = int(
summary.pre_current_energy_consumption
)
return replace(epc_data, **updates)
class _FileUploadRecord(NamedTuple):
file_path: str
file_type: Optional[str]
@ -101,7 +129,7 @@ class PashubService:
upload_records = self._upload_to_s3_and_update_db(
downloaded.core, uprn, hubspot_deal_id, file_source
)
self._save_site_notes(upload_records)
self._save_site_notes(upload_records, active_client, job_id)
if downloaded.other:
self._upload_to_s3_and_update_db(
@ -190,17 +218,44 @@ class PashubService:
return upload_records
def _save_site_notes(self, upload_records: List[_FileUploadRecord]) -> None:
def _fetch_rdsap_summary(
self, active_client: PashubClient, job_id: str
) -> RdSapSummary:
try:
return active_client.get_rdsap_summary_by_job_id(job_id)
except UnauthorizedError:
if active_client is not self._pashub_client:
raise
logger.info(
f"PasHub credentials unauthorized for RdSAP Summary on job {job_id}; "
"retrying with CoordinationHub credentials"
)
return self._get_coordination_client().get_rdsap_summary_by_job_id(job_id)
def _save_site_notes(
self,
upload_records: List[_FileUploadRecord],
active_client: PashubClient,
job_id: str,
) -> None:
for record in upload_records:
if (
record.file_type is None
or FileTypeEnum(record.file_type) != FileTypeEnum.RD_SAP_SITE_NOTE
):
continue
# The RdSAP Summary carries the as-surveyed Lodged Performance the
# Site Notes PDF lacks. Fetch it outside the parse try/except so a
# missing summary fails loudly rather than persisting a Property with
# an incomplete Lodged Performance.
summary: RdSapSummary = self._fetch_rdsap_summary(active_client, job_id)
try:
epc_data: EpcPropertyData = parse_site_notes_pdf(
record.file_path, uprn=record.uprn
)
epc_data = _overlay_lodged_performance(epc_data, summary)
with db_session() as session:
save_epc_property_data(
session, epc_data, uploaded_file_id=record.uploaded_file_id

View file

@ -290,13 +290,15 @@ def test_run_uses_hubspot_deal_id_path_when_no_uprn() -> None:
def test_run_parses_and_saves_site_notes_for_rd_sap_site_note_file() -> None:
mock_client = MagicMock(spec=PashubClient)
mock_client.get_uprn_by_job_id.return_value = None
mock_client.get_evidence_files_by_job_id.return_value = make_downloaded(
core=["/tmp/RdSAP_SiteNote_001.pdf"]
mock_client = _site_note_client(
summary=RdSapSummary(
pre_sap_rating=None,
pre_current_co2_emissions=None,
pre_current_energy_consumption=None,
)
)
fake_epc_data = MagicMock()
parsed = make_epc_property_data()
fake_session = MagicMock()
fake_uploaded_file_id = 99
@ -306,7 +308,7 @@ def test_run_parses_and_saves_site_notes_for_rd_sap_site_note_file() -> None:
patch("backend.pashub_fetcher.pashub_service.upload_file_to_s3"),
patch(
"backend.pashub_fetcher.pashub_service.parse_site_notes_pdf",
return_value=fake_epc_data,
return_value=parsed,
) as mock_parse,
patch(
"backend.pashub_fetcher.pashub_service.save_epc_property_data"
@ -320,9 +322,9 @@ def test_run_parses_and_saves_site_notes_for_rd_sap_site_note_file() -> None:
mock_db.return_value.__enter__.return_value = fake_session
service.run(make_request(uprn="12345"))
mock_parse.assert_called_once_with("/tmp/RdSAP_SiteNote_001.pdf")
mock_parse.assert_called_once_with(RD_SAP_SITE_NOTE_PATH, uprn=12345)
mock_save.assert_called_once_with(
fake_session, fake_epc_data, uploaded_file_id=fake_uploaded_file_id
fake_session, parsed, uploaded_file_id=fake_uploaded_file_id
)