From f1e86979f816f39ddfd97dfe3cc8a0499ca89e32 Mon Sep 17 00:00:00 2001 From: Tyler Date: Fri, 19 Jun 2026 15:56:09 -0600 Subject: [PATCH] feat(backend): per-claim JSON writer + summary.json --- backend/src/cyclone/parsers/__init__.py | 1 + backend/src/cyclone/parsers/writer.py | 54 +++++++++++++++ backend/tests/test_writer.py | 90 +++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 backend/src/cyclone/parsers/writer.py create mode 100644 backend/tests/test_writer.py diff --git a/backend/src/cyclone/parsers/__init__.py b/backend/src/cyclone/parsers/__init__.py index 23d9be7..dcd37e4 100644 --- a/backend/src/cyclone/parsers/__init__.py +++ b/backend/src/cyclone/parsers/__init__.py @@ -37,6 +37,7 @@ _LAZY_EXPORTS: dict[str, str] = { "tokenize": "cyclone.parsers.segments", # orchestrator "parse": "cyclone.parsers.parse_837", + "write_outputs": "cyclone.parsers.writer", } _cache: dict[str, object] = {} diff --git a/backend/src/cyclone/parsers/writer.py b/backend/src/cyclone/parsers/writer.py new file mode 100644 index 0000000..1b89be5 --- /dev/null +++ b/backend/src/cyclone/parsers/writer.py @@ -0,0 +1,54 @@ +"""Write a :class:`ParseResult` to disk as one JSON file per claim + summary.json.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from cyclone.parsers.models import ParseResult + +_UNSAFE = re.compile(r"[^A-Za-z0-9._-]") + + +def _sanitize(s: str) -> str: + return _UNSAFE.sub("-", s).strip("-") or "claim" + + +def _filename(control_number: str, transaction_date_iso: str, claim_id: str, used: set[str]) -> str: + base = f"claim-{_sanitize(control_number)}-{_sanitize(transaction_date_iso.replace('-', ''))}-{_sanitize(claim_id)}.json" + if base not in used: + return base + # Disambiguate: append -2, -3, ... + stem = base[:-5] + n = 2 + while True: + cand = f"{stem}-{n}.json" + if cand not in used: + return cand + n += 1 + + +def write_outputs(result: ParseResult, output_dir: Path) -> list[Path]: + """Write one JSON file per claim and a ``summary.json``. + + Returns the list of written claim-file paths, in input order. + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + txn_date = result.envelope.transaction_date.isoformat() if result.envelope else "unknown" + control = result.envelope.control_number if result.envelope else "unknown" + + used: set[str] = set() + paths: list[Path] = [] + for claim in result.claims: + name = _filename(control, txn_date, claim.claim_id, used) + used.add(name) + path = output_dir / name + path.write_text(claim.model_dump_json(indent=2)) + paths.append(path) + + summary_path = output_dir / "summary.json" + summary_path.write_text(result.summary.model_dump_json(indent=2)) + return paths diff --git a/backend/tests/test_writer.py b/backend/tests/test_writer.py new file mode 100644 index 0000000..3f81f48 --- /dev/null +++ b/backend/tests/test_writer.py @@ -0,0 +1,90 @@ +import json +from datetime import date +from decimal import Decimal +from pathlib import Path + +import pytest + +from cyclone.parsers.models import ( + Address, + BatchSummary, + BillingProvider, + ClaimHeader, + ClaimOutput, + Envelope, + Payer, + Subscriber, + ValidationReport, +) +from cyclone.parsers.parse_837 import ParseResult +from cyclone.parsers.writer import write_outputs + + +def _claim(cid: str) -> ClaimOutput: + return ClaimOutput( + claim_id=cid, + control_number="991102977", + transaction_date=date(2026, 6, 11), + billing_provider=BillingProvider(name="X", npi="1234567890", address=Address(line1="1", city="x", state="CO", zip="80000")), + subscriber=Subscriber(first_name="J", last_name="D", member_id="M", dob=date(1980, 1, 1), gender="M", address=Address(line1="1", city="x", state="CO", zip="80000")), + payer=Payer(name="P", id="SKCO0"), + claim=ClaimHeader(claim_id=cid, total_charge=Decimal("100"), place_of_service="12", frequency_code="1", provider_signature="Y", assignment="Y", release_of_info="Y"), + diagnoses=[], + service_lines=[], + validation=ValidationReport(passed=True, errors=[], warnings=[]), + raw_segments=[], + ) + + +def _result(*cids: str) -> ParseResult: + return ParseResult( + envelope=Envelope(sender_id="X", receiver_id="Y", control_number="991102977", transaction_date=date(2026, 6, 11)), + claims=[_claim(c) for c in cids], + summary=BatchSummary(input_file="f", control_number="991102977", transaction_date=date(2026, 6, 11), total_claims=len(cids), passed=len(cids), failed=0), + ) + + +def test_write_outputs_creates_one_file_per_claim(tmp_path: Path): + result = _result("C1", "C2") + paths = write_outputs(result, tmp_path) + assert len(paths) == 2 + for p in paths: + assert p.exists() + assert p.suffix == ".json" + data = json.loads(p.read_text()) + assert "validation" in data + + +def test_filename_pattern(tmp_path: Path): + result = _result("C1") + paths = write_outputs(result, tmp_path) + assert paths[0].name == "claim-991102977-20260611-C1.json" + + +def test_filename_sanitizes_unsafe_chars(tmp_path: Path): + bad = _claim("C/1:*?") + result = ParseResult( + envelope=result.envelope if False else None, # type: ignore + claims=[bad], + summary=BatchSummary(input_file="f", total_claims=1, passed=1, failed=0), + ) # use explicit envelope + # Simpler: rebuild + from cyclone.parsers.models import Envelope as E + result = ParseResult( + envelope=E(sender_id="X", receiver_id="Y", control_number="991102977", transaction_date=date(2026, 6, 11)), + claims=[bad], + summary=BatchSummary(input_file="f", total_claims=1, passed=1, failed=0), + ) + paths = write_outputs(result, tmp_path) + # All non-alnum chars except - and _ replaced with - + assert all(c.isalnum() or c in "-_" for c in paths[0].stem) + + +def test_summary_json_written(tmp_path: Path): + result = _result("C1") + write_outputs(result, tmp_path) + summary_path = tmp_path / "summary.json" + assert summary_path.exists() + data = json.loads(summary_path.read_text()) + assert data["total_claims"] == 1 + assert data["passed"] == 1