feat(store): get_claim_detail returns full claim context with history + matched remit
This commit is contained in:
@@ -417,6 +417,203 @@ def to_ui_claim_from_orm(
|
||||
}
|
||||
|
||||
|
||||
# Max number of ActivityEvent rows surfaced in the detail drawer's
|
||||
# state history. The spec caps it at 50; a higher claim volume (manual
|
||||
# match/unmatch thrash) just shows the 50 most recent. Exposed as a
|
||||
# module constant so the endpoint layer can pass it through as a
|
||||
# default if it ever supports a `?limit=N` query param.
|
||||
CLAIM_DETAIL_HISTORY_LIMIT = 50
|
||||
|
||||
|
||||
def _iso_z(value: datetime | None) -> str:
|
||||
"""Format a tz-aware-or-naive UTC datetime as ISO-8601 with trailing Z.
|
||||
|
||||
The DB columns are declared ``DateTime(timezone=True)`` and rows are
|
||||
stored UTC at write time, but SQLite drops the tzinfo on read
|
||||
(returning a naive ``datetime``). Re-attach UTC for naive values
|
||||
so the spec contract holds: every ISO datetime field ends in Z.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _address_to_ui(addr: dict | None) -> dict:
|
||||
"""Render a raw ``Address`` dict in the spec's parties address shape.
|
||||
|
||||
Returns an empty dict when the source is missing so the UI can
|
||||
branch on the field's presence rather than the value. The spec
|
||||
shape is ``{line1, line2|null, city, state, zip}``.
|
||||
"""
|
||||
if not addr:
|
||||
return {}
|
||||
return {
|
||||
"line1": addr.get("line1") or "",
|
||||
"line2": addr.get("line2"),
|
||||
"city": addr.get("city") or "",
|
||||
"state": addr.get("state") or "",
|
||||
"zip": addr.get("zip") or "",
|
||||
}
|
||||
|
||||
|
||||
def _validation_issues_to_ui(issues: list[dict] | None) -> list[dict]:
|
||||
"""Project ValidationIssue dicts onto the spec's per-issue shape.
|
||||
|
||||
Source includes ``segment_index`` (a parser debug aid) which the
|
||||
spec doesn't surface; we drop it. The endpoint contract is
|
||||
``{rule, severity, message}`` per issue.
|
||||
"""
|
||||
if not issues:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"rule": issue.get("rule", ""),
|
||||
"severity": issue.get("severity", "error"),
|
||||
"message": issue.get("message", ""),
|
||||
}
|
||||
for issue in issues
|
||||
]
|
||||
|
||||
|
||||
def to_ui_claim_detail(
|
||||
row: Claim,
|
||||
*,
|
||||
batch_id: str,
|
||||
parsed_at: datetime,
|
||||
) -> dict:
|
||||
"""Map an ORM ``Claim`` row to the SP4 detail-drawer UI shape.
|
||||
|
||||
A superset of :func:`to_ui_claim_from_orm`: same top-level identity
|
||||
fields, plus the full parties / validation / service-lines /
|
||||
diagnoses / raw-segments / service-date / state-label payload that
|
||||
the drawer needs. ``matchedRemittance`` and ``stateHistory`` are
|
||||
*not* filled in here — they require extra queries and are stitched
|
||||
in by :meth:`CycloneStore.get_claim_detail`.
|
||||
|
||||
The mapper is deliberately a pure function (no DB I/O) so the
|
||||
endpoint layer can call it from a worker thread or swap the
|
||||
history/remittance sources for tests without re-implementing the
|
||||
body.
|
||||
"""
|
||||
raw = row.raw_json or {}
|
||||
bp = raw.get("billing_provider", {}) or {}
|
||||
payer_obj = raw.get("payer", {}) or {}
|
||||
sub = raw.get("subscriber", {}) or {}
|
||||
service_lines = raw.get("service_lines", []) or []
|
||||
diagnoses = raw.get("diagnoses", []) or []
|
||||
validation = raw.get("validation", {}) or {}
|
||||
raw_segments = raw.get("raw_segments", []) or []
|
||||
|
||||
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
|
||||
state_value = (
|
||||
row.state.value if hasattr(row.state, "value") else str(row.state)
|
||||
)
|
||||
|
||||
# Service dates come from the dedicated ORM columns (denormalized at
|
||||
# ingest in _claim_837_row so the list views can sort/filter on
|
||||
# them without a JSON parse). ``isoformat()`` on a ``date`` gives
|
||||
# ``YYYY-MM-DD`` — the spec shape.
|
||||
service_date_from_iso = (
|
||||
row.service_date_from.isoformat() if row.service_date_from else None
|
||||
)
|
||||
service_date_to_iso = (
|
||||
row.service_date_to.isoformat() if row.service_date_to else None
|
||||
)
|
||||
|
||||
return {
|
||||
# -- identity + state -----------------------------------------
|
||||
"id": row.id,
|
||||
"batchId": batch_id,
|
||||
"state": state_value,
|
||||
"stateLabel": state_value.capitalize(),
|
||||
# -- money + dates --------------------------------------------
|
||||
"billedAmount": float(row.charge_amount or 0),
|
||||
"serviceDateFrom": service_date_from_iso,
|
||||
"serviceDateTo": service_date_to_iso,
|
||||
"submissionDate": parsed_iso,
|
||||
"parsedAt": parsed_iso,
|
||||
# -- patient / provider / payer -------------------------------
|
||||
"patientName": (
|
||||
f"{sub.get('first_name', '')} {sub.get('last_name', '')}".strip()
|
||||
),
|
||||
"providerNpi": bp.get("npi") or row.provider_npi or "",
|
||||
"providerName": bp.get("name") or "",
|
||||
"payerName": payer_obj.get("name") or "",
|
||||
"payerId": payer_obj.get("id") or row.payer_id or "",
|
||||
# -- diagnoses ------------------------------------------------
|
||||
"diagnoses": [
|
||||
{
|
||||
"code": d.get("code", ""),
|
||||
"qualifier": d.get("qualifier"),
|
||||
}
|
||||
for d in diagnoses
|
||||
],
|
||||
# -- service lines --------------------------------------------
|
||||
# ``service_lines[i].procedure`` is a nested dict in the
|
||||
# serialized raw_json; the spec flattens it into the line.
|
||||
# ``charge`` and ``units`` are stored as Decimal via Pydantic
|
||||
# and serialized to string — coerce defensively. ``modifiers``
|
||||
# defaults to [] so the UI doesn't have to handle null.
|
||||
"serviceLines": [
|
||||
{
|
||||
"lineNumber": sl.get("line_number"),
|
||||
"procedureQualifier": (
|
||||
sl.get("procedure", {}).get("qualifier", "") or ""
|
||||
),
|
||||
"procedureCode": (
|
||||
sl.get("procedure", {}).get("code", "") or ""
|
||||
),
|
||||
"modifiers": list(
|
||||
sl.get("procedure", {}).get("modifiers") or []
|
||||
),
|
||||
"charge": float(sl.get("charge") or 0),
|
||||
"units": (
|
||||
float(sl["units"])
|
||||
if sl.get("units") is not None
|
||||
else None
|
||||
),
|
||||
"unitType": sl.get("unit_type"),
|
||||
"serviceDate": sl.get("service_date"),
|
||||
}
|
||||
for sl in service_lines
|
||||
],
|
||||
# -- parties --------------------------------------------------
|
||||
"parties": {
|
||||
"billingProvider": {
|
||||
"name": bp.get("name") or "",
|
||||
"npi": bp.get("npi") or "",
|
||||
"taxId": bp.get("tax_id") or "",
|
||||
"address": _address_to_ui(bp.get("address")),
|
||||
},
|
||||
"subscriber": {
|
||||
"firstName": sub.get("first_name") or "",
|
||||
"lastName": sub.get("last_name") or "",
|
||||
"memberId": sub.get("member_id") or "",
|
||||
"dob": sub.get("dob"),
|
||||
"gender": sub.get("gender"),
|
||||
},
|
||||
"payer": {
|
||||
"name": payer_obj.get("name") or "",
|
||||
"id": payer_obj.get("id") or "",
|
||||
},
|
||||
},
|
||||
# -- validation ----------------------------------------------
|
||||
"validation": {
|
||||
"passed": bool(validation.get("passed", True)),
|
||||
"errors": _validation_issues_to_ui(validation.get("errors")),
|
||||
"warnings": _validation_issues_to_ui(validation.get("warnings")),
|
||||
},
|
||||
# -- raw segments (debug aid) --------------------------------
|
||||
"rawSegments": raw_segments,
|
||||
# -- matched remittance (filled by get_claim_detail) ---------
|
||||
"matchedRemittance": None,
|
||||
# -- state history (filled by get_claim_detail) --------------
|
||||
"stateHistory": [],
|
||||
}
|
||||
|
||||
|
||||
def to_ui_remittance_from_orm(
|
||||
row: Remittance,
|
||||
*,
|
||||
@@ -852,6 +1049,96 @@ class CycloneStore:
|
||||
cas_rows=cas_rows,
|
||||
)
|
||||
|
||||
def get_claim_detail(self, claim_id: str) -> dict | None:
|
||||
"""Return the SP4 detail-drawer shape for one claim, or ``None``.
|
||||
|
||||
Drives ``GET /api/claims/{claim_id}``. Returns the spec-shaped
|
||||
dict from :func:`to_ui_claim_detail` (header + state + parties +
|
||||
validation + service lines + diagnoses + raw segments) stitched
|
||||
with the claim's recent activity history and, if paired, a
|
||||
matched-remittance summary.
|
||||
|
||||
Returns ``None`` when ``claim_id`` is not in the DB so the API
|
||||
layer can map that to a 404 — the URL-driven drawer
|
||||
distinguishes "claim doesn't exist" from "fetch failed" (the
|
||||
spec §3.4 calls for a distinct 404 state in the drawer).
|
||||
|
||||
The history is capped at :data:`CLAIM_DETAIL_HISTORY_LIMIT`
|
||||
(50, per the spec) and ordered ``ts DESC`` so the most recent
|
||||
event is first. The status string in ``matchedRemittance``
|
||||
follows the same ``reconciled``/``received`` mapping used by
|
||||
:func:`to_ui_remittance_from_orm`.
|
||||
"""
|
||||
# Lazy import — same pattern used throughout this module to
|
||||
# avoid a circular store ↔ db import on cold start.
|
||||
from cyclone import db as _db
|
||||
|
||||
with _db.SessionLocal()() as s:
|
||||
row = s.get(Claim, claim_id)
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
history_rows = (
|
||||
s.query(ActivityEvent)
|
||||
.filter(ActivityEvent.claim_id == claim_id)
|
||||
.order_by(ActivityEvent.ts.desc())
|
||||
.limit(CLAIM_DETAIL_HISTORY_LIMIT)
|
||||
.all()
|
||||
)
|
||||
|
||||
parsed_at = (
|
||||
row.batch.parsed_at
|
||||
if row.batch is not None
|
||||
else None
|
||||
)
|
||||
if parsed_at is not None and parsed_at.tzinfo is None:
|
||||
parsed_at = parsed_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
detail = to_ui_claim_detail(
|
||||
row,
|
||||
batch_id=row.batch_id,
|
||||
parsed_at=parsed_at,
|
||||
)
|
||||
|
||||
detail["stateHistory"] = [
|
||||
{
|
||||
"kind": ev.kind,
|
||||
# SQLite drops tzinfo on read; rows are stored UTC
|
||||
# at write time (see ``add`` / ``manual_match``),
|
||||
# so re-attach UTC if needed to keep the spec
|
||||
# contract that ``ts`` ends in Z.
|
||||
"ts": _iso_z(ev.ts),
|
||||
"batchId": ev.batch_id,
|
||||
"remittanceId": ev.remittance_id,
|
||||
}
|
||||
for ev in history_rows
|
||||
]
|
||||
|
||||
if row.matched_remittance_id is not None:
|
||||
remit = s.get(Remittance, row.matched_remittance_id)
|
||||
if remit is not None:
|
||||
status = (
|
||||
"reconciled"
|
||||
if remit.status_code in ("21", "22")
|
||||
else "received"
|
||||
)
|
||||
detail["matchedRemittance"] = {
|
||||
"id": remit.id,
|
||||
"totalPaid": float(remit.total_paid or 0),
|
||||
"status": status,
|
||||
"receivedAt": _iso_z(remit.received_at),
|
||||
}
|
||||
# If the remittance was deleted out from under the FK
|
||||
# (the FK is ``ON DELETE SET NULL`` so the column is
|
||||
# already cleared in normal flow), the matched_remittance_id
|
||||
# would be None here and we wouldn't enter this branch.
|
||||
# If the FK is non-null but the row is gone (e.g. tests
|
||||
# that bypass the cascade), fall through with the
|
||||
# default ``None`` — the UI shows "no match" rather
|
||||
# than crashing.
|
||||
|
||||
return detail
|
||||
|
||||
def all(self) -> list[BatchRecord]:
|
||||
"""Return every ``BatchRecord``, oldest first (no pagination)."""
|
||||
with db.SessionLocal()() as s:
|
||||
|
||||
Reference in New Issue
Block a user