534130ee2b
Migration 0014 changes the PRIMARY KEYs of claims and remittances from single-column id to composite (batch_id, id). Enables the spec'd cross-batch CLM01 / CLP01 collision workflow: same CLM01 in multiple batches is now representable (resubmits), pre-flight dedup 409 path is genuinely exercisable, force-insert can skip pre-existing duplicates. Strategy: PRAGMA defer_foreign_keys = ON + table recreation for every table whose FKs pointed at the old single-column PK. Tables recreated: remittances, claims, matches, cas_adjustments, service_line_payments, line_reconciliations. Each child table gains a batch_id (or remittance_batch_id) column for the composite FK side; INSERT INTO new SELECT FROM old JOINs populate it from the already-recreated parent. Cross-table FKs (remittances.claim_id, claims.matched_remittance_id) cannot be SQL-enforced with composite PKs (SQLite has no ALTER CONSTRAINT). Dropped at SQL level; enforced via application-layer invariants in store.manual_match / manual_unmatch / reconcile.run and the dedup.preflight_* helpers. ORM updates (db.py): - Claim / Remittance: composite PK via explicit PrimaryKeyConstraint in __table_args__ (column order matches SQL: batch_id, id). - New Claim.matched_remittance_batch_id column. - Match / CasAdjustment / ServiceLinePayment / LineReconciliation: added the batch side of their composite FK to the parent table. - SQLAlchemy before_insert events auto-populate the batch side of the composite FK from session.new, then identity_map, then SQL fallback. Production code updates: - store.manual_match / manual_unmatch: also write matched_remittance_batch_id on the claim (was missing). - api.py manual-match endpoint: same fix. - reconcile.run: same fix for auto-matched pairs. Test updates: replaced s.get(Claim, X) with the composite key (batch_id, id) where batch_id is known, or s.query().filter().first() where the test only knows the id. Tests that previously inserted a Match row pointing at a non-existent Remittance now seed the parent Remittance so the new NOT NULL composite FK is satisfied.
592 lines
22 KiB
Python
592 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
|
|
# Migration 0014: composite PK — claim ingested under batch "b-837".
|
|
claim_row = session.get(Claim, ("b-837", 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).
|
|
# Migration 0014: composite PK — remit ingested under batch "b-835-sp7".
|
|
r = session.get(Remittance, ("b-835-sp7", 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
|
|
# Migration 0014: composite PK — remit ingested under batch "b-835-idemp".
|
|
r = session.get(Remittance, ("b-835-idemp", remit_id))
|
|
assert r is not None
|
|
assert r.adjustment_amount == Decimal("20.00")
|
|
assert r.claim_level_adjustment_amount == Decimal("20.00") |