feat(sp27): extract handle_835 from scheduler.py into handlers/
This commit is contained in:
@@ -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))
|
||||
@@ -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``
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user