feat(sp41): 999-ack dump + classify NOT_IN_835 into rejected-at-999 vs never-submitted

This commit is contained in:
Cyclone
2026-07-07 23:36:21 -06:00
parent b0ad0c27b8
commit cfe9c25d9d
2 changed files with 584 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
"""999-ack dump from Gainwell for MarJun 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",
}