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(...)
|
- 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.
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -96,4 +103,161 @@ def test_add_835_aggregates_cas_into_adjustment_amount():
|
|||||||
cas_rows = session.query(CasAdjustment).all()
|
cas_rows = session.query(CasAdjustment).all()
|
||||||
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")
|
||||||
Reference in New Issue
Block a user