feat(sp41): Edifabric gate on every emitted 837P (quarantine on failure)

Replace the run_rebill() .touch() placeholders for Pipeline A and Pipeline B with real serialize → validate → write paths.

Pipeline A: build a ClaimOutput adapter from the canonical RebillClaim fields (claim_id/member_id/procedure/svc_date/charge) — the per-claim envelope context (provider NPI, subscriber name, payer name) comes from the original claim being replaced and isn't carried forward by the rebill pipeline, so the adapter emits safe placeholders and lets the SP40 serializer fallbacks fill PER / SBR09. Then serialize_837 → cyclone.edifabric.validate_edi → write to pipeline-a/ on success or quarantine/ on Status=='error'.

Pipeline B: serialize_member_week_batch(b, tpid=tpid) (the Task 14 overload — takes a MemberWeekBatch, not a ClaimOutput) → cyclone.edifabric.validate_edi → write to pipeline-b/ or quarantine/.

Filename disambiguation: build_outbound_filename embeds a millisecond timestamp; multiple files emitted in the same millisecond would collide. Append per-claim / per-batch suffix so the audit trail (claim_id ↔ file / batch_key ↔ file) stays one-to-one.

Edifabric unavailability (no API key, network error, 5xx) fails OPEN with a WARNING log: the file lands in the pipeline dir, matches the SP40 dev/CI posture for boxes without a paid key. Quarantine only on Edifabric Status=='error'.

