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(...)
- distinct_providers() / recent_activity(limit)
New API (stubs for T10/T12):
- list_unmatched()
New API (T12):
- list_unmatched(kind="both")
- manual_match(claim_id, remit_id)
- manual_unmatch(claim_id)
- AlreadyMatchedError, NotMatchedError exception classes
Backward-compat shims for tests that relied on the in-memory internals:
- ``_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
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"]
@@ -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(
*,
npi: str,
@@ -850,27 +959,276 @@ class CycloneStore:
for r in rows
]
# -- T12 stubs: manual reconciliation -----------------------------
# -- manual reconciliation (T12) -----------------------------------
def list_unmatched(self) -> list[dict]:
"""T12 stub: return claims/remits that auto-match didn't pair.
def list_unmatched(self, *, kind: str = "both") -> dict:
"""Return unmatched claims and/or remittances.
Returns an empty list for T9. T12 will fill in the implementation
by querying ``Claim`` rows with ``matched_remittance_id IS NULL``
plus ``Remittance`` rows with ``claim_id IS NULL``.
An unmatched claim is one with ``matched_remittance_id IS NULL`` —
either auto-match never paired it, or it was unpaired by
``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.
return []
if kind not in ("claims", "remittances", "both"):
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:
"""T12 stub: pair a claim with a remittance manually."""
# TODO(t12): implement — create a Match row, update Claim.state.
return None
result: dict = {"claims": [], "remittances": []}
with db.SessionLocal()() as s:
if kind in ("claims", "both"):
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:
"""T12 stub: unpair a previously matched claim."""
# TODO(t12): implement — delete the Match row, restore prior state.
return None
if kind in ("remittances", "both"):
rows = (
s.query(Remittance)
.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.