feat(sp8): GET /api/claims/{id}/serialize-837 endpoint

Returns the persisted ClaimOutput as a regenerated X12 837P file via
the new outbound serializer (Approach A — full rebuild from canonical
fields). 404 on missing claim, 422 if the stored raw_json cannot be
validated as a ClaimOutput. text/x12 content-type, attachment
disposition with the claim id as the filename.

3 tests:
- endpoint returns text/x12 attachment starting with ISA*
- 404 for missing claim id
- regenerated text round-trips back through parse()
This commit is contained in:
Tyler
2026-06-20 20:34:44 -06:00
parent 561018c690
commit 3f4e6849c6
2 changed files with 108 additions and 1 deletions
+54 -1
View File
@@ -28,7 +28,7 @@ from typing import Any, AsyncIterator
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError from pydantic import ValidationError
from cyclone import __version__, db 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.parse_ta1 import parse_ta1_text
from cyclone.parsers.serialize_270 import serialize_270 from cyclone.parsers.serialize_270 import serialize_270
from cyclone.parsers.serialize_999 import serialize_999 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.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.validator_835 import validate as validate_835 from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.pubsub import EventBus from cyclone.pubsub import EventBus
@@ -1212,6 +1213,58 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
return body 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") @app.get("/api/claims/{claim_id}/line-reconciliation")
def get_claim_line_reconciliation(claim_id: str) -> dict: def get_claim_line_reconciliation(claim_id: str) -> dict:
"""Per-line reconciliation view for the ClaimDrawer tab. """Per-line reconciliation view for the ClaimDrawer tab.
+54
View File
@@ -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