feat(fusion_accounting_followup): level_resolver service
Made-with: Cursor
This commit is contained in:
52
fusion_accounting_followup/services/level_resolver.py
Normal file
52
fusion_accounting_followup/services/level_resolver.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user