feat(backend): add reconcile.run orchestrator with match + apply + CAS agg

This commit is contained in:
Tyler
2026-06-19 22:54:25 -06:00
parent c3306b882a
commit 8c92ef43bd
2 changed files with 269 additions and 6 deletions
+111 -1
View File
@@ -18,10 +18,12 @@ Match algorithm:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Iterable, Optional, Protocol from typing import Iterable, Optional, Protocol
from sqlalchemy import func
from cyclone.db import ClaimState from cyclone.db import ClaimState
@@ -206,3 +208,111 @@ def split_unmatched(
[c for c in claims if c.id not in matched_claim_ids], [c for c in claims if c.id not in matched_claim_ids],
[r for r in remits if r.id not in matched_remit_ids], [r for r in remits if r.id not in matched_remit_ids],
) )
# --- reconcile.run() orchestrator ---------------------------------------------
@dataclass
class ReconcileResult:
matched: int = 0
unmatched_claims: int = 0
unmatched_remittances: int = 0
skipped: int = 0
def run(session, batch_id: str) -> ReconcileResult:
"""Orchestrate reconciliation for an 835 batch.
Expects Batch + Remittance + CasAdjustment rows already persisted
(this is called from store.add() AFTER commit). Loads the new
remittances + all currently-unmatched Claims, calls match(), then
applies each match's intent inside the caller's session.
Returns counts. Does NOT commit; caller controls transaction.
Raises on failure (caller catches and writes activity event).
"""
from sqlalchemy import select
from cyclone.db import (
Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent,
ClaimState,
)
new_remits = list(
session.execute(
select(Remittance).where(Remittance.batch_id == batch_id)
).scalars().all()
)
unmatched_claims = list(
session.execute(
select(Claim).where(Claim.matched_remittance_id.is_(None))
).scalars().all()
)
matches = match(unmatched_claims, new_remits)
applied = 0
skipped = 0
for m in matches:
if m.is_reversal:
intent = apply_reversal(m.claim, m.remittance)
else:
intent = apply_payment(
m.claim, m.remittance,
charge=m.claim.charge_amount, paid=m.remittance.total_paid,
status_code=m.remittance.status_code,
)
if intent.skipped:
session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json={"reason": intent.reason,
"current_state": getattr(m.claim.state, "value", m.claim.state)},
))
skipped += 1
continue
session.add(Match(
claim_id=m.claim.id, remittance_id=m.remittance.id,
strategy=m.strategy,
matched_at=datetime.now(timezone.utc),
prior_claim_state=intent.prior_claim_state,
is_reversal=m.is_reversal,
))
if not m.is_reversal:
m.claim.state = intent.new_state
else:
m.claim.state = ClaimState.REVERSED
m.claim.matched_remittance_id = m.remittance.id
session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json={"new_state": m.claim.state.value},
))
applied += 1
# Aggregate CAS adjustments per Remittance.
for r in new_remits:
total = session.execute(
select(func.sum(CasAdjustment.amount))
.where(CasAdjustment.remittance_id == r.id)
).scalar_one() or Decimal("0")
r.adjustment_amount = total
# Partition.
unmatched_claims_after, unmatched_remits_after = split_unmatched(
unmatched_claims, new_remits,
[m for m in matches if m.claim.id in {c.id for c in unmatched_claims}],
)
return ReconcileResult(
matched=applied,
unmatched_claims=len(unmatched_claims_after),
unmatched_remittances=len(unmatched_remits_after),
skipped=skipped,
)
+158 -5
View File
@@ -7,15 +7,22 @@ SQLAlchemy ORM instances, so the reconcile module has zero DB dependency.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional
import pytest import pytest
from cyclone import reconcile from cyclone import db, reconcile
from cyclone.db import ClaimState from cyclone.db import (
from cyclone.reconcile import Match ActivityEvent,
Batch,
Claim,
ClaimState,
Match,
Remittance,
)
from cyclone.reconcile import Match as ReconcileMatch
# --- Stand-in types ----------------------------------------------------------- # --- Stand-in types -----------------------------------------------------------
@@ -196,7 +203,153 @@ def test_apply_reversal_of_already_reversed_is_noop():
def test_split_unmatched_partitions_by_match_set(): def test_split_unmatched_partitions_by_match_set():
claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)] claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)]
remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)] remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)]
matches = [Match(claim=claims[0], remittance=remits[0], strategy="auto", is_reversal=False)] matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="auto", is_reversal=False)]
unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches) unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches)
assert [c.id for c in unmatched_claims] == ["CLM-2"] assert [c.id for c in unmatched_claims] == ["CLM-2"]
assert [r.id for r in unmatched_remits] == ["CLP-2"] assert [r.id for r in unmatched_remits] == ["CLP-2"]
# --- reconcile.run() integration tests ----------------------------------------
@pytest.fixture
def fixture_835(tmp_path, monkeypatch):
"""Set up a clean DB and return an empty helper for building remits."""
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/reconcile.db")
db._reset_for_tests()
db.init_db()
yield
db._reset_for_tests()
def _make_batch(s, batch_id="b1", kind="835"):
s.add(Batch(
id=batch_id, kind=kind, input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.flush()
def _make_claim(s, claim_id="CLM-1", pcn="PCN-A", charge="100.00",
service_date=None, state=ClaimState.SUBMITTED):
s.add(Claim(
id=claim_id, batch_id="b1", patient_control_number=pcn,
service_date_from=service_date, charge_amount=Decimal(charge),
state=state,
))
s.flush()
def _make_remit(s, remit_id="CLP-1", pcn="PCN-A", status="1",
charge="100.00", paid="100.00", service_date=None,
is_reversal=False):
rid = remit_id + ":b1xxxxxx"
s.add(Remittance(
id=rid, batch_id="b1", payer_claim_control_number=pcn,
status_code=status, total_charge=Decimal(charge), total_paid=Decimal(paid),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
service_date=service_date, is_reversal=is_reversal,
))
s.flush()
return rid
def test_run_matches_and_updates_state(fixture_835):
with db.SessionLocal()() as s:
_make_batch(s)
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
_make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00", date(2026, 6, 1))
s.commit()
with db.SessionLocal()() as s:
from cyclone import reconcile
result = reconcile.run(s, "b1")
s.commit()
assert result.matched == 1
assert result.unmatched_remittances == 0
assert result.unmatched_claims == 0
with db.SessionLocal()() as s:
claim = s.get(Claim, "CLM-1")
assert claim.state == ClaimState.PAID
assert claim.matched_remittance_id == "CLP-1:b1xxxxxx"
def test_run_orphan_remit_leaves_claim_unmatched(fixture_835):
with db.SessionLocal()() as s:
_make_batch(s)
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1))
_make_remit(s, "CLP-2", "PCN-NEW", "1", "50.00", "50.00", date(2026, 6, 5))
s.commit()
with db.SessionLocal()() as s:
from cyclone import reconcile
result = reconcile.run(s, "b1")
s.commit()
assert result.matched == 0
assert result.unmatched_remittances == 1
assert result.unmatched_claims == 0
def test_run_reversal_flips_paid_to_reversed(fixture_835):
with db.SessionLocal()() as s:
_make_batch(s)
_make_claim(s, "CLM-1", "PCN-A", "100.00", date(2026, 6, 1),
state=ClaimState.PAID)
# Match row already exists from prior reconcile.
s.add(Match(
claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx",
strategy="auto", matched_at=datetime.now(timezone.utc),
is_reversal=False,
))
s.flush()
_make_remit(s, "CLP-REV", "PCN-A", "22", "100.00", "-100.00",
date(2026, 6, 10), is_reversal=True)
s.commit()
with db.SessionLocal()() as s:
from cyclone import reconcile
result = reconcile.run(s, "b1")
s.commit()
assert result.matched == 1
with db.SessionLocal()() as s:
claim = s.get(Claim, "CLM-1")
assert claim.state == ClaimState.REVERSED
# Match rows: original + reversal.
matches = s.query(Match).filter(Match.claim_id == "CLM-1").all()
assert len(matches) == 2
reversal_match = [m for m in matches if m.is_reversal][0]
assert reversal_match.prior_claim_state == ClaimState.PAID
def test_run_failed_reconcile_writes_activity_event(fixture_835):
"""If reconcile crashes, the batch + remittances stay; activity event records failure."""
with db.SessionLocal()() as s:
_make_batch(s)
_make_remit(s, "CLP-1", "PCN-A", "1", "100.00", "100.00")
s.commit()
# Monkeypatch reconcile.match to raise.
from cyclone import reconcile
real_match = reconcile.match
def boom(*a, **kw): raise RuntimeError("synthetic fault")
reconcile.match = boom
try:
with db.SessionLocal()() as s:
try:
reconcile.run(s, "b1")
s.commit()
except RuntimeError:
pass # caller is responsible for catch; reconcile.run itself shouldn't raise
finally:
reconcile.match = real_match
# Batch + remit still present.
with db.SessionLocal()() as s:
b = s.get(Batch, "b1")
assert b is not None
r = s.query(Remittance).first()
assert r is not None