659 lines
23 KiB
Python
659 lines
23 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 import backend
|
|
from trytond.i18n import gettext
|
|
from trytond.model import (
|
|
ChatMixin, DeactivableMixin, MatchMixin, ModelSQL, ModelView, fields,
|
|
sequence_ordered)
|
|
from trytond.modules.currency.fields import Monetary
|
|
from trytond.modules.product import price_digits, round_price
|
|
from trytond.pool import Pool
|
|
from trytond.pyson import Bool, Eval, Id, If
|
|
from trytond.tools import (
|
|
decistmt, grouped_slice, reduce_ids, sqlite_apply_types)
|
|
from trytond.transaction import Transaction, check_access
|
|
from trytond.wizard import Button, StateAction, StateView, Wizard
|
|
|
|
from .exceptions import FormulaError
|
|
|
|
|
|
class Agent(DeactivableMixin, ModelSQL, ModelView):
|
|
__name__ = 'commission.agent'
|
|
party = fields.Many2One('party.party', "Party", required=True,
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'},
|
|
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",
|
|
domain=[
|
|
If(~Eval('active', True),
|
|
('end_date', '!=', None),
|
|
()),
|
|
],
|
|
states={
|
|
'invisible': Eval('type_') != 'agent',
|
|
})
|
|
products = fields.Many2Many(
|
|
'product.template-commission.agent', 'agent', 'template', "Products",
|
|
states={
|
|
'invisible': Eval('type_') != 'principal',
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
})
|
|
|
|
@fields.depends('active', 'selections', 'company')
|
|
def on_change_active(self):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
with Transaction().set_context(
|
|
company=self.company.id if self.company else None):
|
|
today = Date.today()
|
|
if not self.active and self.selections:
|
|
for selection in self.selections:
|
|
start_date = getattr(selection, 'start_date') or today
|
|
end_date = getattr(selection, 'end_date')
|
|
if not end_date:
|
|
selection.end_date = max(today, start_date)
|
|
self.selections = self.selections
|
|
|
|
@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 is not None and company >= 0:
|
|
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).as_('pending_amount'),
|
|
where=where,
|
|
group_by=commission.agent)
|
|
if backend.name == 'sqlite':
|
|
sqlite_apply_types(query, [None, 'NUMERIC'])
|
|
cursor.execute(*query)
|
|
amounts.update(dict(cursor))
|
|
if backend.name == 'sqlite':
|
|
for agent_id, amount in amounts.items():
|
|
if amount is not None:
|
|
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):
|
|
__name__ = 'commission.agent.selection'
|
|
agent = fields.Many2One(
|
|
'commission.agent', "Agent", required=True,
|
|
domain=[
|
|
('type_', '=', 'agent'),
|
|
])
|
|
start_date = fields.Date(
|
|
"Start Date",
|
|
domain=[
|
|
If(Eval('start_date') & Eval('end_date'),
|
|
('start_date', '<=', Eval('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')),
|
|
()),
|
|
],
|
|
help="The last date that the agent will be considered for selection.")
|
|
party = fields.Many2One(
|
|
'party.party', "Party", ondelete='CASCADE',
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
company = fields.Function(fields.Many2One('company.company', "Company"),
|
|
'on_change_with_company', searcher='search_company')
|
|
employee = fields.Many2One(
|
|
'company.employee', "Employee",
|
|
domain=[
|
|
('company', '=', Eval('company', -1)),
|
|
])
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.__access__.add('agent')
|
|
cls._order.insert(0, ('party', 'ASC NULLS LAST'))
|
|
cls._order.insert(1, ('employee', 'ASC NULLS LAST'))
|
|
|
|
@fields.depends('agent', '_parent_agent.company')
|
|
def on_change_with_company(self, name=None):
|
|
return self.agent.company if self.agent else None
|
|
|
|
@classmethod
|
|
def search_company(cls, name, clause):
|
|
return [('agent.' + clause[0],) + tuple(clause[1:])]
|
|
|
|
def match(self, pattern):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
|
|
pattern = pattern.copy()
|
|
if 'company' in pattern:
|
|
pattern.pop('company')
|
|
with Transaction().set_context(company=self.company.id):
|
|
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):
|
|
__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):
|
|
__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")
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.__access__.add('plan')
|
|
|
|
@staticmethod
|
|
def default_formula():
|
|
return 'amount'
|
|
|
|
@classmethod
|
|
def validate_fields(cls, lines, field_names):
|
|
super().validate_fields(lines, field_names)
|
|
cls.check_formula(lines, field_names)
|
|
|
|
@classmethod
|
|
def check_formula(cls, lines, field_names=None):
|
|
if field_names and 'formula' not in field_names:
|
|
return
|
|
for line in lines:
|
|
context = line.plan.get_context_formula(Decimal(0), None)
|
|
try:
|
|
if not isinstance(line.get_amount(**context), Decimal):
|
|
raise ValueError
|
|
except Exception as exception:
|
|
raise FormulaError(
|
|
gettext('commission.msg_plan_line_invalid_formula',
|
|
formula=line.formula,
|
|
line=line.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().match(pattern)
|
|
|
|
|
|
class Commission(ModelSQL, ModelView, ChatMixin):
|
|
__name__ = 'commission'
|
|
_readonly_states = {
|
|
'readonly': Bool(Eval('invoice_line')),
|
|
}
|
|
origin = fields.Reference(
|
|
"Origin", selection='get_origin', states=_readonly_states,
|
|
help="The source of the commission.")
|
|
date = fields.Date(
|
|
"Date", states=_readonly_states,
|
|
help="When the commission is due.")
|
|
agent = fields.Many2One('commission.agent', 'Agent', required=True,
|
|
states=_readonly_states)
|
|
product = fields.Many2One('product.product', 'Product', required=True,
|
|
states=_readonly_states,
|
|
help="The product that is used on the invoice line.")
|
|
base_amount = Monetary(
|
|
"Base Amount", currency='currency', digits=price_digits,
|
|
states=_readonly_states)
|
|
amount = Monetary(
|
|
"Amount", currency='currency', required=True, digits=price_digits,
|
|
domain=[('amount', '!=', 0)],
|
|
states=_readonly_states)
|
|
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)
|
|
invoice_state = fields.Function(fields.Selection([
|
|
('', ''),
|
|
('invoiced', 'Invoiced'),
|
|
('paid', 'Paid'),
|
|
('cancelled', 'Cancelled'),
|
|
], "Invoice State",
|
|
help="The current state of the invoice "
|
|
"that the commission appears on."),
|
|
'get_invoice_state')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.__access__.add('agent')
|
|
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')
|
|
get_name = Model.get_name
|
|
models = cls._get_origin()
|
|
return [(None, '')] + [(m, get_name(m)) for m in models]
|
|
|
|
@fields.depends('agent', 'origin', 'base_amount')
|
|
def on_change_with_base_amount(self):
|
|
pool = Pool()
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
Currency = pool.get('currency.currency')
|
|
if (self.agent
|
|
and isinstance(self.origin, InvoiceLine)
|
|
and self.origin.id is not None
|
|
and self.origin.id >= 0
|
|
and self.base_amount is None):
|
|
return Currency.compute(
|
|
self.origin.invoice.currency, self.origin.amount,
|
|
self.agent.currency, round=False)
|
|
|
|
@fields.depends('agent')
|
|
def on_change_with_currency(self, name=None):
|
|
return self.agent.currency if self.agent else None
|
|
|
|
@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 and invoice.state in {'paid', 'cancelled'}:
|
|
state = invoice.state
|
|
return state
|
|
|
|
def chat_language(self, audience='internal'):
|
|
language = super().chat_language(audience=audience)
|
|
if audience == 'public':
|
|
language = (
|
|
self.agent.party.lang.code if self.agent.party.lang else None)
|
|
return language
|
|
|
|
@classmethod
|
|
def copy(cls, commissions, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('invoice_line', None)
|
|
return super().copy(commissions, default=default)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
def invoice(cls, commissions):
|
|
pool = Pool()
|
|
Invoice = pool.get('account.invoice')
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
try:
|
|
Move = pool.get('stock.move')
|
|
except KeyError:
|
|
Move = None
|
|
|
|
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 = []
|
|
invoice_lines = []
|
|
to_save = []
|
|
for key, commissions in groupby(commissions, key=invoice_key):
|
|
commissions = list(commissions)
|
|
key = dict(key)
|
|
invoice = cls._get_invoice(key)
|
|
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_lines.append(invoice_line)
|
|
for commission in commissions:
|
|
commission.invoice_line = invoice_line
|
|
to_save.append(commission)
|
|
Invoice.save(invoices)
|
|
InvoiceLine.save(invoice_lines)
|
|
Invoice.update_taxes(invoices)
|
|
cls.save(to_save)
|
|
|
|
if Move and hasattr(Move, 'update_unit_price'):
|
|
moves = list(set().union(*(c.stock_moves for c in commissions)))
|
|
if moves:
|
|
Move.__queue__.update_unit_price(moves)
|
|
|
|
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.currency = invoice.currency
|
|
invoice_line.company = invoice.company
|
|
invoice_line.type = 'line'
|
|
# Use product.id to instantiate it with the correct context
|
|
invoice_line.product = product.id
|
|
invoice_line.quantity = 1
|
|
|
|
invoice_line.on_change_product()
|
|
|
|
invoice_line.unit_price = amount
|
|
return invoice_line
|
|
|
|
@property
|
|
def stock_moves(self):
|
|
pool = Pool()
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
stock_moves = set()
|
|
if (isinstance(self.origin, InvoiceLine)
|
|
and hasattr(InvoiceLine, 'stock_moves')):
|
|
stock_moves.update(self.origin.stock_moves)
|
|
return stock_moves
|
|
|
|
|
|
class CreateInvoice(Wizard):
|
|
__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'))
|
|
if self.ask.agents:
|
|
agents = [agent.id for agent in self.ask.agents]
|
|
domain.append(('agent', 'in', agents))
|
|
return domain
|
|
|
|
def do_create_(self, action):
|
|
pool = Pool()
|
|
Commission = pool.get('commission')
|
|
with check_access():
|
|
commissions = Commission.search(self.get_domain(),
|
|
order=[('agent', 'DESC'), ('date', 'DESC')])
|
|
commissions = Commission.browse(commissions)
|
|
Commission.invoice(commissions)
|
|
invoice_ids = list({c.invoice_line.invoice.id for c in commissions})
|
|
return action, {'res_id': invoice_ids}
|
|
|
|
|
|
class CreateInvoiceAsk(ModelView):
|
|
__name__ = 'commission.create_invoice.ask'
|
|
from_ = fields.Date('From',
|
|
domain=[
|
|
If(Eval('to') & Eval('from_'), [('from_', '<=', Eval('to'))],
|
|
[]),
|
|
],
|
|
help="Limit to commissions from this date.")
|
|
to = fields.Date('To',
|
|
domain=[
|
|
If(Eval('from_') & Eval('to'), [('to', '>=', Eval('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.")
|
|
agents = fields.Many2Many(
|
|
'commission.agent', None, None, "Agents",
|
|
domain=[
|
|
If(Eval('type_') == 'in',
|
|
('type_', '=', 'principal'), ()),
|
|
If(Eval('type_') == 'out',
|
|
('type_', '=', 'agent'), ()),
|
|
],
|
|
help="Limit to commissions for these agents.\n"
|
|
"If empty all agents of the selected type are used.")
|
|
|
|
@staticmethod
|
|
def default_type_():
|
|
return 'both'
|