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:
Tyler
2026-06-22 11:01:58 -06:00
parent 35298907bc
commit 9bca4b608a
54 changed files with 6224 additions and 3871 deletions
+316 -11
View File
@@ -35,7 +35,7 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import ValidationError
from cyclone import __version__, db
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.db import Batch, Claim, ClaimState, Remittance
from sqlalchemy import desc, or_
from sqlalchemy.exc import IntegrityError
from cyclone.inbox_state import apply_999_rejections
@@ -420,11 +420,18 @@ async def parse_837(
ack_body = _build_and_persist_ack(rec.id)
if ack_body is not None:
body["ack"] = ack_body
# Surface the server-side batch id so the frontend can call
# /api/batches/{id}/export-837 (and any other batch-scoped
# endpoint) without a separate listBatches round-trip.
body["batch_id"] = rec.id
return JSONResponse(content=body)
# Default: NDJSON stream.
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client (the React Upload page) can call batch-scoped
# endpoints like /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip.
return StreamingResponse(
_ndjson_stream(result),
_ndjson_stream(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@@ -606,9 +613,12 @@ async def parse_835_endpoint(
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
return JSONResponse(content=body)
# Default: NDJSON stream.
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client can call batch-scoped endpoints without a
# separate GET /api/batches round-trip (see /api/parse-837 for the
# parallel change).
return StreamingResponse(
_ndjson_stream_835(result),
_ndjson_stream_835(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@@ -1295,6 +1305,215 @@ def inbox_resubmit_rejected(
)
@app.post("/api/batches/{batch_id}/export-837")
def export_batch_837(request: Request, batch_id: str, body: dict):
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
Body shape: ``{"claim_ids": [str, ...]}``.
Each successfully serialized claim becomes an entry in the ZIP named
per the HCPF X12 File Naming Standards:
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
millisecond offset so every file in the bundle has a unique name).
The ``serialize_837_for_resubmit`` serializer is used so every file
gets a unique interchange / group control number — back-to-back
exports of the same set must produce different envelopes (required
by X12).
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
the clearhouse singleton (dzinesco's identity in the seeded config)
and the receiver block (NM1*40) is populated from the per-payer
config. Without this wiring, the serializer falls back to
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
No DB state is mutated by this endpoint — it is read-only. Compare
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
intentionally separate.
Responses:
200 — ``application/zip`` with the .x12 entries. Per-claim failures
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
(JSON-encoded array of ``{claim_id, reason}``).
400 — ``claim_ids`` missing or empty.
404 — ``batch_id`` unknown.
422 — every claim failed to serialize; body is JSON listing all
failures (``{"detail": {"serialize_errors": [...]}}``).
"""
import zipfile
from datetime import datetime
from zoneinfo import ZoneInfo
from cyclone.edi.filenames import build_outbound_filename
ids = body.get("claim_ids") or []
if not ids:
raise HTTPException(400, "claim_ids required")
with db.SessionLocal()() as s:
batch = s.get(Batch, batch_id)
if batch is None:
raise HTTPException(404, f"unknown batch: {batch_id}")
serialize_errors: list[dict] = []
ordered_rows: list[tuple[str, "Claim"]] = []
for cid in ids:
c = s.get(Claim, cid)
if c is None:
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
continue
ordered_rows.append((cid, c))
# Pull clearhouse identity (submitter). If unseeded, the serializer
# falls back to placeholder defaults — degraded but not a hard error.
ch = store.get_clearhouse()
submitter_kwargs: dict = {}
if ch is not None:
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
# Submitter phone is not in the clearhouse config today, but if
# it ever is, wire it here. Email is the canonical contact
# channel for HCPF submissions per the SP9 spec.
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Resolve per-claim payer config so each file's receiver (NM1*40)
# and SBR09 are correct. Cache so we don't re-query the same payer.
from cyclone.db import PayerConfigORM as _PayerConfigORM
_payer_cache: dict[str, dict | None] = {}
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
cache_key = pid or pname
if cache_key in _payer_cache:
return _payer_cache[cache_key]
cfg: dict | None = None
with db.SessionLocal()() as ss:
# 1. Exact match on (payer_id, "837P")
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
cfg = dict(row.config_json)
# 2. Fallback: any row whose payer_id matches the parsed payer.name
# (HCPF files emit "SKCO0" in NM109 but the canonical
# payer_id in the DB is "CO_TXIX" — name-matching is the
# pragmatic lookup for that case).
if cfg is None and pname:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in row:
cj = dict(r.config_json)
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
cfg = cj
break
if (r.payer_id or "").upper() == pname.upper():
cfg = cj
break
# 3. Last resort: first 837P row in the table.
if cfg is None:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
if row is not None:
cfg = dict(row.config_json)
_payer_cache[cache_key] = cfg
return cfg
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
# defaults to the parsed payer name/ID if no config row matches.
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = (
payer_cfg.get("receiver_id")
or (claim_obj.payer.id if claim_obj.payer else None)
or "RECEIVER"
)
receiver_name = (
payer_cfg.get("receiver_name")
or (claim_obj.payer.name if claim_obj.payer else None)
or receiver_id
)
sbr09 = payer_cfg.get("sbr09_default") or "MC"
return {
"receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09,
}
# Base MT timestamp for HCPF filenames. We add a per-claim
# millisecond offset so each file in the ZIP has a unique 17-digit
# ts (HCPF requires that; the spec also enforces "1of1" for the
# sequence element).
base_ts = datetime.now(ZoneInfo("America/Denver"))
def _per_claim_filename(idx: int, cid: str) -> str:
if ch is None:
# No clearhouse — fall back to a per-claim friendly name.
return f"claim-{cid}.x12"
# Millisecond offset, with second/minute rollover.
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
ts_mt = base_ts.fromtimestamp(
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
)
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for idx, (cid, c) in enumerate(ordered_rows, start=1):
if not c.raw_json:
serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
continue
try:
claim_obj = ClaimOutput.model_validate(c.raw_json)
except Exception as exc:
serialize_errors.append(
{"claim_id": cid, "reason": f"raw_json invalid: {exc}"}
)
continue
try:
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
text = serialize_837_for_resubmit(
claim_obj, interchange_index=idx, **kwargs
)
except SerializeError837 as exc:
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
continue
zf.writestr(_per_claim_filename(idx, cid), text)
success_count = len(ids) - len(serialize_errors)
if serialize_errors and success_count == 0:
# Every claim failed — surface the failure list in the body so the
# UI can render a useful error toast (the response is not a ZIP).
raise HTTPException(
422,
detail={"serialize_errors": serialize_errors},
)
buf.seek(0)
headers = {
"Content-Disposition": (
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
),
}
if serialize_errors:
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers=headers,
)
@app.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str):
"""Stream a CSV for a single lane."""
@@ -2321,6 +2540,51 @@ def submit_to_clearhouse(body: dict):
if ch is None:
raise HTTPException(status_code=500, detail="clearhouse not seeded")
# Submitter (Loop 1000A) comes from the clearhouse config. The
# receiver (NM1*40) and SBR09 come from the per-payer config and
# are resolved per-claim below. Without this wiring, the
# serializer would emit "CYCLONE" / "RECEIVER" placeholders and
# the file would be rejected by HCPF.
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Build a payer_id → PayerConfig837 map once so we can look up the
# receiver + SBR09 default for each claim.
from cyclone.db import PayerConfigORM as _PayerConfigORM
def _resolve_payer_cfg(claim_obj) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
with db.SessionLocal()() as ss:
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
return dict(row.config_json)
if pname:
rows = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in rows:
cj = dict(r.config_json)
if (r.payer_id or "").upper() == pname.upper():
return cj
if pname.lower() in str(cj).lower():
return cj
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
return dict(row.config_json) if row else None
client = make_client(ch.sftp_block)
results = []
for cid in claim_ids:
@@ -2329,6 +2593,34 @@ def submit_to_clearhouse(body: dict):
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
# Re-resolve the claim so we can look up its payer config. We
# re-parse the stored x12_text to get a ClaimOutput (same path
# the serializer uses).
try:
from cyclone.parsers.parse_837 import parse as _parse837
claim_row_obj = _load_claim_row(cid)
if claim_row_obj is None or not (claim_row_obj.raw_json or {}).get("x12_text"):
raise RuntimeError("no stored x12_text for claim")
parsed = _parse837(claim_row_obj.raw_json["x12_text"])
claim_obj = parsed.claims[0] if parsed.claims else None
except Exception:
claim_obj = None
if claim_obj is not None:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = payer_cfg.get("receiver_id") or (claim_obj.payer.id if claim_obj.payer else None) or "RECEIVER"
receiver_name = payer_cfg.get("receiver_name") or (claim_obj.payer.name if claim_obj.payer else None) or receiver_id
sbr09 = payer_cfg.get("sbr09_default") or "MC"
# Re-serialize with the proper envelope values.
try:
x12_text = _serialize_claim_for_submit(
cid,
**{**submitter_kwargs, "receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09},
)
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
filename = build_outbound_filename(ch.tpid, "837P")
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
@@ -2357,9 +2649,20 @@ def submit_to_clearhouse(body: dict):
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
def _serialize_claim_for_submit(claim_id: str) -> str:
def _load_claim_row(claim_id: str):
"""Helper: load a Claim row by id (or return None)."""
with db.SessionLocal()() as s:
return s.get(Claim, claim_id)
def _serialize_claim_for_submit(claim_id: str, **kwargs) -> str:
"""Serialize a claim to X12 for SFTP submission. Lazy import of the
serializer to avoid pulling FastAPI machinery at module import time."""
serializer to avoid pulling FastAPI machinery at module import time.
Optional ``**kwargs`` are forwarded to the serializer — used to
pass through clearhouse submitter info and per-payer receiver info
so the regenerated file matches what the HCPF MFT expects.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone import db
with db.SessionLocal()() as s:
@@ -2371,15 +2674,17 @@ def _serialize_claim_for_submit(claim_id: str) -> str:
from cyclone.parsers.parse_837 import parse
raw = row.raw_json or {}
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
return _serialize_claim_from_raw(row, raw)
return _serialize_claim_from_raw(row, raw, **kwargs)
def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
"""Best-effort serializer that uses the stored raw_json to emit a fresh 837.
For SP9 this delegates to the existing serialize_837 helper if the
claim has a complete raw_segments array. Otherwise it returns a
minimal placeholder.
minimal placeholder. ``**kwargs`` are forwarded to the serializer
so callers can pass through submitter / receiver / SBR09 values
from the clearhouse and per-payer configs.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone.parsers.parse_837 import parse
@@ -2389,7 +2694,7 @@ def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
if isinstance(raw, dict) and raw.get("x12_text"):
result = parse(raw["x12_text"])
if result.claims:
return serialize_837(result.claims[0])
return serialize_837(result.claims[0], **kwargs)
# Fallback: raise so the caller sees an error.
raise RuntimeError(
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
+30 -6
View File
@@ -93,19 +93,40 @@ def ndjson_stream_list(
}) + "\n"
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary."""
def ndjson_stream_837(
result: ParseResult, batch_id: str | None = None,
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary.
The ``batch_id`` is the server-side UUID assigned by the persistence
layer when the batch is ingested; the JSON response path exposes it
as the top-level ``batch_id`` field, but the NDJSON stream needs it
inline on the summary event so streaming clients can call
batch-scoped endpoints (``/api/batches/{id}/export-837``, …) without
a separate ``GET /api/batches`` round-trip. When ``batch_id`` is not
supplied the summary omits the field, preserving backward compat
with clients that don't expect it.
"""
envelope_obj = (
result.envelope.model_dump() if result.envelope is not None else None
)
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json())
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
def ndjson_stream_835(
result: ParseResult835, batch_id: str | None = None,
) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.
See ``ndjson_stream_837`` for why the optional ``batch_id`` is
merged into the summary event.
"""
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
@@ -113,7 +134,10 @@ def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
summary_data = json.loads(result.summary.model_dump_json())
if batch_id is not None:
summary_data["batch_id"] = batch_id
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
def strict_rewrite_837(result: ParseResult) -> ParseResult:
+11 -6
View File
@@ -4,8 +4,8 @@ SP9. Source-of-truth spec:
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
Outbound (we send):
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: tp11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
# Regexes
# ---------------------------------------------------------------------------
# Outbound: 11525703-837P-20260620132243505-1of1.x12
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
# - tp: literal "tp" prefix
# - tpid: 1+ digits
# - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1"
# - ext: 1+ alnum
OUTBOUND_RE = re.compile(
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
)
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
@@ -79,7 +80,8 @@ def build_outbound_filename(
time in ``America/Denver`` is used.
Returns:
Filename like "11525703-837P-20260620132243505-1of1.x12"
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
(note the ``tp`` prefix per HCPF outbound spec).
Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or
@@ -99,7 +101,10 @@ def build_outbound_filename(
# Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
# Per HCPF outbound spec, prefix is "tp" + tpid. Matches the format
# we receive from HPE inbound (which uses uppercase TP) and the
# historical outbound prodfile naming (e.g. tp11525703-837P-...).
return f"tp{tpid}-{tx}-{ts}-1of1.{ext}"
# ---------------------------------------------------------------------------
+9
View File
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
frequency_code: str | None = None
provider_signature: str | None = None
assignment: str | None = None
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
release_of_info: str | None = None
prior_auth: str | None = None
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
place_of_service: str | None = None
service_date: date | None = None
provider_reference: str | None = None
# SV1-07 — Diagnosis Code Pointer. Points to one or more
# diagnosis codes in the parent claim's HI segment ("1".. "12",
# space-separated when multiple). For 837P with a non-empty HI
# segment, SV1-07 is required by HCPF / Gainwell. The parser
# captures it from the source; the serializer defaults to "1"
# when the claim has at least one diagnosis and no explicit
# pointer was captured (matches the common single-dx case).
dx_pointer: str | None = None
class ValidationIssue(_Base):
+8
View File
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
frequency_code=freq or None,
provider_signature=clm[6] if len(clm) > 6 else None,
assignment=clm[7] if len(clm) > 7 else None,
benefits_assignment_certification=clm[8] if len(clm) > 8 else None,
release_of_info=clm[9] if len(clm) > 9 else None,
)
@@ -285,6 +286,12 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
except Exception:
units = None
place_of_service = seg[5] if len(seg) > 5 else None
# SV1-06 (Unit Basis of Measurement) is X12 "UN" for "units" — we
# already use unit_type in SV1-03; SV1-06 is rarely populated and
# is not required by HCPF.
# SV1-07 — Diagnosis Code Pointer (e.g. "1" for the first HI
# diagnosis). Required by HCPF when the claim has diagnoses.
dx_pointer = seg[7] if len(seg) > 7 and seg[7] else None
service_date: date | None = None
provider_ref: str | None = None
@@ -311,6 +318,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
place_of_service=place_of_service,
service_date=service_date,
provider_reference=provider_ref,
dx_pointer=dx_pointer,
),
idx,
)
+149 -49
View File
@@ -112,7 +112,10 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
_FUNCTIONAL_ID_HEALTH_CARE,
sender_id,
receiver_id,
_today_yymmdd(),
# GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD
# (6 digits) for the older format, but the GS segment is the
# newer ANSI X12 format and requires the full year.
_today_yyyymmdd(),
_today_hhmm(),
group_control_number,
"X",
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
return _ELEM.join(parts) + _SEG
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
"""PER segment — submitter contact. Returns empty when no contact info."""
if not contact_name and not contact_phone:
return ""
parts = [
"PER",
"IC", # PER01 — contact function code (Information Contact)
contact_name or "",
"TE", # PER03 — phone qualifier
contact_phone or "",
]
def _build_per(
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> str:
"""PER segment — submitter contact (Loop 1000A).
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
(Submitter Name) and at least PER01 must be present, so this
builder always emits a segment. PER01 = "IC" (Information Contact).
The remaining elements are filled from the available contact info:
name, then email (preferred — Gainwell/HCPF expect this), then phone.
"""
parts = ["PER", "IC"]
if contact_name:
parts.append(contact_name)
if contact_email:
parts.append(email_qual) # PER03 — email qualifier (default "EM")
parts.append(contact_email) # PER04 — the email itself
elif contact_phone:
parts.append("TE") # PER03 — phone qualifier
parts.append(contact_phone) # PER04 — the phone itself
return _ELEM.join(parts) + _SEG
@@ -236,26 +252,40 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
return _ELEM.join(parts) + _SEG
def _build_sbr(relationship_code: str | None, member_id: str | None,
payer_name: str | None) -> str:
def _build_sbr(
individual_relationship_code: str | None,
claim_filing_indicator_code: str | None,
) -> str:
"""SBR segment — subscriber information.
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
the most common case for professional claims; the parser does not store
this on the canonical Subscriber model so we cannot thread it through
without adding a model field.
Slot layout (X12 005010X222A1):
SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
(Patient = primary). The parser does not capture this
field on ``ClaimOutput`` so we default it.
SBR02 — Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
The parser does not capture this either; we default ``"18"``
for the common self-pay case.
SBR09 — Claim Filing Indicator Code. ``"MC"`` for Medicaid,
``"16"`` for Medicare Part B, etc. The canonical
PayerConfig837 carries ``sbr09_default``; we thread it in
from the caller.
The member_id and payer name do NOT belong in SBR — the member_id
lives in NM109 of the NM1*IL segment, and the payer name is in
NM103 of NM1*PR. (Earlier revisions of this function put them in
SBR06 / SBR09, which is wrong and rejected by HCPF.)
"""
parts = [
"SBR",
relationship_code or "P",
"", # SBR02 — group number
"", # SBR03 — group name
"", # SBR04 — claim filing indicator code
"", # SBR05 — sequence number code
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
"", # SBR07
"", # SBR08
member_id or "", # SBR09 — claim submitter's id
"P", # SBR01 — primary
individual_relationship_code or "18", # SBR02 — self
"", # SBR03 — group number
"", # SBR04 — group name
"", # SBR05 — insurance type code
"", # SBR06 — coordination of benefits
"", # SBR07 — yes/no condition
"", # SBR08 — employment status code
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
]
return _ELEM.join(parts) + _SEG
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
clm05, # CLM05 — composite POS:qualifier:frequency_code
claim.provider_signature or "Y", # CLM06
claim.assignment or "Y", # CLM07
"", # CLM08 — benefit assignment certification
# CLM08 — Benefits Assignment Certification. X12 837P requires
# this when CLM07 = "Y" (the common case for in-network
# professional claims). Default to "Y" when the source did
# not capture one — matches what 99% of HCPF files look like.
claim.benefits_assignment_certification or "Y", # CLM08
claim.release_of_info or "Y", # CLM09
]
return _ELEM.join(parts) + _SEG
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
return _ELEM.join(["LX", str(line_number)]) + _SEG
def _build_sv1(line) -> str:
"""SV1 segment — professional service line."""
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
"""SV1 segment — professional service line.
X12 005010X222A1 layout (837P):
SV1-01 composite procedure identifier
SV1-02 monetary amount (charge)
SV1-03 unit of basis measurement (UN, MJ, etc.) — ``line.unit_type``
SV1-04 service unit count — ``line.units``
SV1-05 place of service code — ``line.place_of_service``
SV1-06 **NOT USED** by this guide (must be empty)
SV1-07 diagnosis code pointer — ``dx_pointer`` (required when the
parent claim has an HI segment)
The parser captures the original SV1-07 pointer when present; the
serializer defaults it to ``"1"`` (pointing at the first HI
diagnosis) when the claim has diagnoses and no explicit pointer
was captured. When the claim has no HI segment we leave SV1-07
empty to match the spec.
"""
proc = line.procedure
code = proc.code if proc else ""
mods = proc.modifiers if proc else []
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
charge = f"{Decimal(line.charge or 0):.2f}"
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
sv1_07 = dx_pointer or ""
parts = [
"SV1",
composite,
charge,
line.unit_type or "UN",
units,
line.place_of_service or "",
composite, # SV1-01
charge, # SV1-02
line.unit_type or "UN", # SV1-03 — unit basis code
units, # SV1-04
line.place_of_service or "", # SV1-05
"", # SV1-06 — NOT USED in 837P
sv1_07, # SV1-07 — diagnosis pointer
]
return _ELEM.join(parts) + _SEG
@@ -357,15 +411,20 @@ def _build_dtp_472(service_date: date | None) -> str:
# ---------------------------------------------------------------------------
def _build_submitter_block(sender_id: str, submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None) -> list[str]:
def _build_submitter_block(
sender_id: str,
submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> list[str]:
out = [
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
]
per = _build_per(contact_name, contact_phone)
if per:
out.append(per)
# PER is required by X12 (at least PER01). _build_per always emits
# the segment; the submitter block always has exactly one.
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
return out
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
return out
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
def _build_subscriber_block(
subscriber,
claim_filing_indicator_code: str | None,
) -> list[str]:
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
out = [
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
_build_sbr("18", subscriber.member_id, payer_name),
_build_sbr("18", claim_filing_indicator_code),
_build_nm1(
"IL", "IL",
f"{subscriber.last_name} {subscriber.first_name}".strip(),
@@ -427,12 +489,24 @@ def _build_payer_block(payer) -> list[str]:
]
def _build_service_lines_block(service_lines) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R.
``has_diagnoses`` is True when the parent claim emits an HI segment;
in that case SV1-07 is required by X12 and we default each line's
pointer to ``"1"`` (the first HI diagnosis) unless the source
captured a different pointer on the line itself.
"""
out: list[str] = []
for idx, line in enumerate(service_lines or [], start=1):
out.append(_build_lx(idx))
out.append(_build_sv1(line))
# Prefer the line's captured pointer (parser pulled SV1-07
# when present). Fall back to "1" only when the claim has
# diagnoses and the source had no explicit pointer — the
# common single-diagnosis case.
line_pointer = getattr(line, "dx_pointer", None)
effective_pointer = line_pointer or ("1" if has_diagnoses else "")
out.append(_build_sv1(line, dx_pointer=effective_pointer))
dtp = _build_dtp_472(line.service_date)
if dtp:
out.append(dtp)
@@ -450,7 +524,10 @@ def serialize_837(
submitter_name: str | None = None,
submitter_contact_name: str | None = None,
submitter_contact_phone: str | None = None,
submitter_contact_email: str | None = None,
submitter_contact_email_qual: str = "EM",
receiver_name: str | None = None,
claim_filing_indicator_code: str | None = None,
interchange_control_number: str = "000000001",
group_control_number: str = "1",
) -> str:
@@ -462,6 +539,16 @@ def serialize_837(
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
the configured values.
The submitter block (Loop 1000A) always emits a PER segment per the
X12 spec — the canonical clearhouse config provides the contact
name and email so callers should pass them through.
The claim filing indicator (SBR09) is read from the per-payer
config (``PayerConfig837.sbr09_default``); callers should pass it
in. If not passed, SBR09 is left empty (which causes the
:func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
its check — degraded but not a hard error).
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
emitted from the canonical ``ClaimOutput`` fields, so post-parse
edits propagate to the output.
@@ -481,11 +568,13 @@ def serialize_837(
),
]
segments.extend(_build_submitter_block(
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
sender_id, submitter_name,
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
submitter_contact_email_qual,
))
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.payer.name))
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
segments.extend(_build_payer_block(claim.payer))
# Claim-level editable segments.
@@ -497,7 +586,10 @@ def serialize_837(
segments.append(_build_hi(claim.diagnoses))
# Service lines (LX / SV1 / DTP*472 / REF*6R).
segments.extend(_build_service_lines_block(claim.service_lines))
segments.extend(_build_service_lines_block(
claim.service_lines,
has_diagnoses=bool(claim.diagnoses),
))
# SE segment count includes ST (line 3, 1-based) through SE itself
# — i.e. the entire ST..SE block inclusive.
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
claim: ClaimOutput,
*,
interchange_index: int,
**kwargs,
) -> str:
"""Like :func:`serialize_837` but assigns deterministic-but-unique
interchange + group control numbers for a bundle position.
Interchange number = ``f"{interchange_index:09d}"``.
Group number = ``str(interchange_index)``.
All other keyword arguments (sender_id, receiver_id, submitter_*
and receiver_* contact info, claim_filing_indicator_code) are
forwarded to :func:`serialize_837` unchanged so callers — like the
export and SFTP-submit endpoints — can pass through clearhouse +
payer config without copying the signature.
"""
return serialize_837(
claim,
interchange_control_number=f"{interchange_index:09d}",
group_control_number=str(interchange_index),
**kwargs,
)
+1 -1
View File
@@ -2277,7 +2277,7 @@ class CycloneStore:
submitter_contact_email="tyler@dzinesco.com",
filename_block={
"tz": "America/Denver",
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"outbound_template": "tp{tpid}-{tx}-{ts_mt}-1of1.{ext}",
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
},
sftp_block={
+8
View File
@@ -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):
+7
View File
@@ -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):
+317
View File
@@ -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"]
+15 -5
View File
@@ -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")
+215 -8
View File
@@ -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)
+2 -2
View File
@@ -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):