feat(backend): per-claim JSON writer + summary.json
This commit is contained in:
@@ -37,6 +37,7 @@ _LAZY_EXPORTS: dict[str, str] = {
|
|||||||
"tokenize": "cyclone.parsers.segments",
|
"tokenize": "cyclone.parsers.segments",
|
||||||
# orchestrator
|
# orchestrator
|
||||||
"parse": "cyclone.parsers.parse_837",
|
"parse": "cyclone.parsers.parse_837",
|
||||||
|
"write_outputs": "cyclone.parsers.writer",
|
||||||
}
|
}
|
||||||
|
|
||||||
_cache: dict[str, object] = {}
|
_cache: dict[str, object] = {}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user