diff --git a/backend/src/cyclone/store.py b/backend/src/cyclone/store.py index c6f5397..de4396a 100644 --- a/backend/src/cyclone/store.py +++ b/backend/src/cyclone/store.py @@ -1297,6 +1297,78 @@ class CycloneStore: # default ``None`` — the UI shows "no match" rather # than crashing. + # SP7 §5.2: slim per-line projection so the ServiceLinesTable + # can show Paid + Adjustments columns without a second fetch. + # The 837 side is keyed by ``claim_service_line_number`` (the + # 1-based line number from raw_json) since 837 service lines + # are not a separate ORM table. + from cyclone.db import ( + LineReconciliation, ServiceLinePayment, CasAdjustment, + ) + slim_lrs = list( + s.query(LineReconciliation) + .filter(LineReconciliation.claim_id == claim_id) + .all() + ) + svc_ids_for_cas = [ + lr.service_line_payment_id + for lr in slim_lrs + if lr.service_line_payment_id is not None + ] + cas_sums_by_svc: dict = {} + svc_by_id_slim: dict = {} + if svc_ids_for_cas: + cas_rows = ( + s.query(CasAdjustment.service_line_payment_id, CasAdjustment.amount) + .filter(CasAdjustment.service_line_payment_id.in_(svc_ids_for_cas)) + .all() + ) + from collections import defaultdict + agg = defaultdict(lambda: Decimal("0")) + for svc_id, amount in cas_rows: + agg[svc_id] += Decimal(str(amount)) + cas_sums_by_svc = {k: str(v) for k, v in agg.items()} + for svc in ( + s.query(ServiceLinePayment) + .filter(ServiceLinePayment.id.in_(svc_ids_for_cas)) + .all() + ): + svc_by_id_slim[svc.id] = svc + + slim_by_num: dict = { + lr.claim_service_line_number: lr + for lr in slim_lrs + if lr.claim_service_line_number is not None + } + line_reconciliation_slim: list = [] + for sl in detail["serviceLines"]: + ln = sl.get("lineNumber") + lr = slim_by_num.get(ln) + if lr is None: + line_reconciliation_slim.append({ + "lineNumber": ln, + "status": "unmatched_837_only", + "paid": None, + "adjustmentsSum": None, + }) + continue + svc = ( + svc_by_id_slim.get(lr.service_line_payment_id) + if lr.service_line_payment_id + else None + ) + line_reconciliation_slim.append({ + "lineNumber": ln, + "status": lr.status, + "paid": str(Decimal(str(svc.payment))) if svc else None, + "adjustmentsSum": ( + cas_sums_by_svc.get(lr.service_line_payment_id) + if lr.service_line_payment_id + else None + ), + }) + detail["lineReconciliation"] = line_reconciliation_slim + return detail def all(self) -> list[BatchRecord]: diff --git a/backend/tests/test_api_claim_detail.py b/backend/tests/test_api_claim_detail.py index efd139d..640c002 100644 --- a/backend/tests/test_api_claim_detail.py +++ b/backend/tests/test_api_claim_detail.py @@ -211,4 +211,26 @@ def test_get_claim_detail_matched_remit_inlines_summary(seeded_store_with_pair): assert mr["status"] in {"received", "reconciled"} # ISO timestamp must end in Z — the spec contract that lets the # UI render the time without a tz suffix shim. - assert mr["receivedAt"].endswith("Z"), mr["receivedAt"] \ No newline at end of file + assert mr["receivedAt"].endswith("Z"), mr["receivedAt"] + + +def test_get_claim_detail_includes_line_reconciliation_slim_projection(seeded_store): + """SP7 §5.2: claim detail exposes a `lineReconciliation` slim list + parallel to `serviceLines`, indexed by line number. The values are + strings (Decimals serialized) or nulls when the line is unmatched.""" + cid = seeded_store.get("/api/claims", headers=JSON).json()["items"][0]["id"] + resp = seeded_store.get(f"/api/claims/{cid}") + assert resp.status_code == 200, resp.text + body = resp.json() + assert "lineReconciliation" in body + lr_list = body["lineReconciliation"] + assert isinstance(lr_list, list) + # No 835 was ingested in this fixture, so every line is unmatched + # on the 835 side — the default for an unpaired claim. + for row in lr_list: + assert "lineNumber" in row + assert "status" in row + assert "paid" in row + assert "adjustmentsSum" in row + # One entry per service line. + assert len(lr_list) == len(body["serviceLines"]) \ No newline at end of file