feat(modelling): ProductJsonRepository behind the ProductRepository port

Adds the file-backed Product catalogue — the stopgap source for costs
the ETL does not yet supply, behind the same ProductRepository port as
ProductPostgresRepository. The JSON file maps each Measure Type to its
fully-loaded unit cost; the per-Measure-Type contingency is joined from
config (not stored in the file), so config stays the single source of
truth for contingency — mirroring the Postgres repo's mapping.

Strict-raises (ValueError) on an absent measure type, a non-object
entry, or a missing/non-numeric unit_cost_per_m2, matching the
repo-wide strict-no-silent-default convention. tmp_path-backed tests,
no DB fixture needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 09:49:02 +00:00
parent ed6cd9c11a
commit cc0bb8f9bb
2 changed files with 110 additions and 0 deletions

View file

@ -0,0 +1,49 @@
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 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 ValueError(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),
)

View file

@ -0,0 +1,61 @@
"""Behaviour of the JSON-backed ProductRepository: reading a Product from a
catalogue file the stopgap source for costs the ETL does not yet supply,
behind the same port as the Postgres-backed catalogue. The per-measure-type
contingency is joined from config, not stored in the file. See CONTEXT.md
(Product, Cost, Contingency)."""
import json
from pathlib import Path
from typing import Any
import pytest
from domain.modelling.product import Product
from repositories.product.product_json_repository import ProductJsonRepository
def _write_catalogue(tmp_path: Path, payload: dict[str, Any]) -> Path:
path: Path = tmp_path / "products.json"
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def test_get_maps_a_json_entry_to_a_product_with_contingency(
tmp_path: Path,
) -> None:
# Arrange
catalogue: Path = _write_catalogue(
tmp_path, {"cavity_wall_insulation": {"unit_cost_per_m2": 18.5}}
)
# Act
product: Product = ProductJsonRepository(catalogue).get(
"cavity_wall_insulation"
)
# Assert
assert product.measure_type == "cavity_wall_insulation"
assert abs(product.unit_cost_per_m2 - 18.5) <= 1e-9
assert abs(product.contingency_rate - 0.10) <= 1e-9
def test_get_raises_when_measure_type_absent(tmp_path: Path) -> None:
# Arrange
catalogue: Path = _write_catalogue(
tmp_path, {"loft_insulation": {"unit_cost_per_m2": 22.0}}
)
# Act / Assert
with pytest.raises(ValueError):
ProductJsonRepository(catalogue).get("cavity_wall_insulation")
def test_get_raises_when_entry_lacks_unit_cost(tmp_path: Path) -> None:
# Arrange
catalogue: Path = _write_catalogue(
tmp_path, {"cavity_wall_insulation": {"cost_unit": "gbp_per_m2"}}
)
# Act / Assert
with pytest.raises(ValueError):
ProductJsonRepository(catalogue).get("cavity_wall_insulation")