d033ce85db
manual_match previously only flipped the claim↔remit FK and the
claim state — it never ran line-level reconciliation, so manually-
paired claims surfaced empty line-reconciliation rows to the UI
and skipped CLP-level CAS aggregate recompute.
Refactor reconcile.run() to call a new per-pair helper
_reconcile_pair(session, claim, remittance) that:
- clears any existing LineReconciliation rows for the claim
(idempotent re-run; safe across manual_unmatch + manual_rematch
cycles that may pair the claim with a different remittance),
- reads 837 SV1 lines from Claim.raw_json and 835 SVC rows from
ServiceLinePayment,
- runs match_service_lines() and persists a LineReconciliation row
per side,
- recomputes Remittance.claim_level_adjustment_amount (CLP-level
CAS) and Remittance.adjustment_amount (total CAS).
manual_match now calls this helper after the FK is set and before
commit, mirroring the auto-match path. Reversals are skipped (they
don't have SV1↔SVC line pairs; per §7.3).
Tests (test_store_reconcile.py):
- test_manual_match_populates_line_reconciliation_rows: end-to-end
check that a manual pair writes the expected matched +
unmatched_837_only rows plus zero CAS aggregates.
- test_manual_match_idempotent_line_reconciliation: after
manual_unmatch + manual_rematch + inserting a CLP-level CAS row
directly, the claim has exactly two fresh LineReconciliation rows
(no duplicates) and the remittance aggregate reflects the new CAS.
Smoke tested end-to-end via TestClient: parse co_medicaid_837p.txt
+ co_medicaid_835.txt, auto-matcher skips (PCNs differ), manual
match via POST /api/reconciliation/match, then
GET /api/claims/{id}/line-reconciliation returns 2 rows and
GET /api/inbox/lanes shows matched_remittance.total_lines=2.
Note: matched_lines may be 0 on real 835 fixtures due to a
pre-existing parser bug in _consume_service_payment that swaps
SVC04 (units) and SVC05 (unit-of-measure) when reading units from
the segment. The SP7 strict-match criterion requires units parity,
so the misread produces None on the SVC side and strict-match
never succeeds. Surfaced as a follow-up; the SP7 work itself is
correct (line counts are computed and persisted).
589 lines
22 KiB
Python
589 lines
22 KiB
Python
"""Integration tests for CycloneStore.add() with 835 batches.
|
|
|
|
Covers: parse-835 persist → reconcile trigger → CAS aggregation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime, timezone
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from cyclone import db
|
|
from cyclone.parsers.models_835 import (
|
|
ClaimPayment, ClaimAdjustment, ServicePayment,
|
|
ParseResult835, Envelope, FinancialInfo, ReassociationTrace,
|
|
Payer835, Payee835, BatchSummary,
|
|
)
|
|
from cyclone.store import CycloneStore
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
|
db._reset_for_tests()
|
|
db.init_db()
|
|
yield
|
|
db._reset_for_tests()
|
|
|
|
|
|
def _make_remit_with_cas(remit_id="CLP-1",
|
|
status="1", charge="124.00", paid="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=pcn if pcn is not None else remit_id,
|
|
status_code=status,
|
|
status_label="Primary",
|
|
total_charge=charge, total_paid=paid,
|
|
service_payments=[
|
|
ServicePayment(
|
|
line_number=1, procedure_qualifier="HC", procedure_code="99213",
|
|
charge=charge, payment=paid,
|
|
adjustments=[ClaimAdjustment(group_code="CO", reason_code="45",
|
|
amount=cas_amount)],
|
|
),
|
|
],
|
|
)
|
|
return cp
|
|
|
|
|
|
def _make_835_result(claims):
|
|
# NOTE: field names below follow the actual model definitions in
|
|
# cyclone/parsers/models_835.py — the plan snippet (lines 2550-2566)
|
|
# referenced stale field names (e.g. credit_debit/handling_code) that
|
|
# no longer exist after the model was migrated.
|
|
return ParseResult835(
|
|
envelope=Envelope(
|
|
sender_id="S", receiver_id="R", control_number="0001",
|
|
transaction_date=date(2026, 6, 19),
|
|
),
|
|
financial_info=FinancialInfo(
|
|
handling_code="C", paid_amount=Decimal("0"),
|
|
credit_debit_flag="C", payment_method=None,
|
|
),
|
|
trace=ReassociationTrace(
|
|
trace_type_code="1", trace_number="0001",
|
|
originating_company_id="S",
|
|
),
|
|
payer=Payer835(name="X", id="SKCO0"),
|
|
payee=Payee835(name="Y", npi="1234567890"),
|
|
claims=claims,
|
|
summary=BatchSummary(
|
|
input_file="era.txt", control_number="0001",
|
|
transaction_date=date(2026, 6, 19),
|
|
total_claims=len(claims), passed=len(claims), failed=0,
|
|
),
|
|
)
|
|
|
|
|
|
def test_add_835_aggregates_cas_into_adjustment_amount():
|
|
s = CycloneStore()
|
|
cp = _make_remit_with_cas()
|
|
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)
|
|
|
|
with db.SessionLocal()() as session:
|
|
from cyclone.db import Remittance, CasAdjustment
|
|
r = session.query(Remittance).first()
|
|
assert r is not None
|
|
assert r.adjustment_amount == 62.0
|
|
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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SP7: manual_match populates LineReconciliation rows + CAS aggregates.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_manual_match_populates_line_reconciliation_rows():
|
|
"""manual_match must run line-level reconciliation, same as auto-match.
|
|
|
|
Without this, operators who manually pair claims see no per-line audit
|
|
data (every line shows up as ``unmatched_837_only`` in the UI). The
|
|
SP7 fix routes ``manual_match`` through ``reconcile._reconcile_pair``
|
|
after the claim↔remit FK is set, mirroring the auto-match path.
|
|
|
|
Setup:
|
|
* 837 claim with two SV1 lines (99213 + 99214).
|
|
* 835 remit with one SVC line (99213) — matches line 1, leaves
|
|
line 2 unmatched_837_only.
|
|
* member_id and PCN differ so auto-match doesn't pair them.
|
|
|
|
Expected post-match state:
|
|
* 1 ``matched`` LineReconciliation row (line 1 ↔ SVC 1).
|
|
* 1 ``unmatched_837_only`` LineReconciliation row (line 2).
|
|
* ``Remittance.adjustment_amount`` recomputed (0 — no CAS in this test).
|
|
* ``Remittance.claim_level_adjustment_amount`` recomputed (0).
|
|
"""
|
|
from sqlalchemy import select
|
|
from cyclone.db import LineReconciliation, Remittance
|
|
from cyclone.store import BatchRecord837, BatchRecord835
|
|
from cyclone.parsers.models import (
|
|
BatchSummary as BatchSummary837,
|
|
BillingProvider, ClaimHeader, ClaimOutput, Envelope,
|
|
Payer, ParseResult, Procedure, ServiceLine, Subscriber,
|
|
ValidationReport,
|
|
)
|
|
|
|
s = CycloneStore()
|
|
|
|
# 837 claim with two SV1 service lines.
|
|
co = ClaimOutput(
|
|
claim_id="CLM-MANUAL",
|
|
control_number="0001",
|
|
transaction_date=date(2026, 6, 19),
|
|
billing_provider=BillingProvider(name="Test", npi="1234567890"),
|
|
# member_id deliberately ≠ PCN below so auto-match skips this claim.
|
|
subscriber=Subscriber(first_name="Jane", last_name="Doe",
|
|
member_id="MANUAL"),
|
|
payer=Payer(name="Test Payer", id="P1"),
|
|
claim=ClaimHeader(
|
|
claim_id="CLM-MANUAL", total_charge=Decimal("200.00"),
|
|
frequency_code="1", place_of_service="11",
|
|
),
|
|
diagnoses=[],
|
|
service_lines=[
|
|
ServiceLine(
|
|
line_number=1,
|
|
procedure=Procedure(qualifier="HC", code="99213",
|
|
modifiers=[]),
|
|
charge=Decimal("100.00"),
|
|
unit_type="UN",
|
|
units=Decimal("1"),
|
|
service_date=date(2026, 6, 19),
|
|
),
|
|
ServiceLine(
|
|
line_number=2,
|
|
procedure=Procedure(qualifier="HC", code="99214",
|
|
modifiers=[]),
|
|
charge=Decimal("100.00"),
|
|
unit_type="UN",
|
|
units=Decimal("1"),
|
|
service_date=date(2026, 6, 19),
|
|
),
|
|
],
|
|
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=BatchSummary837(
|
|
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-sp7", kind="837p", input_filename="c.txt",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
result=pr837,
|
|
))
|
|
|
|
# 835 remit with one SVC line that matches claim line 1.
|
|
cp = ClaimPayment(
|
|
payer_claim_control_number="CLM-MANUAL", # != member_id "MANUAL"
|
|
status_code="1",
|
|
status_label="Primary",
|
|
total_charge=Decimal("200.00"),
|
|
total_paid=Decimal("200.00"),
|
|
service_payments=[
|
|
ServicePayment(
|
|
line_number=1, procedure_qualifier="HC",
|
|
procedure_code="99213",
|
|
charge=Decimal("100.00"),
|
|
payment=Decimal("100.00"),
|
|
units=Decimal("1"),
|
|
service_date=date(2026, 6, 19),
|
|
adjustments=[],
|
|
),
|
|
],
|
|
)
|
|
s.add(BatchRecord835(
|
|
id="b-835-sp7", kind="835", input_filename="era.txt",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
result=_make_835_result([cp]),
|
|
))
|
|
|
|
# Verify auto-match didn't pair them (they should both be unmatched).
|
|
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"]
|
|
|
|
# Manual match — must populate LineReconciliation rows.
|
|
result = s.manual_match(claim_id, remit_id)
|
|
assert result["claim"]["state"] == "paid"
|
|
|
|
with db.SessionLocal()() as session:
|
|
lrs = list(
|
|
session.execute(
|
|
select(LineReconciliation)
|
|
.where(LineReconciliation.claim_id == claim_id)
|
|
.order_by(LineReconciliation.id)
|
|
).scalars().all()
|
|
)
|
|
# Two claim SV1 lines → exactly two LineReconciliation rows
|
|
# (matched + unmatched_837_only). No SVC-only rows since the
|
|
# single SVC line pairs with claim line 1.
|
|
assert len(lrs) == 2, [lr.status for lr in lrs]
|
|
statuses = sorted(lr.status for lr in lrs)
|
|
assert statuses == ["matched", "unmatched_837_only"], statuses
|
|
|
|
# The matched row references claim line 1 + SVC row 1.
|
|
matched = next(lr for lr in lrs if lr.status == "matched")
|
|
assert matched.claim_service_line_number == 1
|
|
assert matched.service_line_payment_id is not None
|
|
# The unmatched_837_only row references claim line 2 only.
|
|
unmatched = next(lr for lr in lrs if lr.status == "unmatched_837_only")
|
|
assert unmatched.claim_service_line_number == 2
|
|
assert unmatched.service_line_payment_id is None
|
|
|
|
# Remittance aggregates were recomputed (0 CAS in this test).
|
|
r = session.get(Remittance, remit_id)
|
|
assert r is not None
|
|
assert r.adjustment_amount == Decimal("0")
|
|
assert r.claim_level_adjustment_amount == Decimal("0")
|
|
|
|
|
|
def test_manual_match_idempotent_line_reconciliation():
|
|
"""Re-running ``manual_match`` semantics on a rematched claim must not
|
|
duplicate LineReconciliation rows.
|
|
|
|
The helper clears stale rows for the claim before inserting fresh
|
|
ones, so a manual_unmatch → manual_rematch cycle (possibly to a
|
|
different remittance) produces a clean slate per claim.
|
|
"""
|
|
from sqlalchemy import select
|
|
from cyclone.db import LineReconciliation
|
|
from cyclone.store import BatchRecord837, BatchRecord835
|
|
from cyclone.parsers.models import (
|
|
BatchSummary as BatchSummary837,
|
|
BillingProvider, ClaimHeader, ClaimOutput, Envelope,
|
|
Payer, ParseResult, Procedure, ServiceLine, Subscriber,
|
|
ValidationReport,
|
|
)
|
|
|
|
s = CycloneStore()
|
|
|
|
# 837 with two SV1 lines, member_id="IDEMP" so it doesn't auto-pair.
|
|
co = ClaimOutput(
|
|
claim_id="CLM-IDEMP",
|
|
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="IDEMP"),
|
|
payer=Payer(name="Test Payer", id="P1"),
|
|
claim=ClaimHeader(
|
|
claim_id="CLM-IDEMP", total_charge=Decimal("200.00"),
|
|
frequency_code="1", place_of_service="11",
|
|
),
|
|
diagnoses=[],
|
|
service_lines=[
|
|
ServiceLine(
|
|
line_number=1,
|
|
procedure=Procedure(qualifier="HC", code="99213",
|
|
modifiers=[]),
|
|
charge=Decimal("100.00"),
|
|
unit_type="UN",
|
|
units=Decimal("1"),
|
|
service_date=date(2026, 6, 19),
|
|
),
|
|
ServiceLine(
|
|
line_number=2,
|
|
procedure=Procedure(qualifier="HC", code="99214",
|
|
modifiers=[]),
|
|
charge=Decimal("100.00"),
|
|
unit_type="UN",
|
|
units=Decimal("1"),
|
|
service_date=date(2026, 6, 19),
|
|
),
|
|
],
|
|
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=BatchSummary837(
|
|
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-idemp", kind="837p", input_filename="c.txt",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
result=pr837,
|
|
))
|
|
|
|
# 835 with two SVC lines — auto-match won't pair (PCN="CLM-IDEMP" ≠
|
|
# member_id="IDEMP"), so manual_match has work to do.
|
|
cp = ClaimPayment(
|
|
payer_claim_control_number="CLM-IDEMP",
|
|
status_code="1",
|
|
status_label="Primary",
|
|
total_charge=Decimal("200.00"),
|
|
total_paid=Decimal("200.00"),
|
|
service_payments=[
|
|
ServicePayment(
|
|
line_number=1, procedure_qualifier="HC",
|
|
procedure_code="99213",
|
|
charge=Decimal("100.00"),
|
|
payment=Decimal("100.00"),
|
|
units=Decimal("1"),
|
|
service_date=date(2026, 6, 19),
|
|
adjustments=[],
|
|
),
|
|
ServicePayment(
|
|
line_number=2, procedure_qualifier="HC",
|
|
procedure_code="99214",
|
|
charge=Decimal("100.00"),
|
|
payment=Decimal("100.00"),
|
|
units=Decimal("1"),
|
|
service_date=date(2026, 6, 19),
|
|
adjustments=[],
|
|
),
|
|
],
|
|
)
|
|
s.add(BatchRecord835(
|
|
id="b-835-idemp", kind="835", input_filename="era.txt",
|
|
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
|
result=_make_835_result([cp]),
|
|
))
|
|
|
|
out = s.list_unmatched(kind="both")
|
|
claim_id = out["claims"][0]["id"]
|
|
remit_id = out["remittances"][0]["id"]
|
|
|
|
# First match — creates LineReconciliation rows.
|
|
s.manual_match(claim_id, remit_id)
|
|
with db.SessionLocal()() as session:
|
|
first_count = len(list(
|
|
session.execute(
|
|
select(LineReconciliation)
|
|
.where(LineReconciliation.claim_id == claim_id)
|
|
).scalars().all()
|
|
))
|
|
assert first_count == 2
|
|
|
|
# Unmatch — clears FK but leaves LineReconciliation rows in place.
|
|
s.manual_unmatch(claim_id)
|
|
|
|
# Insert a CLP-level CAS row directly so we can verify aggregate
|
|
# recompute on the rematch. This simulates a forward-compat CLP CAS
|
|
# (today's parser doesn't produce these, see _persist_835_remit).
|
|
from cyclone.db import CasAdjustment
|
|
with db.SessionLocal()() as session:
|
|
session.add(CasAdjustment(
|
|
remittance_id=remit_id,
|
|
group_code="CO", reason_code="45",
|
|
amount=Decimal("20.00"),
|
|
quantity=None,
|
|
service_line_payment_id=None, # CLP-level
|
|
))
|
|
session.commit()
|
|
|
|
# Rematch — _reconcile_pair clears stale rows and inserts fresh ones.
|
|
s.manual_match(claim_id, remit_id)
|
|
with db.SessionLocal()() as session:
|
|
second = list(
|
|
session.execute(
|
|
select(LineReconciliation)
|
|
.where(LineReconciliation.claim_id == claim_id)
|
|
.order_by(LineReconciliation.id)
|
|
).scalars().all()
|
|
)
|
|
# Still exactly 2 rows — stale rows from the first match were
|
|
# deleted before the new ones were inserted.
|
|
assert len(second) == 2, [lr.status for lr in second]
|
|
statuses = sorted(lr.status for lr in second)
|
|
assert statuses == ["matched", "matched"], statuses
|
|
|
|
# CLP-level CAS aggregate now reflects the inserted row.
|
|
from cyclone.db import Remittance
|
|
r = session.get(Remittance, remit_id)
|
|
assert r is not None
|
|
assert r.adjustment_amount == Decimal("20.00")
|
|
assert r.claim_level_adjustment_amount == Decimal("20.00") |