From 43a88a356c745dacce0f518ee793e231a2128cd7 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 23:42:22 -0600 Subject: [PATCH] feat(backend): parse-835 JSON response includes reconciliation summary --- backend/src/cyclone/api.py | 38 +++++++++++++++++++++++- backend/tests/test_api_parse_persists.py | 24 +++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index ad0a40f..2dc6efc 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -303,6 +303,40 @@ def _has_835_validation_errors(result: ParseResult835) -> bool: return result.validation is not None and not result.validation.passed +def _reconciliation_summary_for_batch(batch_id: str) -> dict: + """Return ``{matched, unmatched_claims, unmatched_remittances, skipped}`` for a batch. + + Reads from the DB after ``store.add()`` has already triggered T10 + reconciliation synchronously (via ``_run_reconcile``). Counts are + observed at this moment; a subsequent manual match/unmatch will not + be reflected until the next request. + + ``skipped`` is reserved for future use — the T10 orchestrator tracks + skipped claims internally but does not surface a queryable count. + """ + from sqlalchemy import func, select + from cyclone import db + from cyclone.db import Match, Remittance + + with db.SessionLocal()() as s: + matched = s.execute( + select(func.count(Match.id)).where( + Match.remittance_id.in_( + select(Remittance.id).where(Remittance.batch_id == batch_id) + ) + ) + ).scalar_one() + + # Pull unmatched via the store (small result set; cheap). + unmatched = store.list_unmatched(kind="both") + return { + "matched": matched, + "unmatched_claims": len(unmatched["claims"]), + "unmatched_remittances": len(unmatched["remittances"]), + "skipped": 0, # reserved — T10 does not persist a skipped count + } + + @app.post("/api/parse-835") async def parse_835_endpoint( request: Request, @@ -384,7 +418,9 @@ async def parse_835_endpoint( store.add(rec) if _client_wants_json(request): - return JSONResponse(content=json.loads(result.model_dump_json())) + body = json.loads(result.model_dump_json()) + body["reconciliation"] = _reconciliation_summary_for_batch(rec.id) + return JSONResponse(content=body) # Default: NDJSON stream. return StreamingResponse( diff --git a/backend/tests/test_api_parse_persists.py b/backend/tests/test_api_parse_persists.py index daede6c..81321a4 100644 --- a/backend/tests/test_api_parse_persists.py +++ b/backend/tests/test_api_parse_persists.py @@ -11,6 +11,7 @@ from cyclone.api import app from cyclone.store import store as global_store FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" +FIXTURE_835 = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt" @pytest.fixture(autouse=True) @@ -79,3 +80,26 @@ def test_validation_failed_837_does_not_create_batch(client: TestClient): ) assert resp.status_code == 422, resp.text assert len(global_store.list()) == 0 + + +def test_parse_835_response_includes_reconciliation_summary( + client: TestClient, tmp_path: Path +): + """A successful 835 parse returns matched/unmatched counts in JSON.""" + fixture = FIXTURE_835.read_text() + p = tmp_path / "era.txt" + p.write_text(fixture) + with open(p, "rb") as f: + r = client.post( + "/api/parse-835", + files={"file": ("era.txt", f, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert r.status_code == 200 + body = r.json() + assert "reconciliation" in body, f"missing reconciliation: {list(body.keys())}" + rec = body["reconciliation"] + assert "matched" in rec + assert "unmatched_claims" in rec + assert "unmatched_remittances" in rec + assert "skipped" in rec