New tests in backend/tests/test_rebill_run.py cover: real 837P file emission (non-empty, ISA..IEA envelope) for both pipelines, quarantine on Edifabric error, fail-open on Edifabric unavailability, HCPF-spec filename prefix preserved, per-claim / per-batch disambiguation suffix present, original claim_id preserved in Pipeline A CLM01.
This commit is contained in:
Cyclone Dev
2026-07-07 23:25:15 -06:00
parent 9beab5b4fc
commit b0d03bc10e
2 changed files with 505 additions and 25 deletions
+174 -22
View File
@@ -9,11 +9,14 @@ paths from --visits / --ingest / --out; HTTP endpoint passes the same.
from __future__ import annotations from __future__ import annotations
import csv as _csv import csv as _csv
import logging
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from cyclone import edifabric as _edifabric
from cyclone.edi.filenames import build_outbound_filename
from cyclone.rebill.carc_filter import CarcDecision, decide_carc from cyclone.rebill.carc_filter import CarcDecision, decide_carc
from cyclone.rebill.parse_835_svc import SvcRow, parse_835_svc from cyclone.rebill.parse_835_svc import SvcRow, parse_835_svc
from cyclone.rebill.pipeline_a import RebillClaim from cyclone.rebill.pipeline_a import RebillClaim
@@ -33,6 +36,8 @@ from cyclone.rebill.summary import (
) )
from cyclone.rebill.timely_filing import timely_filing_decision from cyclone.rebill.timely_filing import timely_filing_decision
_log = logging.getLogger(__name__)
@dataclass @dataclass
class RunResult: class RunResult:
@@ -78,14 +83,17 @@ def run_rebill(
- NOT_IN_835 + past-window → EXCLUDED_TIMELY_FILING (no override) - NOT_IN_835 + past-window → EXCLUDED_TIMELY_FILING (no override)
- DENIED/PARTIAL + EXCLUDED CARC → EXCLUDED_CARC - DENIED/PARTIAL + EXCLUDED CARC → EXCLUDED_CARC
- DENIED/PARTIAL + REVIEW/REBILL → Pipeline A (REBILLED_A) - DENIED/PARTIAL + REVIEW/REBILL → Pipeline A (REBILLED_A)
5. Emit Pipeline A files (``<out>/pipeline-a/x-{claim_id}.837``) and 5. Emit Pipeline A files (``<out>/pipeline-a/tp{tpid}-837P-...-1of1.x12``)
Pipeline B files (``<out>/pipeline-b/x-{member_id}-{iso_year}-W{iso_week:02d}.837``). and Pipeline B files (``<out>/pipeline-b/...-1of1.{member}-W{week}.x12``).
Files are ``touch()``ed placeholders here — Task 13 wraps each emit Each file is serialized via the canonical 837P helpers and gated
in a serialize-then-Edifabric-validate step, with quarantine on failure. through Edifabric's ``validate_edi``; failures land in
``<out>/quarantine/{key}.837`` so the operator can triage without
blocking the batch.
6. Write ``<out>/summary.csv`` with one row per non-PAID visit. 6. Write ``<out>/summary.csv`` with one row per non-PAID visit.
The ``tpid`` argument is preserved on the signature for Task 13 / Task 14 The ``tpid`` argument threads into the HCPF outbound filename
to thread into the real filename builder; unused in this placeholder emit. (``build_outbound_filename(tpid, "837P")``) and the Pipeline-B batch
serializer's submitter/receiver block (``serialize_member_week_batch``).
``as_of`` defaults to ``date.today()`` — pin it from tests / HTTP so the ``as_of`` defaults to ``date.today()`` — pin it from tests / HTTP so the
120-day timely-filing gate is deterministic. 120-day timely-filing gate is deterministic.
@@ -224,44 +232,188 @@ def run_rebill(
file_path="pipeline-a/", file_path="pipeline-a/",
)) ))
# 5) Emit Pipeline A files — placeholder; Task 13 wraps with the # 5) Emit Pipeline A files — serialize each RebillClaim, gate
# serialize-then-Edifabric-validate step. Filename shape: # through Edifabric's validate_edi, write clean files into
# ``x-{claim_id}.837`` so the audit trail ties back to the # ``pipeline-a/`` and Edifabric-rejected files into
# original claim_submit_id via the SummaryRow.cas_reasons chain. # ``quarantine/`` for operator triage.
#
# The HCPF-spec filename ``build_outbound_filename(tpid, "837P")``
# embeds a millisecond timestamp; two Pipeline-A claims emitted
# in the same millisecond would collide. We disambiguate with a
# ``-{claim_id}`` suffix so the audit trail (claim_id ↔ file)
# stays one-to-one and the file can round-trip back to the
# original claim via the SummaryRow chain.
a_dir = out_dir_p / "pipeline-a" a_dir = out_dir_p / "pipeline-a"
a_dir.mkdir(parents=True, exist_ok=True) a_dir.mkdir(parents=True, exist_ok=True)
quarantine_dir = out_dir_p / "quarantine"
quarantine_dir.mkdir(parents=True, exist_ok=True)
a_files: list[Path] = [] a_files: list[Path] = []
for claim in pipeline_a_claims: for claim in pipeline_a_claims:
p = a_dir / f"x-{claim.claim_id}.837" body = _serialize_pipeline_a(claim, tpid=tpid)
p.touch() status = _validate_or_skip(body, claim_id=claim.claim_id)
if status == "quarantine":
qp = quarantine_dir / f"{claim.claim_id}.837"
qp.write_bytes(body)
else:
base = build_outbound_filename(tpid, "837P")
# Disambiguate same-millisecond collisions across claims.
stem, dot_ext = base.rsplit(".", 1)
p = a_dir / f"{stem}-{claim.claim_id}.{dot_ext}"
p.write_bytes(body)
a_files.append(p) a_files.append(p)
# 6) Build + emit Pipeline B batches — placeholder; Task 13 wraps # 6) Build + emit Pipeline B batches. The Task 14 overload
# with the Edifabric gate. Filename shape mirrors the HCPF # ``serialize_member_week_batch`` takes a ``MemberWeekBatch`` and
# member-week pattern: ``x-{member_id}-{iso_year}-W{iso_week:02d}.837``. # emits one envelope with one CLM per visit. We use THAT — not
# ``serialize_837`` (the per-ClaimOutput helper) — because the
# Pipeline-B batches have no ClaimOutput shape; they're a
# (member, ISO-week) visit list keyed off the original visits.
#
# Filename disambiguation: ``build_outbound_filename`` produces
# the same string within a millisecond, so multiple batches
# would collide. Append ``-{member_id}-W{iso_week:02d}`` to the
# HCPF-spec filename so each batch round-trips back to its
# originating visits via the SummaryRow chain.
b_batches = build_pipeline_b_batches( b_batches = build_pipeline_b_batches(
pipeline_b_visits, as_of=as_of_date, override=override_filing, pipeline_b_visits, as_of=as_of_date, override=override_filing,
) )
b_dir = out_dir_p / "pipeline-b" b_dir = out_dir_p / "pipeline-b"
b_dir.mkdir(parents=True, exist_ok=True) b_dir.mkdir(parents=True, exist_ok=True)
b_files: list[Path] = [] b_files: list[Path] = []
from cyclone.parsers.serialize_837 import serialize_member_week_batch
for b in b_batches: for b in b_batches:
p = b_dir / f"x-{b.member_id}-{b.iso_year}-W{b.iso_week:02d}.837" body = serialize_member_week_batch(b, tpid=tpid)
p.touch() batch_key = f"{b.member_id}-{b.iso_year}-W{b.iso_week:02d}"
status = _validate_or_skip(body, claim_id=batch_key)
if status == "quarantine":
qp = quarantine_dir / f"{batch_key}.837"
qp.write_bytes(body)
else:
base = build_outbound_filename(tpid, "837P")
stem, dot_ext = base.rsplit(".", 1)
p = b_dir / f"{stem}-{batch_key}.{dot_ext}"
p.write_bytes(body)
b_files.append(p) b_files.append(p)
# 7) Summary CSV — one row per non-PAID visit, regardless of pipeline. # 7) Summary CSV — one row per non-PAID visit, regardless of pipeline.
summary_path = out_dir_p / "summary.csv" summary_path = out_dir_p / "summary.csv"
write_summary_csv(summary, summary_path) write_summary_csv(summary, summary_path)
# Touching `tpid` so it doesn't lint as unused — Task 13 / Task 14 will
# thread it into the real filename builder (cyclone.edi.filenames.
# build_outbound_filename) once the serializer adapter lands.
_ = tpid
return RunResult( return RunResult(
summary_path=summary_path, summary_path=summary_path,
counts=counts, counts=counts,
pipeline_a_files=a_files, pipeline_a_files=a_files,
pipeline_b_files=b_files, pipeline_b_files=b_files,
) )
def _serialize_pipeline_a(claim: RebillClaim, *, tpid: str) -> bytes:
"""Build a 837P byte string for a Pipeline-A RebillClaim.
Pipeline-A's input shape (RebillClaim) only carries the
freq-7-relevant canonical fields: claim_id (the original
claim_submit_id), member_id, procedure, svc_date, charge. The
per-claim envelope context (billing provider NPI, subscriber name,
payer name) lives on the original claim that's being replaced; the
rebill pipeline doesn't carry that forward, so we emit safe
placeholders and let the SP40 serializer fallbacks fill the
contact / SBR09 values.
Returns ASCII bytes (the 837P envelope is pure ASCII).
"""
# Lazy import: serialize_837 pulls Pydantic models on first use;
# keep it out of the rebill module's import-time surface.
from cyclone.parsers.models import (
BillingProvider,
ClaimHeader,
ClaimOutput,
Payer,
Procedure,
ServiceLine,
Subscriber,
ValidationReport,
)
from cyclone.parsers.serialize_837 import serialize_837
# Deterministic control numbers derived from the original claim_id
# so a retry of the same DOS window produces the same filenames
# (the operator can then diff against the prior run).
cn = claim.claim_id[:9].rjust(4, "0")[-4:]
placeholder_claim = ClaimOutput(
claim_id=claim.claim_id,
control_number=cn,
transaction_date=claim.svc_date,
billing_provider=BillingProvider(
name="REBILL PROVIDER",
npi="0000000000",
),
subscriber=Subscriber(
first_name="REBILL",
last_name=claim.member_id, # surface the member id in NM103
member_id=claim.member_id,
),
payer=Payer(name="CO_TXIX", id="CO_TXIX"),
claim=ClaimHeader(
claim_id=claim.claim_id,
total_charge=claim.charge,
place_of_service="11",
facility_code_qualifier="B",
frequency_code=claim.frequency_code, # always "7"
),
service_lines=[
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code=claim.procedure),
charge=claim.charge,
units=Decimal("1"),
unit_type="UN",
place_of_service="11",
service_date=claim.svc_date,
),
],
validation=ValidationReport(passed=True),
transaction_type_code="CH",
)
text = serialize_837(placeholder_claim, sender_id=tpid)
return text.encode("ascii")
def _validate_or_skip(body: bytes, *, claim_id: str) -> str:
"""Run Edifabric's validate_edi on the emitted 837P bytes.
Returns:
"ok" — Edifabric reports ``Status in {"success", "warning"}``;
emit to the pipeline dir.
"quarantine" — Edifabric reports ``Status == "error"``; emit
to ``<out>/quarantine/`` for operator triage.
"skip" — Edifabric was unreachable (no API key, network error,
5xx, etc.); treat as ``ok`` and emit to the pipeline dir.
A WARNING is logged so the operator knows the gate didn't
actually run. This matches the SP40 fail-open posture for
the dev/CI path (no API key in tests) without breaking
in-window rebill runs.
"""
try:
result = _edifabric.validate_edi(body)
except _edifabric.EdifabricError as exc:
# No API key / unreachable / 5xx — fail-open: emit to pipeline
# dir, log a WARNING so the operator sees the gate didn't fire.
_log.warning(
"SP41 rebill: Edifabric validation skipped for %s (%s); "
"emitting to pipeline dir without gate confirmation.",
claim_id, exc,
)
return "skip"
status = result.get("Status", "")
if status == "error":
details = result.get("Details") or []
msgs = "; ".join(
f"{d.get('SegmentId', '?')}: {d.get('Message', '?')}"
for d in details[:5]
)
_log.warning(
"SP41 rebill: Edifabric rejected %s — quarantining. %s",
claim_id, msgs,
)
return "quarantine"
return "ok"
+328
View File
@@ -0,0 +1,328 @@
"""SP41 Task 13 — Edifabric validation gate on emitted rebill files.
Covers the new emit path in ``cyclone.rebill.run.run_rebill``:
* Real 837P files are written (not ``Path.touch()`` placeholders).
* Files pass through ``cyclone.edifabric.validate_edi`` before they
land in ``<out>/pipeline-{a,b}/``; failures go to
``<out>/quarantine/``.
* When Edifabric is unavailable (no API key, network error, etc.),
the gate fails open with a WARNING log and emits to the pipeline
dirs (matches the SP40 dev/CI posture).
Each test builds its own visits CSV + 835 stub in ``tmp_path`` and
invokes ``run_rebill`` directly (no HTTP). The Edifabric API is mocked
via ``unittest.mock.patch`` on
``cyclone.rebill.run._edifabric.validate_edi`` so no live HTTP hits the
network.
"""
from __future__ import annotations
import csv
from datetime import date
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
import pytest
from cyclone.rebill.run import RunResult, run_rebill
# --------------------------------------------------------------------------- #
# Fixtures / helpers
# --------------------------------------------------------------------------- #
def _write_visits_csv(path: Path, rows: list[tuple[str, str, str, str]]) -> Path:
"""rows: list of (dos_mmddyyyy, member, procedure, billed_str)."""
with path.open("w", newline="") as f:
w = csv.writer(f)
w.writerow(["Visit Date", "Member ID", "Procedure Code", "Billable Amount"])
for r in rows:
w.writerow(r)
return path
def _stub_835_with_denied_svc(
ingest_dir: Path,
member: str,
procedure: str,
svc_date: date,
claim_id: str = "ORIG-1",
carc_group: str = "CO",
carc_reason: str = "29",
) -> Path:
"""An 835 with a single denied SVC so the visit lands in Pipeline A.
The rebill pipeline reuses the SVC's ``claim_id`` as the Pipeline-A
RebillClaim's ``claim_id`` (preserves the freq-7 anchor). The visit
must match (member_id, procedure, svc_date) so the reconcile step
pairs them into DENIED + CARC REBILL.
The 835 shape mirrors ``tests/fixtures/835_sample_svc_with_member.txt``:
CLP02 = 4 (Denied), NM1*QC.NM109 = member_id, then SVC → DTM*472 → CAS.
Segments are concatenated without ``\\n`` separators — the parser
splits on ``~`` only, and a leading ``\\n`` makes ``elems[0]`` an
empty string so the segment-name match silently drops the segment.
"""
ingest_dir.mkdir(exist_ok=True)
svc_date_str = svc_date.strftime("%Y%m%d")
segs = [
"ISA*00* *00* *ZZ*SENDER *ZZ*RECEIVER *260627*1200*^*00501*000000001*0*P*:~",
"GS*HC*SENDER*RECEIVER*20260627*1200*1*X*005010X221A1~",
"ST*835*0001~",
"BPR*I*0*C*ACH*CCP*01*021000021*DA*123456789*1512345678**01*021000021*DA*123456789*20260101~",
"TRN*1*TRACE01*1512345678~",
"DTM*405*20260118~",
"N1*PR*COLORADO MEDICAL ASSISTANCE PROGRAM*XV*COMEDASSISTPROG~",
"N3*PO BOX 1100~",
"N4*DENVER*CO*80202~",
"REF*2U*7912900843~",
"LX*1~",
# CLP02=4 means Denied → reconciles to DENIED → Pipeline A.
f"CLP*{claim_id}*4*100*0**MC*2026029105200*11*1~",
# NM1*QC.NM109 is the member_id; parse_835_svc propagates it to
# the SVC row so the visit-side reconcile key matches.
f"NM1*QC*1*PATIENT*NAME****MR*{member}~",
f"SVC*HC:{procedure}*100*0*UN*1~",
f"DTM*472*{svc_date_str}~",
# CAS group code + reason → CARC REBILL (not EXCLUDED).
f"CAS*{carc_group}*{carc_reason}*100~",
"SE*14*0001~",
"GE*1*1~",
"IEA*1*000000001~",
]
body = "".join(segs)
p = ingest_dir / "x.835"
p.write_text(body)
return p
def _stub_empty_835(ingest_dir: Path) -> Path:
"""Empty 835 → rebill's parse_835_svc emits zero SVCs."""
ingest_dir.mkdir(exist_ok=True)
p = ingest_dir / "x.835"
p.write_text("ST*835*0001~SE*0*0001~")
return p
# --------------------------------------------------------------------------- #
# Tests
# --------------------------------------------------------------------------- #
def test_run_rebill_emits_real_files_for_pipeline_b(tmp_path):
"""Pipeline-B happy path: 1 in-window visit, 0 SVCs → 1 real 837P
file in pipeline-b/ (non-empty, contains ISA..IEA), 0 files in
quarantine/.
Edifabric is patched to return ``success`` so the file passes the
gate cleanly. The fixture is the same shape as the existing
``test_post_rebill_runs_and_returns_summary_path`` happy-path test.
"""
visits_path = _write_visits_csv(tmp_path / "visits.csv", [
("06/27/2026", "J813715", "T1019", "$2.32"),
])
ingest_dir = _stub_empty_835(tmp_path / "ingest")
out_dir = tmp_path / "out"
fake_result = {"Status": "success", "Details": [], "LastIndex": 20}
with patch(
"cyclone.rebill.run._edifabric.validate_edi",
return_value=fake_result,
) as mock_validate:
result = run_rebill(
window_start=date(2026, 1, 1),
window_end=date(2026, 6, 27),
override_filing=False,
visits_csv_path=str(visits_path),
ingest_dir=str(ingest_dir),
out_dir=str(out_dir),
)
# Pipeline B emitted exactly one file (matches the existing
# ``len(body["pipeline_b_files"]) == 1`` assertion in test_api_rebill).
assert len(result.pipeline_b_files) == 1
b_path = result.pipeline_b_files[0]
assert b_path.exists(), b_path
assert b_path.stat().st_size > 0, "Pipeline-B file must be non-empty"
# The file is a real 837P envelope (starts with ISA, ends with IEA).
body = b_path.read_bytes()
assert body.startswith(b"ISA*"), body[:32]
assert b"IEA*" in body, body[-32:]
# Edifabric gate was called exactly once for the one Pipeline-B batch.
assert mock_validate.call_count == 1
# HCPF-spec filename prefix is preserved.
assert b_path.name.startswith("tp11525703-837P-"), b_path.name
# Per-batch disambiguation suffix is present so same-millisecond
# collisions don't overwrite each other.
assert "J813715-2026-W26" in b_path.name, b_path.name
# Quarantine is empty (no validation failures).
q_files = list((out_dir / "quarantine").glob("*.837"))
assert q_files == [], q_files
def test_run_rebill_quarantines_on_edifabric_error(tmp_path):
"""When validate_edi returns Status='error', the file lands in
``quarantine/`` (NOT in ``pipeline-b/``).
Exercises both the Edifabric-rejected path AND the case-isolation
that splits good files from bad files in the same run.
"""
visits_path = _write_visits_csv(tmp_path / "visits.csv", [
("06/27/2026", "J813715", "T1019", "$2.32"),
("06/27/2026", "J999999", "T1019", "$2.32"), # different member
])
ingest_dir = _stub_empty_835(tmp_path / "ingest")
out_dir = tmp_path / "out"
# First call (J813715) returns error → quarantine.
# Second call (J999999) returns success → pipeline-b.
fake_results = iter([
{
"Status": "error",
"Details": [
{"SegmentId": "PER", "Message": "PER-04 is required", "Status": "error"},
],
"LastIndex": 5,
},
{"Status": "success", "Details": [], "LastIndex": 20},
])
def _side_effect(_body):
return next(fake_results)
with patch(
"cyclone.rebill.run._edifabric.validate_edi",
side_effect=_side_effect,
) as mock_validate:
result = run_rebill(
window_start=date(2026, 1, 1),
window_end=date(2026, 6, 27),
override_filing=False,
visits_csv_path=str(visits_path),
ingest_dir=str(ingest_dir),
out_dir=str(out_dir),
)
# Only the success batch should land in pipeline-b/.
assert len(result.pipeline_b_files) == 1, (
f"only the success batch should land in pipeline-b/, got "
f"{[str(p) for p in result.pipeline_b_files]}"
)
# One file in quarantine (the rejected batch).
q_files = list((out_dir / "quarantine").glob("*.837"))
assert len(q_files) == 1, q_files
# Quarantine filename keys off the batch (member_id-W{iso_week:02d}).
assert "J813715" in q_files[0].name, q_files[0].name
assert q_files[0].stat().st_size > 0, "quarantined file must be non-empty"
# validate_edi called twice — once per Pipeline-B batch.
assert mock_validate.call_count == 2
def test_run_rebill_fails_open_when_edifabric_unavailable(tmp_path):
"""When validate_edi raises EdifabricError (no API key, network
error, 5xx), the file lands in the pipeline dir with a WARNING log.
Matches the SP40 dev/CI posture: tests / dev boxes don't have a
real API key, so the gate must NOT block the rebill run.
"""
from cyclone.edifabric import EdifabricError
visits_path = _write_visits_csv(tmp_path / "visits.csv", [
("06/27/2026", "J813715", "T1019", "$2.32"),
])
ingest_dir = _stub_empty_835(tmp_path / "ingest")
out_dir = tmp_path / "out"
with patch(
"cyclone.rebill.run._edifabric.validate_edi",
side_effect=EdifabricError(0, "API key not configured"),
) as mock_validate:
result = run_rebill(
window_start=date(2026, 1, 1),
window_end=date(2026, 6, 27),
override_filing=False,
visits_csv_path=str(visits_path),
ingest_dir=str(ingest_dir),
out_dir=str(out_dir),
)
# File emitted to pipeline-b/ (NOT to quarantine — fail-open).
assert len(result.pipeline_b_files) == 1
assert result.pipeline_b_files[0].exists()
# Quarantine stays empty.
q_files = list((out_dir / "quarantine").glob("*.837"))
assert q_files == [], q_files
# Gate was actually called (so we know the fail-open path fired,
# not some accidental short-circuit).
assert mock_validate.call_count == 1
def test_run_rebill_emits_real_files_for_pipeline_a(tmp_path):
"""Pipeline-A happy path: 1 in-window visit, 1 denied SVC matching
it → 1 real 837P file in pipeline-a/ with frequency-code 7
preserved.
The SVC's original claim_id (ORIG-1) carries through as the
Pipeline-A RebillClaim.claim_id and shows up in the emitted file's
CLM01 segment AND in the on-disk filename suffix.
"""
svc_date = date(2026, 6, 27)
visits_path = _write_visits_csv(tmp_path / "visits.csv", [
(svc_date.strftime("%m/%d/%Y"), "J813715", "T1019", "$100.00"),
])
ingest_dir = tmp_path / "ingest"
_stub_835_with_denied_svc(
ingest_dir,
member="J813715",
procedure="T1019",
svc_date=svc_date,
claim_id="ORIG-1",
)
out_dir = tmp_path / "out"
fake_result = {"Status": "success", "Details": [], "LastIndex": 30}
with patch(
"cyclone.rebill.run._edifabric.validate_edi",
return_value=fake_result,
):
result = run_rebill(
window_start=date(2026, 1, 1),
window_end=date(2026, 6, 27),
override_filing=False,
visits_csv_path=str(visits_path),
ingest_dir=str(ingest_dir),
out_dir=str(out_dir),
)
# Pipeline A emitted exactly one file.
assert len(result.pipeline_a_files) == 1, result.pipeline_a_files
a_path = result.pipeline_a_files[0]
assert a_path.exists(), a_path
assert a_path.stat().st_size > 0, "Pipeline-A file must be non-empty"
body = a_path.read_bytes()
assert body.startswith(b"ISA*"), body[:32]
assert b"IEA*" in body, body[-32:]
# Original claim_submit_id is preserved in CLM01 (anchors the
# frequency-7 replacement).
assert b"CLM*ORIG-1*" in body, body
# Frequency-code 7 emitted in CLM05 composite (11:B:7).
assert b"CLM*ORIG-1*100.00***11:B:7" in body, body
# Pipeline-A filename uses HCPF-spec prefix + claim_id suffix.
assert a_path.name.startswith("tp11525703-837P-"), a_path.name
assert a_path.name.endswith("-ORIG-1.x12"), a_path.name
# Quarantine empty.
q_files = list((out_dir / "quarantine").glob("*.837"))
assert q_files == [], q_files