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
|
||||
|
||||
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)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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