From 330c74323fe839505232d031b4ce433db25b44fd Mon Sep 17 00:00:00 2001 From: EricPaque <60603143+Radisio@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:26:06 +0200 Subject: [PATCH] Migrate number to varchar and add country --- CLAUDE.md | 4 +++- ports/crm_core.py | 5 +++-- ports/crm_core_sqlalchemy.py | 12 +++++++----- tests/api/administrative_document/test_generation.py | 2 +- tests/factories/crm_participant_factory.py | 2 +- tests/sql/crm_test_schema.sql | 10 ++++++---- tests/test_crm_participants.py | 10 ++++++---- 7 files changed, 27 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4da62fd..5d4bb18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,9 @@ bundles through `document-generation`. Read `README.md` for the domain. - Cross-DB refs (`id_sharing_operation`, `id_member`, `ean`) are plain columns, never FKs. - Errors: `ErrorException(errors.admin.X, status_code=...)`; every key needs all four locales (`tests/test_locales.py` enforces it). Generation block is 2360-2365. -- CRM `address.number` is an **INTEGER**. Coerce at the port boundary. +- CRM `address.number` is a **VARCHAR(32)** since 2026-08-30 (`12A` is a real + Belgian house number). The port still coerces via `_as_optional_str`, which is + now a no-op — keep it, so the port survives either column type. ## Gotchas - **`api/administrative_document/routes.py` must NOT `from __future__ import diff --git a/ports/crm_core.py b/ports/crm_core.py index d681246..1e2ffa8 100644 --- a/ports/crm_core.py +++ b/ports/crm_core.py @@ -30,8 +30,9 @@ class PostalAddress: """A CRM address, normalised to strings. - ``number`` is an INTEGER column in the CRM; it is converted at the adapter - boundary so downstream string formatting never has to care. + ``number`` is a VARCHAR(32) in the CRM as of 2026-08-30 — a Belgian house + number is ``12A``, not 12. It is still normalised at the adapter boundary, so + this type is correct against either column type. """ street: str | None = None diff --git a/ports/crm_core_sqlalchemy.py b/ports/crm_core_sqlalchemy.py index aa2c318..7ad39d5 100644 --- a/ports/crm_core_sqlalchemy.py +++ b/ports/crm_core_sqlalchemy.py @@ -121,8 +121,10 @@ def _as_optional_str(value: object) -> str | None: """Normalise a CRM column to a string. - ``address.number`` is an INTEGER in the CRM, so a naive join into an address - line raises TypeError. Convert once, here. + ``address.number`` was an INTEGER in the CRM until 2026-08-30, when it became + a VARCHAR(32); a naive join into an address line raised TypeError. The + conversion is now usually a no-op, and is kept deliberately: it is what lets + this adapter run against a CRM on either side of that migration. """ if value is None: return None @@ -141,9 +143,9 @@ def _address(row: dict[str, Any], prefix: str) -> PostalAddress | None: values = {field: row.get(column) for field, column in columns.items()} if not any(value is not None for value in values.values()): return None - # address.number is an INTEGER column in the CRM — coerce at the boundary so - # no downstream formatter has to care. Getting this wrong silently broke - # billing's whole issue pipeline once. + # Coerce at the boundary so no downstream formatter has to care. Getting this + # wrong silently broke billing's whole issue pipeline once — which is why the + # coercion stays even though address.number is text since 2026-08-30. return PostalAddress(**{field: _as_optional_str(value) for field, value in values.items()}) diff --git a/tests/api/administrative_document/test_generation.py b/tests/api/administrative_document/test_generation.py index e6923d3..02e26d8 100644 --- a/tests/api/administrative_document/test_generation.py +++ b/tests/api/administrative_document/test_generation.py @@ -30,7 +30,7 @@ async def register_bundle(db_session, *, doc_type="annex6_notification", uri=_BU async def seed_participants(db_session, community, sharing_operation) -> None: - home = await f.create_address(db_session, street="Rue Basse", number=3) + home = await f.create_address(db_session, street="Rue Basse", number="3") alice = await f.create_member( db_session, id_community=community.id, diff --git a/tests/factories/crm_participant_factory.py b/tests/factories/crm_participant_factory.py index 17f9e14..36bdc7d 100644 --- a/tests/factories/crm_participant_factory.py +++ b/tests/factories/crm_participant_factory.py @@ -17,7 +17,7 @@ async def create_address( session: AsyncSession, *, street: str = "Rue Haute", - number: int = 12, + number: str = "12", postcode: str = "5000", city: str = "Namur", supplement: str | None = None, diff --git a/tests/sql/crm_test_schema.sql b/tests/sql/crm_test_schema.sql index 42c4c33..e8d205b 100644 --- a/tests/sql/crm_test_schema.sql +++ b/tests/sql/crm_test_schema.sql @@ -7,9 +7,10 @@ -- against a single Postgres instance, so we mirror the minimum CRM DDL the -- suite needs here. -- --- Keep column types identical to production. In particular `address.number` is --- an INTEGER in the real CRM — mirroring it as VARCHAR here would hide the --- str/int bug class at the port boundary. +-- Keep column types identical to production. `address.number` is a VARCHAR(32) +-- in the real CRM as of 2026-08-30 (a Belgian house number is `12A`, not 12); +-- the port still coerces with `_as_optional_str`, which is now a no-op rather +-- than a conversion, and is kept so the port survives either column type. -- ---- community ------------------------------------------------------------- -- Mirrors core/database/models.py::Community plus the legal/regulatory columns @@ -51,10 +52,11 @@ CREATE INDEX IF NOT EXISTS idx_community_subscription_id_community CREATE TABLE IF NOT EXISTS address ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, street VARCHAR(255), - number INTEGER, -- matches the real CRM: house number is an integer column + number VARCHAR(32), -- matches the real CRM: text, because 12A is a real house number postcode VARCHAR(16), supplement VARCHAR(255), city VARCHAR(255), + country CHAR(2) NOT NULL DEFAULT 'BE', id_community INTEGER, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP diff --git a/tests/test_crm_participants.py b/tests/test_crm_participants.py index db48d84..74f8671 100644 --- a/tests/test_crm_participants.py +++ b/tests/test_crm_participants.py @@ -18,7 +18,7 @@ async def _alice_with_two_meters(db_session, community, sharing_operation) -> int: - home = await f.create_address(db_session, street="Rue Basse", number=3) + home = await f.create_address(db_session, street="Rue Basse", number="3") id_member = await f.create_member( db_session, id_community=community.id, @@ -28,7 +28,7 @@ async def _alice_with_two_meters(db_session, community, sharing_operation) -> in email="alice@example.be", id_home_address=home, ) - site = await f.create_address(db_session, street="Rue du Site", number=7, city="Jambes") + site = await f.create_address(db_session, street="Rue du Site", number="7", city="Jambes") for ean, address in (("541448000000000001", site), ("541448000000000002", None)): await f.create_meter( db_session, @@ -71,8 +71,10 @@ async def test_a_member_is_returned_once_with_all_their_meters( async def test_the_house_number_arrives_as_a_string( self, db_session, community, sharing_operation ): - """address.number is INTEGER in the CRM. This exact coercion bug once - broke billing's whole issue pipeline.""" + """The port owes a string whatever the column is. `address.number` was an + INTEGER until 2026-08-30 and is a VARCHAR(32) now; this exact coercion bug + once broke billing's whole issue pipeline, so the guarantee is asserted + rather than assumed.""" await _alice_with_two_meters(db_session, community, sharing_operation) result = await SqlAlchemyCrmCoreRead(db_session).get_operation_participants(