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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user