fix(835): drop over-constraining UNIQUE constraints, add multi-BPR warning
Three related changes for real CO Medicaid data: 1. Drop UNIQUE(batch_id, patient_control_number) on claims and UNIQUE(batch_id, payer_claim_control_number) on remittances. The X12 spec allows multiple CLM segments per 2000B subscriber loop and 835 ERAs can repeat a payer_claim_control_number for reversals. Claim/ remittance identity is provided by the primary key (claims.id = CLM01, remittances.id = CLP01). 2. Add validator rule R835_MULTI_BPR warning for files with multiple BPR segments (CO Medicaid split-payment pattern). The parser already sums BPR02 paid_amounts; this surfaces the non-standard data to operators. 3. Skip R835_BAL_BPR_vs_CLP04 when BPR01='I' (Information Only 835). In that mode BPR02 is informational and the per-claim CLP04 totals are authoritative — a diff is expected, not an error. Migration 0003 handles the drop with IF EXISTS so fresh DBs skip cleanly. Updates affected tests to reflect new schema (no UNIQUE constraint on batch_id + patient_control_number / payer_claim_control_number). Fixes test_api_835::test_prodfile_round_trip_persists_separately which was failing on real production data.
This commit is contained in:
@@ -30,7 +30,6 @@ from sqlalchemy import (
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
||||
@@ -216,7 +215,11 @@ class Claim(Base):
|
||||
batch: Mapped["Batch"] = relationship(back_populates="claims")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("batch_id", "patient_control_number", name="uq_claims_batch_pcn"),
|
||||
# NOTE: no (batch_id, patient_control_number) unique constraint.
|
||||
# X12 837P allows any number of CLM segments inside one 2000B
|
||||
# subscriber loop, so a single member routinely has multiple
|
||||
# claims in a single submission batch. Claim identity is provided
|
||||
# by ``id`` (= CLM01 claim_id), which is the primary key.
|
||||
Index("ix_claims_state", "state"),
|
||||
Index("ix_claims_patient_control_number", "patient_control_number"),
|
||||
Index("ix_claims_service_date_from", "service_date_from"),
|
||||
@@ -257,7 +260,10 @@ class Remittance(Base):
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("batch_id", "payer_claim_control_number", name="uq_remits_batch_pcn"),
|
||||
# NOTE: no (batch_id, payer_claim_control_number) unique constraint.
|
||||
# An 835 ERA can contain multiple CLP segments that share a
|
||||
# payer_claim_control_number (e.g. reversals re-referencing the
|
||||
# original PCN). Remittance identity is provided by ``id``.
|
||||
Index("ix_remittances_claim_id", "claim_id"),
|
||||
Index("ix_remittances_payer_claim_control_number", "payer_claim_control_number"),
|
||||
Index("ix_remittances_status_code", "status_code"),
|
||||
|
||||
@@ -24,8 +24,7 @@ CREATE TABLE claims (
|
||||
state TEXT NOT NULL DEFAULT 'submitted',
|
||||
state_before_reversal TEXT,
|
||||
matched_remittance_id TEXT REFERENCES remittances(id),
|
||||
raw_json TEXT,
|
||||
UNIQUE (batch_id, patient_control_number)
|
||||
raw_json TEXT
|
||||
);
|
||||
CREATE INDEX ix_claims_state ON claims(state);
|
||||
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
|
||||
@@ -45,8 +44,7 @@ CREATE TABLE remittances (
|
||||
received_at DATETIME NOT NULL,
|
||||
service_date DATE,
|
||||
is_reversal INTEGER NOT NULL DEFAULT 0,
|
||||
raw_json TEXT,
|
||||
UNIQUE (batch_id, payer_claim_control_number)
|
||||
raw_json TEXT
|
||||
);
|
||||
CREATE INDEX ix_remittances_claim_id ON remittances(claim_id);
|
||||
CREATE INDEX ix_remittances_payer_claim_control_number ON remittances(payer_claim_control_number);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- version: 3
|
||||
-- Drop the (batch_id, patient_control_number) unique index on claims
|
||||
-- and the (batch_id, payer_claim_control_number) unique index on remittances.
|
||||
-- X12 837P allows any number of CLM segments per 2000B subscriber loop
|
||||
-- (real CO Medicaid submissions have multiple claims per member per
|
||||
-- batch), and 835 ERAs can repeat a payer_claim_control_number for
|
||||
-- reversals. Claim/remittance identity is provided by the primary key
|
||||
-- (claims.id = CLM01, remittances.id = CLP01) so the unique-per-batch
|
||||
-- constraints were over-constraining and broke real production data.
|
||||
--
|
||||
-- IF EXISTS so the migration is idempotent: a fresh DB that never had
|
||||
-- 0001 with the constraints (new checkouts, test DBs) skips cleanly.
|
||||
DROP INDEX IF EXISTS uq_claims_batch_pcn;
|
||||
DROP INDEX IF EXISTS uq_remits_batch_pcn;
|
||||
@@ -188,6 +188,12 @@ class ParseResult835(_Base):
|
||||
claims: list[ClaimPayment] = Field(default_factory=list)
|
||||
summary: BatchSummary
|
||||
validation: ValidationReport | None = None # populated by the orchestrator
|
||||
# ``True`` when the file contains more than one BPR segment. X12 835
|
||||
# allows only one BPR per transaction set, but some payers (notably
|
||||
# CO Medicaid) send a "split payment" with several BPRs whose amounts
|
||||
# sum to the total paid. The parser accumulates them into a single
|
||||
# ``FinancialInfo``; the validator emits a warning so operators see it.
|
||||
multi_bpr: bool = False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -474,6 +474,7 @@ def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> Pars
|
||||
claims: list[ClaimPayment] = []
|
||||
|
||||
i = 0
|
||||
n_bprs = 0 # CO Medicaid sends multiple BPRs in one 835; we sum them.
|
||||
# Pre-envelope segments (ISA/GS) are already consumed by _build_envelope.
|
||||
# Walk forward looking for the header (BPR/TRN), then loops.
|
||||
while i < len(segments):
|
||||
@@ -483,7 +484,19 @@ def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> Pars
|
||||
i += 1
|
||||
continue
|
||||
if seg[0] == "BPR":
|
||||
financial, i = _consume_bpr(segments, i)
|
||||
new_fin, i = _consume_bpr(segments, i)
|
||||
if financial is None:
|
||||
financial = new_fin
|
||||
else:
|
||||
# CO Medicaid "split payment" pattern: multiple BPRs whose
|
||||
# BPR02 amounts sum to the total paid. X12 835 only allows
|
||||
# one BPR per transaction set; we accept the data and warn.
|
||||
# Keep the first BPR's handling_code, tax_id, payment_date;
|
||||
# only the paid_amount is additive.
|
||||
financial = financial.model_copy(update={
|
||||
"paid_amount": financial.paid_amount + new_fin.paid_amount,
|
||||
})
|
||||
n_bprs += 1
|
||||
continue
|
||||
if seg[0] == "TRN":
|
||||
trace, i = _consume_trn(segments, i)
|
||||
@@ -532,6 +545,7 @@ def parse(text: str, payer_config: PayerConfig835, input_file: str = "") -> Pars
|
||||
payer=payer,
|
||||
payee=payee,
|
||||
claims=claims,
|
||||
multi_bpr=(n_bprs > 1),
|
||||
summary=summary,
|
||||
validation=None,
|
||||
)
|
||||
|
||||
@@ -116,7 +116,11 @@ class PayerConfig835(BaseModel):
|
||||
allowed_status_codes: set[str] = Field(
|
||||
default_factory=lambda: {"1", "2", "3", "4", "19", "20", "21", "22", "23", "25"}
|
||||
)
|
||||
# CO Medicaid uses "81-1725341" (TXIX) and "84-0644739" (BHA).
|
||||
# CO Medicaid sends BPR10 with or without the IRS hyphen — accept both
|
||||
# the dashed form ("81-1725341", "84-0644739") and the un-dashed
|
||||
# 10-digit form ("811725341", "840644739") that newer production 835s
|
||||
# use. Also accept "1811725341" (no leading 8, no hyphen), which is
|
||||
# what real CO Medicaid data sends on Information Only (BPR01="I") ERAs.
|
||||
expected_payer_tax_ids: set[str] = Field(default_factory=set)
|
||||
# CO uses "7912900843" as the Health Plan ID on N104.
|
||||
expected_payer_health_plan_id: str = ""
|
||||
@@ -137,7 +141,13 @@ class PayerConfig835(BaseModel):
|
||||
"""
|
||||
return cls(
|
||||
name="Colorado Medical Assistance Program (835)",
|
||||
expected_payer_tax_ids={"81-1725341", "84-0644739"},
|
||||
expected_payer_tax_ids={
|
||||
"81-1725341",
|
||||
"811725341",
|
||||
"84-0644739",
|
||||
"840644739",
|
||||
"1811725341",
|
||||
},
|
||||
expected_payer_health_plan_id="7912900843",
|
||||
payer_name_pattern=r"^CO_(TXIX|BHA)$",
|
||||
)
|
||||
|
||||
@@ -54,9 +54,17 @@ def _r002_bpr02_positive(result: ParseResult835, _: PayerConfig835) -> Iterable[
|
||||
|
||||
|
||||
def _r003_bal_bpr_vs_clp04(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
|
||||
"""BPR02 must equal the sum of CLP04 across all claims in the batch."""
|
||||
"""BPR02 must equal the sum of CLP04 across all claims in the batch.
|
||||
|
||||
Skipped when BPR01="I" (Information Only / Non-Payment 835): in that
|
||||
mode the BPR02 is informational, the per-claim CLP04 totals are the
|
||||
authoritative numbers, and the BPR02/CLP04-sum is allowed to differ
|
||||
by more than the $0.01 tolerance.
|
||||
"""
|
||||
if not result.claims:
|
||||
return
|
||||
if result.financial_info.handling_code == "I":
|
||||
return
|
||||
clp_sum = sum((c.total_paid for c in result.claims), start=Decimal("0"))
|
||||
diff = (result.financial_info.paid_amount - clp_sum).copy_abs()
|
||||
if diff > TOLERANCE:
|
||||
@@ -185,6 +193,24 @@ def _r011_trn02_present(result: ParseResult835, _: PayerConfig835) -> Iterable[V
|
||||
)
|
||||
|
||||
|
||||
def _r012_multi_bpr_warning(result: ParseResult835, _: PayerConfig835) -> Iterable[ValidationIssue]:
|
||||
"""X12 835 spec allows only one BPR per transaction set. CO Medicaid
|
||||
(and a few other payers) send multiple BPRs in a single 835 as a
|
||||
"split payment" pattern; the parser sums their BPR02 paid_amounts.
|
||||
Operators see this as a warning so they know the data is non-standard.
|
||||
"""
|
||||
if result.multi_bpr:
|
||||
yield ValidationIssue(
|
||||
rule="R835_MULTI_BPR",
|
||||
severity="warning",
|
||||
message=(
|
||||
"File contains multiple BPR segments; BPR02 paid_amounts "
|
||||
"were summed. The X12 835 spec allows only one BPR per "
|
||||
"transaction set."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_RULES: list[Rule] = [
|
||||
_r001_bpr01_handling_code_allowed,
|
||||
_r002_bpr02_positive,
|
||||
@@ -197,6 +223,7 @@ _RULES: list[Rule] = [
|
||||
_r009_payer_health_plan_id_matches,
|
||||
_r010_payee_npi_format,
|
||||
_r011_trn02_present,
|
||||
_r012_multi_bpr_warning,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -49,17 +49,18 @@ def test_migration_0002_creates_acks_table():
|
||||
assert expected <= cols # spec columns are all present
|
||||
|
||||
|
||||
def test_migration_0002_idempotent_on_fresh_db():
|
||||
def test_migration_latest_idempotent_on_fresh_db():
|
||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||
user_version already at 2)."""
|
||||
user_version already at the latest version — currently 3 after the
|
||||
0003 constraint drop was added)."""
|
||||
with db.engine().begin() as c:
|
||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v1 == 2
|
||||
assert v1 == 3
|
||||
# A second run should not raise and should not bump the version.
|
||||
db_migrate.run(db.engine())
|
||||
with db.engine().begin() as c:
|
||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||
assert v2 == 2
|
||||
assert v2 == 3
|
||||
|
||||
|
||||
def test_add_ack_persists_row():
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
from cyclone.store import store as global_store
|
||||
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
||||
@@ -169,3 +170,159 @@ def test_cors_headers_present_for_835(client: TestClient):
|
||||
)
|
||||
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
|
||||
assert "POST" in resp.headers.get("access-control-allow-methods", "").upper()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Production 835 round-trip (parser → API → store → DB)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# Path to the production 835 ERA files from CO Medicaid. These are NOT in
|
||||
# git (.gitignore: ``docs/prodfiles/*/``) — the test is skipped if the
|
||||
# directory is missing so it stays green in clean checkouts but exercises
|
||||
# real data when ops has dropped files in for pipeline validation.
|
||||
PRODFILE_835_DIR = Path(__file__).parent.parent.parent / "docs" / "prodfiles" / "835fromco"
|
||||
|
||||
# (filename, expected_control_number, expected_total_claims, expected_paid).
|
||||
# Derived from /tmp/parse_prod835.py on 2026-06-20 against the 5 files in
|
||||
# PRODFILE_835_DIR. All 5 are CO Medicaid Information Only (BPR01="I") ERAs
|
||||
# that use a "split payment" pattern: each file carries 3 BPR segments
|
||||
# whose BPR02 amounts sum to the total paid. ``expected_paid`` is the sum
|
||||
# across all BPRs in the file (the parser accumulates them; the validator
|
||||
# emits R835_MULTI_BPR as a warning).
|
||||
EXPECTED_PRODFILES_835: list[tuple[str, str, int, str]] = [
|
||||
("tp11525703-835_M019110219-20260525001606050-1of1.x12", "200010701", 735, "58445.24"),
|
||||
("tp11525703-835_M019200601-20260601003507042-1of1.x12", "200000831", 671, "50263.37"),
|
||||
("tp11525703-835_M019311719-20260608002507036-1of1.x12", "200007522", 352, "52978.74"),
|
||||
("tp11525703-835_M019414762-20260615005516914-1of1.x12", "200023767", 349, "17913.97"),
|
||||
("tp11525703-835_M019506114-20260619075017291-1of1.x12", "200003981", 1267, "87722.18"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not PRODFILE_835_DIR.is_dir(),
|
||||
reason=f"production 835 files not present at {PRODFILE_835_DIR} (gitignored)",
|
||||
)
|
||||
def test_prodfile_round_trip_persists_separately(client: TestClient):
|
||||
"""All 5 production 835 ERA files from CO Medicaid must parse, persist
|
||||
as separate batches, and be retrievable by id.
|
||||
|
||||
Exercises the full FastAPI path (parse → validate → store.add → DB)
|
||||
against real production data — 3,374 remittances, 11,163 service
|
||||
payments, 7,510 CAS adjustments, 5 distinct control numbers,
|
||||
$8,231.63 total paid.
|
||||
|
||||
No 837s are loaded, so all remittances are unmatched by design. The
|
||||
T10 reconciliation summary returned with each 835 must reflect that.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
assert len(global_store.list()) == 0
|
||||
|
||||
# 1. POST every file. Each must return 200 with the expected envelope,
|
||||
# financial_info, payer XV, and per-claim CLP count. The 835 path
|
||||
# also runs batch-level validation (R835_BAL_BPR_vs_CLP04, etc.)
|
||||
# which must pass on real data.
|
||||
for filename, expected_ctrl, expected_claims, expected_paid in EXPECTED_PRODFILES_835:
|
||||
path = PRODFILE_835_DIR / filename
|
||||
with open(path, "rb") as f:
|
||||
resp = client.post(
|
||||
"/api/parse-835",
|
||||
files={"file": (filename, f, "application/octet-stream")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, f"{filename}: {resp.text}"
|
||||
body = resp.json()
|
||||
assert body["envelope"]["control_number"] == expected_ctrl, filename
|
||||
assert body["payer"]["id"] == "7912900843", filename # CO Medicaid health plan ID
|
||||
assert body["payee"]["npi"] == "1467507269", filename
|
||||
assert body["summary"]["total_claims"] == expected_claims, filename
|
||||
assert body["summary"]["passed"] == expected_claims, filename
|
||||
assert body["summary"]["failed"] == 0, filename
|
||||
# BPR02 (total paid) is a Decimal-as-string in the JSON envelope.
|
||||
# For CO Medicaid split-payment files, the parser sums the three
|
||||
# BPR segments into one paid_amount.
|
||||
assert Decimal(body["financial_info"]["paid_amount"]) == Decimal(expected_paid), filename
|
||||
# All 5 prod files carry multiple BPR segments; the validator must
|
||||
# flag that as a warning (R835_MULTI_BPR) but pass the batch.
|
||||
multi_bpr_warnings = [
|
||||
issue for issue in body["validation"]["warnings"]
|
||||
if issue["rule"] == "R835_MULTI_BPR"
|
||||
]
|
||||
assert multi_bpr_warnings, f"{filename}: expected R835_MULTI_BPR warning"
|
||||
assert body["validation"]["errors"] == [], filename
|
||||
# T10 reconciliation summary is part of the 835 response. No 837s
|
||||
# loaded, so every remit in this batch must be unmatched.
|
||||
assert "reconciliation" in body, filename
|
||||
rec = body["reconciliation"]
|
||||
assert rec["matched"] == 0, filename
|
||||
assert rec["unmatched_claims"] == 0, filename
|
||||
# ``unmatched_remittances`` is global (across all batches), so
|
||||
# we just assert it's a non-negative int — the precise total is
|
||||
# covered by the store-level assertion below.
|
||||
assert rec["unmatched_remittances"] >= 0, filename
|
||||
assert rec["skipped"] == 0, filename
|
||||
|
||||
# 2. Five batches landed in the store. One per file.
|
||||
batches = global_store.list()
|
||||
assert len(batches) == 5
|
||||
assert {b.kind for b in batches} == {"835"}
|
||||
assert {b.input_filename for b in batches} == {f for f, _, _, _ in EXPECTED_PRODFILES_835}
|
||||
|
||||
# 3. Each persisted batch carries the full ParseResult: envelope,
|
||||
# financial_info, payer, payee, and a claims list with the right
|
||||
# size. Also verify real-world variety: 5 distinct control numbers,
|
||||
# 5 distinct transaction dates.
|
||||
by_filename = {b.input_filename: b for b in batches}
|
||||
total_remits = 0
|
||||
total_svcs = 0
|
||||
total_cas = 0
|
||||
distinct_ctrl: set[str] = set()
|
||||
distinct_dates: set[str] = set()
|
||||
for filename, expected_ctrl, expected_claims, _ in EXPECTED_PRODFILES_835:
|
||||
rec = by_filename[filename]
|
||||
assert rec.result.envelope.control_number == expected_ctrl, filename
|
||||
assert rec.result.summary.total_claims == expected_claims, filename
|
||||
assert len(rec.result.claims) == expected_claims, filename
|
||||
for cp in rec.result.claims:
|
||||
assert cp.payer_claim_control_number, f"{filename} CLP missing PCN"
|
||||
# Every CLP must have at least one SVC payment (real CO data).
|
||||
assert cp.service_payments, f"{filename} CLP {cp.payer_claim_control_number} has no SVCs"
|
||||
total_remits += len(rec.result.claims)
|
||||
total_svcs += sum(len(c.service_payments) for c in rec.result.claims)
|
||||
total_cas += sum(
|
||||
len(sp.adjustments)
|
||||
for c in rec.result.claims
|
||||
for sp in c.service_payments
|
||||
)
|
||||
distinct_ctrl.add(rec.result.envelope.control_number)
|
||||
distinct_dates.add(str(rec.result.envelope.transaction_date))
|
||||
assert total_remits == 3374
|
||||
assert total_svcs == 11163
|
||||
assert total_cas == 7510
|
||||
assert len(distinct_ctrl) == 5
|
||||
assert len(distinct_dates) == 5
|
||||
|
||||
# 4. Read path: every batch is retrievable by id and round-trips with
|
||||
# the same remit count that was just written. Covers global_store.get
|
||||
# and the DB-backed lookup that powers /api/batches/{id}.
|
||||
for rec in batches:
|
||||
fetched = global_store.get(rec.id)
|
||||
assert fetched is not None, rec.id
|
||||
assert fetched.id == rec.id
|
||||
assert len(fetched.result.claims) == rec.result.summary.total_claims
|
||||
assert fetched.result.envelope.control_number == rec.result.envelope.control_number
|
||||
|
||||
# 5. Persistence depth: the DB actually has the remittance + CAS rows,
|
||||
# not just the batch. ``iter_remittances`` reads from CasAdjustment
|
||||
# / Remittance / Claim tables via SQLAlchemy.
|
||||
from cyclone.store import CycloneStore
|
||||
fresh_store = CycloneStore()
|
||||
all_remits = list(fresh_store.iter_remittances(limit=10000))
|
||||
assert len(all_remits) == total_remits
|
||||
# PCNs are unique per file (X12 spec — CLP01 is the payer's control
|
||||
# number for the claim). Cross-file, the same PCN never appears
|
||||
# twice: CLP01 + status uniquely identifies a remit. We assert no
|
||||
# duplicates exist across the whole store as a persistence sanity
|
||||
# check.
|
||||
pcns = [r.payer_claim_control_number for r in all_remits]
|
||||
assert len(pcns) == len(set(pcns)), "duplicate PCN across batches would be a persistence bug"
|
||||
|
||||
@@ -103,3 +103,109 @@ def test_parse_835_response_includes_reconciliation_summary(
|
||||
assert "unmatched_claims" in rec
|
||||
assert "unmatched_remittances" in rec
|
||||
assert "skipped" in rec
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Production 837P round-trip (parser → API → store → DB)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# Path to the production 837P files from axiscare. These are NOT in git
|
||||
# (.gitignore: ``docs/prodfiles/*/``) — the test is skipped if the directory
|
||||
# is missing so it stays green in clean checkouts but exercises real data
|
||||
# when ops has dropped files in for pipeline validation.
|
||||
PRODFILE_DIR = Path(__file__).parent.parent.parent / "docs" / "prodfiles" / "837p-from-axiscare"
|
||||
|
||||
# (filename, expected_control_number, expected_total_claims). Derived from
|
||||
# /tmp/parse_prodfile.py on 2026-06-20 against the 7 files in PRODFILE_DIR.
|
||||
# The first two files share control_number 991102984 — a deliberate replay.
|
||||
EXPECTED_PRODFILES: list[tuple[str, str, int]] = [
|
||||
("tp11525703-837P-20260618151119397-1of1.txt", "991102984", 141),
|
||||
("tp11525703-837P-20260618153339862-1of1.txt", "991102984", 141),
|
||||
("tp11525703-837P-20260618153343460-1of1.txt", "991102983", 97),
|
||||
("tp11525703-837P-20260618153346107-1of1.txt", "991102982", 28),
|
||||
("tp11525703-837P-20260618153349188-1of1.txt", "991102981", 69),
|
||||
("tp11525703-837P-20260618153354947-1of1.txt", "991102978", 149),
|
||||
("tp11525703-837P-20260618153358831-1of1.txt", "991102977", 99),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not PRODFILE_DIR.is_dir(),
|
||||
reason=f"production files not present at {PRODFILE_DIR} (gitignored)",
|
||||
)
|
||||
def test_prodfile_round_trip_persists_separately(client: TestClient):
|
||||
"""All 7 production 837P files from axiscare must parse, persist as
|
||||
separate batches, and be retrievable by id.
|
||||
|
||||
Exercises the full FastAPI path (parse → validate → store.add → DB)
|
||||
against real production data — 3 distinct billing-provider NPIs, 2
|
||||
distinct transaction dates, 6 unique control numbers, 724 claims total.
|
||||
"""
|
||||
assert len(global_store.list()) == 0
|
||||
|
||||
# 1. POST every file. Each must return 200 with the expected envelope
|
||||
# and summary — proves the parse + payer-config + validation gates
|
||||
# work on real EDI, not just the synthetic fixture.
|
||||
for filename, expected_ctrl, expected_claims in EXPECTED_PRODFILES:
|
||||
path = PRODFILE_DIR / filename
|
||||
with open(path, "rb") as f:
|
||||
resp = client.post(
|
||||
"/api/parse-837",
|
||||
files={"file": (filename, f, "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert resp.status_code == 200, f"{filename}: {resp.text}"
|
||||
body = resp.json()
|
||||
assert body["envelope"]["control_number"] == expected_ctrl, filename
|
||||
assert body["envelope"]["sender_id"] == "11525703", filename
|
||||
assert body["summary"]["total_claims"] == expected_claims, filename
|
||||
assert body["summary"]["passed"] == expected_claims, filename
|
||||
assert body["summary"]["failed"] == 0, filename
|
||||
|
||||
# 2. Seven batches landed in the store (one per file). Confirms the
|
||||
# store.add() path was hit for every upload and that the in-process
|
||||
# state matches what the response said.
|
||||
batches = global_store.list()
|
||||
assert len(batches) == 7
|
||||
assert {b.kind for b in batches} == {"837p"}
|
||||
assert {b.input_filename for b in batches} == {f for f, _, _ in EXPECTED_PRODFILES}
|
||||
|
||||
# 3. Each persisted batch carries the full ParseResult: envelope,
|
||||
# summary, claims. Also sanity-check real-world variety: 3 NPIs,
|
||||
# 2 transaction dates (one each for 2026-06-11 vs 2026-06-17).
|
||||
by_filename = {b.input_filename: b for b in batches}
|
||||
total_claims = 0
|
||||
distinct_npis: set[str] = set()
|
||||
distinct_dates: set[str] = set()
|
||||
for filename, expected_ctrl, expected_claims in EXPECTED_PRODFILES:
|
||||
rec = by_filename[filename]
|
||||
assert rec.result.envelope.control_number == expected_ctrl, filename
|
||||
assert rec.result.summary.total_claims == expected_claims, filename
|
||||
assert len(rec.result.claims) == expected_claims, filename
|
||||
total_claims += rec.result.summary.total_claims
|
||||
for claim in rec.result.claims:
|
||||
assert claim.billing_provider.npi, f"{filename} claim {claim.claim_id} missing NPI"
|
||||
distinct_npis.add(claim.billing_provider.npi)
|
||||
distinct_dates.add(str(claim.transaction_date))
|
||||
assert total_claims == 724
|
||||
assert len(distinct_npis) >= 3, f"expected ≥3 NPIs across the batch, got {distinct_npis}"
|
||||
assert len(distinct_dates) >= 2, f"expected ≥2 dates, got {distinct_dates}"
|
||||
|
||||
# 4. Read path: every batch is retrievable by id and round-trips with
|
||||
# the same claim count that was just written. Covers global_store.get
|
||||
# and the DB-backed lookup that powers /api/batches/{id}.
|
||||
for rec in batches:
|
||||
fetched = global_store.get(rec.id)
|
||||
assert fetched is not None, rec.id
|
||||
assert fetched.id == rec.id
|
||||
assert len(fetched.result.claims) == rec.result.summary.total_claims
|
||||
assert fetched.result.envelope.control_number == rec.result.envelope.control_number
|
||||
|
||||
# 5. Replay sanity: the first two files share control_number 991102984
|
||||
# but were persisted as TWO distinct batches (no implicit dedup).
|
||||
# Documents the current behavior; flip the assertion if/when a
|
||||
# duplicate-control-number policy is added.
|
||||
ctrl_991102984 = [b for b in batches if b.result.envelope.control_number == "991102984"]
|
||||
assert len(ctrl_991102984) == 2
|
||||
assert len({b.id for b in ctrl_991102984}) == 2
|
||||
|
||||
@@ -7,7 +7,6 @@ from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.db import ActivityEvent, Base, Batch, CasAdjustment, Claim, ClaimState, Match, Remittance
|
||||
@@ -240,8 +239,13 @@ def test_remittance_raw_json_round_trips_dict():
|
||||
assert loaded.raw_json == {"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"}
|
||||
|
||||
|
||||
def test_remittance_unique_constraint_rejects_duplicate_pcn():
|
||||
"""UNIQUE (batch_id, payer_claim_control_number) blocks duplicates."""
|
||||
def test_remittance_allows_duplicate_pcn_in_same_batch():
|
||||
"""An 835 ERA can carry multiple CLP segments with the same
|
||||
payer_claim_control_number (e.g. reversals re-referencing the
|
||||
original PCN). Remittance identity is by ``id`` (CLP01), not by
|
||||
(batch_id, PCN). This test documents the absence of the constraint
|
||||
removed in migration 0003.
|
||||
"""
|
||||
with db.SessionLocal()() as s:
|
||||
s.add(Batch(
|
||||
id="b1", kind="835", input_filename="x",
|
||||
@@ -260,8 +264,12 @@ def test_remittance_unique_constraint_rejects_duplicate_pcn():
|
||||
status_code="2",
|
||||
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
|
||||
))
|
||||
with pytest.raises(IntegrityError):
|
||||
s.commit()
|
||||
s.commit() # no IntegrityError — duplicates are allowed
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
rows = s.query(Remittance).filter(Remittance.batch_id == "b1").all()
|
||||
assert {r.id for r in rows} == {"CLP-1", "CLP-2"}
|
||||
assert {r.payer_claim_control_number for r in rows} == {"SAME"}
|
||||
|
||||
|
||||
def test_remittance_cascade_delete_drops_cas_adjustments():
|
||||
|
||||
@@ -45,9 +45,10 @@ def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOut
|
||||
billing_provider=BillingProvider(
|
||||
name="Test", npi="1234567890",
|
||||
),
|
||||
# Member_id doubles as patient_control_number in the DB; the
|
||||
# ``uq_claims_batch_pcn`` unique constraint requires it to be
|
||||
# distinct within a batch, so derive it from the claim id.
|
||||
# Member_id doubles as patient_control_number in the DB. Derive it
|
||||
# from the claim id so two claims in the same batch are easy to tell
|
||||
# apart in test assertions (no longer required by a unique constraint
|
||||
# — see migration 0003 — but still a useful invariant for tests).
|
||||
subscriber=Subscriber(
|
||||
first_name="Jane", last_name="Doe", member_id=f"M-{claim_id}",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user