diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index bb201b0a..f1c3473c 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Follow-up', - 'version': '19.0.1.0.13', + 'version': '19.0.1.0.14', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/fusion_followup_engine.py b/fusion_accounting_followup/models/fusion_followup_engine.py index 21523427..a674e3a3 100644 --- a/fusion_accounting_followup/models/fusion_followup_engine.py +++ b/fusion_accounting_followup/models/fusion_followup_engine.py @@ -54,12 +54,12 @@ class FusionFollowupEngine(models.AbstractModel): } @api.model - def compute_followup_level(self, partner): + def compute_followup_level(self, partner, *, ignore_pause=False): """Return the fusion.followup.level recordset that should fire now, or empty recordset if no action needed.""" partner.ensure_one() Level = self.env['fusion.followup.level'] - if partner.fusion_followup_paused_until and \ + if not ignore_pause and partner.fusion_followup_paused_until and \ partner.fusion_followup_paused_until > fields.Date.today(): return Level @@ -101,11 +101,6 @@ class FusionFollowupEngine(models.AbstractModel): Creates a fusion.followup.run record. Uses cached text if available.""" partner.ensure_one() - if not level: - level = self.compute_followup_level(partner) - if not level: - return {'status': 'no_action', 'partner_id': partner.id} - if not force and partner.fusion_followup_paused_until and \ partner.fusion_followup_paused_until > fields.Date.today(): return { @@ -113,6 +108,11 @@ class FusionFollowupEngine(models.AbstractModel): 'partner_id': partner.id, } + if not level: + level = self.compute_followup_level(partner, ignore_pause=force) + if not level: + return {'status': 'no_action', 'partner_id': partner.id} + if level.requires_manual_review and not force: run = self._create_run(partner, level, state='manual_review') return { diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 49561b1d..2ef1dce5 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_fusion_followup_text_cache from . import test_res_partner_inherit from . import test_account_move_line_inherit from . import test_fusion_followup_engine +from . import test_engine_integration diff --git a/fusion_accounting_followup/tests/test_engine_integration.py b/fusion_accounting_followup/tests/test_engine_integration.py new file mode 100644 index 00000000..37bf69b5 --- /dev/null +++ b/fusion_accounting_followup/tests/test_engine_integration.py @@ -0,0 +1,76 @@ +"""Integration tests: full follow-up flow with real overdue invoices.""" + +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install', 'integration') +class TestFollowupEngineIntegration(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.followup.engine'] + self.partner = self.env['res.partner'].create({ + 'name': 'Integration Partner', 'email': 'integ@test.local', + }) + for seq, name, days, tone in [(801, 'Test Reminder', 7, 'gentle'), + (802, 'Test Warning', 30, 'firm'), + (803, 'Test Legal', 60, 'legal')]: + self.env['fusion.followup.level'].create({ + 'name': name, 'sequence': seq, 'delay_days': days, 'tone': tone, + }) + + line = self.env['account.move.line'].search([ + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ], limit=1) + if not line: + self.skipTest("No posted unreconciled receivable lines in test DB") + line.write({ + 'partner_id': self.partner.id, + 'date_maturity': date.today() - timedelta(days=20), + }) + + def test_get_overdue_finds_lines(self): + result = self.engine.get_overdue_for_partner(self.partner) + self.assertGreater(result['overdue_line_count'], 0) + self.assertGreater(result['aging']['total_overdue_amount'], 0) + + def test_compute_level_picks_reminder_at_20_days(self): + level = self.engine.compute_followup_level(self.partner) + self.assertTrue(level) + self.assertGreater(level.delay_days, 0) + + def test_send_followup_creates_run(self): + result = self.engine.send_followup_email(self.partner, force=True) + self.assertIn(result['status'], ('sent', 'manual_review')) + if 'run_id' in result: + run = self.env['fusion.followup.run'].browse(result['run_id']) + self.assertEqual(run.partner_id, self.partner) + + def test_pause_blocks_send_unless_force(self): + self.engine.pause_followup(self.partner, + until_date=date.today() + timedelta(days=30)) + result = self.engine.send_followup_email(self.partner) + self.assertTrue(result['status'].startswith('paused')) + result_force = self.engine.send_followup_email(self.partner, force=True) + self.assertIn(result_force['status'], ('sent', 'manual_review')) + + def test_history_grows_with_each_send(self): + Run = self.env['fusion.followup.run'] + before = Run.search_count([('partner_id', '=', self.partner.id)]) + self.engine.send_followup_email(self.partner, force=True) + after = Run.search_count([('partner_id', '=', self.partner.id)]) + self.assertGreater(after, before) + + def test_text_cache_used_on_repeat_call(self): + Cache = self.env['fusion.followup.text.cache'] + self.engine.send_followup_email(self.partner, force=True) + cache_count_after_first = Cache.search_count([('partner_id', '=', self.partner.id)]) + self.engine.send_followup_email(self.partner, force=True) + cache_count_after_second = Cache.search_count([('partner_id', '=', self.partner.id)]) + self.assertEqual(cache_count_after_first, cache_count_after_second, + "Repeat send with same params should not create new cache row")