# 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'