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.

556 lines
26 KiB
Python

# -*- 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)]
line_ids = set(self.env['account_followup.followup.line'].search(domain+company_domain).ids)
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):
all_data = self._query_followup_data()
for partner in self:
partner_data = all_data.get(partner._origin.id, {'followup_status': 'no_action_needed', 'followup_line_id': False})
partner.followup_status = partner_data['followup_status']
partner.followup_line_id = partner_data['followup_line_id']
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'),
('parent_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.
If no followup contacts are found, use the billing address
and default to contact if there isn't any for invoice
"""
self.ensure_one()
followup_contacts = self.child_ids.filtered(lambda partner: partner.type == 'followup')
if not followup_contacts:
followup_contacts = self.env['res.partner'].browse(self.address_get(['invoice'])['invoice'])
return followup_contacts
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_invoices_to_print(self, options):
self.ensure_one()
if not options:
options = {}
invoices_to_print = self._get_included_unreconciled_aml_ids().move_id.filtered(lambda l: l.is_invoice(include_receipts=True))
if options.get('manual_followup'):
# For manual reminders, only print invoices with the selected attachments
return invoices_to_print.filtered(lambda inv: inv.message_main_attachment_id.id in options.get('attachment_ids'))
return invoices_to_print.filtered(lambda inv: inv.message_main_attachment_id)
def _get_included_unreconciled_aml_ids(self):
self.ensure_one()
return self.unreconciled_aml_ids.filtered(lambda aml: not aml.blocked)
@api.model
def _get_first_followup_level(self):
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):
self.env['account.move.line'].check_access_rights('read')
self.env['account.move.line'].flush_model()
self.env['res.partner'].flush_model()
self.env['account_followup.followup.line'].flush_model()
# Put the data in a cache in the database to avoid running costly query multiple times in same transaction.
# Only do it if the table doesn't exist yet.
self.env.cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='followup_data_cache'")
is_cached = self.env.cr.fetchone()
if all_partners:
if not is_cached:
query, params = self._get_followup_data_query()
self.env.cr.execute(f"""
CREATE TEMP TABLE followup_data_cache (partner_id int4, followup_line_id int4, followup_status varchar) ON COMMIT DROP;
INSERT INTO followup_data_cache {query}
""", params)
self.env.cr.execute('SELECT * FROM followup_data_cache')
else:
if not self.ids:
return {}
elif is_cached:
query, params = "SELECT * FROM followup_data_cache WHERE partner_id IN %s", [tuple(self.ids)]
else:
query, params = self._get_followup_data_query(self.ids)
self.env.cr.execute(query, params)
result = {r['partner_id']: r for r in self.env.cr.dictfetchall()}
return result
def _get_followup_data_query(self, partner_ids=None):
return f"""
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(COALESCE(next_ful.delay, ful.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
LEFT JOIN account_followup_followup_line ful ON ful.id = aml.followup_line_id
LEFT 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, %(min_delay)s - 1)
AND next_ful.company_id = %(company_id)s
ORDER BY next_ful.delay ASC
LIMIT 1
)
WHERE account.deprecated IS NOT TRUE
AND account.account_type = 'asset_receivable'
AND aml.parent_state = 'posted'
AND aml.reconciled IS NOT TRUE
AND aml.blocked IS FALSE
AND aml.balance > 0
AND aml.company_id = %(company_id)s
{"" if partner_ids is None else "AND aml.partner_id IN %(partner_ids)s"}
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
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 line.parent_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, %(min_delay)s - 1) <= partner.followup_delay
AND COALESCE(line.date_maturity, line.date) + COALESCE(ful.delay, %(min_delay)s - 1) < %(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
WHERE line.partner_id = partner.id
AND account.account_type = 'asset_receivable'
AND account.deprecated IS NOT TRUE
AND line.parent_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(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
""", {
'company_id': self.env.company.id,
'partner_ids': tuple(partner_ids or []),
'current_date': fields.Date.context_today(self), # Allow mocking the current day for testing purpose.
'min_delay': self._get_first_followup_level().delay or 0,
}
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)
if not options.get('join_invoices', followup_line.join_invoices):
options['attachment_ids'] = []
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.warning(e, exc_info=True)
def _cron_execute_followup(self):
for company in self.env["res.company"].search([]):
# Since the cache is done by database and not by company, we need to invalidate in this special case
# where the context is changing in the same transaction
self.env.cr.execute("DROP TABLE IF EXISTS followup_data_cache")
self.with_context(allowed_company_ids=company.ids)._cron_execute_followup_company()