Files
tryton/commission.py
T
Cédric Krier 30cd6e4cf9 Add round_price
We add a common method to round prices that are stored using the price digits.
It is useless to use the digits from the field because it can not be changed in
any other way than by configuration.

issue9146
review299521002
2020-04-23 21:44:00 +02:00

567 lines
19 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from decimal import Decimal
from itertools import groupby
from simpleeval import simple_eval
try:
from sql import Null
except ImportError:
Null = None
from sql.aggregate import Sum
from trytond.i18n import gettext
from trytond.model import ModelView, ModelSQL, MatchMixin, fields, \
sequence_ordered
from trytond.pyson import Eval, Bool, If, Id, PYSONEncoder
from trytond.tools import decistmt, grouped_slice, reduce_ids
from trytond.pool import Pool
from trytond.wizard import Wizard, StateView, StateAction, Button
from trytond.transaction import Transaction
from trytond.modules.product import price_digits, round_price
from .exceptions import FormulaError
class Agent(ModelSQL, ModelView):
'Commission Agent'
__name__ = 'commission.agent'
party = fields.Many2One('party.party', "Party", required=True,
help="The party for whom the commission is calculated.")
type_ = fields.Selection([
('agent', 'Agent Of'),
('principal', 'Principal Of'),
], 'Type')
company = fields.Many2One('company.company', 'Company', required=True)
plan = fields.Many2One('commission.plan', "Plan",
help="The plan used to calculate the commission.")
currency = fields.Many2One('currency.currency', "Currency", required=True)
pending_amount = fields.Function(fields.Numeric('Pending Amount',
digits=price_digits), 'get_pending_amount')
selections = fields.One2Many(
'commission.agent.selection', 'agent', "Selections")
@classmethod
def __register__(cls, module_name):
pool = Pool()
Company = pool.get('company.company')
sql_table = cls.__table__()
company = Company.__table__()
super().__register__(module_name)
transaction = Transaction()
cursor = transaction.connection.cursor()
table = cls.__table_handler__(module_name)
# Migration from 5.4: Ensure currency is set
# Don't use UPDATE FROM because SQLite does not support it
cursor.execute(*sql_table.update(
columns=[sql_table.currency],
values=[company.select(
company.currency,
where=company.id == sql_table.company)],
where=(sql_table.currency == Null)))
# Migration from 5.4: Add not null on currency
table.not_null_action('currency', 'add')
@staticmethod
def default_company():
return Transaction().context.get('company')
@staticmethod
def default_type_():
return 'agent'
@classmethod
def default_currency(cls):
pool = Pool()
Company = pool.get('company.company')
company = cls.default_company()
if company:
return Company(company).currency.id
@fields.depends('company', 'currency')
def on_change_company(self):
if self.company and not self.currency:
self.currency = self.company.currency
def get_rec_name(self, name):
if self.plan:
return '%s - %s' % (self.party.rec_name, self.plan.rec_name)
else:
return self.party.rec_name
@classmethod
def search_rec_name(cls, name, clause):
if clause[1].startswith('!') or clause[1].startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('party.rec_name',) + tuple(clause[1:]),
('plan.rec_name',) + tuple(clause[1:]),
]
@classmethod
def get_pending_amount(cls, agents, name):
pool = Pool()
Commission = pool.get('commission')
commission = Commission.__table__()
cursor = Transaction().connection.cursor()
ids = [a.id for a in agents]
amounts = dict.fromkeys(ids, None)
for sub_ids in grouped_slice(ids):
where = reduce_ids(commission.agent, sub_ids)
where &= commission.invoice_line == Null
query = commission.select(commission.agent, Sum(commission.amount),
where=where,
group_by=commission.agent)
cursor.execute(*query)
amounts.update(dict(cursor.fetchall()))
for agent_id, amount in amounts.items():
if amount:
# SQLite uses float for SUM
if not isinstance(amount, Decimal):
amount = Decimal(str(amount))
amounts[agent_id] = round_price(amount)
return amounts
@property
def account(self):
if self.type_ == 'agent':
return self.party.account_payable_used
elif self.type_ == 'principal':
return self.party.account_receivable_used
class AgentSelection(sequence_ordered(), MatchMixin, ModelSQL, ModelView):
"Agent Selection"
__name__ = 'commission.agent.selection'
agent = fields.Many2One('commission.agent', "Agent", required=True)
start_date = fields.Date(
"Start Date",
domain=[
If(Eval('start_date') & Eval('end_date'),
('start_date', '<=', Eval('end_date')),
()),
],
depends=['end_date'],
help="The first date that the agent will be considered for selection.")
end_date = fields.Date(
"End Date",
domain=[
If(Eval('start_date') & Eval('end_date'),
('end_date', '>=', Eval('start_date')),
()),
],
depends=['start_date'],
help="The last date that the agent will be considered for selection.")
party = fields.Many2One(
'party.party', "Party", ondelete='CASCADE', select=True)
@classmethod
def __setup__(cls):
super().__setup__()
cls._order.insert(0, ('party', 'ASC NULLS LAST'))
def match(self, pattern):
pool = Pool()
Date = pool.get('ir.date')
pattern = pattern.copy()
if 'company' in pattern:
pattern.pop('company')
date = pattern.pop('date', None) or Date.today()
if self.start_date and self.start_date > date:
return False
if self.end_date and self.end_date < date:
return False
return super().match(pattern)
class Plan(ModelSQL, ModelView):
'Commission Plan'
__name__ = 'commission.plan'
name = fields.Char('Name', required=True, translate=True)
commission_product = fields.Many2One('product.product',
'Commission Product', required=True,
domain=[
('type', '=', 'service'),
('default_uom', '=', Id('product', 'uom_unit')),
('template.type', '=', 'service'),
('template.default_uom', '=', Id('product', 'uom_unit')),
],
help="The product that is used on the invoice lines.")
commission_method = fields.Selection([
('posting', 'On Posting'),
('payment', 'On Payment'),
], 'Commission Method',
help="When the commission is due.")
lines = fields.One2Many('commission.plan.line', 'plan', "Lines",
help="The formulas used to calculate the commission for different "
"criteria.")
@staticmethod
def default_commission_method():
return 'posting'
def get_context_formula(self, amount, product):
return {
'names': {
'amount': amount,
},
}
def compute(self, amount, product, pattern=None):
'Compute commission amount for the amount'
def parents(categories):
for category in categories:
while category:
yield category
category = category.parent
if pattern is None:
pattern = {}
if product:
pattern['categories'] = [
c.id for c in parents(product.categories_all)]
pattern['product'] = product.id
else:
pattern['categories'] = []
pattern['product'] = None
context = self.get_context_formula(amount, product)
for line in self.lines:
if line.match(pattern):
return line.get_amount(**context)
class PlanLines(sequence_ordered(), ModelSQL, ModelView, MatchMixin):
'Commission Plan Line'
__name__ = 'commission.plan.line'
plan = fields.Many2One('commission.plan', 'Plan', required=True,
ondelete='CASCADE',
help="The plan to which the line belongs.")
category = fields.Many2One(
'product.category', "Category", ondelete='CASCADE',
help="Apply only to products in the category.")
product = fields.Many2One('product.product', "Product", ondelete='CASCADE',
help="Apply only to the product.")
formula = fields.Char('Formula', required=True,
help="The python expression used to calculate the amount of "
"commission for the line.\n"
"It is evaluated with:\n"
"- amount: the original amount")
@staticmethod
def default_formula():
return 'amount'
@classmethod
def validate(cls, lines):
super(PlanLines, cls).validate(lines)
for line in lines:
line.check_formula()
def check_formula(self):
context = self.plan.get_context_formula(Decimal(0), None)
try:
if not isinstance(self.get_amount(**context), Decimal):
raise ValueError
except Exception as exception:
raise FormulaError(
gettext('commission.msg_plan_line_invalid_formula',
formula=self.formula,
line=self.rec_name,
exception=exception)) from exception
def get_amount(self, **context):
'Return amount (as Decimal)'
context.setdefault('functions', {})['Decimal'] = Decimal
return simple_eval(decistmt(self.formula), **context)
def match(self, pattern):
if 'categories' in pattern:
pattern = pattern.copy()
categories = pattern.pop('categories')
if (self.category is not None
and self.category.id not in categories):
return False
return super(PlanLines, self).match(pattern)
class Commission(ModelSQL, ModelView):
'Commission'
__name__ = 'commission'
_readonly_states = {
'readonly': Bool(Eval('invoice_line')),
}
_readonly_depends = ['invoice_line']
origin = fields.Reference('Origin', selection='get_origin', select=True,
readonly=True,
help="The source of the commission.")
date = fields.Date('Date', select=True, states=_readonly_states,
depends=_readonly_depends,
help="When the commission is due.")
agent = fields.Many2One('commission.agent', 'Agent', required=True,
states=_readonly_states, depends=_readonly_depends)
product = fields.Many2One('product.product', 'Product', required=True,
states=_readonly_states, depends=_readonly_depends,
help="The product that is used on the invoice line.")
amount = fields.Numeric('Amount', required=True, digits=price_digits,
domain=[('amount', '!=', 0)],
states=_readonly_states, depends=_readonly_depends)
currency = fields.Function(fields.Many2One('currency.currency',
'Currency'), 'on_change_with_currency')
type_ = fields.Function(fields.Selection([
('in', 'Incoming'),
('out', 'Outgoing'),
], 'Type'), 'on_change_with_type_')
invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
readonly=True, depends=['amount', 'type_'])
invoice_state = fields.Function(fields.Selection([
('', ''),
('invoiced', 'Invoiced'),
('paid', 'Paid'),
('cancel', 'Canceled'),
], "Invoice State",
help="The current state of the invoice "
"that the commission appears on."),
'get_invoice_state')
@classmethod
def __setup__(cls):
super(Commission, cls).__setup__()
cls._buttons.update({
'invoice': {
'invisible': Bool(Eval('invoice_line')),
'depends': ['invoice_line'],
},
})
@classmethod
def _get_origin(cls):
'Return list of Model names for origin Reference'
return ['account.invoice.line']
@classmethod
def get_origin(cls):
pool = Pool()
Model = pool.get('ir.model')
models = cls._get_origin()
models = Model.search([
('model', 'in', models),
])
return [(None, '')] + [(m.model, m.name) for m in models]
@fields.depends('agent')
def on_change_with_currency(self, name=None):
if self.agent:
return self.agent.currency.id
@fields.depends('agent')
def on_change_with_type_(self, name=None):
if self.agent:
return {
'agent': 'out',
'principal': 'in',
}.get(self.agent.type_)
@fields.depends('agent', 'product')
def on_change_agent(self):
if not self.product and self.agent and self.agent.plan:
self.product = self.agent.plan.commission_product
def get_invoice_state(self, name):
state = ''
if self.invoice_line:
state = 'invoiced'
invoice = self.invoice_line.invoice
if invoice:
if invoice.state == 'paid':
state = 'paid'
elif invoice.state == 'cancel':
state = 'cancel'
return state
@classmethod
def copy(cls, commissions, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('invoice_line', None)
return super(Commission, cls).copy(commissions, default=default)
@classmethod
@ModelView.button
def invoice(cls, commissions):
pool = Pool()
Invoice = pool.get('account.invoice')
def invoice_key(c):
return c._group_to_invoice_key()
def line_key(c):
return c._group_to_invoice_line_key()
commissions.sort(key=invoice_key)
invoices = []
to_write = []
for key, commissions in groupby(commissions, key=invoice_key):
commissions = list(commissions)
key = dict(key)
invoice = cls._get_invoice(key)
invoice.save()
invoices.append(invoice)
commissions.sort(key=line_key)
for key, commissions in groupby(commissions, key=line_key):
commissions = [c for c in commissions if not c.invoice_line]
key = dict(key)
invoice_line = cls._get_invoice_line(key, invoice, commissions)
invoice_line.save()
to_write.extend([commissions, {
'invoice_line': invoice_line.id,
}])
if to_write:
cls.write(*to_write)
Invoice.update_taxes(invoices)
def _group_to_invoice_key(self):
direction = {
'in': 'out',
'out': 'in',
}.get(self.type_)
return (('agent', self.agent), ('type', direction))
@classmethod
def get_journal(cls):
pool = Pool()
Journal = pool.get('account.journal')
journals = Journal.search([
('type', '=', 'commission'),
], limit=1)
if journals:
return journals[0]
@classmethod
def _get_invoice(cls, key):
pool = Pool()
Invoice = pool.get('account.invoice')
agent = key['agent']
if key['type'] == 'out':
payment_term = agent.party.customer_payment_term
else:
payment_term = agent.party.supplier_payment_term
return Invoice(
company=agent.company,
type=key['type'],
journal=cls.get_journal(),
party=agent.party,
invoice_address=agent.party.address_get(type='invoice'),
currency=agent.currency,
account=agent.account,
payment_term=payment_term,
)
def _group_to_invoice_line_key(self):
return (('product', self.product),)
@classmethod
def _get_invoice_line(cls, key, invoice, commissions):
pool = Pool()
InvoiceLine = pool.get('account.invoice.line')
def sign(commission):
if invoice.type == commission.type_:
return -1
else:
return 1
product = key['product']
amount = invoice.currency.round(
sum(c.amount * sign(c) for c in commissions))
invoice_line = InvoiceLine()
invoice_line.invoice = invoice
invoice_line.type = 'line'
invoice_line.product = product
invoice_line.quantity = 1
invoice_line.on_change_product()
invoice_line.unit_price = amount
return invoice_line
class CreateInvoice(Wizard):
'Create Commission Invoice'
__name__ = 'commission.create_invoice'
start_state = 'ask'
ask = StateView('commission.create_invoice.ask',
'commission.commission_create_invoice_ask_view_form', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('OK', 'create_', 'tryton-ok', default=True),
])
create_ = StateAction('account_invoice.act_invoice_form')
def get_domain(self):
domain = [('invoice_line', '=', None)]
if self.ask.from_:
domain.append(('date', '>=', self.ask.from_))
if self.ask.to:
domain.append(('date', '<=', self.ask.to))
if self.ask.type_ == 'in':
domain.append(('agent.type_', '=', 'principal'))
elif self.ask.type_ == 'out':
domain.append(('agent.type_', '=', 'agent'))
return domain
def do_create_(self, action):
pool = Pool()
Commission = pool.get('commission')
commissions = Commission.search(self.get_domain(),
order=[('agent', 'DESC'), ('date', 'DESC')])
Commission.invoice(commissions)
invoice_ids = list({c.invoice_line.invoice.id for c in commissions})
encoder = PYSONEncoder()
action['pyson_domain'] = encoder.encode(
[('id', 'in', invoice_ids)])
action['pyson_search_value'] = encoder.encode([])
return action, {}
class CreateInvoiceAsk(ModelView):
'Create Commission Invoice'
__name__ = 'commission.create_invoice.ask'
from_ = fields.Date('From',
domain=[
If(Eval('to') & Eval('from_'), [('from_', '<=', Eval('to'))],
[]),
],
depends=['to'],
help="Limit to commissions from this date.")
to = fields.Date('To',
domain=[
If(Eval('from_') & Eval('to'), [('to', '>=', Eval('from_'))],
[]),
],
depends=['from_'],
help="Limit to commissions to this date.")
type_ = fields.Selection([
('in', 'Incoming'),
('out', 'Outgoing'),
('both', 'Both'),
], 'Type',
help="Limit to commissions of this type.")
@staticmethod
def default_type_():
return 'both'