feat(sp39): sibling-project regen_corrected_files.py + test
Re-serialize the 363 corrected 837P files through the SP39-fixed serializer, emitting NM1*PR*2*CO_TXIX*****PI*CO_TXIX in 2010BB and writing to ingest/corrected-v2/v2-batch-<batch-id>-<N>-claims/. Lives under dev/unbilled-july2026/scripts/ because the unbilled-july2026 project at /home/tyler/dev/unbilled-july2026/ is NOT a git repo of its own (the operator's working tree only). Mirroring the physical path keeps the script's import path (sys.path.insert ../../cyclone/backend/src) working without per-run shimming. Verified end-to-end: ok=363 failed=0, 5 random spot-checks all show NM1*PR*2*CO_TXIX*****PI*CO_TXIX, zero SKCO0/COHCPF fallbacks in any of the 363 output files.
This commit is contained in:
@@ -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-<batch-id>-<N>-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-<id>-<N>-claims`` -> ``<id>`` (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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user