feat(sp8): outbound 837P serializer — full rebuild + round-trip tests

- backend/src/cyclone/parsers/serialize_837.py — full-rebuild 837P serializer.
  Emits envelope (ISA/GS/ST/SE/GE/IEA + BHT) + submitter/receiver/billing
  provider/subscriber/payer hierarchy + editable segments (CLM/REF*G1/HI)
  + per-service-line LX/SV1/DTP*472/REF*6R — all from canonical
  ClaimOutput fields.

  Pivoted from spec §3.1 hybrid to full rebuild because ClaimOutput.raw_segments
  only captures post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the
  envelope or hierarchies. A pass-through approach cannot regenerate those
  without expanding raw_segments in parse_837.py (out of scope for this SP).

- backend/tests/test_serialize_837.py — 36 tests covering envelope shape,
  hierarchy segments, claim-level builders, service-line builders, edited-field
  propagation, round-trip, custom sender/receiver IDs, and resubmit helper.

- backend/tests/test_prodfiles_smoke.py::test_claims_prodfile_round_trip —
  every file in docs/prodfiles/claims/ (113 files) round-trips through
  serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo
  validation, which is recomputed by the parser).

- docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md — full plan
  with amendment note documenting the Approach A pivot.

Per session convention, plan note about unrelated modifications to
parse_835.py / fixtures stashed separately.
This commit is contained in:
Tyler
2026-06-20 20:27:48 -06:00
parent b3de9c4d22
commit 561018c690
4 changed files with 2573 additions and 1 deletions
+76 -1
View File
@@ -245,4 +245,79 @@ def _ack_count(store: CycloneStore) -> int:
"""Count rows in the acks table."""
from cyclone import db
with db.SessionLocal()() as s:
return s.query(db.Ack).count()
return s.query(db.Ack).count()
# ---------------------------------------------------------------------------
# Outbound 837P serializer round-trip smoke (SP8)
# ---------------------------------------------------------------------------
@pytest.mark.skipif(
not (DOCS_PRODFILES / "claims").exists(),
reason="docs/prodfiles/claims/ not present",
)
def test_claims_prodfile_round_trip():
"""Every prodfile in docs/prodfiles/claims/ round-trips through
serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo
validation, which is recomputed by the parser).
Parametrized over the glob but counted as 1 test in the suite total.
"""
from decimal import Decimal
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.payer import PayerConfig
from cyclone.parsers.serialize_837 import serialize_837
cfg = PayerConfig(name="CO_MEDICAID")
claims_dir = DOCS_PRODFILES / "claims"
failures: list[str] = []
for fixture_path in sorted(claims_dir.glob("*.x12")):
text = fixture_path.read_text()
source = parse(text, cfg)
if not source.claims:
failures.append(f"{fixture_path.name}: no claims parsed")
continue
for claim in source.claims:
try:
out = serialize_837(claim)
reparsed = parse(out, cfg).claims[0]
except Exception as e:
failures.append(f"{fixture_path.name}: serialize failed: {e}")
continue
# Compare canonical fields (raw_segments won't match byte-for-byte
# since we do a full rebuild; validation isn't compared because
# the parser recomputes it).
if reparsed.claim.claim_id != claim.claim.claim_id:
failures.append(f"{fixture_path.name}: claim_id mismatch")
if Decimal(reparsed.claim.total_charge) != claim.claim.total_charge:
failures.append(f"{fixture_path.name}: total_charge mismatch")
if claim.claim.place_of_service and reparsed.claim.place_of_service != claim.claim.place_of_service:
failures.append(
f"{fixture_path.name}: place_of_service mismatch "
f"({claim.claim.place_of_service!r} vs {reparsed.claim.place_of_service!r})"
)
if claim.claim.frequency_code and reparsed.claim.frequency_code != claim.claim.frequency_code:
failures.append(
f"{fixture_path.name}: frequency_code mismatch "
f"({claim.claim.frequency_code!r} vs {reparsed.claim.frequency_code!r})"
)
src_diags = sorted(d.code for d in claim.diagnoses)
out_diags = sorted(d.code for d in reparsed.diagnoses)
if src_diags != out_diags:
failures.append(f"{fixture_path.name}: diagnoses mismatch ({src_diags} vs {out_diags})")
if len(reparsed.service_lines) != len(claim.service_lines):
failures.append(
f"{fixture_path.name}: service_lines count mismatch "
f"({len(claim.service_lines)} vs {len(reparsed.service_lines)})"
)
continue
for src_line, out_line in zip(claim.service_lines, reparsed.service_lines):
if src_line.procedure.code != out_line.procedure.code:
failures.append(f"{fixture_path.name}: procedure code mismatch")
if Decimal(src_line.charge) != Decimal(out_line.charge):
failures.append(f"{fixture_path.name}: service line charge mismatch")
if failures:
sample = "\n".join(failures[:10])
pytest.fail(
f"{len(failures)} round-trip failure(s) across {len(list(claims_dir.glob('*.x12')))} files. "
f"First 10:\n{sample}"
)