feat(backend): parse-835 JSON response includes reconciliation summary
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user