feat(sp27): extract handle_999 from scheduler.py into handlers/
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""Handle a 999 Implementation Acknowledgment file (SP27 Task 2).
|
||||
|
||||
Lifted verbatim from ``scheduler.py:_handle_999``. The handler owns
|
||||
its own DB session, dispatches to ``parse_999_text``, applies 999
|
||||
rejections to any matched claims via ``inbox_state.apply_999_rejections``,
|
||||
persists the ack row, and returns a ``(parser_used, claim_count)``
|
||||
tuple.
|
||||
|
||||
The actor tag (``"999-parser-scheduler"``) is preserved so the audit
|
||||
log keeps tracing back to the same source after extraction. Both
|
||||
the FastAPI endpoint and the scheduler path (re)use this module —
|
||||
see Task 6 for the API migration that drops the inline copy.
|
||||
|
||||
``claim_count`` mirrors ``parsed.received`` — the count of AK2
|
||||
(set-level) responses in the 999, which is the authoritative
|
||||
per-claim accept/reject signal (see ``_ack_id`` for why AK9 is
|
||||
ignored).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from cyclone import db
|
||||
from cyclone.audit_log import AuditEvent, append_event
|
||||
from cyclone.handlers._ack_id import (
|
||||
ack_count_summary,
|
||||
ack_synthetic_source_batch_id,
|
||||
)
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
from cyclone.parsers.parse_999 import parse_999_text
|
||||
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 a 999, apply rejections, persist ack row.
|
||||
|
||||
Args:
|
||||
text: Raw 999 document bytes (decoded).
|
||||
source_file: Filename the 999 came from. Used to derive a
|
||||
unique synthetic ``batches.id`` (see ``_ack_id``).
|
||||
event_bus: Optional pubsub handle. When provided, an
|
||||
``ack_received`` event is best-effort published. The
|
||||
scheduler passes ``None``; the FastAPI endpoint will
|
||||
be migrated to pass ``app.state.event_bus`` in Task 6.
|
||||
|
||||
Returns:
|
||||
``(parser_used, claim_count)`` tuple. Matches the scheduler
|
||||
``_download_and_parse`` destructure; the HandleResult
|
||||
migration happens in Task 7.
|
||||
|
||||
Raises:
|
||||
ValueError: on parser-level failure (wraps CycloneParseError).
|
||||
"""
|
||||
try:
|
||||
result = parse_999_text(text, input_file=source_file)
|
||||
except CycloneParseError as exc:
|
||||
raise ValueError(f"999 parse error: {exc}") from exc
|
||||
|
||||
received, accepted, rejected, ack_code = ack_count_summary(result)
|
||||
icn = result.envelope.control_number
|
||||
pcn = (
|
||||
result.set_responses[0].set_control_number
|
||||
if result.set_responses else None
|
||||
)
|
||||
synthetic_id = ack_synthetic_source_batch_id(
|
||||
icn, pcn=pcn, source_filename=source_file,
|
||||
)
|
||||
|
||||
with db.SessionLocal()() as session:
|
||||
def _lookup(pcn: str):
|
||||
return (
|
||||
session.query(db.Claim)
|
||||
.filter_by(patient_control_number=pcn)
|
||||
.first()
|
||||
)
|
||||
rejection_result = apply_999_rejections(
|
||||
session, result, claim_lookup=_lookup,
|
||||
)
|
||||
if rejection_result.matched:
|
||||
for cid in rejection_result.matched:
|
||||
append_event(session, AuditEvent(
|
||||
event_type="claim.rejected",
|
||||
entity_type="claim",
|
||||
entity_id=cid,
|
||||
payload={"source_batch_id": synthetic_id},
|
||||
actor="999-parser-scheduler",
|
||||
))
|
||||
row = cycl_store.add_ack(
|
||||
source_batch_id=synthetic_id,
|
||||
accepted_count=accepted,
|
||||
rejected_count=rejected,
|
||||
received_count=received,
|
||||
ack_code=ack_code,
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Best-effort pubsub publish. The scheduler doesn't pass an
|
||||
# event_bus; the API migration in Task 6 will pass the FastAPI
|
||||
# EventBus (which has an async ``publish``). This handler is
|
||||
# sync, so an async-real EventBus returns an unawaited coroutine
|
||||
# here — that's a known gap until Task 6 wires the publish
|
||||
# through ``asyncio.run_coroutine_threadsafe`` or makes
|
||||
# ``handle`` async. See TODO(sp27-task-6) below.
|
||||
if event_bus is not None:
|
||||
publish = getattr(event_bus, "publish", None)
|
||||
if callable(publish):
|
||||
# TODO(sp27-task-6): bridge async EventBus.publish → sync
|
||||
# caller (see comment above).
|
||||
try:
|
||||
publish("ack_received", {
|
||||
"source_batch_id": synthetic_id,
|
||||
"ack_code": ack_code,
|
||||
"kind": "999",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("event_bus publish failed: %s", exc)
|
||||
|
||||
return ("parse_999", received)
|
||||
@@ -55,7 +55,10 @@ from cyclone.audit_log import AuditEvent, append_event
|
||||
from cyclone.clearhouse import InboundFile, SftpClient
|
||||
from cyclone.db import ProcessedInboundFile
|
||||
from cyclone.edi.filenames import parse_inbound_filename
|
||||
from cyclone.inbox_state import apply_999_rejections
|
||||
from cyclone.handlers import handle_999 as _handle_999
|
||||
from cyclone.handlers._ack_id import (
|
||||
two77ca_synthetic_source_batch_id as _277ca_synthetic_source_batch_id,
|
||||
)
|
||||
from cyclone.inbox_state_277ca import apply_277ca_rejections
|
||||
from cyclone.providers import SftpBlock
|
||||
|
||||
@@ -141,60 +144,11 @@ class SchedulerStatus:
|
||||
# Per-file-type handlers. Each returns (parser_name, claim_count) and
|
||||
# persists its own DB rows. The scheduler records the outcome.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _handle_999(text: str, source_file: str) -> tuple[str, int]:
|
||||
"""Parse a 999, apply rejections, persist ack row. Returns (parser, count)."""
|
||||
from cyclone.parsers.parse_999 import parse_999_text
|
||||
from cyclone.parsers.exceptions import CycloneParseError
|
||||
|
||||
try:
|
||||
result = parse_999_text(text, input_file=source_file)
|
||||
except CycloneParseError as exc:
|
||||
raise ValueError(f"999 parse error: {exc}") from exc
|
||||
|
||||
received, accepted, rejected, ack_code = _ack_count_summary(result)
|
||||
icn = result.envelope.control_number
|
||||
# The natural unique key for a 999 is the AK2 set_control_number
|
||||
# (= the original claim's patient_control_number). Each 999 ack
|
||||
# covers exactly one claim, so the PCN is 1:1 with the 999 and
|
||||
# far more useful for the operator than the ISA interchange
|
||||
# control number (Gainwell's MFT ships every 999 with the same
|
||||
# default ICN, which used to collapse all 385 daily acks onto
|
||||
# ``999-000000001``). Fall back to ICN → ``unknown`` if the AK2 is
|
||||
# missing.
|
||||
pcn = result.set_responses[0].set_control_number if result.set_responses else None
|
||||
synthetic_id = _ack_synthetic_source_batch_id(icn, pcn=pcn, source_filename=source_file)
|
||||
|
||||
with db.SessionLocal()() as session:
|
||||
def _lookup(pcn: str):
|
||||
return (
|
||||
session.query(db.Claim)
|
||||
.filter_by(patient_control_number=pcn)
|
||||
.first()
|
||||
)
|
||||
rejection_result = apply_999_rejections(
|
||||
session, result, claim_lookup=_lookup,
|
||||
)
|
||||
if rejection_result.matched:
|
||||
for cid in rejection_result.matched:
|
||||
append_event(session, AuditEvent(
|
||||
event_type="claim.rejected",
|
||||
entity_type="claim",
|
||||
entity_id=cid,
|
||||
payload={"source_batch_id": synthetic_id},
|
||||
actor="999-parser-scheduler",
|
||||
))
|
||||
row = cycl_store.add_ack(
|
||||
source_batch_id=synthetic_id,
|
||||
accepted_count=accepted,
|
||||
rejected_count=rejected,
|
||||
received_count=received,
|
||||
ack_code=ack_code,
|
||||
raw_json=json.loads(result.model_dump_json()),
|
||||
)
|
||||
session.commit()
|
||||
return "parse_999", received
|
||||
#
|
||||
# The 999 handler now lives in ``cyclone.handlers.handle_999`` (SP27
|
||||
# Task 2); the 277CA / TA1 / 835 handlers still live inline here and
|
||||
# are lifted in Tasks 3-5. The dict literal below references the
|
||||
# alias imports so the wiring stays identical.
|
||||
|
||||
|
||||
def _handle_835(text: str, source_file: str) -> tuple[str, int]:
|
||||
@@ -341,73 +295,14 @@ HANDLERS: dict[str, Callable[[str, str], tuple[str, int]]] = {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ack_count_summary(result: Any) -> tuple[int, int, int, str]:
|
||||
"""Return (received, accepted, rejected, ack_code) for a 999.
|
||||
|
||||
Mirrors the logic in ``cyclone.api._ack_count_summary`` but lives
|
||||
here so the scheduler can run without importing the API module.
|
||||
|
||||
Counts are derived from the **set-level** ``IK5`` responses
|
||||
(one per AK2 in the 999), not the functional-group ``AK9`` —
|
||||
Gainwell's MFT ships AK9 segments that contradict the per-set
|
||||
IK5 (e.g. ``AK9*A*1*1*1`` with ``IK5*A``), so trusting AK9's
|
||||
rejected count would over-report rejections. The set-level
|
||||
IK5 is the authoritative per-claim accept/reject signal.
|
||||
"""
|
||||
sets = result.set_responses
|
||||
received = len(sets)
|
||||
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
|
||||
rejected = received - accepted
|
||||
if rejected == 0:
|
||||
code = "A"
|
||||
elif accepted == 0:
|
||||
code = "R"
|
||||
else:
|
||||
code = "P"
|
||||
return (received, accepted, rejected, code)
|
||||
|
||||
|
||||
def _ack_synthetic_source_batch_id(
|
||||
interchange_control_number: str,
|
||||
*,
|
||||
pcn: str | None = None,
|
||||
source_filename: str | None = None,
|
||||
) -> str:
|
||||
"""Synthetic batches.id for a received 999 with no source batch.
|
||||
|
||||
Gainwell's MFT ships every 999 with the same default ISA
|
||||
interchange control number (``000000001``), so the ICN alone
|
||||
collapses all daily acks onto one row. The AK2
|
||||
``set_control_number`` (= the original claim's
|
||||
patient_control_number) is per-batch — Gainwell's 999
|
||||
acks are per-batch, not per-claim, so a daily pull of 385
|
||||
999s typically has only ~4 distinct PCNs. To make every
|
||||
acks row distinguishable in the UI, the source_batch_id
|
||||
always includes an 8-char hash of the inbound filename.
|
||||
|
||||
Precedence for the human-readable part of the id
|
||||
(column is VARCHAR(32)):
|
||||
|
||||
1. ``999-{pcn}-{hash8}`` if AK2 set_control_number is
|
||||
present (the common case). 4+9+1+8 = 22 chars max.
|
||||
2. ``999-{icn}-{hash8}`` if no AK2 (envelope-only 999).
|
||||
3. ``999-{hash12}`` if no filename either (shouldn't
|
||||
happen in production).
|
||||
"""
|
||||
import hashlib
|
||||
short_hash = ""
|
||||
if source_filename:
|
||||
short_hash = hashlib.sha1(source_filename.encode("utf-8")).hexdigest()[:8]
|
||||
if pcn and pcn.strip():
|
||||
return f"999-{pcn.strip()}-{short_hash}"
|
||||
icn = (interchange_control_number or "").strip() or "000000001"
|
||||
if short_hash:
|
||||
return f"999-{icn}-{short_hash}"
|
||||
return f"999-{short_hash or icn}"
|
||||
|
||||
|
||||
def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||
"""Synthetic batches.id for a received 277CA with no source batch."""
|
||||
"""Synthetic batches.id for a received 277CA with no source batch.
|
||||
|
||||
Kept here as a scheduler-local copy because ``_handle_277ca`` is
|
||||
still inline (SP27 Task 4 will move it next to ``handle_999`` in
|
||||
``cyclone/handlers/``). For the 999 side, ``_handle_999`` is
|
||||
already imported from ``cyclone.handlers.handle_999`` (Task 2).
|
||||
"""
|
||||
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user