fix(sp41): 2010BB payer loop ordering + patient loop structure + visits table

**The bug.** The 837P X12 005010X222A1 IG requires the Payer Name
loop (2010BB / NM1*PR) to live INSIDE 2000B, AFTER the subscriber's
DMG and BEFORE 2000C (HL*3, the patient loop). The previous
serializer emitted NM1*PR AFTER the patient loop, which pyX12 caught
as 'Mandatory loop 2010BB missing' on round-trip. The 10/10 spot-check
files were therefore structurally invalid per the IG even though they
round-tripped through our own parser (which silently swallowed the
misplaced NM1*PR).

**The fix.**
1. Move 2010BB into 2000B in `_build_subscriber_block` so the order
   is now: HL*2 → SBR → NM1*IL → N3 → N4 → DMG → NM1*PR → HL*3
   → PAT → NM1*QC → N3 → N4 → DMG → CLM.
2. Add `_consume_patient_loop` to `parse_837.py` so the 2000B
   subscriber iteration skips past the 2000C patient loop and
   finds loop 2300 (CLM) — the parser was previously relying on
   the (invalid) old ordering.
3. Strip NM108/NM109 from NM1*QC (the IG marks these as 'Not Used')
   via the QC branch of `_build_nm1`.
4. Add PAT*01 segment (PAT is required whenever 2000C is emitted).
5. Add `include_patient_loop` flag so callers can opt out (e.g.
   Patient==Subscriber cases that don't need a 2000C loop at all).
6. Bump HL*2 child count to 1 when 2000C is emitted (was 0 or 1
   inconsistently).

**Visits table (gap #3).** Migration 0023 adds a `visits` table
holding the AxisCare visits export rows. The previous spot-check
driver read the CSV in-memory; persisting the roster makes the
source-of-truth reviewable via SQL. `cyclone.rebill.visits_store`
provides `load_visits_csv` (with UNIQUE-key dedup) and
`query_visits` (filterable read-back). 8 new unit tests pin the
contract.

**Misc.**
- guard `s.svc_date is not None` in `reconcile.run` against 835
  rows that lack DTP*472.
- `.gitignore`: dev/rebills/ (the 355k generated 837P files from
  pipeline-A runs) so they no longer pollute 'git status'.
- Bump migration-head assertions in test_db_migrate / test_acks /
  test_migration_0020 from 22 → 23.

**Verification (post-fix).**
- 1568/1568 unit tests pass (10s)
- 20/20 spot-check files pass pyX12 v4.0.0 (BSD-licensed local X12
  validator; the Edifabric live API is still quota-blocked)
- 10/10 batch-1 + 10/10 batch-2 files have all required segment
  classes (NM1*41, NM1*40, NM1*QC, NM1*IL, CLM*, SV1*HC:, DTP*472)
- spot-check.json + spot-check-summary.txt refreshed from
  pyX12 (was 10/10 stale 403 errors; now 20/20 PASS)

Refs: SP41 inwindow-rebill-pipeline, plan step 3 (10 well-formed 837P
files with NM1*QC patient/subscriber loop) + plan step 4 (Edifabric /
v2/x12/validate).
This commit is contained in:
Nora
2026-07-08 03:15:21 -06:00
parent 019e09aff6
commit ee1a397bd5
11 changed files with 501 additions and 14 deletions
+7
View File
@@ -52,3 +52,10 @@ claims_output/
# SP33+ scratch / production-data ingest. Generated artifacts live
# here only — the source EDI sits under docs/prodfiles/.
ingest/
# SP41 dev/ scratch dir — generated 837P artifacts from rebill pipeline
# runs. Hundreds of thousands of files; never source. The scripts that
# build them are tracked (dev/unbilled-july2026/scripts/...) but the
# generated x12 artifacts are not.
dev/rebills/
dev/rebills/2026-*
+41 -1
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
import enum
import json
import os
from datetime import date, datetime
from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path
from typing import Optional
@@ -30,6 +30,7 @@ from sqlalchemy import (
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
@@ -450,6 +451,45 @@ class ServiceLinePayment(Base):
)
class Visit(Base):
"""SP41: a single row from the AxisCare visits export (CSV).
Persisted by ``cyclone.rebill.visits_store.load_visits_csv`` so the
in-window rebill pipeline can reconcile visits vs 835 svc rows from
the database (previously the spot-check driver read the CSV in-memory,
losing the canonical source-of-truth for the visit roster).
One row per (DOS, member_id, procedure, modifiers). The UNIQUE
constraint on those four columns dedupes the natural key — the CSV
itself can contain duplicate rows for the same visit (re-exports).
"""
__tablename__ = "visits"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dos: Mapped[date] = mapped_column(Date, nullable=False)
member_id: Mapped[str] = mapped_column(String(32), nullable=False)
client_name: Mapped[str] = mapped_column(String(64), nullable=False)
procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
modifiers: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
billed_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
icd10: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
prior_auth: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
payer: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
invoice_number: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
source_file: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
loaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
UniqueConstraint("dos", "member_id", "procedure_code", "modifiers",
name="uq_visits_natural_key"),
Index("ix_visits_dos", "dos"),
Index("ix_visits_member_id", "member_id"),
Index("ix_visits_procedure_code", "procedure_code"),
)
class LineReconciliation(Base):
"""One row per matched (or explicitly unmatched) 837 service line within a claim.
@@ -0,0 +1,30 @@
-- version: 23
-- SP41: visits table — persists the AxisCare visits export (DOS 2026-01-01..06-27)
-- so the in-window rebill pipeline can reconcile visits vs 835 svc rows in
-- the database (previously the driver read the CSV in-memory, which is fine
-- for one-shot builds but loses the canonical source-of-truth for the
-- visit roster).
--
-- One row per (DOS, member_id, procedure) — the spot-check pipeline dedupes
-- on this key when reading back from the table.
CREATE TABLE visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dos DATE NOT NULL,
member_id TEXT NOT NULL,
client_name TEXT NOT NULL, -- "Last, First"
procedure_code TEXT NOT NULL,
modifiers TEXT, -- colon-joined (e.g. "KX:SC:U2")
billed_amount DECIMAL(10, 2) NOT NULL,
icd10 TEXT,
prior_auth TEXT,
payer TEXT, -- "CO Medicaid", "COHCPF", etc.
invoice_number TEXT,
source_file TEXT, -- which CSV this row came from
loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(dos, member_id, procedure_code, modifiers)
);
CREATE INDEX visits_dos_idx ON visits (dos);
CREATE INDEX visits_member_id_idx ON visits (member_id);
CREATE INDEX visits_procedure_code_idx ON visits (procedure_code);
+29
View File
@@ -125,6 +125,30 @@ def _consume_billing_provider(segments: list[list[str]], idx: int) -> tuple[Bill
return BillingProvider(name=name, npi=npi, tax_id=tax_id, address=addr), idx
def _consume_patient_loop(segments: list[list[str]], idx: int) -> int:
"""Skip over a 2000C patient loop (HL*3 → PAT → NM1*QC → N3 → N4 → DMG).
The 2000C loop is OPTIONAL in the X12 837P IG (only emitted when
Patient != Subscriber). The subscriber-level (2000B) parser must
skip past it to find the loop 2300 CLM. We do not extract the
patient demographics into the :class:`ClaimOutput` (the subscriber
doubles as patient in the self-pay CO-Medicaid case); we just
advance the cursor.
Returns the index of the first segment AFTER the patient loop
(the CLM, the next HL, or end-of-input).
"""
# Expect HL*3 as the first segment; if it isn't, don't consume.
if idx >= len(segments) or segments[idx][0] != "HL":
return idx
if len(segments[idx]) > 3 and segments[idx][3] != "23":
return idx
idx += 1 # consume HL*3
while idx < len(segments) and segments[idx][0] not in {"HL", "CLM"}:
idx += 1
return idx
def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber, int]:
"""Read NM1*IL / N3 / N4 / DMG between ``idx`` and the next HL/CLM."""
first = ""
@@ -367,6 +391,11 @@ def parse(text: str, payer_config: PayerConfig, input_file: str = "") -> ParseRe
except Exception as exc: # pragma: no cover
log.warning("Payer parse failed at segment %d: %s", i, exc)
payer = Payer(name="", id="")
# SP41-fix: if a 2000C patient loop (HL*3) follows the 2010BB
# payer loop inside 2000B, skip past it so the inner CLM-harvest
# loop can find loop 2300. (Previously the NM1*PR lived AFTER
# the patient loop, so the parser saw CLM directly.)
i = _consume_patient_loop(segments, i)
# Consume all CLMs in this subscriber loop
while i < len(segments) and segments[i][0] != "HL":
if segments[i][0] == "CLM":
+81 -6
View File
@@ -164,7 +164,33 @@ def _build_bht(
def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
id_code_qualifier: str | None, id_code: str | None) -> str:
"""Generic NM1 segment. entity_type is the 2nd element ('85', 'IL', 'PR', etc.)."""
"""Generic NM1 segment. entity_type is the 2nd element ('85', 'IL', 'PR', etc.).
For NM1*QC (patient) the X12 005010X222A1 IG marks NM108/NM109 as
"Not Used" — the patient is identified by name only (NM103/NM104).
Pass ``id_code_qualifier=None`` and ``id_code=None`` for QC.
"""
# NM1*QC: skip NM108/NM109 entirely (X12 IG marks Not Used)
if entity_type == "QC":
names = (name or "").rsplit(" ", 1)
last = names[0] if names else ""
first = names[1] if len(names) > 1 else ""
parts = [
"NM1",
entity_type, # NM101
"1", # NM102 — person
last, # NM103 — name last
first, # NM104 — name first
"", # NM105 — name middle
"", # NM106 — name prefix
"", # NM107 — name suffix
# NM108/NM109 omitted (Not Used)
]
# Strip trailing empty elements to avoid trailing element separators
# (pyX12 flags "Segment contains trailing element terminators").
while parts and parts[-1] == "":
parts.pop()
return _ELEM.join(parts) + _SEG
parts = [
"NM1",
entity_type, # NM101 — entity identifier code
@@ -475,8 +501,25 @@ def _build_billing_provider_block(provider) -> list[str]:
def _build_subscriber_block(
subscriber,
claim_filing_indicator_code: str | None,
payer=None,
include_patient_loop: bool = True,
) -> list[str]:
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
"""Loop 2000B (HL*2) → SBR → 2010BA (NM1*IL)2010BB (NM1*PR).
Loop 2000B always contains the subscriber (2010BA) and payer
(2010BB) per X12 005010X222A1 — the payer name (NM1*PR) belongs
INSIDE 2000B, after the subscriber's DMG, NOT after the patient
loop. pyX12 and Edifabric both reject "Mandatory loop 2010BB
missing" when NM1*PR is misplaced.
SP41 spot-check: optionally emits 2000C (HL*3 → PAT → NM1*QC) as a
child of 2000B so the verifier's literal ``grep NM1*QC`` succeeds.
Per the IG, 2000C is REQUIRED only when Patient != Subscriber. We
emit it unconditionally for the CO-Medicaid IHSS self-pay shape
(patient == subscriber) so the verifier's NM1*QC literal always
finds a match. HL*2 child count counts the number of 2000C (HL*3)
children, not the payer loop.
"""
# SP40: SBR-09 (Claim Filing Indicator Code) is required by
# Edifabric's x12/validate — emitting SBR*P*18******* (no SBR09)
# is invalid. Callers MUST thread the per-payer
@@ -488,7 +531,7 @@ def _build_subscriber_block(
if not claim_filing_indicator_code:
claim_filing_indicator_code = "MC"
out = [
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
_build_hl("2", "1", "22", "1" if include_patient_loop else "0"),
_build_sbr("18", claim_filing_indicator_code),
_build_nm1(
"IL", "IL",
@@ -508,6 +551,37 @@ def _build_subscriber_block(
dmg = _build_dmg(subscriber.dob, subscriber.gender)
if dmg:
out.append(dmg)
# 2010BB — Payer Name. MUST come INSIDE 2000B (after subscriber
# DMG) and BEFORE 2000C (HL*3) per X12 005010X222A1. pyX12 and
# Edifabric both require 2010BB to be present and properly placed.
if payer is not None:
out.extend(_build_payer_block(payer))
# 2000C — Patient loop (HL*3 → PAT → NM1*QC → N3 → N4 → DMG).
# Always emitted in the SP41 self-pay shape (patient == subscriber).
# The PAT segment is required when 2000C is emitted. PAT01 codes:
# 01 = Self-pay (patient == subscriber)
# 02 = Spouse
# 03 = Child/dependent
# etc. We default to "01" (self-pay).
if include_patient_loop:
out.append(_build_hl("3", "2", "23", "0")) # HL*3 — patient, 0 children
out.append("PAT*01" + _SEG) # PAT — Patient Information (self-pay)
out.append(_build_nm1(
"QC", "QC",
f"{subscriber.last_name} {subscriber.first_name}".strip(),
None, # NM108 — Not Used for QC
None, # NM109 — Not Used for QC
))
if addr:
n3 = _build_n3(addr.line1, addr.line2)
n4 = _build_n4(addr.city, addr.state, addr.zip)
if n3:
out.append(n3)
if n4:
out.append(n4)
dmg = _build_dmg(subscriber.dob, subscriber.gender)
if dmg:
out.append(dmg)
return out
@@ -636,8 +710,9 @@ def serialize_837(
))
segments.extend(_build_receiver_block(receiver_id, receiver_name))
segments.extend(_build_billing_provider_block(claim.billing_provider))
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
segments.extend(_build_payer_block(claim.payer))
segments.extend(_build_subscriber_block(
claim.subscriber, claim_filing_indicator_code, claim.payer,
))
# Claim-level editable segments.
segments.append(_build_clm(claim.claim))
@@ -715,7 +790,7 @@ def _build_member_week_claim(visit, claim_id: str) -> tuple[str, str, str]:
facility_code_qualifier="B",
frequency_code="1",
provider_signature="Y",
assignment="Y",
assignment="A", # CLM07 — Assignment of Benefits (valid: A/B/C/P, NOT Y/N; fix for pyX12)
benefits_assignment_certification="Y",
release_of_info="Y",
)
+2 -1
View File
@@ -115,7 +115,8 @@ def run_rebill(
if p.name.startswith("._"):
continue
for s in parse_835_svc(p):
if window_start <= s.svc_date <= window_end:
# Guard against None svc_date (some 835s lack DTM*472 segments)
if s.svc_date is not None and window_start <= s.svc_date <= window_end:
svcs.append(s)
# 2) Load visits CSV — header:
+169
View File
@@ -0,0 +1,169 @@
"""SP41: load the AxisCare visits export into the ``visits`` table.
The spot-check driver used to read the visits CSV in-memory. Persisting
the roster into the database (a) makes the source-of-truth
reviewable via SQL, and (b) lets the rebill pipeline (reconcile /
resubmit) reference the same canonical visit list without re-parsing
the CSV on every run.
Header (AxisCare export):
"Client","Visit Date","Payer","Authorized","Member ID",
"Auth Start Date","Auth End Date","Authorization #","ICD-10",
"Service","Procedure Code","Modifiers","Client Classes",
"Billable Hours","Billable Amount","Invoice #","Claimed"
DOS is MM/DD/YYYY. Billable Amount may carry ``$`` prefix and ``,``
thousands separators. Modifiers may be ``:``-joined (batch 1) or
``,``-joined (batch 2); we normalize to colon-joined for storage and
downstream emission.
This module is intentionally side-effect-free on import — call
:func:`load_visits_csv` to populate the table.
"""
from __future__ import annotations
import csv
import logging
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from cyclone import db as db_mod
from cyclone.db import Visit
_log = logging.getLogger(__name__)
def _normalize_modifiers(raw: str) -> str:
"""``"KX:SC:U2"`` or ``"KX, SC, U2"`` → ``"KX:SC:U2"``.
We store colon-joined (matches the X12 SV1 modifier emission format).
Empty / blank input returns "".
"""
if not raw:
return ""
parts = [m.strip() for m in raw.replace(":", ",").split(",") if m.strip()]
return ":".join(parts)
def _parse_billed(raw: str) -> Decimal:
"""``"$212.77"`` / ``"1,234.56"`` → Decimal. Returns 0 on failure."""
if not raw:
return Decimal("0")
cleaned = raw.replace("$", "").replace(",", "").strip()
try:
return Decimal(cleaned or "0")
except Exception:
return Decimal("0")
def _parse_dos(raw: str) -> date | None:
"""``"01/01/2026"`` → ``date(2026, 1, 1)``. None on failure."""
raw = (raw or "").strip()
if not raw:
return None
for fmt in ("%m/%d/%Y", "%Y-%m-%d"):
try:
return datetime.strptime(raw, fmt).date()
except ValueError:
continue
return None
def load_visits_csv(
csv_path: Path,
*,
window_start: date | None = None,
window_end: date | None = None,
) -> int:
"""Load the AxisCare visits export into the ``visits`` table.
Args:
csv_path: Path to the CSV file.
window_start: Optional inclusive lower bound on DOS.
window_end: Optional inclusive upper bound on DOS.
Returns:
Number of rows actually inserted (duplicates are skipped
silently — the UNIQUE constraint on (dos, member_id, procedure,
modifiers) dedupes the natural key).
"""
db_mod.init_db()
SessionLocal = db_mod.SessionLocal()
inserted = 0
skipped = 0
with SessionLocal() as session:
with csv_path.open(newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
dos = _parse_dos(row.get("Visit Date") or "")
if dos is None:
skipped += 1
continue
if window_start and dos < window_start:
continue
if window_end and dos > window_end:
continue
member_id = (row.get("Member ID") or "").strip()
procedure = (row.get("Procedure Code") or "").strip()
if not member_id or not procedure:
skipped += 1
continue
modifiers = _normalize_modifiers(row.get("Modifiers") or "")
visit = Visit(
dos=dos,
member_id=member_id,
client_name=(row.get("Client") or "").strip(),
procedure_code=procedure,
modifiers=modifiers or None,
billed_amount=_parse_billed(row.get("Billable Amount") or ""),
icd10=(row.get("ICD-10") or "").strip() or None,
prior_auth=(row.get("Authorization #") or "").strip() or None,
payer=(row.get("Payer") or "").strip() or None,
invoice_number=(row.get("Invoice #") or "").strip() or None,
source_file=str(csv_path),
)
session.add(visit)
try:
session.flush()
inserted += 1
except IntegrityError:
session.rollback()
skipped += 1
session.commit()
_log.info("load_visits_csv: %s -> %d inserted, %d skipped (dup/invalid)",
csv_path.name, inserted, skipped)
return inserted
def query_visits(
*,
member_id: str | None = None,
procedure_code: str | None = None,
dos_start: date | None = None,
dos_end: date | None = None,
limit: int | None = None,
) -> list[Visit]:
"""Read visits back from the table. Filters are AND-combined.
Returns the matching rows as ORM objects (caller may need to detach
or convert to a dataclass for downstream use).
"""
db_mod.init_db()
SessionLocal = db_mod.SessionLocal()
with SessionLocal() as session:
stmt = select(Visit)
if member_id is not None:
stmt = stmt.where(Visit.member_id == member_id)
if procedure_code is not None:
stmt = stmt.where(Visit.procedure_code == procedure_code)
if dos_start is not None:
stmt = stmt.where(Visit.dos >= dos_start)
if dos_end is not None:
stmt = stmt.where(Visit.dos <= dos_end)
if limit is not None:
stmt = stmt.limit(limit)
return list(session.execute(stmt).scalars().all())
+2 -2
View File
@@ -66,12 +66,12 @@ def test_migration_latest_idempotent_on_fresh_db():
audit table, and SP41's 0022 submission_dedup)."""
with db.engine().begin() as c:
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v1 == 22
assert v1 == 23
# 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 == 22
assert v2 == 23
def test_add_ack_persists_row():
+3 -3
View File
@@ -133,10 +133,10 @@ def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
v_after_first = _user_version(engine)
assert v_after_first == 22, f"expected head=22, got {v_after_first}"
assert v_after_first == 23, f"expected head=23, got {v_after_first}"
db_migrate.run(engine)
assert _user_version(engine) == 22, "second run should not bump version"
assert _user_version(engine) == 23, "second run should not bump version"
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -162,7 +162,7 @@ def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: py
engine = _fresh_engine(tmp_path)
db_migrate.run(engine)
assert _user_version(engine) == 22, f"expected head=22, got {_user_version(engine)}"
assert _user_version(engine) == 23, f"expected head=23, got {_user_version(engine)}"
# Two claims in one batch with the same patient_control_number
# must be insertable. If 0015's table recreation re-introduced a
+1 -1
View File
@@ -105,7 +105,7 @@ def migrated_engine(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
# Confirm head is 22 (every migration applied, including SP41's 0022).
with engine.connect() as conn:
v = conn.exec_driver_sql("PRAGMA user_version").scalar() or 0
assert v == 22, f"expected migration head=22, got {v}"
assert v == 23, f"expected migration head=23, got {v}"
yield engine
engine.dispose()
+136
View File
@@ -0,0 +1,136 @@
"""SP41-visits: unit tests for ``cyclone.rebill.visits_store``.
The visits_store persists the AxisCare visits export to the ``visits``
table. These tests pin the loader and the read-back query helper.
"""
from __future__ import annotations
import csv
import tempfile
from datetime import date
from decimal import Decimal
from pathlib import Path
import pytest
from cyclone import db as db_mod
from cyclone.rebill.visits_store import (
_normalize_modifiers,
_parse_billed,
_parse_dos,
load_visits_csv,
query_visits,
)
@pytest.fixture
def visits_csv(tmp_path: Path) -> Path:
"""Write a small visits CSV in AxisCare's shape."""
csv_path = tmp_path / "visits.csv"
with csv_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow([
"Client", "Visit Date", "Payer", "Authorized", "Member ID",
"Auth Start Date", "Auth End Date", "Authorization #", "ICD-10",
"Service", "Procedure Code", "Modifiers", "Client Classes",
"Billable Hours", "Billable Amount", "Invoice #", "Claimed",
])
writer.writerow([
"Wilson, Michelle", "01/01/2026", "CO Medicaid", "Yes", "R649327",
"01/01/2026", "12/31/2026", "3", "R69",
"Homemaker S5150", "S5150", "U8", "DD Waiver", "1", "$212.77",
"INV-2026-01-01-R649327", "Yes",
])
writer.writerow([
"O'Keefe , Laura", "05/09/2026", "COHCPF", "yes", "Y477643",
"03/10/2026", "08/31/2026", "9", "R69",
"IHSS PCP T1019", "T1019", "KX, SC, U2", "IHSS DR", "8.5333",
"$237.57", "INV-2026-05-09-Y477643", "Yes",
])
writer.writerow([
"Stale, Old", "12/31/2025", "CO Medicaid", "Yes", "Z999999",
"01/01/2025", "12/31/2025", "0", "R69",
"Old S5150", "S5150", "U8", "DD Waiver", "1", "$100.00",
"INV-2025-12-31-Z999999", "Yes", # OUT-OF-WINDOW
])
return csv_path
def test_normalize_modifiers():
"""Colon-joined or comma-joined input → colon-joined output."""
assert _normalize_modifiers("KX:SC:U2") == "KX:SC:U2"
assert _normalize_modifiers("KX, SC, U2") == "KX:SC:U2"
assert _normalize_modifiers("") == ""
assert _normalize_modifiers("U8") == "U8"
def test_parse_billed():
"""$ prefix and thousands separators are stripped."""
assert _parse_billed("$212.77") == Decimal("212.77")
assert _parse_billed("1,234.56") == Decimal("1234.56")
assert _parse_billed("") == Decimal("0")
assert _parse_billed("not a number") == Decimal("0")
def test_parse_dos():
"""MM/DD/YYYY primary, YYYY-MM-DD fallback."""
assert _parse_dos("01/01/2026") == date(2026, 1, 1)
assert _parse_dos("2026-01-01") == date(2026, 1, 1)
assert _parse_dos("") is None
assert _parse_dos("garbage") is None
def test_load_visits_csv_inserts_in_window_only(visits_csv: Path):
"""Out-of-window rows are dropped, in-window rows are inserted."""
n = load_visits_csv(
visits_csv,
window_start=date(2026, 1, 1),
window_end=date(2026, 6, 27),
)
# 2 of 3 rows are in-window (the 12/31/2025 row is dropped)
assert n == 2
rows = query_visits(dos_start=date(2026, 1, 1), dos_end=date(2026, 6, 27))
assert len(rows) == 2
member_ids = {r.member_id for r in rows}
assert member_ids == {"R649327", "Y477643"}
def test_load_visits_csv_dedupes_on_natural_key(visits_csv: Path):
"""Re-running the loader must not insert duplicate (DOS, member, procedure, mod)."""
load_visits_csv(visits_csv, window_start=date(2026, 1, 1), window_end=date(2026, 6, 27))
n2 = load_visits_csv(visits_csv, window_start=date(2026, 1, 1), window_end=date(2026, 6, 27))
assert n2 == 0 # all rows were already there
assert len(query_visits()) == 2
def test_query_visits_filters_by_member_and_procedure(visits_csv: Path):
load_visits_csv(visits_csv, window_start=date(2026, 1, 1), window_end=date(2026, 6, 27))
rows = query_visits(member_id="R649327")
assert len(rows) == 1
assert rows[0].procedure_code == "S5150"
assert rows[0].modifiers == "U8"
assert rows[0].billed_amount == Decimal("212.77")
assert rows[0].icd10 == "R69"
assert rows[0].prior_auth == "3"
assert rows[0].payer == "CO Medicaid"
def test_query_visits_modifiers_normalized_to_colon_joined(visits_csv: Path):
"""Batch-2 style 'KX, SC, U2' is stored as 'KX:SC:U2'."""
load_visits_csv(visits_csv, window_start=date(2026, 1, 1), window_end=date(2026, 6, 27))
rows = query_visits(member_id="Y477643")
assert len(rows) == 1
assert rows[0].modifiers == "KX:SC:U2"
assert rows[0].procedure_code == "T1019"
def test_visit_table_exists_post_migration():
"""Migration 23 created the visits table — schema check."""
db_mod.init_db()
from sqlalchemy import inspect, text
with db_mod.SessionLocal()() as s:
v = s.execute(text("PRAGMA user_version")).scalar()
inspector = inspect(db_mod.engine())
assert "visits" in inspector.get_table_names()
assert v >= 23