feat(sp6): inbox endpoints — lanes, match, dismiss, resubmit, csv export

T7-T10 combined (single edit: all endpoints share a section).

- GET /api/inbox/lanes — four lanes in one call
- POST /api/inbox/candidates/{remit_id}/match — 409 on conflict
- POST /api/inbox/candidates/dismiss — session-scoped dismissed set
- POST /api/inbox/rejected/resubmit — 200 with conflicts list
- GET /api/inbox/export.csv?lane=<lane> — streams CSV

Also adds module-level imports (db, Claim, ClaimState, Remittance,
csv, io, datetime) that the new endpoints need; cleans up the
duplicated local imports in the parse-999 SP6 T4 block.
This commit is contained in:
Tyler
2026-06-20 18:34:09 -06:00
parent cdffb3faf3
commit f4baceb574
2 changed files with 283 additions and 4 deletions
+140 -4
View File
@@ -16,10 +16,13 @@ plus GET/POST with any header.
from __future__ import annotations
import asyncio
import csv
import io
import json
import logging
import os
import uuid
from datetime import datetime, timezone
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
@@ -28,7 +31,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import ValidationError
from cyclone import __version__
from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.inbox_state import apply_999_rejections
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
@@ -623,9 +627,7 @@ async def parse_999_endpoint(
# The 999's set_control_number (AK202) is the source 837's ST02; in
# practice we look it up against patient_control_number because that's
# the field 999 ACKs cross-reference in this product.
from cyclone import db as _db
from cyclone.db import Claim
with _db.SessionLocal()() as session:
with db.SessionLocal()() as session:
def _lookup(pcn: str):
return session.query(Claim).filter_by(patient_control_number=pcn).first()
_rejection_result = apply_999_rejections(
@@ -665,6 +667,140 @@ async def parse_999_endpoint(
})
# --------------------------------------------------------------------------- #
# SP6 — Inbox endpoints
# --------------------------------------------------------------------------- #
@app.get("/api/inbox/lanes")
def inbox_lanes():
"""Return all four Inbox lanes in one call."""
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
with db.SessionLocal()() as session:
from cyclone.inbox_lanes import compute_lanes
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
return {
"rejected": lanes.rejected,
"candidates": lanes.candidates,
"unmatched": lanes.unmatched,
"done_today": lanes.done_today,
}
@app.post("/api/inbox/candidates/{remit_id}/match")
def inbox_match_candidate(remit_id: str, body: dict):
"""Manually link a remit to a claim."""
claim_id = body.get("claim_id")
if not claim_id:
raise HTTPException(400, "claim_id required")
with db.SessionLocal()() as s:
claim = s.get(Claim, claim_id)
remit = s.get(Remittance, remit_id)
if claim is None or remit is None:
raise HTTPException(404, "claim or remit not found")
if claim.matched_remittance_id and claim.matched_remittance_id != remit_id:
raise HTTPException(
409,
detail={
"error": "claim_already_matched",
"current_state": (
claim.state.value if hasattr(claim.state, "value")
else str(claim.state)
),
"matched_remittance_id": claim.matched_remittance_id,
},
)
claim.matched_remittance_id = remit_id
remit.claim_id = claim_id
s.commit()
return {"ok": True, "claim_id": claim_id, "remit_id": remit_id}
@app.post("/api/inbox/candidates/dismiss")
def inbox_dismiss_candidates(body: dict):
"""Add candidate pairs to the session-scoped dismissed set."""
pairs = body.get("pairs") or []
if not hasattr(app.state, "dismissed_pairs"):
app.state.dismissed_pairs = set()
for p in pairs:
cid = p.get("claim_id")
rid = p.get("remit_id")
if cid and rid:
app.state.dismissed_pairs.add(frozenset({cid, rid}))
return {"ok": True, "dismissed_count": len(pairs)}
@app.post("/api/inbox/rejected/resubmit")
def inbox_resubmit_rejected(body: dict):
"""Bulk move REJECTED claims back to SUBMITTED."""
ids = body.get("claim_ids") or []
if not ids:
raise HTTPException(400, "claim_ids required")
accepted: list[str] = []
conflicts: list[dict] = []
with db.SessionLocal()() as s:
for cid in ids:
c = s.get(Claim, cid)
if c is None:
continue
if c.state != ClaimState.REJECTED:
conflicts.append({
"claim_id": cid,
"current_state": (
c.state.value if hasattr(c.state, "value")
else str(c.state)
),
})
continue
c.state = ClaimState.SUBMITTED
c.state_changed_at = datetime.now(timezone.utc)
c.rejection_reason = None
c.rejected_at = None
c.resubmit_count = (c.resubmit_count or 0) + 1
accepted.append(cid)
s.commit()
return {"ok": True, "resubmitted": accepted, "conflicts": conflicts}
@app.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane."""
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
raise HTTPException(400, f"unknown lane: {lane}")
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
with db.SessionLocal()() as session:
from cyclone.inbox_lanes import compute_lanes
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
rows = getattr(lanes, lane)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"id", "kind", "patient_control_number", "charge_amount",
"payer_id", "provider_npi", "state", "rejection_reason",
"service_date", "score",
])
for r in rows:
writer.writerow([
r.get("id") or r.get("payer_claim_control_number"),
r.get("kind"),
r.get("patient_control_number"),
r.get("charge_amount"),
r.get("payer_id"),
r.get("provider_npi") or r.get("rendering_provider_npi"),
r.get("state"),
r.get("rejection_reason"),
r.get("service_date_from") or r.get("service_date"),
r.get("score"),
])
buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'},
)
# --------------------------------------------------------------------------- #
# GET endpoints (read views over the in-memory store)
# --------------------------------------------------------------------------- #
+143
View File
@@ -0,0 +1,143 @@
"""TDD: /api/inbox/* endpoints.
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
Tasks 7-10 (T7-T10).
"""
from __future__ import annotations
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_four_keys(client: TestClient):
r = client.get("/api/inbox/lanes")
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {"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]