From f4baceb5741d6a701ed1b596a7162b0b3dc4c3af Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 18:34:09 -0600 Subject: [PATCH] =?UTF-8?q?feat(sp6):=20inbox=20endpoints=20=E2=80=94=20la?= =?UTF-8?q?nes,=20match,=20dismiss,=20resubmit,=20csv=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= — 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. --- backend/src/cyclone/api.py | 144 +++++++++++++++++++++++++- backend/tests/test_inbox_endpoints.py | 143 +++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_inbox_endpoints.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index 58ca2e9..b788511 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -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) # --------------------------------------------------------------------------- # diff --git a/backend/tests/test_inbox_endpoints.py b/backend/tests/test_inbox_endpoints.py new file mode 100644 index 0000000..ecd38e0 --- /dev/null +++ b/backend/tests/test_inbox_endpoints.py @@ -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]