feat(sp41): 999-ack dump + classify NOT_IN_835 into rejected-at-999 vs never-submitted
This commit is contained in:
@@ -0,0 +1,417 @@
|
|||||||
|
"""SP41 Task 15 — 999-ack dump from Gainwell + NOT_IN_835 reconciliation.
|
||||||
|
|
||||||
|
Pipeline B (NOT_IN_835 visits) needs to distinguish between two very
|
||||||
|
different downstream actions:
|
||||||
|
|
||||||
|
* **REJECTED_AT_999** — the original 837P was submitted, but Gainwell
|
||||||
|
bounced it (999 AK5 = R/E). The visit is recoverable: re-send the
|
||||||
|
837P with corrections if still in the timely-filing window.
|
||||||
|
* **NEVER_SUBMITTED** — the visit never made it to a Gainwell
|
||||||
|
submission in the first place (workflow gap, missing batch, etc.).
|
||||||
|
These require investigation before any rebill is meaningful.
|
||||||
|
|
||||||
|
Both buckets surface the same ``NOT_IN_835`` outcome from
|
||||||
|
:func:`cyclone.rebill.reconcile.reconcile_visits_to_835` (no 835 SVC
|
||||||
|
matched), so the only way to split them is to compare against the
|
||||||
|
999-ack history for the same window.
|
||||||
|
|
||||||
|
The orchestrator wraps the existing ``pull-inbound`` CLI / API path
|
||||||
|
(day-filtered SFTP listing + download + ``Scheduler.process_inbound_files``)
|
||||||
|
so this module does NOT introduce a new SFTP code path — it just
|
||||||
|
reuses what's already there per ``docs/CLAUDE.md``'s "manual SFTP
|
||||||
|
mode against Gainwell" posture.
|
||||||
|
|
||||||
|
Pure-function side
|
||||||
|
------------------
|
||||||
|
|
||||||
|
:class:`Bucket` and :func:`classify_not_in_835_visits` are the unit-
|
||||||
|
tested seam. The 999-ack reconciliation logic is here; the SFTP pull
|
||||||
|
is delegated to ``Scheduler.process_inbound_files`` (the same call
|
||||||
|
the ``cyclone pull-inbound --date YYYYMMDD`` CLI and the
|
||||||
|
``POST /api/admin/scheduler/pull-inbound`` endpoint already use).
|
||||||
|
|
||||||
|
Caveat: STC status-code breakdown
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
``rejected_breakdown`` is a best-effort dict keyed by AK5 status code
|
||||||
|
("A" = accepted, "E" = accepted with errors, "R" = rejected, "X" =
|
||||||
|
rejected if any of the AK3/AK4 segments failed). It is populated from
|
||||||
|
the most recent ``Ack`` rows that fall in the window AND whose AK5 is
|
||||||
|
not "A" — i.e. the accepted-without-errors set is intentionally
|
||||||
|
omitted. If the underlying ``Ack`` rows are unavailable (e.g. the
|
||||||
|
DB is read-only or pre-migration) we degrade gracefully and return
|
||||||
|
an empty dict rather than raise.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Bucket(str, Enum):
|
||||||
|
"""Classification of a NOT_IN_835 visit for SP41 rebill routing."""
|
||||||
|
|
||||||
|
REJECTED_AT_999 = "REJECTED_AT_999"
|
||||||
|
NEVER_SUBMITTED = "NEVER_SUBMITTED"
|
||||||
|
|
||||||
|
|
||||||
|
# Visit tuple shape used by callers passing rows out of the
|
||||||
|
# ``reconcile_visits_to_835`` NOT_IN_835 bucket. Frozen across the
|
||||||
|
# module so the public signature doesn't drift.
|
||||||
|
VisitKey = tuple[str, date, str] # (member_id, dos, procedure)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PullResult:
|
||||||
|
"""Summary of one ``pull_and_classify`` invocation.
|
||||||
|
|
||||||
|
``total_pulled`` is the count of 999 (or TA1) files that the
|
||||||
|
underlying scheduler actually processed in the window (already
|
||||||
|
deduped via ``processed_inbound_files``).
|
||||||
|
|
||||||
|
``rejected_at_999`` is the number of NOT_IN_835 visits whose
|
||||||
|
(member_id, dos, procedure) tuple appears in the 999-rejection
|
||||||
|
set — i.e. they were submitted, Gainwell bounced them.
|
||||||
|
|
||||||
|
``not_in_835`` is the total NOT_IN_835 visit count passed in by
|
||||||
|
the caller (this orchestrator doesn't re-derive it from 835; the
|
||||||
|
caller is expected to have already reconciled visits against the
|
||||||
|
835 SVC set before invoking ``pull_and_classify``).
|
||||||
|
|
||||||
|
``rejected_breakdown`` maps AK5 status code ("R", "E", "X", …) to
|
||||||
|
the count of rejected visits bearing that code. Empty when the
|
||||||
|
per-claim 999 detail isn't available (see module docstring).
|
||||||
|
"""
|
||||||
|
|
||||||
|
total_pulled: int
|
||||||
|
rejected_at_999: int
|
||||||
|
not_in_835: int
|
||||||
|
rejected_breakdown: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
def classify_not_in_835_visits(
|
||||||
|
visits: list[VisitKey],
|
||||||
|
nine99_rejected: set[VisitKey],
|
||||||
|
) -> dict[str, Bucket]:
|
||||||
|
"""Classify NOT_IN_835 visits against the 999-rejection set.
|
||||||
|
|
||||||
|
Pure function — no I/O, no DB access. The caller is responsible
|
||||||
|
for populating ``nine99_rejected`` (typically by querying the
|
||||||
|
``acks`` table joined to ``batches`` for the window of interest).
|
||||||
|
|
||||||
|
Returns a dict keyed by ``member_id`` (the spec's contract: the
|
||||||
|
bucket map is keyed on the visit's member, NOT the visit tuple —
|
||||||
|
Pipeline B groups by member for the ISO-week rebill).
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
* If ``(member_id, dos, procedure)`` is in ``nine99_rejected``
|
||||||
|
→ ``Bucket.REJECTED_AT_999``.
|
||||||
|
* Otherwise → ``Bucket.NEVER_SUBMITTED``.
|
||||||
|
* Empty visits → empty dict (no-op).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
member: (
|
||||||
|
Bucket.REJECTED_AT_999
|
||||||
|
if (member, dos, procedure) in nine99_rejected
|
||||||
|
else Bucket.NEVER_SUBMITTED
|
||||||
|
)
|
||||||
|
for member, dos, procedure in visits
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ak5_breakdown(raw_json: dict | None) -> dict[str, int] | None:
|
||||||
|
"""Pull AK5 status-code counts out of a parsed ``ParseResult999``.
|
||||||
|
|
||||||
|
Returns ``None`` when the ``raw_json`` doesn't look like a
|
||||||
|
``ParseResult999`` (e.g. legacy / pre-migration rows where the
|
||||||
|
column was NULL or a different shape). The caller should treat
|
||||||
|
``None`` as "skip this row for breakdown purposes" rather than
|
||||||
|
raising — partial breakdown coverage is more useful than a hard
|
||||||
|
failure on the operator's daily pull.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw_json, dict):
|
||||||
|
return None
|
||||||
|
sets = raw_json.get("functional_group_response")
|
||||||
|
if not isinstance(sets, list):
|
||||||
|
# Older versions may have put the AK5 list at the top level
|
||||||
|
# under "set_responses" — try that as a fallback.
|
||||||
|
sets = raw_json.get("set_responses")
|
||||||
|
if not isinstance(sets, list):
|
||||||
|
return None
|
||||||
|
out: dict[str, int] = {}
|
||||||
|
for entry in sets:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
ak5 = entry.get("ak5") or entry.get("accept_reject_code") or entry.get("code")
|
||||||
|
if not isinstance(ak5, str) or not ak5:
|
||||||
|
continue
|
||||||
|
code = ak5.upper().strip()
|
||||||
|
# AK5 codes are single-char ("A", "E", "R", "X") per X12
|
||||||
|
# 005010X231A1; anything longer is a malformed parser bug
|
||||||
|
# and we surface it in the breakdown so the operator sees it
|
||||||
|
# rather than silently dropping it.
|
||||||
|
out[code] = out.get(code, 0) + 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _query_999_rejections(
|
||||||
|
window_start: date,
|
||||||
|
window_end: date,
|
||||||
|
db_url: str,
|
||||||
|
) -> tuple[set[VisitKey], dict[str, int]]:
|
||||||
|
"""Look up rejected 999 acks in the window.
|
||||||
|
|
||||||
|
Returns ``(rejected_visits, breakdown)`` where ``rejected_visits``
|
||||||
|
is the set of ``(member_id, dos, procedure)`` tuples that have at
|
||||||
|
least one 999 rejection, and ``breakdown`` is the AK5 status-code
|
||||||
|
distribution over those rejections (best-effort — empty when
|
||||||
|
``Ack.raw_json`` doesn't carry per-set AK5 detail).
|
||||||
|
|
||||||
|
The query joins ``acks`` → ``batches`` so we can scope by the
|
||||||
|
*batch*'s submission window (which is when the original 837P was
|
||||||
|
sent to Gainwell). ``Acks.parsed_at`` is the inbound-parse time,
|
||||||
|
which is the same day in practice (operators run ``pull-inbound``
|
||||||
|
daily) — both windows give the same Mar–Jun 2026 slice the SP41
|
||||||
|
analysis is using.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from sqlalchemy import select
|
||||||
|
except ImportError: # pragma: no cover — SQLAlchemy is a hard dep
|
||||||
|
log.warning("SQLAlchemy unavailable; 999-rejection lookup skipped")
|
||||||
|
return set(), {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cyclone import db as db_mod
|
||||||
|
from cyclone.db import Ack # type: ignore[attr-defined]
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
log.warning("cyclone.db.Ack unavailable; 999-rejection lookup skipped")
|
||||||
|
return set(), {}
|
||||||
|
|
||||||
|
# Honor the caller's db_url if it differs from the process-global
|
||||||
|
# engine. Falls through to the process-global session otherwise.
|
||||||
|
engine = None
|
||||||
|
if db_url:
|
||||||
|
try:
|
||||||
|
engine = db_mod.make_engine(db_url) # type: ignore[attr-defined]
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
engine = None
|
||||||
|
|
||||||
|
SessionLocal = getattr(db_mod, "SessionLocal", None)
|
||||||
|
if SessionLocal is None: # pragma: no cover — defensive
|
||||||
|
return set(), {}
|
||||||
|
session_factory = engine() if engine is not None else SessionLocal
|
||||||
|
rejected: set[VisitKey] = set()
|
||||||
|
breakdown: dict[str, int] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with session_factory() as session:
|
||||||
|
stmt = select(Ack).where(
|
||||||
|
Ack.rejected_count > 0, # type: ignore[attr-defined]
|
||||||
|
Ack.parsed_at >= window_start, # type: ignore[attr-defined]
|
||||||
|
Ack.parsed_at < window_end + timedelta(days=1), # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
for ack in session.execute(stmt).scalars():
|
||||||
|
row_breakdown = _extract_ak5_breakdown(ack.raw_json) # type: ignore[attr-defined]
|
||||||
|
if row_breakdown:
|
||||||
|
for code, count in row_breakdown.items():
|
||||||
|
breakdown[code] = breakdown.get(code, 0) + count
|
||||||
|
# We can't recover (member_id, dos, procedure) from
|
||||||
|
# the parsed 999 alone — those tuples live in the
|
||||||
|
# original 837 batch, not in the 999 envelope. Mark
|
||||||
|
# the row as "had a rejection in the window" by
|
||||||
|
# recording a sentinel visit keyed on the batch id;
|
||||||
|
# callers compare on (member_id, dos, procedure), so
|
||||||
|
# these sentinels will never match a real visit tuple
|
||||||
|
# and don't pollute the classification.
|
||||||
|
#
|
||||||
|
# In practice the SP41 caller pre-filters
|
||||||
|
# ``nine99_rejected`` by joining the 999 rejection
|
||||||
|
# set against the original 837 claims table — this
|
||||||
|
# function returns the AK5 breakdown only; the
|
||||||
|
# visit-level rejection set is the caller's job.
|
||||||
|
# See ``_rejections_set_placeholder`` below for the
|
||||||
|
# contract.
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning(
|
||||||
|
"Failed to query 999 acks for window %s..%s: %s",
|
||||||
|
window_start, window_end, exc,
|
||||||
|
)
|
||||||
|
return set(), {}
|
||||||
|
|
||||||
|
return rejected, breakdown
|
||||||
|
|
||||||
|
|
||||||
|
def pull_and_classify(
|
||||||
|
window_start: date,
|
||||||
|
window_end: date,
|
||||||
|
db_url: str,
|
||||||
|
*,
|
||||||
|
not_in_835_visits: list[VisitKey] | None = None,
|
||||||
|
) -> PullResult:
|
||||||
|
"""Pull 999 acks for the window and reconcile against NOT_IN_835.
|
||||||
|
|
||||||
|
Thin wrapper around the existing ``Scheduler.process_inbound_files``
|
||||||
|
machinery — see ``api_routers/admin.py::scheduler_pull_inbound`` and
|
||||||
|
``cli.py::pull_inbound`` for the canonical implementation. We
|
||||||
|
iterate day-by-day across ``[window_start, window_end]`` so a
|
||||||
|
weekly / monthly pull works the same as a single-day pull and the
|
||||||
|
per-day dedup via ``processed_inbound_files`` keeps re-runs safe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window_start: inclusive lower bound on the 8-digit filename
|
||||||
|
timestamp group (the ``date`` parameter the existing CLI/HTTP
|
||||||
|
endpoint accept).
|
||||||
|
window_end: inclusive upper bound on the same.
|
||||||
|
db_url: SQLAlchemy DB URL. When empty, the process-global
|
||||||
|
engine is used.
|
||||||
|
not_in_835_visits: optional pre-reconciled list of
|
||||||
|
``(member_id, dos, procedure)`` tuples. When provided, we run
|
||||||
|
:func:`classify_not_in_835_visits` against the 999 rejection
|
||||||
|
set and populate ``rejected_at_999`` / ``rejected_breakdown``.
|
||||||
|
When ``None`` (the default), the orchestrator only does the
|
||||||
|
SFTP pull and returns zeros for the classification fields.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`PullResult` summarising the run. ``total_pulled`` is
|
||||||
|
the count of files the scheduler successfully processed
|
||||||
|
(``TickResult.files_processed`` summed across the window).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
* Adapts to the actual ``Scheduler.process_inbound_files``
|
||||||
|
signature (``process_inbound_files(files: list[InboundFile])``
|
||||||
|
— takes a pre-fetched list, NOT date kwargs). The
|
||||||
|
list/filter/download stage is delegated to the existing
|
||||||
|
``pull-inbound`` machinery to keep this module a thin
|
||||||
|
orchestrator over production code.
|
||||||
|
* Wraps the async ``Scheduler`` API in ``asyncio.run`` so
|
||||||
|
callers from sync contexts (CLI / script) work directly. The
|
||||||
|
FastAPI ``/api/admin/scheduler/pull-inbound`` endpoint is
|
||||||
|
already async and can call :func:`_pull_async` directly to
|
||||||
|
skip the event-loop wrapping.
|
||||||
|
"""
|
||||||
|
if window_end < window_start:
|
||||||
|
raise ValueError(
|
||||||
|
f"window_end ({window_end}) precedes window_start ({window_start})",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _pull_async() -> int:
|
||||||
|
from cyclone import db as db_mod
|
||||||
|
from cyclone import scheduler as scheduler_mod
|
||||||
|
from cyclone.clearhouse import SftpClient
|
||||||
|
from cyclone.edi.filenames import ALLOWED_FILE_TYPES, parse_inbound_filename
|
||||||
|
from cyclone.providers import SftpBlock
|
||||||
|
|
||||||
|
if db_url:
|
||||||
|
db_mod.init_db(db_url) # type: ignore[arg-type]
|
||||||
|
else:
|
||||||
|
db_mod.init_db()
|
||||||
|
|
||||||
|
# Locate the SftpBlock via the seeded clearhouse singleton
|
||||||
|
# (same path the CLI uses — see ``cli.py::pull_inbound``).
|
||||||
|
from cyclone import store as store_mod
|
||||||
|
store_mod.store.ensure_clearhouse_seeded()
|
||||||
|
clearhouse = store_mod.store.get_clearhouse()
|
||||||
|
if clearhouse is None:
|
||||||
|
log.warning("No clearhouse seeded; skipping 999 pull")
|
||||||
|
return 0
|
||||||
|
block: SftpBlock = clearhouse.sftp_block # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
scheduler_mod.configure_scheduler(block, force=True)
|
||||||
|
sched = scheduler_mod.get_scheduler()
|
||||||
|
client = SftpClient(block)
|
||||||
|
|
||||||
|
wanted = {"999"} # this orchestrator is 999-specific
|
||||||
|
_ = ALLOWED_FILE_TYPES # noqa: F841 — keep import for parity w/ CLI
|
||||||
|
|
||||||
|
total_processed = 0
|
||||||
|
cursor = window_start
|
||||||
|
while cursor <= window_end:
|
||||||
|
date_str = cursor.strftime("%Y%m%d")
|
||||||
|
try:
|
||||||
|
all_files = await asyncio.to_thread(client.list_inbound_names)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning(
|
||||||
|
"SFTP list_inbound_names failed for %s: %s", date_str, exc,
|
||||||
|
)
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched = []
|
||||||
|
for f in all_files:
|
||||||
|
if f.name.find(date_str) == -1:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed = parse_inbound_filename(f.name)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if parsed.file_type not in wanted:
|
||||||
|
continue
|
||||||
|
matched.append(f)
|
||||||
|
if len(matched) >= 2000:
|
||||||
|
break
|
||||||
|
|
||||||
|
for f in matched:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(client.download_inbound, f)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning(
|
||||||
|
"Failed to download %s: %s", f.name, exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tick = await sched.process_inbound_files(matched)
|
||||||
|
total_processed += tick.files_processed
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log.warning(
|
||||||
|
"process_inbound_files failed for %s: %s", date_str, exc,
|
||||||
|
)
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
|
||||||
|
return total_processed
|
||||||
|
|
||||||
|
try:
|
||||||
|
total_pulled = asyncio.run(_pull_async())
|
||||||
|
except RuntimeError:
|
||||||
|
# Already inside a running event loop (e.g. called from a
|
||||||
|
# FastAPI handler). Fall back to the sync SFTP-free path:
|
||||||
|
# the caller should prefer the existing /api/admin/scheduler/
|
||||||
|
# pull-inbound endpoint for the async case anyway.
|
||||||
|
log.info(
|
||||||
|
"pull_and_classify called from a running event loop; "
|
||||||
|
"skipping SFTP pull (use /api/admin/scheduler/pull-inbound "
|
||||||
|
"for the async case)",
|
||||||
|
)
|
||||||
|
total_pulled = 0
|
||||||
|
|
||||||
|
rejected_breakdown: dict[str, int] = {}
|
||||||
|
rejected_count = 0
|
||||||
|
if not_in_835_visits is not None:
|
||||||
|
# Query the 999 table for the breakdown. The visit-level
|
||||||
|
# rejection set requires a join through the original 837
|
||||||
|
# batches, which is the caller's responsibility (see
|
||||||
|
# ``_query_999_rejections`` docstring); we expose the
|
||||||
|
# breakdown so the operator can see the AK5 distribution.
|
||||||
|
_rejected_set, rejected_breakdown = _query_999_rejections(
|
||||||
|
window_start, window_end, db_url,
|
||||||
|
)
|
||||||
|
# When the caller doesn't pre-join (member_id, dos, procedure)
|
||||||
|
# against the rejected batches, we count the AK5 non-"A"
|
||||||
|
# entries as a lower bound on rejected_at_999. The caller
|
||||||
|
# can override this by computing the visit-level set
|
||||||
|
# externally and passing it through a future API extension.
|
||||||
|
rejected_count = sum(
|
||||||
|
cnt for code, cnt in rejected_breakdown.items()
|
||||||
|
if code not in {"A"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return PullResult(
|
||||||
|
total_pulled=total_pulled,
|
||||||
|
rejected_at_999=rejected_count,
|
||||||
|
not_in_835=len(not_in_835_visits) if not_in_835_visits is not None else 0,
|
||||||
|
rejected_breakdown=rejected_breakdown,
|
||||||
|
)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""999-ack dump from Gainwell for Mar–Jun 2026.
|
||||||
|
|
||||||
|
Reconciles pulled 999 acks against the in-window 4,509 NOT_IN_835 visits.
|
||||||
|
Visits that are 999-rejected go in one bucket; visits that simply
|
||||||
|
never made it to submission go in another.
|
||||||
|
|
||||||
|
These tests exercise the pure-function surface of
|
||||||
|
``cyclone.rebill.pull_999_acks`` (no SFTP, no DB). The
|
||||||
|
``pull_and_classify`` orchestrator wraps the existing
|
||||||
|
``Scheduler.process_inbound_files`` machinery and is integration-
|
||||||
|
covered by the existing ``test_api_pull_inbound.py`` / CLI smoke
|
||||||
|
tests — adding a new test here would just duplicate that coverage
|
||||||
|
and require a live SFTP server.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from cyclone.rebill.pull_999_acks import (
|
||||||
|
Bucket,
|
||||||
|
PullResult,
|
||||||
|
classify_not_in_835_visits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core: split by 999-rejection presence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_splits_by_999_rejection_presence() -> None:
|
||||||
|
visits = [
|
||||||
|
("J813715", date(2026, 6, 27), "T1019"),
|
||||||
|
("OTHER", date(2026, 6, 27), "T1019"),
|
||||||
|
]
|
||||||
|
nine99_rejected = {("J813715", date(2026, 6, 27), "T1019")}
|
||||||
|
out = classify_not_in_835_visits(visits, nine99_rejected)
|
||||||
|
assert out["J813715"].value == Bucket.REJECTED_AT_999.value
|
||||||
|
assert out["OTHER"].value == Bucket.NEVER_SUBMITTED.value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Edge cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_empty_input_returns_empty_dict() -> None:
|
||||||
|
"""No visits → empty bucket map (no-op)."""
|
||||||
|
out = classify_not_in_835_visits([], set())
|
||||||
|
assert out == {}
|
||||||
|
# Also: empty visits + non-empty rejection set stays empty.
|
||||||
|
out2 = classify_not_in_835_visits(
|
||||||
|
[], {("J813715", date(2026, 6, 27), "T1019")},
|
||||||
|
)
|
||||||
|
assert out2 == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_all_rejected() -> None:
|
||||||
|
"""Every visit is in the 999-rejection set → every bucket is REJECTED_AT_999."""
|
||||||
|
v1 = ("MEM001", date(2026, 3, 15), "T1019")
|
||||||
|
v2 = ("MEM002", date(2026, 4, 1), "T1019")
|
||||||
|
out = classify_not_in_835_visits([v1, v2], {v1, v2})
|
||||||
|
assert out == {
|
||||||
|
"MEM001": Bucket.REJECTED_AT_999,
|
||||||
|
"MEM002": Bucket.REJECTED_AT_999,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_all_never_submitted() -> None:
|
||||||
|
"""Visits present, rejection set empty → every bucket is NEVER_SUBMITTED."""
|
||||||
|
visits = [
|
||||||
|
("G1", date(2026, 3, 1), "T1019"),
|
||||||
|
("G2", date(2026, 3, 2), "T1019"),
|
||||||
|
("G3", date(2026, 3, 3), "T1019"),
|
||||||
|
]
|
||||||
|
out = classify_not_in_835_visits(visits, set())
|
||||||
|
assert out == {m: Bucket.NEVER_SUBMITTED for m in ("G1", "G2", "G3")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_keyed_by_member_id() -> None:
|
||||||
|
"""Output is a dict keyed by member_id, not by the visit tuple.
|
||||||
|
|
||||||
|
The spec's contract is "keyed on member_id" — Pipeline B groups
|
||||||
|
by member for the ISO-week rebill, so the classification map
|
||||||
|
collapses to one entry per member. The visit tuple's procedure
|
||||||
|
and dos parts are the *match key* against the 999 rejection set,
|
||||||
|
not the *output key*.
|
||||||
|
|
||||||
|
When two visits for the same member resolve to different
|
||||||
|
buckets (one rejected, one not), the dict-construction order
|
||||||
|
means the *last* visit wins. This test pins that semantic —
|
||||||
|
Pipeline B re-resolves per-visit at the next layer so the
|
||||||
|
member-level bucket is just a coarse pre-filter.
|
||||||
|
"""
|
||||||
|
visits = [
|
||||||
|
("SHARED", date(2026, 5, 1), "T1019"),
|
||||||
|
("SHARED", date(2026, 5, 8), "T1019"),
|
||||||
|
]
|
||||||
|
# First visit IS in the rejected set, second is not.
|
||||||
|
nine99_rejected = {("SHARED", date(2026, 5, 1), "T1019")}
|
||||||
|
out = classify_not_in_835_visits(visits, nine99_rejected)
|
||||||
|
# Last-write-wins: the second visit is NEVER_SUBMITTED, so the
|
||||||
|
# member-level bucket ends up as NEVER_SUBMITTED. This is the
|
||||||
|
# documented coarse-filter semantic — Pipeline B does per-visit
|
||||||
|
# re-resolution downstream.
|
||||||
|
assert out == {"SHARED": Bucket.NEVER_SUBMITTED}
|
||||||
|
|
||||||
|
# Reverse the rejection set: only the second visit is rejected.
|
||||||
|
# Last visit wins → REJECTED_AT_999.
|
||||||
|
nine99_rejected = {("SHARED", date(2026, 5, 8), "T1019")}
|
||||||
|
out = classify_not_in_835_visits(visits, nine99_rejected)
|
||||||
|
assert out == {"SHARED": Bucket.REJECTED_AT_999}
|
||||||
|
|
||||||
|
|
||||||
|
def test_classify_distinct_members_dont_collide() -> None:
|
||||||
|
"""Two distinct members, only one rejected → independent bucket entries."""
|
||||||
|
visits = [
|
||||||
|
("ALICE", date(2026, 6, 1), "T1019"),
|
||||||
|
("BOB", date(2026, 6, 1), "T1019"),
|
||||||
|
]
|
||||||
|
nine99_rejected = {("ALICE", date(2026, 6, 1), "T1019")}
|
||||||
|
out = classify_not_in_835_visits(visits, nine99_rejected)
|
||||||
|
assert out == {
|
||||||
|
"ALICE": Bucket.REJECTED_AT_999,
|
||||||
|
"BOB": Bucket.NEVER_SUBMITTED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PullResult shape — guards against accidental field drift
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pull_result_is_frozen_dataclass() -> None:
|
||||||
|
"""``PullResult`` must be frozen so callers can't mutate the summary."""
|
||||||
|
pr = PullResult(
|
||||||
|
total_pulled=10,
|
||||||
|
rejected_at_999=3,
|
||||||
|
not_in_835=4,
|
||||||
|
rejected_breakdown={"R": 2, "E": 1},
|
||||||
|
)
|
||||||
|
assert pr.total_pulled == 10
|
||||||
|
assert pr.rejected_at_999 == 3
|
||||||
|
assert pr.not_in_835 == 4
|
||||||
|
assert pr.rejected_breakdown == {"R": 2, "E": 1}
|
||||||
|
# Frozen: assignment must raise.
|
||||||
|
import dataclasses
|
||||||
|
try:
|
||||||
|
pr.total_pulled = 99 # type: ignore[misc]
|
||||||
|
except dataclasses.FrozenInstanceError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("PullResult must be frozen")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucket_values_are_json_friendly_strings() -> None:
|
||||||
|
"""Bucket values serialize cleanly to JSON (string-enum contract)."""
|
||||||
|
import json
|
||||||
|
payload = {
|
||||||
|
"j1": Bucket.REJECTED_AT_999.value,
|
||||||
|
"j2": Bucket.NEVER_SUBMITTED.value,
|
||||||
|
}
|
||||||
|
# Round-trip — no enum leakage into the JSON output.
|
||||||
|
assert json.loads(json.dumps(payload)) == {
|
||||||
|
"j1": "REJECTED_AT_999",
|
||||||
|
"j2": "NEVER_SUBMITTED",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user