2c0afbe9c5
- 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.
250 lines
8.9 KiB
Python
250 lines
8.9 KiB
Python
"""TDD: /api/inbox/* endpoints.
|
|
|
|
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
|
|
Tasks 7-10 (T7-T10).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from cyclone import db
|
|
from cyclone.api import app
|
|
from cyclone.db import Batch, Claim, ClaimState, Remittance
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db")
|
|
db._reset_for_tests()
|
|
db.init_db()
|
|
# Initialize app state used by inbox endpoints.
|
|
app.state.dismissed_pairs = set()
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
def _seed_batch() -> None:
|
|
with db.SessionLocal()() as s:
|
|
if s.get(Batch, "B-1") is not None:
|
|
return
|
|
s.add(Batch(
|
|
id="B-1", kind="837p", input_filename="x.txt",
|
|
parsed_at=datetime.now(timezone.utc),
|
|
totals_json={"total_claims": 1},
|
|
validation_json={"passed": True, "warnings": [], "errors": []},
|
|
raw_result_json={"_": "stub"},
|
|
))
|
|
s.commit()
|
|
|
|
|
|
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", "payer_rejected", "candidates", "unmatched", "done_today",
|
|
}
|
|
for v in body.values():
|
|
assert isinstance(v, list)
|
|
|
|
|
|
def test_match_endpoint_links_remit_to_claim(client: TestClient):
|
|
_seed_batch()
|
|
with db.SessionLocal()() as s:
|
|
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="PCN-1",
|
|
state=ClaimState.SUBMITTED))
|
|
s.add(Remittance(id="R1", batch_id="B-1",
|
|
payer_claim_control_number="PCN-1",
|
|
status_code="1", status_label="P",
|
|
total_charge=100, total_paid=0,
|
|
received_at=datetime.now(timezone.utc)))
|
|
s.commit()
|
|
|
|
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
|
|
assert r.status_code == 200, r.text
|
|
|
|
with db.SessionLocal()() as s:
|
|
c = s.get(Claim, "C1")
|
|
assert c.matched_remittance_id == "R1"
|
|
|
|
|
|
def test_match_409_if_claim_already_matched(client: TestClient):
|
|
_seed_batch()
|
|
with db.SessionLocal()() as s:
|
|
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
|
|
state=ClaimState.SUBMITTED,
|
|
matched_remittance_id="R-OTHER"))
|
|
s.add(Remittance(id="R1", batch_id="B-1",
|
|
payer_claim_control_number="X",
|
|
status_code="1", status_label="P",
|
|
total_charge=100, total_paid=0,
|
|
received_at=datetime.now(timezone.utc)))
|
|
s.commit()
|
|
|
|
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
|
|
assert r.status_code == 409
|
|
assert "current_state" in r.json()["detail"]
|
|
|
|
|
|
def test_dismiss_endpoint_adds_pair_to_session_set(client: TestClient):
|
|
r = client.post(
|
|
"/api/inbox/candidates/dismiss",
|
|
json={"pairs": [{"claim_id": "C1", "remit_id": "R1"}]},
|
|
)
|
|
assert r.status_code == 200
|
|
assert frozenset({"C1", "R1"}) in client.app.state.dismissed_pairs
|
|
|
|
|
|
def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestClient):
|
|
_seed_batch()
|
|
with db.SessionLocal()() as s:
|
|
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
|
|
state=ClaimState.REJECTED,
|
|
rejection_reason="old",
|
|
resubmit_count=2))
|
|
s.commit()
|
|
|
|
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
|
|
assert r.status_code == 200, r.text
|
|
|
|
with db.SessionLocal()() as s:
|
|
c = s.get(Claim, "C1")
|
|
assert c.state == ClaimState.SUBMITTED
|
|
assert c.rejection_reason is None
|
|
assert c.resubmit_count == 3
|
|
|
|
|
|
def test_resubmit_returns_conflicts_for_non_rejected_claim(client: TestClient):
|
|
"""Resubmit returns 200 with a conflicts list (does NOT raise 409)."""
|
|
_seed_batch()
|
|
with db.SessionLocal()() as s:
|
|
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
|
|
state=ClaimState.SUBMITTED))
|
|
s.commit()
|
|
|
|
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["resubmitted"] == []
|
|
assert any(c["claim_id"] == "C1" for c in body["conflicts"])
|
|
|
|
|
|
def test_export_csv_streams_rows_with_csv_content_type(client: TestClient):
|
|
r = client.get("/api/inbox/export.csv?lane=rejected")
|
|
assert r.status_code == 200
|
|
assert "text/csv" in r.headers["content-type"]
|
|
body = r.text
|
|
assert "id" in body.splitlines()[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/inbox/rejected/resubmit?download=true — SP8 bundle download
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# These tests seed a REJECTED claim with a real ClaimOutput in raw_json
|
|
# (produced by ingesting a real 837 fixture) so the download path has
|
|
# something to serialize. The bundle is built in-memory; we unzip the
|
|
# response body and assert the per-claim .x12 files round-trip back
|
|
# through the parser.
|
|
|
|
|
|
def _seed_rejected_claim_with_raw_837(client: TestClient) -> str:
|
|
"""Parse the canonical CO Medicaid 837, move its claim into REJECTED,
|
|
and return the claim_id. The raw_json column gets populated by the
|
|
/api/parse-837 endpoint as a side effect of ingesting the file."""
|
|
from pathlib import Path
|
|
|
|
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
|
|
r = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
real_id = r.json()["claims"][0]["claim_id"]
|
|
_seed_batch()
|
|
with db.SessionLocal()() as s:
|
|
c = s.get(Claim, real_id)
|
|
assert c is not None
|
|
c.state = ClaimState.REJECTED
|
|
c.rejection_reason = "test fixture"
|
|
s.commit()
|
|
return real_id
|
|
|
|
|
|
def test_resubmit_with_download_returns_zip_with_one_x12_per_accepted_claim(client: TestClient):
|
|
"""Happy path: 1 rejected claim → zip with 1 .x12 file that round-trips
|
|
back through the parser. Filename in Content-Disposition reflects count."""
|
|
from cyclone.parsers.parse_837 import parse as parse_837
|
|
from cyclone.parsers.payer import PayerConfig
|
|
|
|
real_id = _seed_rejected_claim_with_raw_837(client)
|
|
|
|
r = client.post(
|
|
"/api/inbox/rejected/resubmit?download=true",
|
|
json={"claim_ids": [real_id]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
assert r.headers["content-type"].startswith("application/zip")
|
|
cd = r.headers["content-disposition"]
|
|
assert "attachment" in cd
|
|
# Count in filename comes from the number of accepted (resubmitted)
|
|
# claims, not the input list length — conflicts are excluded.
|
|
assert "resubmit-1-claims.zip" in cd
|
|
|
|
# The zip body is a real archive with one x12 per accepted claim.
|
|
buf = io.BytesIO(r.content)
|
|
with zipfile.ZipFile(buf) as zf:
|
|
names = zf.namelist()
|
|
assert names == [f"claim-{real_id}.x12"]
|
|
body = zf.read(names[0]).decode("utf-8")
|
|
|
|
# And it parses back to the same claim id.
|
|
result = parse_837(body, PayerConfig(name="CO_MEDICAID"))
|
|
assert any(c.claim_id == real_id for c in result.claims)
|
|
|
|
# Side effect: claim actually moved to SUBMITTED.
|
|
with db.SessionLocal()() as s:
|
|
c = s.get(Claim, real_id)
|
|
assert c.state == ClaimState.SUBMITTED
|
|
|
|
|
|
def test_resubmit_with_download_excludes_conflicts_from_zip(client: TestClient):
|
|
"""The download is a 'give me the files I asked for' payload — only
|
|
claims that were successfully resubmitted end up in the zip. Missing
|
|
ids and non-rejected conflicts are omitted (the user already saw them
|
|
in the JSON response on the non-download path).
|
|
"""
|
|
_seed_batch()
|
|
accepted_id = _seed_rejected_claim_with_raw_837(client)
|
|
conflict_id = "C-NOT-REJECTED"
|
|
with db.SessionLocal()() as s:
|
|
s.add(Claim(id=conflict_id, batch_id="B-1",
|
|
patient_control_number="X",
|
|
state=ClaimState.SUBMITTED))
|
|
s.commit()
|
|
|
|
r = client.post(
|
|
"/api/inbox/rejected/resubmit?download=true",
|
|
json={"claim_ids": [accepted_id, conflict_id, "C-DOES-NOT-EXIST"]},
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
# Filename count = accepted count, not input length.
|
|
assert "resubmit-1-claims.zip" in r.headers["content-disposition"]
|
|
|
|
buf = io.BytesIO(r.content)
|
|
with zipfile.ZipFile(buf) as zf:
|
|
names = set(zf.namelist())
|
|
assert names == {f"claim-{accepted_id}.x12"}
|