feat(sp7): GET /api/claims/{id}/line-reconciliation endpoint

This commit is contained in:
Tyler
2026-06-20 19:35:17 -06:00
parent c1323c44d0
commit 48d4739864
2 changed files with 309 additions and 0 deletions
+197
View File
@@ -1212,6 +1212,203 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
return body
@app.get("/api/claims/{claim_id}/line-reconciliation")
def get_claim_line_reconciliation(claim_id: str) -> dict:
"""Per-line reconciliation view for the ClaimDrawer tab.
Spec §5.1. Returns the 837 service lines and 835 SVC composites
side-by-side, with per-line CAS adjustments and a summary block.
Architecture note: 837 service lines live in ``Claim.raw_json``
(not a separate ORM table), so the 837-side rows are read from the
JSON blob; the 835-side rows come from ``ServiceLinePayment`` ORM.
``LineReconciliation.claim_service_line_number`` stores the 1-based
line number to join them.
"""
from sqlalchemy import select
from cyclone.db import (
LineReconciliation, ServiceLinePayment, CasAdjustment,
)
import json as _json
from decimal import Decimal
with db.SessionLocal()() as s:
claim = s.get(db.Claim, claim_id)
if claim is None:
raise HTTPException(
status_code=404,
detail={"error": "Not found", "detail": f"Claim {claim_id} not found"},
)
# 837 service lines: from raw_json.
raw = claim.raw_json or {}
claim_lines_raw = raw.get("service_lines") or []
# Normalize to dicts for the response.
claim_lines = [_claim_line_dict(d) for d in claim_lines_raw]
# 835 service payments: ORM rows from the matched remit.
remits = list(
s.execute(
select(db.Remittance).where(db.Remittance.claim_id == claim_id)
).scalars().all()
)
svc_payments: list[dict] = []
svc_ids: list[int] = []
if remits:
svc_rows = list(
s.execute(
select(ServiceLinePayment).where(
ServiceLinePayment.remittance_id.in_([r.id for r in remits])
).order_by(ServiceLinePayment.line_number)
).scalars().all()
)
for svc in svc_rows:
d = _svc_to_dict(svc)
svc_payments.append(d)
svc_ids.append(svc.id)
# LineReconciliation rows.
lrs = list(
s.execute(
select(LineReconciliation).where(LineReconciliation.claim_id == claim_id)
).scalars().all()
)
# Index by claim_service_line_number and service_line_payment_id.
lr_by_claim_num: dict[int, LineReconciliation] = {
lr.claim_service_line_number: lr for lr in lrs if lr.claim_service_line_number is not None
}
lr_by_svc: dict[int, LineReconciliation] = {
lr.service_line_payment_id: lr for lr in lrs if lr.service_line_payment_id is not None
}
# CAS rows grouped by svc id.
cas_by_svc: dict[int, list[CasAdjustment]] = {}
if svc_ids:
cas_rows = list(
s.execute(
select(CasAdjustment).where(CasAdjustment.service_line_payment_id.in_(svc_ids))
).scalars().all()
)
for c in cas_rows:
cas_by_svc.setdefault(c.service_line_payment_id, []).append(c)
# Build output lines array, preserving 837 order then 835-only.
svc_by_id: dict[int, dict] = {d["id"]: d for d in svc_payments}
lines_out: list[dict] = []
billed_total = Decimal("0")
paid_total = Decimal("0")
adjustment_total = Decimal("0")
matched_count = 0
used_svc_ids: set[int] = set()
for cl in claim_lines:
billed_total += Decimal(str(cl["charge"]))
lr = lr_by_claim_num.get(cl["line_number"])
if lr is None:
lines_out.append({
"claim_service_line": cl,
"service_line_payment": None,
"status": "unmatched_837_only",
"adjustments": [],
})
continue
svc_id = lr.service_line_payment_id
svc = svc_by_id.get(svc_id) if svc_id else None
if svc_id is not None:
used_svc_ids.add(svc_id)
cas_list = cas_by_svc.get(svc_id, []) if svc_id is not None else []
cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
if svc:
paid_total += Decimal(str(svc["payment"]))
adjustment_total += cas_total
if lr.status == "matched":
matched_count += 1
lines_out.append({
"claim_service_line": cl,
"service_line_payment": svc,
"status": lr.status,
"adjustments": [
{"group_code": c.group_code, "reason_code": c.reason_code,
"amount": str(Decimal(str(c.amount)))}
for c in cas_list
],
})
# 835-only lines (no claim match).
for lr in lrs:
if lr.claim_service_line_number is not None:
continue
svc_id = lr.service_line_payment_id
if svc_id is None:
continue
if svc_id in used_svc_ids:
continue
svc = svc_by_id.get(svc_id)
cas_list = cas_by_svc.get(svc_id, [])
cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
if svc:
paid_total += Decimal(str(svc["payment"]))
adjustment_total += cas_total
lines_out.append({
"claim_service_line": None,
"service_line_payment": svc,
"status": lr.status,
"adjustments": [
{"group_code": c.group_code, "reason_code": c.reason_code,
"amount": str(Decimal(str(c.amount)))}
for c in cas_list
],
})
return {
"claim_id": claim_id,
"summary": {
"billed_total": str(billed_total),
"paid_total": str(paid_total),
"adjustment_total": str(adjustment_total),
"matched_lines": matched_count,
"total_lines": len(claim_lines),
},
"lines": lines_out,
}
def _claim_line_dict(d: dict) -> dict:
"""Project an 837 service-line dict from ``Claim.raw_json`` to wire shape."""
from decimal import Decimal
proc = d.get("procedure") or {}
charge = d.get("charge")
units = d.get("units")
return {
"line_number": d.get("line_number"),
"procedure_qualifier": proc.get("qualifier", "HC"),
"procedure_code": proc.get("code", ""),
"modifiers": proc.get("modifiers") or [],
"charge": str(Decimal(str(charge))) if charge is not None else "0",
"units": str(Decimal(str(units))) if units is not None else None,
"unit_type": d.get("unit_type"),
"service_date": d.get("service_date"),
}
def _svc_to_dict(svc) -> dict:
"""Project an ORM ``ServiceLinePayment`` to wire shape."""
import json as _json
from decimal import Decimal
return {
"id": svc.id,
"line_number": svc.line_number,
"procedure_qualifier": svc.procedure_qualifier,
"procedure_code": svc.procedure_code,
"modifiers": _json.loads(svc.modifiers_json or "[]"),
"charge": str(Decimal(str(svc.charge))),
"payment": str(Decimal(str(svc.payment))),
"units": str(Decimal(str(svc.units))) if svc.units is not None else None,
"unit_type": svc.unit_type,
"service_date": svc.service_date.isoformat() if svc.service_date else None,
}
@app.get("/api/reconciliation/unmatched")
def get_reconciliation_unmatched() -> dict:
"""Return unmatched Claims (left) and unmatched Remittances (right).
@@ -0,0 +1,112 @@
"""SP7 — endpoint test for GET /api/claims/{id}/line-reconciliation.
Spec §5.1.
Architecture note: 837 service lines live in ``Claim.raw_json``, not a
separate ORM table, so the test seeds them via the raw_json blob.
"""
from datetime import date, datetime, timezone
from decimal import Decimal
import json
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from cyclone import db
from cyclone.api import app
from cyclone.parsers.models_835 import ClaimPayment, ServicePayment
from cyclone.reconcile import run
from cyclone.store import _persist_835_remit, _remittance_835_row
@pytest.fixture
def client():
return TestClient(app)
def _seed(client):
"""Insert one claim + matching remit + 4 service lines (3 matched, 1 unmatched 837).
Returns the claim id."""
bid = str(uuid.uuid4())
claim_id = "CLM-API-1"
svc_lines_json = []
# 4 837 service lines: 99213, 99214, 99215, 90837 (last one is 837-only — no SVC)
for i, code in enumerate(["99213", "99214", "99215", "90837"], start=1):
svc_lines_json.append({
"line_number": i,
"procedure": {"qualifier": "HC", "code": code, "modifiers": []},
"charge": "100.00",
"unit_type": "UN",
"units": "1",
"place_of_service": "11",
"service_date": f"2026-06-0{i}",
"provider_reference": None,
})
with db.SessionLocal()() as s:
s.add(db.Batch(
id=bid, kind="835", input_filename="x.835",
parsed_at=datetime.now(timezone.utc),
totals_json={"total_claims": 1, "total_paid": "240"},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"_": "stub"},
))
s.add(db.Claim(
id=claim_id,
patient_control_number="PCN-API",
charge_amount=Decimal("300.00"),
provider_npi="1234567890",
payer_id="SKCO0",
service_date_from=date(2026, 6, 1),
service_date_to=date(2026, 6, 4),
state="submitted",
batch_id=bid,
raw_json={"service_lines": svc_lines_json},
))
# Remit pays the first 3 lines (line 4 is 837-only)
cp = ClaimPayment(
payer_claim_control_number="PCN-API",
status_code="1",
total_charge=Decimal("300.00"),
total_paid=Decimal("240.00"),
service_payments=[
ServicePayment(
line_number=i, procedure_qualifier="HC",
procedure_code=code, charge=Decimal("100.00"),
payment=Decimal("80.00"),
units=Decimal("1"),
service_date=date(2026, 6, i),
)
for i, code in enumerate(["99213", "99214", "99215"], start=1)
],
)
remit = _remittance_835_row(cp, bid)
s.add(remit)
s.flush()
_persist_835_remit(s, cp, remit.id)
s.commit()
# Run reconcile so matched_remittance_id gets set + LineReconciliation rows persist
run(s, bid)
s.commit()
return claim_id
def test_endpoint_returns_documented_shape(client):
claim_id = _seed(client)
r = client.get(f"/api/claims/{claim_id}/line-reconciliation")
assert r.status_code == 200, r.text
body = r.json()
assert body["claim_id"] == claim_id
assert body["summary"]["total_lines"] == 4
assert body["summary"]["matched_lines"] == 3
statuses = sorted(row["status"] for row in body["lines"])
assert statuses == ["matched", "matched", "matched", "unmatched_837_only"]
def test_endpoint_404_for_unknown_claim(client):
r = client.get("/api/claims/UNKNOWN/line-reconciliation")
assert r.status_code == 404