diff --git a/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py new file mode 100644 index 000000000..8e29b6e33 --- /dev/null +++ b/alembic/versions/20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py @@ -0,0 +1,41 @@ +"""v142 -- supplier_facilities.phone for 21 CFR 1.1310 (A-2190). + +Revision ID: e5cd73f8d1bb +Revises: 08c7cf36ddbe (v141 traceability_events_append_only) +Create Date: 2026-10-31 + +Adds an optional business phone on supplier_facilities so a Facility location +description can carry name + phone + address (or a GLN stand-in). Nullable so +existing rows stay valid; UI and API accept phone on create. + +Additive. Synthetic fixtures only until SEL-590 clears. + +Note: #3717's fsma.traceability_events append-only trigger landed on main as +v141 (08c7cf36ddbe); this migration chains after it and does not vendor it. +Earlier, shipping it as a side-effect of the phone +column caused Live DB suite CheckViolation failures on intentional tamper +UPDATEs (append-only policy), which phone ADD COLUMN never touches. +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "e5cd73f8d1bb" # pragma: allowlist secret +down_revision: Union[str, Sequence[str], None] = "08c7cf36ddbe" # pragma: allowlist secret +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "supplier_facilities", + sa.Column("phone", sa.Text(), nullable=True), + ) + + +def downgrade() -> None: + # Destructive: deletes every stored facility phone. Back up + # supplier_facilities.phone first if those values must be kept. + op.drop_column("supplier_facilities", "phone") diff --git a/docs/schema/expected_schema.json b/docs/schema/expected_schema.json index 7803847ca..f7c6aa8c5 100644 --- a/docs/schema/expected_schema.json +++ b/docs/schema/expected_schema.json @@ -7074,6 +7074,10 @@ "nullable": false, "type": "text" }, + "phone": { + "nullable": true, + "type": "text" + }, "postal_code": { "nullable": false, "type": "text" diff --git a/regengine/models.py b/regengine/models.py index d839a9d53..72ee27844 100644 --- a/regengine/models.py +++ b/regengine/models.py @@ -91,12 +91,13 @@ class Facility: name: str address: Optional[str] = None facility_type: Optional[str] = None + phone: Optional[str] = None @classmethod def from_api(cls, payload: Dict[str, Any]) -> "Facility": """Build a ``Facility`` from a facility object in a trace response. - Trace results carry ``gln``/``name``/``address``/``facility_type`` + Trace results carry ``gln``/``name``/``address``/``phone``/``facility_type`` with no separate identifier, so ``id`` falls back to the GLN. Keys the SDK does not model are ignored. """ @@ -106,6 +107,7 @@ def from_api(cls, payload: Dict[str, Any]) -> "Facility": gln=gln, name=_first(payload, "name", default=""), address=_first(payload, "address"), + phone=_first(payload, "phone"), facility_type=_first(payload, "facility_type", "type"), ) diff --git a/services/admin/app/bulk_upload/transaction_manager.py b/services/admin/app/bulk_upload/transaction_manager.py index 370ba4459..f28221879 100644 --- a/services/admin/app/bulk_upload/transaction_manager.py +++ b/services/admin/app/bulk_upload/transaction_manager.py @@ -493,6 +493,7 @@ def resolve_facility_by_name(name: str) -> SupplierFacilityModel: "city": facility.city, "state": facility.state, "postal_code": facility.postal_code, + "phone": facility.phone, "fda_registration_number": facility.fda_registration_number, "roles": facility.roles or [], "categories": normalized_categories, @@ -534,6 +535,7 @@ def resolve_facility_by_name(name: str) -> SupplierFacilityModel: city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], categories=categories, diff --git a/services/admin/app/sqlalchemy_models.py b/services/admin/app/sqlalchemy_models.py index b0fbb9bd8..6067ee07e 100644 --- a/services/admin/app/sqlalchemy_models.py +++ b/services/admin/app/sqlalchemy_models.py @@ -420,6 +420,7 @@ class SupplierFacilityModel(Base): city = Column(String, nullable=False) state = Column(String, nullable=False) postal_code = Column(String, nullable=False) + phone = Column(Text, nullable=True) # A-2190 / 21 CFR 1.1310 fda_registration_number = Column(String, nullable=True) roles = Column(JSONType(), nullable=False, default=list) # Hours after a CTE's event time the supplier agreed to send it. NULL: none. diff --git a/services/admin/app/supplier_facilities_routes.py b/services/admin/app/supplier_facilities_routes.py index 250f7f5c3..4c904e9bf 100644 --- a/services/admin/app/supplier_facilities_routes.py +++ b/services/admin/app/supplier_facilities_routes.py @@ -89,6 +89,7 @@ async def list_supplier_facilities( city=f.city, state=f.state, postal_code=f.postal_code, + phone=f.phone, fda_registration_number=f.fda_registration_number, roles=f.roles or [], ) @@ -115,6 +116,7 @@ async def create_supplier_facility( city=request.city.strip(), state=request.state.strip(), postal_code=request.postal_code.strip(), + phone=(request.phone.strip() if request.phone else None), fda_registration_number=(request.fda_registration_number.strip() if request.fda_registration_number else None), roles=[role.strip() for role in request.roles if role.strip()], ) @@ -129,6 +131,7 @@ async def create_supplier_facility( city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], ) @@ -255,6 +258,7 @@ async def check_supplier_facility_removal( "city": facility.city, "state": facility.state, "postal_code": facility.postal_code, + "phone": facility.phone or "", }, ) @@ -390,6 +394,7 @@ async def set_facility_ftl_categories( city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], categories=categories, diff --git a/services/admin/app/supplier_funnel_routes.py b/services/admin/app/supplier_funnel_routes.py index ef428b3f9..2bd242d4d 100644 --- a/services/admin/app/supplier_funnel_routes.py +++ b/services/admin/app/supplier_funnel_routes.py @@ -598,6 +598,7 @@ async def reset_supplier_demo( city=facility.city, state=facility.state, postal_code=facility.postal_code, + phone=facility.phone, fda_registration_number=facility.fda_registration_number, roles=facility.roles or [], categories=selected_categories, diff --git a/services/admin/app/supplier_graph_sync.py b/services/admin/app/supplier_graph_sync.py index c1785d61c..5f7466d44 100644 --- a/services/admin/app/supplier_graph_sync.py +++ b/services/admin/app/supplier_graph_sync.py @@ -93,6 +93,7 @@ facility.city = $city, facility.state = $state, facility.postal_code = $postal_code, + facility.phone = $phone, facility.fda_registration_number = $fda_registration_number, facility.roles = $roles, facility.updated_at = datetime() @@ -464,6 +465,7 @@ def record_facility_ftl_scoping( fda_registration_number: Optional[str], roles: list[str], categories: list[dict[str, Any]], + phone: Optional[str] = None, db: Optional["SQLASession"] = None, ) -> None: normalized_categories = [ @@ -486,6 +488,7 @@ def record_facility_ftl_scoping( "city": city, "state": state, "postal_code": postal_code, + "phone": phone, "fda_registration_number": fda_registration_number, "roles": roles, "categories": normalized_categories, diff --git a/services/admin/app/supplier_onboarding_routes.py b/services/admin/app/supplier_onboarding_routes.py index 6a8952a43..25949504f 100644 --- a/services/admin/app/supplier_onboarding_routes.py +++ b/services/admin/app/supplier_onboarding_routes.py @@ -63,6 +63,10 @@ class SupplierFacilityCreateRequest(BaseModel): city: str = Field(min_length=2) state: str = Field(min_length=2) postal_code: str = Field(min_length=3) + phone: str | None = Field( + default=None, + description="Facility business phone (21 CFR 1.1310 location description)", + ) fda_registration_number: str | None = None roles: list[str] = Field(default_factory=list) @@ -74,6 +78,7 @@ class SupplierFacilityResponse(BaseModel): city: str state: str postal_code: str + phone: str | None = None fda_registration_number: str | None roles: list[str] diff --git a/services/admin/tests/test_bulk_upload_graph_sync_wiring.py b/services/admin/tests/test_bulk_upload_graph_sync_wiring.py index ac1608883..b80ee4754 100644 --- a/services/admin/tests/test_bulk_upload_graph_sync_wiring.py +++ b/services/admin/tests/test_bulk_upload_graph_sync_wiring.py @@ -467,3 +467,51 @@ def _boom(*_args: Any, **_kwargs: Any) -> None: if __name__ == "__main__": pytest.main([__file__, "-v"]) + + +def test_facility_scoping_outbox_row_binds_every_cypher_parameter( + db_session: Session, + monkeypatch: pytest.MonkeyPatch, +): + """A replayed outbox row must supply every ``$param`` the Cypher reads. + + ``FACILITY_FTL_SCOPING_QUERY`` gained ``$phone``. Neo4j rejects a query + with an unbound parameter, so a row without it can never drain. + """ + import re + + import app.bulk_upload.transaction_manager as tx_manager + from app.supplier_graph_sync import FACILITY_FTL_SCOPING_QUERY + + monkeypatch.setattr(tx_manager.supplier_graph_sync, "record_cte_event", lambda **_kwargs: None) + monkeypatch.setattr( + tx_manager.supplier_graph_sync, + "record_facility_ftl_scoping", + lambda **_kwargs: None, + ) + + current_user = db_session.get(UserModel, TEST_USER_ID) + assert current_user is not None + + payload = _build_payload(event_count=1) + payload["ftl_scopes"] = [ + { + "facility_name": "Bulk Test Packhouse", + "category_id": next(iter(tx_manager.FTL_CATEGORY_LOOKUP)), + } + ] + tx_manager.execute_bulk_commit( + db_session, + tenant_id=TEST_TENANT_ID, + current_user=current_user, + normalized_payload=payload, + ) + + rows = [r for r in _outbox_rows(db_session) if r["operation"] == "facility_ftl_scoping"] + assert rows, "expected a facility_ftl_scoping outbox row" + + required = set(re.findall(r"\$([A-Za-z_][A-Za-z0-9_]*)", FACILITY_FTL_SCOPING_QUERY)) + assert "phone" in required + for row in rows: + params = row["params"] if isinstance(row["params"], dict) else json.loads(row["params"]) + assert required <= set(params), f"unbound: {sorted(required - set(params))}" diff --git a/services/graph/app/models/fsma_nodes.py b/services/graph/app/models/fsma_nodes.py index d28424008..59326d567 100644 --- a/services/graph/app/models/fsma_nodes.py +++ b/services/graph/app/models/fsma_nodes.py @@ -364,6 +364,7 @@ class Facility: city: Optional[str] = None state: Optional[str] = None country: Optional[str] = "US" + phone: Optional[str] = None # A-2190 / 21 CFR 1.1310 location description facility_type: FacilityType = FacilityType.UNKNOWN tenant_id: Optional[str] = None # Merkle hash-chain integrity @@ -384,6 +385,37 @@ def identifier(self) -> str: """Return the primary identifier (GLN preferred, then FDA reg, then name).""" return self.gln or self.fda_registration or self.name + @property + def is_location_description_complete(self) -> bool: + """Whether this facility satisfies 21 CFR 1.1310 location description. + + Complete only with business name + phone + physical address + (street address, or city+state). A GLN is not a stand-in by itself: + it counts only when the location master row has already resolved to + those three fields on this Facility node. A bare / unresolved GLN + leaves the description incomplete (flag; ingest must not reject — + A-2170). A TLC source reference is never a stand-in on Facility + (it only substitutes for the TLC source location field on an event + at ingest). See Food Safety Lens answers for A-2190 / PR #3771. + """ + name_ok = bool((self.name or "").strip()) + phone_ok = bool((self.phone or "").strip()) + address_ok = bool( + (self.address or "").strip() + or ((self.city or "").strip() and (self.state or "").strip()) + ) + return name_ok and phone_ok and address_ok + + @property + def has_unresolved_gln(self) -> bool: + """True when a GLN is stored but name+phone+address are incomplete. + + Flag for operators / export; does not reject the record (A-2170). + """ + if not (self.gln or "").strip(): + return False + return not self.is_location_description_complete + @property def node_properties(self) -> Dict[str, Any]: """Return properties for Neo4j node creation.""" @@ -400,6 +432,8 @@ def node_properties(self) -> Dict[str, Any]: props["state"] = self.state if self.country: props["country"] = self.country + if self.phone: + props["phone"] = self.phone props["facility_type"] = ( self.facility_type.value if isinstance(self.facility_type, FacilityType) else self.facility_type ) diff --git a/services/graph/tests/test_graph_models_unit.py b/services/graph/tests/test_graph_models_unit.py index 465b2f7c3..5ea7ed450 100644 --- a/services/graph/tests/test_graph_models_unit.py +++ b/services/graph/tests/test_graph_models_unit.py @@ -324,3 +324,52 @@ def test_response_envelope_carries_data_and_fresh_meta(): assert first.data == {"hello": "world"} assert isinstance(first.meta, ResponseMeta) assert first.meta is not second.meta + + +def test_facility_phone_serialises_into_node_properties(): + facility = Facility( + gln="G1", + name="Valley Fresh Farms", + address="100 Farm Rd", + phone="+1-202-555-0147", + tenant_id="tenant-a", + ) + props = facility.node_properties + assert props["phone"] == "+1-202-555-0147" + assert props["address"] == "100 Farm Rd" + + +def test_facility_location_description_complete_with_name_phone_address(): + complete = Facility( + name="Valley Fresh Farms", + address="100 Farm Rd", + phone="+1-202-555-0147", + ) + assert complete.is_location_description_complete is True + assert complete.has_unresolved_gln is False + + missing_phone = Facility(name="Valley Fresh Farms", address="100 Farm Rd") + assert missing_phone.is_location_description_complete is False + + # Bare / unresolved GLN is incomplete (Food Safety Lens A-2190): flag only. + bare_gln = Facility(gln="0614141000005", name="") + assert bare_gln.is_location_description_complete is False + assert bare_gln.has_unresolved_gln is True + + # GLN counts only once the master row carries name + phone + address. + resolved_gln = Facility( + gln="0614141000005", + name="Valley Fresh Farms", + address="100 Farm Rd", + phone="+1-202-555-0147", + ) + assert resolved_gln.is_location_description_complete is True + assert resolved_gln.has_unresolved_gln is False + + city_state = Facility( + name="Cooler Co", + city="Salinas", + state="CA", + phone="+1-831-555-0100", + ) + assert city_state.is_location_description_complete is True diff --git a/supabase/migrations/20261031120000_supplier_facility_phone.sql b/supabase/migrations/20261031120000_supplier_facility_phone.sql new file mode 100644 index 000000000..a40a1d5cc --- /dev/null +++ b/supabase/migrations/20261031120000_supplier_facility_phone.sql @@ -0,0 +1,9 @@ +-- A-2190 / 21 CFR 1.1310: optional business phone on supplier_facilities. +-- Mirrors alembic 20261031_e5cd73f8d1bb_v142_supplier_facility_phone.py. +-- Additive and nullable. Synthetic fixtures only until SEL-590 clears. + +ALTER TABLE public.supplier_facilities + ADD COLUMN IF NOT EXISTS phone text; + +COMMENT ON COLUMN public.supplier_facilities.phone IS + 'Facility business phone for 21 CFR 1.1310 location description (A-2190)';