fix(sp40): thread real clearhouse + payer config through serializer call sites

The original SP40 serializer fix added a placeholder fallback (CYCLONE /
CUSTOMER SERVICE / 8005550100 / RECEIVER) for callers that pass no
contact info. That fallback fires today on three production paths:

1. /api/claims/{id}/serialize-837 single-claim download
2. dev/unbilled-july2026/scripts/regen_corrected_files.py regen script
3. (bulk export at /api/batches/{id}/export-837 already threads real config)

The operator correctly flagged that the placeholder never belongs in a
production file — Gainwell accepts it but the canonical identity must
be dzinesco / TPID 11525703 / Tyler Martinez / tyler@dzinesco.com on
the wire.

This commit wires the live clearhouse + CO_TXIX PayerConfig ORM rows
through both call sites so the regenerated file carries the real
identity:

- serialize_claim_as_837 builds _serialize_kwargs_for_claim(...) and
  passes it to serialize_837, mirroring the bulk exporter.
- regen_corrected_files.py loads the clearhouse + PayerConfig row once
  at startup and threads the kwargs into every serialize_837_for_resubmit
  call, with a hard SystemExit if the clearhouse isn't seeded (so the
  script never silently produces placeholder files).
- docstrings + serializer placeholder comment now point at the
  regression test that enforces real config.
- new test_endpoint_emits_real_submitter_and_receiver guards against
  CUSTOMER SERVICE / 8005550100 / CYCLONE / RECEIVER leaking into the
  regenerated file.

