diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index e386ae5..5a3255c 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -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)]) diff --git a/backend/src/cyclone/api_routers/__init__.py b/backend/src/cyclone/api_routers/__init__.py index 6905649..b27be9d 100644 --- a/backend/src/cyclone/api_routers/__init__.py +++ b/backend/src/cyclone/api_routers/__init__.py @@ -14,6 +14,7 @@ from cyclone.api_routers import ( acks, activity, admin, + batches, claim_acks, clearhouse, config, @@ -32,6 +33,7 @@ routers: list[APIRouter] = [ acks.router, # gated activity.router, # gated admin.router, # gated + batches.router, # gated claim_acks.router, # gated clearhouse.router, # gated config.router, # gated diff --git a/backend/src/cyclone/api_routers/batches.py b/backend/src/cyclone/api_routers/batches.py new file mode 100644 index 0000000..ec9172f --- /dev/null +++ b/backend/src/cyclone/api_routers/batches.py @@ -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())