53 lines
1.7 KiB
Python
53 lines
1.7 KiB
Python
"""Level resolver: which follow-up level should fire for this partner?
|
|
|
|
Pure-Python: caller passes the aging report + the configured levels list,
|
|
and we pick the highest-numbered level whose threshold is met."""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
@dataclass
|
|
class FollowupLevelSpec:
|
|
sequence: int
|
|
name: str
|
|
delay_days: int
|
|
tone: str
|
|
|
|
def __post_init__(self):
|
|
if self.tone not in ('gentle', 'firm', 'legal'):
|
|
raise ValueError(f"Invalid tone: {self.tone}")
|
|
|
|
|
|
def resolve_level(*, aging_report, levels: list[FollowupLevelSpec]) -> FollowupLevelSpec | None:
|
|
"""Pick the highest-sequence level whose delay_days has been crossed by
|
|
the most-overdue line in the aging report. Returns None if no overdue
|
|
lines or no levels configured."""
|
|
if not levels or not aging_report:
|
|
return None
|
|
max_days_overdue = _max_days_overdue(aging_report)
|
|
if max_days_overdue <= 0:
|
|
return None
|
|
levels_sorted = sorted(levels, key=lambda l: l.sequence, reverse=True)
|
|
for level in levels_sorted:
|
|
if level.delay_days <= max_days_overdue:
|
|
return level
|
|
return None
|
|
|
|
|
|
def _max_days_overdue(aging_report) -> int:
|
|
"""Return the actual max days-overdue tracked on the report, falling
|
|
back to the highest populated bucket's lower bound when an older
|
|
aging report (without `max_days_overdue`) is passed in."""
|
|
tracked = getattr(aging_report, 'max_days_overdue', 0) or 0
|
|
if tracked:
|
|
return tracked
|
|
max_days = 0
|
|
for b in aging_report.buckets:
|
|
if b.name == 'current' or b.amount <= 0:
|
|
continue
|
|
if b.days_max is None:
|
|
max_days = max(max_days, b.days_min)
|
|
else:
|
|
max_days = max(max_days, b.days_min)
|
|
return max_days
|