feat(sp36): extract batches router (3 routes + 3 single-router helpers)
Task 14.
api_routers/batches.py (new, 450 lines):
- POST /api/batches/{batch_id}/export-837 (regenerated 837 ZIP)
- GET /api/batches (summary list with
SP30 billing-outcome fields)
- GET /api/batches/{batch_id} (full record)
- 3 single-router helpers stay in-file per D4:
_batch_summary_claim_count, _batch_summary_claim_ids,
_batch_summary_billing_outcomes
- SP30 state-bucket tuples + 277CA STC category comment preserved
- All inline imports inside handlers preserved verbatim
(zipfile, datetime, ZoneInfo, build_outbound_filename,
PayerConfigORM, sqlalchemy.func)
Slicing: 1 contiguous cut (drop 1-indexed 1276-1734 = the now-orphan
'# SP6 Inbox endpoints' section divider + the entire batches block).
After the cut, the next route is /api/claims at api.py:1278 with
the standard 3-blank separator.
Import fix: BatchRecord lives in cyclone.store, not cyclone.db.
Initial draft imported it from cyclone.db which crashed the
import; corrected to 'from cyclone.store import BatchRecord, store'.
api.py: 2179 -> 1720 LOC (-459; 3 routes + 3 helpers extracted).
api_routers/__init__.py: registry extended (alphabetical) with
batches. 16 routers in registry.
Pytest: bit-identical to baseline — 21 failed, 1246 passed,
10 skipped, 6 errors.
Live-tested via curl on the running container:
GET /api/batches -> 401
GET /api/batches/<id> -> 401
POST /api/batches/<id>/export-837 -> 401 (gated before body parse)
GET /api/batches/<id>/export-837 -> 405 (method-not-allowed)
GET /api/batches/<id>/serialize-837 -> 404 (no such route)
pr-reviewer: skipped (Tasks 13-16 batch — folded into Task 17).
This commit is contained in:
@@ -1273,465 +1273,6 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# SP6 — Inbox endpoints
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
@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.
|
|
||||||
|
|
||||||
Body shape: ``{"claim_ids": [str, ...]}``.
|
|
||||||
|
|
||||||
Each successfully serialized claim becomes an entry in the ZIP named
|
|
||||||
per the HCPF X12 File Naming Standards:
|
|
||||||
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
|
|
||||||
millisecond offset so every file in the bundle has a unique name).
|
|
||||||
The ``serialize_837_for_resubmit`` serializer is used so every file
|
|
||||||
gets a unique interchange / group control number — back-to-back
|
|
||||||
exports of the same set must produce different envelopes (required
|
|
||||||
by X12).
|
|
||||||
|
|
||||||
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
|
|
||||||
the clearhouse singleton (dzinesco's identity in the seeded config)
|
|
||||||
and the receiver block (NM1*40) is populated from the per-payer
|
|
||||||
config. Without this wiring, the serializer falls back to
|
|
||||||
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
|
|
||||||
|
|
||||||
No DB state is mutated by this endpoint — it is read-only. Compare
|
|
||||||
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
|
|
||||||
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
|
|
||||||
intentionally separate.
|
|
||||||
|
|
||||||
Responses:
|
|
||||||
200 — ``application/zip`` with the .x12 entries. Per-claim failures
|
|
||||||
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
|
|
||||||
(JSON-encoded array of ``{claim_id, reason}``).
|
|
||||||
400 — ``claim_ids`` missing or empty.
|
|
||||||
404 — ``batch_id`` unknown.
|
|
||||||
422 — every claim failed to serialize; body is JSON listing all
|
|
||||||
failures (``{"detail": {"serialize_errors": [...]}}``).
|
|
||||||
"""
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from cyclone.edi.filenames import build_outbound_filename
|
|
||||||
|
|
||||||
ids = body.get("claim_ids") or []
|
|
||||||
if not ids:
|
|
||||||
raise HTTPException(400, "claim_ids required")
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
batch = s.get(Batch, batch_id)
|
|
||||||
if batch is None:
|
|
||||||
raise HTTPException(404, f"unknown batch: {batch_id}")
|
|
||||||
|
|
||||||
serialize_errors: list[dict] = []
|
|
||||||
ordered_rows: list[tuple[str, "Claim"]] = []
|
|
||||||
for cid in ids:
|
|
||||||
c = s.get(Claim, cid)
|
|
||||||
if c is None:
|
|
||||||
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
|
|
||||||
continue
|
|
||||||
ordered_rows.append((cid, c))
|
|
||||||
|
|
||||||
# Pull clearhouse identity (submitter). If unseeded, the serializer
|
|
||||||
# falls back to placeholder defaults — degraded but not a hard error.
|
|
||||||
ch = store.get_clearhouse()
|
|
||||||
submitter_kwargs: dict = {}
|
|
||||||
if ch is not None:
|
|
||||||
submitter_kwargs = {
|
|
||||||
"sender_id": ch.tpid,
|
|
||||||
"submitter_name": ch.submitter_name,
|
|
||||||
"submitter_contact_name": ch.submitter_contact_name,
|
|
||||||
"submitter_contact_email": ch.submitter_contact_email,
|
|
||||||
}
|
|
||||||
# Submitter phone is not in the clearhouse config today, but if
|
|
||||||
# it ever is, wire it here. Email is the canonical contact
|
|
||||||
# channel for HCPF submissions per the SP9 spec.
|
|
||||||
if getattr(ch, "submitter_contact_phone", None):
|
|
||||||
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
|
|
||||||
|
|
||||||
# Resolve per-claim payer config so each file's receiver (NM1*40)
|
|
||||||
# and SBR09 are correct. Cache so we don't re-query the same payer.
|
|
||||||
from cyclone.db import PayerConfigORM as _PayerConfigORM
|
|
||||||
_payer_cache: dict[str, dict | None] = {}
|
|
||||||
|
|
||||||
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
|
|
||||||
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
|
|
||||||
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
|
|
||||||
cache_key = pid or pname
|
|
||||||
if cache_key in _payer_cache:
|
|
||||||
return _payer_cache[cache_key]
|
|
||||||
cfg: dict | None = None
|
|
||||||
with db.SessionLocal()() as ss:
|
|
||||||
# 1. Exact match on (payer_id, "837P")
|
|
||||||
if pid:
|
|
||||||
row = ss.get(_PayerConfigORM, (pid, "837P"))
|
|
||||||
if row is not None:
|
|
||||||
cfg = dict(row.config_json)
|
|
||||||
# 2. Fallback: any row whose payer_id matches the parsed payer.name
|
|
||||||
# (HCPF files emit "SKCO0" in NM109 but the canonical
|
|
||||||
# payer_id in the DB is "CO_TXIX" — name-matching is the
|
|
||||||
# pragmatic lookup for that case).
|
|
||||||
if cfg is None and pname:
|
|
||||||
row = (
|
|
||||||
ss.query(_PayerConfigORM)
|
|
||||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
for r in row:
|
|
||||||
cj = dict(r.config_json)
|
|
||||||
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
|
|
||||||
cfg = cj
|
|
||||||
break
|
|
||||||
if (r.payer_id or "").upper() == pname.upper():
|
|
||||||
cfg = cj
|
|
||||||
break
|
|
||||||
# 3. Last resort: first 837P row in the table.
|
|
||||||
if cfg is None:
|
|
||||||
row = (
|
|
||||||
ss.query(_PayerConfigORM)
|
|
||||||
.filter(_PayerConfigORM.transaction_type == "837P")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if row is not None:
|
|
||||||
cfg = dict(row.config_json)
|
|
||||||
_payer_cache[cache_key] = cfg
|
|
||||||
return cfg
|
|
||||||
|
|
||||||
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
|
|
||||||
# defaults to the parsed payer name/ID if no config row matches.
|
|
||||||
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
|
|
||||||
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
|
|
||||||
receiver_id = (
|
|
||||||
payer_cfg.get("receiver_id")
|
|
||||||
or (claim_obj.payer.id if claim_obj.payer else None)
|
|
||||||
or "RECEIVER"
|
|
||||||
)
|
|
||||||
receiver_name = (
|
|
||||||
payer_cfg.get("receiver_name")
|
|
||||||
or (claim_obj.payer.name if claim_obj.payer else None)
|
|
||||||
or receiver_id
|
|
||||||
)
|
|
||||||
sbr09 = payer_cfg.get("sbr09_default") or "MC"
|
|
||||||
return {
|
|
||||||
"receiver_id": receiver_id,
|
|
||||||
"receiver_name": receiver_name,
|
|
||||||
"claim_filing_indicator_code": sbr09,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Base MT timestamp for HCPF filenames. We add a per-claim
|
|
||||||
# millisecond offset so each file in the ZIP has a unique 17-digit
|
|
||||||
# ts (HCPF requires that; the spec also enforces "1of1" for the
|
|
||||||
# sequence element).
|
|
||||||
base_ts = datetime.now(ZoneInfo("America/Denver"))
|
|
||||||
|
|
||||||
def _per_claim_filename(idx: int, cid: str) -> str:
|
|
||||||
if ch is None:
|
|
||||||
# No clearhouse — fall back to a per-claim friendly name.
|
|
||||||
return f"claim-{cid}.x12"
|
|
||||||
# Millisecond offset, with second/minute rollover.
|
|
||||||
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
|
|
||||||
ts_mt = base_ts.fromtimestamp(
|
|
||||||
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
|
|
||||||
)
|
|
||||||
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
|
|
||||||
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
||||||
for idx, (cid, c) in enumerate(ordered_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:
|
|
||||||
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
|
|
||||||
text = serialize_837_for_resubmit(
|
|
||||||
claim_obj, interchange_index=idx, **kwargs
|
|
||||||
)
|
|
||||||
except SerializeError837 as exc:
|
|
||||||
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
|
|
||||||
continue
|
|
||||||
zf.writestr(_per_claim_filename(idx, cid), text)
|
|
||||||
|
|
||||||
success_count = len(ids) - len(serialize_errors)
|
|
||||||
if serialize_errors and success_count == 0:
|
|
||||||
# Every claim failed — surface the failure list in the body so the
|
|
||||||
# UI can render a useful error toast (the response is not a ZIP).
|
|
||||||
raise HTTPException(
|
|
||||||
422,
|
|
||||||
detail={"serialize_errors": serialize_errors},
|
|
||||||
)
|
|
||||||
|
|
||||||
buf.seek(0)
|
|
||||||
headers = {
|
|
||||||
"Content-Disposition": (
|
|
||||||
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
|
|
||||||
),
|
|
||||||
}
|
|
||||||
if serialize_errors:
|
|
||||||
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
|
|
||||||
return Response(
|
|
||||||
content=buf.getvalue(),
|
|
||||||
media_type="application/zip",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
|
||||||
|
|
||||||
|
|
||||||
def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
|
||||||
"""Return the number of claims on a batch, handling both 837P and 835."""
|
|
||||||
if rec.kind == "837p":
|
|
||||||
return len(rec.result.claims) # type: ignore[attr-defined]
|
|
||||||
if rec.kind == "835":
|
|
||||||
return len(rec.result.claims) # type: ignore[attr-defined]
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
|
|
||||||
"""Return per-claim ids for an 837P batch, or ``[]`` otherwise.
|
|
||||||
|
|
||||||
The Upload page's History tab renders a one-click Re-export ZIP
|
|
||||||
button per row; that button calls
|
|
||||||
``POST /api/batches/{id}/export-837`` with the row's claim ids.
|
|
||||||
Carrying them in the list response avoids an extra round-trip
|
|
||||||
to ``/api/batches/{id}`` for every row. 835 has no re-export
|
|
||||||
endpoint, so the list is empty for those — the UI uses the
|
|
||||||
empty list as the signal to hide the button.
|
|
||||||
"""
|
|
||||||
if rec.kind != "837p":
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
c.claim_id
|
|
||||||
for c in rec.result.claims # type: ignore[attr-defined]
|
|
||||||
if getattr(c, "claim_id", None)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# SP30: state buckets the Dashboard widget (and any future "how the
|
|
||||||
# last batch billed" surface) reads at a glance. Keep these in sync
|
|
||||||
# with ClaimState — adding a new state here is a deliberate decision
|
|
||||||
# the operator needs to see, not a coincidence.
|
|
||||||
_BATCH_SUMMARY_ACCEPTED_STATES: tuple[ClaimState, ...] = (
|
|
||||||
ClaimState.PAID,
|
|
||||||
ClaimState.RECEIVED,
|
|
||||||
ClaimState.RECONCILED,
|
|
||||||
ClaimState.PARTIAL,
|
|
||||||
)
|
|
||||||
_BATCH_SUMMARY_REJECTED_STATES: tuple[ClaimState, ...] = (
|
|
||||||
ClaimState.REJECTED,
|
|
||||||
ClaimState.DENIED,
|
|
||||||
ClaimState.REVERSED,
|
|
||||||
)
|
|
||||||
_BATCH_SUMMARY_PENDING_STATES: tuple[ClaimState, ...] = (
|
|
||||||
ClaimState.SUBMITTED,
|
|
||||||
)
|
|
||||||
# 277CA STC category A4/A6/A7 — payer-side rejections that may not
|
|
||||||
# yet have flipped Claim.state (the operator hasn't acknowledged).
|
|
||||||
# The Dashboard widget treats these as problems too, mirroring the
|
|
||||||
# Inbox `rejected + payer_rejected` aggregation.
|
|
||||||
_BATCH_SUMMARY_PAYER_REJECT_CODES: tuple[str, ...] = ("A4", "A6", "A7")
|
|
||||||
|
|
||||||
|
|
||||||
def _batch_summary_billing_outcomes(
|
|
||||||
records: list[BatchRecord],
|
|
||||||
) -> dict[str, dict]:
|
|
||||||
"""Compute per-batch billing outcome for the Dashboard widget.
|
|
||||||
|
|
||||||
Returns ``{batch_id: {accepted, rejected, pending, billed,
|
|
||||||
top_rejection_reason, has_problem}}`` for every batch in
|
|
||||||
``records``. Empty input → empty dict.
|
|
||||||
|
|
||||||
Two SQL queries, both bounded by the supplied batch ids:
|
|
||||||
|
|
||||||
1. One GROUP BY ``(batch_id, state)`` aggregate that produces
|
|
||||||
the accepted/rejected/pending counts and the sum of
|
|
||||||
``charge_amount`` (the billed total). Single pass — no N+1.
|
|
||||||
2. One ordered scan over the rejected + payer-rejected subset
|
|
||||||
to pick the most recent rejection reason (truncated to 60
|
|
||||||
chars). Skipped when the first query found no rejections
|
|
||||||
and no payer-rejects, so the happy path stays at one query.
|
|
||||||
|
|
||||||
835 batches have no Claim rows — the GROUP BY returns no
|
|
||||||
rows for them, so the dict entry for an 835 batch is
|
|
||||||
``{accepted:0, rejected:0, pending:0, billed:0.0,
|
|
||||||
top_rejection_reason:None, has_problem:False}`` (filled by
|
|
||||||
the caller's ``.get(id, defaults)`` pattern).
|
|
||||||
"""
|
|
||||||
if not records:
|
|
||||||
return {}
|
|
||||||
from sqlalchemy import func # local import to keep top-of-file light
|
|
||||||
|
|
||||||
batch_ids = [r.id for r in records]
|
|
||||||
outcome: dict[str, dict] = {
|
|
||||||
bid: {
|
|
||||||
"accepted": 0,
|
|
||||||
"rejected": 0,
|
|
||||||
"pending": 0,
|
|
||||||
"billed": 0.0,
|
|
||||||
"top_rejection_reason": None,
|
|
||||||
"has_problem": False,
|
|
||||||
}
|
|
||||||
for bid in batch_ids
|
|
||||||
}
|
|
||||||
|
|
||||||
with db.SessionLocal()() as s:
|
|
||||||
# ---- 1. GROUP BY (batch_id, state) for counts + billed total ----
|
|
||||||
rows = (
|
|
||||||
s.query(
|
|
||||||
Claim.batch_id,
|
|
||||||
Claim.state,
|
|
||||||
func.count(Claim.id),
|
|
||||||
func.coalesce(func.sum(Claim.charge_amount), 0),
|
|
||||||
)
|
|
||||||
.filter(Claim.batch_id.in_(batch_ids))
|
|
||||||
.group_by(Claim.batch_id, Claim.state)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
any_rejection_or_payer = False
|
|
||||||
for batch_id, state, count, billed in rows:
|
|
||||||
slot = outcome.get(batch_id)
|
|
||||||
if slot is None:
|
|
||||||
continue # batch has no row in our pre-allocated dict
|
|
||||||
count = int(count or 0)
|
|
||||||
billed_f = float(billed or 0)
|
|
||||||
slot["billed"] += billed_f
|
|
||||||
if state in _BATCH_SUMMARY_ACCEPTED_STATES:
|
|
||||||
slot["accepted"] += count
|
|
||||||
elif state in _BATCH_SUMMARY_REJECTED_STATES:
|
|
||||||
slot["rejected"] += count
|
|
||||||
any_rejection_or_payer = True
|
|
||||||
elif state in _BATCH_SUMMARY_PENDING_STATES:
|
|
||||||
slot["pending"] += count
|
|
||||||
# everything else (DRAFT, etc.) is excluded from the widget.
|
|
||||||
|
|
||||||
# ---- 2. Most-recent rejection reason + payer-reject probe ----
|
|
||||||
# Only run when we know there IS at least one rejection OR a
|
|
||||||
# payer-reject claim somewhere in the batch set; otherwise
|
|
||||||
# the first query alone is enough.
|
|
||||||
if any_rejection_or_payer:
|
|
||||||
rej_rows = (
|
|
||||||
s.query(
|
|
||||||
Claim.batch_id,
|
|
||||||
Claim.rejection_reason,
|
|
||||||
Claim.payer_rejected_status_code,
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
Claim.batch_id.in_(batch_ids),
|
|
||||||
Claim.state.in_(_BATCH_SUMMARY_REJECTED_STATES)
|
|
||||||
| Claim.payer_rejected_status_code.in_(
|
|
||||||
_BATCH_SUMMARY_PAYER_REJECT_CODES
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.order_by(Claim.rejected_at.desc().nullslast())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
seen_reason: set[str] = set()
|
|
||||||
for batch_id, reason, payer_code in rej_rows:
|
|
||||||
slot = outcome.get(batch_id)
|
|
||||||
if slot is None:
|
|
||||||
continue
|
|
||||||
if payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES:
|
|
||||||
slot["has_problem"] = True
|
|
||||||
# Capture the first non-null reason for this batch
|
|
||||||
# (rej_rows is ordered newest-first, so the first
|
|
||||||
# non-null wins). Truncate to 60 chars + ellipsis.
|
|
||||||
if (
|
|
||||||
slot["top_rejection_reason"] is None
|
|
||||||
and reason
|
|
||||||
and batch_id not in seen_reason
|
|
||||||
):
|
|
||||||
r = reason.strip()
|
|
||||||
if len(r) > 60:
|
|
||||||
r = r[:60] + "…"
|
|
||||||
slot["top_rejection_reason"] = r
|
|
||||||
seen_reason.add(batch_id)
|
|
||||||
if (
|
|
||||||
slot["rejected"] > 0
|
|
||||||
or payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES
|
|
||||||
):
|
|
||||||
slot["has_problem"] = True
|
|
||||||
|
|
||||||
return outcome
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batches", dependencies=[Depends(matrix_gate)])
|
|
||||||
def list_batches(
|
|
||||||
request: Request,
|
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
|
||||||
) -> Any:
|
|
||||||
"""Summary of all parsed batches, newest first.
|
|
||||||
|
|
||||||
Each item includes ``claimIds`` (837P only) so the History tab
|
|
||||||
on the Upload page can render a one-click re-export button per
|
|
||||||
row without an extra round-trip to ``/api/batches/{id}``. The
|
|
||||||
list is still capped at ``limit`` claims; see the full result
|
|
||||||
via the by-id endpoint when more is needed.
|
|
||||||
|
|
||||||
SP30: also returns billing-outcome fields
|
|
||||||
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
|
|
||||||
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so
|
|
||||||
the Dashboard "Recent batches" widget can render one row per
|
|
||||||
batch without an N+1 fetch. See
|
|
||||||
:func:`_batch_summary_billing_outcomes`.
|
|
||||||
"""
|
|
||||||
records = store.list(limit=limit)
|
|
||||||
outcomes = _batch_summary_billing_outcomes(records)
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"kind": r.kind,
|
|
||||||
"inputFilename": r.input_filename,
|
|
||||||
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
|
|
||||||
"claimCount": _batch_summary_claim_count(r),
|
|
||||||
"claimIds": _batch_summary_claim_ids(r),
|
|
||||||
"acceptedCount": outcomes.get(r.id, {}).get("accepted", 0),
|
|
||||||
"rejectedCount": outcomes.get(r.id, {}).get("rejected", 0),
|
|
||||||
"pendingCount": outcomes.get(r.id, {}).get("pending", 0),
|
|
||||||
"billedTotal": round(outcomes.get(r.id, {}).get("billed", 0.0), 2),
|
|
||||||
"topRejectionReason": outcomes.get(r.id, {}).get(
|
|
||||||
"top_rejection_reason"
|
|
||||||
),
|
|
||||||
"hasProblem": outcomes.get(r.id, {}).get("has_problem", False),
|
|
||||||
}
|
|
||||||
for r in records
|
|
||||||
]
|
|
||||||
all_records = store.all()
|
|
||||||
total = len(all_records)
|
|
||||||
returned = len(items)
|
|
||||||
has_more = total > returned
|
|
||||||
if _wants_ndjson(request):
|
|
||||||
return StreamingResponse(
|
|
||||||
_ndjson_stream_list(items, total, returned, has_more),
|
|
||||||
media_type="application/x-ndjson",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"items": items,
|
|
||||||
"total": total,
|
|
||||||
"returned": returned,
|
|
||||||
"has_more": has_more,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/batches/{batch_id}", dependencies=[Depends(matrix_gate)])
|
|
||||||
def get_batch(batch_id: str) -> Any:
|
|
||||||
rec = store.get(batch_id)
|
|
||||||
if rec is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail={"error": "Not found", "detail": f"Batch {batch_id} not found"},
|
|
||||||
)
|
|
||||||
return json.loads(rec.result.model_dump_json())
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
|
@app.get("/api/claims", dependencies=[Depends(matrix_gate)])
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from cyclone.api_routers import (
|
|||||||
acks,
|
acks,
|
||||||
activity,
|
activity,
|
||||||
admin,
|
admin,
|
||||||
|
batches,
|
||||||
claim_acks,
|
claim_acks,
|
||||||
clearhouse,
|
clearhouse,
|
||||||
config,
|
config,
|
||||||
@@ -32,6 +33,7 @@ routers: list[APIRouter] = [
|
|||||||
acks.router, # gated
|
acks.router, # gated
|
||||||
activity.router, # gated
|
activity.router, # gated
|
||||||
admin.router, # gated
|
admin.router, # gated
|
||||||
|
batches.router, # gated
|
||||||
claim_acks.router, # gated
|
claim_acks.router, # gated
|
||||||
clearhouse.router, # gated
|
clearhouse.router, # gated
|
||||||
config.router, # gated
|
config.router, # gated
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
"""``/api/batches*`` — read views + ZIP export over the parsed-batch population.
|
||||||
|
|
||||||
|
Three endpoints, all gated by ``matrix_gate``:
|
||||||
|
|
||||||
|
- ``POST /api/batches/{batch_id}/export-837`` — download a ZIP of
|
||||||
|
regenerated X12 837 files for the requested claim ids. Per-claim
|
||||||
|
payer config + clearhouse identity drive the submitter/receiver
|
||||||
|
blocks; per-claim millisecond offset on the filename keeps every
|
||||||
|
file in the bundle unique (HCPF requires this). Read-only — does
|
||||||
|
NOT mutate Claim state (compare with
|
||||||
|
``/api/inbox/rejected/resubmit?download=true`` which DOES flip
|
||||||
|
``REJECTED → SUBMITTED``).
|
||||||
|
- ``GET /api/batches`` — summary list,
|
||||||
|
newest-first, capped at ``limit``. Each row includes ``claimIds``
|
||||||
|
(837P only, so the Upload page can render a one-click Re-export
|
||||||
|
button per row without a round-trip to ``/api/batches/{id}``).
|
||||||
|
SP30: also returns billing-outcome fields
|
||||||
|
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
|
||||||
|
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so the
|
||||||
|
Dashboard "Recent batches" widget can render one row per batch
|
||||||
|
without an N+1 fetch.
|
||||||
|
- ``GET /api/batches/{batch_id}`` — full batch record
|
||||||
|
(parsed envelope + claims). 404 when unknown.
|
||||||
|
|
||||||
|
Three single-router helpers stay in this file (per spec D4):
|
||||||
|
|
||||||
|
- :func:`_batch_summary_claim_count` — claim count (837P or 835).
|
||||||
|
- :func:`_batch_summary_claim_ids` — per-claim ids (837P only).
|
||||||
|
- :func:`_batch_summary_billing_outcomes` — per-batch GROUP BY state
|
||||||
|
aggregate plus the most-recent rejection reason.
|
||||||
|
|
||||||
|
Inline imports inside handlers (preserved verbatim per spec D5):
|
||||||
|
``zipfile``, ``datetime``, ``ZoneInfo``, ``build_outbound_filename``,
|
||||||
|
``PayerConfigORM``, ``func`` (sqlalchemy).
|
||||||
|
|
||||||
|
SP36 Task 14: this block moved here from ``api.py:1280`` (the 3
|
||||||
|
``/api/batches*`` routes + 3 single-router helpers + the SP30
|
||||||
|
state-bucket tuples and the ``# 277CA STC category`` note).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.api_helpers import (
|
||||||
|
ndjson_stream_list as _ndjson_stream_list,
|
||||||
|
wants_ndjson as _wants_ndjson,
|
||||||
|
)
|
||||||
|
from cyclone.auth.deps import matrix_gate
|
||||||
|
from cyclone.db import Batch, Claim, ClaimState
|
||||||
|
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
|
||||||
|
from cyclone.store import BatchRecord, store
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(matrix_gate)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/batches/{batch_id}/export-837")
|
||||||
|
def export_batch_837(request: Request, batch_id: str, body: dict):
|
||||||
|
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
|
||||||
|
|
||||||
|
Body shape: ``{"claim_ids": [str, ...]}``.
|
||||||
|
|
||||||
|
Each successfully serialized claim becomes an entry in the ZIP named
|
||||||
|
per the HCPF X12 File Naming Standards:
|
||||||
|
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
|
||||||
|
millisecond offset so every file in the bundle has a unique name).
|
||||||
|
The ``serialize_837_for_resubmit`` serializer is used so every file
|
||||||
|
gets a unique interchange / group control number — back-to-back
|
||||||
|
exports of the same set must produce different envelopes (required
|
||||||
|
by X12).
|
||||||
|
|
||||||
|
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
|
||||||
|
the clearhouse singleton (dzinesco's identity in the seeded config)
|
||||||
|
and the receiver block (NM1*40) is populated from the per-payer
|
||||||
|
config. Without this wiring, the serializer falls back to
|
||||||
|
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
|
||||||
|
|
||||||
|
No DB state is mutated by this endpoint — it is read-only. Compare
|
||||||
|
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
|
||||||
|
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
|
||||||
|
intentionally separate.
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
200 — ``application/zip`` with the .x12 entries. Per-claim failures
|
||||||
|
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
|
||||||
|
(JSON-encoded array of ``{claim_id, reason}``).
|
||||||
|
400 — ``claim_ids`` missing or empty.
|
||||||
|
404 — ``batch_id`` unknown.
|
||||||
|
422 — every claim failed to serialize; body is JSON listing all
|
||||||
|
failures (``{"detail": {"serialize_errors": [...]}}``).
|
||||||
|
"""
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from cyclone.edi.filenames import build_outbound_filename
|
||||||
|
|
||||||
|
ids = body.get("claim_ids") or []
|
||||||
|
if not ids:
|
||||||
|
raise HTTPException(400, "claim_ids required")
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
batch = s.get(Batch, batch_id)
|
||||||
|
if batch is None:
|
||||||
|
raise HTTPException(404, f"unknown batch: {batch_id}")
|
||||||
|
|
||||||
|
serialize_errors: list[dict] = []
|
||||||
|
ordered_rows: list[tuple[str, "Claim"]] = []
|
||||||
|
for cid in ids:
|
||||||
|
c = s.get(Claim, cid)
|
||||||
|
if c is None:
|
||||||
|
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
|
||||||
|
continue
|
||||||
|
ordered_rows.append((cid, c))
|
||||||
|
|
||||||
|
# Pull clearhouse identity (submitter). If unseeded, the serializer
|
||||||
|
# falls back to placeholder defaults — degraded but not a hard error.
|
||||||
|
ch = store.get_clearhouse()
|
||||||
|
submitter_kwargs: dict = {}
|
||||||
|
if ch is not None:
|
||||||
|
submitter_kwargs = {
|
||||||
|
"sender_id": ch.tpid,
|
||||||
|
"submitter_name": ch.submitter_name,
|
||||||
|
"submitter_contact_name": ch.submitter_contact_name,
|
||||||
|
"submitter_contact_email": ch.submitter_contact_email,
|
||||||
|
}
|
||||||
|
# Submitter phone is not in the clearhouse config today, but if
|
||||||
|
# it ever is, wire it here. Email is the canonical contact
|
||||||
|
# channel for HCPF submissions per the SP9 spec.
|
||||||
|
if getattr(ch, "submitter_contact_phone", None):
|
||||||
|
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
|
||||||
|
|
||||||
|
# Resolve per-claim payer config so each file's receiver (NM1*40)
|
||||||
|
# and SBR09 are correct. Cache so we don't re-query the same payer.
|
||||||
|
from cyclone.db import PayerConfigORM as _PayerConfigORM
|
||||||
|
_payer_cache: dict[str, dict | None] = {}
|
||||||
|
|
||||||
|
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
|
||||||
|
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
|
||||||
|
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
|
||||||
|
cache_key = pid or pname
|
||||||
|
if cache_key in _payer_cache:
|
||||||
|
return _payer_cache[cache_key]
|
||||||
|
cfg: dict | None = None
|
||||||
|
with db.SessionLocal()() as ss:
|
||||||
|
# 1. Exact match on (payer_id, "837P")
|
||||||
|
if pid:
|
||||||
|
row = ss.get(_PayerConfigORM, (pid, "837P"))
|
||||||
|
if row is not None:
|
||||||
|
cfg = dict(row.config_json)
|
||||||
|
# 2. Fallback: any row whose payer_id matches the parsed payer.name
|
||||||
|
# (HCPF files emit "SKCO0" in NM109 but the canonical
|
||||||
|
# payer_id in the DB is "CO_TXIX" — name-matching is the
|
||||||
|
# pragmatic lookup for that case).
|
||||||
|
if cfg is None and pname:
|
||||||
|
row = (
|
||||||
|
ss.query(_PayerConfigORM)
|
||||||
|
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for r in row:
|
||||||
|
cj = dict(r.config_json)
|
||||||
|
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
|
||||||
|
cfg = cj
|
||||||
|
break
|
||||||
|
if (r.payer_id or "").upper() == pname.upper():
|
||||||
|
cfg = cj
|
||||||
|
break
|
||||||
|
# 3. Last resort: first 837P row in the table.
|
||||||
|
if cfg is None:
|
||||||
|
row = (
|
||||||
|
ss.query(_PayerConfigORM)
|
||||||
|
.filter(_PayerConfigORM.transaction_type == "837P")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if row is not None:
|
||||||
|
cfg = dict(row.config_json)
|
||||||
|
_payer_cache[cache_key] = cfg
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
|
||||||
|
# defaults to the parsed payer name/ID if no config row matches.
|
||||||
|
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
|
||||||
|
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
|
||||||
|
receiver_id = (
|
||||||
|
payer_cfg.get("receiver_id")
|
||||||
|
or (claim_obj.payer.id if claim_obj.payer else None)
|
||||||
|
or "RECEIVER"
|
||||||
|
)
|
||||||
|
receiver_name = (
|
||||||
|
payer_cfg.get("receiver_name")
|
||||||
|
or (claim_obj.payer.name if claim_obj.payer else None)
|
||||||
|
or receiver_id
|
||||||
|
)
|
||||||
|
sbr09 = payer_cfg.get("sbr09_default") or "MC"
|
||||||
|
return {
|
||||||
|
"receiver_id": receiver_id,
|
||||||
|
"receiver_name": receiver_name,
|
||||||
|
"claim_filing_indicator_code": sbr09,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Base MT timestamp for HCPF filenames. We add a per-claim
|
||||||
|
# millisecond offset so each file in the ZIP has a unique 17-digit
|
||||||
|
# ts (HCPF requires that; the spec also enforces "1of1" for the
|
||||||
|
# sequence element).
|
||||||
|
base_ts = datetime.now(ZoneInfo("America/Denver"))
|
||||||
|
|
||||||
|
def _per_claim_filename(idx: int, cid: str) -> str:
|
||||||
|
if ch is None:
|
||||||
|
# No clearhouse — fall back to a per-claim friendly name.
|
||||||
|
return f"claim-{cid}.x12"
|
||||||
|
# Millisecond offset, with second/minute rollover.
|
||||||
|
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
|
||||||
|
ts_mt = base_ts.fromtimestamp(
|
||||||
|
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
|
||||||
|
)
|
||||||
|
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for idx, (cid, c) in enumerate(ordered_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:
|
||||||
|
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
|
||||||
|
text = serialize_837_for_resubmit(
|
||||||
|
claim_obj, interchange_index=idx, **kwargs
|
||||||
|
)
|
||||||
|
except SerializeError837 as exc:
|
||||||
|
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
|
||||||
|
continue
|
||||||
|
zf.writestr(_per_claim_filename(idx, cid), text)
|
||||||
|
|
||||||
|
success_count = len(ids) - len(serialize_errors)
|
||||||
|
if serialize_errors and success_count == 0:
|
||||||
|
# Every claim failed — surface the failure list in the body so the
|
||||||
|
# UI can render a useful error toast (the response is not a ZIP).
|
||||||
|
raise HTTPException(
|
||||||
|
422,
|
||||||
|
detail={"serialize_errors": serialize_errors},
|
||||||
|
)
|
||||||
|
|
||||||
|
buf.seek(0)
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": (
|
||||||
|
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if serialize_errors:
|
||||||
|
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_summary_claim_count(rec: BatchRecord) -> int:
|
||||||
|
"""Return the number of claims on a batch, handling both 837P and 835."""
|
||||||
|
if rec.kind == "837p":
|
||||||
|
return len(rec.result.claims) # type: ignore[attr-defined]
|
||||||
|
if rec.kind == "835":
|
||||||
|
return len(rec.result.claims) # type: ignore[attr-defined]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
|
||||||
|
"""Return per-claim ids for an 837P batch, or ``[]`` otherwise.
|
||||||
|
|
||||||
|
The Upload page's History tab renders a one-click Re-export ZIP
|
||||||
|
button per row; that button calls
|
||||||
|
``POST /api/batches/{id}/export-837`` with the row's claim ids.
|
||||||
|
Carrying them in the list response avoids an extra round-trip
|
||||||
|
to ``/api/batches/{id}`` for every row. 835 has no re-export
|
||||||
|
endpoint, so the list is empty for those — the UI uses the
|
||||||
|
empty list as the signal to hide the button.
|
||||||
|
"""
|
||||||
|
if rec.kind != "837p":
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
c.claim_id
|
||||||
|
for c in rec.result.claims # type: ignore[attr-defined]
|
||||||
|
if getattr(c, "claim_id", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# SP30: state buckets the Dashboard widget (and any future "how the
|
||||||
|
# last batch billed" surface) reads at a glance. Keep these in sync
|
||||||
|
# with ClaimState — adding a new state here is a deliberate decision
|
||||||
|
# the operator needs to see, not a coincidence.
|
||||||
|
_BATCH_SUMMARY_ACCEPTED_STATES: tuple[ClaimState, ...] = (
|
||||||
|
ClaimState.PAID,
|
||||||
|
ClaimState.RECEIVED,
|
||||||
|
ClaimState.RECONCILED,
|
||||||
|
ClaimState.PARTIAL,
|
||||||
|
)
|
||||||
|
_BATCH_SUMMARY_REJECTED_STATES: tuple[ClaimState, ...] = (
|
||||||
|
ClaimState.REJECTED,
|
||||||
|
ClaimState.DENIED,
|
||||||
|
ClaimState.REVERSED,
|
||||||
|
)
|
||||||
|
_BATCH_SUMMARY_PENDING_STATES: tuple[ClaimState, ...] = (
|
||||||
|
ClaimState.SUBMITTED,
|
||||||
|
)
|
||||||
|
# 277CA STC category A4/A6/A7 — payer-side rejections that may not
|
||||||
|
# yet have flipped Claim.state (the operator hasn't acknowledged).
|
||||||
|
# The Dashboard widget treats these as problems too, mirroring the
|
||||||
|
# Inbox `rejected + payer_rejected` aggregation.
|
||||||
|
_BATCH_SUMMARY_PAYER_REJECT_CODES: tuple[str, ...] = ("A4", "A6", "A7")
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_summary_billing_outcomes(
|
||||||
|
records: list[BatchRecord],
|
||||||
|
) -> dict[str, dict]:
|
||||||
|
"""Compute per-batch billing outcome for the Dashboard widget.
|
||||||
|
|
||||||
|
Returns ``{batch_id: {accepted, rejected, pending, billed,
|
||||||
|
top_rejection_reason, has_problem}}`` for every batch in
|
||||||
|
``records``. Empty input → empty dict.
|
||||||
|
|
||||||
|
Two SQL queries, both bounded by the supplied batch ids:
|
||||||
|
|
||||||
|
1. One GROUP BY ``(batch_id, state)`` aggregate that produces
|
||||||
|
the accepted/rejected/pending counts and the sum of
|
||||||
|
``charge_amount`` (the billed total). Single pass — no N+1.
|
||||||
|
2. One ordered scan over the rejected + payer-rejected subset
|
||||||
|
to pick the most recent rejection reason (truncated to 60
|
||||||
|
chars). Skipped when the first query found no rejections
|
||||||
|
and no payer-rejects, so the happy path stays at one query.
|
||||||
|
|
||||||
|
835 batches have no Claim rows — the GROUP BY returns no
|
||||||
|
rows for them, so the dict entry for an 835 batch is
|
||||||
|
``{accepted:0, rejected:0, pending:0, billed:0.0,
|
||||||
|
top_rejection_reason:None, has_problem:False}`` (filled by
|
||||||
|
the caller's ``.get(id, defaults)`` pattern).
|
||||||
|
"""
|
||||||
|
if not records:
|
||||||
|
return {}
|
||||||
|
from sqlalchemy import func # local import to keep top-of-file light
|
||||||
|
|
||||||
|
batch_ids = [r.id for r in records]
|
||||||
|
outcome: dict[str, dict] = {
|
||||||
|
bid: {
|
||||||
|
"accepted": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"billed": 0.0,
|
||||||
|
"top_rejection_reason": None,
|
||||||
|
"has_problem": False,
|
||||||
|
}
|
||||||
|
for bid in batch_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
# ---- 1. GROUP BY (batch_id, state) for counts + billed total ----
|
||||||
|
rows = (
|
||||||
|
s.query(
|
||||||
|
Claim.batch_id,
|
||||||
|
Claim.state,
|
||||||
|
func.count(Claim.id),
|
||||||
|
func.coalesce(func.sum(Claim.charge_amount), 0),
|
||||||
|
)
|
||||||
|
.filter(Claim.batch_id.in_(batch_ids))
|
||||||
|
.group_by(Claim.batch_id, Claim.state)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
any_rejection_or_payer = False
|
||||||
|
for batch_id, state, count, billed in rows:
|
||||||
|
slot = outcome.get(batch_id)
|
||||||
|
if slot is None:
|
||||||
|
continue # batch has no row in our pre-allocated dict
|
||||||
|
count = int(count or 0)
|
||||||
|
billed_f = float(billed or 0)
|
||||||
|
slot["billed"] += billed_f
|
||||||
|
if state in _BATCH_SUMMARY_ACCEPTED_STATES:
|
||||||
|
slot["accepted"] += count
|
||||||
|
elif state in _BATCH_SUMMARY_REJECTED_STATES:
|
||||||
|
slot["rejected"] += count
|
||||||
|
any_rejection_or_payer = True
|
||||||
|
elif state in _BATCH_SUMMARY_PENDING_STATES:
|
||||||
|
slot["pending"] += count
|
||||||
|
# everything else (DRAFT, etc.) is excluded from the widget.
|
||||||
|
|
||||||
|
# ---- 2. Most-recent rejection reason + payer-reject probe ----
|
||||||
|
# Only run when we know there IS at least one rejection OR a
|
||||||
|
# payer-reject claim somewhere in the batch set; otherwise
|
||||||
|
# the first query alone is enough.
|
||||||
|
if any_rejection_or_payer:
|
||||||
|
rej_rows = (
|
||||||
|
s.query(
|
||||||
|
Claim.batch_id,
|
||||||
|
Claim.rejection_reason,
|
||||||
|
Claim.payer_rejected_status_code,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
Claim.batch_id.in_(batch_ids),
|
||||||
|
Claim.state.in_(_BATCH_SUMMARY_REJECTED_STATES)
|
||||||
|
| Claim.payer_rejected_status_code.in_(
|
||||||
|
_BATCH_SUMMARY_PAYER_REJECT_CODES
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(Claim.rejected_at.desc().nullslast())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
seen_reason: set[str] = set()
|
||||||
|
for batch_id, reason, payer_code in rej_rows:
|
||||||
|
slot = outcome.get(batch_id)
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
if payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES:
|
||||||
|
slot["has_problem"] = True
|
||||||
|
# Capture the first non-null reason for this batch
|
||||||
|
# (rej_rows is ordered newest-first, so the first
|
||||||
|
# non-null wins). Truncate to 60 chars + ellipsis.
|
||||||
|
if (
|
||||||
|
slot["top_rejection_reason"] is None
|
||||||
|
and reason
|
||||||
|
and batch_id not in seen_reason
|
||||||
|
):
|
||||||
|
r = reason.strip()
|
||||||
|
if len(r) > 60:
|
||||||
|
r = r[:60] + "…"
|
||||||
|
slot["top_rejection_reason"] = r
|
||||||
|
seen_reason.add(batch_id)
|
||||||
|
if (
|
||||||
|
slot["rejected"] > 0
|
||||||
|
or payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES
|
||||||
|
):
|
||||||
|
slot["has_problem"] = True
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/batches")
|
||||||
|
def list_batches(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
|
) -> Any:
|
||||||
|
"""Summary of all parsed batches, newest first.
|
||||||
|
|
||||||
|
Each item includes ``claimIds`` (837P only) so the History tab
|
||||||
|
on the Upload page can render a one-click re-export button per
|
||||||
|
row without an extra round-trip to ``/api/batches/{id}``. The
|
||||||
|
list is still capped at ``limit`` claims; see the full result
|
||||||
|
via the by-id endpoint when more is needed.
|
||||||
|
|
||||||
|
SP30: also returns billing-outcome fields
|
||||||
|
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
|
||||||
|
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so
|
||||||
|
the Dashboard "Recent batches" widget can render one row per
|
||||||
|
batch without an N+1 fetch. See
|
||||||
|
:func:`_batch_summary_billing_outcomes`.
|
||||||
|
"""
|
||||||
|
records = store.list(limit=limit)
|
||||||
|
outcomes = _batch_summary_billing_outcomes(records)
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"kind": r.kind,
|
||||||
|
"inputFilename": r.input_filename,
|
||||||
|
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
|
||||||
|
"claimCount": _batch_summary_claim_count(r),
|
||||||
|
"claimIds": _batch_summary_claim_ids(r),
|
||||||
|
"acceptedCount": outcomes.get(r.id, {}).get("accepted", 0),
|
||||||
|
"rejectedCount": outcomes.get(r.id, {}).get("rejected", 0),
|
||||||
|
"pendingCount": outcomes.get(r.id, {}).get("pending", 0),
|
||||||
|
"billedTotal": round(outcomes.get(r.id, {}).get("billed", 0.0), 2),
|
||||||
|
"topRejectionReason": outcomes.get(r.id, {}).get(
|
||||||
|
"top_rejection_reason"
|
||||||
|
),
|
||||||
|
"hasProblem": outcomes.get(r.id, {}).get("has_problem", False),
|
||||||
|
}
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
all_records = store.all()
|
||||||
|
total = len(all_records)
|
||||||
|
returned = len(items)
|
||||||
|
has_more = total > returned
|
||||||
|
if _wants_ndjson(request):
|
||||||
|
return StreamingResponse(
|
||||||
|
_ndjson_stream_list(items, total, returned, has_more),
|
||||||
|
media_type="application/x-ndjson",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"returned": returned,
|
||||||
|
"has_more": has_more,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/batches/{batch_id}")
|
||||||
|
def get_batch(batch_id: str) -> Any:
|
||||||
|
rec = store.get(batch_id)
|
||||||
|
if rec is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={"error": "Not found", "detail": f"Batch {batch_id} not found"},
|
||||||
|
)
|
||||||
|
return json.loads(rec.result.model_dump_json())
|
||||||
Reference in New Issue
Block a user