Model/repositories/product/product_json_repository.py
Khalim Conn-Kowlessar 53d9f21f73 fix(modelling): offer ASHP when the catalogue has no ASHP row
The ASHP bundle is priced from the rate sheet (ADR-0025); the catalogue
row is read only for its material id, which is nullable end-to-end. The
live `material` catalogue has no `air_source_heat_pump` row, so
`products.get` raised `ValueError: no active product` and aborted every
ASHP-eligible property.

Add `ProductNotFound(ValueError)` + a concrete `ProductRepository
.get_optional`, raise the typed error from both repos, and have
`_ashp_option` look the row up optionally — a missing row now yields an
ASHP Option with `material_id=None` rather than crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:55:41 +00:00

49 lines
2 KiB
Python

from __future__ import annotations
import json
from pathlib import Path
from typing import Any, cast
from domain.modelling.contingencies import contingency_rate
from domain.modelling.product import Product
from repositories.product.product_repository import ProductNotFound, ProductRepository
class ProductJsonRepository(ProductRepository):
"""Reads Products from a JSON catalogue file — the stopgap source for
costs the ETL does not yet supply, behind the same `ProductRepository`
port as the Postgres-backed catalogue.
The file maps each Measure Type to its fully-loaded unit cost::
{"cavity_wall_insulation": {"unit_cost_per_m2": 18.5}, ...}
The per-Measure-Type contingency is joined from config (not stored in the
file), exactly as `ProductPostgresRepository` joins it — config stays the
single source of truth for contingency.
"""
def __init__(self, path: Path) -> None:
with path.open(encoding="utf-8") as handle:
loaded: Any = json.load(handle)
if not isinstance(loaded, dict):
raise ValueError(f"product catalogue {path} is not a JSON object")
self._entries: dict[str, Any] = loaded
def get(self, measure_type: str) -> Product:
entry: Any = self._entries.get(measure_type)
if entry is None:
raise ProductNotFound(f"no product for measure type {measure_type!r}")
if not isinstance(entry, dict):
raise ValueError(f"product {measure_type!r} entry is not an object")
typed_entry: dict[str, Any] = cast("dict[str, Any]", entry)
unit_cost: Any = typed_entry.get("unit_cost_per_m2")
if isinstance(unit_cost, bool) or not isinstance(unit_cost, (int, float)):
raise ValueError(
f"product {measure_type!r} has no numeric unit_cost_per_m2"
)
return Product(
measure_type=measure_type,
unit_cost_per_m2=float(unit_cost),
contingency_rate=contingency_rate(measure_type),
)