feat(sp24): add cyclone.reissue subpackage with pure core functions
The script at scripts/reissue_claims.py was the operator's ad-hoc
workflow for rebuilding the 2026-07-08 dzinesco July-8 batches into
358 IG-correct single-claim X12 files. This commit lifts that
workflow into the cyclone namespace as cyclone.reissue — a pure
package with no Click imports inside.
Public surface (see cyclone/reissue/__init__.py):
- parse_inputs(input_dir, payer_config) -> (claims, errors)
AppleDouble-aware glob; per-file failure tolerance.
- emit_outputs(claims, ..., output_dir, ...) -> list[Path]
HCPF-spec filenames, per-claim 1ms offsets for uniqueness.
- write_summary_sidecar(claims, written, output_dir) -> Path
Operator-facing _serialize_summary.json sidecar.
- zip_outputs(files, zip_path) -> None
zipfile.ZIP_DEFLATED + testzip() integrity check.
- ig_correctness_check(logger=None) -> bool
Verifies serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED is False.
The CLI wrapper (cyclone reissue-claims) lands in the next commit;
this commit is just the pure layer so the unit tests in
test_reissue_core.py can exercise the functions without a CliRunner.
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
"""cyclone.reissue — offline 837P re-emission workflow.
|
||||
|
||||
Pure functions for parsing raw 837P files into ``ClaimOutput`` Pydantic
|
||||
models and re-emitting one IG-correct single-claim X12 file per claim.
|
||||
No Click imports inside :mod:`cyclone.reissue.core`; the CLI wrapper at
|
||||
:mod:`cyclone.cli` is a thin shell over these functions.
|
||||
|
||||
Public surface:
|
||||
|
||||
- :func:`parse_inputs` — parse every ``*.x12`` / ``*.txt`` / ``*.edi``
|
||||
under a directory; tolerates per-file failures and skips claims with
|
||||
hard validation errors.
|
||||
- :func:`emit_outputs` — write one X12 per claim to ``output_dir``
|
||||
using HCPF-spec filenames; per-claim 1 ms timestamp offsets guarantee
|
||||
unique filenames within a batch.
|
||||
- :func:`write_summary_sidecar` — write a ``_serialize_summary.json``
|
||||
sidecar with one row per emitted file (operator audit trail).
|
||||
- :func:`zip_outputs` — zip the per-claim X12 files into a single flat
|
||||
archive with a ``testzip()`` integrity check.
|
||||
- :func:`ig_correctness_check` — verify the serializer's
|
||||
``PATIENT_LOOP_DEFAULT_INCLUDED`` is ``False`` (the IG-correct
|
||||
shape for SBR02 == "18" claims). See
|
||||
``docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md``
|
||||
for the SP24 rationale.
|
||||
|
||||
See `scripts/reissue_claims.py` for the deprecation shim; the
|
||||
canonical entry point is ``cyclone reissue-claims`` (SP24).
|
||||
"""
|
||||
from cyclone.reissue.core import (
|
||||
emit_outputs,
|
||||
ig_correctness_check,
|
||||
parse_inputs,
|
||||
write_summary_sidecar,
|
||||
zip_outputs,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"parse_inputs",
|
||||
"emit_outputs",
|
||||
"write_summary_sidecar",
|
||||
"zip_outputs",
|
||||
"ig_correctness_check",
|
||||
]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""SP24 — pure functions for the offline 837P reissue workflow.
|
||||
|
||||
The functions here are deliberately Click-free so the CLI layer at
|
||||
:mod:`cyclone.cli` stays a thin shell, and so unit tests can exercise
|
||||
the behaviour without :class:`click.testing.CliRunner`.
|
||||
|
||||
Workflow contract:
|
||||
|
||||
>>> claims, errors = parse_inputs(input_dir, payer_config)
|
||||
>>> written = emit_outputs(claims, payer_config=..., output_dir=..., ...)
|
||||
>>> zip_outputs(written, zip_path)
|
||||
|
||||
``ig_correctness_check`` is the regression guard for the SP24 fix.
|
||||
The serializer's ``PATIENT_LOOP_DEFAULT_INCLUDED`` constant MUST be
|
||||
``False``; flipping it back to ``True`` would re-introduce the
|
||||
Edifabric 999 rejection on every SBR02 == "18" claim.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from cyclone.edi.filenames import build_outbound_filename
|
||||
from cyclone.parsers.models import ClaimOutput
|
||||
from cyclone.parsers.parse_837 import parse as parse_837_text
|
||||
from cyclone.parsers.serialize_837 import (
|
||||
PATIENT_LOOP_DEFAULT_INCLUDED,
|
||||
serialize_837,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ig_correctness_check(*, logger: logging.Logger | None = None) -> bool:
|
||||
"""Return True iff the serializer's IG-correct patient-loop default is in place.
|
||||
|
||||
Per X12 005010X222A1, the 2000C Patient Hierarchical Level
|
||||
(``HL*3 → PAT → NM1*QC``) MUST be absent when ``SBR02 == "18"``
|
||||
(Self-pay, the CO-Medicaid IHSS workflow). The serializer encodes
|
||||
this as a module-level constant ``PATIENT_LOOP_DEFAULT_INCLUDED``
|
||||
in :mod:`cyclone.parsers.serialize_837`; this function checks
|
||||
that constant.
|
||||
|
||||
Args:
|
||||
logger: optional logger for the diagnostic WARNING when the
|
||||
guard fires. Defaults to this module's logger.
|
||||
|
||||
Returns:
|
||||
True if the constant is ``False`` (IG-correct). False
|
||||
otherwise — callers should refuse to emit any X12 in that case.
|
||||
"""
|
||||
log = logger or _log
|
||||
if PATIENT_LOOP_DEFAULT_INCLUDED is not False:
|
||||
log.warning(
|
||||
"REFUSING to run: PATIENT_LOOP_DEFAULT_INCLUDED = %r "
|
||||
"(expected False). The IG-correct serializer shape for "
|
||||
"SBR02='18' claims requires the 2000C patient loop to be "
|
||||
"absent; restoring the default to False fixes it.",
|
||||
PATIENT_LOOP_DEFAULT_INCLUDED,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_inputs(
|
||||
input_dir: Path,
|
||||
payer_config,
|
||||
) -> tuple[list[ClaimOutput], list[tuple[Path, str]]]:
|
||||
"""Parse every ``*.x12`` / ``*.txt`` / ``*.edi`` under ``input_dir``.
|
||||
|
||||
AppleDouble metadata files (``._foo.x12``, the macOS resource
|
||||
forks that Samba / Finder scatter) are skipped at the glob stage.
|
||||
|
||||
Per-file failures are tolerated: a file that raises during
|
||||
``parse_837_text`` lands in the second tuple element with the
|
||||
exception class + message. The first tuple element collects every
|
||||
surviving :class:`ClaimOutput`, including those with **warnings**
|
||||
(warnings don't abort). Claims with hard ``validation.errors``
|
||||
are dropped with an error message in the second tuple element.
|
||||
|
||||
Args:
|
||||
input_dir: directory to walk (one level).
|
||||
payer_config: a :class:`PayerConfig` instance passed to the
|
||||
parser — typically ``PayerConfig.co_medicaid()``.
|
||||
|
||||
Returns:
|
||||
``(claims, errors)`` where ``claims`` is the surviving
|
||||
:class:`ClaimOutput` list and ``errors`` is a list of
|
||||
``(path, message)`` tuples for each per-file failure.
|
||||
"""
|
||||
raw_files: list[Path] = []
|
||||
for ext in ("*.x12", "*.txt", "*.edi"):
|
||||
for p in sorted(input_dir.glob(ext)):
|
||||
# Skip macOS AppleDouble metadata (._<name>) — binary
|
||||
# forks of the resource fork, not actual EDI.
|
||||
if p.name.startswith("._"):
|
||||
continue
|
||||
raw_files.append(p)
|
||||
|
||||
claims: list[ClaimOutput] = []
|
||||
errors: list[tuple[Path, str]] = []
|
||||
for f in raw_files:
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8")
|
||||
result = parse_837_text(text, payer_config, input_file=str(f))
|
||||
except Exception as exc: # noqa: BLE001 — log + skip
|
||||
errors.append((f, f"{exc.__class__.__name__}: {exc}"))
|
||||
continue
|
||||
if not result.claims:
|
||||
errors.append((f, "no claims parsed (empty CLM loop?)"))
|
||||
continue
|
||||
for c in result.claims:
|
||||
if c.validation.errors:
|
||||
msgs = [f"{i.rule}: {i.message}" for i in c.validation.errors]
|
||||
errors.append((f, f"hard errors in {c.claim_id}: {msgs}"))
|
||||
continue
|
||||
claims.append(c)
|
||||
return claims, errors
|
||||
|
||||
|
||||
def emit_outputs(
|
||||
claims: list[ClaimOutput],
|
||||
*,
|
||||
payer_config,
|
||||
output_dir: Path,
|
||||
sender_id: str,
|
||||
receiver_id: str,
|
||||
submitter_name: str,
|
||||
submitter_contact_name: str,
|
||||
submitter_contact_email: str,
|
||||
receiver_name: str,
|
||||
) -> list[Path]:
|
||||
"""Write one X12 per claim under ``output_dir`` using HCPF-spec filenames.
|
||||
|
||||
Per-claim 1 ms timestamp offsets on the base datetime guarantee
|
||||
unique filenames within a batch. The output directory is created
|
||||
if it doesn't exist. Claims are sorted by ``claim_id`` before
|
||||
emission so the per-claim filenames line up with the canonical
|
||||
sort order.
|
||||
|
||||
Args:
|
||||
claims: parsed :class:`ClaimOutput` instances (typically the
|
||||
first tuple element from :func:`parse_inputs`).
|
||||
payer_config: a :class:`PayerConfig` — threads
|
||||
``sbr09_claim_filing`` into the SBR09 segment.
|
||||
output_dir: directory to write the per-claim X12 files into.
|
||||
sender_id, receiver_id, submitter_*, receiver_name: envelope
|
||||
metadata threaded into the ISA/GS/NM1*41/NM1*40 segments.
|
||||
|
||||
Returns:
|
||||
The list of written :class:`Path` instances, in the same
|
||||
order as the sorted claims.
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
# America/Denver is the operator's home tz and the canonical
|
||||
# timezone for HCPF submission timestamps; matching the tz
|
||||
# keeps the 999 round-trip deterministic for the operator's
|
||||
# daily pull.
|
||||
base_ts = datetime.now(tz=ZoneInfo("America/Denver"))
|
||||
|
||||
written: list[Path] = []
|
||||
for i, claim in enumerate(sorted(claims, key=lambda c: c.claim_id), start=1):
|
||||
body = serialize_837(
|
||||
claim,
|
||||
sender_id=sender_id,
|
||||
receiver_id=receiver_id,
|
||||
submitter_name=submitter_name,
|
||||
submitter_contact_name=submitter_contact_name,
|
||||
submitter_contact_email=submitter_contact_email,
|
||||
receiver_name=receiver_name,
|
||||
claim_filing_indicator_code=payer_config.sbr09_claim_filing,
|
||||
interchange_control_number=f"{i:09d}",
|
||||
group_control_number="1",
|
||||
)
|
||||
ts = base_ts + timedelta(milliseconds=i)
|
||||
fname = build_outbound_filename(sender_id, "837P", now_mt=ts)
|
||||
path = output_dir / fname
|
||||
path.write_text(body, encoding="ascii")
|
||||
written.append(path)
|
||||
return written
|
||||
|
||||
|
||||
def write_summary_sidecar(
|
||||
claims: list[ClaimOutput],
|
||||
written: list[Path],
|
||||
output_dir: Path,
|
||||
) -> Path:
|
||||
"""Write ``_serialize_summary.json`` next to the per-claim X12 files.
|
||||
|
||||
Operator-facing sidecar for audit: one row per emitted file with
|
||||
``claim_id``, ``output_file`` (absolute path), and ``byte_size``.
|
||||
Both ``claims`` and ``written`` are zipped in the same order as
|
||||
``emit_outputs`` produced them (i.e. sorted by ``claim_id``).
|
||||
|
||||
Returns the path of the written sidecar.
|
||||
"""
|
||||
summary = [
|
||||
{
|
||||
"claim_id": c.claim_id,
|
||||
"output_file": str(p),
|
||||
"byte_size": p.stat().st_size,
|
||||
}
|
||||
for c, p in zip(sorted(claims, key=lambda c: c.claim_id), written)
|
||||
]
|
||||
path = output_dir / "_serialize_summary.json"
|
||||
path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def zip_outputs(files: list[Path], zip_path: Path) -> None:
|
||||
"""Zip the per-claim X12 files into a single flat archive.
|
||||
|
||||
Uses :class:`zipfile.ZIP_DEFLATED`. After writing, runs
|
||||
:meth:`zipfile.ZipFile.testzip` to verify CRC integrity and
|
||||
raises :class:`RuntimeError` if any entry is corrupt.
|
||||
|
||||
Args:
|
||||
files: the per-claim X12 file paths (typically the return
|
||||
value of :func:`emit_outputs`).
|
||||
zip_path: destination archive path; parent directories are
|
||||
created as needed.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if any entry fails the ``testzip()`` integrity
|
||||
check.
|
||||
"""
|
||||
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in files:
|
||||
zf.write(p, arcname=p.name)
|
||||
bad = zipfile.ZipFile(zip_path).testzip()
|
||||
if bad is not None:
|
||||
raise RuntimeError(f"zip integrity check failed: {bad} is corrupt")
|
||||
Reference in New Issue
Block a user