# Part of Fusion Accounting. See LICENSE file for full copyright and licensing details. from dateutil.relativedelta import relativedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError class FusionFollowupLevel(models.Model): """Defines escalation levels for payment follow-up reminders. Each level represents a stage in the collection process, configured with a delay (days past the invoice due date) and communication channels (email, SMS, letter). Levels are ordered by sequence so the system can automatically escalate from gentle reminders to more urgent notices. """ _name = 'fusion.followup.level' _description = "Fusion Payment Follow-up Level" _order = 'sequence, id' # ---- Core Fields ---- name = fields.Char( string="Follow-up Action", required=True, translate=True, help="Short label for this follow-up step (e.g. 'First Reminder').", ) description = fields.Html( string="Message Body", translate=True, help="Default message included in the follow-up communication.", ) sequence = fields.Integer( string="Sequence", default=10, help="Determines the escalation order. Lower values run first.", ) delay = fields.Integer( string="Due Days", required=True, default=15, help="Number of days after the invoice due date before this level triggers.", ) company_id = fields.Many2one( comodel_name='res.company', string="Company", required=True, default=lambda self: self.env.company, ) active = fields.Boolean( string="Active", default=True, ) # ---- Communication Channels ---- send_email = fields.Boolean( string="Send Email", default=True, help="Automatically send an email when this follow-up level is executed.", ) send_sms = fields.Boolean( string="Send SMS", default=False, help="Send an SMS notification when this follow-up level is executed.", ) send_letter = fields.Boolean( string="Print Letter", default=False, help="Generate a printable letter when this follow-up level is executed.", ) # ---- Templates ---- email_template_id = fields.Many2one( comodel_name='mail.template', string="Email Template", domain="[('model', '=', 'res.partner')]", help="Email template to use. Leave empty to use the default follow-up template.", ) sms_template_id = fields.Many2one( comodel_name='sms.template', string="SMS Template", domain="[('model', '=', 'res.partner')]", help="SMS template to use when the SMS channel is enabled.", ) # ---- Options ---- join_invoices = fields.Boolean( string="Attach Open Invoices", default=False, help="When enabled, PDF copies of open invoices are attached to the email.", ) # -------------------------------------------------- # Helpers # -------------------------------------------------- def _get_next_level(self): """Return the follow-up level that comes after this one. :returns: A ``fusion.followup.level`` recordset (single or empty). """ self.ensure_one() return self.search([ ('company_id', '=', self.company_id.id), ('sequence', '>', self.sequence), ], order='sequence, id', limit=1) class FusionFollowupLine(models.Model): """Tracks the follow-up state for a specific partner. Each record links a partner to their current follow-up level and stores the date of the last action. Computed fields determine the next action date, overdue amounts, and whether action is needed. """ _name = 'fusion.followup.line' _description = "Fusion Partner Follow-up Tracking" _order = 'next_followup_date asc, id' _rec_name = 'partner_id' # ---- Relational Fields ---- partner_id = fields.Many2one( comodel_name='res.partner', string="Partner", required=True, ondelete='cascade', index=True, ) company_id = fields.Many2one( comodel_name='res.company', string="Company", required=True, default=lambda self: self.env.company, ) followup_level_id = fields.Many2one( comodel_name='fusion.followup.level', string="Current Level", domain="[('company_id', '=', company_id)]", help="The most recent follow-up level applied to this partner.", ) # ---- Date Fields ---- date = fields.Date( string="Last Follow-up Date", help="Date of the most recent follow-up action.", ) next_followup_date = fields.Date( string="Next Action Date", compute='_compute_next_followup_date', store=True, help="Calculated date for the next follow-up step.", ) # ---- Computed Amounts ---- overdue_amount = fields.Monetary( string="Total Overdue", compute='_compute_overdue_values', currency_field='currency_id', store=True, help="Sum of all overdue receivable amounts for this partner.", ) overdue_count = fields.Integer( string="Overdue Invoices", compute='_compute_overdue_values', store=True, help="Number of overdue invoices for this partner.", ) currency_id = fields.Many2one( comodel_name='res.currency', string="Currency", related='company_id.currency_id', store=True, readonly=True, ) # ---- Status ---- followup_status = fields.Selection( selection=[ ('in_need', 'In Need of Action'), ('with_overdue', 'With Overdue Invoices'), ('no_action_needed', 'No Action Needed'), ], string="Follow-up Status", compute='_compute_followup_status', store=True, help="Indicates whether this partner requires a follow-up action.", ) # ---- SQL Constraint ---- _sql_constraints = [ ( 'partner_company_unique', 'UNIQUE(partner_id, company_id)', 'A partner can only have one follow-up tracking record per company.', ), ] # -------------------------------------------------- # Computed Fields # -------------------------------------------------- @api.depends('date', 'followup_level_id', 'followup_level_id.delay') def _compute_next_followup_date(self): """Calculate the next follow-up date based on the current level delay. If no level is assigned the next date equals the last follow-up date. When no date exists at all the field stays empty. """ for line in self: if line.date and line.followup_level_id: next_level = line.followup_level_id._get_next_level() if next_level: line.next_followup_date = line.date + relativedelta( days=next_level.delay - line.followup_level_id.delay, ) else: # Already at the highest level; re-trigger after same delay line.next_followup_date = line.date + relativedelta( days=line.followup_level_id.delay, ) elif line.date: line.next_followup_date = line.date else: line.next_followup_date = False @api.depends('partner_id', 'company_id') def _compute_overdue_values(self): """Compute overdue totals from the partner's unpaid receivable move lines.""" today = fields.Date.context_today(self) for line in self: overdue_lines = self.env['account.move.line'].search([ ('partner_id', '=', line.partner_id.commercial_partner_id.id), ('company_id', '=', line.company_id.id), ('account_id.account_type', '=', 'asset_receivable'), ('parent_state', '=', 'posted'), ('reconciled', '=', False), ('date_maturity', '<', today), ]) line.overdue_amount = sum(overdue_lines.mapped('amount_residual')) line.overdue_count = len(overdue_lines.mapped('move_id')) @api.depends('overdue_amount', 'next_followup_date') def _compute_followup_status(self): """Determine the follow-up status for each tracking record. * **in_need** – there are overdue invoices and the next follow-up date has been reached. * **with_overdue** – there are overdue invoices but the next action date is still in the future. * **no_action_needed** – nothing is overdue. """ today = fields.Date.context_today(self) for line in self: if line.overdue_amount <= 0: line.followup_status = 'no_action_needed' elif line.next_followup_date and line.next_followup_date > today: line.followup_status = 'with_overdue' else: line.followup_status = 'in_need' # -------------------------------------------------- # Business Logic # -------------------------------------------------- def compute_followup_status(self): """Manually recompute overdue values and status. Useful for the UI refresh button and scheduled actions. """ self._compute_overdue_values() self._compute_followup_status() return True def execute_followup(self): """Execute the follow-up action for the current level. Sends emails and/or SMS messages based on the channel settings of the current follow-up level, then advances the partner to the next level. :raises UserError: If no follow-up level is set. """ self.ensure_one() if not self.followup_level_id: raise UserError(_( "No follow-up level is set for partner '%s'. " "Please configure follow-up levels first.", self.partner_id.display_name, )) level = self.followup_level_id partner = self.partner_id # ---- Send Email ---- if level.send_email: template = level.email_template_id or self.env.ref( 'fusion_accounting.email_template_fusion_followup_default', raise_if_not_found=False, ) if template: attachment_ids = [] if level.join_invoices: attachment_ids = self._get_invoice_attachments(partner) template.send_mail( partner.id, force_send=True, email_values={'attachment_ids': attachment_ids}, ) # ---- Send SMS ---- if level.send_sms and level.sms_template_id: try: level.sms_template_id._send_sms(partner.id) except Exception: # SMS delivery failures should not block the follow-up process pass # ---- Advance to next level ---- next_level = level._get_next_level() self.write({ 'date': fields.Date.context_today(self), 'followup_level_id': next_level.id if next_level else level.id, }) return True def _get_invoice_attachments(self, partner): """Generate PDF attachments for the partner's open invoices. :param partner: A ``res.partner`` recordset. :returns: List of ``ir.attachment`` IDs. """ overdue_invoices = self.env['account.move'].search([ ('partner_id', '=', partner.commercial_partner_id.id), ('company_id', '=', self.company_id.id), ('move_type', 'in', ('out_invoice', 'out_debit')), ('payment_state', 'in', ('not_paid', 'partial')), ('state', '=', 'posted'), ]) if not overdue_invoices: return [] pdf_report = self.env.ref('account.account_invoices', raise_if_not_found=False) if not pdf_report: return [] attachment_ids = [] for invoice in overdue_invoices: content, _content_type = self.env['ir.actions.report']._render( pdf_report.report_name, invoice.ids, ) attachment = self.env['ir.attachment'].create({ 'name': f"{invoice.name}.pdf", 'type': 'binary', 'raw': content, 'mimetype': 'application/pdf', 'res_model': 'account.move', 'res_id': invoice.id, }) attachment_ids.append(attachment.id) return attachment_ids