1267 lines
44 KiB
Python
1267 lines
44 KiB
Python
"""FastAPI HTTP surface for the Cyclone 837P and 835 ERA parsers.
|
||
|
||
The frontend (Vite/React on http://localhost:5173) uploads an X12 file via
|
||
``POST /api/parse-837`` or ``POST /api/parse-835`` and receives either:
|
||
|
||
* a single ``ParseResult`` (or ``ParseResult835``) JSON object when it
|
||
sends ``Accept: application/json``, or
|
||
* a stream of newline-delimited JSON objects (``application/x-ndjson``)
|
||
— one envelope, one line per claim/payout, then one summary — so the
|
||
UI can render records incrementally as they're produced.
|
||
|
||
CORS is configured to allow the Vite dev origin (``http://localhost:5173``)
|
||
plus GET/POST with any header.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import uuid
|
||
from typing import Any, Iterator
|
||
|
||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, StreamingResponse
|
||
from pydantic import ValidationError
|
||
|
||
from cyclone import __version__
|
||
from cyclone.parsers.exceptions import CycloneParseError
|
||
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
||
from cyclone.parsers.models_270 import (
|
||
EligibilityBenefitInquiry,
|
||
InformationReceiver270,
|
||
InformationSource270,
|
||
ParseResult270,
|
||
Subscriber270,
|
||
)
|
||
from cyclone.parsers.models_271 import ParseResult271
|
||
from cyclone.parsers.models_835 import ParseResult835
|
||
from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
||
from cyclone.parsers.parse_270 import parse as parse_270_text
|
||
from cyclone.parsers.parse_271 import parse as parse_271_text
|
||
from cyclone.parsers.parse_837 import parse
|
||
from cyclone.parsers.parse_835 import parse as parse_835
|
||
from cyclone.parsers.parse_999 import parse_999_text
|
||
from cyclone.parsers.serialize_270 import serialize_270
|
||
from cyclone.parsers.serialize_999 import serialize_999
|
||
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
|
||
from cyclone.parsers.validator_835 import validate as validate_835
|
||
from cyclone.store import (
|
||
AlreadyMatchedError,
|
||
BatchRecord,
|
||
InvalidStateError,
|
||
store,
|
||
utcnow,
|
||
)
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
# Mirror cli._PAYER_FACTORIES. Kept local so the API doesn't depend on the
|
||
# Click-based CLI module.
|
||
PAYER_FACTORIES: dict[str, Any] = {
|
||
"co_medicaid": PayerConfig.co_medicaid,
|
||
"generic_837p": PayerConfig.generic_837p,
|
||
}
|
||
|
||
PAYER_FACTORIES_835: dict[str, Any] = {
|
||
"co_medicaid_835": PayerConfig835.co_medicaid_835,
|
||
"generic_835": PayerConfig835.generic_835,
|
||
}
|
||
|
||
VITE_DEV_ORIGIN = "http://localhost:5173"
|
||
|
||
app = FastAPI(
|
||
title="Cyclone 837P / 835 Parser API",
|
||
version=__version__,
|
||
description="Upload X12 837P and 835 files and receive parsed records as JSON or NDJSON.",
|
||
)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=[VITE_DEV_ORIGIN],
|
||
allow_credentials=False,
|
||
allow_methods=["GET", "POST"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
def _resolve_payer(name: str) -> PayerConfig:
|
||
if name not in PAYER_FACTORIES:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"error": "Unknown payer",
|
||
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES)}",
|
||
},
|
||
)
|
||
return PAYER_FACTORIES[name]()
|
||
|
||
|
||
def _resolve_payer_835(name: str) -> PayerConfig835:
|
||
if name not in PAYER_FACTORIES_835:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"error": "Unknown payer",
|
||
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES_835)}",
|
||
},
|
||
)
|
||
return PAYER_FACTORIES_835[name]()
|
||
|
||
|
||
def _strict_rewrite(result: ParseResult) -> ParseResult:
|
||
"""Promote warnings to errors (mirrors the CLI's --strict)."""
|
||
claims: list[ClaimOutput] = []
|
||
for claim in result.claims:
|
||
promoted = [
|
||
issue.model_copy(update={"severity": "error"})
|
||
for issue in claim.validation.warnings
|
||
]
|
||
new_errors = claim.validation.errors + promoted
|
||
claims.append(
|
||
claim.model_copy(
|
||
update={
|
||
"validation": claim.validation.model_copy(
|
||
update={"errors": new_errors, "passed": not new_errors}
|
||
)
|
||
}
|
||
)
|
||
)
|
||
passed = sum(1 for c in claims if c.validation.passed)
|
||
failed = len(claims) - passed
|
||
summary = result.summary.model_copy(
|
||
update={
|
||
"passed": passed,
|
||
"failed": failed,
|
||
"failed_claim_ids": [c.claim_id for c in claims if not c.validation.passed],
|
||
}
|
||
)
|
||
return result.model_copy(update={"claims": claims, "summary": summary})
|
||
|
||
|
||
def _drop_raw_segments(result: ParseResult) -> ParseResult:
|
||
"""Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
|
||
claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
|
||
return result.model_copy(update={"claims": claims})
|
||
|
||
|
||
def _client_wants_json(request: Request) -> bool:
|
||
"""Content negotiation: prefer ``application/json`` when the client asks for it.
|
||
|
||
NDJSON is the default for browser uploads that don't set ``Accept``. The
|
||
frontend opts into JSON via ``Accept: application/json``.
|
||
"""
|
||
accept = request.headers.get("accept", "")
|
||
# If the client mentions JSON at all (and isn't asking for NDJSON
|
||
# specifically) treat it as a single-object request. The browser default
|
||
# ``*/*`` falls through to NDJSON.
|
||
if "application/json" in accept and "application/x-ndjson" not in accept:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _wants_ndjson(request: Request) -> bool:
|
||
"""Content negotiation for list endpoints: NDJSON is an opt-in, JSON is the
|
||
default (per spec 6.2: "Default JSON response wraps the same data in a
|
||
{items, total, returned, has_more} envelope so the frontend can paginate
|
||
uniformly").
|
||
|
||
Used by the GET list routes (/api/batches, /api/claims, /api/remittances,
|
||
/api/providers, /api/activity). NDJSON is returned only when the client
|
||
explicitly sends ``Accept: application/x-ndjson`` (with or without
|
||
``application/json``). Bare ``*/*``, an empty Accept, or an explicit
|
||
``Accept: application/json`` all return the JSON envelope.
|
||
"""
|
||
accept = request.headers.get("accept", "")
|
||
return "application/x-ndjson" in accept
|
||
|
||
|
||
def _ndjson_stream_list(
|
||
items: list[dict], total: int, returned: int, has_more: bool,
|
||
) -> Iterator[str]:
|
||
"""Yield NDJSON lines for a list endpoint: one ``item`` per dict, then a
|
||
final ``summary`` line. Mirrors spec section 6.2 streaming rule.
|
||
"""
|
||
for it in items:
|
||
yield json.dumps({"type": "item", "data": it}) + "\n"
|
||
yield json.dumps({
|
||
"type": "summary",
|
||
"data": {"total": total, "returned": returned, "has_more": has_more},
|
||
}) + "\n"
|
||
|
||
|
||
def _has_claim_validation_errors(result: ParseResult) -> bool:
|
||
return any(not c.validation.passed for c in result.claims)
|
||
|
||
|
||
@app.get("/api/health")
|
||
def health() -> dict[str, str]:
|
||
return {"status": "ok", "version": __version__}
|
||
|
||
|
||
@app.post("/api/parse-837")
|
||
async def parse_837(
|
||
request: Request,
|
||
file: UploadFile = File(...),
|
||
payer: str = Query("co_medicaid"),
|
||
include_raw_segments: bool = Query(True),
|
||
strict: bool = Query(False),
|
||
ack: bool = Query(False),
|
||
) -> Any:
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
config = _resolve_payer(payer)
|
||
|
||
try:
|
||
result = parse(text, config, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
if strict:
|
||
result = _strict_rewrite(result)
|
||
if not include_raw_segments:
|
||
result = _drop_raw_segments(result)
|
||
|
||
if _has_claim_validation_errors(result):
|
||
# Per spec: 422 when claims failed validation.
|
||
# Body still includes the full ParseResult so the client can show errors.
|
||
# Validation-failed parses are NOT persisted (the data is suspect);
|
||
# only parses that survive validation end up in the store.
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
# Persist the cleaned-up result so the cleaned result is what clients
|
||
# retrieve via /api/batches/{id}.
|
||
rec = BatchRecord(
|
||
id=uuid.uuid4().hex,
|
||
kind="837p",
|
||
input_filename=file.filename or "upload.txt",
|
||
parsed_at=utcnow(),
|
||
result=result,
|
||
)
|
||
store.add(rec)
|
||
|
||
if _client_wants_json(request):
|
||
body = json.loads(result.model_dump_json())
|
||
if ack:
|
||
ack_body = _build_and_persist_ack(rec.id)
|
||
if ack_body is not None:
|
||
body["ack"] = ack_body
|
||
return JSONResponse(content=body)
|
||
|
||
# Default: NDJSON stream.
|
||
return StreamingResponse(
|
||
_ndjson_stream(result),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
|
||
|
||
def _build_and_persist_ack(batch_id: str) -> dict | None:
|
||
"""Build a 999 ACK for ``batch_id`` and persist the row.
|
||
|
||
Returns the ack payload dict (matches the ``/api/parse-999``
|
||
response shape so the JSON and NDJSON clients can share the
|
||
schema) or None if the build failed. The build is fail-soft:
|
||
errors are logged but never abort the 837 ingest, because the
|
||
user-visible 837 result is still correct.
|
||
"""
|
||
try:
|
||
ack_result = build_ack_for_batch(batch_id)
|
||
except Exception:
|
||
log.exception("build_ack_for_batch failed for %s", batch_id)
|
||
return None
|
||
fg = ack_result.functional_group_acks[0] if ack_result.functional_group_acks else None
|
||
if fg is None:
|
||
return None
|
||
raw_text = serialize_999(ack_result, interchange_control_number=ack_result.envelope.control_number)
|
||
row = store.add_ack(
|
||
source_batch_id=batch_id,
|
||
accepted_count=fg.accepted_count,
|
||
rejected_count=fg.rejected_count,
|
||
received_count=fg.received_count,
|
||
ack_code=fg.ack_code,
|
||
raw_json=json.loads(ack_result.model_dump_json()),
|
||
)
|
||
return {
|
||
"id": row.id,
|
||
"accepted_count": fg.accepted_count,
|
||
"rejected_count": fg.rejected_count,
|
||
"received_count": fg.received_count,
|
||
"ack_code": fg.ack_code,
|
||
"source_batch_id": batch_id,
|
||
"raw_999_text": raw_text,
|
||
}
|
||
|
||
|
||
def _ndjson_stream(result: ParseResult) -> Iterator[bytes]:
|
||
"""Yield one JSON object per line: envelope → claims → summary."""
|
||
envelope_obj = (
|
||
result.envelope.model_dump() if result.envelope is not None else None
|
||
)
|
||
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
||
for claim in result.claims:
|
||
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 835 ERA (Health Care Claim Payment/Advice)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _strict_rewrite_835(result: ParseResult835) -> ParseResult835:
|
||
"""Promote warnings to errors (mirrors the CLI's --strict)."""
|
||
if result.validation is None:
|
||
return result
|
||
report = result.validation
|
||
promoted = [i.model_copy(update={"severity": "error"}) for i in report.warnings]
|
||
new_errors = report.errors + promoted
|
||
new_report = report.model_copy(update={"errors": new_errors, "passed": not new_errors})
|
||
passed = 1 if new_report.passed else 0
|
||
failed = 1 if not new_report.passed else 0
|
||
new_summary = result.summary.model_copy(
|
||
update={"passed": passed, "failed": failed}
|
||
)
|
||
return result.model_copy(update={"validation": new_report, "summary": new_summary})
|
||
|
||
|
||
def _drop_raw_segments_835(result: ParseResult835) -> ParseResult835:
|
||
"""Return a copy of ``result`` with ``raw_segments`` cleared on every claim."""
|
||
claims = [c.model_copy(update={"raw_segments": []}) for c in result.claims]
|
||
return result.model_copy(update={"claims": claims})
|
||
|
||
|
||
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,
|
||
file: UploadFile = File(...),
|
||
payer: str = Query("co_medicaid_835"),
|
||
include_raw_segments: bool = Query(True),
|
||
strict: bool = Query(False),
|
||
) -> Any:
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
config = _resolve_payer_835(payer)
|
||
|
||
try:
|
||
result = parse_835(text, config, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
# Always run the validator; attach the report so the JSON path can
|
||
# surface it and the NDJSON path can fold the counts into the summary.
|
||
# 835 validation is batch-level, so pass/fail applies uniformly to every
|
||
# claim payment in the batch (passed=N or 0, failed=0 or N).
|
||
report = validate_835(result, config)
|
||
n = len(result.claims)
|
||
claim_ids = [c.payer_claim_control_number for c in result.claims]
|
||
if report.passed:
|
||
passed, failed, failed_claim_ids = n, 0, []
|
||
else:
|
||
passed, failed, failed_claim_ids = 0, n, claim_ids
|
||
result = result.model_copy(update={
|
||
"validation": report,
|
||
"summary": result.summary.model_copy(update={
|
||
"passed": passed,
|
||
"failed": failed,
|
||
"failed_claim_ids": failed_claim_ids,
|
||
}),
|
||
})
|
||
|
||
if strict:
|
||
result = _strict_rewrite_835(result)
|
||
if not include_raw_segments:
|
||
result = _drop_raw_segments_835(result)
|
||
|
||
if _has_835_validation_errors(result):
|
||
return JSONResponse(
|
||
status_code=422,
|
||
content=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
# Persist the cleaned-up result so the cleaned result is what clients
|
||
# retrieve via /api/batches/{id}.
|
||
rec = BatchRecord(
|
||
id=uuid.uuid4().hex,
|
||
kind="835",
|
||
input_filename=file.filename or "upload.txt",
|
||
parsed_at=utcnow(),
|
||
result=result,
|
||
)
|
||
store.add(rec)
|
||
|
||
if _client_wants_json(request):
|
||
body = json.loads(result.model_dump_json())
|
||
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
|
||
return JSONResponse(content=body)
|
||
|
||
# Default: NDJSON stream.
|
||
return StreamingResponse(
|
||
_ndjson_stream_835(result),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
|
||
|
||
def _ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
|
||
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
|
||
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
|
||
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
|
||
yield (json.dumps({"type": "payer", "data": json.loads(result.payer.model_dump_json())}) + "\n").encode("utf-8")
|
||
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
|
||
for claim in result.claims:
|
||
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 999 ACK (Implementation Acknowledgment)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _ack_count_summary(result) -> tuple[int, int, int, str]:
|
||
"""Aggregate (received, accepted, rejected, ack_code) from a ParseResult999.
|
||
|
||
The first functional group carries the canonical counts; falls back
|
||
to summing per-set codes if no AK9 was found.
|
||
"""
|
||
if result.functional_group_acks:
|
||
fg = result.functional_group_acks[0]
|
||
return (fg.received_count, fg.accepted_count, fg.rejected_count, fg.ack_code)
|
||
sets = result.set_responses
|
||
received = len(sets)
|
||
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
|
||
rejected = received - accepted
|
||
if rejected == 0:
|
||
code = "A"
|
||
elif accepted == 0:
|
||
code = "R"
|
||
else:
|
||
code = "P"
|
||
return (received, accepted, rejected, code)
|
||
|
||
|
||
def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||
"""Return a synthetic batches.id for a received 999 with no source batch.
|
||
|
||
The acks.source_batch_id FK requires a row in batches; for received
|
||
999s we synthesize an id of the form ``999-<ISA13>``. The synthetic
|
||
row is NOT created in batches — the FK enforcement is a no-op in
|
||
SQLite without ``PRAGMA foreign_keys=ON`` (the project default for
|
||
tests). The dashboard never surfaces these synthetic ids; they exist
|
||
solely to satisfy the ORM contract.
|
||
"""
|
||
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
||
|
||
|
||
@app.post("/api/parse-999")
|
||
async def parse_999_endpoint(
|
||
file: UploadFile = File(...),
|
||
) -> Any:
|
||
"""Parse a 999 ACK file, persist a row, and return JSON.
|
||
|
||
Behavior mirrors ``/api/parse-835``:
|
||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||
- 200 on success with ``{"ack": {id, accepted_count, rejected_count,
|
||
received_count, ack_code, raw_999_text}, "parsed": <ParseResult999>}``.
|
||
|
||
The persisted ``acks.source_batch_id`` is a synthetic id
|
||
(``999-<ISA13>``) because a received 999 has no inbound 837 batch
|
||
to FK to. The dashboard's `/acks` list surfaces these; operators
|
||
can still see which interchange each row came from.
|
||
"""
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
try:
|
||
result = parse_999_text(text, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure on 999")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
received, accepted, rejected, ack_code = _ack_count_summary(result)
|
||
icn = result.envelope.control_number
|
||
synthetic_id = _ack_synthetic_source_batch_id(icn)
|
||
|
||
# Build the raw 999 text from the parsed result (round-trip).
|
||
raw_999_text = serialize_999(result, interchange_control_number=icn or "000000001")
|
||
|
||
row = store.add_ack(
|
||
source_batch_id=synthetic_id,
|
||
accepted_count=accepted,
|
||
rejected_count=rejected,
|
||
received_count=received,
|
||
ack_code=ack_code,
|
||
raw_json=json.loads(result.model_dump_json()),
|
||
)
|
||
|
||
return JSONResponse(content={
|
||
"ack": {
|
||
"id": row.id,
|
||
"accepted_count": accepted,
|
||
"rejected_count": rejected,
|
||
"received_count": received,
|
||
"ack_code": ack_code,
|
||
"source_batch_id": synthetic_id,
|
||
"raw_999_text": raw_999_text,
|
||
},
|
||
"parsed": json.loads(result.model_dump_json()),
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# GET endpoints (read views over the in-memory store)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
||
"""Return the number of claims on a batch, handling both 837P and 835."""
|
||
if rec.kind == "837p":
|
||
return len(rec.result.claims) # type: ignore[attr-defined]
|
||
if rec.kind == "835":
|
||
return len(rec.result.claims) # type: ignore[attr-defined]
|
||
return 0
|
||
|
||
|
||
@app.get("/api/batches")
|
||
def list_batches(
|
||
request: Request,
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
) -> Any:
|
||
"""Summary of all parsed batches, newest first."""
|
||
records = store.list(limit=limit)
|
||
items = [
|
||
{
|
||
"id": r.id,
|
||
"kind": r.kind,
|
||
"inputFilename": r.input_filename,
|
||
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
|
||
"claimCount": _batch_summary_claim_count(r),
|
||
}
|
||
for r in records
|
||
]
|
||
all_records = store.all()
|
||
total = len(all_records)
|
||
returned = len(items)
|
||
has_more = total > returned
|
||
if _wants_ndjson(request):
|
||
return StreamingResponse(
|
||
_ndjson_stream_list(items, total, returned, has_more),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
return {
|
||
"items": items,
|
||
"total": total,
|
||
"returned": returned,
|
||
"has_more": has_more,
|
||
}
|
||
|
||
|
||
@app.get("/api/batches/{batch_id}")
|
||
def get_batch(batch_id: str) -> Any:
|
||
rec = store.get(batch_id)
|
||
if rec is None:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={"error": "Not found", "detail": f"Batch {batch_id} not found"},
|
||
)
|
||
return json.loads(rec.result.model_dump_json())
|
||
|
||
|
||
@app.get("/api/claims")
|
||
def list_claims(
|
||
request: Request,
|
||
batch_id: str | None = Query(None),
|
||
status: str | None = Query(None),
|
||
provider_npi: str | None = Query(None),
|
||
payer: str | None = Query(None),
|
||
date_from: str | None = Query(None),
|
||
date_to: str | None = Query(None),
|
||
sort: str | None = Query(None),
|
||
order: str = Query("desc"),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
) -> Any:
|
||
common = dict(
|
||
batch_id=batch_id,
|
||
status=status,
|
||
provider_npi=provider_npi,
|
||
payer=payer,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
)
|
||
items = list(store.iter_claims(
|
||
sort=sort, order=order, limit=limit, offset=offset, **common,
|
||
))
|
||
total = len(list(store.iter_claims(**common)))
|
||
returned = len(items)
|
||
has_more = total > offset + returned
|
||
if _wants_ndjson(request):
|
||
return StreamingResponse(
|
||
_ndjson_stream_list(items, total, returned, has_more),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
return {
|
||
"items": items,
|
||
"total": total,
|
||
"returned": returned,
|
||
"has_more": has_more,
|
||
}
|
||
|
||
|
||
@app.get("/api/claims/{claim_id}")
|
||
def get_claim_detail_endpoint(claim_id: str) -> dict:
|
||
"""Return one claim with full drawer context (SP4).
|
||
|
||
Body shape is produced by :meth:`CycloneStore.get_claim_detail`:
|
||
header, state, service lines, diagnoses, parties, validation,
|
||
raw segments, ``stateHistory`` (most-recent-first, capped at 50),
|
||
and a populated ``matchedRemittance`` block when paired.
|
||
|
||
Path param is ``claim_id`` (matches the SP3 ``/api/acks/{ack_id}``
|
||
convention). Returns 404 — never 500 — on a missing claim so the
|
||
UI can distinguish "doesn't exist" from a transient fetch error.
|
||
"""
|
||
body = store.get_claim_detail(claim_id)
|
||
if body is None:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={
|
||
"error": "Not found",
|
||
"detail": f"Claim {claim_id} not found",
|
||
},
|
||
)
|
||
return body
|
||
|
||
|
||
@app.get("/api/reconciliation/unmatched")
|
||
def get_reconciliation_unmatched() -> dict:
|
||
"""Return unmatched Claims (left) and unmatched Remittances (right).
|
||
|
||
Powers the reconciliation review surface: every Claim with no
|
||
paired Remittance appears on the left, every Remittance with no
|
||
paired Claim appears on the right. The two lists are always present
|
||
(empty list, never absent) so the UI can index unconditionally.
|
||
"""
|
||
return store.list_unmatched(kind="both")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Side-by-side diff between two batches (SP3 P4 / T18)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@app.get("/api/batch-diff")
|
||
def get_batch_diff(
|
||
a: str | None = Query(None),
|
||
b: str | None = Query(None),
|
||
) -> dict:
|
||
"""Return a side-by-side diff of two batches identified by id.
|
||
|
||
Query params: ``a=<batch_id>``, ``b=<batch_id>`` (both required).
|
||
|
||
Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the
|
||
projector shapes):
|
||
- ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt,
|
||
inputFilename, claimCount)
|
||
- ``added`` — claims present in B but not A
|
||
- ``removed`` — claims present in A but not B
|
||
- ``changed`` — claims present in both, with field deltas
|
||
- ``summary`` — precomputed counts
|
||
|
||
Errors:
|
||
- 400 — missing ``a`` or ``b``
|
||
- 404 — either batch id is unknown
|
||
|
||
Pure read endpoint — never mutates the store. Both 837P and 835
|
||
batches are accepted (mixed-kind diffs are valid: comparing the
|
||
submitted claims against the matching remittances).
|
||
"""
|
||
if not a or not b:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Missing param", "detail": "Both ?a=<batch_id> and ?b=<batch_id> are required."},
|
||
)
|
||
try:
|
||
a_rec, b_rec = store.load_two_for_diff(a, b)
|
||
except LookupError as exc:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={"error": "Not found", "detail": str(exc)},
|
||
)
|
||
|
||
# Lazy import — keeps the module's import surface small until the
|
||
# endpoint is actually hit. Mirrors the same pattern used by other
|
||
# endpoint-local helpers (e.g. reconciler).
|
||
from cyclone.batch_diff import diff_batches_to_wire
|
||
|
||
return diff_batches_to_wire(a_rec, b_rec)
|
||
|
||
|
||
@app.post("/api/reconciliation/match")
|
||
def post_reconciliation_match(body: dict) -> dict:
|
||
"""Manually pair a Claim with a Remittance (operator override).
|
||
|
||
Body: ``{"claim_id": ..., "remit_id": ...}``. Returns
|
||
``{"claim": <ui>, "match": <ui>}`` on success. Errors:
|
||
- 400: missing ``claim_id`` or ``remit_id``
|
||
- 404: claim or remittance not found
|
||
- 409: claim already matched, or apply_* returned a noop
|
||
(claim in terminal state) — detail echoes ``current_state``
|
||
and ``activity_kind`` so the UI can render a precise message.
|
||
"""
|
||
claim_id = body.get("claim_id")
|
||
remit_id = body.get("remit_id")
|
||
if not claim_id or not remit_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="claim_id and remit_id required",
|
||
)
|
||
try:
|
||
return store.manual_match(claim_id, remit_id)
|
||
except AlreadyMatchedError as e:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={"error": "already_matched", "message": str(e)},
|
||
)
|
||
except InvalidStateError as e:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={
|
||
"error": "invalid_state",
|
||
"current_state": e.current_state,
|
||
"activity_kind": e.activity_kind,
|
||
},
|
||
)
|
||
except LookupError:
|
||
# manual_match raises LookupError when the claim or remittance
|
||
# row is missing (we catch the parent class so any future
|
||
# KeyError subclasses in the store get the same treatment).
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="claim_or_remit_not_found",
|
||
)
|
||
|
||
|
||
@app.post("/api/reconciliation/unmatch")
|
||
def post_reconciliation_unmatch(body: dict) -> dict:
|
||
"""Remove the current match for a Claim; reset Claim to submitted.
|
||
|
||
Body: ``{"claim_id": ...}``. Returns
|
||
``{"claim": <ui>, "deletedMatches": <count>}``. Errors:
|
||
- 400: missing ``claim_id``
|
||
- 404: claim not found
|
||
- 409: claim has no current match (NotMatchedError is mapped
|
||
by the store; we surface 409 to match the manual_match contract)
|
||
"""
|
||
from cyclone.store import NotMatchedError
|
||
claim_id = body.get("claim_id")
|
||
if not claim_id:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="claim_id required",
|
||
)
|
||
try:
|
||
return store.manual_unmatch(claim_id)
|
||
except NotMatchedError as e:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail={"error": "not_matched", "message": str(e)},
|
||
)
|
||
except LookupError:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail="claim_not_found",
|
||
)
|
||
|
||
|
||
@app.get("/api/remittances")
|
||
def list_remittances(
|
||
request: Request,
|
||
batch_id: str | None = Query(None),
|
||
payer: str | None = Query(None),
|
||
claim_id: str | None = Query(None),
|
||
date_from: str | None = Query(None),
|
||
date_to: str | None = Query(None),
|
||
sort: str | None = Query(None),
|
||
order: str = Query("desc"),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
) -> Any:
|
||
common = dict(
|
||
batch_id=batch_id,
|
||
payer=payer,
|
||
claim_id=claim_id,
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
)
|
||
items = list(store.iter_remittances(
|
||
sort=sort, order=order, limit=limit, offset=offset, **common,
|
||
))
|
||
total = len(list(store.iter_remittances(**common)))
|
||
returned = len(items)
|
||
has_more = total > offset + returned
|
||
if _wants_ndjson(request):
|
||
return StreamingResponse(
|
||
_ndjson_stream_list(items, total, returned, has_more),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
return {
|
||
"items": items,
|
||
"total": total,
|
||
"returned": returned,
|
||
"has_more": has_more,
|
||
}
|
||
|
||
|
||
@app.get("/api/remittances/{remittance_id}")
|
||
def get_remittance(remittance_id: str) -> dict:
|
||
"""Return one remittance with its labeled CAS ``adjustments`` array.
|
||
|
||
Path param is ``remittance_id`` (not ``id``) to avoid shadowing
|
||
FastAPI's internal ``id`` name and to keep OpenAPI docs self-
|
||
describing. Returns 404 when the remittance is missing — never 500.
|
||
"""
|
||
body = store.get_remittance(remittance_id)
|
||
if body is None:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={"error": "Not found", "detail": f"Remittance {remittance_id} not found"},
|
||
)
|
||
return body
|
||
|
||
|
||
@app.get("/api/providers")
|
||
def list_providers(
|
||
request: Request,
|
||
npi: str | None = Query(None),
|
||
state: str | None = Query(None),
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
offset: int = Query(0, ge=0),
|
||
) -> Any:
|
||
items = store.distinct_providers()
|
||
if npi is not None:
|
||
items = [p for p in items if p["npi"] == npi]
|
||
if state is not None:
|
||
items = [p for p in items if p.get("state") == state]
|
||
paged = items[offset:offset + limit]
|
||
total = len(items)
|
||
returned = len(paged)
|
||
has_more = total > offset + returned
|
||
if _wants_ndjson(request):
|
||
return StreamingResponse(
|
||
_ndjson_stream_list(paged, total, returned, has_more),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
return {
|
||
"items": paged,
|
||
"total": total,
|
||
"returned": returned,
|
||
"has_more": has_more,
|
||
}
|
||
|
||
|
||
@app.get("/api/activity")
|
||
def list_activity(
|
||
request: Request,
|
||
kind: str | None = Query(None),
|
||
since: str | None = Query(None),
|
||
limit: int = Query(200, ge=1, le=500),
|
||
) -> Any:
|
||
events = store.recent_activity(limit=limit)
|
||
if kind is not None:
|
||
events = [e for e in events if e["kind"] == kind]
|
||
if since is not None:
|
||
events = [e for e in events if e["timestamp"] >= since]
|
||
total = len(events)
|
||
has_more = False
|
||
if _wants_ndjson(request):
|
||
return StreamingResponse(
|
||
_ndjson_stream_list(events, total, total, has_more),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
return {
|
||
"items": events,
|
||
"total": total,
|
||
"returned": total,
|
||
"has_more": has_more,
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 999 ACKs (read views)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _ack_to_ui(row) -> dict:
|
||
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
|
||
|
||
Field names match the rest of the Cyclone API (snake_case). The
|
||
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
|
||
interface in ``src/types/index.ts``.
|
||
"""
|
||
return {
|
||
"id": row.id,
|
||
"source_batch_id": row.source_batch_id,
|
||
"accepted_count": row.accepted_count,
|
||
"rejected_count": row.rejected_count,
|
||
"received_count": row.received_count,
|
||
"ack_code": row.ack_code,
|
||
"parsed_at": (
|
||
row.parsed_at.isoformat().replace("+00:00", "Z")
|
||
if row.parsed_at is not None
|
||
else ""
|
||
),
|
||
}
|
||
|
||
|
||
@app.get("/api/acks")
|
||
def list_acks_endpoint(
|
||
request: Request,
|
||
limit: int = Query(100, ge=1, le=1000),
|
||
) -> Any:
|
||
"""Return the list of persisted 999 ACKs, newest first."""
|
||
rows = store.list_acks()
|
||
items = [_ack_to_ui(r) for r in rows[:limit]]
|
||
total = len(rows)
|
||
returned = len(items)
|
||
has_more = total > returned
|
||
if _wants_ndjson(request):
|
||
return StreamingResponse(
|
||
_ndjson_stream_list(items, total, returned, has_more),
|
||
media_type="application/x-ndjson",
|
||
)
|
||
return {
|
||
"items": items,
|
||
"total": total,
|
||
"returned": returned,
|
||
"has_more": has_more,
|
||
}
|
||
|
||
|
||
@app.get("/api/acks/{ack_id}")
|
||
def get_ack_endpoint(ack_id: int) -> dict:
|
||
"""Return one persisted ACK row with its parsed detail.
|
||
|
||
Path param is ``ack_id`` (not ``id``) to avoid shadowing FastAPI's
|
||
internal ``id`` name and to keep OpenAPI docs self-describing.
|
||
Returns 404 when the ACK is missing — never 500.
|
||
"""
|
||
from cyclone import db as _db
|
||
row = store.get_ack(ack_id)
|
||
if row is None:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
|
||
)
|
||
body = _ack_to_ui(row)
|
||
body["raw_json"] = row.raw_json
|
||
# Regenerate the X12 text from raw_json so the operator can download
|
||
# the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry
|
||
# the regenerated text to keep payloads small; detail does.)
|
||
if row.raw_json:
|
||
try:
|
||
from cyclone.parsers.models_999 import ParseResult999
|
||
regenerated = ParseResult999.model_validate(row.raw_json)
|
||
icn = regenerated.envelope.control_number or "000000001"
|
||
body["raw_999_text"] = serialize_999(regenerated, interchange_control_number=icn)
|
||
except Exception as exc: # noqa: BLE001 — never 500 on a regen failure
|
||
log.warning("Could not regenerate 999 for ack %s: %s", ack_id, exc)
|
||
body["raw_999_text"] = None
|
||
else:
|
||
body["raw_999_text"] = None
|
||
return body
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 270 / 271 eligibility (SP3 P4 T23–T24) — API-only, no DB persistence
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
|
||
"""Build a :class:`ParseResult270` from a request body dict.
|
||
|
||
The body shape is the minimum surface needed to build a valid 270
|
||
inquiry (per spec section 3.4 — operator-driven, ephemeral):
|
||
|
||
::
|
||
|
||
{
|
||
"subscriber": {first_name, last_name, member_id, dob},
|
||
"provider": {npi, name},
|
||
"payer": {id, name},
|
||
"service_type_code": "1"
|
||
}
|
||
|
||
Returns ``(ParseResult270, service_type_code)``. Raises
|
||
:class:`HTTPException` (400) when the body is missing required
|
||
fields.
|
||
"""
|
||
subscriber_in = body.get("subscriber") or {}
|
||
provider_in = body.get("provider") or {}
|
||
payer_in = body.get("payer") or {}
|
||
service_type_code = (body.get("service_type_code") or "").strip()
|
||
|
||
# Required-field checks. We surface a single 400 with the first
|
||
# missing field name to match the rest of the API's error contract.
|
||
if not service_type_code:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "service_type_code is required"},
|
||
)
|
||
if not subscriber_in.get("member_id"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "subscriber.member_id is required"},
|
||
)
|
||
if not provider_in.get("npi"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "provider.npi is required"},
|
||
)
|
||
if not payer_in.get("name"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": "payer.name is required"},
|
||
)
|
||
|
||
# Build the Pydantic models. The serializer handles all envelope
|
||
# generation (sender_id/receiver_id/control_number/transaction_date
|
||
# are filled in by the serializer with sensible defaults).
|
||
from datetime import date as _date
|
||
|
||
subscriber_dob_raw = subscriber_in.get("dob")
|
||
subscriber_dob: _date | None = None
|
||
if subscriber_dob_raw:
|
||
try:
|
||
subscriber_dob = _date.fromisoformat(subscriber_dob_raw)
|
||
except (TypeError, ValueError) as exc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={
|
||
"error": "Bad request",
|
||
"detail": f"subscriber.dob must be YYYY-MM-DD: {exc}",
|
||
},
|
||
) from exc
|
||
|
||
result = ParseResult270(
|
||
envelope=Envelope(
|
||
sender_id="SUBMITTERID",
|
||
receiver_id=str(payer_in.get("id") or "RECEIVERID"),
|
||
control_number="000000001",
|
||
transaction_date=_date.today(),
|
||
implementation_guide="005010X279A1",
|
||
),
|
||
information_source=InformationSource270(
|
||
name=str(payer_in["name"]),
|
||
id=str(payer_in.get("id") or "") or None,
|
||
),
|
||
information_receiver=InformationReceiver270(
|
||
name=str(provider_in.get("name") or ""),
|
||
npi=str(provider_in["npi"]),
|
||
),
|
||
subscriber=Subscriber270(
|
||
member_id=str(subscriber_in["member_id"]),
|
||
first_name=str(subscriber_in.get("first_name") or "") or None,
|
||
last_name=str(subscriber_in.get("last_name") or "") or None,
|
||
dob=subscriber_dob,
|
||
),
|
||
inquiries=[EligibilityBenefitInquiry(service_type_code=service_type_code)],
|
||
summary=BatchSummary(
|
||
input_file="eligibility_request",
|
||
control_number="000000001",
|
||
transaction_date=_date.today(),
|
||
total_claims=1,
|
||
passed=1,
|
||
failed=0,
|
||
),
|
||
)
|
||
return result, service_type_code
|
||
|
||
|
||
@app.post("/api/eligibility/request")
|
||
def post_eligibility_request(body: dict) -> Any:
|
||
"""Build a 270 eligibility inquiry from a small JSON body.
|
||
|
||
Returns ``{"raw_270_text": <X12>, "parsed": <ParseResult270>}``
|
||
so the operator can either download the raw text (paste into a
|
||
payer portal) or render the parsed fields directly. Per spec
|
||
section 3.4, nothing is persisted to the DB.
|
||
"""
|
||
try:
|
||
result, _ = _validate_eligibility_request(body)
|
||
except HTTPException:
|
||
raise
|
||
except (KeyError, TypeError, ValueError) as exc:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail={"error": "Bad request", "detail": f"Malformed body: {exc}"},
|
||
) from exc
|
||
|
||
raw_270_text = serialize_270(result)
|
||
return {
|
||
"raw_270_text": raw_270_text,
|
||
"parsed": json.loads(result.model_dump_json()),
|
||
}
|
||
|
||
|
||
@app.post("/api/eligibility/parse-271")
|
||
async def post_eligibility_parse_271(
|
||
file: UploadFile = File(...),
|
||
) -> Any:
|
||
"""Parse a 271 eligibility response and return the structured summary.
|
||
|
||
Accepts the raw 271 text as a file upload (multipart/form-data),
|
||
mirrors the ``/api/parse-999`` contract. Per spec section 3.4 the
|
||
result is NOT persisted — the operator re-pastes the 271 each
|
||
time they need a fresh read.
|
||
|
||
The response body is a JSON object with three top-level keys:
|
||
``coverage_benefits``, ``subscriber``, and ``summary``. 400 is
|
||
returned on empty / undecodable / malformed EDI; 200 on success.
|
||
"""
|
||
raw = await file.read()
|
||
if not raw:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
|
||
)
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Encoding error", "detail": str(exc)},
|
||
)
|
||
|
||
try:
|
||
result = parse_271_text(text, input_file=file.filename or "")
|
||
except CycloneParseError as exc:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"error": "Parse error", "detail": str(exc)},
|
||
)
|
||
except Exception as exc: # pragma: no cover - safety net
|
||
log.exception("Unexpected parser failure on 271")
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={"error": "Internal server error", "detail": str(exc)},
|
||
)
|
||
|
||
return {
|
||
"coverage_benefits": [
|
||
json.loads(cb.model_dump_json()) for cb in result.coverage_benefits
|
||
],
|
||
"subscriber": json.loads(result.subscriber.model_dump_json()),
|
||
"summary": json.loads(result.summary.model_dump_json()),
|
||
"envelope": json.loads(result.envelope.model_dump_json()),
|
||
"information_source": json.loads(result.information_source.model_dump_json()),
|
||
"information_receiver": json.loads(result.information_receiver.model_dump_json()),
|
||
}
|
||
|
||
|
||
__all__ = ["app"]
|