mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
ed6cd9c11a
commit
cc0bb8f9bb
2 changed files with 110 additions and 0 deletions
49
repositories/product/product_json_repository.py
Normal file
49
repositories/product/product_json_repository.py
Normal 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),
|
||||
)
|
||||
61
tests/repositories/product/test_product_json_repository.py
Normal file
61
tests/repositories/product/test_product_json_repository.py
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue