ee1a397bd5
**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).
137 lines
5.0 KiB
Python
137 lines
5.0 KiB
Python
"""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
|