feat(fusion_accounting_followup): overdue_aging service with 6 buckets
Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
from . import reports
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Follow-up',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import overdue_aging
|
||||
|
||||
88
fusion_accounting_followup/services/overdue_aging.py
Normal file
88
fusion_accounting_followup/services/overdue_aging.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Aging bucket primitives.
|
||||
|
||||
Pure-Python: callers pass a list of move-line dicts with `date_maturity`
|
||||
and `amount_residual`; we bucket them into 0/30/60/90/120+ days overdue."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
|
||||
|
||||
BUCKETS = [
|
||||
('current', 0, 0),
|
||||
('1_30', 1, 30),
|
||||
('31_60', 31, 60),
|
||||
('61_90', 61, 90),
|
||||
('91_120', 91, 120),
|
||||
('120_plus', 121, None),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgingBucket:
|
||||
name: str
|
||||
days_min: int
|
||||
days_max: int | None
|
||||
amount: float = 0.0
|
||||
line_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgingReport:
|
||||
as_of: date
|
||||
buckets: list[AgingBucket] = field(default_factory=list)
|
||||
total_amount: float = 0.0
|
||||
total_overdue_amount: float = 0.0
|
||||
line_count: int = 0
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'as_of': str(self.as_of),
|
||||
'total_amount': self.total_amount,
|
||||
'total_overdue_amount': self.total_overdue_amount,
|
||||
'line_count': self.line_count,
|
||||
'buckets': [{
|
||||
'name': b.name, 'days_min': b.days_min, 'days_max': b.days_max,
|
||||
'amount': b.amount, 'line_count': b.line_count,
|
||||
} for b in self.buckets],
|
||||
}
|
||||
|
||||
|
||||
def compute_aging(*, move_lines: list[dict], as_of: date | None = None) -> AgingReport:
|
||||
"""Bucket move-line dicts into aging brackets.
|
||||
|
||||
Each dict needs: date_maturity (date), amount_residual (float).
|
||||
`as_of` defaults to today."""
|
||||
as_of = as_of or date.today()
|
||||
report = AgingReport(as_of=as_of)
|
||||
for name, days_min, days_max in BUCKETS:
|
||||
report.buckets.append(AgingBucket(name=name, days_min=days_min, days_max=days_max))
|
||||
|
||||
for ml in move_lines:
|
||||
maturity = ml.get('date_maturity')
|
||||
amount = ml.get('amount_residual', 0.0)
|
||||
if not maturity:
|
||||
continue
|
||||
days_overdue = (as_of - maturity).days
|
||||
bucket = _find_bucket(report.buckets, days_overdue)
|
||||
if bucket:
|
||||
bucket.amount += amount
|
||||
bucket.line_count += 1
|
||||
report.total_amount += amount
|
||||
if days_overdue > 0:
|
||||
report.total_overdue_amount += amount
|
||||
report.line_count += 1
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def _find_bucket(buckets: list[AgingBucket], days_overdue: int) -> AgingBucket | None:
|
||||
if days_overdue <= 0:
|
||||
return next((b for b in buckets if b.name == 'current'), None)
|
||||
for b in buckets:
|
||||
if b.name == 'current':
|
||||
continue
|
||||
if b.days_max is None and days_overdue >= b.days_min:
|
||||
return b
|
||||
if b.days_max is not None and b.days_min <= days_overdue <= b.days_max:
|
||||
return b
|
||||
return None
|
||||
@@ -0,0 +1 @@
|
||||
from . import test_overdue_aging
|
||||
|
||||
69
fusion_accounting_followup/tests/test_overdue_aging.py
Normal file
69
fusion_accounting_followup/tests/test_overdue_aging.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_followup.services.overdue_aging import (
|
||||
compute_aging, BUCKETS,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestOverdueAging(TransactionCase):
|
||||
|
||||
def test_empty_lines_returns_zero_buckets(self):
|
||||
report = compute_aging(move_lines=[], as_of=date(2026, 4, 19))
|
||||
self.assertEqual(report.total_amount, 0)
|
||||
self.assertEqual(len(report.buckets), len(BUCKETS))
|
||||
for b in report.buckets:
|
||||
self.assertEqual(b.amount, 0)
|
||||
|
||||
def test_current_bucket_for_future_maturity(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': date(2026, 5, 19), 'amount_residual': 100}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
current = next(b for b in report.buckets if b.name == 'current')
|
||||
self.assertEqual(current.amount, 100)
|
||||
self.assertEqual(report.total_overdue_amount, 0)
|
||||
|
||||
def test_30_day_bucket(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
b = next(b for b in report.buckets if b.name == '1_30')
|
||||
self.assertEqual(b.amount, 200)
|
||||
|
||||
def test_60_day_bucket(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 300}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
b = next(b for b in report.buckets if b.name == '31_60')
|
||||
self.assertEqual(b.amount, 300)
|
||||
|
||||
def test_120_plus_bucket(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
b = next(b for b in report.buckets if b.name == '120_plus')
|
||||
self.assertEqual(b.amount, 500)
|
||||
|
||||
def test_total_overdue_excludes_current(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [
|
||||
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
|
||||
{'date_maturity': as_of - timedelta(days=10), 'amount_residual': 200},
|
||||
{'date_maturity': as_of - timedelta(days=50), 'amount_residual': 300},
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
self.assertEqual(report.total_amount, 600)
|
||||
self.assertEqual(report.total_overdue_amount, 500)
|
||||
|
||||
def test_buckets_sum_equals_total(self):
|
||||
as_of = date(2026, 4, 19)
|
||||
lines = [
|
||||
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
|
||||
{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200},
|
||||
{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300},
|
||||
{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500},
|
||||
]
|
||||
report = compute_aging(move_lines=lines, as_of=as_of)
|
||||
bucket_sum = sum(b.amount for b in report.buckets)
|
||||
self.assertAlmostEqual(bucket_sum, report.total_amount, places=2)
|
||||
Reference in New Issue
Block a user