Initial commit
This commit is contained in:
365
Fusion Accounting/models/followup.py
Normal file
365
Fusion Accounting/models/followup.py
Normal file
@@ -0,0 +1,365 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user