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:
@@ -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())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user