9bca4b608a
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.
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""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}"
|