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:
@@ -223,6 +223,59 @@ def _compact_ack_links_for_claim(claim_id: str) -> list[dict]:
|
||||
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")
|
||||
def serialize_claim_as_837(claim_id: str):
|
||||
"""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
|
||||
the stored payload has no parseable ClaimOutput (data integrity
|
||||
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:
|
||||
row = s.get(Claim, claim_id)
|
||||
@@ -258,8 +320,13 @@ def serialize_claim_as_837(claim_id: str):
|
||||
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:
|
||||
text = serialize_837(claim_obj)
|
||||
text = serialize_837(claim_obj, **serialize_kwargs)
|
||||
except SerializeError837 as exc:
|
||||
return JSONResponse(
|
||||
{"error": "Unprocessable", "detail": str(exc)},
|
||||
|
||||
@@ -422,12 +422,17 @@ def _build_submitter_block(
|
||||
) -> list[str]:
|
||||
# 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
|
||||
# the file invalid. Fall back to safe placeholders when the caller
|
||||
# passes no contact kwargs AND the clearhouse config doesn't carry
|
||||
# any. Real deployments should pass the configured values; the
|
||||
# placeholder is documented in the function docstring as a
|
||||
# last-resort safety net so future per-billing-office contact info
|
||||
# lands at the call site instead of silently dropping through.
|
||||
# the file invalid. Callers (the HTTP /api/claims/{id}/serialize-837
|
||||
# endpoint, the bulk /api/batches/{id}/export-837 exporter, the
|
||||
# regen-corrected-files sibling script, and the
|
||||
# resubmit-rejected-claims CLI) MUST thread the real clearhouse
|
||||
# submitter_contact_* values through. The placeholders below are a
|
||||
# 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]):
|
||||
contact_name = "CUSTOMER SERVICE"
|
||||
contact_phone = "8005550100"
|
||||
@@ -473,10 +478,12 @@ def _build_subscriber_block(
|
||||
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
|
||||
# SP40: SBR-09 (Claim Filing Indicator Code) is required by
|
||||
# Edifabric's x12/validate — emitting SBR*P*18******* (no SBR09)
|
||||
# is invalid. Default to "MC" (Medicaid) per the SP9-era seeding
|
||||
# convention when the caller doesn't pass an explicit code; callers
|
||||
# that thread in the per-payer ``PayerConfig837.sbr09_claim_filing``
|
||||
# value get the correct code for their trading partner.
|
||||
# is invalid. Callers MUST thread the per-payer
|
||||
# ``PayerConfig837.sbr09_claim_filing`` (or
|
||||
# ``PayerConfigORM.config_json['sbr09_default']``) value through;
|
||||
# "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:
|
||||
claim_filing_indicator_code = "MC"
|
||||
out = [
|
||||
|
||||
@@ -51,4 +51,49 @@ def test_endpoint_regenerated_text_round_trips_through_parser():
|
||||
assert r.status_code == 200
|
||||
result = parse(r.text, PayerConfig(name="CO_MEDICAID"))
|
||||
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
|
||||
Reference in New Issue
Block a user