feat(backend): implement list_unmatched + manual_match + manual_unmatch
This commit is contained in:
+376
-18
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user