diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index e5010e9..0446dc0 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -28,7 +28,7 @@ from typing import Any, AsyncIterator from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi.responses import JSONResponse, Response, StreamingResponse from pydantic import ValidationError from cyclone import __version__, db @@ -54,6 +54,7 @@ from cyclone.parsers.parse_999 import parse_999_text from cyclone.parsers.parse_ta1 import parse_ta1_text from cyclone.parsers.serialize_270 import serialize_270 from cyclone.parsers.serialize_999 import serialize_999 +from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837 from cyclone.parsers.batch_ack_builder import build_ack_for_batch from cyclone.parsers.validator_835 import validate as validate_835 from cyclone.pubsub import EventBus @@ -1212,6 +1213,58 @@ def get_claim_detail_endpoint(claim_id: str) -> dict: return body +@app.get("/api/claims/{claim_id}/serialize-837") +def serialize_claim_as_837(claim_id: str): + """Return the claim as a regenerated X12 837P file (SP8). + + Loads the ClaimOutput from the persisted ``raw_json`` and runs the + outbound serializer. Returns 404 if the claim doesn't exist, 422 if + the stored payload has no parseable ClaimOutput (data integrity + issue, not a transient failure). + """ + with db.SessionLocal()() as s: + row = s.get(Claim, claim_id) + if row is None: + return JSONResponse( + {"error": "Not found", "detail": f"Claim {claim_id} not found"}, + status_code=404, + ) + if not row.raw_json: + return JSONResponse( + { + "error": "Unprocessable", + "detail": f"Claim {claim_id} has no raw_json; cannot serialize", + }, + status_code=422, + ) + try: + claim_obj = ClaimOutput.model_validate(row.raw_json) + except Exception as exc: + return JSONResponse( + { + "error": "Unprocessable", + "detail": f"Claim {claim_id} raw_json is malformed: {exc}", + }, + status_code=422, + ) + + try: + text = serialize_837(claim_obj) + except SerializeError837 as exc: + return JSONResponse( + {"error": "Unprocessable", "detail": str(exc)}, + status_code=422, + ) + + return Response( + content=text, + media_type="text/x12", + headers={ + "Content-Disposition": f'attachment; filename="claim-{claim_id}.x12"' + }, + ) + + @app.get("/api/claims/{claim_id}/line-reconciliation") def get_claim_line_reconciliation(claim_id: str) -> dict: """Per-line reconciliation view for the ClaimDrawer tab. diff --git a/backend/tests/test_api_serialize_837.py b/backend/tests/test_api_serialize_837.py new file mode 100644 index 0000000..746a9d3 --- /dev/null +++ b/backend/tests/test_api_serialize_837.py @@ -0,0 +1,54 @@ +"""Tests for the GET /api/claims/{id}/serialize-837 endpoint (SP8).""" +import io +from pathlib import Path + +from fastapi.testclient import TestClient + +from cyclone.api import app + + +def _seed_claim(client: TestClient) -> str: + fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text() + r = client.post( + "/api/parse-837", + files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert r.status_code == 200, r.text + claim_id = r.json()["claims"][0]["claim_id"] + return claim_id + + +def test_endpoint_returns_x12_attachment(): + with TestClient(app) as client: + claim_id = _seed_claim(client) + r = client.get(f"/api/claims/{claim_id}/serialize-837") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/x12") + cd = r.headers["content-disposition"] + assert "attachment" in cd + assert f"claim-{claim_id}.x12" in cd + assert r.text.startswith("ISA*") + + +def test_endpoint_404_for_missing_claim(): + with TestClient(app) as client: + r = client.get("/api/claims/CLM-DOES-NOT-EXIST/serialize-837") + assert r.status_code == 404 + body = r.json() + assert body["error"] == "Not found" + assert "CLM-DOES-NOT-EXIST" in body["detail"] + + +def test_endpoint_regenerated_text_round_trips_through_parser(): + """The endpoint's body should be a parseable 837 — round-trip test.""" + from cyclone.parsers.parse_837 import parse + from cyclone.parsers.payer import PayerConfig + + with TestClient(app) as client: + claim_id = _seed_claim(client) + r = client.get(f"/api/claims/{claim_id}/serialize-837") + assert r.status_code == 200 + result = parse(r.text, PayerConfig(name="CO_MEDICAID")) + assert result.claims, "endpoint body didn't parse back to any claims" + assert result.claims[0].claim.claim_id == claim_id \ No newline at end of file