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.
|
||||
|
||||
@@ -30,9 +30,16 @@ def _setup(tmp_path, monkeypatch):
|
||||
|
||||
def _make_remit_with_cas(remit_id="CLP-1",
|
||||
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(
|
||||
payer_claim_control_number=remit_id,
|
||||
payer_claim_control_number=pcn if pcn is not None else remit_id,
|
||||
status_code=status,
|
||||
status_label="Primary",
|
||||
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()
|
||||
assert len(cas_rows) == 1
|
||||
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