Files
enterprise-addons/sale_commission/model/commission_plan.py
T

256 lines
12 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api, Command, _
from odoo.exceptions import ValidationError, UserError
class SaleCommissionPlan(models.Model):
_name = 'sale.commission.plan'
_description = 'Commission Plan'
_order = 'id'
_inherit = ['mail.thread']
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
active = fields.Boolean(default=True)
currency_id = fields.Many2one('res.currency', related='company_id.currency_id', store=True)
name = fields.Char("Name", required=True)
date_from = fields.Date("From", required=True, default=lambda self: fields.Date.today() + relativedelta(day=1, month=1), tracking=True)
date_to = fields.Date("To", required=True, default=lambda self: fields.Date.today() + relativedelta(years=1, day=1, month=1) - relativedelta(days=1), tracking=True)
periodicity = fields.Selection([('month', "Monthly"), ('quarter', "Quarterly"), ('year', "Yearly")], required=True, default='quarter', tracking=True)
commission_amount = fields.Monetary("On Target Commission", help='OTC', currency_field='currency_id', default=1000)
type = fields.Selection([('target', "Targets"), ('achieve', "Achievements")], required=True, default='achieve', tracking=True)
user_type = fields.Selection([('person', "Salesperson"), ('team', "Sales Team")], required=True, default='person', tracking=True)
team_id = fields.Many2one('crm.team', 'Sale team')
achievement_ids = fields.One2many('sale.commission.plan.achievement', 'plan_id', default=[Command.create({'type': 'amount_invoiced'})], copy=True)
target_ids = fields.One2many('sale.commission.plan.target', 'plan_id', compute='_compute_targets',
store=True, readonly=False, copy=True)
target_commission_ids = fields.One2many('sale.commission.plan.target.commission', 'plan_id',
compute='_compute_target_commission_ids',
inverse='_inverse_target_commission_ids',
store=True, readonly=False, copy=True)
target_commission_graph = fields.Text(compute="_compute_target_commission_graph")
user_ids = fields.One2many('sale.commission.plan.user', 'plan_id', copy=True)
state = fields.Selection([('draft', "Draft"), ('approved', "Approved"), ('done', "Done"), ('cancel', "Cancelled")],
required=True, default='draft', tracking=True)
def init(self):
self.env.cr.execute("""
CREATE INDEX IF NOT EXISTS sale_order_team_id_date_order_idx ON sale_order (team_id, date_order) WHERE state = 'sale';
CREATE INDEX IF NOT EXISTS account_move_team_id_date_idx ON account_move (team_id, date) WHERE move_type IN ('out_invoice', 'out_refund');
CREATE INDEX IF NOT EXISTS account_move_invoice_user_id_date_idx ON account_move (invoice_user_id, date) WHERE move_type IN ('out_invoice', 'out_refund');
""")
super().init()
@api.constrains('target_commission_ids')
def _constrains_target_commission_ids(self):
for plan in self:
if plan.type == 'target' and not plan.target_commission_ids.filtered(lambda target: target.target_rate == 0):
raise ValidationError(_("The plan should have at least one target with an achievement rate of 0%"))
@api.constrains('date_from', 'date_to')
def _date_constraint(self):
for plan in self:
if plan.date_to and plan.date_from and not plan.date_to > plan.date_from:
raise ValidationError(_("The start date must be before the end date."))
@api.constrains('team_id', 'user_type')
def _constrains_team_id(self):
for plan in self:
if plan.user_type == 'team' and not plan.team_id:
raise ValidationError(_("The team is required in team plan."))
@api.model
def _date2name(self, dt, periodicity):
if periodicity == 'month':
return dt.strftime('%b %Y')
elif periodicity == 'quarter':
return f'{dt.year} Q{(dt.month // 3) + 1}'
elif periodicity == 'year':
return dt.strftime('%Y')
return ''
@api.depends('periodicity', 'date_from', 'date_to', 'type')
def _compute_targets(self, amount=0):
for plan in self:
if not plan.date_from or not plan.date_to:
continue
date_from = plan.date_from
if plan.periodicity == 'month':
timedelta = relativedelta(months=1, day=1)
date_from = date_from - relativedelta(days=1) + timedelta
elif plan.periodicity == 'quarter':
timedelta = relativedelta(months=3, day=1)
r_delta = relativedelta(month=(date_from.month // 3) * 3 + 1, day=1)
if r_delta.month >12:
d1 = r_delta.month - 12
r_delta = relativedelta(year=1, day=1) + relativedelta(month=d1, day=1)
start_date = date_from + r_delta
while start_date < date_from:
start_date += timedelta
date_from = start_date
else:
timedelta = relativedelta(years=1, month=1, day=1)
date_from = date_from - relativedelta(days=1) + timedelta
match plan.periodicity:
case 'month':
expected_target_duration = range(27, 31)
case 'quarter':
expected_target_duration = range(89, 92)
case 'year':
expected_target_duration = range(364, 366)
# Map existing targets and track changes, starting by unlinking out-of-range ones.
existing_targets = {(t.date_from, t.date_to): t for t in plan.target_ids}
target_changes = [
Command.unlink(t.id) for t in plan.target_ids
if (t.date_from < plan.date_from or t.date_to > plan.date_to) # Doesn't belong to updated period
or (t.date_to - t.date_from).days not in expected_target_duration # Doesn't belong to updated periodicity
]
while date_from + timedelta - relativedelta(days=1) <= plan.date_to:
# Add new targets for missing periods.
period_end = date_from + timedelta - relativedelta(days=1)
if (date_from, period_end) not in existing_targets:
target_changes += [Command.create({
'name': self._date2name(date_from, plan.periodicity),
'date_from': date_from,
'date_to': period_end,
'amount': amount,
})]
date_from += timedelta
if target_changes:
plan.target_ids = target_changes
@api.depends('commission_amount', 'type')
def _compute_target_commission_ids(self):
for plan in self:
if plan.type != 'target':
continue
elif not plan.target_commission_ids:
plan.target_commission_ids = [Command.create({
'plan_id': plan.id,
'target_rate': 0,
'amount': 0,
}), Command.create({
'plan_id': plan.id,
'target_rate': 0.5,
'amount': 0,
}), Command.create({
'plan_id': plan.id,
'target_rate': 1,
'amount': plan.commission_amount or 1,
'amount_rate': 1,
})]
else:
for target in plan.target_commission_ids:
target.amount = target.amount_rate * plan.commission_amount
def _inverse_target_commission_ids(self):
for plan in self:
sorted_amounts = {}
sorted_rates = []
exact_value = False
for target in plan.target_commission_ids.sorted('target_rate'):
sorted_amounts[target.target_rate] = target.amount
sorted_rates.append(target.target_rate)
if target.target_rate == 1 and target.amount:
plan.commission_amount = target.amount
exact_value = True
break
if not exact_value:
# Try to interpolate/extrapolate values at 100
amount = self._get_completion_value(sorted_amounts, sorted_rates)
if amount:
plan.commission_amount = amount
@api.depends('target_commission_ids')
def _compute_target_commission_graph(self):
for plan in self:
plan.target_commission_graph = json.dumps(plan._get_graph_data())
def _get_graph_data(self):
self.ensure_one()
values = []
for target in self.target_commission_ids.sorted('target_rate'):
values += [{'x': round(target.target_rate * 100), 'y': target.amount}]
if values:
# For visual purpose: explain how values is computed outside of known bounds
values += [{'x': values[-1]['x'] + 50, 'y': values[-1]['y']}]
return {'values': values, 'currency': self.currency_id.symbol}
def action_open_commission(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"res_model": "sale.commission.report",
"name": _("Related commissions"),
"views": [[self.env.ref('sale_commission.sale_commission_report_view_list').id, "list"]],
"domain": [('plan_id', '=', self.id)],
}
@staticmethod
def _extract_past_users(user_ids):
today = fields.Date.today()
return [user for user in user_ids if len(user) == 3 and not user[2].get('date_to') or user[2]['date_to'] >= today]
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [
dict(vals, name=_("%s (copy)", cp.name), user_ids=self._extract_past_users(vals.get('user_ids', [])))
for cp, vals in zip(self, vals_list)
]
def action_approve(self):
self.state = 'approved'
def action_draft(self):
self.state = 'draft'
def action_done(self):
self.state = 'done'
def action_cancel(self):
self.state = 'cancel'
def action_export_targets(self):
self.ensure_one()
if self.type != 'target':
raise UserError(_("Exporting targets is only available for plans based on targets"))
return {
"type": "ir.actions.act_url",
"url": f"/sale_commission/export/targets/{self.id}",
"target": "download",
}
@api.model
def _get_completion_value(self, sorted_amounts, sorted_rates):
""" Interpolate/extrapolate the amount at 100% target completion.
return: amount at 100% target completion
"""
lowest_vals = [v for v in sorted_rates if v < 1]
higher_vals = [v for v in sorted_rates if v > 1]
# The plan has always a value at 0% completion
low, high = None, None
if lowest_vals and higher_vals:
low = lowest_vals[-1]
high = higher_vals[0]
elif not higher_vals and len(lowest_vals) > 1:
low = lowest_vals[-2]
high = lowest_vals[-1]
if not (low and high) or low == high:
# No values or division by zero
return
# y = ax + b
return sorted_amounts[low] + (sorted_amounts[high] - sorted_amounts[low])/(high-low) * (1-low)