feat(backend): wire parse-837/parse-835 to persist to InMemoryStore
This commit is contained in:
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||||
@@ -32,6 +33,7 @@ from cyclone.parsers.payer import PayerConfig, PayerConfig835
|
|||||||
from cyclone.parsers.parse_837 import parse
|
from cyclone.parsers.parse_837 import parse
|
||||||
from cyclone.parsers.parse_835 import parse as parse_835
|
from cyclone.parsers.parse_835 import parse as parse_835
|
||||||
from cyclone.parsers.validator_835 import validate as validate_835
|
from cyclone.parsers.validator_835 import validate as validate_835
|
||||||
|
from cyclone.store import BatchRecord, store, utcnow
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -195,11 +197,24 @@ async def parse_837(
|
|||||||
if _has_claim_validation_errors(result):
|
if _has_claim_validation_errors(result):
|
||||||
# Per spec: 422 when claims failed validation.
|
# Per spec: 422 when claims failed validation.
|
||||||
# Body still includes the full ParseResult so the client can show errors.
|
# 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(
|
return JSONResponse(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
content=json.loads(result.model_dump_json()),
|
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):
|
if _client_wants_json(request):
|
||||||
return JSONResponse(content=json.loads(result.model_dump_json()))
|
return JSONResponse(content=json.loads(result.model_dump_json()))
|
||||||
|
|
||||||
@@ -321,6 +336,17 @@ async def parse_835_endpoint(
|
|||||||
content=json.loads(result.model_dump_json()),
|
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):
|
if _client_wants_json(request):
|
||||||
return JSONResponse(content=json.loads(result.model_dump_json()))
|
return JSONResponse(content=json.loads(result.model_dump_json()))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Successful parses must persist to the store; failed parses must not."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.store import store as global_store
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_store():
|
||||||
|
"""Reset the module-level store before and after each test."""
|
||||||
|
with global_store._lock:
|
||||||
|
global_store._batches.clear()
|
||||||
|
yield
|
||||||
|
with global_store._lock:
|
||||||
|
global_store._batches.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_parse_837_creates_batch(client: TestClient):
|
||||||
|
assert len(global_store.list()) == 0
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("test.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert len(global_store.list()) == 1
|
||||||
|
rec = global_store.list()[0]
|
||||||
|
assert rec.kind == "837p"
|
||||||
|
assert rec.input_filename == "test.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_parse_837_does_not_create_batch(client: TestClient):
|
||||||
|
"""An empty / garbage file should NOT create a batch."""
|
||||||
|
assert len(global_store.list()) == 0
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("garbage.txt", "not-edi", "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
# Either a 4xx from the parser, or a 200 with 0 claims — the contract
|
||||||
|
# is that NO batch is added either way.
|
||||||
|
assert resp.status_code in (200, 400, 422)
|
||||||
|
assert len(global_store.list()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_failed_837_does_not_create_batch(client: TestClient):
|
||||||
|
"""A parse that produced claims with validation errors (422) must not be stored.
|
||||||
|
|
||||||
|
The spec only mandates "do not store on CycloneParseError or unhandled
|
||||||
|
exception"; we choose the stricter interpretation that 422 validation
|
||||||
|
failures are also excluded so the data on disk is always clean.
|
||||||
|
"""
|
||||||
|
# Construct a minimal but technically malformed 837P that the parser
|
||||||
|
# will accept but validation will reject. We inject a wrong NPI
|
||||||
|
# length to trigger the per-claim NPI validation rule.
|
||||||
|
text = FIXTURE.read_text()
|
||||||
|
# Replace the provider NPI (XX*1881068062) with an invalid 8-digit NPI
|
||||||
|
# (the rule is NPI must be 10 digits). This keeps the parse step
|
||||||
|
# green while failing claim validation.
|
||||||
|
text = text.replace("XX*1881068062", "XX*12345678")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("bad.txt", text, "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, resp.text
|
||||||
|
assert len(global_store.list()) == 0
|
||||||
Reference in New Issue
Block a user