feat(sp41): 999-ack dump + classify NOT_IN_835 into rejected-at-999 vs never-submitted
This commit is contained in:
@@ -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