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.db import ProcessedInboundFile
|
||||||
from cyclone.edi.filenames import parse_inbound_filename
|
from cyclone.edi.filenames import parse_inbound_filename
|
||||||
from cyclone.handlers import handle_277ca as _handle_277ca
|
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_999 as _handle_999
|
||||||
from cyclone.handlers import handle_ta1 as _handle_ta1
|
from cyclone.handlers import handle_ta1 as _handle_ta1
|
||||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
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.
|
# persists its own DB rows. The scheduler records the outcome.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# 999 (Task 2), TA1 (Task 3), and 277CA (Task 4) now live in
|
# 999 (Task 2), TA1 (Task 3), 277CA (Task 4), and 835 (Task 5) now
|
||||||
# ``cyclone.handlers``; only the 835 handler stays inline and is
|
# live in ``cyclone.handlers``. The HANDLERS dict literal below
|
||||||
# lifted in Task 5. The HANDLERS dict literal below references the
|
# references the alias imports so the wiring stays identical.
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
# The 277CA handler has moved to ``cyclone.handlers.handle_277ca``
|
# 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