diff --git a/account_usability/account_view.xml b/account_usability/account_view.xml index dea527d..abc58ef 100644 --- a/account_usability/account_view.xml +++ b/account_usability/account_view.xml @@ -374,8 +374,9 @@ module --> account.move.line - - + + 1 + diff --git a/commission_simple/__init__.py b/commission_simple/__init__.py new file mode 100644 index 0000000..35e7c96 --- /dev/null +++ b/commission_simple/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import wizard diff --git a/commission_simple/__manifest__.py b/commission_simple/__manifest__.py new file mode 100644 index 0000000..83acfa0 --- /dev/null +++ b/commission_simple/__manifest__.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 Akretion France (http://www.akretion.com) +# @author Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Commission Simple', + 'version': '10.0.1.0.0', + 'category': 'Sales', + 'license': 'AGPL-3', + 'summary': 'Compute commissions for salesman', + 'description': """ +Commission Simple +================= + +This module is a **simple** module to compute commission for salesman. From my experience, companies often use very specific methods to compute commissions and it's impossible to develop a module that can support all of them. So the goal of this module is just to have a simple base to build the company-specific commissionning system by inheriting this simple module. + +Here is a short description of this module: + +* create commission profiles using rules (per product category, per product, per product and customer, etc.), +* the commission rules can have a start and end date (optional), +* commissionning can happen on invoicing or on payment, +* each invoice line can only be commissionned to one salesman, +* commission reports are stored in Odoo. + +This module has been written by Alexis de Lattre from Akretion +. + """, + 'author': 'Akretion', + 'website': 'http://www.akretion.com', + 'depends': [ + 'account', + 'date_range', + # this uses some related fields on account.invoice.line + 'account_usability', + ], + 'data': [ + 'data/decimal_precision.xml', + 'views/commission.xml', + 'views/res_users.xml', + 'views/account_config_settings.xml', + 'wizard/commission_compute_view.xml', + 'security/ir.model.access.csv', + 'security/rule.xml', + ], + 'installable': True, +} diff --git a/commission_simple/data/decimal_precision.xml b/commission_simple/data/decimal_precision.xml new file mode 100644 index 0000000..5878b9d --- /dev/null +++ b/commission_simple/data/decimal_precision.xml @@ -0,0 +1,11 @@ + + + + + + Commission Rate + 2 + + + + diff --git a/commission_simple/models/__init__.py b/commission_simple/models/__init__.py new file mode 100644 index 0000000..789deef --- /dev/null +++ b/commission_simple/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +from . import commission +from . import res_users +from . import res_company +from . import account_config_settings +from . import account_invoice_line diff --git a/commission_simple/models/account_config_settings.py b/commission_simple/models/account_config_settings.py new file mode 100644 index 0000000..5d959d4 --- /dev/null +++ b/commission_simple/models/account_config_settings.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AccountConfigSettings(models.TransientModel): + _inherit = 'account.config.settings' + + commission_date_range_type_id = fields.Many2one( + related='company_id.commission_date_range_type_id', readonly=False) diff --git a/commission_simple/models/account_invoice_line.py b/commission_simple/models/account_invoice_line.py new file mode 100644 index 0000000..4d78052 --- /dev/null +++ b/commission_simple/models/account_invoice_line.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 Akretion France (http://www.akretion.com/) +# @author Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import api, fields, models +import odoo.addons.decimal_precision as dp + + +class AccountInvoiceLine(models.Model): + _inherit = 'account.invoice.line' + + user_id = fields.Many2one( + related='invoice_id.user_id', store=True, readonly=True) + product_categ_id = fields.Many2one( + related='product_id.categ_id', store=True, readonly=True) + commission_result_id = fields.Many2one( + 'commission.result', string='Commission Result') + commission_rule_id = fields.Many2one( + 'commission.rule', 'Matched Commission Rule', ondelete='restrict') + commission_base = fields.Monetary('Commission Base', currency_field='company_currency_id') + commission_rate = fields.Float('Commission Rate', digits=dp.get_precision('Commission Rate')) + commission_amount = fields.Monetary( + string='Commission Amount', currency_field='company_currency_id', + readonly=True, compute='_compute_commission_amount', store=True) + + @api.depends('commission_rate', 'commission_base') + def _compute_amount(self): + for line in self: + line.commission_amount = line.commission_rate * line.commission_base / 100.0 + + def compute_commission_for_one_user(self, user, date_range, rules): + profile = user.commission_profile_id + assert profile + domain = [ + ('invoice_type', 'in', ('out_invoice', 'out_refund')), + ('date_invoice', '<=', date_range.date_end), + ('company_id', '=', self.env.user.company_id.id), + ('user_id', '=', user.id), + ('commission_result_id', '=', False), + ] + if profile.trigger_type == 'invoice': + domain.append(('state', 'in', ('open', 'paid'))) + elif profile.trigger_type == 'payment': + # TODO : for this trigger, we would need to filter + # out the invoices paid after the end date of the period compute + domain.append(('state', '=', 'paid')) + else: + raise + ilines = self.search(domain, order='date_invoice, invoice_id, sequence') + com_result = self.env['commission.result'].create({ + 'user_id': user.id, + 'profile_id': profile.id, + 'date_range_id': date_range.id, + }) + total = 0.0 + for iline in ilines: + rule = iline._match_commission_rule(rules[profile.id]) + if rule: + lvals = iline._prepare_commission_data(rule, com_result) + if lvals: + iline.write(lvals) + total += lvals['commission_amount'] + com_result.amount_total = total + return com_result + + def _match_commission_rule(self, rules): + # commission rules are already in the right order + self.ensure_one() + for rule in rules: + if rule['date_start'] and rule['date_start'] > self.date_invoice: + continue + if rule['date_end'] and rule['date_end'] < self.date_invoice: + continue + if rule['applied_on'] == '0_customer_product': + if ( + self.commercial_partner_id.id in + rule['partner_ids'] and + self.product_id.id in rule['product_ids']): + return rule + elif rule['applied_on'] == '1_customer_product_category': + if ( + self.commercial_partner_id.id in + rule['partner_ids'] and + self.product_categ_id.id in rule['product_categ_ids']): + return rule + elif rule['applied_on'] == '2_product': + if self.product_id.id in rule['product_ids']: + return rule + elif rule['applied_on'] == '3_product_category': + if self.product_categ_id.id in rule['product_categ_ids']: + return rule + elif rule['applied_on'] == '4_global': + return rule + return False + + def _prepare_commission_data(self, rule, commission_result): + self.ensure_one() + lvals = { + 'commission_result_id': commission_result.id, + 'commission_rule_id': rule['id'], + # company currency + 'commission_base': self.price_subtotal_signed, + 'commission_rate': rule['rate'], + 'commission_amount': rule['rate'] * self.price_subtotal_signed, + } + return lvals diff --git a/commission_simple/models/commission.py b/commission_simple/models/commission.py new file mode 100644 index 0000000..b1a7182 --- /dev/null +++ b/commission_simple/models/commission.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# Copyright Akretion France (http://www.akretion.com/) +# @author Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models, api +import odoo.addons.decimal_precision as dp + + +class CommissionProfile(models.Model): + _name = 'commission.profile' + _description = 'Commission Profile' + + name = fields.Char(string='Name of the Profile', required=True) + active = fields.Boolean(string='Active', default=True) + company_id = fields.Many2one( + 'res.company', string='Company', + required=True, + default=lambda self: self.env['res.company']._company_default_get()) + line_ids = fields.One2many( + 'commission.rule', 'profile_id', string='Commission Rules') + trigger_type = fields.Selection([ + ('invoice', 'Invoicing'), + ('payment', 'Payment'), + ], default='invoice', string='Trigger', required=True) + + +class CommissionRule(models.Model): + _name = 'commission.rule' + _description = 'Commission Rule' + _order = 'profile_id, applied_on' + + partner_ids = fields.Many2many( + 'res.partner', string='Customers', + domain=[('parent_id', '=', False), ('customer', '=', True)]) + product_categ_ids = fields.Many2many( + 'product.category', string="Product Categories", + domain=[('type', '=', 'normal')]) + product_ids = fields.Many2many('product.product', string='Products') + date_start = fields.Date('Start Date') + date_end = fields.Date('End Date') + profile_id = fields.Many2one( + 'commission.profile', string='Profile', ondelete='cascade') + company_id = fields.Many2one( + related='profile_id.company_id', store=True, readonly=True) + rate = fields.Float( + 'Commission Rate', digits=dp.get_precision('Commission Rate'), + copy=False) + applied_on = fields.Selection([ + ('0_customer_product', 'Products and Customers'), + ('1_customer_product_category', "Product Categories and Customers"), + ('2_product', "Products"), + ('3_product_category', "Product Categories"), + ('4_global', u'Global')], + string='Apply On', default='4_global', required=True) + active = fields.Boolean(string='Active', default=True) + + @api.model + def load_all_rules(self): + rules = self.search_read() + res = {} # key = profile, value = [rule1 recordset, rule2] + for rule in rules: + if rule['profile_id']: + if rule['profile_id'][0] not in res: + res[rule['profile_id'][0]] = [rule] + else: + res[rule['profile_id'][0]].append(rule) + return res + + _sql_constraints = [( + 'rate_positive', + 'CHECK(rate >= 0)', + 'Rate must be positive !')] + + +class CommissionResult(models.Model): + _name = 'commission.result' + _description = "Commission Result" + _order = 'date_start desc' + + user_id = fields.Many2one( + 'res.users', 'Salesman', required=True, ondelete='restrict', + readonly=True) + profile_id = fields.Many2one( + 'commission.profile', string='Commission Profile', + readonly=True) + company_id = fields.Many2one( + 'res.company', string='Company', + required=True, readonly=True, + default=lambda self: self.env['res.company']._company_default_get()) + company_currency_id = fields.Many2one( + related='company_id.currency_id', string='Company Currency', + readonly=True, store=True) + date_range_id = fields.Many2one( + 'date.range', required=True, string='Period', readonly=True) + date_start = fields.Date( + related='date_range_id.date_start', readonly=True, store=True) + date_end = fields.Date( + related='date_range_id.date_end', readonly=True, store=True) + line_ids = fields.One2many( + 'account.invoice.line', 'commission_result_id', 'Commission Lines', + readonly=True) + amount_total = fields.Monetary( + string='Commission Total', currency_field='company_currency_id', + help='This is the total amount at the date of the computation of the commission', + readonly=True) + + def name_get(self): + res = [] + for result in self: + name = '%s (%s)' % (result.user_id.name, result.date_range_id.name) + res.append((result.id, name)) + return res + + _sql_constraints = [( + 'salesman_period_company_unique', + 'unique(company_id, commission_partner_id, date_range_id)', + 'A commission result already exists for this salesman for ' + 'the same period')] diff --git a/commission_simple/models/res_company.py b/commission_simple/models/res_company.py new file mode 100644 index 0000000..f77ac36 --- /dev/null +++ b/commission_simple/models/res_company.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 Akretion France (http://www.akretion.com/) +# @author Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + commission_date_range_type_id = fields.Many2one( + 'date.range.type', string='Commission Periodicity') diff --git a/commission_simple/models/res_users.py b/commission_simple/models/res_users.py new file mode 100644 index 0000000..b0bebe4 --- /dev/null +++ b/commission_simple/models/res_users.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 Akretion France (http://www.akretion.com/) +# @author Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = 'res.users' + + commission_profile_id = fields.Many2one( + 'commission.profile', string='Commission Profile', + company_dependant=True) diff --git a/commission_simple/security/ir.model.access.csv b/commission_simple/security/ir.model.access.csv new file mode 100644 index 0000000..350af85 --- /dev/null +++ b/commission_simple/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_commission_profile_read,Read access on commission.profile for employees,model_commission_profile,base.group_user,1,0,0,0 +access_commission_profile_full,Full access on commission.profile for financial manager,model_commission_profile,account.group_account_manager,1,1,1,1 +access_commission_rule_full,Full access on commission.rule for financial manager,model_commission_rule,account.group_account_manager,1,1,1,1 +access_commission_rule_read,Read access on commission.rule for invoicing group,model_commission_rule,account.group_account_invoice,1,0,0,0 +access_commission_result_full,Full access on commission.result to accountant,model_commission_result,account.group_account_user,1,1,1,1 +access_commission_result_read,Read access on commission.result to invoicing grp,model_commission_result,account.group_account_invoice,1,0,0,0 diff --git a/commission_simple/security/rule.xml b/commission_simple/security/rule.xml new file mode 100644 index 0000000..b9bee43 --- /dev/null +++ b/commission_simple/security/rule.xml @@ -0,0 +1,30 @@ + + + + + + + + Commission Profile multi-company + + ['|', ('company_id', '=', False), ('company_id', 'child_of', [user.company_id.id])] + + + + Commission Rule multi-company + + ['|', ('company_id', '=', False), ('company_id', 'child_of', [user.company_id.id])] + + + + Commission Result multi-company + + ['|', ('company_id', '=', False), ('company_id', 'child_of', [user.company_id.id])] + + + + diff --git a/commission_simple/views/account_config_settings.xml b/commission_simple/views/account_config_settings.xml new file mode 100644 index 0000000..1dbf87d --- /dev/null +++ b/commission_simple/views/account_config_settings.xml @@ -0,0 +1,31 @@ + + + + + + + + commission.account.config.settings.form + account.config.settings + + + + + + + + + + + diff --git a/commission_simple/views/commission.xml b/commission_simple/views/commission.xml new file mode 100644 index 0000000..4c9aa95 --- /dev/null +++ b/commission_simple/views/commission.xml @@ -0,0 +1,211 @@ + + + + + + + + + + + commission.profile.form + commission.profile + +
+ +
+ +
+ + + + + + + + +
+
+
+
+ + + commission.profile.tree + commission.profile + + + + + + + + + + + Commission Profiles + commission.profile + tree,form + + + + + + + + commission.rule.form + commission.rule + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + commission.rule.tree + commission.rule + + + + + + + + + + + + + commission.rule.search + commission.rule + + + + + + + + + + + + + Commission Rules + commission.rule + tree,form + {'commission_rule_main_view': True} + + + + + + + + commission.result.form + commission.result + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + commission.result.tree + commission.result + + + + + + + + + + + + + + commission.result.search + commission.result + + + + + + + + + + + + + + Commissions + commission.result + tree,form + + + + + +
diff --git a/commission_simple/views/res_users.xml b/commission_simple/views/res_users.xml new file mode 100644 index 0000000..9d6a447 --- /dev/null +++ b/commission_simple/views/res_users.xml @@ -0,0 +1,25 @@ + + + + + + + + commission.res.users.form + res.users + + + + + + + + + + + + diff --git a/commission_simple/wizard/__init__.py b/commission_simple/wizard/__init__.py new file mode 100644 index 0000000..cd25b88 --- /dev/null +++ b/commission_simple/wizard/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import commission_compute diff --git a/commission_simple/wizard/commission_compute.py b/commission_simple/wizard/commission_compute.py new file mode 100644 index 0000000..f3a8fec --- /dev/null +++ b/commission_simple/wizard/commission_compute.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 Akretion France (http://www.akretion.com) +# @author Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models, _ +from dateutil.relativedelta import relativedelta +from odoo.exceptions import UserError +import logging +logger = logging.getLogger(__name__) + + +class CommissionCompute(models.TransientModel): + _name = 'commission.compute' + _description = 'Compute Commissoins' + + @api.model + def _default_date_range(self): + drange_type = self.env.user.company_id.commission_date_range_type_id + if not drange_type: + return False + today = fields.Date.from_string(fields.Date.context_today(self)) + first_day_last_month = today + relativedelta(months=-1, day=1) + dranges = self.env['date.range'].search([ + ('company_id', '=', self.env.user.company_id.id), + ('type_id', '=', drange_type.id), + ('date_start', '=', fields.Date.to_string(first_day_last_month)) + ]) + return dranges and dranges[0] or dranges + + date_range_id = fields.Many2one( + 'date.range', required=True, string='Period', + default=lambda self: self._default_date_range()) + date_start = fields.Date(related='date_range_id.date_start', readonly=True) + date_end = fields.Date(related='date_range_id.date_end', readonly=True) + + def run(self): + self.ensure_one() + creso = self.env['commission.result'] + ruo = self.env['res.users'] + date_range = self.date_range_id + existing_res = creso.search([('date_range_id', '=', date_range.id)]) + if existing_res: + raise UserError( + u'Il existe déjà des commissions pour cette période.') + com_result_ids = self.core_compute() + if not com_result_ids: + raise UserError(_('No commission generated.')) + action = self.env['ir.actions.act_window'].for_xml_id( + 'commission_simple', 'commission_result_action') + action.update({ + 'views': False, + 'domain': "[('id', 'in', %s)]" % com_result_ids, + }) + return action + + def core_compute(self): + rules = self.env['commission.rule'].load_all_rules() + ailo = self.env['account.invoice.line'] + ruo = self.env['res.users'] + com_result_ids = [] + for user in ruo.with_context(active_test=False).search([]): + if user.commission_profile_id: + if user.commission_profile_id.id not in rules: + raise UserError(_( + "The commission profile '%s' doesn't have any rules.") + % user.commission_profile_id.name) + com_result = ailo.compute_commission_for_one_user(user, self.date_range_id, rules) + if com_result: + com_result_ids.append(com_result.id) + else: + logger.debug( + "Commission computation: salesman '%s' " + "doesn't have a commission profile", + user.name) + return com_result_ids + + diff --git a/commission_simple/wizard/commission_compute_view.xml b/commission_simple/wizard/commission_compute_view.xml new file mode 100644 index 0000000..8ecac26 --- /dev/null +++ b/commission_simple/wizard/commission_compute_view.xml @@ -0,0 +1,38 @@ + + + + + + + commission.compute.form + commission.compute + +
+ + + + + +
+
+
+
+
+ + + Compute Commissions + commission.compute + form + new + + + + +
diff --git a/commission_simple_sale/__init__.py b/commission_simple_sale/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/commission_simple_sale/__manifest__.py b/commission_simple_sale/__manifest__.py new file mode 100644 index 0000000..9aedc1c --- /dev/null +++ b/commission_simple_sale/__manifest__.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 Akretion France (http://www.akretion.com) +# @author Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Commission Simple Sale', + 'version': '10.0.1.0.0', + 'category': 'Sales', + 'license': 'AGPL-3', + 'summary': 'Give access to commission results to Salesman', + 'description': """ +Commission Simple Sale +====================== + +This module allows salesman to see their commissions in Odoo, under the Sales menu. + +This module has been written by Alexis de Lattre from Akretion +. + """, + 'author': 'Akretion', + 'website': 'http://www.akretion.com', + 'depends': [ + 'sale', + 'commission_simple', + ], + 'data': [ + 'views/commission.xml', + 'security/rule.xml', + 'security/ir.model.access.csv', + ], + 'installable': True, +} diff --git a/commission_simple_sale/security/ir.model.access.csv b/commission_simple_sale/security/ir.model.access.csv new file mode 100644 index 0000000..e54abab --- /dev/null +++ b/commission_simple_sale/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_commission_result_salesman_read,Read access on commission.result to salesman,commission_simple.model_commission_result,sales_team.group_sale_salesman,1,0,0,0 diff --git a/commission_simple_sale/security/rule.xml b/commission_simple_sale/security/rule.xml new file mode 100644 index 0000000..00e8e15 --- /dev/null +++ b/commission_simple_sale/security/rule.xml @@ -0,0 +1,19 @@ + + + + + + + + Commission Result for Salesman + + + [('user_id', '=', user.id)] + + + + diff --git a/commission_simple_sale/views/commission.xml b/commission_simple_sale/views/commission.xml new file mode 100644 index 0000000..b5c915d --- /dev/null +++ b/commission_simple_sale/views/commission.xml @@ -0,0 +1,15 @@ + + + + + + + + + + +