diff --git a/applications/audit_generator/Master Sero Template - Data Extraction.xlsx b/applications/audit_generator/Master Sero Template - Data Extraction.xlsx index 15bc8d3ab..31ab7f6b6 100644 Binary files a/applications/audit_generator/Master Sero Template - Data Extraction.xlsx and b/applications/audit_generator/Master Sero Template - Data Extraction.xlsx differ diff --git a/applications/audit_generator/local_handler/docker-compose.yml b/applications/audit_generator/local_handler/docker-compose.yml new file mode 100644 index 000000000..cc3f89fc9 --- /dev/null +++ b/applications/audit_generator/local_handler/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3.9" + +services: + audit-generator-lambda: + build: + context: ../../../ + dockerfile: applications/audit_generator/handler/Dockerfile + ports: + - "9001:8080" + env_file: + - ../../../.env diff --git a/applications/audit_generator/local_handler/invoke_local_lambda.py b/applications/audit_generator/local_handler/invoke_local_lambda.py new file mode 100644 index 000000000..14d1339a3 --- /dev/null +++ b/applications/audit_generator/local_handler/invoke_local_lambda.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +import json +import requests + +HOST = "localhost" +PORT = "9001" + +LAMBDA_URL = f"http://{HOST}:{PORT}/2015-03-31/functions/function/invocations" + +payload = { + "Records": [ + { + "messageId": "test-message-id", + "body": json.dumps( + { + "task_id": "00000000-0000-0000-0000-000000000001", + "sub_task_id": "00000000-0000-0000-0000-000000000002", + "hubspot_deal_id": "500311510234", + } + ), + } + ] +} + +response = requests.post(LAMBDA_URL, json=payload) + +print("Status code:", response.status_code) +print("Response:") +print(response.text) diff --git a/applications/audit_generator/local_handler/invoke_local_orchestrator.py b/applications/audit_generator/local_handler/invoke_local_orchestrator.py new file mode 100644 index 000000000..edc7bb8f4 --- /dev/null +++ b/applications/audit_generator/local_handler/invoke_local_orchestrator.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Run AuditGeneratorOrchestrator directly, bypassing @subtask_handler. + +Loads credentials from the repo-root .env file so no DB task/subtask rows +are needed. +""" + +import os +import sys +from pathlib import Path +from typing import Any + +import boto3 +from dotenv import load_dotenv + +REPO_ROOT = Path(__file__).resolve().parents[3] +load_dotenv(REPO_ROOT / ".env") + +sys.path.insert(0, str(REPO_ROOT)) + +from utilities.logger import setup_logger + +setup_logger() + +from infrastructure.postgres.config import PostgresConfig +from infrastructure.postgres.engine import make_engine, make_session +from infrastructure.s3.s3_client import S3Client +from orchestration.audit_generator_orchestrator import AuditGeneratorOrchestrator +from orchestration.audit_generator_unit_of_work import AuditGeneratorUnitOfWork + +HUBSPOT_DEAL_ID = "505712829658" + +boto3_client: Any = boto3.client # type: ignore[attr-defined] +s3_client = S3Client( + boto_s3_client=boto3_client("s3"), + # bucket=os.environ["S3_BUCKET_NAME"], + bucket="retrofit-energy-assessments-dev", +) + +engine = make_engine(PostgresConfig.from_env(os.environ)) + + +def session_factory() -> Any: + return make_session(engine) + + +def uow_factory() -> AuditGeneratorUnitOfWork: + return AuditGeneratorUnitOfWork(session_factory) + + +print(f"Running AuditGeneratorOrchestrator for deal: {HUBSPOT_DEAL_ID!r}") +AuditGeneratorOrchestrator( + hubspot_deal_id=HUBSPOT_DEAL_ID, + s3_client=s3_client, + uow_factory=uow_factory, +).run() +print("Done") diff --git a/domain/magicplan/mapper.py b/domain/magicplan/mapper.py index 604e330a0..2befadd3a 100644 --- a/domain/magicplan/mapper.py +++ b/domain/magicplan/mapper.py @@ -99,8 +99,17 @@ def _map_window_ventilation( opening_type=_try_get_str_value("Opening Type"), num_openings=_try_get_int_value("Number of Openings (In Same Window)"), pct_openable=_try_get_int_value("% of Window Openable"), - trickle_vent_area_mm2=_try_get_int_value( - "Trickle Vent Effective Area (mm2) (No Code Then Width x Height)" + trickle_vent_area_mm2=next( + ( + v + for label in [ + "Total Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", + "Individual Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", + "Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", + ] + if (v := _try_get_int_value(label)) is not None + ), + None, ), num_trickle_vents=_try_get_int_value("No. of Trickle Vents"), ) diff --git a/domain/magicplan/ventilation_audit.py b/domain/magicplan/ventilation_audit.py index 74fc66e1f..9751d770d 100644 --- a/domain/magicplan/ventilation_audit.py +++ b/domain/magicplan/ventilation_audit.py @@ -11,7 +11,7 @@ from domain.magicplan.models import Door, Plan, Room, Window _DATA_START_ROW = 6 _MAX_ROWS = 50 -_DOOR_AREA_COL = "U" +_DOOR_AREA_COL = "T" _DOOR_AREA_CF_RANGE = f"{_DOOR_AREA_COL}{_DATA_START_ROW}:{_DOOR_AREA_COL}{_DATA_START_ROW + _MAX_ROWS - 1}" _Y_THRESHOLD = 7600 _DOOR_AREA_HEADER = CellRichText( @@ -94,9 +94,9 @@ def populate_sheet(sheet: Any, plan: Plan) -> None: for i, (room_name, door) in enumerate(doors): row = _DATA_START_ROW + i vent = door.ventilation - _write_cell(sheet, row, "R", room_name) - _write_cell(sheet, row, "S", door.width_mm) - _write_cell(sheet, row, "T", vent.undercut_mm if vent else 0) - # U = formula =S*T — do not write + _write_cell(sheet, row, "Q", room_name) + _write_cell(sheet, row, "R", door.width_mm) + _write_cell(sheet, row, "S", vent.undercut_mm if vent else 0) + # T = formula =R*S — do not write _apply_column_y_formatting(sheet) diff --git a/tests/domain/magicplan/test_mapper.py b/tests/domain/magicplan/test_mapper.py index 419a66fc8..b429df70f 100644 --- a/tests/domain/magicplan/test_mapper.py +++ b/tests/domain/magicplan/test_mapper.py @@ -8,6 +8,7 @@ import domain.magicplan.api.response as api from domain.magicplan.api.response import MagicPlanPlan, Symbol, Vec3, WallItem from domain.magicplan.mapper import ( _map_window, # pyright: ignore[reportPrivateUsage] + _map_window_ventilation, # pyright: ignore[reportPrivateUsage] map_address, map_plan, ) @@ -229,3 +230,42 @@ def test_map_address_city_absent_is_omitted() -> None: def test_map_address_none_returns_none() -> None: assert map_address(None) is None + + +def _make_survey_field(label: str, value: str) -> api.SurveyField: + return api.SurveyField( + id="test", + type=5, + type_as_string="float", + is_required=False, + label=label, + value=api.FieldValue(index=-1, has_value=True, is_array=False, value=value), + ) + + +def test_trickle_vent_area_read_from_legacy_field_label() -> None: + # Arrange — old MagicPlan label, not yet renamed in the API response + fields = [_make_survey_field( + "Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", "1700" + )] + + # Act + vent = _map_window_ventilation(fields) + + # Assert + assert vent is not None + assert vent.trickle_vent_area_mm2 == 1700 + + +def test_trickle_vent_area_read_from_individual_field_label() -> None: + # Arrange — "Individual" variant used in some MagicPlan templates + fields = [_make_survey_field( + "Individual Trickle Vent Effective Area (mm2) (No Code Then Width x Height)", "900" + )] + + # Act + vent = _map_window_ventilation(fields) + + # Assert + assert vent is not None + assert vent.trickle_vent_area_mm2 == 900 diff --git a/tests/domain/magicplan/test_ventilation_audit.py b/tests/domain/magicplan/test_ventilation_audit.py index 6ebb39362..eb30b98dc 100644 --- a/tests/domain/magicplan/test_ventilation_audit.py +++ b/tests/domain/magicplan/test_ventilation_audit.py @@ -148,7 +148,7 @@ def test_writes_trickle_vent_data_to_columns_N_and_O() -> None: assert sheet["O6"].value == 2 -def test_writes_door_room_name_to_column_R() -> None: +def test_writes_door_room_name_to_column_Q() -> None: # Arrange plan = _make_plan(num_rooms=1, num_windows_per_room=0, num_doors_per_room=1) sheet = _blank_sheet() @@ -157,10 +157,10 @@ def test_writes_door_room_name_to_column_R() -> None: populate_sheet(sheet, plan) # Assert - assert sheet["R6"].value == "Room 0" + assert sheet["Q6"].value == "Room 0" -def test_writes_door_dimensions_to_columns_S_and_T() -> None: +def test_writes_door_dimensions_to_columns_R_and_S() -> None: # Arrange plan = _make_plan(num_rooms=1, num_windows_per_room=0, num_doors_per_room=1) sheet = _blank_sheet() @@ -169,8 +169,8 @@ def test_writes_door_dimensions_to_columns_S_and_T() -> None: populate_sheet(sheet, plan) # Assert - assert sheet["S6"].value == 800.0 - assert sheet["T6"].value == 10.0 + assert sheet["R6"].value == 800.0 + assert sheet["S6"].value == 10.0 def test_writes_zeros_when_window_has_no_ventilation() -> None: @@ -200,7 +200,7 @@ def test_writes_zeros_when_door_has_no_ventilation() -> None: populate_sheet(sheet, plan) # Assert - assert sheet["T6"].value == 0 + assert sheet["S6"].value == 0 _TEMPLATE_PATH = ( @@ -220,4 +220,4 @@ def test_real_template_survives_18_doors() -> None: populate_sheet(sheet, plan) - assert sheet["R23"].value == "Room 17" + assert sheet["Q23"].value == "Room 17" diff --git a/tests/magic_plan/magicplan_api_plan_response.json b/tests/magic_plan/magicplan_api_plan_response.json index bdf8b1bd3..16956f42d 100644 --- a/tests/magic_plan/magicplan_api_plan_response.json +++ b/tests/magic_plan/magicplan_api_plan_response.json @@ -1 +1 @@ -{"message":"OK","data":{"plan":{"id":"72efd2e0-b2b9-48cd-b82e-41f5b3166c9a","project_id":"eb23b4b9-19a4-4874-9a8d-554e350b7e70","name":"20 Larch Way BR2 8DU","address":{"street":"20 Larch Way","street_number":null,"postal_code":"BR2 8DU","city":"Bromley","country":"GB","longitude":0.0573414,"latitude":51.382861},"creation_date":"2026-05-25T15:48:48+00:00","update_date":"2026-06-03T08:51:53+00:00","thumbnail_url":"https:\/\/s3.amazonaws.com\/prod.plans.sensopia.com\/72efd2e0-b2b9-48cd-b82e-41f5b3166c9a\/plan.thumb","public_url":"https:\/\/cloud.magicplan.app\/plan\/72efd2e0-b2b9-48cd-b82e-41f5b3166c9a","cloud_url":"https:\/\/cloud.magicplan.app\/projects\/72efd2e0-b2b9-48cd-b82e-41f5b3166c9a","3d_url":"https:\/\/3d.magicplan.app\/#embed\/?key=OWJiNDI5YmM1ZWI5ODJlYmFiNDYyZmUxOTM4ZTc2NmQzZGYzNjA5MjZlMDQzNDNlMzIxOTk4MTFjNDY5M2ZkYcYjZ6i4MQhzK3HNkFrpRYw%2FPSRN%2FvCLu%2BKMjClzi4BYxQEtpulZnzfzAAV4feMAtqc%2FvajEeEiU419uOp2%2FtjPXiexQVxJJMWawfBWzStVG","workgroup_id":"677d01685458a","team_id":null,"created_by":{"id":"0e165295-6f60-443b-b3ac-779a6bc3a278","firstname":null,"lastname":null,"email":"jane.cooper@domna.homes"}},"plan_detail":{"magicplan_format_xml":"\nJane Cooper<\/value>2026-05-25<\/value>Sero<\/value>2.134<\/value>100<\/value>0<\/value>0<\/value><\/values>Sero<\/notes>Ground Floor<\/name>2.43<\/value>0.28<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>outdoors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>70<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>External.Door<\/value>80<\/value>1700<\/value>1<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>80<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1.14<\/value>0.54<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>80<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1.14<\/value>0.54<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>2<\/value>80<\/value>1700<\/value>2<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>1<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>70<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.2500<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>30.Hinged.Pivot.Window<\/value>1<\/value>50<\/value>1700<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>1.0<\/value>0.5<\/value>0.4<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>plumbing<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value>0.9<\/value><\/values><\/symbolInstance>m<\/value>0<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>1<\/value>70<\/value>m<\/value>m2<\/value>doors<\/value>m3<\/value><\/values><\/symbolInstance>m<\/value>m<\/value>m2<\/value>hvac<\/value>m3<\/value>0.1500<\/value><\/values><\/symbolInstance>677d01685458a<\/value><\/values><\/symbolInstance>structure<\/value>1<\/value>0<\/value><\/values><\/symbolInstance>3.19<\/value><\/values><\/point><\/floorRoom>