diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 316a3177..9cdb79a1 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.6', + 'version': '19.0.1.0.7', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index c7fb1f45..d1d9e020 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -1 +1,2 @@ from . import fusion_followup_level +from . import fusion_followup_run diff --git a/fusion_accounting_followup/models/fusion_followup_run.py b/fusion_accounting_followup/models/fusion_followup_run.py new file mode 100644 index 00000000..327039ea --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_run.py @@ -0,0 +1,54 @@ +"""Audit record of one follow-up execution (per partner per level).""" + +from odoo import _, api, fields, models + + +STATE_SELECTION = [ + ('draft', 'Draft'), + ('sent', 'Sent'), + ('manual_review', 'Manual Review'), + ('skipped', 'Skipped'), + ('failed', 'Failed'), +] + + +class FusionFollowupRun(models.Model): + _name = "fusion.followup.run" + _description = "Fusion Follow-up Run (Per-Partner Audit)" + _order = "execution_date desc, id desc" + + partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade') + company_id = fields.Many2one('res.company', required=True, + default=lambda self: self.env.company) + level_id = fields.Many2one('fusion.followup.level', ondelete='restrict') + + execution_date = fields.Datetime(default=fields.Datetime.now, required=True) + state = fields.Selection(STATE_SELECTION, default='draft', required=True) + + overdue_amount = fields.Float() + longest_overdue_days = fields.Integer() + + risk_score = fields.Integer() + risk_band = fields.Selection([ + ('low', 'Low'), ('medium', 'Medium'), + ('high', 'High'), ('critical', 'Critical'), + ]) + + subject = fields.Char() + body = fields.Text() + tone_used = fields.Selection([ + ('gentle', 'Gentle'), ('firm', 'Firm'), ('legal', 'Legal'), + ]) + sent_to_email = fields.Char() + + text_was_ai_generated = fields.Boolean(default=False) + ai_provider = fields.Char(help="LLM provider name (openai, claude, etc.) if AI was used.") + + notes = fields.Text() + error_message = fields.Text() + + def action_mark_sent(self): + self.write({'state': 'sent'}) + + def action_mark_failed(self, error: str = ''): + self.write({'state': 'failed', 'error_message': error}) diff --git a/fusion_accounting_followup/security/ir.model.access.csv b/fusion_accounting_followup/security/ir.model.access.csv index ccb4588c..892f6622 100644 --- a/fusion_accounting_followup/security/ir.model.access.csv +++ b/fusion_accounting_followup/security/ir.model.access.csv @@ -1,3 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fusion_followup_level_user,fusion.followup.level.user,model_fusion_followup_level,base.group_user,1,0,0,0 access_fusion_followup_level_admin,fusion.followup.level.admin,model_fusion_followup_level,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_followup_run_user,fusion.followup.run.user,model_fusion_followup_run,base.group_user,1,0,0,0 +access_fusion_followup_run_admin,fusion.followup.run.admin,model_fusion_followup_run,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 67265c7b..fe74fe4c 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -4,3 +4,4 @@ from . import test_risk_scorer from . import test_tone_selector from . import test_followup_text_generator from . import test_fusion_followup_level +from . import test_fusion_followup_run diff --git a/fusion_accounting_followup/tests/test_fusion_followup_run.py b/fusion_accounting_followup/tests/test_fusion_followup_run.py new file mode 100644 index 00000000..25e5c7c1 --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_run.py @@ -0,0 +1,44 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionFollowupRun(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'Run Test Partner'}) + cls.level = cls.env['fusion.followup.level'].create({ + 'name': 'Reminder', 'sequence': 301, 'delay_days': 7, 'tone': 'gentle', + }) + + def test_create_minimal(self): + run = self.env['fusion.followup.run'].create({ + 'partner_id': self.partner.id, + 'level_id': self.level.id, + }) + self.assertEqual(run.state, 'draft') + self.assertTrue(run.execution_date) + + def test_action_mark_sent(self): + run = self.env['fusion.followup.run'].create({ + 'partner_id': self.partner.id, + 'level_id': self.level.id, + }) + run.action_mark_sent() + self.assertEqual(run.state, 'sent') + + def test_action_mark_failed_records_error(self): + run = self.env['fusion.followup.run'].create({ + 'partner_id': self.partner.id, + }) + run.action_mark_failed(error='SMTP unreachable') + self.assertEqual(run.state, 'failed') + self.assertEqual(run.error_message, 'SMTP unreachable') + + def test_partner_required(self): + with self.assertRaises(Exception): + self.env['fusion.followup.run'].create({ + 'level_id': self.level.id, + })