diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index d2c719b..cac5e4c 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -15,8 +15,6 @@ plus GET/POST with any header. from __future__ import annotations -import csv -import io import json import logging import uuid @@ -26,7 +24,7 @@ from typing import Any, AsyncIterator from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, Response, StreamingResponse +from fastapi.responses import JSONResponse, StreamingResponse from pydantic import ValidationError from cyclone import __version__, db @@ -35,7 +33,7 @@ from cyclone.inbox_state import apply_999_rejections from cyclone.inbox_state_277ca import apply_277ca_rejections from cyclone.audit_log import AuditEvent, append_event, verify_chain from cyclone.parsers.exceptions import CycloneParseError -from cyclone.parsers.models import BatchSummary, ClaimOutput, Envelope, ParseResult +from cyclone.parsers.models import BatchSummary, Envelope from cyclone.parsers.models_270 import ( EligibilityBenefitInquiry, InformationReceiver270, @@ -55,7 +53,7 @@ from cyclone.parsers.parse_999 import parse_999_text from cyclone.parsers.parse_ta1 import parse_ta1_text from cyclone.parsers.serialize_270 import serialize_270 from cyclone.parsers.serialize_999 import serialize_999 -from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837, serialize_837_for_resubmit + from cyclone.parsers.batch_ack_builder import build_ack_for_batch from cyclone.parsers.validator_835 import validate as validate_835 from cyclone.pubsub import EventBus @@ -149,7 +147,9 @@ from cyclone.api_routers import ( # noqa: E402 clearhouse, config, health, + inbox, providers, + reconciliation, remittances, ta1_acks, ) @@ -164,6 +164,8 @@ app.include_router(activity.router) app.include_router(batches.router) app.include_router(claims.router) app.include_router(remittances.router) +app.include_router(inbox.router) +app.include_router(reconciliation.router) # Backwards-compat re-exports: test_api_stream_live.py imports these # names directly from ``cyclone.api`` to invoke the endpoint coroutines @@ -901,416 +903,10 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str: # --------------------------------------------------------------------------- # -@app.get("/api/inbox/lanes") -def inbox_lanes(): - """Return all 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, - # SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from - # the 999 envelope rejection in ``rejected`` above. - "payer_rejected": lanes.payer_rejected, - "candidates": lanes.candidates, - "unmatched": lanes.unmatched, - "done_today": lanes.done_today, - } +# --- /api/inbox/* lives in cyclone.api_routers.inbox --- +# --- /api/reconciliation/* + /api/batch-diff live in cyclone.api_routers.reconciliation --- -@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)} - - -# --------------------------------------------------------------------------- # -# SP14: Payer-Rejected acknowledge -# -# Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear -# the claim from the working surface. We don't delete the rejection -# (the original payer_rejected_* fields stay for SP11 audit), we just -# set payer_rejected_acknowledged_at so the lane query filters it out. -# -# Idempotent: re-acknowledging an already-acknowledged claim is a noop -# (the timestamp is not bumped). Returns the count actually transitioned -# so the UI can show "3 of 5 were already acknowledged". -# --------------------------------------------------------------------------- # -@app.post("/api/inbox/payer-rejected/acknowledge") -def inbox_acknowledge_payer_rejected(body: dict): - """Mark Payer-Rejected claims as acknowledged by the operator.""" - claim_ids = body.get("claim_ids") or [] - actor = body.get("actor") or "operator" - if not isinstance(claim_ids, list) or not claim_ids: - raise HTTPException(400, "claim_ids must be a non-empty list") - if not all(isinstance(c, str) for c in claim_ids): - raise HTTPException(400, "claim_ids must be a list of strings") - - with db.SessionLocal()() as session: - from cyclone.db import Claim - now = datetime.now(timezone.utc) - transitioned = 0 - already_acked = 0 - not_found = 0 - not_rejected = 0 - for cid in claim_ids: - claim = session.get(Claim, cid) - if claim is None: - not_found += 1 - continue - if claim.payer_rejected_at is None: - not_rejected += 1 - continue - if claim.payer_rejected_acknowledged_at is not None: - already_acked += 1 - continue - claim.payer_rejected_acknowledged_at = now - claim.payer_rejected_acknowledged_actor = actor - transitioned += 1 - # SP11: audit event for the acknowledge action. - try: - from cyclone.audit_log import append_event, AuditEvent - append_event(session, AuditEvent( - event_type="claim.payer_rejected_acknowledged", - entity_type="claim", - entity_id=claim.id, - actor=actor, - payload={ - "payer_rejected_status_code": claim.payer_rejected_status_code, - "payer_rejected_by_277ca_id": claim.payer_rejected_by_277ca_id, - }, - )) - except Exception: # noqa: BLE001 - # Audit append is best-effort; don't block the operator's - # acknowledge action on an audit-log failure. - pass - if transitioned: - session.commit() - - return { - "ok": True, - "transitioned": transitioned, - "already_acked": already_acked, - "not_found": not_found, - "not_rejected": not_rejected, - } - - -@app.post("/api/inbox/rejected/resubmit") -def inbox_resubmit_rejected( - request: Request, - body: dict, - download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."), -): - """Bulk move REJECTED claims back to SUBMITTED. - - With ``?download=true``, the response is a ``application/zip`` archive - containing one ``claim-{id}.x12`` per successfully resubmitted claim - (regenerated via ``serialize_837_for_resubmit`` so each file gets a - unique interchange/group control number). Conflicts are omitted from - the ZIP — they remain visible to the caller via the JSON shape of the - non-download path. Empty resubmit + download → 200 with an empty zip - so the UI can still hand the user a downloadable artifact. - """ - ids = body.get("claim_ids") or [] - if not ids: - raise HTTPException(400, "claim_ids required") - accepted: list[str] = [] - conflicts: list[dict] = [] - # Track which claims are about to be resubmitted (and their index in - # the bundle) so the download path can serialize them with unique - # control numbers — back-to-back resubmits in the same file would - # otherwise all share ISA13/GS06 = "000000001". - accepted_with_rows: list[tuple[str, "Claim"]] = [] - 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) - accepted_with_rows.append((cid, c)) - s.commit() - - if not download: - return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} - - # Build a ZIP of regenerated 837s for the accepted claims. Conflicts - # and missing ids are deliberately excluded — the user already saw - # them in the JSON response on prior actions; the download is the - # "give me the files I asked for" payload. - import zipfile - buf = io.BytesIO() - serialize_errors: list[dict] = [] - with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: - for idx, (cid, c) in enumerate(accepted_with_rows, start=1): - if not c.raw_json: - serialize_errors.append({"claim_id": cid, "reason": "no raw_json"}) - continue - try: - claim_obj = ClaimOutput.model_validate(c.raw_json) - except Exception as exc: - serialize_errors.append({"claim_id": cid, "reason": f"raw_json invalid: {exc}"}) - continue - try: - text = serialize_837_for_resubmit(claim_obj, interchange_index=idx) - except SerializeError837 as exc: - serialize_errors.append({"claim_id": cid, "reason": str(exc)}) - continue - zf.writestr(f"claim-{cid}.x12", text) - buf.seek(0) - headers = { - "Content-Disposition": ( - f'attachment; filename="resubmit-{len(accepted)}-claims.zip"' - ), - } - # Surface per-claim serialization failures as a custom response header - # so the UI can show "10 resubmitted, 2 couldn't be regenerated" without - # parsing the binary. The header value is JSON-encoded; the UI is - # expected to JSON.parse it after a fetch with response.ok. - if serialize_errors: - headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors) - return Response( - content=buf.getvalue(), - media_type="application/zip", - headers=headers, - ) - - -@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"'}, - ) - - -# --- /api/batches lives in cyclone.api_routers.batches --- - - -# --- /api/claims lives in cyclone.api_routers.claims --- - -@app.get("/api/reconciliation/unmatched") -def get_reconciliation_unmatched() -> dict: - """Return unmatched Claims (left) and unmatched Remittances (right). - - Powers the reconciliation review surface: every Claim with no - paired Remittance appears on the left, every Remittance with no - paired Claim appears on the right. The two lists are always present - (empty list, never absent) so the UI can index unconditionally. - """ - return store.list_unmatched(kind="both") - - -# --------------------------------------------------------------------------- # -# Side-by-side diff between two batches (SP3 P4 / T18) -# --------------------------------------------------------------------------- # - - -@app.get("/api/batch-diff") -def get_batch_diff( - a: str | None = Query(None), - b: str | None = Query(None), -) -> dict: - """Return a side-by-side diff of two batches identified by id. - - Query params: ``a=``, ``b=`` (both required). - - Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the - projector shapes): - - ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt, - inputFilename, claimCount) - - ``added`` — claims present in B but not A - - ``removed`` — claims present in A but not B - - ``changed`` — claims present in both, with field deltas - - ``summary`` — precomputed counts - - Errors: - - 400 — missing ``a`` or ``b`` - - 404 — either batch id is unknown - - Pure read endpoint — never mutates the store. Both 837P and 835 - batches are accepted (mixed-kind diffs are valid: comparing the - submitted claims against the matching remittances). - """ - if not a or not b: - raise HTTPException( - status_code=400, - detail={"error": "Missing param", "detail": "Both ?a= and ?b= are required."}, - ) - try: - a_rec, b_rec = store.load_two_for_diff(a, b) - except LookupError as exc: - raise HTTPException( - status_code=404, - detail={"error": "Not found", "detail": str(exc)}, - ) - - # Lazy import — keeps the module's import surface small until the - # endpoint is actually hit. Mirrors the same pattern used by other - # endpoint-local helpers (e.g. reconciler). - from cyclone.batch_diff import diff_batches_to_wire - - return diff_batches_to_wire(a_rec, b_rec) - - -@app.post("/api/reconciliation/match") -def post_reconciliation_match(body: dict) -> dict: - """Manually pair a Claim with a Remittance (operator override). - - Body: ``{"claim_id": ..., "remit_id": ...}``. Returns - ``{"claim": , "match": }`` on success. Errors: - - 400: missing ``claim_id`` or ``remit_id`` - - 404: claim or remittance not found - - 409: claim already matched, or apply_* returned a noop - (claim in terminal state) — detail echoes ``current_state`` - and ``activity_kind`` so the UI can render a precise message. - """ - claim_id = body.get("claim_id") - remit_id = body.get("remit_id") - if not claim_id or not remit_id: - raise HTTPException( - status_code=400, - detail="claim_id and remit_id required", - ) - try: - return store.manual_match(claim_id, remit_id) - except AlreadyMatchedError as e: - raise HTTPException( - status_code=409, - detail={"error": "already_matched", "message": str(e)}, - ) - except InvalidStateError as e: - raise HTTPException( - status_code=409, - detail={ - "error": "invalid_state", - "current_state": e.current_state, - "activity_kind": e.activity_kind, - }, - ) - except LookupError: - # manual_match raises LookupError when the claim or remittance - # row is missing (we catch the parent class so any future - # KeyError subclasses in the store get the same treatment). - raise HTTPException( - status_code=404, - detail="claim_or_remit_not_found", - ) - - -@app.post("/api/reconciliation/unmatch") -def post_reconciliation_unmatch(body: dict) -> dict: - """Remove the current match for a Claim; reset Claim to submitted. - - Body: ``{"claim_id": ...}``. Returns - ``{"claim": , "deletedMatches": }``. Errors: - - 400: missing ``claim_id`` - - 404: claim not found - - 409: claim has no current match (NotMatchedError is mapped - by the store; we surface 409 to match the manual_match contract) - """ - from cyclone.store import NotMatchedError - claim_id = body.get("claim_id") - if not claim_id: - raise HTTPException( - status_code=400, - detail="claim_id required", - ) - try: - return store.manual_unmatch(claim_id) - except NotMatchedError as e: - raise HTTPException( - status_code=409, - detail={"error": "not_matched", "message": str(e)}, - ) - except LookupError: - raise HTTPException( - status_code=404, - detail="claim_not_found", - ) # --- /api/remittances lives in cyclone.api_routers.remittances --- diff --git a/backend/src/cyclone/api_routers/inbox.py b/backend/src/cyclone/api_routers/inbox.py new file mode 100644 index 0000000..2dd2c7e --- /dev/null +++ b/backend/src/cyclone/api_routers/inbox.py @@ -0,0 +1,309 @@ +"""``/api/inbox/*`` — operator-facing inbox surface. + +Six endpoints powering the Inbox page (lanes, candidates, payer-rejected +acknowledge, rejected resubmit, CSV export). All endpoints run inside a +``db.SessionLocal()`` scope; the lanes / dismiss endpoints also read +``app.state.dismissed_pairs`` (a session-scoped set so dismissed items +don't re-appear until process restart). + +Endpoints: +* ``GET /api/inbox/lanes`` — return all four lanes in one call + (rejected / payer_rejected / candidates / unmatched / done_today). +* ``POST /api/inbox/candidates/{remit_id}/match`` — manually link a + remit to a claim from the candidates lane. +* ``POST /api/inbox/candidates/dismiss`` — add candidate pairs to the + dismissed set so they stop appearing in the candidates lane. +* ``POST /api/inbox/payer-rejected/acknowledge`` — clear SP14 + payer-rejected claims from the working surface without deleting the + rejection (idempotent: re-acknowledging is a noop). +* ``POST /api/inbox/rejected/resubmit`` — bulk move REJECTED claims + back to SUBMITTED; with ``?download=true`` returns a ZIP of + regenerated 837s with unique interchange control numbers. +* ``GET /api/inbox/export.csv?lane=…`` — stream a CSV for a single lane. +""" +from __future__ import annotations + +import csv +import io +import json +import zipfile +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Query, Request, Response +from fastapi.responses import StreamingResponse + +from cyclone import db +from cyclone.audit_log import AuditEvent, append_event +from cyclone.db import Claim, ClaimState, Remittance +from cyclone.parsers.models import ClaimOutput +from cyclone.parsers.serialize_837 import ( + SerializeError as SerializeError837, + serialize_837_for_resubmit, +) + +router = APIRouter() + + +@router.get("/api/inbox/lanes") +def inbox_lanes(request: Request) -> dict: + """Return all Inbox lanes in one call. + + Reads the session-scoped ``dismissed_pairs`` set so the + candidates lane excludes pairs the operator already dismissed in + this process lifetime. + """ + dismissed_pairs = getattr(request.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, + # SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from + # the 999 envelope rejection in ``rejected`` above. + "payer_rejected": lanes.payer_rejected, + "candidates": lanes.candidates, + "unmatched": lanes.unmatched, + "done_today": lanes.done_today, + } + + +@router.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} + + +@router.post("/api/inbox/candidates/dismiss") +def inbox_dismiss_candidates(request: Request, body: dict): + """Add candidate pairs to the session-scoped dismissed set.""" + pairs = body.get("pairs") or [] + if not hasattr(request.app.state, "dismissed_pairs"): + request.app.state.dismissed_pairs = set() + for p in pairs: + cid = p.get("claim_id") + rid = p.get("remit_id") + if cid and rid: + request.app.state.dismissed_pairs.add(frozenset({cid, rid})) + return {"ok": True, "dismissed_count": len(pairs)} + + +@router.post("/api/inbox/payer-rejected/acknowledge") +def inbox_acknowledge_payer_rejected(body: dict): + """Mark Payer-Rejected claims as acknowledged by the operator. + + Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear + the claim from the working surface. We don't delete the rejection + (the original payer_rejected_* fields stay for SP11 audit), we just + set ``payer_rejected_acknowledged_at`` so the lane query filters it + out. + + Idempotent: re-acknowledging an already-acknowledged claim is a noop + (the timestamp is not bumped). Returns the count actually transitioned + so the UI can show "3 of 5 were already acknowledged". + """ + claim_ids = body.get("claim_ids") or [] + actor = body.get("actor") or "operator" + if not isinstance(claim_ids, list) or not claim_ids: + raise HTTPException(400, "claim_ids must be a non-empty list") + if not all(isinstance(c, str) for c in claim_ids): + raise HTTPException(400, "claim_ids must be a list of strings") + + with db.SessionLocal()() as session: + now = datetime.now(timezone.utc) + transitioned = 0 + already_acked = 0 + not_found = 0 + not_rejected = 0 + for cid in claim_ids: + claim = session.get(Claim, cid) + if claim is None: + not_found += 1 + continue + if claim.payer_rejected_at is None: + not_rejected += 1 + continue + if claim.payer_rejected_acknowledged_at is not None: + already_acked += 1 + continue + claim.payer_rejected_acknowledged_at = now + claim.payer_rejected_acknowledged_actor = actor + transitioned += 1 + # SP11: audit event for the acknowledge action. Best-effort: + # an audit-log failure must not block the operator's action. + try: + append_event(session, AuditEvent( + event_type="claim.payer_rejected_acknowledged", + entity_type="claim", + entity_id=claim.id, + actor=actor, + payload={ + "payer_rejected_status_code": claim.payer_rejected_status_code, + "payer_rejected_by_277ca_id": claim.payer_rejected_by_277ca_id, + }, + )) + except Exception: # noqa: BLE001 + pass + if transitioned: + session.commit() + + return { + "ok": True, + "transitioned": transitioned, + "already_acked": already_acked, + "not_found": not_found, + "not_rejected": not_rejected, + } + + +@router.post("/api/inbox/rejected/resubmit") +def inbox_resubmit_rejected( + body: dict, + download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."), +): + """Bulk move REJECTED claims back to SUBMITTED. + + With ``?download=true``, the response is a ``application/zip`` archive + containing one ``claim-{id}.x12`` per successfully resubmitted claim + (regenerated via ``serialize_837_for_resubmit`` so each file gets a + unique interchange/group control number). Conflicts are omitted from + the ZIP — they remain visible to the caller via the JSON shape of the + non-download path. Empty resubmit + download → 200 with an empty zip + so the UI can still hand the user a downloadable artifact. + """ + ids = body.get("claim_ids") or [] + if not ids: + raise HTTPException(400, "claim_ids required") + accepted: list[str] = [] + conflicts: list[dict] = [] + # Track which claims are about to be resubmitted (and their index in + # the bundle) so the download path can serialize them with unique + # control numbers — back-to-back resubmits in the same file would + # otherwise all share ISA13/GS06 = "000000001". + accepted_with_rows: list[tuple[str, Claim]] = [] + 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) + accepted_with_rows.append((cid, c)) + s.commit() + + if not download: + return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} + + # Build a ZIP of regenerated 837s for the accepted claims. Conflicts + # and missing ids are deliberately excluded — the user already saw + # them in the JSON response on prior actions; the download is the + # "give me the files I asked for" payload. + buf = io.BytesIO() + serialize_errors: list[dict] = [] + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for idx, (cid, c) in enumerate(accepted_with_rows, start=1): + if not c.raw_json: + serialize_errors.append({"claim_id": cid, "reason": "no raw_json"}) + continue + try: + claim_obj = ClaimOutput.model_validate(c.raw_json) + except Exception as exc: + serialize_errors.append({"claim_id": cid, "reason": f"raw_json invalid: {exc}"}) + continue + try: + text = serialize_837_for_resubmit(claim_obj, interchange_index=idx) + except SerializeError837 as exc: + serialize_errors.append({"claim_id": cid, "reason": str(exc)}) + continue + zf.writestr(f"claim-{cid}.x12", text) + buf.seek(0) + headers = { + "Content-Disposition": ( + f'attachment; filename="resubmit-{len(accepted)}-claims.zip"' + ), + } + # Surface per-claim serialization failures as a custom response header + # so the UI can show "10 resubmitted, 2 couldn't be regenerated" without + # parsing the binary. The header value is JSON-encoded; the UI is + # expected to JSON.parse it after a fetch with response.ok. + if serialize_errors: + headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors) + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers=headers, + ) + + +@router.get("/api/inbox/export.csv") +def inbox_export_csv(request: Request, 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(request.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"'}, + ) diff --git a/backend/src/cyclone/api_routers/reconciliation.py b/backend/src/cyclone/api_routers/reconciliation.py new file mode 100644 index 0000000..e069be5 --- /dev/null +++ b/backend/src/cyclone/api_routers/reconciliation.py @@ -0,0 +1,164 @@ +"""``/api/reconciliation/*`` + ``/api/batch-diff`` — operator review surface. + +Four endpoints powering the reconciliation page: + +* ``GET /api/reconciliation/unmatched`` — return unmatched Claims + (left) and unmatched Remittances (right). +* ``POST /api/reconciliation/match`` — manually pair a Claim with a + Remittance (operator override). +* ``POST /api/reconciliation/unmatch`` — remove the current match and + reset the Claim to submitted. +* ``GET /api/batch-diff`` — side-by-side diff of two batches (SP3 P4 / + T18). + +The two POST endpoints delegate to ``store.manual_match`` / +``store.manual_unmatch``; the router is responsible for translating the +store's exception hierarchy (``AlreadyMatchedError``, ``InvalidStateError``, +``NotMatchedError``, ``LookupError``) into the HTTP error contract. +""" +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Query + +from cyclone.store import ( + AlreadyMatchedError, + InvalidStateError, + store, +) + +router = APIRouter() + + +@router.get("/api/reconciliation/unmatched") +def get_reconciliation_unmatched() -> dict: + """Return unmatched Claims (left) and unmatched Remittances (right). + + Powers the reconciliation review surface: every Claim with no + paired Remittance appears on the left, every Remittance with no + paired Claim appears on the right. The two lists are always present + (empty list, never absent) so the UI can index unconditionally. + """ + return store.list_unmatched(kind="both") + + +@router.get("/api/batch-diff") +def get_batch_diff( + a: str | None = Query(None), + b: str | None = Query(None), +) -> dict: + """Return a side-by-side diff of two batches identified by id. + + Query params: ``a=``, ``b=`` (both required). + + Response body (snake_case keys, see :mod:`cyclone.batch_diff` for the + projector shapes): + - ``a`` / ``b`` — small metadata blocks (id, kind, parsedAt, + inputFilename, claimCount) + - ``added`` — claims present in B but not A + - ``removed`` — claims present in A but not B + - ``changed`` — claims present in both, with field deltas + - ``summary`` — precomputed counts + + Errors: + - 400 — missing ``a`` or ``b`` + - 404 — either batch id is unknown + + Pure read endpoint — never mutates the store. Both 837P and 835 + batches are accepted (mixed-kind diffs are valid: comparing the + submitted claims against the matching remittances). + """ + if not a or not b: + raise HTTPException( + status_code=400, + detail={"error": "Missing param", "detail": "Both ?a= and ?b= are required."}, + ) + try: + a_rec, b_rec = store.load_two_for_diff(a, b) + except LookupError as exc: + raise HTTPException( + status_code=404, + detail={"error": "Not found", "detail": str(exc)}, + ) + + # Lazy import — keeps the module's import surface small until the + # endpoint is actually hit. + from cyclone.batch_diff import diff_batches_to_wire + + return diff_batches_to_wire(a_rec, b_rec) + + +@router.post("/api/reconciliation/match") +def post_reconciliation_match(body: dict) -> dict: + """Manually pair a Claim with a Remittance (operator override). + + Body: ``{"claim_id": ..., "remit_id": ...}``. Returns + ``{"claim": , "match": }`` on success. Errors: + - 400: missing ``claim_id`` or ``remit_id`` + - 404: claim or remittance not found + - 409: claim already matched, or apply_* returned a noop + (claim in terminal state) — detail echoes ``current_state`` + and ``activity_kind`` so the UI can render a precise message. + """ + claim_id = body.get("claim_id") + remit_id = body.get("remit_id") + if not claim_id or not remit_id: + raise HTTPException( + status_code=400, + detail="claim_id and remit_id required", + ) + try: + return store.manual_match(claim_id, remit_id) + except AlreadyMatchedError as e: + raise HTTPException( + status_code=409, + detail={"error": "already_matched", "message": str(e)}, + ) + except InvalidStateError as e: + raise HTTPException( + status_code=409, + detail={ + "error": "invalid_state", + "current_state": e.current_state, + "activity_kind": e.activity_kind, + }, + ) + except LookupError: + # manual_match raises LookupError when the claim or remittance + # row is missing (we catch the parent class so any future + # KeyError subclasses in the store get the same treatment). + raise HTTPException( + status_code=404, + detail="claim_or_remit_not_found", + ) + + +@router.post("/api/reconciliation/unmatch") +def post_reconciliation_unmatch(body: dict) -> dict: + """Remove the current match for a Claim; reset Claim to submitted. + + Body: ``{"claim_id": ...}``. Returns + ``{"claim": , "deletedMatches": }``. Errors: + - 400: missing ``claim_id`` + - 404: claim not found + - 409: claim has no current match (NotMatchedError is mapped + by the store; we surface 409 to match the manual_match contract) + """ + from cyclone.store import NotMatchedError + claim_id = body.get("claim_id") + if not claim_id: + raise HTTPException( + status_code=400, + detail="claim_id required", + ) + try: + return store.manual_unmatch(claim_id) + except NotMatchedError as e: + raise HTTPException( + status_code=409, + detail={"error": "not_matched", "message": str(e)}, + ) + except LookupError: + raise HTTPException( + status_code=404, + detail="claim_not_found", + )