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:
@@ -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}"
|
||||
Reference in New Issue
Block a user