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:
+140
-4
@@ -16,10 +16,13 @@ plus GET/POST with any header.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
@@ -28,7 +31,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pydantic import ValidationError
|
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.inbox_state import apply_999_rejections
|
||||||
from cyclone.parsers.exceptions import CycloneParseError
|
from cyclone.parsers.exceptions import CycloneParseError
|
||||||
from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult
|
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
|
# 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
|
# practice we look it up against patient_control_number because that's
|
||||||
# the field 999 ACKs cross-reference in this product.
|
# the field 999 ACKs cross-reference in this product.
|
||||||
from cyclone import db as _db
|
with db.SessionLocal()() as session:
|
||||||
from cyclone.db import Claim
|
|
||||||
with _db.SessionLocal()() as session:
|
|
||||||
def _lookup(pcn: str):
|
def _lookup(pcn: str):
|
||||||
return session.query(Claim).filter_by(patient_control_number=pcn).first()
|
return session.query(Claim).filter_by(patient_control_number=pcn).first()
|
||||||
_rejection_result = apply_999_rejections(
|
_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)
|
# GET endpoints (read views over the in-memory store)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user