You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

554 lines
27 KiB
Python

8 months ago
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models, _
from odoo.tools.misc import format_date
from datetime import datetime, timedelta
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ResPartner(models.Model):
_inherit = 'res.partner'
followup_next_action_date = fields.Date(string='Next reminder', copy=False, company_dependent=True,
help="The date before which no follow-up action should be taken.")
# readonly=False in order to be able to edit it directly in the view form, without having to click on 'Edit'
# It's mainly used for usability purposes to easily include/exclude unreconciled move lines
unreconciled_aml_ids = fields.One2many('account.move.line', compute='_compute_unreconciled_aml_ids', readonly=False)
unpaid_invoice_ids = fields.One2many('account.move', compute='_compute_unpaid_invoices')
unpaid_invoices_count = fields.Integer(compute='_compute_unpaid_invoices')
total_due = fields.Monetary(
compute='_compute_total_due',
groups='account.group_account_readonly,account.group_account_invoice')
total_overdue = fields.Monetary(
compute='_compute_total_due',
groups='account.group_account_readonly,account.group_account_invoice')
followup_status = fields.Selection(
[('in_need_of_action', 'In need of action'), ('with_overdue_invoices', 'With overdue invoices'), ('no_action_needed', 'No action needed')],
compute='_compute_followup_status',
string='Follow-up Status',
search='_search_status',
groups='account.group_account_readonly,account.group_account_invoice',
)
followup_line_id = fields.Many2one(
comodel_name='account_followup.followup.line',
string="Follow-up Level",
compute='_compute_followup_status',
inverse='_set_followup_line_on_unreconciled_amls',
search='_search_followup_line',
groups='account.group_account_readonly,account.group_account_invoice',
)
followup_reminder_type = fields.Selection([('automatic', 'Automatic'), ('manual', 'Manual')], string="Reminders", default='automatic')
type = fields.Selection(selection_add=[('followup', 'Follow-up Address')])
followup_responsible_id = fields.Many2one(
comodel_name='res.users',
string='Responsible',
help="Optionally you can assign a user to this field, which will make him responsible for the activities. If empty, we will find someone responsible.",
tracking=True,
copy=False,
company_dependent=True,
groups='account.group_account_readonly,account.group_account_invoice',
)
def _get_name(self):
# OVERRIDE base/models/res_partner.py
name = super()._get_name()
# Add a placeholder name for the followup address
if self.type == 'followup' and not self.name:
name += dict(self.fields_get(['type'])['type']['selection'])[self.type]
return name
def _search_status(self, operator, value):
"""
Compute the search on the field 'followup_status'
"""
if isinstance(value, str):
value = [value]
value = [v for v in value if v in ['in_need_of_action', 'with_overdue_invoices', 'no_action_needed']]
if operator not in ('in', '=') or not value:
return []
followup_data = self._query_followup_data(all_partners=True)
return [('id', 'in', [
d['partner_id']
for d in followup_data.values()
if d['followup_status'] in value
])]
def _search_followup_line(self, operator, value):
company_domain = [('company_id', '=', self.env.company.id)]
if isinstance(value, str):
domain = [('name', operator, value)]
elif isinstance(value, (int, list, tuple)):
domain = [('id', operator, value)]
first_followup_line = self.env['account_followup.followup.line'].search(company_domain, order="delay asc", limit=1)
line_ids = set(self.env['account_followup.followup.line'].search(domain+company_domain).ids)
if first_followup_line.id in line_ids:
# If we are searching for the 1st followup line, we also have to include None (aka no followup level at all)
# The result from the query is None when a partner is not yet at a followup level
line_ids.add(None)
followup_data = self._query_followup_data(all_partners=True)
return [('id', 'in', [
d['partner_id']
for d in followup_data.values()
if d['followup_line_id'] in line_ids
])]
@api.depends('unreconciled_aml_ids', 'followup_next_action_date')
@api.depends_context('company', 'allowed_company_ids')
def _compute_total_due(self):
today = fields.Date.context_today(self)
for partner in self:
total_overdue = 0
total_due = 0
for aml in partner.unreconciled_aml_ids:
is_overdue = today >= aml.date_maturity if aml.date_maturity else today >= aml.date
if aml.company_id == self.env.company and not aml.blocked:
total_due += aml.amount_residual
if is_overdue:
total_overdue += aml.amount_residual
partner.total_due = total_due
partner.total_overdue = total_overdue
@api.depends('unreconciled_aml_ids', 'followup_next_action_date')
@api.depends_context('company', 'allowed_company_ids')
def _compute_followup_status(self):
followup_lines_info = self._get_followup_lines_info()
today = fields.Date.context_today(self)
for partner in self:
max_followup = partner._included_unreconciled_aml_max_followup()
max_aml_delay = max_followup.get('max_delay') or 0
next_followup_delay = max_followup.get('next_followup_delay') or 0
has_overdue_invoices = max_followup.get('has_overdue_invoices')
most_delayed_aml = max_followup.get('most_delayed_aml')
highest_followup_line = max_followup.get('highest_followup_line')
# computation of followup_status
new_status = 'no_action_needed'
if has_overdue_invoices and most_delayed_aml:
new_status = 'with_overdue_invoices'
next_followup_date_exceeded = today >= partner.followup_next_action_date if partner.followup_next_action_date else True
if max_aml_delay >= next_followup_delay and next_followup_date_exceeded and followup_lines_info:
new_status = 'in_need_of_action'
partner.followup_status = new_status
# computation of followup_line_id
new_line = highest_followup_line
if most_delayed_aml and (most_delayed_aml.last_followup_date or max_aml_delay >= next_followup_delay) and followup_lines_info:
index = highest_followup_line.id if highest_followup_line else None
next_line_id = followup_lines_info[index].get('next_followup_line_id')
new_line = self.env['account_followup.followup.line'].browse(next_line_id)
partner.followup_line_id = new_line
def _compute_unpaid_invoices(self):
for partner in self:
unpaid_receivable_lines = self.env['account.move.line'].search([
('company_id', '=', self.env.company.id),
('move_id.commercial_partner_id', '=', partner.id),
('parent_state', '=', 'posted'),
('move_id.payment_state', 'in', ('not_paid', 'partial')),
('move_id.move_type', 'in', self.env['account.move'].get_sale_types()),
('account_id.account_type', '=', 'asset_receivable'),
])
unpaid_invoices = unpaid_receivable_lines.move_id
partner.unpaid_invoice_ids = unpaid_invoices
partner.unpaid_invoices_count = len(unpaid_invoices)
def action_view_unpaid_invoices(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type")
action['domain'] = [('id', 'in', self.unpaid_invoice_ids.ids)]
action['context'] = {
'default_move_type': 'out_invoice',
'move_type': 'out_invoice',
'journal_type': 'sale',
'partner_id': self.id
}
return action
@api.depends('invoice_ids')
@api.depends_context('company', 'allowed_company_ids')
def _compute_unreconciled_aml_ids(self):
values = {
read['partner_id'][0]: read['line_ids']
for read in self.env['account.move.line'].read_group(
domain=self._get_unreconciled_aml_domain(),
fields=['line_ids:array_agg(id)'],
groupby=['partner_id']
)
}
for partner in self:
partner.unreconciled_aml_ids = values.get(partner.id, False)
def _set_followup_line_on_unreconciled_amls(self):
today = fields.Date.today()
for partner in self:
current_followup_line = partner.followup_line_id
previous_followup_line = self.env['account_followup.followup.line'].search([('delay', '<', current_followup_line.delay), ('company_id', '=', self.env.company.id)], order='delay desc', limit=1)
for unreconciled_aml in partner.unreconciled_aml_ids:
if not unreconciled_aml.blocked:
unreconciled_aml.followup_line_id = previous_followup_line
# When a specific followup line is manually selected, we consider the followup as processed
unreconciled_aml.last_followup_date = today
def _get_unreconciled_aml_domain(self):
return [
('reconciled', '=', False),
('account_id.deprecated', '=', False),
('account_id.account_type', '=', 'asset_receivable'),
('move_id.state', '=', 'posted'),
('partner_id', 'in', self.ids),
('company_id', '=', self.env.company.id),
]
def _get_followup_responsible(self):
self.ensure_one()
responsible_type = self.followup_line_id.activity_default_responsible_type
if responsible_type == 'account_manager' and self.user_id:
return self.user_id
most_delayed_aml = self._included_unreconciled_aml_max_followup().get('most_delayed_aml')
if responsible_type == 'salesperson' and most_delayed_aml and most_delayed_aml.move_id.invoice_user_id:
return most_delayed_aml.move_id.invoice_user_id
if self.followup_responsible_id:
return self.followup_responsible_id
if self.user_id:
return self.user_id
if most_delayed_aml and most_delayed_aml.move_id.invoice_user_id:
return most_delayed_aml.move_id.invoice_user_id
return self.env.user
def _get_all_followup_contacts(self):
""" Returns every contact of type 'followup' in the children of self.
"""
self.ensure_one()
return self.child_ids.filtered(lambda partner: partner.type == 'followup')
def _included_unreconciled_aml_max_followup(self):
""" Computes the maximum delay in days and the highest level of followup (followup line with highest delay) of all the unreconciled amls included.
Also returns the delay for the next level (after the highest_followup_line), the most delayed aml and a boolean specifying if any invoice is overdue.
:return dict with key/values: most_delayed_aml, max_delay, highest_followup_line, next_followup_delay, has_overdue_invoices
"""
self.ensure_one()
today = fields.Date.context_today(self)
highest_followup_line = None
most_delayed_aml = self.env['account.move.line']
first_followup_line = self._get_first_followup_level()
# Minimum value for delay, will always be smaller than any other delay
max_delay = first_followup_line.delay - 1
has_overdue_invoices = False
for aml in self.unreconciled_aml_ids:
aml_delay = (today - (aml.date_maturity or aml.date)).days
is_overdue = aml_delay >= 0
if is_overdue:
has_overdue_invoices = True
if aml.company_id == self.env.company and not aml.blocked:
if aml.followup_line_id and aml.followup_line_id.delay >= (highest_followup_line or first_followup_line).delay:
highest_followup_line = aml.followup_line_id
max_delay = max(max_delay, aml_delay)
if most_delayed_aml.amount_residual < aml.amount_residual:
most_delayed_aml = aml
followup_lines_info = self._get_followup_lines_info()
next_followup_delay = None
if followup_lines_info:
key = highest_followup_line.id if highest_followup_line else None
current_followup_line_info = followup_lines_info.get(key)
next_followup_delay = current_followup_line_info.get('next_delay')
return {
'most_delayed_aml': most_delayed_aml,
'max_delay': max_delay,
'highest_followup_line': highest_followup_line,
'next_followup_delay': next_followup_delay,
'has_overdue_invoices': has_overdue_invoices,
}
def _get_included_unreconciled_aml_ids(self):
self.ensure_one()
return self.unreconciled_aml_ids.filtered(lambda aml: not aml.blocked)
def _get_first_followup_level(self):
self.ensure_one()
return self.env['account_followup.followup.line'].search([('company_id', '=', self.env.company.id)], order='delay asc', limit=1)
def _update_next_followup_action_date(self, followup_line):
"""Updates the followup_next_action_date of the right account move lines
"""
self.ensure_one()
# Arbitrary 14 days delay (like the _get_next_date() method) if there is no followup_line
# This will be changed/removed in an upcoming improvement
next_date = followup_line._get_next_date() if followup_line else fields.Date.today() + timedelta(days=14)
self.followup_next_action_date = datetime.strftime(next_date, DEFAULT_SERVER_DATE_FORMAT)
msg = _('Next Reminder Date set to %s', format_date(self.env, self.followup_next_action_date))
self.message_post(body=msg)
today = fields.Date.today()
for aml in self._get_included_unreconciled_aml_ids():
aml.followup_line_id = followup_line
aml.last_followup_date = today
def open_action_followup(self):
self.ensure_one()
return {
'name': _("Overdue Payments for %s", self.display_name),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'views': [[self.env.ref('account_followup.customer_statements_form_view').id, 'form']],
'res_model': 'res.partner',
'res_id': self.id,
}
def send_followup_email(self, options):
"""
Send a follow-up report by email to customers in self
"""
for record in self:
options['partner_id'] = record.id
self.env['account.followup.report']._send_email(options)
def send_followup_sms(self, options):
"""
Send a follow-up report by sms to customers in self
"""
for partner in self:
options['partner_id'] = partner.id
self.env['account.followup.report']._send_sms(options)
def get_followup_html(self, options=None):
"""
Return the content of the follow-up report in HTML
"""
if options is None:
options = {}
options.update({
'partner_id': self.id,
'followup_line_id': self.followup_line_id,
'keep_summary': True
})
return self.env['account.followup.report'].with_context(print_mode=True, lang=self.lang or self.env.user.lang).get_followup_report_html(options)
def _get_followup_lines_info(self):
""" returns the followup plan of the current user's company
in the form of a dictionary with
* keys being the different possible lines of followup for account.move.line's (None or IDs of account_followup.followup.line)
* values being a dict of 3 elements:
- 'next_followup_line_id': the followup ID of the next followup line
- 'next_delay': the delay in days of the next followup line
"""
followup_lines = self.env['account_followup.followup.line'].search([('company_id', '=', self.env.company.id)], order="delay asc")
previous_line_id = None
followup_lines_info = {}
for line in followup_lines:
delay_in_days = line.delay
followup_lines_info[previous_line_id] = {
'next_followup_line_id': line.id,
'next_delay': delay_in_days,
}
previous_line_id = line.id
if previous_line_id:
followup_lines_info[previous_line_id] = {
'next_followup_line_id': previous_line_id,
'next_delay': delay_in_days,
}
return followup_lines_info
def _query_followup_data(self, all_partners=False):
# Allow mocking the current day for testing purpose.
today = fields.Date.context_today(self)
if not self.ids and not all_partners:
return {}
self.env['account.move.line'].check_access_rights('read')
sql = """
SELECT partner.id as partner_id,
ful.id as followup_line_id,
CASE WHEN partner.balance <= 0 THEN 'no_action_needed'
WHEN in_need_of_action_aml.id IS NOT NULL AND (prop_date.value_datetime IS NULL OR prop_date.value_datetime::date <= %(current_date)s) THEN 'in_need_of_action'
WHEN exceeded_unreconciled_aml.id IS NOT NULL THEN 'with_overdue_invoices'
ELSE 'no_action_needed' END as followup_status
FROM (
SELECT partner.id,
max(current_followup_line.delay) as followup_delay,
SUM(aml.balance) as balance
FROM res_partner partner
JOIN account_move_line aml ON aml.partner_id = partner.id
JOIN account_account account ON account.id = aml.account_id
JOIN account_move move ON move.id = aml.move_id
-- Get the followup line
LEFT JOIN LATERAL (
SELECT COALESCE(next_ful.id, ful.id) as id, COALESCE(next_ful.delay, ful.delay) as delay
FROM account_move_line line
LEFT OUTER JOIN account_followup_followup_line ful ON ful.id = aml.followup_line_id
LEFT OUTER JOIN account_followup_followup_line next_ful ON next_ful.id = (
SELECT next_ful.id FROM account_followup_followup_line next_ful
WHERE next_ful.delay > COALESCE(ful.delay, -999)
AND COALESCE(aml.date_maturity, aml.date) + next_ful.delay <= %(current_date)s
AND next_ful.company_id = %(company_id)s
ORDER BY next_ful.delay ASC
LIMIT 1
)
WHERE line.id = aml.id
AND aml.partner_id = partner.id
AND aml.balance > 0
) current_followup_line ON true
WHERE account.deprecated IS NOT TRUE
AND account.account_type = 'asset_receivable'
AND move.state = 'posted'
AND aml.reconciled IS NOT TRUE
AND aml.blocked IS FALSE
AND aml.company_id = %(company_id)s
{where}
GROUP BY partner.id
) partner
LEFT JOIN account_followup_followup_line ful ON ful.delay = partner.followup_delay AND ful.company_id = %(company_id)s
-- Get the followup status data
LEFT OUTER JOIN LATERAL (
SELECT line.id
FROM account_move_line line
JOIN account_account account ON line.account_id = account.id
JOIN account_move move ON line.move_id = move.id
LEFT JOIN account_followup_followup_line ful ON ful.id = line.followup_line_id
WHERE line.partner_id = partner.id
AND account.account_type = 'asset_receivable'
AND account.deprecated IS NOT TRUE
AND move.state = 'posted'
AND line.reconciled IS NOT TRUE
AND line.balance > 0
AND line.blocked IS FALSE
AND line.company_id = %(company_id)s
AND COALESCE(ful.delay, -999) <= partner.followup_delay
AND COALESCE(line.date_maturity, line.date) + COALESCE(ful.delay, -999) <= %(current_date)s
LIMIT 1
) in_need_of_action_aml ON true
LEFT OUTER JOIN LATERAL (
SELECT line.id
FROM account_move_line line
JOIN account_account account ON line.account_id = account.id
JOIN account_move move ON line.move_id = move.id
WHERE line.partner_id = partner.id
AND account.account_type = 'asset_receivable'
AND account.deprecated IS NOT TRUE
AND move.state = 'posted'
AND line.reconciled IS NOT TRUE
AND line.balance > 0
AND line.company_id = %(company_id)s
AND COALESCE(line.date_maturity, line.date) <= %(current_date)s
LIMIT 1
) exceeded_unreconciled_aml ON true
LEFT OUTER JOIN ir_property prop_date ON prop_date.res_id = CONCAT('res.partner,', partner.id)
AND prop_date.name = 'followup_next_action_date'
AND prop_date.company_id = %(company_id)s
""".format(
where="" if all_partners else "AND aml.partner_id in %(partner_ids)s",
)
params = {
'company_id': self.env.company.id,
'partner_ids': tuple(self.ids),
'current_date': today,
}
self.env['account.move.line'].flush_model()
self.env['res.partner'].flush_model()
self.env['account_followup.followup.line'].flush_model()
self.env.cr.execute(sql, params)
result = self.env.cr.dictfetchall()
result = {r['partner_id']: r for r in result}
return result
def _send_followup(self, options):
""" Send the follow-up to the partner, depending on selected options.
Can be overridden to include more ways of sending the follow-up.
"""
self.ensure_one()
followup_line = options.get('followup_line')
if options.get('email', followup_line.send_email):
self.send_followup_email(options)
if options.get('sms', followup_line.send_sms):
self.send_followup_sms(options)
def _execute_followup_partner(self, options=None):
""" Execute the actions to do with follow-ups for this partner (apart from printing).
This is either called when processing the follow-ups manually (wizard), or automatically (cron).
Automatic follow-ups can also be triggered manually with *action_manually_process_automatic_followups*.
When processing automatically, options is None.
Returns True if any action was processed, False otherwise
"""
self.ensure_one()
if options is None:
options = {}
if options.get('manual_followup', self.followup_status == 'in_need_of_action'):
followup_line = self.followup_line_id or self._get_first_followup_level()
if followup_line.create_activity:
# log a next activity for today
self.activity_schedule(
activity_type_id=followup_line.activity_type_id and followup_line.activity_type_id.id or self._default_activity_type().id,
note=followup_line.activity_note,
summary=followup_line.activity_summary,
user_id=(self._get_followup_responsible()).id
)
self._update_next_followup_action_date(followup_line)
self._send_followup(options={'followup_line': followup_line, **options})
return True
return False
def execute_followup(self, options):
""" Execute the actions to do with follow-ups for this partner.
This is called when processing the follow-ups manually, via the wizard.
options is a dictionary containing at least the following information:
- 'partner_id': id of partner (self)
- 'email': boolean to trigger the sending of email or not
- 'email_subject': subject of email
- 'followup_contacts': partners (contacts) to send the followup to
- 'body': email body
- 'attachment_ids': invoice attachments to join to email/letter
- 'sms': boolean to trigger the sending of sms or not
- 'sms_body': sms body
- 'print': boolean to trigger the printing of pdf letter or not
- 'manual_followup': boolean to indicate whether this followup is triggered via the manual reminder wizard
"""
self.ensure_one()
to_print = self._execute_followup_partner(options=options)
if options.get('print') and to_print:
return self.env['account.followup.report']._print_followup_letter(self, options)
def action_manually_process_automatic_followups(self):
for partner in self:
partner._execute_followup_partner()
def _cron_execute_followup_company(self):
followup_data = self._query_followup_data(all_partners=True)
in_need_of_action = self.env['res.partner'].browse([d['partner_id'] for d in followup_data.values() if d['followup_status'] == 'in_need_of_action'])
in_need_of_action_auto = in_need_of_action.filtered(lambda p: p.followup_line_id.auto_execute and p.followup_reminder_type == 'automatic')
for partner in in_need_of_action_auto:
try:
partner._execute_followup_partner()
except UserError as e:
# followup may raise exception due to configuration issues
# i.e. partner missing email
_logger.exception(e)
def _cron_execute_followup(self):
for company in self.env["res.company"].search([]):
self.with_context(allowed_company_ids=company.ids)._cron_execute_followup_company()