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)])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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