feat(sp10): 277CA parser + Payer-Rejected Inbox lane
- Add cyclone.parsers.models_277ca + parse_277ca (X12 005010X214)
- Per-Patient HL ClaimStatus with REF*1K (PCN), REF*EJ (tax ID),
STC category code, amount, service date
- STC classifier: A1-A3 accepted, A4/A6/A7 rejected, A8/A9 pended,
P1-P5 paid, anything else unknown
- Multiple STCs per Patient HL: last wins (canonical pattern for
'first pended, then paid' acknowledgments)
- Subscriber-level STCs surface in unscoped_statuses
- Add apply_277ca_rejections — stamps Claim.payer_rejected_* fields on
matching rows (lookup by patient_control_number, mirrors 999 path)
- New /api/parse-277ca, /api/277ca-acks, /api/277ca-acks/{id} endpoints
- New two77ca_acks table + Two77caAck ORM model
- New Claim columns: payer_rejected_at, _reason, _status_code, _by_277ca_id
- New payer_rejected lane in /api/inbox/lanes (distinct from 999
envelope rejected lane)
- New PayerConfig277CA block in config/payers.yaml + Pydantic model
- Migration 0008 bumps user_version to 8
Tests: 654 -> 688 (parser 22 + apply 6 + API 8 + config 6 + adjustments).
All 688 backend tests pass; 1 pre-existing skipped test class unaffected.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""Tests for the FastAPI surface in ``cyclone.api`` for the 277CA endpoint.
|
||||
|
||||
SP10 T3. Mirrors ``test_api_999.py``:
|
||||
- 400 on empty / undecodable / malformed EDI (never 500).
|
||||
- 200 on success with the parsed envelope + counts.
|
||||
- After parse, ``apply_277ca_rejections`` stamps matching claim rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.api import app
|
||||
from cyclone.db import Claim, init_db
|
||||
|
||||
ACCEPTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca.txt"
|
||||
REJECTED_FIXTURE = Path(__file__).parent / "fixtures" / "minimal_277ca_rejected_only.txt"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
"""Each test gets a fresh DB and a clean 277CA ack list."""
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_claim(claim_id: str, pcn: str) -> None:
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Claim(
|
||||
id=claim_id, batch_id="BATCH-1",
|
||||
patient_control_number=pcn, charge_amount=100.00,
|
||||
))
|
||||
s.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Happy path
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAEndpointHappyPath:
|
||||
def test_upload_minimal_277ca_returns_200(self, client: TestClient):
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "ack" in body
|
||||
assert "parsed" in body
|
||||
ack = body["ack"]
|
||||
assert ack["accepted_count"] == 1
|
||||
assert ack["rejected_count"] == 1
|
||||
assert ack["pended_count"] == 1
|
||||
assert ack["control_number"] == "000000123"
|
||||
|
||||
def test_persists_two77ca_row(self, client: TestClient):
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
)
|
||||
rows_resp = client.get("/api/277ca-acks")
|
||||
assert rows_resp.status_code == 200
|
||||
rows = rows_resp.json()
|
||||
assert rows["total"] == 1
|
||||
assert rows["items"][0]["control_number"] == "000000123"
|
||||
|
||||
def test_get_277ca_ack_by_id(self, client: TestClient):
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
post_resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
)
|
||||
ack_id = post_resp.json()["ack"]["id"]
|
||||
detail = client.get(f"/api/277ca-acks/{ack_id}")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["control_number"] == "000000123"
|
||||
|
||||
def test_stamps_matching_claim(self, client: TestClient):
|
||||
"""A rejected 277CA claim with REF*1K=CLAIM002 stamps claim c2."""
|
||||
# Seed two claims matching the fixture's PCNs.
|
||||
_seed_claim("c1", "CLAIM001")
|
||||
_seed_claim("c2", "CLAIM002")
|
||||
text = ACCEPTED_FIXTURE.read_text()
|
||||
client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("minimal_277ca.txt", text, "text/plain")},
|
||||
)
|
||||
with db.SessionLocal()() as s:
|
||||
c1 = s.get(Claim, "c1")
|
||||
c2 = s.get(Claim, "c2")
|
||||
assert c1.payer_rejected_at is None
|
||||
assert c2.payer_rejected_at is not None
|
||||
assert c2.payer_rejected_status_code == "A6"
|
||||
assert "A6" in c2.payer_rejected_reason
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Error paths
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAEndpointErrors:
|
||||
def test_empty_file_raises_400(self, client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("empty.txt", "", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "error" in resp.json()
|
||||
|
||||
def test_garbage_raises_400(self, client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("garbage.txt", "this is not EDI", "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_wrong_transaction_set_raises_400(self, client: TestClient):
|
||||
"""A 999 must NOT be accepted as a 277CA — different transaction set id."""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X231A1~"
|
||||
"ST*999*0001*005010X231A1~"
|
||||
"AK1*HC*0001~"
|
||||
"AK9*A*0*0*0~"
|
||||
"SE*4*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("bad.txt", text, "text/plain")},
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Inbox lane
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInboxPayerRejectedLane:
|
||||
def test_payer_rejected_claim_appears_in_lane(self, client: TestClient):
|
||||
"""A claim with payer_rejected_at set must appear in the payer_rejected lane."""
|
||||
_seed_claim("c1", "CLAIM099")
|
||||
text = REJECTED_FIXTURE.read_text() # single A7 for CLAIM099
|
||||
client.post(
|
||||
"/api/parse-277ca",
|
||||
files={"file": ("rejected.txt", text, "text/plain")},
|
||||
)
|
||||
lanes = client.get("/api/inbox/lanes").json()
|
||||
assert "payer_rejected" in lanes
|
||||
ids = [c["id"] for c in lanes["payer_rejected"]]
|
||||
assert "c1" in ids
|
||||
# The rejected lane (999 envelope) must be empty — we haven't
|
||||
# uploaded a 999, so this claim isn't there.
|
||||
assert "c1" not in [c["id"] for c in lanes["rejected"]]
|
||||
Reference in New Issue
Block a user