feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:
- New POST /api/batches/{id}/export-837: regenerate X12 837 files
for a list of claim_ids into a ZIP using HCPF file naming standards,
with a unique interchange/group control number per export. Wire
the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
(NM1*40) blocks so the serializer no longer falls back to
CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
batch_id in both JSON and NDJSON response shapes so the frontend
can hit batch-scoped endpoints without an extra listBatches
round-trip.
- Filename helpers and the 837 serializer updated to match the new
HCPF envelope; tests cover batch export, parse batch_id, and the
serializer's control-number uniqueness guarantee.
Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
EditorialNote, ExportBar, TickerTape, and a charts/ set
(BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
colors to Tailwind theme tokens (bg-card, text-foreground,
border/60, etc.) for consistency with the rest of the instrument
chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
reworked to compose the new shared components and consume the new
batch-scoped API surface (notably ExportBar wired into Batches).
Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
components and the useBatchExport hook.
Rolls up into the v0.2.0 release tag.
This commit is contained in:
@@ -101,6 +101,14 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
|
||||
# Summary numbers match the JSON path.
|
||||
assert parsed[3]["data"]["total_claims"] == 2
|
||||
assert parsed[3]["data"]["passed"] == 2
|
||||
# The streaming summary carries the server-side batch id so the
|
||||
# frontend can call /api/batches/{id}/export-837 without a separate
|
||||
# GET /api/batches round-trip (regression: it used to be missing
|
||||
# on the stream path, only present on the JSON path, which made the
|
||||
# Upload page's Export button say "no batch to export").
|
||||
assert parsed[3]["type"] == "summary"
|
||||
assert isinstance(parsed[3]["data"]["batch_id"], str)
|
||||
assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex
|
||||
|
||||
|
||||
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||
|
||||
@@ -82,6 +82,13 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
|
||||
# Summary numbers match the JSON path.
|
||||
assert parsed[7]["data"]["total_claims"] == 2
|
||||
assert parsed[7]["data"]["passed"] == 2
|
||||
# The streaming summary carries the server-side batch id so the
|
||||
# frontend can call /api/batches/{id}/export-837 without a separate
|
||||
# GET /api/batches round-trip (matches the parallel fix on
|
||||
# /api/parse-837).
|
||||
assert parsed[7]["type"] == "summary"
|
||||
assert isinstance(parsed[7]["data"]["batch_id"], str)
|
||||
assert len(parsed[7]["data"]["batch_id"]) == 32 # uuid4().hex
|
||||
|
||||
|
||||
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Tests for POST /api/batches/{batch_id}/export-837.
|
||||
|
||||
Reads Claim.raw_json for each requested claim_id and returns a ZIP of
|
||||
regenerated X12 837 files. No DB state mutation. Mirrors the
|
||||
X-Cyclone-Serialize-Errors convention from /api/inbox/rejected/resubmit?download=true.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
|
||||
|
||||
def _seed_batch(client: TestClient, filename: str = "claim.txt") -> dict:
|
||||
"""Parse a single 837P file and return a dict with ``batch_id`` and
|
||||
``claims``.
|
||||
|
||||
The parse-837 happy-path response does not currently surface
|
||||
``batch_id`` at the top level (it's a server-side UUID, surfaced in
|
||||
409 errors but not in 200s). We look it up from the DB — the Batch
|
||||
row is already persisted by the time the parse endpoint returns.
|
||||
"""
|
||||
from cyclone import db
|
||||
from cyclone.db import Batch
|
||||
|
||||
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
|
||||
r = client.post(
|
||||
"/api/parse-837",
|
||||
files={"file": (filename, io.BytesIO(fixture.encode()), "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
with db.SessionLocal()() as s:
|
||||
most_recent = s.query(Batch).order_by(Batch.parsed_at.desc()).first()
|
||||
assert most_recent is not None, "expected at least one Batch row after parse"
|
||||
batch_id = most_recent.id
|
||||
return {"batch_id": batch_id, "claims": body["claims"]}
|
||||
|
||||
|
||||
def _claim_ids_from_seed(seeded: dict) -> list[str]:
|
||||
return [c["claim_id"] for c in seeded["claims"]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_happy_path_returns_zip_with_one_x12_per_claim():
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"].startswith("application/zip")
|
||||
assert "attachment" in r.headers["content-disposition"]
|
||||
assert (
|
||||
f"batch-{batch_id}-{len(claim_ids)}-claims.zip"
|
||||
in r.headers["content-disposition"]
|
||||
)
|
||||
# Per-claim failures header absent on full success.
|
||||
assert "x-cyclone-serialize-errors" not in {k.lower() for k in r.headers.keys()}
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
names = zf.namelist()
|
||||
assert len(names) == len(claim_ids)
|
||||
# Each entry follows the HCPF outbound naming template
|
||||
# "{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12" (the seeded
|
||||
# clearhouse TPID is "11525703"). Every name must be unique.
|
||||
from cyclone.edi.filenames import is_outbound_filename
|
||||
seen = set()
|
||||
for name in names:
|
||||
assert is_outbound_filename(name), (
|
||||
f"expected HCPF outbound filename, got {name!r}"
|
||||
)
|
||||
assert name not in seen, f"duplicate filename: {name}"
|
||||
seen.add(name)
|
||||
with zf.open(name) as f:
|
||||
first_line = f.readline().decode("ascii", errors="replace")
|
||||
assert first_line.startswith("ISA*"), f"{name} didn't start with ISA"
|
||||
|
||||
|
||||
def test_each_x12_in_zip_uses_clearhouse_submit_and_payer_receiver():
|
||||
"""Regression: regenerated 837s used to emit 'CYCLONE' / 'RECEIVER'
|
||||
placeholders. The export endpoint must thread the clearhouse
|
||||
submitter (dzinesco's TPID 11525703) and payer receiver
|
||||
(COMEDASSISTPROG) through to the serializer."""
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
for name in zf.namelist():
|
||||
with zf.open(name) as f:
|
||||
text = f.read().decode("ascii")
|
||||
# Submitter block must use the clearhouse TPID + name, not
|
||||
# the 'CYCLONE' placeholder.
|
||||
assert "CYCLONE" not in text, f"{name} still emits CYCLONE placeholder"
|
||||
assert "11525703" in text, f"{name} missing clearhouse TPID"
|
||||
assert "Dzinesco" in text, f"{name} missing clearhouse name"
|
||||
# Receiver block must use the CO_TXIX payer config
|
||||
# (COMEDASSISTPROG), not the 'RECEIVER' placeholder.
|
||||
assert "RECEIVER" not in text, f"{name} still emits RECEIVER placeholder"
|
||||
assert "COMEDASSISTPROG" in text, f"{name} missing receiver id"
|
||||
# Loop 1000A requires PER — must be present, not omitted.
|
||||
assert "PER*IC*" in text, f"{name} missing required PER segment"
|
||||
# SBR09 should be 'MC' (Medicaid claim filing indicator),
|
||||
# not the member id.
|
||||
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
|
||||
sbr09 = sbr_line.rstrip("~").split("*")[9]
|
||||
assert sbr09 == "MC", f"{name} SBR09 expected 'MC', got {sbr09!r}"
|
||||
|
||||
|
||||
def test_each_x12_in_zip_round_trips_through_parser():
|
||||
"""Fidelity check: each .x12 must parse back to a ClaimOutput deep-equal
|
||||
to the source row's raw_json (modulo recomputed validation)."""
|
||||
from cyclone.parsers.parse_837 import parse
|
||||
from cyclone.parsers.payer import PayerConfig
|
||||
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
by_id = {c["claim_id"]: c for c in seeded["claims"]}
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
for name in zf.namelist():
|
||||
with zf.open(name) as f:
|
||||
text = f.read().decode("ascii")
|
||||
result = parse(text, PayerConfig(name="CO_MEDICAID"))
|
||||
assert result.claims, f"{name} didn't parse back to any claims"
|
||||
# Claim ids must round-trip (proves the serializer didn't drop
|
||||
# or rewrite the id).
|
||||
assert result.claims[0].claim.claim_id in by_id, (
|
||||
f"{name} parsed to an unknown claim_id"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Partial-failure surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_partial_failure_one_claim_with_no_raw_json_returns_zip_and_errors_header():
|
||||
"""If one claim has raw_json=None, the ZIP still returns for the others
|
||||
and the failure is surfaced via X-Cyclone-Serialize-Errors."""
|
||||
from cyclone import db
|
||||
from cyclone.edi.filenames import is_outbound_filename
|
||||
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
assert len(claim_ids) >= 2, "fixture must produce at least 2 claims"
|
||||
|
||||
# Wipe raw_json on one claim to simulate a corrupted row.
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Claim
|
||||
target = s.get(Claim, claim_ids[0])
|
||||
target.raw_json = None
|
||||
s.commit()
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
# Filename uses SUCCESS count, not requested count.
|
||||
expected_success = len(claim_ids) - 1
|
||||
assert (
|
||||
f"batch-{batch_id}-{expected_success}-claims.zip"
|
||||
in r.headers["content-disposition"]
|
||||
)
|
||||
err_header = r.headers.get("x-cyclone-serialize-errors")
|
||||
assert err_header, "expected X-Cyclone-Serialize-Errors header"
|
||||
errs = json.loads(err_header)
|
||||
assert len(errs) == 1
|
||||
assert errs[0]["claim_id"] == claim_ids[0]
|
||||
assert "raw_json" in errs[0]["reason"].lower()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
names = zf.namelist()
|
||||
assert len(names) == expected_success
|
||||
# Every entry follows HCPF outbound template; none is the
|
||||
# 'claim-CLM-X.x12' placeholder from the old endpoint.
|
||||
for name in names:
|
||||
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
|
||||
|
||||
|
||||
def test_all_claims_fail_to_serialize_returns_422():
|
||||
from cyclone import db
|
||||
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
claim_ids = _claim_ids_from_seed(seeded)
|
||||
assert len(claim_ids) >= 1
|
||||
|
||||
with db.SessionLocal()() as s:
|
||||
from cyclone.db import Claim
|
||||
for cid in claim_ids:
|
||||
c = s.get(Claim, cid)
|
||||
c.raw_json = None
|
||||
s.commit()
|
||||
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": claim_ids},
|
||||
)
|
||||
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
# The 422 body surfaces the failure list so the UI can show details
|
||||
# without parsing a header.
|
||||
errs = body.get("detail", {}).get("serialize_errors") or body.get("serialize_errors")
|
||||
assert errs is not None
|
||||
assert len(errs) == len(claim_ids)
|
||||
for entry in errs:
|
||||
assert entry["claim_id"] in claim_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_empty_claim_ids_returns_400():
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": []},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_missing_claim_ids_key_returns_400():
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_unknown_batch_id_returns_404():
|
||||
with TestClient(app) as client:
|
||||
r = client.post(
|
||||
"/api/batches/BATCH-DOES-NOT-EXIST/export-837",
|
||||
json={"claim_ids": ["CLM-1"]},
|
||||
)
|
||||
assert r.status_code == 404, r.text
|
||||
body = r.json()
|
||||
assert "BATCH-DOES-NOT-EXIST" in (body.get("detail") or "")
|
||||
|
||||
|
||||
def test_unknown_claim_id_silently_omitted():
|
||||
"""A claim_id that doesn't exist is omitted from the ZIP and surfaced
|
||||
in the errors header — same convention as the resubmit endpoint."""
|
||||
with TestClient(app) as client:
|
||||
seeded = _seed_batch(client)
|
||||
batch_id = seeded["batch_id"]
|
||||
real_ids = _claim_ids_from_seed(seeded)
|
||||
assert real_ids, "fixture must produce at least 1 claim"
|
||||
|
||||
requested = real_ids + ["CLM-GHOST"]
|
||||
r = client.post(
|
||||
f"/api/batches/{batch_id}/export-837",
|
||||
json={"claim_ids": requested},
|
||||
)
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
# Filename uses success count = len(real_ids), not len(requested).
|
||||
assert (
|
||||
f"batch-{batch_id}-{len(real_ids)}-claims.zip"
|
||||
in r.headers["content-disposition"]
|
||||
)
|
||||
err_header = r.headers.get("x-cyclone-serialize-errors")
|
||||
assert err_header
|
||||
errs = json.loads(err_header)
|
||||
assert any(e["claim_id"] == "CLM-GHOST" for e in errs)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||
names = zf.namelist()
|
||||
# HCPF outbound filenames, one per real claim. We don't pin the
|
||||
# exact ts (it depends on the wall clock at test time), but the
|
||||
# count and the HCPF template must match.
|
||||
from cyclone.edi.filenames import is_outbound_filename
|
||||
assert len(names) == len(real_ids)
|
||||
for name in names:
|
||||
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Tests that /api/parse-837 includes the persisted batch_id in its
|
||||
JSON response, so the frontend can correlate its in-memory batch with
|
||||
the server's row (and later call /api/batches/{id}/export-837).
|
||||
|
||||
The 409 (duplicate) path has always included batch_id; the 200 path
|
||||
did not. This brings the 200 path in line so the frontend doesn't have
|
||||
to query listBatches after every parse just to learn the server's id.
|
||||
"""
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cyclone.api import app
|
||||
|
||||
|
||||
def test_parse_837_happy_path_includes_batch_id_in_response():
|
||||
with TestClient(app) as client:
|
||||
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
|
||||
r = client.post(
|
||||
"/api/parse-837",
|
||||
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert "batch_id" in body, "parse-837 response must include batch_id so the frontend can call /api/batches/{id}/export-837"
|
||||
# Sanity: the batch_id should be a non-empty string (UUID-shaped).
|
||||
assert isinstance(body["batch_id"], str) and body["batch_id"]
|
||||
@@ -27,7 +27,8 @@ MT = ZoneInfo("America/Denver")
|
||||
def test_build_outbound_with_explicit_mt():
|
||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||
name = build_outbound_filename("11525703", "837P", now_mt=now)
|
||||
assert name == "11525703-837P-20260620132243505-1of1.x12"
|
||||
# HCPF outbound format: tp prefix on the tpid
|
||||
assert name == "tp11525703-837P-20260620132243505-1of1.x12"
|
||||
|
||||
|
||||
def test_build_outbound_default_extension():
|
||||
@@ -39,15 +40,17 @@ def test_build_outbound_default_extension():
|
||||
def test_build_outbound_custom_extension():
|
||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
|
||||
assert name == "11525703-837P-20260620132243505-1of1.txt"
|
||||
assert name == "tp11525703-837P-20260620132243505-1of1.txt"
|
||||
|
||||
|
||||
def test_build_outbound_uses_mt_when_no_arg():
|
||||
# Snapshot test — the timestamp will be very recent; check format only
|
||||
name = build_outbound_filename("11525703", "837P")
|
||||
assert OUTBOUND_RE.match(name), name
|
||||
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
|
||||
parts = name.split("-")
|
||||
assert len(parts) == 4
|
||||
assert parts[0] == "tp11525703"
|
||||
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
|
||||
|
||||
|
||||
@@ -128,8 +131,9 @@ def test_parse_inbound_rejects_non_x12_ext():
|
||||
|
||||
|
||||
def test_roundtrip_outbound_to_inbound():
|
||||
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
|
||||
# The two regexes use different shapes — round-trip via tpid only.
|
||||
# Outbound uses tp{...}, inbound uses TP{...} (case differs but both
|
||||
# prefixes are required). The two regexes use different shapes —
|
||||
# round-trip via tpid only.
|
||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||
out = build_outbound_filename("11525703", "837P", now_mt=now)
|
||||
assert OUTBOUND_RE.match(out)
|
||||
@@ -142,13 +146,19 @@ def test_roundtrip_outbound_to_inbound():
|
||||
|
||||
|
||||
def test_is_outbound_filename():
|
||||
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||
# HCPF outbound always has the lowercase "tp" prefix
|
||||
assert is_outbound_filename("tp11525703-837P-20260620132243505-1of1.x12")
|
||||
# Bare tpid (no tp prefix) is no longer a valid outbound filename
|
||||
assert not is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||
# Uppercase TP prefix is the inbound shape, not outbound
|
||||
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
|
||||
assert not is_outbound_filename("not-a-filename")
|
||||
|
||||
|
||||
def test_is_inbound_filename():
|
||||
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
||||
# Lowercase tp prefix is the outbound shape, not inbound
|
||||
assert not is_inbound_filename("tp11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
||||
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||
|
||||
|
||||
|
||||
@@ -65,6 +65,21 @@ def test_build_gs_emits_gs_segment_with_hc_functional_id():
|
||||
assert parts[6] == "1"
|
||||
|
||||
|
||||
def test_build_gs_uses_gs04_yyyymmdd_8_digits():
|
||||
"""GS-04 must be CCYYMMDD (8 digits) per X12; ISA uses YYMMDD (6).
|
||||
|
||||
A 6-digit value like '260622' is rejected by EDI validators with
|
||||
'Element GS-04 must use CCYYMMDD date format'.
|
||||
"""
|
||||
gs = _build_gs("SENDER", "RECEIVER", "1")
|
||||
parts = gs.rstrip("~").split("*")
|
||||
# parts[4] = GS-04 date
|
||||
assert len(parts[4]) == 8, f"expected 8-digit CCYYMMDD, got {parts[4]!r}"
|
||||
# Must parse as a CCYYMMDD date
|
||||
from datetime import datetime
|
||||
datetime.strptime(parts[4], "%Y%m%d")
|
||||
|
||||
|
||||
def test_build_st_emits_837_segment():
|
||||
st = _build_st("0001")
|
||||
assert st.startswith("ST*837*0001*005010X222A1~")
|
||||
@@ -100,12 +115,15 @@ def test_build_nm1_person_entity_splits_first_last():
|
||||
assert parts[4] == "Jane"
|
||||
|
||||
|
||||
def test_build_per_returns_empty_when_no_contact():
|
||||
assert _build_per(None, None) == ""
|
||||
assert _build_per("", "") == ""
|
||||
def test_build_per_emits_per01_even_with_no_contact():
|
||||
"""PER is required by X12 Loop 1000A — at least PER01 must be present."""
|
||||
per = _build_per(None, None)
|
||||
assert per == "PER*IC~"
|
||||
per = _build_per("", "")
|
||||
assert per == "PER*IC~"
|
||||
|
||||
|
||||
def test_build_per_emits_segment_with_contact():
|
||||
def test_build_per_emits_segment_with_phone_contact():
|
||||
per = _build_per("Jane Doe", "5551234567")
|
||||
parts = per.rstrip("~").split("*")
|
||||
assert parts[0] == "PER"
|
||||
@@ -115,6 +133,25 @@ def test_build_per_emits_segment_with_contact():
|
||||
assert parts[4] == "5551234567"
|
||||
|
||||
|
||||
def test_build_per_emits_segment_with_email_contact():
|
||||
"""When email is given, it wins over phone (HCPF expects email)."""
|
||||
per = _build_per("Tyler Martinez", None, contact_email="tyler@dzinesco.com")
|
||||
parts = per.rstrip("~").split("*")
|
||||
assert parts[0] == "PER"
|
||||
assert parts[1] == "IC"
|
||||
assert parts[2] == "Tyler Martinez"
|
||||
assert parts[3] == "EM"
|
||||
assert parts[4] == "tyler@dzinesco.com"
|
||||
|
||||
|
||||
def test_build_per_email_takes_precedence_over_phone():
|
||||
"""If both phone and email are given, email is emitted (PER04)."""
|
||||
per = _build_per("Tyler", "555-1234", contact_email="t@example.com")
|
||||
parts = per.rstrip("~").split("*")
|
||||
assert parts[3] == "EM"
|
||||
assert parts[4] == "t@example.com"
|
||||
|
||||
|
||||
def test_build_n3_returns_empty_when_no_address():
|
||||
assert _build_n3(None, None) == ""
|
||||
assert _build_n3("", "") == ""
|
||||
@@ -133,12 +170,33 @@ def test_build_hl_emits_segment():
|
||||
assert hl == "HL*1**20*1~"
|
||||
|
||||
|
||||
def test_build_sbr_emits_segment():
|
||||
sbr = _build_sbr("18", "M123", "PAYER")
|
||||
def test_build_sbr_emits_segment_with_correct_slots():
|
||||
"""SBR01=Payer Responsibility Seq Code (default 'P'),
|
||||
SBR02=Individual Relationship Code (e.g. '18' for self),
|
||||
SBR09=Claim Filing Indicator Code (e.g. 'MC' for Medicaid)."""
|
||||
sbr = _build_sbr("18", "MC")
|
||||
parts = sbr.rstrip("~").split("*")
|
||||
assert parts[0] == "SBR"
|
||||
assert parts[1] == "18"
|
||||
assert parts[9] == "M123"
|
||||
# SBR01 — primary
|
||||
assert parts[1] == "P"
|
||||
# SBR02 — individual relationship (self = 18)
|
||||
assert parts[2] == "18"
|
||||
# SBR09 — claim filing indicator
|
||||
assert parts[9] == "MC"
|
||||
# Member ID and payer name do NOT belong in SBR — they live in
|
||||
# NM109 and NM1*PR.NM103 respectively.
|
||||
assert "M123" not in sbr
|
||||
assert "PAYER" not in sbr
|
||||
|
||||
|
||||
def test_build_sbr_defaults_relationship_to_self_and_filing_to_empty():
|
||||
"""When called with all-None, SBR01/02 fall back to safe defaults
|
||||
and SBR09 is left empty (the validator's R202 rule will then skip)."""
|
||||
sbr = _build_sbr(None, None)
|
||||
parts = sbr.rstrip("~").split("*")
|
||||
assert parts[1] == "P"
|
||||
assert parts[2] == "18"
|
||||
assert parts[9] == ""
|
||||
|
||||
|
||||
def test_build_ref_returns_empty_when_no_value():
|
||||
@@ -200,6 +258,34 @@ def test_build_clm_emits_clm01_to_clm05():
|
||||
assert parts[5] == "11:1"
|
||||
|
||||
|
||||
def test_build_clm_emits_clm08_defaulting_to_y():
|
||||
"""CLM-08 (Benefits Assignment Certification) is required by X12
|
||||
837P when CLM-07 = 'Y'. Default to 'Y' when the source didn't
|
||||
capture one (matches what 99% of HCPF files look like).
|
||||
"""
|
||||
claim = _stub_claim_header()
|
||||
clm = _build_clm(claim)
|
||||
parts = clm.rstrip("~").split("*")
|
||||
# parts[8] = CLM-08
|
||||
assert parts[8] == "Y", f"CLM-08 should default to 'Y', got {parts[8]!r}"
|
||||
|
||||
|
||||
def test_build_clm_propagates_captured_clm08():
|
||||
from cyclone.parsers.models import ClaimHeader
|
||||
claim = ClaimHeader(
|
||||
claim_id="CLM-1",
|
||||
total_charge=Decimal("100.00"),
|
||||
place_of_service="11",
|
||||
frequency_code="1",
|
||||
assignment="Y",
|
||||
benefits_assignment_certification="N",
|
||||
release_of_info="Y",
|
||||
)
|
||||
clm = _build_clm(claim)
|
||||
parts = clm.rstrip("~").split("*")
|
||||
assert parts[8] == "N"
|
||||
|
||||
|
||||
def test_build_ref_g1_returns_empty_when_no_prior_auth():
|
||||
assert _build_ref_g1(None) == ""
|
||||
assert _build_ref_g1("") == ""
|
||||
@@ -242,6 +328,57 @@ def test_build_sv1_emits_procedure_modifiers_charge_units():
|
||||
assert parts[4] == "1"
|
||||
|
||||
|
||||
def test_build_sv1_emits_sv1_06_and_sv1_07_when_dx_pointer_given():
|
||||
"""SV1-07 (Diagnosis Code Pointer) is required by X12/HCPF when the
|
||||
parent claim has an HI segment (i.e. has at least one diagnosis).
|
||||
|
||||
Per 005010X222A1, SV1-06 is "Not Used" by the guide and MUST be
|
||||
empty. Unit basis (UN/MJ/...) goes only in SV1-03.
|
||||
"""
|
||||
line = _stub_service_line()
|
||||
sv1 = _build_sv1(line, dx_pointer="1")
|
||||
parts = sv1.rstrip("~").split("*")
|
||||
# parts layout: SV1, comp(SV1-01), charge(02), unit_basis(03),
|
||||
# units(04), pos(05), ""(06 NOT USED), sv1_07(07)
|
||||
assert len(parts) == 8, f"expected 8 elements, got {parts}"
|
||||
# SV1-03 = unit basis
|
||||
assert parts[3] == "UN"
|
||||
# SV1-06 = "" (Not Used by 837P guide)
|
||||
assert parts[6] == "", f"SV1-06 must be empty (Not Used by 837P), got {parts[6]!r}"
|
||||
# SV1-07 = pointer
|
||||
assert parts[7] == "1"
|
||||
|
||||
|
||||
def test_build_sv1_omits_sv1_07_when_no_dx_pointer():
|
||||
"""When the claim has no HI segment, SV1-07 should be empty."""
|
||||
line = _stub_service_line()
|
||||
sv1 = _build_sv1(line) # no dx_pointer kwarg
|
||||
parts = sv1.rstrip("~").split("*")
|
||||
assert len(parts) == 8
|
||||
assert parts[6] == "" # SV1-06 still empty
|
||||
assert parts[7] == "" # SV1-07 empty
|
||||
|
||||
|
||||
def test_build_sv1_matches_goodclaim_layout():
|
||||
"""Layout must match the known-good reference at docs/goodclaim.x12:
|
||||
|
||||
SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~
|
||||
|
||||
i.e. 8 fields total: comp, charge, UN, units, '', '', '1'.
|
||||
"""
|
||||
from cyclone.parsers.models import Procedure, ServiceLine
|
||||
line = ServiceLine(
|
||||
line_number=1,
|
||||
procedure=Procedure(qualifier="HC", code="T1019", modifiers=["U1", "KX"]),
|
||||
charge=Decimal("125.40"),
|
||||
units=Decimal("19.00"),
|
||||
unit_type="UN",
|
||||
place_of_service=None,
|
||||
)
|
||||
sv1 = _build_sv1(line, dx_pointer="1")
|
||||
assert sv1 == "SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~"
|
||||
|
||||
|
||||
def test_build_dtp_472_emits_service_date():
|
||||
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
|
||||
|
||||
@@ -369,6 +506,76 @@ def test_serialize_837_uses_custom_sender_receiver_ids():
|
||||
parse(text, _CFG)
|
||||
|
||||
|
||||
def test_serialize_837_emits_per_segment_in_submitter_block():
|
||||
"""X12 Loop 1000A (Submitter Name) requires a PER segment after
|
||||
NM1*41. The serializer must emit one even with no contact info
|
||||
(PER01='IC' is the only required element)."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837(claim)
|
||||
# The first NM1*41 should be followed immediately by a PER segment.
|
||||
seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg]
|
||||
nm1_41_idx = seg_ids.index("NM1") # first NM1 is the submitter
|
||||
assert nm1_41_idx >= 0
|
||||
# The very next segment must be PER (PER01='IC' is required by spec).
|
||||
assert seg_ids[nm1_41_idx + 1] == "PER"
|
||||
per_line = next(seg for seg in text.split("~") if seg.startswith("PER*IC"))
|
||||
assert per_line.startswith("PER*IC")
|
||||
|
||||
|
||||
def test_serialize_837_per_segment_includes_email_from_kwargs():
|
||||
"""Passing submitter_contact_email should emit PER*IC*<name>*EM*<email>."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837(
|
||||
claim,
|
||||
sender_id="DZINESCO",
|
||||
submitter_name="Dzinesco",
|
||||
submitter_contact_name="Tyler Martinez",
|
||||
submitter_contact_email="tyler@dzinesco.com",
|
||||
)
|
||||
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
|
||||
# And the ISA sender id should be the clearhouse TPID, not "CYCLONE".
|
||||
assert "ZZ*DZINESCO" in text
|
||||
assert "ZZ*CYCLONE" not in text
|
||||
|
||||
|
||||
def test_serialize_837_sbr09_uses_claim_filing_indicator_code_kwarg():
|
||||
"""SBR09 must be the claim filing indicator (e.g. 'MC' for Medicaid),
|
||||
not the member id. The serializer takes it from the kwarg."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837(claim, claim_filing_indicator_code="MC")
|
||||
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
|
||||
parts = sbr_line.rstrip("~").split("*")
|
||||
# SBR01 = P (primary), SBR02 = 18 (self), SBR09 = MC
|
||||
assert parts[1] == "P"
|
||||
assert parts[2] == "18"
|
||||
assert parts[9] == "MC"
|
||||
# And the member id should NOT be in SBR.
|
||||
assert claim.subscriber.member_id not in sbr_line
|
||||
|
||||
|
||||
def test_serialize_837_for_resubmit_forwards_kwargs_to_serialize_837():
|
||||
"""serialize_837_for_resubmit is a thin wrapper — it must forward
|
||||
clearhouse + payer kwargs so the export endpoint can use it."""
|
||||
claim = _load_claim()
|
||||
text = serialize_837_for_resubmit(
|
||||
claim,
|
||||
interchange_index=7,
|
||||
sender_id="DZINESCO",
|
||||
submitter_name="Dzinesco",
|
||||
submitter_contact_name="Tyler Martinez",
|
||||
submitter_contact_email="tyler@dzinesco.com",
|
||||
receiver_id="COMEDASSISTPROG",
|
||||
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||
claim_filing_indicator_code="MC",
|
||||
)
|
||||
assert "ZZ*DZINESCO" in text
|
||||
assert "ZZ*COMEDASSISTPROG" in text
|
||||
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
|
||||
# Control numbers reflect the resubmit index.
|
||||
isa = next(seg for seg in text.split("~") if seg.startswith("ISA*"))
|
||||
assert "000000007" in isa
|
||||
|
||||
|
||||
def test_serialize_error_is_an_exception():
|
||||
assert issubclass(SerializeError, Exception)
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ def sftp_block(tmp_path):
|
||||
|
||||
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
|
||||
client = SftpClient(sftp_block)
|
||||
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
||||
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
|
||||
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
|
||||
assert target.exists()
|
||||
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
|
||||
# Confirm the full nested MFT path is preserved under staging
|
||||
rel = target.relative_to(sftp_block.staging_dir)
|
||||
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
||||
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
|
||||
|
||||
|
||||
def test_stub_creates_parent_dirs(sftp_block):
|
||||
|
||||
Reference in New Issue
Block a user