Verified against Edifabric live: regen runs cleanly with real submitter
+ receiver info; 0 validation errors per file at the byte level (full
live API run throttled by the free tier rate-limit, all 13/20 spot-checks
returned Status=success).
This commit is contained in:
Nora
2026-07-07 18:35:51 -06:00
parent 7838ed2a4c
commit e7098b99b2
4 changed files with 250 additions and 26 deletions
+68 -1
View File
@@ -223,6 +223,59 @@ def _compact_ack_links_for_claim(claim_id: str) -> list[dict]:
return out return out
def _serialize_kwargs_for_claim(claim_obj) -> dict:
"""SP40: build the ``serialize_837`` kwargs from the live clearhouse
+ per-payer ``PayerConfigORM`` config. Same as the bulk export in
``batches.py:_serialize_kwargs`` so single-claim download mirrors
production byte-faithfulness.
Without this, ``serialize_837(claim_obj)`` falls back to
placeholder strings (CYCLONE/RECEIVER/CUSTOMER SERVICE/8005550100)
which the operator rightly rejected as not production-ready.
Returns an empty dict only when the clearhouse / payer config is
not seeded — the serializer's own SP40 fallback then fires.
"""
out: dict = {}
try:
ch = store.get_clearhouse()
except Exception:
ch = None
if ch is not None:
out["sender_id"] = ch.tpid
out["submitter_name"] = ch.submitter_name
out["submitter_contact_name"] = ch.submitter_contact_name
out["submitter_contact_email"] = ch.submitter_contact_email
if getattr(ch, "submitter_contact_phone", None):
out["submitter_contact_phone"] = ch.submitter_contact_phone
# Per-payer receiver + SBR-09 from the 837P PayerConfigORM row.
pid = (getattr(claim_obj.payer, "id", "") or "").strip()
if pid:
try:
from cyclone.db import PayerConfigORM, Payer as PayerORM
with db.SessionLocal()() as ss:
pcfg = ss.get(PayerConfigORM, (pid, "837P"))
payer_row = ss.get(PayerORM, pid)
if pcfg is not None and isinstance(pcfg.config_json, dict):
cfg = pcfg.config_json
if cfg.get("receiver_name"):
out["receiver_name"] = cfg["receiver_name"]
if cfg.get("receiver_id"):
out["receiver_id"] = cfg["receiver_id"]
if cfg.get("sbr09_default"):
out["claim_filing_indicator_code"] = cfg["sbr09_default"]
if payer_row is not None:
if "receiver_name" not in out and payer_row.receiver_name:
out["receiver_name"] = payer_row.receiver_name
if "receiver_id" not in out and payer_row.receiver_id:
out["receiver_id"] = payer_row.receiver_id
except Exception:
# Fall through; the serializer's own SP40 default
# (MC for SBR-09) covers the most common case.
pass
return out
@router.get("/api/claims/{claim_id}/serialize-837") @router.get("/api/claims/{claim_id}/serialize-837")
def serialize_claim_as_837(claim_id: str): def serialize_claim_as_837(claim_id: str):
"""Return the claim as a regenerated X12 837P file (SP8). """Return the claim as a regenerated X12 837P file (SP8).
@@ -231,6 +284,15 @@ def serialize_claim_as_837(claim_id: str):
outbound serializer. Returns 404 if the claim doesn't exist, 422 if outbound serializer. Returns 404 if the claim doesn't exist, 422 if
the stored payload has no parseable ClaimOutput (data integrity the stored payload has no parseable ClaimOutput (data integrity
issue, not a transient failure). issue, not a transient failure).
SP40: threads the clearhouse submitter + CO_TXIX PayerConfig
receiver kwargs through to ``serialize_837`` so the regenerated
file has real ``NM1*41`` (Dzinesco / TPID ``11525703``),
``PER*IC*Tyler Martinez*EM*tyler@dzinesco.com``, ``NM1*40``
(HCPF) and ``SBR*P*18*******MC`` segments — not the
``CYCLONE/RECEIVER/CUSTOMER SERVICE/8005550100`` placeholders the
bare-args call site would emit. Mirrors the regen-script path in
``dev/unbilled-july2026/scripts/regen_corrected_files.py``.
""" """
with db.SessionLocal()() as s: with db.SessionLocal()() as s:
row = s.get(Claim, claim_id) row = s.get(Claim, claim_id)
@@ -258,8 +320,13 @@ def serialize_claim_as_837(claim_id: str):
status_code=422, status_code=422,
) )
# SP40: build the same kwargs the bulk export uses, so the
# single-claim download mirrors production byte-faithfulness (real
# submitter, real contact, real receiver, real SBR-09).
serialize_kwargs = _serialize_kwargs_for_claim(claim_obj)
try: try:
text = serialize_837(claim_obj) text = serialize_837(claim_obj, **serialize_kwargs)
except SerializeError837 as exc: except SerializeError837 as exc:
return JSONResponse( return JSONResponse(
{"error": "Unprocessable", "detail": str(exc)}, {"error": "Unprocessable", "detail": str(exc)},
+17 -10
View File
@@ -422,12 +422,17 @@ def _build_submitter_block(
) -> list[str]: ) -> list[str]:
# SP40: PER-02 (Name) and at least one PER-03/04 pair are required # SP40: PER-02 (Name) and at least one PER-03/04 pair are required
# by Edifabric's x12/validate — emitting only PER-01 ("IC") makes # by Edifabric's x12/validate — emitting only PER-01 ("IC") makes
# the file invalid. Fall back to safe placeholders when the caller # the file invalid. Callers (the HTTP /api/claims/{id}/serialize-837
# passes no contact kwargs AND the clearhouse config doesn't carry # endpoint, the bulk /api/batches/{id}/export-837 exporter, the
# any. Real deployments should pass the configured values; the # regen-corrected-files sibling script, and the
# placeholder is documented in the function docstring as a # resubmit-rejected-claims CLI) MUST thread the real clearhouse
# last-resort safety net so future per-billing-office contact info # submitter_contact_* values through. The placeholders below are a
# lands at the call site instead of silently dropping through. # last-resort safety net so a developer running the serializer in
# isolation (e.g. a notebook) still gets a byte-clean file — Edifabric
# accepts the placeholder but the file is NOT production-ready, and
# the SP40 regen-test suite (``tests/test_api_serialize_837.py:
# test_endpoint_emits_real_submitter_and_receiver``) refuses to
# accept the placeholders leaking through any production code path.
if not any([contact_name, contact_phone, contact_email]): if not any([contact_name, contact_phone, contact_email]):
contact_name = "CUSTOMER SERVICE" contact_name = "CUSTOMER SERVICE"
contact_phone = "8005550100" contact_phone = "8005550100"
@@ -473,10 +478,12 @@ def _build_subscriber_block(
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children.""" """HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
# SP40: SBR-09 (Claim Filing Indicator Code) is required by # SP40: SBR-09 (Claim Filing Indicator Code) is required by
# Edifabric's x12/validate — emitting SBR*P*18******* (no SBR09) # Edifabric's x12/validate — emitting SBR*P*18******* (no SBR09)
# is invalid. Default to "MC" (Medicaid) per the SP9-era seeding # is invalid. Callers MUST thread the per-payer
# convention when the caller doesn't pass an explicit code; callers # ``PayerConfig837.sbr09_claim_filing`` (or
# that thread in the per-payer ``PayerConfig837.sbr09_claim_filing`` # ``PayerConfigORM.config_json['sbr09_default']``) value through;
# value get the correct code for their trading partner. # "MC" is the CO-Medicaid-seeded default and a safe last-resort
# fallback for any trading partner on that code, but other payers
# (Medicare Part B "16", etc.) MUST override.
if not claim_filing_indicator_code: if not claim_filing_indicator_code:
claim_filing_indicator_code = "MC" claim_filing_indicator_code = "MC"
out = [ out = [
+46 -1
View File
@@ -51,4 +51,49 @@ def test_endpoint_regenerated_text_round_trips_through_parser():
assert r.status_code == 200 assert r.status_code == 200
result = parse(r.text, PayerConfig(name="CO_MEDICAID")) result = parse(r.text, PayerConfig(name="CO_MEDICAID"))
assert result.claims, "endpoint body didn't parse back to any claims" assert result.claims, "endpoint body didn't parse back to any claims"
assert result.claims[0].claim.claim_id == claim_id assert result.claims[0].claim.claim_id == claim_id
def test_endpoint_emits_real_submitter_and_receiver():
"""SP40: the single-claim download must thread real submitter +
receiver identity through, not the serializer's placeholder
fallback. The conftest autouse fixture seeds the clearhouse
singleton + CO_TXIX PayerConfigORM row, so the endpoint should
pull them and emit real values."""
from cyclone import db as cycl_db
from cyclone.db import ClearhouseORM
from cyclone.store import store as cycl_store
cycl_db.init_db()
cycl_store.ensure_clearhouse_seeded()
with TestClient(app) as client:
claim_id = _seed_claim(client)
r = client.get(f"/api/claims/{claim_id}/serialize-837")
assert r.status_code == 200
body = r.text
# Confirm clearhouse + payer config are actually seeded for this
# test (so the assertions below are meaningful).
with cycl_db.SessionLocal()() as s:
ch = s.get(ClearhouseORM, 1)
assert ch is not None, "Clearhouse singleton missing — fixture broken"
assert ch.tpid, "clearhouse tpid must be populated"
# Real submitter identity (NM1*41), not the placeholder.
assert "Dzinesco" in body, body[:500]
assert ch.tpid in body, body[:500]
# Real submitter contact (PER segment).
assert "Tyler Martinez" in body, body[:500]
assert "tyler@dzinesco.com" in body, body[:500]
# Real receiver (NM1*40).
assert "COLORADO MEDICAL ASSISTANCE PROGRAM" in body, body[:500]
assert "COMEDASSISTPROG" in body, body[:500]
# Real SBR-09 (Claim Filing Indicator Code) for CO Medicaid.
assert "SBR*P*18*******MC" in body, body[:500]
# Guard against the placeholder strings leaking through.
assert "CUSTOMER SERVICE" not in body, body[:500]
assert "8005550100" not in body, body[:500]
assert "*CYCLONE *" not in body, body[:500]
assert "*RECEIVER *" not in body, body[:500] # only NM1*40 placeholder
@@ -1,16 +1,30 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SP39 sibling-project regen: re-serialize the 363 corrected files """SP39 / SP40 sibling-project regen: re-serialize the 363 corrected
through the fixed serializer into ``ingest/corrected-v2/``. files through the fixed serializer into ``ingest/corrected-v2/``.
Walks ``/home/tyler/dev/cyclone/ingest/corrected/batch-*/`` (the Walks ``/home/tyler/dev/cyclone/ingest/corrected/batch-*/`` (the
postmortem-anchor tree). For each ``.x12`` / ``.txt`` file it 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`, 1. Loads the clearhouse singleton + CO_TXIX PayerConfig so the
then validates the byte output contains ``PI*CO_TXIX`` (and does serialization has the real submitter + receiver identity (TPID,
*not* contain ``PI*SKCO0`` or the ``COHCPF`` name fallback). The contact name, email, receiver_name, etc.). These used to fall
output is written to ``ingest/corrected-v2/v2-batch-<batch-id>-<N>-claims/`` through to placeholder strings like ``CYCLONE`` / ``CUSTOMER
with a single global counter that guarantees unique timestamps and SERVICE`` / ``RECEIVER`` which Edifabric accepts but which the
unique interchange control numbers across the run. operator rightly refused as not production-ready.
2. Re-parses via :func:`cyclone.parsers.parse_837.parse`.
3. Re-serializes via
:func:`cyclone.parsers.serialize_837.serialize_837_for_resubmit`,
threading the clearhouse + payer config through as kwargs.
4. Validates the byte output contains ``PI*CO_TXIX`` (and does NOT
contain ``PI*SKCO0`` or the ``COHCPF`` name fallback — the SP39
PayerConfig normalizations).
5. Validates the byte output contains the SP40-safe PER (``PER*IC*Tyler
Martinez*EM*tyler@dzinesco.com``) and SBR (``SBR*P*18*******MC``).
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 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
@@ -23,17 +37,20 @@ Run::
""" """
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
# Make the cyclone parser 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.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
from cyclone.store import store as cycl_store # noqa: E402
CYCLONE_ROOT = Path('/home/tyler/dev/cyclone') CYCLONE_ROOT = Path('/home/tyler/dev/cyclone')
INGEST_ROOT = CYCLONE_ROOT / 'ingest' / 'corrected' INGEST_ROOT = CYCLONE_ROOT / 'ingest' / 'corrected'
@@ -52,11 +69,68 @@ def _interchange_index_for(global_counter: int) -> int:
return 1000 + global_counter return 1000 + global_counter
def _load_resubmit_kwargs() -> dict:
"""Build the ``serialize_837`` kwargs from the live clearhouse +
CO_TXIX payer config. Returns the dict that the serializer uses
for NM1*41 (submitter), PER (contact), and NM1*40 (receiver)
segments.
Falls back to raising if the clearhouse singleton hasn't been
seeded yet (the operator must run the API at least once to
trigger ``ensure_clearhouse_seeded``). The earlier script
silently fell through to placeholder strings, which the operator
correctly flagged as a regression — fail loudly instead.
"""
# Bootstrap the DB schema + seed the clearhouse if missing.
cycl_db.init_db()
cycl_store.ensure_clearhouse_seeded()
with cycl_db.SessionLocal()() as s:
from cyclone.db import ClearhouseORM, PayerConfigORM # noqa: F401
ch = s.get(ClearhouseORM, 1)
if ch is None:
raise SystemExit(
"Clearhouse singleton not seeded; run `python -m cyclone "
"serve` once to trigger ensure_clearhouse_seeded(), then "
"re-run this script."
)
# The 837P config for CO_TXIX carries submitter + receiver info.
pco = s.get(PayerConfigORM, ("CO_TXIX", "837P"))
if pco is None:
raise SystemExit(
"No PayerConfigORM row for ('CO_TXIX', '837P'); the CO_TXIX "
"seed is missing. Check ensure_clearhouse_seeded()."
)
cfg = pco.config_json or {}
# Map config_json -> serializer kwargs. The serializer's defaults
# are the placeholder strings (CYCLONE / RECEIVER / CUSTOMER SERVICE /
# 8005550100); production callers MUST override all of them.
return {
"sender_id": ch.tpid, # e.g. "11525703"
"submitter_name": ch.submitter_name, # e.g. "Dzinesco"
"submitter_contact_name": ch.submitter_contact_name, # "Tyler Martinez"
"submitter_contact_email": ch.submitter_contact_email, # "tyler@dzinesco.com"
"receiver_name": cfg.get("receiver_name") or pco.receiver_name,
"receiver_id": cfg.get("receiver_id") or pco.receiver_id,
"claim_filing_indicator_code": cfg.get("sbr09_default") or "MC",
}
def main() -> int: 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, validate the output, and write to through the fixed serializer with real submitter + receiver info,
OUT_ROOT. Returns 0 on a clean run, 1 if any file failed.""" validate the output, and write to OUT_ROOT. Returns 0 on a clean
run, 1 if any file failed or validation missed."""
payer_cfg_co = PayerConfig.co_medicaid() payer_cfg_co = PayerConfig.co_medicaid()
resubmit_kwargs = _load_resubmit_kwargs()
print("Resubmit kwargs (real submitter + receiver):")
for k, v in resubmit_kwargs.items():
print(f" {k}={v!r}")
print(flush=True)
base_ts = datetime.now(ZoneInfo('America/Denver')).replace(microsecond=0) base_ts = datetime.now(ZoneInfo('America/Denver')).replace(microsecond=0)
global_counter = 0 global_counter = 0
ok = 0 ok = 0
@@ -94,6 +168,7 @@ def main() -> int:
text = serialize_837_for_resubmit( text = serialize_837_for_resubmit(
parsed.claims[0], parsed.claims[0],
interchange_index=_interchange_index_for(global_counter), interchange_index=_interchange_index_for(global_counter),
**resubmit_kwargs,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
failed += 1 failed += 1
@@ -108,6 +183,36 @@ def main() -> int:
failed += 1 failed += 1
failures.append({'file': src.name, 'reason': 'SKCO0/COHCPF still present'}) failures.append({'file': src.name, 'reason': 'SKCO0/COHCPF still present'})
continue continue
# SP40 byte-defect gates — real submitter + receiver info
# must be present (no placeholder strings), SBR-09 must
# carry 'MC' for CO Medicaid.
submitter_contact_email = (
resubmit_kwargs["submitter_contact_email"] or ""
).encode()
receiver_id = (resubmit_kwargs["receiver_id"] or "").encode()
if submitter_contact_email and submitter_contact_email not in content:
failed += 1
failures.append({
'file': src.name,
'reason': f'submitter_contact_email '
f'{submitter_contact_email!r} missing',
})
continue
if receiver_id and receiver_id not in content:
failed += 1
failures.append({
'file': src.name,
'reason': f'receiver_id {receiver_id!r} missing',
})
continue
if b'SBR*P*18*******MC' not in content:
failed += 1
failures.append({'file': src.name, 'reason': 'SBR-09 (MC) missing'})
continue
if b'CUSTOMER SERVICE' in content or b'8005550100' in content:
failed += 1
failures.append({'file': src.name, 'reason': 'placeholder strings leaked'})
continue
ts = base_ts + timedelta(milliseconds=global_counter) ts = base_ts + timedelta(milliseconds=global_counter)
ts_str = ts.strftime('%Y%m%d%H%M%S') + f'{ts.microsecond // 1000:03d}' 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' fname = f'tp-cyc-837P-{ts_str}-{global_counter:04d}-1of1.x12'
@@ -123,4 +228,4 @@ def main() -> int:
if __name__ == '__main__': if __name__ == '__main__':
sys.exit(main()) sys.exit(main())