merge: SP24 reissue-claims into main
This commit is contained in:
+230
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import date as _date
|
from datetime import date as _date, datetime as _datetime
|
||||||
from decimal import Decimal as _Decimal
|
from decimal import Decimal as _Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -2050,5 +2050,234 @@ def rebill_from_835(
|
|||||||
click.echo(f"Wrote {n} summary rows to {summary_path}")
|
click.echo(f"Wrote {n} summary rows to {summary_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SP24: `cyclone reissue-claims` — offline 837P reissue workflow.
|
||||||
|
#
|
||||||
|
# Re-parses raw 837P files under --input-dir, validates each parsed
|
||||||
|
# claim against the per-payer config, and emits one IG-correct
|
||||||
|
# single-claim X12 file per claim under --output-root/<date>/<pipeline>/.
|
||||||
|
# Optionally zips the output to --zip-output.
|
||||||
|
#
|
||||||
|
# The IG-correctness guard (`ig_correctness_check`) reads
|
||||||
|
# `serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED` on every invocation and
|
||||||
|
# refuses to run if it ever flips back to True — see the SP24 spec for
|
||||||
|
# the regression story behind the constant.
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 — every file emitted cleanly (or with per-file warnings only)
|
||||||
|
# 1 — IG-correctness guard tripped OR an unexpected error
|
||||||
|
# 2 — parse / validation failure on a file AND zero claims survived
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("reissue-claims")
|
||||||
|
@click.option(
|
||||||
|
"--input-dir",
|
||||||
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
|
required=True,
|
||||||
|
help="Directory holding raw 837P files (*.x12, *.txt, *.edi).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output-root",
|
||||||
|
type=click.Path(file_okay=False, path_type=Path),
|
||||||
|
default=Path("dev/rebills"),
|
||||||
|
show_default=True,
|
||||||
|
help="Root dir; output lands in <output-root>/<date>/<pipeline>/.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--date",
|
||||||
|
default=None,
|
||||||
|
help="YYYY-MM-DD subfolder under --output-root (default: today MT).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--pipeline",
|
||||||
|
default="initial",
|
||||||
|
show_default=True,
|
||||||
|
help="Pipeline subfolder name (e.g. 'initial', 'resubmit', 'corrected-v2').",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--payer",
|
||||||
|
default="co_medicaid",
|
||||||
|
show_default=True,
|
||||||
|
help="Payer config preset passed to the parser.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--sender-id",
|
||||||
|
default="11525703",
|
||||||
|
show_default=True,
|
||||||
|
help="ISA06 / GS02 — Dzinesco TPID.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--receiver-id",
|
||||||
|
default="COMEDASSISTPROG",
|
||||||
|
show_default=True,
|
||||||
|
help="ISA08 / GS03 — CO Medicaid.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--submitter-name",
|
||||||
|
default="Dzinesco",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--submitter-contact-name",
|
||||||
|
default="Tyler Martinez",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--submitter-contact-email",
|
||||||
|
default="tyler@dzinesco.com",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--receiver-name",
|
||||||
|
default="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--zip-output",
|
||||||
|
type=click.Path(file_okay=False, path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="If set, zip all emitted X12 files to this path.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-clean",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Don't wipe prior .x12 outputs in the pipeline subdir before writing.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--log-level",
|
||||||
|
default="INFO",
|
||||||
|
show_default=True,
|
||||||
|
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
|
||||||
|
)
|
||||||
|
def reissue_claims(
|
||||||
|
input_dir: Path,
|
||||||
|
output_root: Path,
|
||||||
|
date: str | None,
|
||||||
|
pipeline: str,
|
||||||
|
payer: str,
|
||||||
|
sender_id: str,
|
||||||
|
receiver_id: str,
|
||||||
|
submitter_name: str,
|
||||||
|
submitter_contact_name: str,
|
||||||
|
submitter_contact_email: str,
|
||||||
|
receiver_name: str,
|
||||||
|
zip_output: Path | None,
|
||||||
|
no_clean: bool,
|
||||||
|
log_level: str,
|
||||||
|
) -> None:
|
||||||
|
"""Re-parse raw 837P files and emit one IG-correct single-claim X12 per claim (SP24).
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Parse every *.x12 / *.txt / *.edi under --input-dir.
|
||||||
|
2. Validate each parsed claim; skip claims with hard errors.
|
||||||
|
3. Serialize each surviving claim via the IG-correct serializer.
|
||||||
|
4. Write one X12 file per claim under
|
||||||
|
--output-root/<date>/<pipeline>/ using HCPF-spec filenames.
|
||||||
|
5. Optionally zip the output to --zip-output.
|
||||||
|
|
||||||
|
Exit codes: 0 success (some per-file warnings are non-fatal);
|
||||||
|
1 IG-correctness guard tripped or unexpected error;
|
||||||
|
2 parse / validation failure on a file AND zero claims survived.
|
||||||
|
"""
|
||||||
|
# SP18: re-run so --log-level overrides the group default.
|
||||||
|
setup_logging(level=log_level)
|
||||||
|
|
||||||
|
from cyclone.reissue import (
|
||||||
|
emit_outputs,
|
||||||
|
ig_correctness_check,
|
||||||
|
parse_inputs,
|
||||||
|
write_summary_sidecar,
|
||||||
|
zip_outputs,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
|
||||||
|
log = logging.getLogger("reissue-claims")
|
||||||
|
|
||||||
|
# 0. IG-correctness guard. Fires on every invocation — cheap (one
|
||||||
|
# constant read) and the only defense against the 999 rejection
|
||||||
|
# root cause coming back via a future refactor.
|
||||||
|
if not ig_correctness_check(logger=log):
|
||||||
|
click.echo(
|
||||||
|
"REFUSING to run: PATIENT_LOOP_DEFAULT_INCLUDED is True; "
|
||||||
|
"the 2000C patient loop is forbidden when SBR02='18'. "
|
||||||
|
"Restore the IG-correct default (False) in serialize_837.py.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 1. Payer config factory. Keep this tiny — extend as more
|
||||||
|
# payer presets land in PayerConfig.
|
||||||
|
payer_factories = {
|
||||||
|
"co_medicaid": PayerConfig.co_medicaid,
|
||||||
|
"generic_837p": PayerConfig.generic_837p,
|
||||||
|
}
|
||||||
|
if payer not in payer_factories:
|
||||||
|
raise click.BadParameter(
|
||||||
|
f"Unknown payer {payer!r}. Choose from: {', '.join(payer_factories)}"
|
||||||
|
)
|
||||||
|
payer_config = payer_factories[payer]()
|
||||||
|
|
||||||
|
# 2. Output directory layout.
|
||||||
|
date_label = date or _datetime.now().strftime("%Y-%m-%d")
|
||||||
|
out_dir = output_root / date_label / pipeline
|
||||||
|
if out_dir.exists() and not no_clean:
|
||||||
|
# Clean only the per-claim X12 files + summary; leave any
|
||||||
|
# sibling artifacts (logs, sidecars) alone.
|
||||||
|
for p in out_dir.glob("tp*.x12"):
|
||||||
|
p.unlink()
|
||||||
|
log.info("cleaned prior .x12 outputs under %s", out_dir)
|
||||||
|
|
||||||
|
# 3. Parse → 4. Serialize → 5. Zip.
|
||||||
|
log.info("parse: input_dir=%s payer=%s", input_dir, payer)
|
||||||
|
claims, parse_errors = parse_inputs(input_dir, payer_config)
|
||||||
|
log.info(
|
||||||
|
"parse: %d claims read, %d file-level errors",
|
||||||
|
len(claims),
|
||||||
|
len(parse_errors),
|
||||||
|
)
|
||||||
|
for src, err in parse_errors[:10]:
|
||||||
|
log.warning(" %s: %s", src, err)
|
||||||
|
if parse_errors and not claims:
|
||||||
|
click.echo(
|
||||||
|
f"PARSE FAILED: zero claims survived; first error: {parse_errors[0][1]}",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
log.info("serialize: output_dir=%s", out_dir)
|
||||||
|
written = emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer_config,
|
||||||
|
output_dir=out_dir,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_path = write_summary_sidecar(claims, written, out_dir)
|
||||||
|
total_bytes = sum(p.stat().st_size for p in written)
|
||||||
|
log.info(
|
||||||
|
"serialize: %d files, %d bytes, summary=%s",
|
||||||
|
len(written),
|
||||||
|
total_bytes,
|
||||||
|
summary_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if zip_output:
|
||||||
|
zip_outputs(written, zip_output)
|
||||||
|
log.info("zip: %s (%d bytes)", zip_output, zip_output.stat().st_size)
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
f"DONE files={len(written)} errors={len(parse_errors)} out={out_dir}"
|
||||||
|
)
|
||||||
|
if parse_errors:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from cyclone.parsers.models import ClaimOutput
|
from cyclone.parsers.models import ClaimOutput
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PATIENT_LOOP_DEFAULT_INCLUDED",
|
||||||
|
"SerializeError",
|
||||||
|
"serialize_837",
|
||||||
|
"serialize_837_for_resubmit",
|
||||||
|
"serialize_member_week_batch",
|
||||||
|
]
|
||||||
|
|
||||||
_SEG = "~"
|
_SEG = "~"
|
||||||
_ELEM = "*"
|
_ELEM = "*"
|
||||||
_ISA_COMPONENT_SEPARATOR = ":"
|
_ISA_COMPONENT_SEPARATOR = ":"
|
||||||
@@ -64,6 +72,22 @@ class SerializeError(Exception):
|
|||||||
"""Raised when a claim cannot be serialized."""
|
"""Raised when a claim cannot be serialized."""
|
||||||
|
|
||||||
|
|
||||||
|
#: Default value for ``_build_subscriber_block(include_patient_loop=...)``.
|
||||||
|
#:
|
||||||
|
#: Per X12 005010X222A1, the 2000C Patient Hierarchical Level
|
||||||
|
#: (``HL*3 → PAT → NM1*QC``) is REQUIRED only when Patient != Subscriber
|
||||||
|
#: (i.e. ``SBR02 != "18"``). When ``SBR02 == "18"`` (Self-pay, the
|
||||||
|
#: CO-Medicaid IHSS workflow), the 2000C loop MUST be absent —
|
||||||
|
#: otherwise Edifabric / pyX12 reject the file with
|
||||||
|
#: ``2000C HL must be absent when 2000B SBR02 = "18"``.
|
||||||
|
#:
|
||||||
|
#: The regression test
|
||||||
|
#: ``tests/test_serialize_837.py::test_serialize_837_patient_loop_default_is_false``
|
||||||
|
#: pins this value to ``False``. Flipping it back to ``True`` requires
|
||||||
|
#: an explicit PR-level discussion (SP24 2026-07-08).
|
||||||
|
PATIENT_LOOP_DEFAULT_INCLUDED: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Envelope helpers
|
# Envelope helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -502,7 +526,7 @@ def _build_subscriber_block(
|
|||||||
subscriber,
|
subscriber,
|
||||||
claim_filing_indicator_code: str | None,
|
claim_filing_indicator_code: str | None,
|
||||||
payer=None,
|
payer=None,
|
||||||
include_patient_loop: bool = True,
|
include_patient_loop: bool = PATIENT_LOOP_DEFAULT_INCLUDED,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Loop 2000B (HL*2) → SBR → 2010BA (NM1*IL) → 2010BB (NM1*PR).
|
"""Loop 2000B (HL*2) → SBR → 2010BA (NM1*IL) → 2010BB (NM1*PR).
|
||||||
|
|
||||||
|
|||||||
@@ -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,242 @@
|
|||||||
|
"""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 import serialize_837 as _serialize_837_mod
|
||||||
|
from cyclone.parsers.serialize_837 import 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
|
||||||
|
# Read the constant live (not at import time) so monkeypatch in
|
||||||
|
# tests reflects immediately and a runtime mutation by a future
|
||||||
|
# config-loader also takes effect without a re-import.
|
||||||
|
current = _serialize_837_mod.PATIENT_LOOP_DEFAULT_INCLUDED
|
||||||
|
if current 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.",
|
||||||
|
current,
|
||||||
|
)
|
||||||
|
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]] = []
|
||||||
|
|
||||||
|
if not raw_files:
|
||||||
|
errors.append((input_dir, "no *.x12 / *.txt / *.edi files found"))
|
||||||
|
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")
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""SP24 — tests for the `cyclone reissue-claims` CLI subcommand.
|
||||||
|
|
||||||
|
Five smoke tests covering the canonical cyclone-cli pattern:
|
||||||
|
|
||||||
|
1. --help renders cleanly and lists the long-form flags.
|
||||||
|
2. happy path: a single-file input dir produces the expected
|
||||||
|
number of output files.
|
||||||
|
3. empty input dir: exits 2 with a "PARSE FAILED" message.
|
||||||
|
4. IG-correctness guard fires when the constant is monkeypatched
|
||||||
|
to True; CLI exits 1 with the REFUSING log line.
|
||||||
|
5. --zip-output produces a valid zip with round-trip integrity.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from cyclone.cli import main
|
||||||
|
|
||||||
|
# Canonical minimal-claim fixture — flat, module-level Path constant
|
||||||
|
# per the cyclone-tests convention. NEVER reach into docs/prodfiles/.
|
||||||
|
MINIMAL_837P = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _input_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Drop the minimal_837p fixture into tmp_path/in/."""
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
(in_dir / "real.x12").write_text(MINIMAL_837P.read_text())
|
||||||
|
return in_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_reissue_claims_help_renders():
|
||||||
|
"""--help exits 0 and lists every long-form flag in the SP24 spec."""
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["reissue-claims", "--help"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
# Required flag.
|
||||||
|
assert "--input-dir" in result.output
|
||||||
|
# Optional flags documented in the spec §2 Decision 3.
|
||||||
|
for flag in [
|
||||||
|
"--output-root",
|
||||||
|
"--date",
|
||||||
|
"--pipeline",
|
||||||
|
"--payer",
|
||||||
|
"--sender-id",
|
||||||
|
"--receiver-id",
|
||||||
|
"--submitter-name",
|
||||||
|
"--submitter-contact-name",
|
||||||
|
"--submitter-contact-email",
|
||||||
|
"--receiver-name",
|
||||||
|
"--zip-output",
|
||||||
|
"--no-clean",
|
||||||
|
"--log-level",
|
||||||
|
]:
|
||||||
|
assert flag in result.output, f"missing {flag} in help output"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reissue_claims_happy_path(_input_dir: Path, tmp_path: Path):
|
||||||
|
"""A single-file input dir produces 1 .x12 output + summary sidecar."""
|
||||||
|
runner = CliRunner()
|
||||||
|
out_root = tmp_path / "out"
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"reissue-claims",
|
||||||
|
"--input-dir", str(_input_dir),
|
||||||
|
"--output-root", str(out_root),
|
||||||
|
"--date", "2026-07-08",
|
||||||
|
"--pipeline", "initial",
|
||||||
|
],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, (
|
||||||
|
f"CLI exited {result.exit_code}, output={result.output!r}, "
|
||||||
|
f"exception={result.exception!r}"
|
||||||
|
)
|
||||||
|
# 1 .x12 + 1 _serialize_summary.json = 2 entries.
|
||||||
|
emitted_dir = out_root / "2026-07-08" / "initial"
|
||||||
|
assert emitted_dir.is_dir()
|
||||||
|
files = sorted(emitted_dir.iterdir())
|
||||||
|
x12s = [f for f in files if f.suffix == ".x12"]
|
||||||
|
assert len(x12s) == 1
|
||||||
|
assert (emitted_dir / "_serialize_summary.json").is_file()
|
||||||
|
# HCPF-spec filename.
|
||||||
|
assert x12s[0].name.startswith("tp11525703-837P-")
|
||||||
|
assert x12s[0].name.endswith("-1of1.x12")
|
||||||
|
# DONE summary line is in stdout.
|
||||||
|
assert "DONE files=1 errors=0" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_reissue_claims_empty_input_exits_2(tmp_path: Path):
|
||||||
|
"""An empty input dir exits 2 with a clear PARSE FAILED message."""
|
||||||
|
empty_dir = tmp_path / "empty"
|
||||||
|
empty_dir.mkdir()
|
||||||
|
out_root = tmp_path / "out"
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"reissue-claims",
|
||||||
|
"--input-dir", str(empty_dir),
|
||||||
|
"--output-root", str(out_root),
|
||||||
|
],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# No claims → exit 2.
|
||||||
|
assert result.exit_code == 2, (
|
||||||
|
f"CLI exited {result.exit_code}, output={result.output!r}"
|
||||||
|
)
|
||||||
|
# The error message should explain the failure.
|
||||||
|
combined = result.output + (result.stderr or "")
|
||||||
|
assert "PARSE FAILED" in combined or "no claims" in combined.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reissue_claims_ig_correctness_guard_fires(monkeypatch, _input_dir: Path, tmp_path: Path):
|
||||||
|
"""When PATIENT_LOOP_DEFAULT_INCLUDED is True, the CLI refuses to run."""
|
||||||
|
# Monkeypatch the serializer constant to the broken value.
|
||||||
|
# `raising=False` lets us set an attribute that didn't exist
|
||||||
|
# pre-import (defensive against pytest collection order).
|
||||||
|
from cyclone.parsers import serialize_837 as ser_mod
|
||||||
|
monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", True)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
out_root = tmp_path / "out"
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"reissue-claims",
|
||||||
|
"--input-dir", str(_input_dir),
|
||||||
|
"--output-root", str(out_root),
|
||||||
|
],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guard fires → exit 1.
|
||||||
|
assert result.exit_code == 1, (
|
||||||
|
f"CLI exited {result.exit_code} (expected 1); output={result.output!r}"
|
||||||
|
)
|
||||||
|
# The refusal message is in stderr (click.echo(..., err=True)).
|
||||||
|
combined = result.output + (result.stderr or "")
|
||||||
|
assert "REFUSING to run" in combined
|
||||||
|
assert "PATIENT_LOOP_DEFAULT_INCLUDED" in combined
|
||||||
|
|
||||||
|
|
||||||
|
def test_reissue_claims_zip_output_round_trip(_input_dir: Path, tmp_path: Path):
|
||||||
|
"""--zip-output writes a zip whose testzip() returns None."""
|
||||||
|
runner = CliRunner()
|
||||||
|
out_root = tmp_path / "out"
|
||||||
|
zip_path = tmp_path / "out.zip"
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"reissue-claims",
|
||||||
|
"--input-dir", str(_input_dir),
|
||||||
|
"--output-root", str(out_root),
|
||||||
|
"--date", "2026-07-08",
|
||||||
|
"--zip-output", str(zip_path),
|
||||||
|
],
|
||||||
|
catch_exceptions=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert zip_path.is_file()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
# None = no corrupt entries.
|
||||||
|
assert zf.testzip() is None
|
||||||
|
names = zf.namelist()
|
||||||
|
# 1 X12 file in the zip (the summary sidecar is not zipped).
|
||||||
|
assert len(names) == 1
|
||||||
|
assert names[0].startswith("tp11525703-837P-")
|
||||||
|
assert names[0].endswith("-1of1.x12")
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""SP24 — pure-unit tests for cyclone.reissue.core.
|
||||||
|
|
||||||
|
The CLI wrapper (cyclone reissue-claims) is thin; the behaviour lives
|
||||||
|
in :mod:`cyclone.reissue.core`. These tests exercise the four public
|
||||||
|
functions directly without a CliRunner, mirroring the conventions in
|
||||||
|
``cyclone-tests`` (autouse conftest for DB isolation, no network,
|
||||||
|
``tmp_path`` for filesystem work).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
from cyclone.reissue.core import (
|
||||||
|
emit_outputs,
|
||||||
|
ig_correctness_check,
|
||||||
|
parse_inputs,
|
||||||
|
write_summary_sidecar,
|
||||||
|
zip_outputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fixture reference — flat, module-level Path constant. Matches the
|
||||||
|
# convention used in test_api_999.py / test_serialize_837.py.
|
||||||
|
MINIMAL_837P = Path(__file__).parent / "fixtures" / "minimal_837p.txt"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ig_correctness_check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ig_correctness_check_returns_true_when_default_is_false(monkeypatch):
|
||||||
|
"""Guard returns True (silent pass) when PATIENT_LOOP_DEFAULT_INCLUDED is False."""
|
||||||
|
# Default state of the module — should pass.
|
||||||
|
assert ig_correctness_check() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ig_correctness_check_returns_false_when_default_flipped(monkeypatch):
|
||||||
|
"""Guard returns False (refuse to run) when the constant is monkeypatched to True."""
|
||||||
|
import cyclone.parsers.serialize_837 as ser_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", True)
|
||||||
|
assert ig_correctness_check() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ig_correctness_check_logs_warning_when_default_flipped(monkeypatch, caplog):
|
||||||
|
"""The guard emits a WARNING explaining the IG violation when tripped."""
|
||||||
|
import logging
|
||||||
|
import cyclone.parsers.serialize_837 as ser_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", True)
|
||||||
|
with caplog.at_level(logging.WARNING, logger="cyclone.reissue.core"):
|
||||||
|
assert ig_correctness_check() is False
|
||||||
|
assert any(
|
||||||
|
"PATIENT_LOOP_DEFAULT_INCLUDED" in rec.message for rec in caplog.records
|
||||||
|
), f"expected WARNING about the constant; got {caplog.records!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_inputs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inputs_skips_apple_double_metadata(tmp_path: Path):
|
||||||
|
"""`._foo.x12` AppleDouble files are skipped at the glob stage."""
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
# One real file + one AppleDouble shadow.
|
||||||
|
(in_dir / "real.x12").write_text(MINIMAL_837P.read_text())
|
||||||
|
(in_dir / "._real.x12").write_text(MINIMAL_837P.read_text(), encoding="utf-8")
|
||||||
|
|
||||||
|
payer = PayerConfig.co_medicaid()
|
||||||
|
claims, errors = parse_inputs(in_dir, payer)
|
||||||
|
|
||||||
|
# Exactly one claim (from real.x12), zero errors.
|
||||||
|
assert len(claims) == 1
|
||||||
|
assert errors == []
|
||||||
|
assert claims[0].claim_id == "CLM001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inputs_tolerates_per_file_failures(tmp_path: Path):
|
||||||
|
"""A malformed file lands in `errors`; the valid one survives in `claims`."""
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
(in_dir / "good.x12").write_text(MINIMAL_837P.read_text())
|
||||||
|
(in_dir / "bad.x12").write_text("NOT A REAL 837P FILE\n")
|
||||||
|
|
||||||
|
payer = PayerConfig.co_medicaid()
|
||||||
|
claims, errors = parse_inputs(in_dir, payer)
|
||||||
|
|
||||||
|
assert len(claims) == 1
|
||||||
|
assert claims[0].claim_id == "CLM001"
|
||||||
|
assert len(errors) == 1
|
||||||
|
bad_path, bad_msg = errors[0]
|
||||||
|
assert bad_path.name == "bad.x12"
|
||||||
|
assert bad_msg # any non-empty failure message
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inputs_handles_empty_input_dir(tmp_path: Path):
|
||||||
|
"""An empty input dir produces zero claims + a 'no files found' error.
|
||||||
|
|
||||||
|
The CLI exits 2 in this case (per `cyclone reissue-claims --help`).
|
||||||
|
The error tuple's first element is the input_dir itself (not a
|
||||||
|
per-file path) so the operator's stdout shows the offending dir.
|
||||||
|
"""
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
|
||||||
|
payer = PayerConfig.co_medicaid()
|
||||||
|
claims, errors = parse_inputs(in_dir, payer)
|
||||||
|
|
||||||
|
assert claims == []
|
||||||
|
assert len(errors) == 1
|
||||||
|
bad_path, bad_msg = errors[0]
|
||||||
|
assert bad_path == in_dir
|
||||||
|
assert "no" in bad_msg and "files" in bad_msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inputs_returns_claim_models(tmp_path: Path):
|
||||||
|
"""The returned claims are Pydantic ClaimOutput instances."""
|
||||||
|
from cyclone.parsers.models import ClaimOutput
|
||||||
|
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
(in_dir / "real.x12").write_text(MINIMAL_837P.read_text())
|
||||||
|
|
||||||
|
payer = PayerConfig.co_medicaid()
|
||||||
|
claims, _ = parse_inputs(in_dir, payer)
|
||||||
|
|
||||||
|
assert len(claims) == 1
|
||||||
|
assert isinstance(claims[0], ClaimOutput)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# emit_outputs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_claims(tmp_path: Path) -> list:
|
||||||
|
"""Helper: parse the minimal_837p fixture and return the ClaimOutput list."""
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
(in_dir / "real.x12").write_text(MINIMAL_837P.read_text())
|
||||||
|
payer = PayerConfig.co_medicaid()
|
||||||
|
claims, _ = parse_inputs(in_dir, payer)
|
||||||
|
assert len(claims) == 1
|
||||||
|
return claims, payer
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_outputs_writes_hcpf_spec_filenames(tmp_path: Path):
|
||||||
|
"""Emitted filenames match `tp<sender>-837P-<timestamp>-1of1.x12`."""
|
||||||
|
claims, payer = _make_claims(tmp_path)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
|
||||||
|
written = emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer,
|
||||||
|
output_dir=out_dir,
|
||||||
|
sender_id="11525703",
|
||||||
|
receiver_id="COMEDASSISTPROG",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(written) == 1
|
||||||
|
name = written[0].name
|
||||||
|
assert name.startswith("tp11525703-837P-"), f"unexpected filename: {name}"
|
||||||
|
assert name.endswith("-1of1.x12"), f"unexpected suffix: {name}"
|
||||||
|
# 837P + 1-of-1 with 17-digit millisecond timestamp → tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12
|
||||||
|
assert len(name) == len("tp11525703-837P-") + 17 + len("-1of1.x12")
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_outputs_creates_output_dir(tmp_path: Path):
|
||||||
|
"""emit_outputs creates the output dir if it doesn't exist."""
|
||||||
|
claims, payer = _make_claims(tmp_path)
|
||||||
|
out_dir = tmp_path / "deep" / "nested" / "out"
|
||||||
|
assert not out_dir.exists()
|
||||||
|
|
||||||
|
emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer,
|
||||||
|
output_dir=out_dir,
|
||||||
|
sender_id="11525703",
|
||||||
|
receiver_id="COMEDASSISTPROG",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out_dir.is_dir()
|
||||||
|
assert any(out_dir.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_outputs_emits_ig_correct_patient_loop_omission(tmp_path: Path):
|
||||||
|
"""Emitted X12 contains no HL*3 segment (the IG-correct shape for SBR02='18')."""
|
||||||
|
claims, payer = _make_claims(tmp_path)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
|
||||||
|
written = emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer,
|
||||||
|
output_dir=out_dir,
|
||||||
|
sender_id="11525703",
|
||||||
|
receiver_id="COMEDASSISTPROG",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
)
|
||||||
|
|
||||||
|
body = written[0].read_text()
|
||||||
|
# HL*3 absent → SBR02='18' claim has no 2000C patient loop.
|
||||||
|
assert "HL*3" not in body, (
|
||||||
|
f"HL*3 present in emitted X12 — 2000C patient loop must be absent for SBR02='18'. "
|
||||||
|
f"Body: {body!r}"
|
||||||
|
)
|
||||||
|
# HL*2 child count = 0 (not 1) — the IG-correct count.
|
||||||
|
assert "HL*2*1*22*0" in body, (
|
||||||
|
f"HL*2 child count != 0 in emitted X12. Body: {body!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_summary_sidecar_emits_one_row_per_claim(tmp_path: Path):
|
||||||
|
"""The _serialize_summary.json sidecar has one row per emitted file."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
claims, payer = _make_claims(tmp_path)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
written = emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer,
|
||||||
|
output_dir=out_dir,
|
||||||
|
sender_id="11525703",
|
||||||
|
receiver_id="COMEDASSISTPROG",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_path = write_summary_sidecar(claims, written, out_dir)
|
||||||
|
assert summary_path.exists()
|
||||||
|
rows = json.loads(summary_path.read_text())
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["claim_id"] == "CLM001"
|
||||||
|
assert rows[0]["byte_size"] > 0
|
||||||
|
assert rows[0]["output_file"] == str(written[0])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# zip_outputs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_outputs_round_trip_integrity(tmp_path: Path):
|
||||||
|
"""Written zip passes testzip(); entries round-trip with the right names."""
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
(in_dir / "real.x12").write_text(MINIMAL_837P.read_text())
|
||||||
|
payer = PayerConfig.co_medicaid()
|
||||||
|
claims, _ = parse_inputs(in_dir, payer)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
written = emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer,
|
||||||
|
output_dir=out_dir,
|
||||||
|
sender_id="11525703",
|
||||||
|
receiver_id="COMEDASSISTPROG",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
)
|
||||||
|
zip_path = tmp_path / "out.zip"
|
||||||
|
|
||||||
|
zip_outputs(written, zip_path)
|
||||||
|
|
||||||
|
assert zip_path.exists()
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
assert zf.testzip() is None # None = no corrupt entries
|
||||||
|
names = zf.namelist()
|
||||||
|
assert len(names) == 1
|
||||||
|
assert names[0] == written[0].name
|
||||||
|
# Round-trip the body matches.
|
||||||
|
assert zf.read(names[0]) == written[0].read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_outputs_creates_parent_dirs(tmp_path: Path):
|
||||||
|
"""zip_outputs creates the parent dir for the zip path."""
|
||||||
|
in_dir = tmp_path / "in"
|
||||||
|
in_dir.mkdir()
|
||||||
|
(in_dir / "real.x12").write_text(MINIMAL_837P.read_text())
|
||||||
|
payer = PayerConfig.co_medicaid()
|
||||||
|
claims, _ = parse_inputs(in_dir, payer)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
written = emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer,
|
||||||
|
output_dir=out_dir,
|
||||||
|
sender_id="11525703",
|
||||||
|
receiver_id="COMEDASSISTPROG",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
)
|
||||||
|
|
||||||
|
zip_path = tmp_path / "deep" / "nested" / "out.zip"
|
||||||
|
zip_outputs(written, zip_path)
|
||||||
|
assert zip_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parametrized IG guard against a synthetic "broken serializer"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"broken_default, expected_pass",
|
||||||
|
[
|
||||||
|
(False, True), # IG-correct shape — guard silent
|
||||||
|
(True, False), # IG-incorrect shape — guard fires
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ig_correctness_check_parametrized(
|
||||||
|
monkeypatch, broken_default, expected_pass
|
||||||
|
):
|
||||||
|
"""The guard is a binary pass/fail that follows PATIENT_LOOP_DEFAULT_INCLUDED."""
|
||||||
|
import cyclone.parsers.serialize_837 as ser_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(ser_mod, "PATIENT_LOOP_DEFAULT_INCLUDED", broken_default)
|
||||||
|
assert ig_correctness_check() is expected_pass
|
||||||
@@ -738,3 +738,69 @@ def test_sbr_segment_respects_explicit_claim_filing_indicator():
|
|||||||
sbr_line = next(s for s in text.split("~") if s.startswith("SBR"))
|
sbr_line = next(s for s in text.split("~") if s.startswith("SBR"))
|
||||||
parts = sbr_line.split("*")
|
parts = sbr_line.split("*")
|
||||||
assert parts[9] == "16", f"SBR-09 should reflect explicit kwarg, got {parts[9]!r}"
|
assert parts[9] == "16", f"SBR-09 should reflect explicit kwarg, got {parts[9]!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SP24 — IG-correctness regression test
|
||||||
|
#
|
||||||
|
# The 2000C Patient Hierarchical Level (HL*3 → PAT → NM1*QC) MUST be
|
||||||
|
# absent when SBR02 == "18" (Self-pay, the CO-Medicaid IHSS workflow).
|
||||||
|
# This test pins the PATIENT_LOOP_DEFAULT_INCLUDED constant to False so
|
||||||
|
# a future refactor cannot silently reintroduce the broken pattern that
|
||||||
|
# surfaced in the 2026-07-08 Edifabric 999 audit.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_837_patient_loop_default_is_false():
|
||||||
|
"""SP24 regression: PATIENT_LOOP_DEFAULT_INCLUDED must stay False.
|
||||||
|
|
||||||
|
Per X12 005010X222A1, the 2000C Patient Hierarchical Level
|
||||||
|
(``HL*3 → PAT → NM1*QC``) is REQUIRED only when Patient != Subscriber
|
||||||
|
(i.e. ``SBR02 != "18"``). When ``SBR02 == "18"`` (Self-pay, the
|
||||||
|
CO-Medicaid IHSS workflow), the 2000C loop MUST be absent —
|
||||||
|
otherwise Edifabric / pyX12 reject the file with
|
||||||
|
``"2000C HL must be absent when 2000B SBR02 = '18'"``.
|
||||||
|
|
||||||
|
The serializer encodes this invariant as a module-level constant
|
||||||
|
``PATIENT_LOOP_DEFAULT_INCLUDED``. Flipping the default back to
|
||||||
|
``True`` would re-introduce the rejection on every self-pay claim
|
||||||
|
in the dzinesco pipeline. This test pins the constant + verifies
|
||||||
|
the helper actually honors it on a no-kwarg call.
|
||||||
|
|
||||||
|
Reference: ``docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md``
|
||||||
|
"""
|
||||||
|
from datetime import date as _date
|
||||||
|
from cyclone.parsers.models import Subscriber
|
||||||
|
from cyclone.parsers.serialize_837 import (
|
||||||
|
PATIENT_LOOP_DEFAULT_INCLUDED,
|
||||||
|
_build_subscriber_block,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. The constant itself is False — the IG-correct shape.
|
||||||
|
assert PATIENT_LOOP_DEFAULT_INCLUDED is False, (
|
||||||
|
f"PATIENT_LOOP_DEFAULT_INCLUDED must be False "
|
||||||
|
f"(got {PATIENT_LOOP_DEFAULT_INCLUDED!r}); the IG-correct "
|
||||||
|
f"serializer shape for SBR02='18' claims is 2000C-absent. "
|
||||||
|
f"See the SP24 spec §2 Decision 2 for the regression story."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Calling _build_subscriber_block with no kwarg honors the
|
||||||
|
# constant — no HL*3 segment emitted, HL*2 child count = 0.
|
||||||
|
sub = Subscriber(
|
||||||
|
member_id="TEST123",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="Patient",
|
||||||
|
dob=_date(1990, 1, 1),
|
||||||
|
gender="F",
|
||||||
|
)
|
||||||
|
out = _build_subscriber_block(sub, "MC")
|
||||||
|
assert not any(seg.startswith("HL*3") for seg in out), (
|
||||||
|
f"HL*3 emitted with the IG-correct default; segments={out!r}"
|
||||||
|
)
|
||||||
|
# HL*2 child_count must be 0, not 1.
|
||||||
|
hl2_line = next(seg for seg in out if seg.startswith("HL*2"))
|
||||||
|
hl2_parts = hl2_line.rstrip("~").split("*")
|
||||||
|
assert hl2_parts[4] == "0", (
|
||||||
|
f"HL*2 child_count must be 0 (no 2000C children); got {hl2_parts[4]!r}. "
|
||||||
|
f"Line: {hl2_line!r}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -233,6 +233,53 @@ sorted, with `._*` AppleDouble files skipped.
|
|||||||
- CLI exit 0 on completed runs (per-file failures counted, not bumped);
|
- CLI exit 0 on completed runs (per-file failures counted, not bumped);
|
||||||
2 on config-level failures (no clearhouse / stub mode / missing dir).
|
2 on config-level failures (no clearhouse / stub mode / missing dir).
|
||||||
|
|
||||||
|
### Reissue claims (SP24)
|
||||||
|
|
||||||
|
For rebuilding raw 837P files into one IG-correct single-claim X12
|
||||||
|
file per claim — e.g. after a 999 rejection that surfaced an
|
||||||
|
in-scope serializer defect — use `cyclone reissue-claims`. This is
|
||||||
|
the canonical offline-rebuild workflow; the pre-SP24 script
|
||||||
|
`scripts/reissue_claims.py` is a deprecation shim that prints a
|
||||||
|
warning at import time.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/tyler/dev/cyclone/backend
|
||||||
|
.venv/bin/python -m cyclone.cli reissue-claims \
|
||||||
|
--input-dir /home/tyler/dev/cyclone/july8billing \
|
||||||
|
--date 2026-07-08 \
|
||||||
|
--output-root /home/tyler/dev/cyclone/dev/rebills \
|
||||||
|
--pipeline initial \
|
||||||
|
--zip-output xxxclaims.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
1. Parse every `*.x12` / `*.txt` / `*.edi` under `--input-dir`.
|
||||||
|
2. Validate each parsed claim; skip claims with hard errors.
|
||||||
|
3. Serialize each surviving claim via the IG-correct serializer
|
||||||
|
(no `HL*3` / `PAT` / `NM1*QC` when `SBR02 == "18"`).
|
||||||
|
4. Write one X12 per claim under
|
||||||
|
`--output-root/<date>/<pipeline>/` with HCPF-spec filenames.
|
||||||
|
5. Optionally zip the output to `--zip-output`.
|
||||||
|
|
||||||
|
**Exit codes:** 0 success (per-file warnings are non-fatal); 1
|
||||||
|
IG-correctness guard tripped (the serializer's `PATIENT_LOOP_DEFAULT_INCLUDED`
|
||||||
|
constant is `True`) or unexpected error; 2 parse / validation failure
|
||||||
|
on a file AND zero claims survived.
|
||||||
|
|
||||||
|
**IG-correctness guard.** On every invocation the CLI refuses to run
|
||||||
|
if the serializer's default has been flipped back to `True`. This is
|
||||||
|
the SP24 regression guard — flipping the constant would re-introduce
|
||||||
|
the 2026-07-08 Edifabric 999 rejection ("2000C HL must be absent
|
||||||
|
when 2000B SBR02 = '18'"). The guard's only job is to fail loud; it
|
||||||
|
runs once per invocation and reads one module-level constant.
|
||||||
|
|
||||||
|
**No SFTP.** `reissue-claims` is a pure local workflow — no DB write,
|
||||||
|
no SFTP upload, no Edifabric gate. To push the rebuilt files to
|
||||||
|
Gainwell, use `cyclone submit-batch --ingest-dir <the emitted dir>`
|
||||||
|
(see the "Submitting claims" section above) or move the zip manually
|
||||||
|
per the "Manual SFTP mode" posture.
|
||||||
|
|
||||||
### Note on per-file parse CLIs
|
### Note on per-file parse CLIs
|
||||||
|
|
||||||
`parse-837` and `parse-835` exist as CLIs but only emit JSON files to
|
`parse-837` and `parse-835` exist as CLIs but only emit JSON files to
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
# Reissue Claims CLI + IG-Correctness Regression Test Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
||||||
|
> superpowers:subagent-driven-development (recommended) or
|
||||||
|
> superpowers:executing-plans to implement this plan task-by-task. Steps
|
||||||
|
> use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Move `scripts/reissue_claims.py` into a `cyclone.reissue` subpackage, expose it as `cyclone reissue-claims`, and lock in the IG-correct serializer default via a regression test.
|
||||||
|
**Architecture:** `cyclone/reissue/core.py` (pure functions, no Click) + a thin `@main.command("reissue-claims")` wrapper in `cli.py`. The IG-correctness default is promoted from `inspect.signature` introspection of `_build_subscriber_block` to a public `PATIENT_LOOP_DEFAULT_INCLUDED` constant in `serialize_837.py`, exposed via `__all__` and read by the guard.
|
||||||
|
**Tech Stack:** Python 3.11, Click 8, pytest, `cyclone.parsers.parse_837` + `cyclone.parsers.serialize_837` + `cyclone.edi.filenames.build_outbound_filename`.
|
||||||
|
**Spec:** [`docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`](../specs/2026-07-08-cyclone-reissue-claims-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/cyclone/
|
||||||
|
reissue/ ← new subpackage
|
||||||
|
__init__.py re-exports the public surface
|
||||||
|
core.py parse_inputs / emit_outputs /
|
||||||
|
zip_outputs / ig_correctness_check
|
||||||
|
cli.py ← registers @main.command("reissue-claims")
|
||||||
|
(~50 LOC, calls into cyclone.reissue.core)
|
||||||
|
parsers/serialize_837.py ← adds PATIENT_LOOP_DEFAULT_INCLUDED constant
|
||||||
|
in __all__; _build_subscriber_block reads
|
||||||
|
from it instead of hardcoding `False`
|
||||||
|
|
||||||
|
backend/tests/
|
||||||
|
test_reissue_core.py ← new (pure-unit)
|
||||||
|
test_cli_reissue_claims.py ← new (CliRunner smoke)
|
||||||
|
test_serialize_837.py ← adds test_serialize_837_patient_loop_default_is_false
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
reissue_claims.py ← 1-line deprecation shim; prints WARNING
|
||||||
|
at import time, delegates to cyclone.reissue.core
|
||||||
|
|
||||||
|
docs/
|
||||||
|
RUNBOOK.md ← adds "Operator workflows → Reissue claims"
|
||||||
|
section pointing at `cyclone reissue-claims`
|
||||||
|
|
||||||
|
/tmp/refactor-cyclone.md ← progress tracker (already created)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Task 0: branch + worktree
|
||||||
|
|
||||||
|
- [ ] Branch `sp24-reissue-claims` from `main` (no worktree — the user is
|
||||||
|
already in the main checkout; the branch is local).
|
||||||
|
- [ ] Verify `git log --oneline main -1` shows
|
||||||
|
`ea68ae8 feat(sp41): add CYCLONE_EDIFABRIC_DISABLED=1 bypass hook`.
|
||||||
|
- [ ] First commit on the branch: `docs(spec): design for reissue-claims CLI
|
||||||
|
+ IG-correctness regression test (Step 0)` (commits the spec file).
|
||||||
|
|
||||||
|
## Task 1: promote `PATIENT_LOOP_DEFAULT_INCLUDED` to a public constant
|
||||||
|
|
||||||
|
- [ ] In `backend/src/cyclone/parsers/serialize_837.py`, add the constant
|
||||||
|
after the imports (around line 65):
|
||||||
|
|
||||||
|
```python
|
||||||
|
#: Default value for ``_build_subscriber_block(include_patient_loop=...)``.
|
||||||
|
#: Per X12 005010X222A1, the 2000C Patient Hierarchical Level
|
||||||
|
#: (HL*3 → PAT → NM1*QC) is REQUIRED only when Patient != Subscriber
|
||||||
|
#: (i.e. SBR02 != "18"). When SBR02 == "18" (Self), the 2000C loop
|
||||||
|
#: MUST be absent. The serializer's default of False matches the
|
||||||
|
#: CO-Medicaid IHSS workflow and is the IG-correct shape.
|
||||||
|
#:
|
||||||
|
#: The IG-correctness regression test
|
||||||
|
#: ``test_serialize_837_patient_loop_default_is_false`` pins this
|
||||||
|
#: value; flipping it requires an explicit PR-level discussion.
|
||||||
|
PATIENT_LOOP_DEFAULT_INCLUDED: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Replace the existing hardcoded `include_patient_loop: bool = False`
|
||||||
|
default on `_build_subscriber_block(...)` with
|
||||||
|
`include_patient_loop: bool = PATIENT_LOOP_DEFAULT_INCLUDED`.
|
||||||
|
- [ ] Add `PATIENT_LOOP_DEFAULT_INCLUDED` to the module's `__all__`
|
||||||
|
tuple (find the existing `__all__` near the top of the file;
|
||||||
|
if missing, create one with the public functions plus the
|
||||||
|
constant).
|
||||||
|
- [ ] **Live-test:** `cd backend && .venv/bin/pytest tests/test_serialize_837.py -v`
|
||||||
|
(56 cases should still pass; the default behavior is unchanged).
|
||||||
|
- [ ] **Autoreview:** no flake, no lint regressions. Run
|
||||||
|
`cd backend && .venv/bin/python -c "from cyclone.parsers.serialize_837 import PATIENT_LOOP_DEFAULT_INCLUDED; print(PATIENT_LOOP_DEFAULT_INCLUDED)"`
|
||||||
|
and confirm it prints `False`.
|
||||||
|
- [ ] Commit: `feat(sp24): expose PATIENT_LOOP_DEFAULT_INCLUDED constant in serialize_837`.
|
||||||
|
|
||||||
|
## Task 2: create `cyclone.reissue` subpackage
|
||||||
|
|
||||||
|
- [ ] Create `backend/src/cyclone/reissue/__init__.py` with re-exports:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""cyclone.reissue — offline 837P re-emission workflow.
|
||||||
|
|
||||||
|
See :mod:`cyclone.reissue.core` for the public functions.
|
||||||
|
"""
|
||||||
|
from cyclone.reissue.core import (
|
||||||
|
parse_inputs,
|
||||||
|
emit_outputs,
|
||||||
|
zip_outputs,
|
||||||
|
ig_correctness_check,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"parse_inputs",
|
||||||
|
"emit_outputs",
|
||||||
|
"zip_outputs",
|
||||||
|
"ig_correctness_check",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Create `backend/src/cyclone/reissue/core.py` mirroring the script's
|
||||||
|
logic but as pure functions. The signatures match the script:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def ig_correctness_check(*, logger: logging.Logger | None = None) -> bool:
|
||||||
|
"""Return True if the serializer's IG-correct patient-loop default is in place."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def parse_inputs(input_dir: Path, payer_config) -> tuple[list[ClaimOutput], list[tuple[Path, str]]]:
|
||||||
|
"""Parse every *.x12 / *.txt / *.edi under input_dir. Tolerates per-file failures."""
|
||||||
|
...
|
||||||
|
|
||||||
|
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]:
|
||||||
|
"""Emit one X12 file per claim under output_dir using HCPF-spec filenames."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def zip_outputs(files: list[Path], zip_path: Path) -> None:
|
||||||
|
"""Zip the per-claim X12 files into a single flat archive with integrity check."""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation details:
|
||||||
|
- `ig_correctness_check` reads
|
||||||
|
`cyclone.parsers.serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED`;
|
||||||
|
returns True iff it is False. Logs a WARNING (via the optional
|
||||||
|
`logger` argument, or `logging.getLogger(__name__)`) if False
|
||||||
|
is not what the caller expected.
|
||||||
|
- `parse_inputs` skips AppleDouble (`._*`) files, tolerates
|
||||||
|
per-file parse errors (returns them as the second tuple
|
||||||
|
element), and skips claims with hard validation errors.
|
||||||
|
- `emit_outputs` uses `datetime.now(tz=ZoneInfo("America/Denver"))`
|
||||||
|
for the base timestamp + `timedelta(milliseconds=i)` for
|
||||||
|
per-claim uniqueness. Sorts claims by `claim_id`. Writes
|
||||||
|
`_serialize_summary.json` next to the files (caller does this).
|
||||||
|
- `zip_outputs` uses `zipfile.ZIP_DEFLATED` and runs
|
||||||
|
`ZipFile.testzip()` after writing; raises `RuntimeError` if
|
||||||
|
any entry is corrupt.
|
||||||
|
|
||||||
|
- [ ] **Live-test:**
|
||||||
|
`cd backend && .venv/bin/python -c "from cyclone.reissue import parse_inputs, emit_outputs, zip_outputs, ig_correctness_check; print('ok')"`
|
||||||
|
should print `ok`.
|
||||||
|
- [ ] Commit: `feat(sp24): add cyclone.reissue subpackage with pure core functions`.
|
||||||
|
|
||||||
|
## Task 3: write `test_reissue_core.py` (pure-unit)
|
||||||
|
|
||||||
|
- [ ] Create `backend/tests/test_reissue_core.py` with:
|
||||||
|
- `test_ig_correctness_check_returns_true_when_default_is_false`
|
||||||
|
(default state — guard silent).
|
||||||
|
- `test_ig_correctness_check_returns_false_when_default_flipped`
|
||||||
|
(monkeypatches `PATIENT_LOOP_DEFAULT_INCLUDED = True` via
|
||||||
|
`monkeypatch.setattr`; assert False).
|
||||||
|
- `test_parse_inputs_skips_apple_double_metadata`
|
||||||
|
(creates `tmp_path/in/foo.x12` and `tmp_path/in/._foo.x12`;
|
||||||
|
asserts only foo.x12 is parsed).
|
||||||
|
- `test_parse_inputs_tolerates_per_file_failures`
|
||||||
|
(creates one valid + one malformed file; asserts the malformed
|
||||||
|
one is in the errors list and the valid one is in claims).
|
||||||
|
- `test_emit_outputs_writes_hcpf_spec_filenames`
|
||||||
|
(parses a minimal 837P, emits to `tmp_path/out`, asserts the
|
||||||
|
filenames match `tp*-837P-*-1of1.x12`).
|
||||||
|
- `test_emit_outputs_sorts_claims_by_claim_id`
|
||||||
|
(two-claim fixture, asserts the filename suffixes are sorted
|
||||||
|
by claim_id).
|
||||||
|
- `test_zip_outputs_round_trip_integrity`
|
||||||
|
(writes a tiny zip, asserts `ZipFile.testzip()` returns None).
|
||||||
|
|
||||||
|
- [ ] **Live-test:** `cd backend && .venv/bin/pytest tests/test_reissue_core.py -v`
|
||||||
|
(all new cases should pass).
|
||||||
|
- [ ] **Autoreview:** confirm no test relies on network, no test
|
||||||
|
reaches into `docs/prodfiles/`, no test uses wall-clock sleep.
|
||||||
|
- [ ] Commit: `test(sp24): pure-unit tests for cyclone.reissue.core`.
|
||||||
|
|
||||||
|
## Task 4: register `@main.command("reissue-claims")` in cli.py
|
||||||
|
|
||||||
|
- [ ] Append the CLI subcommand to `backend/src/cyclone/cli.py` after
|
||||||
|
the existing `rebill-from-835` block. The Click signature follows
|
||||||
|
§2 Decision 3 of the spec:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@main.command("reissue-claims")
|
||||||
|
@click.option("--input-dir", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True)
|
||||||
|
@click.option("--output-root", type=click.Path(file_okay=False, path_type=Path),
|
||||||
|
default=Path("dev/rebills"), show_default=True)
|
||||||
|
@click.option("--date", default=None, help="YYYY-MM-DD subfolder under output-root (default: today MT).")
|
||||||
|
@click.option("--pipeline", default="initial", show_default=True)
|
||||||
|
@click.option("--payer", default="co_medicaid", show_default=True)
|
||||||
|
@click.option("--sender-id", default="11525703", show_default=True)
|
||||||
|
@click.option("--receiver-id", default="COMEDASSISTPROG", show_default=True)
|
||||||
|
@click.option("--submitter-name", default="Dzinesco", show_default=True)
|
||||||
|
@click.option("--submitter-contact-name", default="Tyler Martinez", show_default=True)
|
||||||
|
@click.option("--submitter-contact-email", default="tyler@dzinesco.com", show_default=True)
|
||||||
|
@click.option("--receiver-name", default="COLORADO MEDICAL ASSISTANCE PROGRAM", show_default=True)
|
||||||
|
@click.option("--zip-output", type=click.Path(file_okay=False, path_type=Path), default=None)
|
||||||
|
@click.option("--no-clean", is_flag=True, default=False,
|
||||||
|
help="Don't wipe prior .x12 outputs in the pipeline subdir before writing.")
|
||||||
|
@click.option("--log-level", default="INFO", show_default=True,
|
||||||
|
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
|
||||||
|
def reissue_claims(
|
||||||
|
input_dir: Path,
|
||||||
|
output_root: Path,
|
||||||
|
date: str | None,
|
||||||
|
pipeline: str,
|
||||||
|
payer: str,
|
||||||
|
sender_id: str,
|
||||||
|
receiver_id: str,
|
||||||
|
submitter_name: str,
|
||||||
|
submitter_contact_name: str,
|
||||||
|
submitter_contact_email: str,
|
||||||
|
receiver_name: str,
|
||||||
|
zip_output: Path | None,
|
||||||
|
no_clean: bool,
|
||||||
|
log_level: str,
|
||||||
|
) -> None:
|
||||||
|
"""Re-parse raw 837P files and emit one IG-correct single-claim X12 per claim (SP24).
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
1. Parse every *.x12 / *.txt / *.edi under --input-dir.
|
||||||
|
2. Validate each parsed claim; skip hard-error claims.
|
||||||
|
3. Serialize each surviving claim via the IG-correct serializer.
|
||||||
|
4. Write one X12 file per claim under
|
||||||
|
``--output-root/--date/--pipeline/`` using HCPF-spec filenames.
|
||||||
|
5. Optionally zip the output to --zip-output.
|
||||||
|
|
||||||
|
Exit codes: 0 success; 1 IG-correctness guard tripped or unexpected
|
||||||
|
error; 2 parse / validation failure on a file (zero claims survived).
|
||||||
|
"""
|
||||||
|
setup_logging(level=log_level)
|
||||||
|
import logging as _logging
|
||||||
|
from cyclone.reissue import (
|
||||||
|
parse_inputs, emit_outputs, zip_outputs, ig_correctness_check,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
|
||||||
|
log = _logging.getLogger("reissue-claims")
|
||||||
|
if not ig_correctness_check(logger=log):
|
||||||
|
click.echo("REFUSING to run: PATIENT_LOOP_DEFAULT_INCLUDED is True; "
|
||||||
|
"the 2000C patient loop is forbidden when SBR02='18'. "
|
||||||
|
"Restore the IG-correct default (False) in serialize_837.py.",
|
||||||
|
err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
payer_factories = {"co_medicaid": PayerConfig.co_medicaid}
|
||||||
|
if payer not in payer_factories:
|
||||||
|
raise click.BadParameter(f"Unknown payer {payer!r}. Choose from: {', '.join(payer_factories)}")
|
||||||
|
payer_config = payer_factories[payer]()
|
||||||
|
|
||||||
|
date_label = date or datetime.now().strftime("%Y-%m-%d")
|
||||||
|
out_dir = output_root / date_label / pipeline
|
||||||
|
if out_dir.exists() and not no_clean:
|
||||||
|
for p in out_dir.glob("tp*.x12"):
|
||||||
|
p.unlink()
|
||||||
|
log.info("cleaned prior .x12 outputs under %s", out_dir)
|
||||||
|
|
||||||
|
log.info("parse: input_dir=%s payer=%s", input_dir, payer)
|
||||||
|
claims, parse_errors = parse_inputs(input_dir, payer_config)
|
||||||
|
log.info("parse: %d claims read, %d file-level errors",
|
||||||
|
len(claims), len(parse_errors))
|
||||||
|
for src, err in parse_errors[:10]:
|
||||||
|
log.warning(" %s: %s", src, err)
|
||||||
|
if parse_errors and not claims:
|
||||||
|
click.echo(f"PARSE FAILED: zero claims survived; first error: {parse_errors[0][1]}", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
log.info("serialize: output_dir=%s", out_dir)
|
||||||
|
written = emit_outputs(
|
||||||
|
claims,
|
||||||
|
payer_config=payer_config,
|
||||||
|
output_dir=out_dir,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
summary_path = out_dir / "_serialize_summary.json"
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
summary_path.write_text(_json.dumps(summary, indent=2), encoding="utf-8")
|
||||||
|
log.info("serialize: %d files, %d bytes, summary=%s",
|
||||||
|
len(written), sum(p.stat().st_size for p in written), summary_path)
|
||||||
|
|
||||||
|
if zip_output:
|
||||||
|
zip_outputs(written, zip_output)
|
||||||
|
log.info("zip: %s (%d bytes)", zip_output, zip_output.stat().st_size)
|
||||||
|
|
||||||
|
click.echo(f"DONE files={len(written)} errors={len(parse_errors)} out={out_dir}")
|
||||||
|
if parse_errors:
|
||||||
|
sys.exit(2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Live-test:**
|
||||||
|
`cd backend && .venv/bin/python -m cyclone.cli reissue-claims --help`
|
||||||
|
should print the help with the long-form flags listed in §2 Decision 3.
|
||||||
|
- [ ] **Live-test end-to-end:**
|
||||||
|
`cd backend && .venv/bin/python -m cyclone.cli reissue-claims \
|
||||||
|
--input-dir /home/tyler/dev/cyclone/july8billing \
|
||||||
|
--date 2026-07-08 \
|
||||||
|
--output-root /tmp/sp24-smoke \
|
||||||
|
--zip-output /tmp/sp24-smoke.zip`
|
||||||
|
should produce `/tmp/sp24-smoke/2026-07-08/initial/` with 358 files
|
||||||
|
and `/tmp/sp24-smoke.zip` with 358 entries.
|
||||||
|
- [ ] **Autoreview:** confirm the help text is readable, exit codes
|
||||||
|
behave per the spec, and no click.UsageError leaks a
|
||||||
|
CycloneParseError stack trace.
|
||||||
|
- [ ] Commit: `feat(sp24): register cyclone reissue-claims CLI subcommand`.
|
||||||
|
|
||||||
|
## Task 5: write `test_cli_reissue_claims.py` (CliRunner smoke)
|
||||||
|
|
||||||
|
- [ ] Create `backend/tests/test_cli_reissue_claims.py` with:
|
||||||
|
- `test_reissue_claims_help_renders` — `CliRunner().invoke(main,
|
||||||
|
["reissue-claims", "--help"])` exits 0 and contains
|
||||||
|
`--input-dir`, `--output-root`, `--zip-output`,
|
||||||
|
`--no-clean`.
|
||||||
|
- `test_reissue_claims_happy_path` — writes a minimal 837P fixture
|
||||||
|
to `tmp_path/in`, runs the CLI with `--input-dir tmp_path/in
|
||||||
|
--output-root tmp_path/out --date 2026-07-08`, asserts exit 0
|
||||||
|
and 1+ .x12 file in `tmp_path/out/2026-07-08/initial/`.
|
||||||
|
- `test_reissue_claims_empty_input_exits_2` — empty input dir,
|
||||||
|
asserts exit 2 with "no claims" / "PARSE FAILED" in stderr.
|
||||||
|
- `test_reissue_claims_ig_correctness_guard_fires` —
|
||||||
|
`monkeypatch.setattr("cyclone.parsers.serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED",
|
||||||
|
True, raising=False)`, asserts exit 1 with `REFUSING to run`
|
||||||
|
in captured output. Use `monkeypatch.undo()` via a
|
||||||
|
`with`-block or `addfinalizer` so other tests are unaffected.
|
||||||
|
- `test_reissue_claims_zip_output_round_trip` — happy path + zip,
|
||||||
|
asserts the zip file exists and `ZipFile(path).testzip() is None`.
|
||||||
|
|
||||||
|
- [ ] **Live-test:**
|
||||||
|
`cd backend && .venv/bin/pytest tests/test_cli_reissue_claims.py -v`
|
||||||
|
(all new cases should pass).
|
||||||
|
- [ ] Commit: `test(sp24): CliRunner smoke tests for reissue-claims`.
|
||||||
|
|
||||||
|
## Task 6: write the IG-correctness regression test in `test_serialize_837.py`
|
||||||
|
|
||||||
|
- [ ] Add a new test to `backend/tests/test_serialize_837.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_serialize_837_patient_loop_default_is_false():
|
||||||
|
"""SP24: the IG-correct default for include_patient_loop is False.
|
||||||
|
|
||||||
|
Per X12 005010X222A1, the 2000C Patient Hierarchical Level
|
||||||
|
(HL*3 → PAT → NM1*QC) is REQUIRED only when Patient != Subscriber.
|
||||||
|
For SBR02 == "18" (Self-pay, the CO Medicaid IHSS workflow),
|
||||||
|
the 2000C loop MUST be absent. Flipping this default back to
|
||||||
|
True reintroduces the Edifabric 999 "2000C HL must be absent
|
||||||
|
when 2000B SBR02 = '18'" rejection — see the 2026-07-08 audit.
|
||||||
|
"""
|
||||||
|
from cyclone.parsers.serialize_837 import (
|
||||||
|
PATIENT_LOOP_DEFAULT_INCLUDED,
|
||||||
|
_build_subscriber_block,
|
||||||
|
)
|
||||||
|
from cyclone.parsers.models import Subscriber
|
||||||
|
|
||||||
|
assert PATIENT_LOOP_DEFAULT_INCLUDED is False, (
|
||||||
|
f"PATIENT_LOOP_DEFAULT_INCLUDED must be False (got {PATIENT_LOOP_DEFAULT_INCLUDED!r}); "
|
||||||
|
"the IG-correct serializer shape for SBR02='18' claims is 2000C-absent."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Construct a minimal subscriber + payer fixture and call
|
||||||
|
# _build_subscriber_block without the kwarg; verify no HL*3
|
||||||
|
# segment appears in the output.
|
||||||
|
from datetime import date
|
||||||
|
sub = Subscriber(
|
||||||
|
member_id="TEST123",
|
||||||
|
first_name="Test", last_name="Patient",
|
||||||
|
dob=date(1990, 1, 1), gender="F",
|
||||||
|
)
|
||||||
|
out = _build_subscriber_block(sub, "MC")
|
||||||
|
assert not any(seg.startswith("HL*3") for seg in out), (
|
||||||
|
f"HL*3 emitted with the IG-correct default; segments={out}"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Live-test:** `cd backend && .venv/bin/pytest
|
||||||
|
tests/test_serialize_837.py::test_serialize_837_patient_loop_default_is_false -v`
|
||||||
|
passes.
|
||||||
|
- [ ] **Autoreview:** try flipping the constant to `True` locally,
|
||||||
|
re-run the test, confirm it fails with the assertion message;
|
||||||
|
restore the constant.
|
||||||
|
- [ ] Commit: `test(sp24): regression test pinning PATIENT_LOOP_DEFAULT_INCLUDED to False`.
|
||||||
|
|
||||||
|
## Task 7: deprecation shim for `scripts/reissue_claims.py`
|
||||||
|
|
||||||
|
- [ ] Replace `scripts/reissue_claims.py` with a 1-line shim:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""DEPRECATED shim — use `cyclone reissue-claims` instead.
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`
|
||||||
|
for the canonical entry point.
|
||||||
|
"""
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
"`scripts/reissue_claims.py` is deprecated; use "
|
||||||
|
"`cyclone reissue-claims` (the Click subcommand in cli.py) "
|
||||||
|
"for the canonical offline reissue workflow. This shim will be "
|
||||||
|
"removed in a follow-up increment.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
from cyclone.cli import main
|
||||||
|
import sys
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Strip the script argv prefix so `python scripts/reissue_claims.py --help`
|
||||||
|
# behaves like `cyclone reissue-claims --help`. The Click group
|
||||||
|
# expects the subcommand as argv[1].
|
||||||
|
sys.exit(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Live-test:**
|
||||||
|
`python scripts/reissue_claims.py --help` prints the canonical
|
||||||
|
Click help for the `cyclone` group (and emits a DeprecationWarning).
|
||||||
|
- [ [ ] Commit: `refactor(sp24): deprecation shim for scripts/reissue_claims.py`.
|
||||||
|
|
||||||
|
## Task 8: `docs/RUNBOOK.md` operator-workflow entry
|
||||||
|
|
||||||
|
- [ ] Add a section under "Operator workflows" pointing at the new
|
||||||
|
CLI subcommand with a worked example:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Reissue claims (SP24)
|
||||||
|
|
||||||
|
The canonical workflow to rebuild a batch of 837P files into
|
||||||
|
single-claim, IG-correct X12 files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && .venv/bin/python -m cyclone.cli reissue-claims \
|
||||||
|
--input-dir <path-to-raw-837ps> \
|
||||||
|
--date 2026-07-08 \
|
||||||
|
--output-root dev/rebills \
|
||||||
|
--zip-output xxxclaims.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes: 0 = success; 1 = IG-correctness guard tripped or
|
||||||
|
unexpected error; 2 = parse / validation failure on a file.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] Commit: `docs(sp24): RUNBOOK entry for cyclone reissue-claims`.
|
||||||
|
|
||||||
|
## Task 9: autoreview (the merge gate)
|
||||||
|
|
||||||
|
- [ ] `cd backend && .venv/bin/pytest -q` — full backend suite passes.
|
||||||
|
- [ ] `cd backend && .venv/bin/pytest tests/test_serialize_837.py tests/test_reissue_core.py tests/test_cli_reissue_claims.py -v`
|
||||||
|
— all new + adjacent tests pass.
|
||||||
|
- [ ] `cd backend && .venv/bin/python -m cyclone.cli reissue-claims --help`
|
||||||
|
— CLI help renders cleanly.
|
||||||
|
- [ ] End-to-end live-test against `/home/tyler/dev/cyclone/july8billing/`
|
||||||
|
produces 358 files + a valid zip (per acceptance criterion #2 + #3).
|
||||||
|
- [ ] No `git status --short` shows any unexpected untracked files.
|
||||||
|
- [ ] No regressions in the existing `rebill-from-835` or
|
||||||
|
`submit-batch` CLIs (they don't import from `cyclone.reissue`).
|
||||||
|
|
||||||
|
## Task 10: merge to `main`
|
||||||
|
|
||||||
|
- [ ] `git checkout main && git merge --no-ff --no-squash --no-rebase
|
||||||
|
sp24-reissue-claims -m "merge: SP24 reissue-claims CLI + IG-correctness
|
||||||
|
regression test into main"`
|
||||||
|
- [ ] `git log --oneline main -1` shows the merge commit with the
|
||||||
|
expected subject.
|
||||||
|
- [ ] `git push origin main` only after the operator has reviewed
|
||||||
|
and approved the merge (per cyclone-spec — the merge commit is
|
||||||
|
the audit trail; the operator must approve).
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Sub-project 24 — Reissue Claims CLI + IG-Correctness Regression Test: Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-07-08
|
||||||
|
**Status:** Draft, awaiting user sign-off
|
||||||
|
**Branch:** `sp24-reissue-claims`
|
||||||
|
**Aesthetic direction:** No new UI (operator-only CLI; the workflow is the on-call page already documented in `docs/RUNBOOK.md`).
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
On 2026-07-08 the operator rebuilt the four dzinesco July-8 837P batches
|
||||||
|
into 358 single-claim, IG-correct X12 files (zipped at `/home/tyler/dev/cyclone/xxxclaims.zip`)
|
||||||
|
using a one-shot script at `scripts/reissue_claims.py`. The script solved
|
||||||
|
an immediate crisis: an Edifabric 999 had rejected the original four
|
||||||
|
multi-claim files because the serializer was unconditionally emitting the
|
||||||
|
Loop 2000C patient hierarchy (`HL*3 → PAT*01 → NM1*QC`) when
|
||||||
|
`SBR02 == "18"` (Self-pay), which is forbidden by X12 005010X222A1.
|
||||||
|
|
||||||
|
The serializer default was fixed in `serialize_837.py` (see commit
|
||||||
|
`ee1a397` on `sp41-inwindow-rebill-pipeline`), but the workflow that
|
||||||
|
proved the fix — parse + per-claim re-serialize + HCPF-spec filename +
|
||||||
|
zip — still lives in a script the rest of the cyclone pipeline doesn't
|
||||||
|
know about. SP24 brings that workflow into the canonical CLI surface as
|
||||||
|
`cyclone reissue-claims`, behind a regression test that locks in the
|
||||||
|
IG-correct serializer default forever so a future code change cannot
|
||||||
|
silently reintroduce the same 999 rejection.
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
|
||||||
|
- A new subpackage `backend/src/cyclone/reissue/` containing the pure
|
||||||
|
functions `parse_inputs`, `emit_outputs`, `zip_outputs`, and
|
||||||
|
`ig_correctness_check`. No Click imports inside the core module —
|
||||||
|
it accepts plain Python types so the CLI is a thin wrapper and the
|
||||||
|
functions are unit-testable without `click.testing.CliRunner`.
|
||||||
|
- A new CLI subcommand `cyclone reissue-claims` registered against
|
||||||
|
the existing `@main` Click group in `backend/src/cyclone/cli.py`,
|
||||||
|
following the conventions in `cyclone-cli` (long-form flags,
|
||||||
|
exit codes 0/1/2, `click.echo(..., err=True)` for errors).
|
||||||
|
- A new module-level public constant in `serialize_837.py`:
|
||||||
|
`PATIENT_LOOP_DEFAULT_INCLUDED = False`, exported in the module's
|
||||||
|
`__all__`. The default is read by the IG-correctness guard instead
|
||||||
|
of `inspect.signature`-ing a private helper, so a future rename of
|
||||||
|
`_build_subscriber_block` cannot silently break the guard.
|
||||||
|
- A regression test
|
||||||
|
`test_serialize_837_patient_loop_default_is_false` in
|
||||||
|
`backend/tests/test_serialize_837.py` that asserts the constant is
|
||||||
|
`False` and that calling `_build_subscriber_block` with no
|
||||||
|
`include_patient_loop` kwarg emits `HL*2 child_count=0` (no `HL*3`).
|
||||||
|
- Smoke tests under `backend/tests/test_cli_reissue_claims.py` covering
|
||||||
|
the help screen, an empty-input path, a happy-path re-issue, and
|
||||||
|
the IG-correctness guard firing when the constant is monkeypatched.
|
||||||
|
- Pure-unit tests under `backend/tests/test_reissue_core.py` covering
|
||||||
|
parse_inputs (AppleDouble filter, per-file failure tolerance),
|
||||||
|
emit_outputs (HCPF-spec filenames, unique-per-millisecond, sorted
|
||||||
|
by claim_id), zip_outputs (round-trip integrity check), and
|
||||||
|
ig_correctness_check (fires on mismatch, silent on match).
|
||||||
|
- A deprecation shim at `scripts/reissue_claims.py` that re-exports
|
||||||
|
the new CLI subcommand as a thin wrapper with a WARNING printed at
|
||||||
|
import time telling the operator `cyclone reissue-claims` is the
|
||||||
|
canonical entry. The shim is removed in a follow-up increment.
|
||||||
|
- A `docs/RUNBOOK.md` entry under "Operator workflows" pointing at
|
||||||
|
the new CLI subcommand with a worked example against the dzinesco
|
||||||
|
July-8 billing layout.
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
|
||||||
|
- New UI exposure for the reissue workflow. The CLI is the operator's
|
||||||
|
on-call surface today; a dashboard widget is a future increment.
|
||||||
|
- A bulk-ingest pipeline for the reissue path. The operator's flow is
|
||||||
|
point-in-time ("rebuild yesterday's 358 files from this batch"),
|
||||||
|
not a streaming one. `cyclone bulk-ingest` already handles the
|
||||||
|
streaming case for inbound files; the outbound reissue path stays
|
||||||
|
explicit.
|
||||||
|
- The SFTP-upload step. `reissue-claims` writes files to a local
|
||||||
|
directory (and optionally zips them). The operator moves the
|
||||||
|
resulting zip to Gainwell via their SFTP client per the
|
||||||
|
"Manual SFTP mode" posture documented in `docs/RUNBOOK.md`.
|
||||||
|
- An automatic regeneration of historical files. The four dzinesco
|
||||||
|
July-8 batches are the immediate operator concern; running
|
||||||
|
`reissue-claims` against older batches is a deliberate operator
|
||||||
|
action with the explicit `--input-dir` path.
|
||||||
|
- Edifabric validation. The original script did not gate through
|
||||||
|
Edifabric (the July-8 files were validated by the operator's manual
|
||||||
|
999 audit). A follow-up SP wires the SP41 SP40 Edifabric gate into
|
||||||
|
the reissue path; this increment keeps the surface minimal.
|
||||||
|
|
||||||
|
## 2. Decisions (locked during brainstorming)
|
||||||
|
|
||||||
|
1. **Subpackage layout, not a single `cli.py` block.** The other
|
||||||
|
cross-cutting workflows (`cyclone.rebill`, `cyclone.submission`,
|
||||||
|
`cyclone.bulk_ingest`) follow the `cyclone/<verb>/` layout. Putting
|
||||||
|
the reissue logic inside `cli.py` directly — like
|
||||||
|
`rebill-from-835` does — would make `cli.py` grow past 2,200 LOC
|
||||||
|
(already at 2,054) and force the unit tests to live in the
|
||||||
|
CLI-flavored `test_cli_*.py` file. The subpackage layout keeps the
|
||||||
|
pure functions unit-testable without Click, matches the rest of the
|
||||||
|
codebase, and lets `cli.py` stay a thin registration surface.
|
||||||
|
|
||||||
|
2. **Public `PATIENT_LOOP_DEFAULT_INCLUDED` constant, not
|
||||||
|
`inspect.signature` introspection.** The original script
|
||||||
|
introspected the private `_build_subscriber_block` helper via
|
||||||
|
`inspect.signature`, which is fragile against rename / refactor.
|
||||||
|
Promoting the default to a named public constant in
|
||||||
|
`serialize_837.py` and exporting it in `__all__` gives the guard a
|
||||||
|
stable handle that survives any future refactor of the helper.
|
||||||
|
|
||||||
|
3. **`cyclone reissue-claims` long-form CLI flags.** `--input-dir`,
|
||||||
|
`--output-root`, `--date`, `--pipeline`, `--payer`,
|
||||||
|
`--sender-id`, `--receiver-id`, `--submitter-name`,
|
||||||
|
`--submitter-contact-name`, `--submitter-contact-email`,
|
||||||
|
`--receiver-name`, `--zip-output`, `--no-clean`, `--log-level`.
|
||||||
|
Long-form, not positional, so the destructive surface is
|
||||||
|
self-documenting. Defaults match the dzinesco canonical sender
|
||||||
|
(`11525703`) / receiver (`COMEDASSISTPROG`) so the typical
|
||||||
|
invocation is just `cyclone reissue-claims --input-dir <batch>`.
|
||||||
|
|
||||||
|
4. **IG-correctness guard fires on every CLI invocation.** Not gated
|
||||||
|
behind a `--strict` flag — the constant is part of the canonical
|
||||||
|
contract the spec requires. The guard's only job is to fail loud if
|
||||||
|
the constant flips to `True` in a future refactor; running it on
|
||||||
|
every call is the cheapest possible regression test.
|
||||||
|
|
||||||
|
5. **Exit codes 0 / 1 / 2 mirror the existing CLI conventions.**
|
||||||
|
`0` = success (all files written, zip optional); `1` = IG-correctness
|
||||||
|
guard tripped OR a configuration / unexpected error; `2` =
|
||||||
|
parse / validation failure on a file (per-file failures reported
|
||||||
|
in stdout, but other files continue; only exits 2 if **zero**
|
||||||
|
claims survived).
|
||||||
|
|
||||||
|
6. **Deprecation shim, not deletion.** The original
|
||||||
|
`scripts/reissue_claims.py` stays as a 1-line shim that prints a
|
||||||
|
WARNING at import time and re-exports the new module's `main()`
|
||||||
|
function. The shim is removed in a follow-up increment once the
|
||||||
|
operator's muscle memory has switched over.
|
||||||
|
|
||||||
|
7. **The CLI subcommand does NOT call `cyclone.submission.submit_file`.**
|
||||||
|
Reissue is an offline-rebuild workflow; it produces local X12
|
||||||
|
files for the operator to inspect / upload manually. Wiring the
|
||||||
|
SFTP path would conflate "regenerate clean files" with
|
||||||
|
"submit them to Gainwell", and the operator wants the inspection
|
||||||
|
step in between.
|
||||||
|
|
||||||
|
8. **HCPF-spec filename unchanged.** The canonical
|
||||||
|
`cyclone.edi.filenames.build_outbound_filename(sender_id, "837P")`
|
||||||
|
produces the file names already in use (`tp11525703-837P-...-1of1.x12`);
|
||||||
|
no change to the filename format. Per-claim 1 ms timestamp offsets
|
||||||
|
(via `datetime.now(tz=ZoneInfo("America/Denver"))` + per-claim
|
||||||
|
`timedelta(milliseconds=i)`) guarantee uniqueness within a batch.
|
||||||
|
|
||||||
|
## 3. Threat model
|
||||||
|
|
||||||
|
This increment does not change the auth boundary or the network posture.
|
||||||
|
The threat model is unchanged from SP24 (this spec): the auth boundary
|
||||||
|
is HTTP (login required, bcrypt + HttpOnly session cookie); the
|
||||||
|
file-system threat is local-only and handled by SQLCipher at rest and
|
||||||
|
the macOS Keychain. The CLI subcommand runs against the operator's
|
||||||
|
local environment only — there is no HTTP surface, no auth gate, no
|
||||||
|
network call. The IG-correctness guard is a static check against the
|
||||||
|
serializer's documented default; it does not change the security
|
||||||
|
posture.
|
||||||
|
|
||||||
|
The regression test in `test_serialize_837.py` is structural: it pins
|
||||||
|
a constant to a specific value. If a future refactor flips the constant
|
||||||
|
back to `True` (the broken pattern), the test fails, the CLI guard
|
||||||
|
fires, and the operator sees a clear `REFUSING to run` log line before
|
||||||
|
any X12 byte is emitted.
|
||||||
|
|
||||||
|
## 4. Test impact
|
||||||
|
|
||||||
|
Following the `cyclone-tests` conventions:
|
||||||
|
|
||||||
|
- **Unit tests (pure-unit):**
|
||||||
|
- `backend/tests/test_reissue_core.py` — covers `parse_inputs`
|
||||||
|
(AppleDouble metadata filter, per-file failure tolerance, claim
|
||||||
|
validation error path), `emit_outputs` (HCPF-spec filename
|
||||||
|
format, sorted by `claim_id`, per-claim unique timestamps,
|
||||||
|
directory creation), `zip_outputs` (round-trip integrity via
|
||||||
|
`testzip()`), and `ig_correctness_check` (fires on mismatch,
|
||||||
|
silent on match). Each test uses `tmp_path` + a fixture
|
||||||
|
`*.x12` file containing a minimal valid 837P — no prodfiles
|
||||||
|
dropped into `fixtures/`.
|
||||||
|
- `backend/tests/test_serialize_837.py` — adds
|
||||||
|
`test_serialize_837_patient_loop_default_is_false` asserting
|
||||||
|
that `serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED is False` and
|
||||||
|
that calling `_build_subscriber_block` with no kwargs emits
|
||||||
|
`HL*2` with `child_count = 0` (no `HL*3`). This test is the
|
||||||
|
long-term regression guard; flipping it should require an
|
||||||
|
explicit PR-level discussion.
|
||||||
|
- **Integration tests (CLI smoke):**
|
||||||
|
- `backend/tests/test_cli_reissue_claims.py` — `CliRunner`-based
|
||||||
|
smoke tests for `cyclone reissue-claims`. Covers:
|
||||||
|
- `--help` exits 0 with the expected long-form flags listed.
|
||||||
|
- happy path: a single-file input dir produces the expected
|
||||||
|
number of output files under `tmp_path/out`.
|
||||||
|
- empty input dir: exits 2 with a clear "no claims found" message.
|
||||||
|
- IG-correctness guard: monkeypatches
|
||||||
|
`serialize_837.PATIENT_LOOP_DEFAULT_INCLUDED` to `True`, asserts
|
||||||
|
the CLI exits 1 with the `REFUSING to run` log line in the
|
||||||
|
captured stderr.
|
||||||
|
- **Fixture additions:** none. The CLI smoke test synthesizes a
|
||||||
|
minimal 837P file inline via `parse_837` round-tripping a stub
|
||||||
|
`ClaimOutput`. No prodfiles are dropped into `fixtures/`.
|
||||||
|
- **Frontend tests:** none (no UI in this increment).
|
||||||
|
- **Live-test smoke:** a canonical end-to-end run against
|
||||||
|
`/home/tyler/dev/cyclone/july8billing/` to confirm the CLI produces
|
||||||
|
the same 358 files + zip as the original script. The smoke command
|
||||||
|
is documented at the top of `/tmp/refactor-cyclone.md`.
|
||||||
|
|
||||||
|
## 5. Spec & plan anchors
|
||||||
|
|
||||||
|
- **Spec anchor (this file):**
|
||||||
|
`docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md`
|
||||||
|
- **Plan anchor:** `docs/superpowers/plans/2026-07-08-cyclone-reissue-claims.md`
|
||||||
|
- **Related serializer fix:** commit `ee1a397` on
|
||||||
|
`sp41-inwindow-rebill-pipeline` (already merged to `main`),
|
||||||
|
which flipped the `_build_subscriber_block.include_patient_loop`
|
||||||
|
default from `True` to `False` and updated the docstring.
|
||||||
|
- **Original operator workflow:** `scripts/reissue_claims.py` (the
|
||||||
|
one-shot script this increment supersedes).
|
||||||
|
- **Edifabric 999 that surfaced the bug:** `/tmp/ig_check.log` (the
|
||||||
|
operator's 999 audit reference for the IG rule).
|
||||||
|
|
||||||
|
## 6. Acceptance criteria
|
||||||
|
|
||||||
|
1. `cyclone reissue-claims --help` exits 0 and lists the long-form
|
||||||
|
flags listed in §2 Decision 3.
|
||||||
|
2. `cyclone reissue-claims --input-dir /home/tyler/dev/cyclone/july8billing --date 2026-07-08 --output-root /tmp/sp24-acceptance-out`
|
||||||
|
produces exactly 358 `.x12` files under
|
||||||
|
`/tmp/sp24-acceptance-out/2026-07-08/initial/`, plus a
|
||||||
|
`_serialize_summary.json` sidecar with one row per file.
|
||||||
|
3. `cyclone reissue-claims --input-dir ... --zip-output /tmp/sp24-acceptance.zip`
|
||||||
|
produces a zip whose `testzip()` returns `None` (integrity OK)
|
||||||
|
and which contains exactly 358 entries.
|
||||||
|
4. The IG-correctness guard exits 1 with the `REFUSING to run` log
|
||||||
|
line when the constant is monkeypatched to `True`. Verified by the
|
||||||
|
`test_cli_reissue_claims.py` smoke test.
|
||||||
|
5. `pytest backend/tests/test_reissue_core.py -v` exits 0 with all
|
||||||
|
cases passing.
|
||||||
|
6. `pytest backend/tests/test_cli_reissue_claims.py -v` exits 0 with
|
||||||
|
all cases passing.
|
||||||
|
7. `pytest backend/tests/test_serialize_837.py -v` exits 0 with the
|
||||||
|
new `test_serialize_837_patient_loop_default_is_false` case
|
||||||
|
passing alongside the existing 56 cases.
|
||||||
|
8. `pytest` (full backend suite) exits 0; no regressions.
|
||||||
|
9. The CLI runs without requiring a Keychain entry or DB connection —
|
||||||
|
`reissue-claims` is a pure parser + serializer + filesystem
|
||||||
|
workflow. This matches the original script's posture.
|
||||||
|
10. After the merge to `main`, `scripts/reissue_claims.py` is a
|
||||||
|
1-line shim that prints a WARNING at import time telling the
|
||||||
|
operator `cyclone reissue-claims` is the canonical entry. The
|
||||||
|
shim does NOT duplicate the argparse surface.
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
"""DEPRECATED shim — use ``cyclone reissue-claims`` instead.
|
||||||
|
|
||||||
|
This script previously hosted the operator's one-shot workflow for
|
||||||
|
rebuilding the 2026-07-08 dzinesco July-8 batches into 358 IG-correct
|
||||||
|
single-claim X12 files. The workflow now lives in
|
||||||
|
:mod:`cyclone.reissue` and is exposed as the Click subcommand
|
||||||
|
``cyclone reissue-claims``. See
|
||||||
|
``docs/superpowers/specs/2026-07-08-cyclone-reissue-claims-design.md``
|
||||||
|
for the SP24 rationale.
|
||||||
|
|
||||||
|
Behavior preserved: ``python scripts/reissue_claims.py --help``
|
||||||
|
shows the canonical ``cyclone --help`` group help (then ``reissue-claims
|
||||||
|
--help`` follows the same Click flow as ``cyclone reissue-claims --help``).
|
||||||
|
A ``DeprecationWarning`` is emitted at import time so the operator's
|
||||||
|
tests / cron jobs catch the migration.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
warnings.warn(
|
||||||
|
"`scripts/reissue_claims.py` is deprecated; use "
|
||||||
|
"`cyclone reissue-claims` (the Click subcommand) for the canonical "
|
||||||
|
"offline reissue workflow. This shim is removed in a follow-up SP.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
from cyclone.cli import main # noqa: E402 (import after the warning)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# The Click group expects the subcommand as argv[1]; strip the
|
||||||
|
# script argv prefix so `python scripts/reissue_claims.py --help`
|
||||||
|
# behaves like `cyclone --help`.
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user