fix(sp40): use canonical build_outbound_filename for HCPF-spec filenames

The regen script was emitting filenames like 'tp-cyc-837P-20260707183154271-0271-1of1.x12'
which violate the HCPF X12 File Naming Standards Quick Guide. The spec requires:

    tp{tpid}-{transaction_type}-yyyymmddhhmmssSSS-1of1.x12

— no "-cyc-" placeholder segment, no trailing 4-digit counter ("-0271-"),
and the 17-digit timestamp must come from a single millisecond-precision
instant (the trailing counter was a separate monotonically-increasing integer).

Route filename generation through the canonical
cyclone.edi.filenames.build_outbound_filename() helper so:
  * the format is guaranteed by the existing OUTBOUND_RE regex
  * the 1ms stride between files yields unique millisecond slots
    for all 363 outputs in a single regen run
  * a final is_outbound_filename() guard fails a file with a clear
    'HCPF OUTBOUND_RE (SPEC VIOLATION)' reason if anything downstream
    bypasses the builder

Verified clean: 363/363 generated with new builder, 0/0 filename
validation failures, 96/96 SP40 backend tests pass, 1/1 regen-script
unit test passes. Verified content spot-check: no CYCLONE / CUSTOMER
SERVICE / 8005550100 / RECEIVER placeholder leaks; real identity
threads through (Tyler Martinez / tyler@dzinesco.com / COLORADO MEDICAL
ASSISTANCE PROGRAM / COMEDASSISTPROG).
This commit is contained in:
Nora
2026-07-07 18:40:34 -06:00
parent e7098b99b2
commit acf3b506ec
@@ -21,10 +21,20 @@ postmortem-anchor tree). For each ``.x12`` / ``.txt`` file it:
5. Validates the byte output contains the SP40-safe PER (``PER*IC*Tyler 5. Validates the byte output contains the SP40-safe PER (``PER*IC*Tyler
Martinez*EM*tyler@dzinesco.com``) and SBR (``SBR*P*18*******MC``). Martinez*EM*tyler@dzinesco.com``) and SBR (``SBR*P*18*******MC``).
**Filename format** — HCPF X12 File Naming Standards Quick Guide:
``tp{tpid}-{transaction_type}-yyyymmddhhmmssSSS-1of1.x12``
(example: ``tp11525703-837P-20260708132243505-1of1.x12``).
Built via :func:`cyclone.edi.filenames.build_outbound_filename` (the
single canonical HCPF outbound builder) so the 17-digit
millisecond-precision timestamp + "1of1" sequence + ".x12" extension
are guaranteed. Each file gets a unique millisecond slot by spreading
the batch 1ms apart over the run, so 363 files per regen land in
~363ms with millisecond-level uniqueness — well within the
single-second "1of1" window the spec allows.
The output is written to The output is written to
``ingest/corrected-v2/v2-batch-<batch-id>-<N>-claims/`` with a single ``ingest/corrected-v2/v2-batch-<batch-id>-<N>-claims/``.
global counter that guarantees unique timestamps and unique
interchange control numbers across the run.
The original ``ingest/corrected/batch-*/`` tree is preserved The original ``ingest/corrected/batch-*/`` tree is preserved
(postmortem anchor); the new tree is the production-ready set the (postmortem anchor); the new tree is the production-ready set the
@@ -33,20 +43,18 @@ operator pushes to Gainwell.
Run:: Run::
cd /home/tyler/dev/unbilled-july2026 cd /home/tyler/dev/unbilled-july2026
python3 scripts/regen_corrected_files.py /home/tyler/dev/cyclone/backend/.venv/bin/python scripts/regen_corrected_files.py
""" """
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
# Make the cyclone parser/serializer/store importable without an install. # Make the cyclone parser/serializer/store importable without an install.
sys.path.insert(0, '/home/tyler/dev/cyclone/backend/src') sys.path.insert(0, '/home/tyler/dev/cyclone/backend/src')
from cyclone import db as cycl_db # noqa: E402 from cyclone import db as cycl_db # noqa: E402
from cyclone.edi.filenames import build_outbound_filename # noqa: E402
from cyclone.parsers.parse_837 import parse as parse_837_text # noqa: E402 from cyclone.parsers.parse_837 import parse as parse_837_text # noqa: E402
from cyclone.parsers.payer import PayerConfig # noqa: E402 from cyclone.parsers.payer import PayerConfig # noqa: E402
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit # noqa: E402 from cyclone.parsers.serialize_837 import serialize_837_for_resubmit # noqa: E402
@@ -122,16 +130,27 @@ def main() -> int:
"""Walk every ``batch-*/.x12`` under INGEST_ROOT, re-serialize """Walk every ``batch-*/.x12`` under INGEST_ROOT, re-serialize
through the fixed serializer with real submitter + receiver info, through the fixed serializer with real submitter + receiver info,
validate the output, and write to OUT_ROOT. Returns 0 on a clean validate the output, and write to OUT_ROOT. Returns 0 on a clean
run, 1 if any file failed or validation missed.""" run, 1 if any file failed or validation missed.
Filenames follow the HCPF X12 File Naming Standards Quick Guide:
``tp{tpid}-837P-yyyymmddhhmmssSSS-1of1.x12``. The 17-digit
millisecond-precision timestamp comes from
:func:`build_outbound_filename` so the spec format is guaranteed
(no ``-cyc-`` segment, no trailing ``-{counter}-`` suffix, no
abbreviation). Each file's timestamp is offset by 1ms so all 363
files land in distinct millisecond slots.
"""
payer_cfg_co = PayerConfig.co_medicaid() payer_cfg_co = PayerConfig.co_medicaid()
resubmit_kwargs = _load_resubmit_kwargs() resubmit_kwargs = _load_resubmit_kwargs()
tpid = resubmit_kwargs["sender_id"] # ISA06 — used in the filename
assert tpid.isdigit(), f"TPID must be digits, got {tpid!r}"
print("Resubmit kwargs (real submitter + receiver):") print("Resubmit kwargs (real submitter + receiver):")
for k, v in resubmit_kwargs.items(): for k, v in resubmit_kwargs.items():
print(f" {k}={v!r}") print(f" {k}={v!r}")
print(flush=True) print(f"Filename template: tp{tpid}-837P-yyyymmddhhmmssSSS-1of1.x12")
print(f" (built via cyclone.edi.filenames.build_outbound_filename)", flush=True)
base_ts = datetime.now(ZoneInfo('America/Denver')).replace(microsecond=0)
global_counter = 0 global_counter = 0
ok = 0 ok = 0
failed = 0 failed = 0
@@ -153,6 +172,13 @@ def main() -> int:
for src in files: for src in files:
global_counter += 1 global_counter += 1
# Each file's filename = real HCPF format with a unique
# millisecond slot (1ms stride from now onward).
fname = build_outbound_filename(
tpid, "837P",
now_mt=_now_mt_plus_ms(global_counter),
)
raw = src.read_text() raw = src.read_text()
try: try:
parsed = parse_837_text(raw, payer_cfg_co) parsed = parse_837_text(raw, payer_cfg_co)
@@ -213,9 +239,16 @@ def main() -> int:
failed += 1 failed += 1
failures.append({'file': src.name, 'reason': 'placeholder strings leaked'}) failures.append({'file': src.name, 'reason': 'placeholder strings leaked'})
continue continue
ts = base_ts + timedelta(milliseconds=global_counter) # Final HCPF-spec filename validation guard (cheaper to
ts_str = ts.strftime('%Y%m%d%H%M%S') + f'{ts.microsecond // 1000:03d}' # re-check the filename than to discover a malformed
fname = f'tp-cyc-837P-{ts_str}-{global_counter:04d}-1of1.x12' # filename on upload to Gainwell).
from cyclone.edi.filenames import is_outbound_filename
if not is_outbound_filename(fname):
failed += 1
failures.append({'file': src.name,
'reason': f'filename {fname!r} does not match '
f'HCPF OUTBOUND_RE (SPEC VIOLATION)'})
continue
(out_dir / fname).write_text(text) (out_dir / fname).write_text(text)
ok += 1 ok += 1
@@ -227,5 +260,16 @@ def main() -> int:
return 0 if failed == 0 else 1 return 0 if failed == 0 else 1
def _now_mt_plus_ms(ms_offset: int):
"""Return the current Mountain Time as a tz-aware datetime, offset
by ``ms_offset`` milliseconds. Used to spread the regen run across
distinct millisecond slots so each file's filename is unique
within the spec's 17-digit yyyymmddhhmmssSSS window."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
return datetime.now(ZoneInfo("America/Denver")) + timedelta(milliseconds=ms_offset)
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) sys.exit(main())