docs: surface SP14 (Payer-Rejected UI + acknowledge) + SP15 (key rotation) #3
+9
-413
@@ -15,8 +15,6 @@ plus GET/POST with any header.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -26,7 +24,7 @@ from typing import Any, AsyncIterator
|
|||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from cyclone import __version__, db
|
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.inbox_state_277ca import apply_277ca_rejections
|
||||||
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
from cyclone.audit_log import AuditEvent, append_event, verify_chain
|
||||||
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, Envelope
|
||||||
from cyclone.parsers.models_270 import (
|
from cyclone.parsers.models_270 import (
|
||||||
EligibilityBenefitInquiry,
|
EligibilityBenefitInquiry,
|
||||||
InformationReceiver270,
|
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.parse_ta1 import parse_ta1_text
|
||||||
from cyclone.parsers.serialize_270 import serialize_270
|
from cyclone.parsers.serialize_270 import serialize_270
|
||||||
from cyclone.parsers.serialize_999 import serialize_999
|
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.batch_ack_builder import build_ack_for_batch
|
||||||
from cyclone.parsers.validator_835 import validate as validate_835
|
from cyclone.parsers.validator_835 import validate as validate_835
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
@@ -149,7 +147,9 @@ from cyclone.api_routers import ( # noqa: E402
|
|||||||
clearhouse,
|
clearhouse,
|
||||||
config,
|
config,
|
||||||
health,
|
health,
|
||||||
|
inbox,
|
||||||
providers,
|
providers,
|
||||||
|
reconciliation,
|
||||||
remittances,
|
remittances,
|
||||||
ta1_acks,
|
ta1_acks,
|
||||||
)
|
)
|
||||||
@@ -164,6 +164,8 @@ app.include_router(activity.router)
|
|||||||
app.include_router(batches.router)
|
app.include_router(batches.router)
|
||||||
app.include_router(claims.router)
|
app.include_router(claims.router)
|
||||||
app.include_router(remittances.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
|
# Backwards-compat re-exports: test_api_stream_live.py imports these
|
||||||
# names directly from ``cyclone.api`` to invoke the endpoint coroutines
|
# 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")
|
# --- /api/inbox/* lives in cyclone.api_routers.inbox ---
|
||||||
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/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=<batch_id>``, ``b=<batch_id>`` (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=<batch_id> and ?b=<batch_id> 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": <ui>, "match": <ui>}`` 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": <ui>, "deletedMatches": <count>}``. 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 ---
|
# --- /api/remittances lives in cyclone.api_routers.remittances ---
|
||||||
|
|||||||
@@ -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"'},
|
||||||
|
)
|
||||||
@@ -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=<batch_id>``, ``b=<batch_id>`` (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=<batch_id> and ?b=<batch_id> 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": <ui>, "match": <ui>}`` 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": <ui>, "deletedMatches": <count>}``. 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",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user