256 lines
12 KiB
Python
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)
|