Model/repositories/product/product_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

37 lines
1.5 KiB
Python

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional
from domain.modelling.product import Product
class ProductNotFound(ValueError):
"""Raised when the catalogue has no active entry for a Measure Type. A
subclass of ``ValueError`` so existing callers that catch ``ValueError``
keep working, while callers that only want to know *whether* a row exists
(see ``get_optional``) can catch this case alone."""
class ProductRepository(ABC):
"""Loads Products from the catalogue, abstracting the data source (a
Postgres-backed materials table today; a JSON file for costs the ETL does
not yet supply, behind the same port later). Maps the raw source row into
the `Product` domain object, joining the per-Measure-Type contingency."""
@abstractmethod
def get(self, measure_type: str) -> Product:
"""Return the Product for a Measure Type, raising ``ProductNotFound``
if there is no active catalogue entry."""
...
def get_optional(self, measure_type: str) -> Optional[Product]:
"""Return the Product for a Measure Type, or None when the catalogue has
no active entry. For measures whose cost is composed off-catalogue (e.g.
ASHP, priced from the rate sheet per ADR-0025) the catalogue row is read
only for its id, so a missing row is not an error — the measure is still
offered, just without a ``material_id``."""
try:
return self.get(measure_type)
except ProductNotFound:
return None