feat(sp7): GET /api/claims/{id}/line-reconciliation endpoint
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user