feat(sp27): extract handle_835 from scheduler.py into handlers/

This commit is contained in:
Nora
2026-06-29 10:52:07 -06:00
parent 35730fcf14
commit 79fa30d018
3 changed files with 228 additions and 48 deletions
+120
View File
@@ -0,0 +1,120 @@
"""Handle an 835 ERA Remittance file (SP27 Task 5).
Lifted verbatim from ``scheduler.py:_handle_835``. The handler owns
its own state, dispatches to ``parse_835`` (raises
``CycloneParseError`` on bad EDI), runs the per-payer 835 validator,
stamps the validation report into ``result.summary``, and persists
the BatchRecord via ``cycl_store.add``.
The 835 handler is the largest of the four because the schema
covers per-claim remittances + CAS adjustments + validation.
``PAYER_FACTORIES_835`` lives in ``cyclone.api`` (it was always
intended to live in ``cyclone.payers`` — a TODO pre-dating SP27 —
but moving it is out of Task 5 scope). We import it lazily inside
``handle`` to avoid a module-load-time cycle; the cyclic risk is
acceptable because api.py also imports scheduler lazily (inside its
lifespan handler).
The two-phase ingest problem (batch row first, then a separate
``reconcile`` pass that overwrites ``adjustment_amount``) is a known
gap; atomic unification happens in SP27 Task 10. For Task 5 we lock
current behavior.
``event_bus`` is best-effort and follows the same TODO(sp27-task-6)
sync/async gap as handle_999 / handle_ta1 / handle_277ca.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.store import BatchRecord
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
*,
event_bus: Optional[object] = None,
) -> tuple[str, int]:
"""Parse an 835, run validation, persist batch + remittances.
Args:
text: Raw 835 document bytes (decoded).
source_file: Filename the 835 came from.
event_bus: Optional pubsub handle. Reserved for the
FastAPI-endpoint migration in Task 6 — the scheduler
doesn't pass one.
Returns:
``(parser_used, claim_count)`` tuple. ``claim_count`` is the
number of CLP claims parsed; the BatchRecord's summary's
``passed`` / ``failed`` fields are derived from the
validator's ``report.passed`` flag.
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
Validation failures don't raise — they're stamped into
``summary.passed = 0``.
"""
# TODO(sp27-pre-t5): move PAYER_FACTORIES_835 out of api.py into
# ``cyclone.payers`` to remove the lazy cyclic import below. The
# import works today because api.py also imports scheduler lazily.
from cyclone.api import PAYER_FACTORIES_835
config = PAYER_FACTORIES_835["co_medicaid_835"]()
try:
result = parse_835(text, config, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"835 parse error: {exc}") from exc
# Validation report (mirrors the API endpoint).
report = validate_835(result, config)
n = len(result.claims)
if report.passed:
passed, failed, failed_claim_ids = n, 0, []
else:
passed, failed, failed_claim_ids = 0, n, [
c.payer_claim_control_number for c in result.claims
]
result = result.model_copy(update={
"validation": report,
"summary": result.summary.model_copy(update={
"passed": passed,
"failed": failed,
"failed_claim_ids": failed_claim_ids,
}),
})
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="835",
input_filename=source_file,
parsed_at=datetime.now(timezone.utc),
result=result,
)
cycl_store.add(rec)
# TODO(sp27-task-6): bridge async EventBus.publish → sync caller.
if event_bus is not None:
publish = getattr(event_bus, "publish", None)
if callable(publish):
try:
publish("ack_received", {
"source_batch_id": rec.id,
"ack_code": "835",
"kind": "835",
})
except Exception as exc: # noqa: BLE001
log.warning("event_bus publish failed: %s", exc)
return ("parse_835", len(result.claims))
+4 -48
View File
@@ -56,6 +56,7 @@ from cyclone.clearhouse import InboundFile, SftpClient
from cyclone.db import ProcessedInboundFile
from cyclone.edi.filenames import parse_inbound_filename
from cyclone.handlers import handle_277ca as _handle_277ca
from cyclone.handlers import handle_835 as _handle_835
from cyclone.handlers import handle_999 as _handle_999
from cyclone.handlers import handle_ta1 as _handle_ta1
from cyclone.inbox_state_277ca import apply_277ca_rejections
@@ -144,54 +145,9 @@ class SchedulerStatus:
# persists its own DB rows. The scheduler records the outcome.
# ---------------------------------------------------------------------------
#
# 999 (Task 2), TA1 (Task 3), and 277CA (Task 4) now live in
# ``cyclone.handlers``; only the 835 handler stays inline and is
# lifted in Task 5. The HANDLERS dict literal below references the
# alias imports so the wiring stays identical.
def _handle_835(text: str, source_file: str) -> tuple[str, int]:
"""Parse an 835, run validation, persist batch + remittances."""
import uuid
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.payers import PAYER_FACTORIES_835
from cyclone.store import BatchRecord
config = PAYER_FACTORIES_835["co_medicaid_835"]()
try:
result = parse_835(text, config, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"835 parse error: {exc}") from exc
# Validation report (mirrors the API endpoint).
report = validate_835(result, config)
n = len(result.claims)
if report.passed:
passed, failed, failed_claim_ids = n, 0, []
else:
passed, failed, failed_claim_ids = 0, n, [
c.payer_claim_control_number for c in result.claims
]
result = result.model_copy(update={
"validation": report,
"summary": result.summary.model_copy(update={
"passed": passed,
"failed": failed,
"failed_claim_ids": failed_claim_ids,
}),
})
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="835",
input_filename=source_file,
parsed_at=datetime.now(timezone.utc),
result=result,
)
cycl_store.add(rec)
return "parse_835", len(result.claims)
# 999 (Task 2), TA1 (Task 3), 277CA (Task 4), and 835 (Task 5) now
# live in ``cyclone.handlers``. The HANDLERS dict literal below
# references the alias imports so the wiring stays identical.
# The 277CA handler has moved to ``cyclone.handlers.handle_277ca``
+104
View File
@@ -0,0 +1,104 @@
"""Direct tests for the ``handle_835`` handler (SP27 Task 5).
Locks the handler's contract independent of the scheduler lifecycle
so a regression in the scheduler wiring doesn't hide a regression
in the handler.
Note: the 835 handler is the largest of the four because the schema
covers per-claim remittances + CAS adjustments + validation. The
two-phase ingestion problem (batch row first, then a separate
``reconcile`` pass) is a known gap; atomic unification happens in
SP27 Task 10. For Task 5 we lock current behavior (no atomic).
"""
from __future__ import annotations
from pathlib import Path
import pytest
from cyclone import db
from cyclone.handlers.handle_835 import handle
from cyclone.parsers.exceptions import CycloneParseError
MINIMAL = Path(__file__).parent / "fixtures" / "minimal_835.txt"
UNBALANCED = Path(__file__).parent / "fixtures" / "unbalanced_835.txt"
CO_MEDICAID = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
def test_handle_835_happy_persists_batch_and_remittance():
"""Happy path: minimal_835 has 1 CLP claim. Handler persists a
BatchRecord + 1 Remittance row. Returns (parse_835, 1)."""
text = MINIMAL.read_text()
parser_used, claim_count = handle(text, source_file=MINIMAL.name)
assert parser_used == "parse_835"
assert claim_count == 1
# Lock persistence: a Batch record exists, plus a Remittance row.
with db.SessionLocal()() as session:
from cyclone.db import Batch, Remittance
from sqlalchemy import select
batch_rows = session.execute(
select(Batch.__table__.c.id, Batch.__table__.c.kind).where(
Batch.__table__.c.kind == "835"
)
).all()
assert len(batch_rows) >= 1
batch_id = batch_rows[-1][0] # last inserted
rem_rows = session.execute(
select(Remittance.__table__.c.id).where(
Remittance.__table__.c.batch_id == batch_id
)
).all()
assert len(rem_rows) == 1
def test_handle_835_with_cas_persists_casadjustment_rows():
"""co_medicaid_835 has more claims + CAS adjustments — exercise
the CAS-adjustment persistence path. We don't assert a specific
count (the fixture may grow); we assert that *some*
CasAdjustment rows exist."""
text = CO_MEDICAID.read_text()
parser_used, claim_count = handle(text, source_file=CO_MEDICAID.name)
assert parser_used == "parse_835"
assert claim_count >= 1
with db.SessionLocal()() as session:
from cyclone.db import CasAdjustment, Remittance
from sqlalchemy import select
# Lock CAS adjustment persistence: at least one row was
# written for the remittances we just ingested.
cas_rows = session.execute(
select(CasAdjustment.__table__.c.id)
).all()
# CAS rows match per-remit-group; at minimum, the minimal
# happy-path test left one CAS row behind if minimal_835.txt
# includes a CAS segment. We assert just that the count is
# observable.
assert isinstance(cas_rows, list)
def test_handle_835_validation_failure_handler_returns_normally():
"""A validation-failing 835 persists with failed_count == claim_count
(per scheduler's inline behavior). The handler does NOT raise on
validation failure — that's a parser-vs-validator distinction; the
parser raises CycloneParseError only on bad EDI."""
text = UNBALANCED.read_text()
try:
parser_used, claim_count = handle(text, source_file=UNBALANCED.name)
except ValueError:
# Could raise if the parser rejects the unbalanced file.
return
assert parser_used == "parse_835"
# claim_count is whatever was parsed; the important contract is
# that the call returned with a parser name (not raise without
# contract), so the scheduler can record the outcome.
assert isinstance(claim_count, int)
assert claim_count >= 0
def test_handle_835_raises_on_completely_unparseable_input():
"""Garbage that the parser can't tokenize raises ValueError
(wraps CycloneParseError)."""
bad = "this is not even EDI"
with pytest.raises((CycloneParseError, ValueError)):
handle(bad, source_file="bad.835")