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:
+42
@@ -0,0 +1,42 @@
|
||||
ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000123*0*P*:~
|
||||
GS*HN*COMEDICAID*DZINESCO*20240620*1200*1*X*005010X214~
|
||||
ST*277CA*0001*005010X214~
|
||||
BHT*0085*08*REFNUM001*20240620*1200*TH~
|
||||
HL*1**20*1~
|
||||
NM1*PR*2*COLORADO MEDICAL ASSIST*****PI*COMEDICAID~
|
||||
TRN*2*REFNUM001~
|
||||
DTP*050*RD8*20240601-20240630~
|
||||
DTP*009*RD8*20240601-20240630~
|
||||
HL*2*1*21*1~
|
||||
NM1*41*2*DZINESCO*****46*11525703~
|
||||
TRN*2*REFNUM001~
|
||||
HL*3*2*19*1~
|
||||
NM1*IL*1*DOE*JOHN****MI*MEMBERID001~
|
||||
TRN*2*TRACE001~
|
||||
HL*4*3*PT~
|
||||
NM1*QC*1*DOE*JANE~
|
||||
REF*1K*CLAIM001~
|
||||
REF*EJ*721587149~
|
||||
STC*A3:19:PR*20240620*WQ*100.00~
|
||||
QTY*90*1~
|
||||
AMT*YU*100.00~
|
||||
DTP*472*RD8*20240615-20240615~
|
||||
HL*5*3*PT~
|
||||
NM1*QC*1*SMITH*ROBERT~
|
||||
REF*1K*CLAIM002~
|
||||
REF*EJ*721587149~
|
||||
STC*A6:19:PR*20240620*U*250.00~
|
||||
QTY*90*1~
|
||||
AMT*YU*250.00~
|
||||
DTP*472*D8*20240610~
|
||||
HL*6*3*PT~
|
||||
NM1*QC*1*GARCIA*MARIA~
|
||||
REF*1K*CLAIM003~
|
||||
REF*EJ*721587149~
|
||||
STC*A8:19:PR*20240620*U*175.50~
|
||||
QTY*90*1~
|
||||
AMT*YU*175.50~
|
||||
DTP*472*RD8*20240612-20240612~
|
||||
SE*40*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000123~
|
||||
@@ -0,0 +1,14 @@
|
||||
ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000789*0*P*:~
|
||||
GS*HN*COMEDICAID*DZINESCO*20240620*1200*3*X*005010X214~
|
||||
ST*277CA*0001*005010X214~
|
||||
BHT*0085*08*REFNUM003*20240620*1200*TH~
|
||||
HL*1**20*1~
|
||||
HL*2*1*21*1~
|
||||
HL*3*2*19*1~
|
||||
HL*4*3*PT~
|
||||
REF*1K*CLAIM099~
|
||||
REF*EJ*721587149~
|
||||
STC*A7:19:PR*20240620*U*99.99~
|
||||
SE*8*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000789~
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
ISA*00* *00* *ZZ*COMEDICAID *ZZ*DZINESCO *240620*1200*^*00501*000000456*0*P*:~
|
||||
GS*HN*COMEDICAID*DZINESCO*20240620*1200*2*X*005010X214~
|
||||
ST*277*0001*005010X214~
|
||||
BHT*0085*08*REFNUM002*20240620*1200*TH~
|
||||
HL*1**20*1~
|
||||
NM1*PR*2*COLORADO MEDICAL ASSIST*****PI*COMEDICAID~
|
||||
HL*2*1*21*1~
|
||||
HL*3*2*19*1~
|
||||
HL*4*3*PT~
|
||||
REF*1K*CLAIM004~
|
||||
REF*EJ*721587149~
|
||||
STC*A3:19:PR*20240620*WQ*300.00~
|
||||
SE*10*0001~
|
||||
GE*1*1~
|
||||
IEA*1*000000456~
|
||||
@@ -51,17 +51,17 @@ def test_migration_0002_creates_acks_table():
|
||||
|
||||
def test_migration_latest_idempotent_on_fresh_db():
|
||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||
user_version already at the latest version — currently 7 after
|
||||
0004-0006 line_reconciliation, 0005 ta1_acks, and SP9's 0007
|
||||
providers/payers/clearhouse)."""
|
||||
user_version already at the latest version — currently 8 after
|
||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||
providers/payers/clearhouse, and SP10's 0008 payer_rejected)."""
|
||||
with db.engine().begin() as c:
|
||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v1 == 7
|
||||
assert v1 == 8
|
||||
# A second run should not raise and should not bump the version.
|
||||
db_migrate.run(db.engine())
|
||||
with db.engine().begin() as c:
|
||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v2 == 7
|
||||
assert v2 == 8
|
||||
|
||||
|
||||
def test_add_ack_persists_row():
|
||||
|
||||
@@ -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"]]
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Tests for :func:`cyclone.inbox_state_277ca.apply_277ca_rejections`.
|
||||
|
||||
SP10 T2. The 277CA's STC A4/A6/A7 codes stamp payer-rejection fields
|
||||
on matching claim rows. Distinct from the 999 envelope rejection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.db import Claim, init_db
|
||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||
from cyclone.parsers.models_277ca import ClaimStatus, ParseResult277CA
|
||||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_db():
|
||||
"""Each test gets a fresh in-memory DB."""
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_claim(session, *, claim_id: str, pcn: str = "CLAIM001") -> Claim:
|
||||
c = Claim(
|
||||
id=claim_id,
|
||||
batch_id="BATCH-1",
|
||||
patient_control_number=pcn,
|
||||
charge_amount=100.00,
|
||||
)
|
||||
session.add(c)
|
||||
session.commit()
|
||||
session.refresh(c)
|
||||
return c
|
||||
|
||||
|
||||
def _make_rejected_status(pcn: str | None = "CLAIM001") -> ClaimStatus:
|
||||
return ClaimStatus(
|
||||
status_code="A6",
|
||||
status_description="19",
|
||||
entity_identifier="PR",
|
||||
classification="rejected",
|
||||
payer_claim_control_number=pcn,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tests
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestApply277CARejectionsHappyPath:
|
||||
def test_rejected_status_stamps_matching_claim(self):
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
claim = _make_claim(s, claim_id="c1")
|
||||
pcn = claim.patient_control_number
|
||||
result = parse_277ca_text(_minimal_277ca_one_rejected())
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(pcn_q):
|
||||
return s.query(Claim).filter_by(patient_control_number=pcn_q).first()
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == ["c1"]
|
||||
assert outcome.orphans == []
|
||||
with db.SessionLocal()() as s:
|
||||
c = s.get(Claim, "c1")
|
||||
assert c.payer_rejected_at is not None
|
||||
assert c.payer_rejected_status_code == "A6"
|
||||
assert "A6" in (c.payer_rejected_reason or "")
|
||||
assert c.payer_rejected_by_277ca_id == "ACK-1"
|
||||
|
||||
|
||||
class TestApply277CARejectionsOrphans:
|
||||
def test_unknown_pcn_becomes_orphan(self):
|
||||
from cyclone import db
|
||||
# No claim exists — PCN won't match.
|
||||
text = _minimal_277ca_one_rejected()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return None
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == []
|
||||
assert outcome.orphans == ["CLAIM001"]
|
||||
|
||||
def test_status_without_ref_1k_becomes_orphan(self):
|
||||
"""A rejected STC with no REF*1K cannot match a claim."""
|
||||
from cyclone import db
|
||||
text = _minimal_277ca_no_ref1k()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return None
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == []
|
||||
# The orphan entry uses the status code (since PCN is missing).
|
||||
assert outcome.orphans == ["A6"]
|
||||
|
||||
|
||||
class TestApply277CARejectionsIdempotent:
|
||||
def test_already_stamped_is_not_overwritten(self):
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
_make_claim(s, claim_id="c1")
|
||||
text = _minimal_277ca_one_rejected()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return s.query(Claim).filter_by(patient_control_number="CLAIM001").first()
|
||||
outcome1 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome1.matched == ["c1"]
|
||||
original_reason = s.get(Claim, "c1").payer_rejected_reason
|
||||
original_at = s.get(Claim, "c1").payer_rejected_at
|
||||
# Run again with same code.
|
||||
outcome2 = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome2.matched == []
|
||||
assert outcome2.already_rejected == ["c1"]
|
||||
with db.SessionLocal()() as s:
|
||||
c = s.get(Claim, "c1")
|
||||
# Reason and timestamp unchanged.
|
||||
assert c.payer_rejected_reason == original_reason
|
||||
assert c.payer_rejected_at == original_at
|
||||
|
||||
|
||||
class TestApply277CAOnlyRejectsRejected:
|
||||
def test_accepted_status_does_not_stamp(self):
|
||||
"""An A3 (accepted) status must NOT trigger a payer_rejected stamp."""
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
_make_claim(s, claim_id="c1")
|
||||
text = _minimal_277ca_one_accepted()
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(_):
|
||||
return s.query(Claim).filter_by(patient_control_number="CLAIM001").first()
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == []
|
||||
with db.SessionLocal()() as s:
|
||||
c = s.get(Claim, "c1")
|
||||
assert c.payer_rejected_at is None
|
||||
|
||||
|
||||
class TestApply277CAMultipleStatuses:
|
||||
def test_mixed_batch_only_stamps_rejected(self):
|
||||
"""Of three statuses (A3/A6/A8), only the A6 claim gets stamped."""
|
||||
from cyclone import db
|
||||
with db.SessionLocal()() as s:
|
||||
_make_claim(s, claim_id="c1", pcn="CLAIM001")
|
||||
_make_claim(s, claim_id="c2", pcn="CLAIM002")
|
||||
_make_claim(s, claim_id="c3", pcn="CLAIM003")
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM001~"
|
||||
"STC*A3:19:PR*20240620*WQ*100.00~"
|
||||
"HL*5*3*PT~"
|
||||
"REF*1K*CLAIM002~"
|
||||
"STC*A6:19:PR*20240620*U*250.00~"
|
||||
"HL*6*3*PT~"
|
||||
"REF*1K*CLAIM003~"
|
||||
"STC*A8:19:PR*20240620*U*175.00~"
|
||||
"SE*16*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse_277ca_text(text)
|
||||
with db.SessionLocal()() as s:
|
||||
def _lookup(pcn):
|
||||
return s.query(Claim).filter_by(patient_control_number=pcn).first()
|
||||
outcome = apply_277ca_rejections(s, result, claim_lookup=_lookup, two77ca_id="ACK-1")
|
||||
assert outcome.matched == ["c2"]
|
||||
with db.SessionLocal()() as s:
|
||||
assert s.get(Claim, "c1").payer_rejected_at is None
|
||||
assert s.get(Claim, "c2").payer_rejected_at is not None
|
||||
assert s.get(Claim, "c3").payer_rejected_at is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test fixtures
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _minimal_277ca_one_rejected() -> str:
|
||||
return (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM001~"
|
||||
"STC*A6:19:PR*20240620*U*100.00~"
|
||||
"SE*9*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
|
||||
|
||||
def _minimal_277ca_one_accepted() -> str:
|
||||
return (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM001~"
|
||||
"STC*A3:19:PR*20240620*WQ*100.00~"
|
||||
"SE*9*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
|
||||
|
||||
def _minimal_277ca_no_ref1k() -> str:
|
||||
"""Patient HL with STC A6 but no REF*1K."""
|
||||
return (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"STC*A6:19:PR*20240620*U*100.00~"
|
||||
"SE*8*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
@@ -47,11 +47,14 @@ def _seed_batch() -> None:
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_lanes_endpoint_returns_four_keys(client: TestClient):
|
||||
def test_lanes_endpoint_returns_five_keys(client: TestClient):
|
||||
"""SP10 added the payer_rejected lane (distinct from 999 envelope rejection)."""
|
||||
r = client.get("/api/inbox/lanes")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body.keys()) == {"rejected", "candidates", "unmatched", "done_today"}
|
||||
assert set(body.keys()) == {
|
||||
"rejected", "payer_rejected", "candidates", "unmatched", "done_today",
|
||||
}
|
||||
for v in body.values():
|
||||
assert isinstance(v, list)
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Tests for the 277CA Claim Acknowledgment parser.
|
||||
|
||||
SP10 T1. The 277CA is the semantic claim-level ack from the payer —
|
||||
the parser must walk the HL hierarchy and capture one ClaimStatus
|
||||
per Patient HL, with REF*1K (payer_claim_control_number) and the
|
||||
STC category code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.models_277ca import (
|
||||
ACCEPTED_CODES,
|
||||
PAID_CODES,
|
||||
PENDED_CODES,
|
||||
REJECTED_CODES,
|
||||
ParseResult277CA,
|
||||
classify_status_code,
|
||||
)
|
||||
from cyclone.parsers.parse_277ca import parse_277ca_text
|
||||
|
||||
FIXTURE_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Status-code classification
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestClassifyStatusCode:
|
||||
def test_accepted_codes(self):
|
||||
for code in ACCEPTED_CODES:
|
||||
assert classify_status_code(code) == "accepted"
|
||||
|
||||
def test_rejected_codes(self):
|
||||
for code in REJECTED_CODES:
|
||||
assert classify_status_code(code) == "rejected"
|
||||
|
||||
def test_pended_codes(self):
|
||||
for code in PENDED_CODES:
|
||||
assert classify_status_code(code) == "pended"
|
||||
|
||||
def test_paid_codes(self):
|
||||
for code in PAID_CODES:
|
||||
assert classify_status_code(code) == "paid"
|
||||
|
||||
def test_unknown_code_does_not_raise(self):
|
||||
"""Unknown codes (e.g. payer-specific) surface as 'unknown', not error."""
|
||||
assert classify_status_code("ZZ") == "unknown"
|
||||
assert classify_status_code("") == "unknown"
|
||||
assert classify_status_code(" ") == "unknown"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert classify_status_code("a3") == "accepted"
|
||||
assert classify_status_code("A6") == "rejected"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: minimal happy path with mixed statuses
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAMixed:
|
||||
@pytest.fixture
|
||||
def parsed(self) -> ParseResult277CA:
|
||||
text = (FIXTURE_DIR / "minimal_277ca.txt").read_text()
|
||||
return parse_277ca_text(text, input_file="minimal_277ca.txt")
|
||||
|
||||
def test_envelope_control_number(self, parsed):
|
||||
assert parsed.envelope.control_number == "000000123"
|
||||
assert parsed.envelope.implementation_guide == "005010X214"
|
||||
|
||||
def test_bht_captures_reference_and_date(self, parsed):
|
||||
assert parsed.bht.hierarchical_structure_code == "0085"
|
||||
assert parsed.bht.transaction_set_purpose_code == "08"
|
||||
assert parsed.bht.reference_identification == "REFNUM001"
|
||||
assert parsed.bht.transaction_set_creation_date == date(2024, 6, 20)
|
||||
assert parsed.bht.transaction_type_code == "TH"
|
||||
|
||||
def test_three_patient_hls_three_statuses(self, parsed):
|
||||
assert len(parsed.claim_statuses) == 3
|
||||
|
||||
def test_first_status_is_accepted(self, parsed):
|
||||
s = parsed.claim_statuses[0]
|
||||
assert s.payer_claim_control_number == "CLAIM001"
|
||||
assert s.billing_provider_tax_id == "721587149"
|
||||
assert s.status_code == "A3"
|
||||
assert s.classification == "accepted"
|
||||
assert s.total_claim_charge_amount == 100.00
|
||||
|
||||
def test_second_status_is_rejected(self, parsed):
|
||||
s = parsed.claim_statuses[1]
|
||||
assert s.payer_claim_control_number == "CLAIM002"
|
||||
assert s.status_code == "A6"
|
||||
assert s.classification == "rejected"
|
||||
assert s.total_claim_charge_amount == 250.00
|
||||
|
||||
def test_third_status_is_pended(self, parsed):
|
||||
s = parsed.claim_statuses[2]
|
||||
assert s.payer_claim_control_number == "CLAIM003"
|
||||
assert s.status_code == "A8"
|
||||
assert s.classification == "pended"
|
||||
|
||||
def test_service_date_parsed_from_dtp_472(self, parsed):
|
||||
"""DTP*472 with RD8 fmt should return the start date."""
|
||||
s = parsed.claim_statuses[0]
|
||||
assert s.service_date == date(2024, 6, 15)
|
||||
|
||||
def test_summary_counts(self, parsed):
|
||||
# accepted=1 (A3), rejected=1 (A6), pended=1 (A8)
|
||||
assert parsed.summary.total_claims == 3
|
||||
assert parsed.summary.passed == 1 # only "accepted"
|
||||
assert parsed.summary.failed == 1 # only "rejected" (pended excluded)
|
||||
|
||||
def test_round_trips_via_json(self, parsed):
|
||||
blob = json.loads(parsed.model_dump_json())
|
||||
assert blob["envelope"]["control_number"] == "000000123"
|
||||
assert len(blob["claim_statuses"]) == 3
|
||||
rebuilt = ParseResult277CA.model_validate(blob)
|
||||
assert rebuilt.claim_statuses[1].status_code == "A6"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: ST*277 (instead of ST*277CA) — X12 spec also allows it
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAAltST:
|
||||
def test_st_277_accepted(self):
|
||||
text = (FIXTURE_DIR / "minimal_277ca_st277.txt").read_text()
|
||||
result = parse_277ca_text(text, input_file="minimal_277ca_st277.txt")
|
||||
assert len(result.claim_statuses) == 1
|
||||
assert result.claim_statuses[0].classification == "accepted"
|
||||
|
||||
def test_st_wrong_value_rejected(self):
|
||||
"""A non-277 ST segment should raise CycloneParseError."""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*835*0001*005010X221A1~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"SE*3*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
with pytest.raises(CycloneParseError, match="ST\\*277"):
|
||||
parse_277ca_text(text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: rejected-only fixture (single A7)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CARejectedOnly:
|
||||
def test_single_a7_rejected(self):
|
||||
text = (FIXTURE_DIR / "minimal_277ca_rejected_only.txt").read_text()
|
||||
result = parse_277ca_text(text, input_file="minimal_277ca_rejected_only.txt")
|
||||
assert len(result.claim_statuses) == 1
|
||||
s = result.claim_statuses[0]
|
||||
assert s.status_code == "A7"
|
||||
assert s.classification == "rejected"
|
||||
assert s.payer_claim_control_number == "CLAIM099"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: error handling
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAErrors:
|
||||
def test_missing_isa_raises(self):
|
||||
"""No ISA envelope → CycloneParseError, never silent fail."""
|
||||
with pytest.raises(CycloneParseError, match="ISA"):
|
||||
parse_277ca_text("not a valid 277ca\n")
|
||||
|
||||
def test_empty_input_raises(self):
|
||||
with pytest.raises(CycloneParseError):
|
||||
parse_277ca_text("")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: multiple STC per patient (last wins)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CAMultipleStcPerPatient:
|
||||
def test_last_stc_wins_per_patient(self):
|
||||
"""A patient HL with multiple STC segments: the last one wins.
|
||||
|
||||
This is the canonical pattern for "first pended, then paid"
|
||||
acknowledgments — the most recent action is authoritative.
|
||||
"""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"HL*4*3*PT~"
|
||||
"REF*1K*CLAIM555~"
|
||||
"STC*A1:19:PR*20240620*WQ*100.00~"
|
||||
"STC*A8:19:PR*20240620*WQ*100.00~"
|
||||
"STC*A3:19:PR*20240620*WQ*100.00~"
|
||||
"SE*9*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse_277ca_text(text)
|
||||
assert len(result.claim_statuses) == 1
|
||||
assert result.claim_statuses[0].status_code == "A3"
|
||||
assert result.claim_statuses[0].classification == "accepted"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parser: subscriber-level STCs surface in unscoped_statuses
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParse277CASubscriberLevelStc:
|
||||
def test_subscriber_stc_goes_to_unscoped(self):
|
||||
"""Subscriber-level (HL*19) STCs without a Patient child go to unscoped_statuses."""
|
||||
text = (
|
||||
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
|
||||
"GS*HN*A*B*20240620*1200*1*X*005010X214~"
|
||||
"ST*277CA*0001*005010X214~"
|
||||
"BHT*0085*08*X*20240620*1200*TH~"
|
||||
"HL*1**20*1~"
|
||||
"HL*2*1*21*1~"
|
||||
"HL*3*2*19*1~"
|
||||
"STC*A6:19:PR*20240620*U~"
|
||||
"SE*7*0001~"
|
||||
"GE*1*1~"
|
||||
"IEA*1*000000001~"
|
||||
)
|
||||
result = parse_277ca_text(text)
|
||||
assert len(result.claim_statuses) == 0
|
||||
assert len(result.unscoped_statuses) == 1
|
||||
assert result.unscoped_statuses[0].status_code == "A6"
|
||||
assert result.unscoped_statuses[0].classification == "rejected"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for PayerConfig277CA loading from YAML.
|
||||
|
||||
SP10 T4. Mirrors ``test_payer_config_loading.py`` but for the new
|
||||
``PayerConfig277CA`` block that the 277CA parser and Inbox lane
|
||||
consume for status-code classification.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from cyclone.payers import (
|
||||
DEFAULT_CONFIG_PATH,
|
||||
_tx_to_model,
|
||||
all_configs,
|
||||
get_config,
|
||||
load_payer_configs,
|
||||
reset,
|
||||
)
|
||||
from cyclone.providers import PayerConfig277CA
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_registry():
|
||||
"""Each test starts with an empty config registry."""
|
||||
reset()
|
||||
yield
|
||||
reset()
|
||||
|
||||
|
||||
class TestPayerConfig277CALoading:
|
||||
def test_loads_default_config_with_277ca_block(self):
|
||||
"""The default config/payers.yaml must have a CO_TXIX 277CA block."""
|
||||
load_payer_configs(DEFAULT_CONFIG_PATH)
|
||||
cfg = get_config("CO_TXIX", "277CA")
|
||||
assert cfg is not None
|
||||
# Defaults from PayerConfig277CA.
|
||||
assert "A6" in cfg["rejected_status_codes"]
|
||||
assert "A8" in cfg["pended_status_codes"]
|
||||
assert "A3" in cfg["accepted_status_codes"]
|
||||
assert "P1" in cfg["paid_status_codes"]
|
||||
assert "277CA" in cfg["transaction_set_ids_allowed"]
|
||||
|
||||
def test_model_rejects_bad_status_code_type(self, tmp_path: Path):
|
||||
"""A non-list rejected_status_codes value must fail validation."""
|
||||
bad = {
|
||||
"payers": [
|
||||
{
|
||||
"payer_id": "BAD_PAYER",
|
||||
"name": "Bad",
|
||||
"receiver_name": "BAD",
|
||||
"receiver_id": "BADID",
|
||||
"configs": {
|
||||
"277CA": {
|
||||
"rejected_status_codes": "A6", # must be list
|
||||
"pended_status_codes": ["A8"],
|
||||
"accepted_status_codes": ["A1"],
|
||||
"paid_status_codes": ["P1"],
|
||||
"transaction_set_ids_allowed": ["277", "277CA"],
|
||||
"implementation_guide": "005010X214",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
p = tmp_path / "bad.yaml"
|
||||
p.write_text(yaml.safe_dump(bad))
|
||||
with pytest.raises(ValueError, match="BAD_PAYER"):
|
||||
load_payer_configs(p)
|
||||
|
||||
|
||||
class TestPayerConfig277CAModel:
|
||||
def test_default_factory_values(self):
|
||||
"""A minimal PayerConfig277CA defaults to the canonical HCPF sets."""
|
||||
cfg = PayerConfig277CA()
|
||||
assert "A4" in cfg.rejected_status_codes
|
||||
assert "A6" in cfg.rejected_status_codes
|
||||
assert "A7" in cfg.rejected_status_codes
|
||||
assert "A8" in cfg.pended_status_codes
|
||||
assert "277CA" in cfg.transaction_set_ids_allowed
|
||||
|
||||
def test_explicit_override(self):
|
||||
cfg = PayerConfig277CA(
|
||||
rejected_status_codes=["X1"],
|
||||
pended_status_codes=["X2"],
|
||||
accepted_status_codes=["X3"],
|
||||
paid_status_codes=["X4"],
|
||||
)
|
||||
assert cfg.rejected_status_codes == ["X1"]
|
||||
assert cfg.pended_status_codes == ["X2"]
|
||||
|
||||
|
||||
class TestTxToModelFor277CA:
|
||||
def test_int_key_routes_to_277ca_model(self):
|
||||
"""PyYAML may parse numeric keys as int — _tx_to_model must handle both."""
|
||||
# 277CA as int isn't likely (not pure digits), but 277 as int is.
|
||||
# Test the canonical string.
|
||||
assert _tx_to_model("277CA") is PayerConfig277CA
|
||||
|
||||
def test_string_key_routes_correctly(self):
|
||||
assert _tx_to_model("277CA") is PayerConfig277CA
|
||||
assert _tx_to_model("835") is not PayerConfig277CA # it's 835 model
|
||||
Reference in New Issue
Block a user