Repository navigation
fix(fsma): location complete only with name+phone+address or GLN #3718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
72c9939
2669a49
853a7a6
1deb1e5
4671e7f
6dcca03
239911e
fa0887b
2913c11
3c0708e
e945a8b
18aa5a4
d44b51f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| """Data-free GLN regressions for FDA spreadsheet rows.""" | ||
|
|
||
| import csv | ||
| import io | ||
|
|
||
| import pytest | ||
|
|
||
| from services.compliance.app import fsma_spreadsheet | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("raw", ["x", "1", "bad"]) | ||
| @pytest.mark.parametrize("origin", ["facility_gln", "location_gln", "ship_from_gln"]) | ||
| @pytest.mark.parametrize("destination", ["dest_gln", "ship_to_gln"]) | ||
| def test_spreadsheet_omits_invalid_glns(raw, origin, destination): | ||
| event = {"kdes": {"tlc_source_gln": raw}} | ||
| for key in (origin, destination): | ||
| target = event["kdes"] if key.startswith("ship_") else event | ||
| target[key] = raw | ||
|
|
||
| row = fsma_spreadsheet._normalise_event(event) | ||
|
|
||
| for column in ("origin_gln", "destination_gln", "tlc_source_gln"): | ||
| assert row[column] == "" | ||
| assert row["additional_kdes_json"] == "" | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("raw", ["x", "1", "bad"]) | ||
| def test_spreadsheet_csv_omits_invalid_glns(raw): | ||
| event = {"facility_gln": raw, "dest_gln": raw, "kdes": {"tlc_source_gln": raw}} | ||
|
|
||
| rows = list(csv.reader(io.StringIO(fsma_spreadsheet.generate_fda_csv([event])))) | ||
| header_index = next(index for index, row in enumerate(rows) if row == fsma_spreadsheet.FDA_HEADERS) | ||
| row = dict(zip(fsma_spreadsheet.FDA_COLUMNS, rows[header_index + 1])) | ||
|
|
||
| for column in ("origin_gln", "destination_gln", "tlc_source_gln"): | ||
| assert row[column] == "" | ||
|
|
||
|
|
||
| def test_spreadsheet_preserves_filtered_glns(monkeypatch): | ||
| marker = "GLN_MARKER" | ||
| monkeypatch.setattr(fsma_spreadsheet, "gln_for_export", lambda value: marker) | ||
|
|
||
| row = fsma_spreadsheet._normalise_event({}) | ||
|
|
||
| for column in ("origin_gln", "destination_gln", "tlc_source_gln"): | ||
| assert row[column] == marker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| """Data-free GLN regressions for graph CSV exports.""" | ||
|
|
||
| import csv | ||
| import io | ||
| from types import SimpleNamespace | ||
| from unittest.mock import AsyncMock, MagicMock | ||
|
|
||
| import pytest | ||
| from shared.fsma_validation import calculate_gs1_check_digit, gln_for_export | ||
|
|
||
| from services.graph.app.exporters import recall_contacts_csv, trace_csv | ||
| from services.graph.app.routers.fsma import compliance | ||
|
|
||
|
|
||
| def shapes(): | ||
| base = "0" * 12 | ||
| return [None, "", " ", "x", "0", "x" * 13, base + str(calculate_gs1_check_digit(base))] | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("raw", shapes()) | ||
| def test_trace_facility_gln(raw): | ||
| assert trace_csv._row({}, {}, {"gln": raw})[8] == gln_for_export(raw) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("raw", shapes()) | ||
| def test_recall_facility_gln(raw): | ||
| assert recall_contacts_csv._row({"gln": raw}, {}, {})[1] == gln_for_export(raw) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize("raw", shapes()) | ||
| async def test_fda_sortable_gln(monkeypatch, raw): | ||
| monkeypatch.setattr(compliance, "reconcile_tenant_scope", lambda *a, **k: "x") | ||
| monkeypatch.setattr(compliance.uuid, "UUID", lambda value: value) | ||
| client = MagicMock() | ||
| client.__aenter__ = AsyncMock(return_value=client) | ||
| client.__aexit__ = AsyncMock(return_value=False) | ||
| monkeypatch.setattr(compliance, "Neo4jClient", MagicMock(return_value=client)) | ||
| monkeypatch.setattr(compliance, "_load_range_export_events", AsyncMock(return_value=[{"location_gln": raw}])) | ||
| request = SimpleNamespace(url=SimpleNamespace(path="x")) | ||
| response = await compliance.export_fda_request_sheet.__wrapped__( | ||
| request, start_date="", end_date="", tlc=None, tenant_id=None, api_key=None | ||
| ) | ||
| body = b"".join([chunk async for chunk in response.body_iterator]).decode("utf-8-sig") | ||
| assert list(csv.reader(io.StringIO(body)))[1][6] == gln_for_export(raw) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize("raw", shapes()) | ||
| @pytest.mark.parametrize("direction", ["forward", "backward"]) | ||
| async def test_trace_api_gln(monkeypatch, raw, direction): | ||
| from services.graph.app.fsma.types import TraceResult | ||
| from services.graph.app.routers.fsma import traceability | ||
|
|
||
| result = TraceResult("x", direction, [{"gln": raw}], [], [], None, 0, 0) | ||
| client = MagicMock() | ||
| client.__aenter__ = AsyncMock(return_value=client) | ||
| client.__aexit__ = AsyncMock(return_value=False) | ||
| monkeypatch.setattr(traceability, "Neo4jClient", MagicMock(return_value=client)) | ||
| monkeypatch.setattr(traceability, f"trace_{direction}", AsyncMock(return_value=result)) | ||
| monkeypatch.setattr(traceability, "record_trace_query", MagicMock()) | ||
| monkeypatch.setattr(traceability, "get_audit_log", lambda: SimpleNamespace(alog=AsyncMock())) | ||
| endpoint = getattr(traceability, f"trace_{direction}_endpoint").__wrapped__ | ||
| response = await endpoint(SimpleNamespace(), tlc="x", max_depth=1, tenant_id=None, api_key=None) | ||
| assert response["facilities"][0]["gln"] == gln_for_export(raw) | ||
| assert result.facilities[0]["gln"] == raw | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("raw", shapes()) | ||
| def test_recall_result_gln(raw): | ||
| from services.graph.app.fsma_recall.models import AffectedFacility, RecallResult | ||
|
|
||
| facility = AffectedFacility(raw, "", "", [], None) | ||
| result = RecallResult("x", True, affected_facilities=[facility]) | ||
| assert result.to_dict()["affected_facilities"][0]["gln"] == gln_for_export(raw) | ||
| assert facility.gln == raw | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("raw", shapes()) | ||
| def test_recall_target_gln(raw): | ||
| from datetime import datetime, timezone | ||
|
|
||
| from services.graph.app.fsma_recall.models import RecallDrill, RecallSeverity, RecallType | ||
|
|
||
| drill = RecallDrill( | ||
| "x", | ||
| "x", | ||
| datetime.now(timezone.utc), | ||
| next(iter(RecallType)), | ||
| next(iter(RecallSeverity)), | ||
| target_facility_gln=raw, | ||
| ) | ||
| assert drill.to_dict()["target_facility_gln"] == gln_for_export(raw) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -343,6 +343,8 @@ async def list_templates(): | |
| _PRODUCT_COLUMNS = ("product_description", "product", "description") | ||
| _QUANTITY_COLUMNS = ("quantity", "qty") | ||
| _UNIT_COLUMNS = ("unit_of_measure", "uom") | ||
| _LOCATION_PHONE_COLUMNS = ("location_phone", "ship_from_phone", "receiving_phone", "phone") | ||
| _LOCATION_ADDRESS_COLUMNS = ("location_address", "ship_from_address", "receiving_address", "address") | ||
| _LOCATION_GLN_COLUMNS = ("location_gln", "ship_from_gln", "receiving_gln", "gln") | ||
| _LOCATION_NAME_COLUMNS = ( | ||
| "location_name", | ||
|
|
@@ -361,6 +363,8 @@ async def list_templates(): | |
| _QUANTITY_COLUMNS, | ||
| _UNIT_COLUMNS, | ||
| _LOCATION_GLN_COLUMNS, | ||
| _LOCATION_PHONE_COLUMNS, | ||
| _LOCATION_ADDRESS_COLUMNS, | ||
| _LOCATION_NAME_COLUMNS, | ||
| _SHIP_TO_COLUMNS, | ||
| ) | ||
|
|
@@ -1341,6 +1345,8 @@ def _reject(reason: str, message: str) -> None: | |
|
|
||
| # Location: strip whitespace and expand abbreviations | ||
| loc_gln = _first_present(row, _LOCATION_GLN_COLUMNS) or None | ||
| loc_phone = _first_present(row, _LOCATION_PHONE_COLUMNS) or None | ||
| loc_address = _first_present(row, _LOCATION_ADDRESS_COLUMNS) or None | ||
|
Comment on lines
+1348
to
+1349
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep each CSV contact field with its location. A row with As per path instructions, “Flag any change that could drop, truncate, silently default, reorder, or corrupt Key Data Elements (KDEs).”
|
||
| _loc_raw = _first_present(row, _LOCATION_NAME_COLUMNS) | ||
| loc_name = _normalize_location(_loc_raw) if _loc_raw else None | ||
| if _loc_raw and loc_name != _loc_raw: | ||
|
|
@@ -1411,6 +1417,8 @@ def _reject(reason: str, message: str) -> None: | |
| "unit_of_measure", | ||
| "location_gln", | ||
| "location_name", | ||
| *_LOCATION_PHONE_COLUMNS, | ||
| *_LOCATION_ADDRESS_COLUMNS, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CSV mixes distinct location groupsMedium Severity CSV parsing copies the first phone and first address alias onto a single top-level pair and then drops every alias from KDEs. Reviewed by Cursor Bugbot for commit fa0887b. Configure here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Idempotency ignores new location fieldsMedium Severity In-batch dedup now treats Additional Locations (1)Reviewed by Cursor Bugbot for commit 18aa5a4. Configure here. |
||
| "cte_type", | ||
| "event_type", | ||
| "type", | ||
|
|
@@ -1457,6 +1465,8 @@ def _reject(reason: str, message: str) -> None: | |
| unit_of_measure=uom, | ||
| location_gln=loc_gln, | ||
| location_name=loc_name, | ||
| location_phone=loc_phone, | ||
| location_address=loc_address, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CSV templates omit complete location fieldsHigh Severity CSV parsing now maps Additional Locations (1)Reviewed by Cursor Bugbot for commit 853a7a6. Configure here. |
||
| timestamp=ts, | ||
| kdes=kdes, | ||
| ) | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Extra KDE JSON emits invalid GLNs
High Severity
Named GLN columns now pass through
gln_for_export, but leftover KDE blobs still dump raw*_glnvalues. EDI now keeps invalidseller_gln,buyer_gln,warehouse_gln,depositor_gln, andreceiving_location_glnfor audit, so FDA spreadsheet and FDA export extras still emit those identifiers unfiltered.Additional Locations (1)
services/ingestion/app/fda_export_service.py#L274-L278Reviewed by Cursor Bugbot for commit 18aa5a4. Configure here.