feat(backend): implement list_unmatched + manual_match + manual_unmatch
This commit is contained in:
@@ -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