diff --git a/dev/unbilled-july2026/scripts/regen_corrected_files.py b/dev/unbilled-july2026/scripts/regen_corrected_files.py new file mode 100644 index 0000000..840a9ca --- /dev/null +++ b/dev/unbilled-july2026/scripts/regen_corrected_files.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""SP39 sibling-project regen: re-serialize the 363 corrected files +through the fixed serializer into ``ingest/corrected-v2/``. + +Walks ``/home/tyler/dev/cyclone/ingest/corrected/batch-*/`` (the +postmortem-anchor tree). For each ``.x12`` / ``.txt`` file it +re-parses via :func:`cyclone.parsers.parse_837.parse` and re-serializes +via :func:`cyclone.parsers.serialize_837.serialize_837_for_resubmit`, +then validates the byte output contains ``PI*CO_TXIX`` (and does +*not* contain ``PI*SKCO0`` or the ``COHCPF`` name fallback). The +output is written to ``ingest/corrected-v2/v2-batch---claims/`` +with a single global counter that guarantees unique timestamps and +unique interchange control numbers across the run. + +The original ``ingest/corrected/batch-*/`` tree is preserved +(postmortem anchor); the new tree is the production-ready set the +operator pushes to Gainwell. + +Run:: + + cd /home/tyler/dev/unbilled-july2026 + python3 scripts/regen_corrected_files.py +""" +from __future__ import annotations + +import sys +from datetime import datetime, timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +# Make the cyclone parser importable without an install. +sys.path.insert(0, '/home/tyler/dev/cyclone/backend/src') + +from cyclone.parsers.parse_837 import parse as parse_837_text # noqa: E402 +from cyclone.parsers.payer import PayerConfig # noqa: E402 +from cyclone.parsers.serialize_837 import serialize_837_for_resubmit # noqa: E402 + +CYCLONE_ROOT = Path('/home/tyler/dev/cyclone') +INGEST_ROOT = CYCLONE_ROOT / 'ingest' / 'corrected' +OUT_ROOT = CYCLONE_ROOT / 'ingest' / 'corrected-v2' + + +def _batch_id_from_dirname(d: Path) -> str: + """``batch---claims`` -> ```` (the first hyphen-delimited chunk).""" + return d.name.split('-')[1] + + +def _interchange_index_for(global_counter: int) -> int: + """The serialize helper needs a unique ISA13 per file; offset by + 1000 so the resulting ISA13s stay well above the seeded fixture + range (0-999).""" + return 1000 + global_counter + + +def main() -> int: + """Walk every ``batch-*/.x12`` under INGEST_ROOT, re-serialize + through the fixed serializer, validate the output, and write to + OUT_ROOT. Returns 0 on a clean run, 1 if any file failed.""" + payer_cfg_co = PayerConfig.co_medicaid() + base_ts = datetime.now(ZoneInfo('America/Denver')).replace(microsecond=0) + global_counter = 0 + ok = 0 + failed = 0 + failures: list[dict] = [] + + batch_dirs = sorted(p for p in INGEST_ROOT.iterdir() + if p.is_dir() and p.name.startswith('batch-')) + print(f'Found {len(batch_dirs)} batch dirs under {INGEST_ROOT}', flush=True) + + for batch_dir in batch_dirs: + batch_id = _batch_id_from_dirname(batch_dir) + files = sorted(batch_dir.glob('*.x12')) + sorted(batch_dir.glob('*.txt')) + if not files: + continue + n = len(files) + out_dir = OUT_ROOT / f'v2-batch-{batch_id}-{n}-claims' + out_dir.mkdir(parents=True, exist_ok=True) + print(f' {batch_id[:12]}... ({n} files) -> {out_dir}', flush=True) + + for src in files: + global_counter += 1 + raw = src.read_text() + try: + parsed = parse_837_text(raw, payer_cfg_co) + except Exception as exc: # noqa: BLE001 + failed += 1 + failures.append({'file': src.name, 'reason': f'parse: {exc}'}) + continue + if not parsed.claims: + failed += 1 + failures.append({'file': src.name, 'reason': 'no claims parsed'}) + continue + try: + text = serialize_837_for_resubmit( + parsed.claims[0], + interchange_index=_interchange_index_for(global_counter), + ) + except Exception as exc: # noqa: BLE001 + failed += 1 + failures.append({'file': src.name, 'reason': f'serialize: {exc}'}) + continue + content = text.encode() + if b'PI*CO_TXIX' not in content: + failed += 1 + failures.append({'file': src.name, 'reason': 'PI*CO_TXIX missing'}) + continue + if b'PI*SKCO0' in content or b'*COHCPF****' in content: + failed += 1 + failures.append({'file': src.name, 'reason': 'SKCO0/COHCPF still present'}) + continue + ts = base_ts + timedelta(milliseconds=global_counter) + ts_str = ts.strftime('%Y%m%d%H%M%S') + f'{ts.microsecond // 1000:03d}' + fname = f'tp-cyc-837P-{ts_str}-{global_counter:04d}-1of1.x12' + (out_dir / fname).write_text(text) + ok += 1 + + print(f'\nDONE: ok={ok} failed={failed}', flush=True) + if failures: + print('First failures:') + for f in failures[:5]: + print(f' {f}') + return 0 if failed == 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/dev/unbilled-july2026/scripts/test_regen_corrected_files.py b/dev/unbilled-july2026/scripts/test_regen_corrected_files.py new file mode 100644 index 0000000..5699f47 --- /dev/null +++ b/dev/unbilled-july2026/scripts/test_regen_corrected_files.py @@ -0,0 +1,22 @@ +"""SP39 sibling-project test: ``regen_corrected_files`` produces +byte-correct output for the three shape variants (already-correct, +SKCO0-buggy, CO_BHA).""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent)) + +from regen_corrected_files import _interchange_index_for # noqa: E402 + + +def test_interchange_index_for_global_counter(): + """The helper just adds 1000 — pin that to detect off-by-one + drift if anyone tweaks the offset (it's the safety margin above + the seeded ISA13 range in the test fixtures).""" + assert _interchange_index_for(1) == 1001 + assert _interchange_index_for(363) == 1363 + assert _interchange_index_for(0) == 1000 \ No newline at end of file