feat(sp36): extract inbox router (6 routes, 2-cut non-contiguous block)
Task 13.
api_routers/inbox.py (new, 342 lines):
- GET /api/inbox/lanes (compute_lanes)
- POST /api/inbox/candidates/{remit_id}/match (manual link)
- POST /api/inbox/candidates/dismiss (session-scoped dismiss)
- POST /api/inbox/payer-rejected/acknowledge (SP14)
- POST /api/inbox/rejected/resubmit (bulk + ZIP download)
- GET /api/inbox/export.csv (CSV stream)
- The SP14 comment block (# --- Payer-Rejected acknowledge
rationale, idempotency note, audit best-effort note) is
preserved verbatim.
Slicing note: the 6 inbox routes are non-contiguous in api.py —
5 of them are in api.py:1280-1524 and the 6th (export.csv) is
in api.py:1734-1776, with /api/batches/{batch_id}/export-837
sandwiched in between. Extracted in 2 cuts:
Cut A: drop 1-indexed 1280-1524 (5 routes + 4 trailing blanks)
Cut B: drop 1-indexed 1734-1776 + the now-orphaned '# GET
endpoints' section divider (which described the
inbox-export + batches-helpers block we just emptied)
After both cuts, the remaining /api/batches/{batch_id}/export-837
route sits at api.py:1280+ in the new file, and the
_batch_summary_* helpers follow as before. api.py: 2470 -> 2179
LOC (-291; 6 routes extracted).
api_routers/__init__.py: registry extended (alphabetical) with
inbox. 15 routers in registry.
Pytest: bit-identical to baseline — 21 failed, 1246 passed,
10 skipped, 6 errors. (21 fail / 6 errors are pre-existing
rate-limit + test-isolation flakes, not introduced by this
refactor.)
Live-tested via curl on the running container:
GET /api/inbox/lanes -> 401
POST /api/inbox/candidates/<id>/match -> 401
POST /api/inbox/candidates/dismiss -> 401
POST /api/inbox/payer-rejected/acknowledge -> 401
POST /api/inbox/rejected/resubmit -> 401
GET /api/inbox/export.csv -> 401
GET /api/inbox/export.csv?lane=rejected -> 401
POST /api/inbox/lanes -> 405
pr-reviewer: skipped (user chose Tasks 13-16 batch; per-router
reviews will be folded into the Task 17 integration review
per the SP-N plan's note about 'big pytest cycle at the end').
This commit is contained in:
@@ -1277,251 +1277,6 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@app.get("/api/inbox/lanes", dependencies=[Depends(matrix_gate)])
|
||||
def inbox_lanes(request: Request):
|
||||
"""Return all Inbox lanes in one call.
|
||||
|
||||
Uses ``request.app.state`` rather than the module-level ``app``
|
||||
global so the endpoint is robust against ``importlib.reload`` of
|
||||
this module (some tests do this to mutate the CORS allow-list).
|
||||
After a reload, the module-level ``app`` rebinds to a new
|
||||
FastAPI instance; ``request.app`` always points at the instance
|
||||
that is actually serving the current request, so per-request
|
||||
state stays consistent with the test's TestClient target.
|
||||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/inbox/candidates/{remit_id}/match", dependencies=[Depends(matrix_gate)])
|
||||
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", dependencies=[Depends(matrix_gate)])
|
||||
def inbox_dismiss_candidates(body: dict, request: Request):
|
||||
"""Add candidate pairs to the session-scoped dismissed set.
|
||||
|
||||
Uses ``request.app.state`` rather than the module-level ``app``
|
||||
global so the endpoint is robust against ``importlib.reload`` of
|
||||
this module (some tests do this to mutate the CORS allow-list).
|
||||
After a reload, the module-level ``app`` rebinds to a new
|
||||
FastAPI instance; ``request.app`` always points at the instance
|
||||
that is actually serving the current request, so the test's
|
||||
TestClient target is the one whose state we mutate.
|
||||
"""
|
||||
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)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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", dependencies=[Depends(matrix_gate)])
|
||||
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", dependencies=[Depends(matrix_gate)])
|
||||
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.post("/api/batches/{batch_id}/export-837", dependencies=[Depends(matrix_gate)])
|
||||
def export_batch_837(request: Request, batch_id: str, body: dict):
|
||||
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
|
||||
@@ -1731,52 +1486,6 @@ def export_batch_837(request: Request, batch_id: str, body: dict):
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/inbox/export.csv", dependencies=[Depends(matrix_gate)])
|
||||
def inbox_export_csv(lane: str, request: Request):
|
||||
"""Stream a CSV for a single lane.
|
||||
|
||||
Uses ``request.app.state`` rather than the module-level ``app``
|
||||
global so the endpoint is robust against ``importlib.reload`` of
|
||||
this module (see ``inbox_dismiss_candidates`` for context).
|
||||
"""
|
||||
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"'},
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# GET endpoints (read views over the in-memory store)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from cyclone.api_routers import (
|
||||
dashboard,
|
||||
eligibility,
|
||||
health,
|
||||
inbox,
|
||||
payers,
|
||||
providers,
|
||||
reconciliation,
|
||||
@@ -37,6 +38,7 @@ routers: list[APIRouter] = [
|
||||
dashboard.router, # gated
|
||||
eligibility.router, # gated
|
||||
health.router, # public — health probes must work pre-auth
|
||||
inbox.router, # gated
|
||||
payers.router, # gated
|
||||
providers.router, # gated
|
||||
reconciliation.router, # gated
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""``/api/inbox*`` — operator-facing Inbox surface (SP6 + SP14).
|
||||
|
||||
Six endpoints, all gated by ``matrix_gate``:
|
||||
|
||||
- ``GET /api/inbox/lanes`` — all lanes in one
|
||||
call (``compute_lanes`` from :mod:`cyclone.inbox_lanes`).
|
||||
- ``POST /api/inbox/candidates/{remit_id}/match`` — manually link a
|
||||
remit to a claim; surfaces 409 with the current state when the
|
||||
claim is already matched.
|
||||
- ``POST /api/inbox/candidates/dismiss`` — add candidate
|
||||
pairs to the session-scoped dismissed set (mutates
|
||||
``request.app.state.dismissed_pairs``).
|
||||
- ``POST /api/inbox/payer-rejected/acknowledge`` — SP14: mark
|
||||
Payer-Rejected claims as acknowledged. Idempotent; returns the
|
||||
count actually transitioned vs. already-acked / not-found /
|
||||
not-rejected.
|
||||
- ``POST /api/inbox/rejected/resubmit`` — bulk move
|
||||
REJECTED claims back to SUBMITTED. With
|
||||
``?download=true`` returns a ZIP of regenerated 837 files
|
||||
(``serialize_837_for_resubmit`` with per-claim ``interchange_index``
|
||||
for unique control numbers). Conflicts are omitted from the ZIP
|
||||
and surfaced via the ``X-Cyclone-Serialize-Errors`` header.
|
||||
- ``GET /api/inbox/export.csv`` — stream a CSV for
|
||||
a single lane (rejected / candidates / unmatched / done_today).
|
||||
|
||||
All endpoints use ``request.app.state`` rather than the module-level
|
||||
``app`` global so they're robust against ``importlib.reload`` of
|
||||
the api module — the reload rebinds ``app`` to a new instance, but
|
||||
``request.app`` always points at the instance actually serving the
|
||||
current request.
|
||||
|
||||
SP36 Task 13: this block moved here from ``api.py:1280`` (the 6
|
||||
``/api/inbox*`` routes, with the SP14 comment block preserved
|
||||
verbatim).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.auth.deps import matrix_gate
|
||||
from cyclone.db import Claim, ClaimState, Remittance
|
||||
from cyclone.parsers.models import ClaimOutput
|
||||
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837
|
||||
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
|
||||
|
||||
router = APIRouter(dependencies=[Depends(matrix_gate)])
|
||||
|
||||
|
||||
@router.get("/api/inbox/lanes")
|
||||
def inbox_lanes(request: Request):
|
||||
"""Return all Inbox lanes in one call.
|
||||
|
||||
Uses ``request.app.state`` rather than the module-level ``app``
|
||||
global so the endpoint is robust against ``importlib.reload`` of
|
||||
this module (some tests do this to mutate the CORS allow-list).
|
||||
After a reload, the module-level ``app`` rebinds to a new
|
||||
FastAPI instance; ``request.app`` always points at the instance
|
||||
that is actually serving the current request, so per-request
|
||||
state stays consistent with the test's TestClient target.
|
||||
"""
|
||||
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(body: dict, request: Request):
|
||||
"""Add candidate pairs to the session-scoped dismissed set.
|
||||
|
||||
Uses ``request.app.state`` rather than the module-level ``app``
|
||||
global so the endpoint is robust against ``importlib.reload`` of
|
||||
this module (some tests do this to mutate the CORS allow-list).
|
||||
After a reload, the module-level ``app`` rebinds to a new
|
||||
FastAPI instance; ``request.app`` always points at the instance
|
||||
that is actually serving the current request, so the test's
|
||||
TestClient target is the one whose state we mutate.
|
||||
"""
|
||||
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)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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".
|
||||
# --------------------------------------------------------------------------- #
|
||||
@router.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,
|
||||
}
|
||||
|
||||
|
||||
@router.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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/inbox/export.csv")
|
||||
def inbox_export_csv(lane: str, request: Request):
|
||||
"""Stream a CSV for a single lane.
|
||||
|
||||
Uses ``request.app.state`` rather than the module-level ``app``
|
||||
global so the endpoint is robust against ``importlib.reload`` of
|
||||
this module (see ``inbox_dismiss_candidates`` for context).
|
||||
"""
|
||||
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"'},
|
||||
)
|
||||
Reference in New Issue
Block a user