feat(backend): implement list_unmatched + manual_match + manual_unmatch

This commit is contained in:
Tyler
2026-06-19 23:26:16 -06:00
parent cf2e52efb8
commit aa7cd7654d
2 changed files with 543 additions and 21 deletions
+376 -18
View File
@@ -12,10 +12,11 @@ Public API (preserved from the in-memory version):
- iter_claims(...) / iter_remittances(...) - iter_claims(...) / iter_remittances(...)
- distinct_providers() / recent_activity(limit) - distinct_providers() / recent_activity(limit)
New API (stubs for T10/T12): New API (T12):
- list_unmatched() - list_unmatched(kind="both")
- manual_match(claim_id, remit_id) - manual_match(claim_id, remit_id)
- manual_unmatch(claim_id) - manual_unmatch(claim_id)
- AlreadyMatchedError, NotMatchedError exception classes
Backward-compat shims for tests that relied on the in-memory internals: Backward-compat shims for tests that relied on the in-memory internals:
- ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles - ``_lock`` — a no-op ``threading.RLock``. SQLAlchemy handles
@@ -52,6 +53,23 @@ from cyclone.parsers.models_835 import ClaimPayment, ParseResult835
from cyclone.parsers.payer import PayerConfig835 from cyclone.parsers.payer import PayerConfig835
class AlreadyMatchedError(Exception):
"""Raised by ``CycloneStore.manual_match`` when the claim is already paired.
The claim's ``matched_remittance_id`` is set, so any new pairing would
clobber an existing match. Callers (the T15 API endpoint) should surface
this as a 409 Conflict.
"""
class NotMatchedError(Exception):
"""Raised by ``CycloneStore.manual_unmatch`` when the claim has no match.
Mirrors ``AlreadyMatchedError`` for the unpair operation. Same HTTP
treatment: 409 Conflict at the API layer.
"""
BatchKind = Literal["837p", "835"] BatchKind = Literal["837p", "835"]
@@ -315,6 +333,97 @@ def to_ui_remittance(
} }
def to_ui_claim_from_orm(
row: Claim,
*,
batch_id: str,
parsed_at: datetime,
) -> dict:
"""Map an ORM ``Claim`` row to the UI's claim shape.
``to_ui_claim`` takes a Pydantic ``ClaimOutput`` (used on the write path
during 837 ingest). For read paths — list_unmatched, manual_match return
values — we already have the ORM row and the serialized fields it
carries in ``raw_json``. Reading from ``raw_json`` keeps the UI shape
in sync with the original 837 parse without re-deserializing to a
Pydantic model.
Adds two fields ``to_ui_claim`` doesn't emit: ``state`` (the
reconciliation state machine value) and ``matchedRemittanceId`` (the
FK to the paired remittance, or None). Both are required by the UI.
"""
raw = row.raw_json or {}
bp = raw.get("billing_provider", {})
payer_obj = raw.get("payer", {})
sub = raw.get("subscriber", {})
service_lines = raw.get("service_lines", [])
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
cpt = (
service_lines[0].get("procedure", {}).get("code", "")
if service_lines
else ""
)
state_value = (
row.state.value if hasattr(row.state, "value") else str(row.state)
)
return {
"id": row.id,
"state": state_value,
"billedAmount": float(row.charge_amount or 0),
"patientName": (
f"{sub.get('first_name', '')} {sub.get('last_name', '')}".strip()
),
"providerNpi": bp.get("npi") or row.provider_npi or "",
"payerName": payer_obj.get("name") or "",
"cptCode": cpt,
"submissionDate": parsed_iso,
"parsedAt": parsed_iso,
"status": state_value,
"matchedRemittanceId": row.matched_remittance_id,
"batchId": batch_id,
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
# but expects these on freshly-loaded rows from /api/claims too.
"receivedAmount": 0.0,
"denialReason": None,
}
def to_ui_remittance_from_orm(
row: Remittance,
*,
batch_id: str,
parsed_at: datetime,
) -> dict:
"""Map an ORM ``Remittance`` row to the UI's remittance shape.
Same idea as ``to_ui_claim_from_orm``: read the PayerName from the
parent batch's ``raw_result_json`` (the ParseResult835 stashed at
insert time) since ``Remittance`` itself doesn't carry it.
"""
parsed_iso = parsed_at.isoformat().replace("+00:00", "Z")
payer_name = ""
if row.batch is not None and row.batch.raw_result_json:
payer_obj = row.batch.raw_result_json.get("payer", {}) or {}
payer_name = payer_obj.get("name") or ""
status = (
"reconciled" if row.status_code in ("21", "22") else "received"
)
return {
"id": row.id,
"payerClaimControlNumber": row.payer_claim_control_number,
"claimId": row.claim_id or "",
"payerName": payer_name,
"paidAmount": float(row.total_paid or 0),
"adjustmentAmount": float(row.adjustment_amount or 0),
"status": status,
"denialReason": None,
"validationWarnings": [],
"receivedDate": row.received_at.isoformat().replace("+00:00", "Z"),
"batchId": batch_id,
"parsedAt": parsed_iso,
}
def to_ui_provider( def to_ui_provider(
*, *,
npi: str, npi: str,
@@ -850,27 +959,276 @@ class CycloneStore:
for r in rows for r in rows
] ]
# -- T12 stubs: manual reconciliation ----------------------------- # -- manual reconciliation (T12) -----------------------------------
def list_unmatched(self) -> list[dict]: def list_unmatched(self, *, kind: str = "both") -> dict:
"""T12 stub: return claims/remits that auto-match didn't pair. """Return unmatched claims and/or remittances.
Returns an empty list for T9. T12 will fill in the implementation An unmatched claim is one with ``matched_remittance_id IS NULL`` —
by querying ``Claim`` rows with ``matched_remittance_id IS NULL`` either auto-match never paired it, or it was unpaired by
plus ``Remittance`` rows with ``claim_id IS NULL``. ``manual_unmatch``. An unmatched remittance is one with
``claim_id IS NULL`` — symmetric FK on the remittance side; we
update this in ``manual_match`` so the filter reflects the pair.
Note: T10's ``reconcile.run`` only writes the claim-side FK
(``Claim.matched_remittance_id``) when auto-pairing. Auto-matched
remittances therefore still show as "unmatched" here until the
pair is touched (re-ingest, manual unmatch + rematch). That gap
is intentional for T12 — fixing it requires modifying T10.
``kind`` selects which side(s) to return:
- "claims": only claims
- "remittances": only remittances
- "both": both (default)
Returns ``{"claims": [...], "remittances": [...]}`` with the
unused side always an empty list (never absent) so callers can
unconditionally index.
""" """
# TODO(t12): implement — query unmatched claims and remittances. if kind not in ("claims", "remittances", "both"):
return [] raise ValueError(
f"list_unmatched: unknown kind={kind!r} "
"(expected 'claims', 'remittances', or 'both')"
)
def manual_match(self, claim_id: str, remit_id: str) -> None: result: dict = {"claims": [], "remittances": []}
"""T12 stub: pair a claim with a remittance manually.""" with db.SessionLocal()() as s:
# TODO(t12): implement — create a Match row, update Claim.state. if kind in ("claims", "both"):
return None rows = (
s.query(Claim)
.filter(Claim.matched_remittance_id.is_(None))
.order_by(Claim.id.asc())
.all()
)
for r in rows:
parsed_at = (
r.batch.parsed_at
if r.batch is not None
else r.service_date_from or utcnow()
)
result["claims"].append(
to_ui_claim_from_orm(
r, batch_id=r.batch_id, parsed_at=parsed_at,
)
)
def manual_unmatch(self, claim_id: str) -> None: if kind in ("remittances", "both"):
"""T12 stub: unpair a previously matched claim.""" rows = (
# TODO(t12): implement — delete the Match row, restore prior state. s.query(Remittance)
return None .filter(Remittance.claim_id.is_(None))
.order_by(Remittance.id.asc())
.all()
)
for r in rows:
parsed_at = (
r.batch.parsed_at
if r.batch is not None
else r.received_at
)
result["remittances"].append(
to_ui_remittance_from_orm(
r, batch_id=r.batch_id, parsed_at=parsed_at,
)
)
return result
def manual_match(self, claim_id: str, remit_id: str) -> dict:
"""Pair a claim with a remittance manually (operator override).
Steps:
1. Load the claim; raise ``AlreadyMatchedError`` if it already
has a match (we never silently overwrite an existing pair).
2. Load the remittance; raise ``LookupError`` if missing.
3. Compute the new claim state via ``reconcile.apply_payment``
(or ``apply_reversal`` for status codes 21/22).
4. Insert a ``Match`` row with ``strategy="manual"``.
5. Update the claim (``state``, ``matched_remittance_id``) AND
the remittance (``claim_id``) so the symmetric FK reflects
the pair — required for ``list_unmatched`` to drop them.
6. Record an ``ActivityEvent(kind="manual_match", ...)``.
7. Commit; return ``{"claim": <ui>, "match": <ui>}``.
``reconcile.apply_payment`` may return a noop (claim in terminal
state); we surface that as ``ValueError`` rather than silently
pairing, because the operator clearly intended a state change.
"""
from cyclone import reconcile as _reconcile
with db.SessionLocal()() as s:
claim = s.get(Claim, claim_id)
if claim is None:
raise LookupError(f"claim {claim_id} not found")
if claim.matched_remittance_id is not None:
raise AlreadyMatchedError(
f"claim {claim_id} already matched to "
f"{claim.matched_remittance_id}"
)
remit = s.get(Remittance, remit_id)
if remit is None:
raise LookupError(f"remittance {remit_id} not found")
prior_state = claim.state
if remit.is_reversal:
intent = _reconcile.apply_reversal(claim, remit)
else:
intent = _reconcile.apply_payment(
claim, remit,
charge=claim.charge_amount,
paid=remit.total_paid,
status_code=remit.status_code,
)
if intent.skipped or intent.new_state is None:
raise ValueError(
f"manual_match refused: {intent.reason or 'no state change'}"
)
new_state = intent.new_state
now = utcnow()
s.add(Match(
claim_id=claim_id,
remittance_id=remit_id,
strategy="manual",
matched_at=now,
prior_claim_state=prior_state,
is_reversal=remit.is_reversal,
))
claim.state = new_state
claim.matched_remittance_id = remit_id
# Symmetric FK update — see list_unmatched docstring for why
# we don't rely on T10 to do this.
remit.claim_id = claim_id
s.add(ActivityEvent(
ts=now,
kind="manual_match",
batch_id=remit.batch_id,
claim_id=claim_id,
remittance_id=remit_id,
payload_json={
"strategy": "manual",
"new_state": new_state.value,
"prior_state": prior_state.value,
"is_reversal": remit.is_reversal,
},
))
s.commit()
parsed_at = (
claim.batch.parsed_at
if claim.batch is not None
else now
)
claim_dict = to_ui_claim_from_orm(
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
)
matched_at_iso = now.isoformat().replace("+00:00", "Z")
return {
"claim": claim_dict,
"match": {
"strategy": "manual",
"claimId": claim_id,
"remittanceId": remit_id,
"matchedAt": matched_at_iso,
"isReversal": remit.is_reversal,
"priorState": prior_state.value,
"newState": new_state.value,
},
}
def manual_unmatch(self, claim_id: str) -> dict:
"""Unpair a previously matched claim and restore its prior state.
Reverses ``manual_match`` (and auto-match as a side-effect of
clearing the FK). Strategy:
1. Load the claim; raise ``NotMatchedError`` if it isn't
currently matched.
2. Delete every ``Match`` row for the claim (there may be more
than one — reversals create a 2nd row, see T10 spec).
3. Restore ``claim.state`` from the latest Match's
``prior_claim_state``; fall back to ``SUBMITTED`` when
``prior_claim_state`` is NULL (auto-match doesn't set it for
non-reversal payments — see reconcile.run line ~278).
4. Clear ``claim.matched_remittance_id`` and the symmetric
``remit.claim_id`` so ``list_unmatched`` surfaces the pair
again.
5. Record ``ActivityEvent(kind="manual_unmatch", ...)`` and
commit.
6. Return ``{"claim": <ui>, "deletedMatches": <count>}``.
"""
with db.SessionLocal()() as s:
claim = s.get(Claim, claim_id)
if claim is None:
raise LookupError(f"claim {claim_id} not found")
if claim.matched_remittance_id is None:
raise NotMatchedError(
f"claim {claim_id} has no active match"
)
matches = (
s.query(Match)
.filter(Match.claim_id == claim_id)
.order_by(Match.matched_at.desc())
.all()
)
if not matches:
# Defensive: matched_remittance_id was set but no Match
# rows exist. Shouldn't happen, but if it does, fall back
# to clearing the FK and starting fresh.
latest = None
restored_state = ClaimState.SUBMITTED
else:
latest = matches[0]
restored_state = (
latest.prior_claim_state
if latest.prior_claim_state is not None
else ClaimState.SUBMITTED
)
deleted_count = len(matches)
for m in matches:
s.delete(m)
claim.state = restored_state
claim.matched_remittance_id = None
# Clear the symmetric FK on the remittance so list_unmatched
# surfaces the pair again. The remittance may have been
# deleted between the match and this call — guard with a
# get() so we don't blow up on a stale FK.
if latest is not None:
paired_remit = s.get(Remittance, latest.remittance_id)
if paired_remit is not None:
paired_remit.claim_id = None
now = utcnow()
s.add(ActivityEvent(
ts=now,
kind="manual_unmatch",
claim_id=claim_id,
payload_json={
"restored_state": restored_state.value,
"deleted_matches": deleted_count,
},
))
s.commit()
parsed_at = (
claim.batch.parsed_at
if claim.batch is not None
else now
)
claim_dict = to_ui_claim_from_orm(
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
)
return {
"claim": claim_dict,
"deletedMatches": deleted_count,
}
# Module-level singleton — same import path the old InMemoryStore used. # Module-level singleton — same import path the old InMemoryStore used.
+166 -2
View File
@@ -30,9 +30,16 @@ def _setup(tmp_path, monkeypatch):
def _make_remit_with_cas(remit_id="CLP-1", def _make_remit_with_cas(remit_id="CLP-1",
status="1", charge="124.00", paid="62.00", status="1", charge="124.00", paid="62.00",
cas_amount="62.00"): cas_amount="62.00", pcn=None):
"""Build a single ClaimPayment with one CAS adjustment.
``pcn`` defaults to ``remit_id`` for backward compatibility with T11;
pass it explicitly when the PCN must differ from the row PK (the
T12 tests need this so the Remittance PK can be matched against a
different ``payer_claim_control_number``).
"""
cp = ClaimPayment( cp = ClaimPayment(
payer_claim_control_number=remit_id, payer_claim_control_number=pcn if pcn is not None else remit_id,
status_code=status, status_code=status,
status_label="Primary", status_label="Primary",
total_charge=charge, total_paid=paid, total_charge=charge, total_paid=paid,
@@ -97,3 +104,160 @@ def test_add_835_aggregates_cas_into_adjustment_amount():
assert len(cas_rows) == 1 assert len(cas_rows) == 1
assert cas_rows[0].group_code == "CO" assert cas_rows[0].group_code == "CO"
assert cas_rows[0].reason_code == "45" assert cas_rows[0].reason_code == "45"
# ---------------------------------------------------------------------------
# T12: list_unmatched / manual_match / manual_unmatch
# ---------------------------------------------------------------------------
def test_list_unmatched_returns_orphan_remit():
"""An 835 with a remittance that has no paired Claim surfaces it."""
s = CycloneStore()
# Use a distinct PCN from the PK so the row is uniquely identifiable.
cp = _make_remit_with_cas(remit_id="CLP-ORPHAN", pcn="PCN-NEW")
from cyclone.store import BatchRecord835
rec = BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
)
s.add(rec)
out = s.list_unmatched(kind="both")
assert len(out["remittances"]) == 1
assert out["remittances"][0]["payerClaimControlNumber"] == "PCN-NEW"
assert out["claims"] == []
def test_manual_match_pairs_claim_with_orphan_remit():
"""Pairing an unmatched claim with an unmatched remittance works,
updates Claim.state, records the Match row, and removes both sides
from the unmatched list.
The 837 subscriber's member_id ("M1") intentionally differs from the
835 remit's PCN ("CLM-1") so ``reconcile.run`` doesn't auto-pair them
on ingest — we want them to land in the unmatched list so manual
pairing has something to do.
"""
from cyclone.store import BatchRecord837, BatchRecord835
from cyclone.parsers.models import (
BatchSummary, BillingProvider, ClaimHeader, ClaimOutput,
Envelope, Payer, ParseResult, Subscriber, ValidationReport,
)
s = CycloneStore()
# Add a Claim via 837. member_id="M1" so PCN-based auto-match won't
# pair it with the 835 remit's payer_claim_control_number="CLM-1".
co = ClaimOutput(
claim_id="CLM-1",
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test", npi="1234567890"),
subscriber=Subscriber(
first_name="Jane", last_name="Doe", member_id="M1",
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id="CLM-1", total_charge=Decimal("100"),
frequency_code="1", place_of_service="11",
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
pr837 = ParseResult(
envelope=Envelope(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
claims=[co],
summary=BatchSummary(
input_file="c.txt", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=1, passed=1, failed=0,
),
)
s.add(BatchRecord837(
id="b-837", kind="837p", input_filename="c.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=pr837,
))
# Add an 835 with an orphan remit. paid == charge so apply_payment
# picks ClaimState.PAID (the test asserts result["claim"]["state"]
# == "paid"). cas_amount=0 keeps things simple.
cp = _make_remit_with_cas(
remit_id="CLP-1", charge="100", paid="100", cas_amount="0",
)
s.add(BatchRecord835(
id="b-835", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, 12, 5, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
out = s.list_unmatched(kind="both")
assert len(out["claims"]) == 1
assert len(out["remittances"]) == 1
claim_id = out["claims"][0]["id"]
remit_id = out["remittances"][0]["id"]
result = s.manual_match(claim_id, remit_id)
assert result["claim"]["state"] == "paid"
assert result["match"]["strategy"] == "manual"
# Symmetric FK write — claim side...
assert result["claim"]["matchedRemittanceId"] == remit_id
assert result["match"]["claimId"] == claim_id
assert result["match"]["remittanceId"] == remit_id
# Both sides now filtered out of the unmatched list.
out = s.list_unmatched(kind="both")
assert out["claims"] == []
assert out["remittances"] == []
# Match row was persisted with strategy="manual".
with db.SessionLocal()() as session:
from cyclone.db import Claim, Match
claim_row = session.get(Claim, claim_id)
assert claim_row is not None
assert claim_row.matched_remittance_id == remit_id
match_rows = (
session.query(Match).filter(Match.claim_id == claim_id).all()
)
assert len(match_rows) == 1
assert match_rows[0].strategy == "manual"
def test_manual_match_conflict_raises():
"""Pairing a claim that already has a match raises AlreadyMatchedError.
We construct the matched claim directly (no 837 ingest) so the test
is fully independent of the 837 write path. The 835 batch is added
only to satisfy the FK on the Claim's batch_id — the remittance
itself never participates in the manual_match call.
"""
from cyclone.store import AlreadyMatchedError, BatchRecord835
s = CycloneStore()
cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-A")
s.add(BatchRecord835(
id="b-1", kind="835", input_filename="era.txt",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
result=_make_835_result([cp]),
))
from cyclone.db import Claim
with db.SessionLocal()() as sess:
sess.add(Claim(
id="CLM-X", batch_id="b-1",
patient_control_number="PCN-A",
charge_amount=Decimal("100"), state="paid",
matched_remittance_id="CLP-1:b1xxxxxx",
))
sess.commit()
with pytest.raises(AlreadyMatchedError):
s.manual_match("CLM-X", "CLP-1:b1xxxxxx")