Model/repositories/property/property_override_postgres_repository.py
Jun-te Kim daa1cd7967 feat(address2uprn): withhold ambiguous cross-row UPRN matches (ADR-0057)
Phase 1 of confirming UPRNs before finalise. address2uprn matched each
row independently, so one UPRN could be the best match for two distinct
addresses (a coarse EPC record absorbing several real addresses, e.g.
flats in a block). Those distinct addresses were then silently merged by
the property identity insert, and collided in property_overrides.

resolve_group_ambiguity() withholds a UPRN claimed by >=2 distinct
normalised addresses within a postcode group (keeps genuine same-address
re-listings), and the handler now emits an address2uprn_status column
(matched | ambiguous_duplicate | unmatched | invalid_postcode | error).
Withheld rows drop to a null UPRN but keep their lexiscore for triage on
the (upcoming) confirmation page.

Also adds the ADR-0057 backstop dedup in property_overrides upsert_all so
the ON CONFLICT statement can never double-touch a row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:15:59 +00:00

76 lines
3.4 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from typing import cast
from sqlalchemy import Table
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlmodel import Session
from infrastructure.postgres.property_override_table import PropertyOverrideRow
from repositories.property.property_override_repository import (
PropertyOverrideInsert,
PropertyOverrideRepository,
)
class PropertyOverridePostgresRepository(PropertyOverrideRepository):
"""Postgres adapter for ``property_overrides`` (ADR-0006).
Write-only: the finaliser materialises the fact layer; no reads here.
"""
def __init__(self, session: Session) -> None:
self._session = session
# ``__table__`` is injected at runtime on table=True classes but the
# stubs don't expose it; pin to ``Table`` so the dialect insert is typed.
self._table: Table = cast(Table, getattr(PropertyOverrideRow, "__table__"))
def upsert_all(self, rows: list[PropertyOverrideInsert]) -> int:
if not rows:
return 0
now = datetime.now(timezone.utc)
# Defensive backstop (ADR-0057): a single INSERT ... ON CONFLICT DO
# UPDATE may not touch the same target row twice — two batch rows sharing
# the conflict key (property_id, override_component, building_part) make
# Postgres raise CardinalityViolation ("cannot affect row a second
# time"). ADR-0057 stops distinct addresses being coerced onto one UPRN
# upstream, so this should no longer arise from real data; collapse to
# the last occurrence (last-write-wins, the same outcome DO UPDATE gives
# across separate statements) so the statement can never be invalid.
deduped: dict[tuple[int, str, int], PropertyOverrideInsert] = {
(r.property_id, r.override_component, r.building_part): r for r in rows
}
values = [
{
"property_id": r.property_id,
"portfolio_id": r.portfolio_id,
"building_part": r.building_part,
"override_component": r.override_component,
"override_value": r.override_value,
"original_spreadsheet_description": r.original_spreadsheet_description,
"created_at": now,
"updated_at": now,
}
for r in deduped.values()
]
stmt = pg_insert(self._table).values(values)
# Re-run = recalculate (ADR-0005/0006): refresh the snapshot on conflict,
# in contrast to ``property``'s on_conflict_do_nothing. When a per-property
# user-edit path lands (and a ``source`` column with it), this set_ gains a
# ``WHERE source='classifier'`` guard so hand-edits survive.
stmt = stmt.on_conflict_do_update(
index_elements=["property_id", "override_component", "building_part"],
set_={
"override_value": stmt.excluded.override_value,
"original_spreadsheet_description": stmt.excluded.original_spreadsheet_description,
"updated_at": now,
},
)
# SQLModel re-exports SQLAlchemy's Session.execute; one overload is marked
# deprecated in the stubs but the upsert path is supported.
result = self._session.execute(stmt) # pyright: ignore[reportDeprecated]
return cast(int, result.rowcount)