feat(backend): persist CasAdjustment rows from 835 SVC.adjustments
This commit is contained in:
@@ -167,6 +167,12 @@ def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance:
|
|||||||
"""Build a Remittance ORM row from a ClaimPayment. NOT yet persisted."""
|
"""Build a Remittance ORM row from a ClaimPayment. NOT yet persisted."""
|
||||||
received_at = utcnow()
|
received_at = utcnow()
|
||||||
# Adjustment amount: sum the CAS rows for the first service line.
|
# Adjustment amount: sum the CAS rows for the first service line.
|
||||||
|
# NOTE: This is a best-effort placeholder used until the reconciliation
|
||||||
|
# pass (T10) overwrites it from the persisted CasAdjustment rows. The
|
||||||
|
# authoritative value comes from `reconcile.run()`, which sums
|
||||||
|
# ``CasAdjustment.amount`` per ``remittance_id`` and writes the result
|
||||||
|
# back to ``Remittance.adjustment_amount``. We keep this stub so the
|
||||||
|
# row has a sane value if reconciliation is disabled or fails.
|
||||||
adjustment = Decimal("0")
|
adjustment = Decimal("0")
|
||||||
if cp.service_payments:
|
if cp.service_payments:
|
||||||
sp = cp.service_payments[0]
|
sp = cp.service_payments[0]
|
||||||
@@ -194,6 +200,27 @@ def _remittance_835_row(cp: ClaimPayment, batch_id: str) -> Remittance:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cas_adjustment_row(adj, remittance_id: str) -> "db.CasAdjustment":
|
||||||
|
"""Build a CasAdjustment ORM row from a ClaimAdjustment. NOT yet persisted.
|
||||||
|
|
||||||
|
One row per SVC-level CAS adjustment is persisted so the T10
|
||||||
|
reconcile aggregator can compute ``Remittance.adjustment_amount``
|
||||||
|
as ``SUM(CasAdjustment.amount) WHERE remittance_id = ...``.
|
||||||
|
|
||||||
|
``quantity`` is optional in the X12 CAS spec; we coerce to Decimal
|
||||||
|
only when present to keep the column NULL for the common no-QTY case.
|
||||||
|
"""
|
||||||
|
from cyclone.db import CasAdjustment
|
||||||
|
quantity = getattr(adj, "quantity", None)
|
||||||
|
return CasAdjustment(
|
||||||
|
remittance_id=remittance_id,
|
||||||
|
group_code=adj.group_code,
|
||||||
|
reason_code=adj.reason_code,
|
||||||
|
amount=Decimal(str(adj.amount)),
|
||||||
|
quantity=Decimal(str(quantity)) if quantity is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# UI mappers: ORM rows → simpler UI types.
|
# UI mappers: ORM rows → simpler UI types.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -471,7 +498,16 @@ class CycloneStore:
|
|||||||
cp.payer_claim_control_number, record.id,
|
cp.payer_claim_control_number, record.id,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
s.add(_remittance_835_row(cp, record.id))
|
remit_row = _remittance_835_row(cp, record.id)
|
||||||
|
s.add(remit_row)
|
||||||
|
# Flush so remit_row.id (FK target of cas_adjustments) is
|
||||||
|
# populated. SQLAlchemy assigns the PK on flush; without
|
||||||
|
# this the CasAdjustment inserts below would reference an
|
||||||
|
# unset id and violate the FK.
|
||||||
|
s.flush()
|
||||||
|
for svc in cp.service_payments:
|
||||||
|
for adj in svc.adjustments:
|
||||||
|
s.add(_cas_adjustment_row(adj, remit_row.id))
|
||||||
s.add(ActivityEvent(
|
s.add(ActivityEvent(
|
||||||
ts=record.parsed_at,
|
ts=record.parsed_at,
|
||||||
kind="remit_received",
|
kind="remit_received",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""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"):
|
||||||
|
cp = ClaimPayment(
|
||||||
|
payer_claim_control_number=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"
|
||||||
Reference in New Issue
Block a user