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