# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. "Sales extension for managing leads and opportunities" import datetime from sql import Literal, Null from sql.aggregate import Max, Count, Sum from sql.conditionals import Case, Coalesce from sql.functions import Extract from trytond.i18n import gettext from trytond.model import ModelView, ModelSQL, Workflow, fields, \ sequence_ordered from trytond.model.exceptions import AccessError from trytond.pyson import Eval, In, If, Get, Bool from trytond.transaction import Transaction from trytond.pool import Pool from trytond.ir.attachment import AttachmentCopyMixin from trytond.ir.note import NoteCopyMixin from trytond.modules.company.model import employee_field, set_employee class PartyEvaluationConcept(ModelSQL, ModelView): ''' Model to create concepts to Party Evaluation ''' 'Party Evaluation Concept' __name__ = 'crm.party_evaluation_concept' name = fields.Char('Concept Name', required=True) class PartyEvaluation(ModelSQL, ModelView): ''' Model to make analisys of economic behavior of party''' 'Party Evaluation' __name__ = 'crm.party_evaluation' concept = fields.Many2One('crm.party_evaluation_concept', 'Concept', required=True ) opportunity = fields.Many2One('crm.opportunity', 'Opportunity', required=True ) date = fields.Date('Date', required=True) observation = fields.Text('Observation') approved = fields.Boolean('Approved') class Opportunity( Workflow, ModelSQL, ModelView, AttachmentCopyMixin, NoteCopyMixin): 'CRM Opportunity' __name__ = "crm.opportunity" _history = True _rec_name = 'number' _states_start = { 'readonly': Eval('state') != 'lead', } _depends_start = ['state'] _states_stop = { 'readonly': Eval('state').in_( ['revision', 'accepted', 'lost', 'cancelled']), } _depends_stop = ['state'] number = fields.Char('Number', readonly=True, required=True, select=True) reference = fields.Char('Reference', select=True) party = fields.Many2One( 'party.party', "Party", select=True, states={ 'readonly': Eval('state').in_(['converted', 'lost', 'cancelled']), 'required': ~Eval('state').in_(['lead', 'lost', 'cancelled']), }, context={ 'company': Eval('company', -1), }, depends=['state', 'company']) contact = fields.Many2One( 'party.contact_mechanism', "Contact", context={ 'company': Eval('company', -1), }, search_context={ 'related_party': Eval('party'), }, depends=['party', 'company']) address = fields.Many2One('party.address', 'Address', domain=[('party', '=', Eval('party'))], select=True, depends=['party', 'state'], states=_states_stop) company = fields.Many2One('company.company', 'Company', required=True, select=True, states={ 'readonly': _states_stop['readonly'] | Eval('party', True), }, domain=[ ('id', If(In('company', Eval('context', {})), '=', '!='), Get(Eval('context', {}), 'company', 0)), ], depends=_depends_stop) currency = fields.Function(fields.Many2One('currency.currency', 'Currency'), 'get_currency') currency_digits = fields.Function(fields.Integer('Currency Digits'), 'get_currency_digits') amount = fields.Numeric('Amount', digits=(16, Eval('currency_digits', 2)), states=_states_stop, depends=_depends_stop + ['currency_digits'], help='Estimated revenue amount.') payment_term = fields.Many2One('account.invoice.payment_term', 'Payment Term', states={ 'readonly': In(Eval('state'), ['converted', 'lost', 'cancelled']), }, depends=['state']) employee = fields.Many2One('company.employee', 'Employee', states={ 'readonly': _states_stop['readonly'], 'required': ~Eval('state').in_(['lead', 'lost', 'cancelled']), }, depends=['state', 'company'], domain=[('company', '=', Eval('company'))]) start_date = fields.Date('Start Date', required=True, select=True, states=_states_start, depends=_depends_start) end_date = fields.Date('End Date', select=True, states=_states_stop, depends=_depends_stop) description = fields.Char('Description', states=_states_stop, depends=_depends_stop) comment = fields.Text('Comment', states=_states_stop, depends=_depends_stop) lines = fields.One2Many('crm.opportunity.line', 'opportunity', 'Lines', states=_states_stop, depends=_depends_stop) conversion_probability = fields.Float('Conversion Probability', digits=(1, 4), required=True, domain=[ ('conversion_probability', '>=', 0), ('conversion_probability', '<=', 1), ], states={ 'readonly': ~Eval('state').in_( ['opportunity', 'lead', 'converted']), }, depends=['state'], help="Percentage between 0 and 100.") lost_reason = fields.Text('Reason for loss', states={ 'invisible': Eval('state') != 'lost', }, depends=['state']) sales = fields.One2Many('sale.sale', 'origin', 'Sales') contracts = fields.One2Many('sale.contract', 'origin', 'Contracts') converted_by = employee_field( "Converted By", states=['accepted', 'lost', 'cancelled']) state = fields.Selection([ ('prospecting', "Prospecting"), ('analysis', "Analysis"), ('quotation', "Quotation"), ('internal_validation', "Internal Validation"), ('revision', "Revision"), ('review', "Review"), ('accepted', "Acceptance"), ('cancelled', "Cancelled"), ('lost', "Lost"), ], "State", required=True, select=True, sort=False, readonly=True) type = fields.Selection([ ('sale', 'Sale'), ('contract', 'Contract'), ], "Type", required=True, select=True) source = fields.Selection([ ('website', 'Website'), ('phone', 'Phone'), ('email', 'Email'), ('whatsapp', 'WhatsApp'), ('salesman', 'Salesman'), ('referred', 'Referred'), ], "Source", required=True, select=True) party_evaluations = fields.One2Many('crm.party_evaluation', 'opportunity', 'Party Evaluations') # del _states_start, _depends_start # del _states_stop, _depends_stop @classmethod def __setup__(cls): super(Opportunity, cls).__setup__() cls._order.insert(0, ('start_date', 'DESC')) cls._transitions |= set(( ('prospecting', 'analysis'), ('analysis', 'prospecting'), ('analysis', 'quotation'), ('analysis', 'review'), ('review', 'quotation'), ('review', 'cancelled'), ('review', 'analysis'), ('review', 'lost'), ('quotation', 'analysis'), ('quotation', 'internal_validation'), ('quotation', 'cancelled'), ('revision', 'quotation'), ('internal_validation', 'revision'), ('internal_validation', 'accepted'), ('internal_validation', 'lost'), # ('internal_validation', 'quotation'), ('revision', 'lost'), ('accepted', 'revision'), ('accepted', 'cancelled'), ('accepted', 'internal_validation'), ('cancelled', 'prospecting'), ('lost', 'revision'), )) cls._buttons.update({ 'prospecting': { 'invisible': ~Eval('state').in_( ['analysis']), 'icon': If(Eval('state').in_(['analysis']), 'tryton-back', 'tryton-forward'), }, 'analysis': { 'invisible': ~Eval('state').in_(['prospecting', 'review', 'quotation']), 'icon': If(Eval('state').in_(['review','quotation']), 'tryton-back', 'tryton-forward'), }, 'quotation': { 'invisible': ~Eval('state').in_(['analysis', 'review', 'revision', 'cancelled']), 'icon': If(Eval('state').in_(['revision']), 'tryton-back', 'tryton-forward'), }, 'review': { 'invisible': ~Eval('state').in_(['analysis']), }, 'internal_validation': { 'invisible': ~Eval('state').in_(['quotation','revision', 'accepted']), 'icon': If(Eval('state').in_(['revision', 'accepted']), 'tryton-back', 'tryton-forward'), }, 'revision': { 'invisible': ~Eval('state').in_(['internal_validation', 'lost']), 'icon': If(Eval('state').in_(['accepted','internal_validation']), 'tryton-back', 'tryton-forward'), }, 'accepted': { 'invisible': ~Eval('state').in_(['internal_validation']), 'icon': If(Eval('state').in_(['internal_validation']), 'tryton-create', 'tryton-back'), }, 'lost': { 'invisible': ~Eval('state').in_(['review', 'internal_validation']), }, # 'cancelled': { # 'invisible': ~Eval('state').in_(['prospecting']), # }, }) @staticmethod def default_state(): return 'prospecting' @staticmethod def default_start_date(): Date = Pool().get('ir.date') return Date.today() @staticmethod def default_conversion_probability(): return 0.5 @staticmethod def default_company(): return Transaction().context.get('company') @staticmethod def default_employee(): return Transaction().context.get('employee') @classmethod def default_payment_term(cls): PaymentTerm = Pool().get('account.invoice.payment_term') payment_terms = PaymentTerm.search(cls.payment_term.domain) if len(payment_terms) == 1: return payment_terms[0].id @classmethod def view_attributes(cls): return super().view_attributes() + [ ('/tree', 'visual', If(Eval('state') == 'cancelled', 'muted', '')), ] @classmethod def get_resources_to_copy(cls, name): return { 'sale.sale', } @classmethod def create(cls, vlist): pool = Pool() Config = pool.get('crm.configuration') config = Config().get_configuration() vlist = [x.copy() for x in vlist] default_company = cls.default_company() for vals in vlist: if vals.get('number') is None and config: vals['number'] = config.opportunity_sequence.get() return super(Opportunity, cls).create(vlist) @classmethod def copy(cls, opportunities, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('number', None) default.setdefault('sales', None) default.setdefault('converted_by') return super(CrmOpportunity, cls).copy(opportunities, default=default) def get_currency(self, name): return self.company.currency.id def get_currency_digits(self, name): return self.company.currency.digits @fields.depends('company') def on_change_company(self): if self.company: self.currency = self.company.currency self.currency_digits = self.company.currency.digits @fields.depends('party') def on_change_party(self): if self.party and self.party.customer_payment_term: self.payment_term = self.party.customer_payment_term else: self.payment_term = self.default_payment_term() def _get_contract_opportunity(self): ''' Return contract for an opportunity ''' # Contract = Pool().get('sale.contract') # print(Contract) Date = Pool().get('ir.date') today = Date.today() return { 'description':self.description, 'party':self.party, 'salesman':self.employee, 'payment_term':self.payment_term, 'company':self.company, 'currency':self.company.currency, 'comment':self.comment, 'reference':self.reference, 'contract_date':today, 'origin':self, 'state':'draft', } def _get_sale_opportunity(self): ''' Return sale for an opportunity ''' Sale = Pool().get('sale.sale') return Sale( description=self.description, party=self.party, contact=self.contact, payment_term=self.payment_term, company=self.company, invoice_address=self.address, shipment_address=self.address, currency=self.company.currency, comment=self.comment, sale_date=None, origin=self, warehouse=Sale.default_warehouse(), ) def create_sale(self): ''' Create a sale for the opportunity and return the sale ''' sale = self._get_sale_opportunity() sale_lines = [] for line in self.lines: sale_lines.append(line.get_sale_line(sale)) sale.lines = sale_lines return sale def create_contract(self): ''' Create a contract for the opportunity and return the contract ''' contract = self._get_contract_opportunity() # Contract = Pool().get('sale.contract') # contract = opportunity.create_contract() # Contract.save([contract]) contract_lines = [] for line in self.lines: contract_lines.append(line.get_contract_line()) contract['product_lines'] = [('create',contract_lines)] return contract @classmethod def delete(cls, opportunities): # Cancel before delete cls.cancel(opportunities) for opportunity in opportunities: if opportunity.state != 'cancelled': raise AccessError( gettext('crm.msg_opportunity_delete_cancel', opportunity=opportunity.rec_name)) super(Opportunity, cls).delete(opportunities) @classmethod @ModelView.button @Workflow.transition('prospecting') def prospecting(cls, records): pass @classmethod @ModelView.button @Workflow.transition('analysis') def analysis(cls, records): pass @classmethod @ModelView.button @Workflow.transition('review') def review(cls, records): pass @classmethod @ModelView.button @Workflow.transition('internal_validation') def internal_validation(cls, records): pass @classmethod @ModelView.button @Workflow.transition('revision') def revision(cls, records): pass @classmethod @ModelView.button @Workflow.transition('quotation') @set_employee('converted_by') def quotation(cls, records): pass @property def is_forecast(self): pool = Pool() Date = pool.get('ir.date') today = Date.today() return self.end_date or datetime.date.max > today @classmethod @Workflow.transition('accepted') def accepted(cls, records): cls.procces_opportunity(records) @classmethod def procces_opportunity(cls, records): for opportunity in records: if opportunity.type == 'contract': Contract = Pool().get('sale.contract') contract = opportunity.create_contract() Contract.create([contract]) else: # opportunity.create_sale() Sale = Pool().get('sale.sale') sale = opportunity.create_sale() Sale.save(sale) @classmethod @ModelView.button @Workflow.transition('lost') def lost(cls, records): Date = Pool().get('ir.date') cls.write([o for o in records if o.is_forecast], { 'end_date': Date.today(), 'state': 'lost', }) # @classmethod # @ModelView.button # @Workflow.transition('cancelled') # def cancelled(cls, records): # pass # Date = Pool().get('ir.date') # cls.write([o for o in records if o.is_forecast], { # 'end_date': Date.today(), # }) @staticmethod def _sale_won_states(): return ['confirmed', 'processing', 'done'] @staticmethod def _sale_lost_states(): return ['cancelled'] def is_won(self): sale_won_states = self._sale_won_states() sale_lost_states = self._sale_lost_states() end_states = sale_won_states + sale_lost_states return (self.sales and all(s.state in end_states for s in self.sales) and any(s.state in sale_won_states for s in self.sales)) def is_lost(self): sale_lost_states = self._sale_lost_states() return (self.sales and all(s.state in sale_lost_states for s in self.sales)) @property def sale_amount(self): pool = Pool() Currency = pool.get('currency.currency') if not self.sales: return sale_lost_states = self._sale_lost_states() amount = 0 for sale in self.sales: if sale.state not in sale_lost_states: amount += Currency.compute(sale.currency, sale.untaxed_amount, self.currency) return amount @classmethod def process(cls, opportunities): won = [] lost = [] converted = [] for opportunity in opportunities: sale_amount = opportunity.sale_amount if opportunity.amount != sale_amount: opportunity.amount = sale_amount if opportunity.is_won(): won.append(opportunity) elif opportunity.is_lost(): lost.append(opportunity) elif (opportunity.state != 'converted' and opportunity.sales): converted.append(opportunity) cls.save(opportunities) if won: cls.won(won) if lost: cls.lost(lost) if converted: cls.convert(converted) class CrmOpportunityLine(sequence_ordered(), ModelSQL, ModelView): 'CRM Opportunity Line' __name__ = "crm.opportunity.line" _history = True _states = { 'readonly': Eval('opportunity_state').in_( ['revision', 'accepted', 'lost', 'cancelled']), } _depends = ['opportunity_state'] opportunity = fields.Many2One('crm.opportunity', 'Opportunity', ondelete='CASCADE', select=True, required=True, states={ 'readonly': _states['readonly'] & Bool(Eval('opportunity')), }, depends=_depends) opportunity_state = fields.Function( fields.Selection('get_opportunity_states', "Opportunity State"), 'on_change_with_opportunity_state') product = fields.Many2One('product.product', 'Product', required=True, domain=[('salable', '=', True)], states=_states, depends=_depends) quantity = fields.Float('Quantity', required=True, digits=(16, Eval('unit_digits', 2)), states=_states, depends=['unit_digits'] + _depends) unit = fields.Many2One('product.uom', 'Unit', required=True, states=_states, depends=_depends) unit_digits = fields.Function(fields.Integer('Unit Digits'), 'on_change_with_unit_digits') del _states, _depends @classmethod def __setup__(cls): super().__setup__() cls.__access__.add('opportunity') @classmethod def get_opportunity_states(cls): pool = Pool() Opportunity = pool.get('crm.opportunity') return Opportunity.fields_get(['state'])['state']['selection'] @fields.depends('opportunity', '_parent_opportunity.state') def on_change_with_opportunity_state(self, name=None): if self.opportunity: return self.opportunity.state @fields.depends('unit') def on_change_with_unit_digits(self, name=None): if self.unit: return self.unit.digits return 2 @fields.depends('product', 'unit') def on_change_product(self): if not self.product: return category = self.product.sale_uom.category if not self.unit or self.unit.category != category: self.unit = self.product.sale_uom self.unit_digits = self.product.sale_uom.digits def get_sale_line(self, sale): ''' Return sale line for opportunity line ''' SaleLine = Pool().get('sale.line') sale_line = SaleLine( type='line', product=self.product, sale=sale, description=None, ) sale_line.on_change_product() self._set_sale_line_quantity(sale_line) sale_line.on_change_quantity() return sale_line def get_contract_line(self): ''' Return contract line for opportunity line ''' # ContractLine = Pool().get('sale.contract.product_line') contract_line = { 'type': 'line', # 'contract': contract, 'product': self.product, 'description': self.product.name, 'unit_price': self.unit_price, # quantity = self.quantity, } # print(contract, "Contract in contract line") # print(contract_line, "contract line") return contract_line def _set_sale_line_quantity(self, sale_line): sale_line.quantity = self.quantity sale_line.unit = self.unit def get_rec_name(self, name): pool = Pool() Lang = pool.get('ir.lang') lang = Lang.get() return (lang.format( '%.*f', (self.unit.digits, self.quantity or 0)) + '%s %s @ %s' % ( self.unit.symbol, self.product.rec_name, self.opportunity.rec_name)) @classmethod def search_rec_name(cls, name, clause): return [('product.rec_name',) + tuple(clause[1:])] class SaleOpportunityReportMixin: __slots__ = () number = fields.Integer('Number') converted = fields.Integer('Converted') conversion_rate = fields.Function(fields.Float('Conversion Rate', digits=(1, 4)), 'get_conversion_rate') won = fields.Integer('Won') winning_rate = fields.Function(fields.Float('Winning Rate', digits=(1, 4)), 'get_winning_rate') lost = fields.Integer('Lost') company = fields.Many2One('company.company', 'Company') currency = fields.Function(fields.Many2One('currency.currency', 'Currency'), 'get_currency') currency_digits = fields.Function(fields.Integer('Currency Digits'), 'get_currency_digits') amount = fields.Numeric('Amount', digits=(16, Eval('currency_digits', 2)), depends=['currency_digits']) converted_amount = fields.Numeric('Converted Amount', digits=(16, Eval('currency_digits', 2)), depends=['currency_digits']) conversion_amount_rate = fields.Function(fields.Float( 'Conversion Amount Rate', digits=(1, 4)), 'get_conversion_amount_rate') won_amount = fields.Numeric('Won Amount', digits=(16, Eval('currency_digits', 2)), depends=['currency_digits']) winning_amount_rate = fields.Function(fields.Float( 'Winning Amount Rate', digits=(1, 4)), 'get_winning_amount_rate') @staticmethod def _converted_state(): return ['converted', 'won'] @staticmethod def _won_state(): return ['won'] @staticmethod def _lost_state(): return ['lost'] def get_conversion_rate(self, name): if self.number: digits = getattr(self.__class__, name).digits[1] return round(float(self.converted) / self.number, digits) else: return 0.0 def get_winning_rate(self, name): if self.number: digits = getattr(self.__class__, name).digits[1] return round(float(self.won) / self.number, digits) else: return 0.0 def get_currency(self, name): return self.company.currency.id def get_currency_digits(self, name): return self.company.currency.digits def get_conversion_amount_rate(self, name): if self.amount: digits = getattr(self.__class__, name).digits[1] return round( float(self.converted_amount) / float(self.amount), digits) else: return 0.0 def get_winning_amount_rate(self, name): if self.amount: digits = getattr(self.__class__, name).digits[1] return round(float(self.won_amount) / float(self.amount), digits) else: return 0.0 @classmethod def table_query(cls): Opportunity = Pool().get('crm.opportunity') opportunity = Opportunity.__table__() return opportunity.select( Max(opportunity.create_uid).as_('create_uid'), Max(opportunity.create_date).as_('create_date'), Max(opportunity.write_uid).as_('write_uid'), Max(opportunity.write_date).as_('write_date'), opportunity.company, Count(Literal(1)).as_('number'), Sum(Case( (opportunity.state.in_(cls._converted_state()), Literal(1)), else_=Literal(0))).as_('converted'), Sum(Case( (opportunity.state.in_(cls._won_state()), Literal(1)), else_=Literal(0))).as_('won'), Sum(Case( (opportunity.state.in_(cls._lost_state()), Literal(1)), else_=Literal(0))).as_('lost'), Sum(opportunity.amount).as_('amount'), Sum(Case( (opportunity.state.in_(cls._converted_state()), opportunity.amount), else_=Literal(0))).as_('converted_amount'), Sum(Case( (opportunity.state.in_(cls._won_state()), opportunity.amount), else_=Literal(0))).as_('won_amount')) class SaleOpportunityEmployee(SaleOpportunityReportMixin, ModelSQL, ModelView): 'Sale Opportunity per Employee' __name__ = 'crm.opportunity_employee' employee = fields.Many2One('company.employee', 'Employee') @classmethod def table_query(cls): query = super(SaleOpportunityEmployee, cls).table_query() opportunity, = query.from_ query.columns += ( Coalesce(opportunity.employee, 0).as_('id'), opportunity.employee, ) where = Literal(True) if Transaction().context.get('start_date'): where &= (opportunity.start_date >= Transaction().context['start_date']) if Transaction().context.get('end_date'): where &= (opportunity.start_date <= Transaction().context['end_date']) query.where = where query.group_by = (opportunity.employee, opportunity.company) return query class SaleOpportunityEmployeeContext(ModelView): 'Sale Opportunity per Employee Context' __name__ = 'crm.opportunity_employee.context' start_date = fields.Date('Start Date') end_date = fields.Date('End Date') class SaleOpportunityMonthly(SaleOpportunityReportMixin, ModelSQL, ModelView): 'Sale Opportunity per Month' __name__ = 'crm.opportunity_monthly' year = fields.Char('Year') month = fields.Many2One('ir.calendar.month', "Month") year_month = fields.Function(fields.Char('Year-Month'), 'get_year_month') @classmethod def __setup__(cls): super(SaleOpportunityMonthly, cls).__setup__() cls._order.insert(0, ('year', 'DESC')) cls._order.insert(1, ('month.index', 'DESC')) def get_year_month(self, name): return '%s-%s' % (self.year, self.month.index) @classmethod def table_query(cls): pool = Pool() Month = pool.get('ir.calendar.month') month = Month.__table__() query = super(SaleOpportunityMonthly, cls).table_query() opportunity, = query.from_ type_id = cls.id.sql_type().base type_year = cls.year.sql_type().base year_column = Extract('YEAR', opportunity.start_date ).cast(type_year).as_('year') month_index = Extract('MONTH', opportunity.start_date) query.from_ = opportunity.join( month, condition=month_index == month.index) query.columns += ( Max(Extract('MONTH', opportunity.start_date) + Extract('YEAR', opportunity.start_date) * 100 ).cast(type_id).as_('id'), year_column, month.id.as_('month')) query.group_by = (year_column, month.id, opportunity.company) return query class SaleOpportunityEmployeeMonthly( SaleOpportunityReportMixin, ModelSQL, ModelView): 'Sale Opportunity per Employee per Month' __name__ = 'crm.opportunity_employee_monthly' year = fields.Char('Year') month = fields.Many2One('ir.calendar.month', "Month") employee = fields.Many2One('company.employee', 'Employee') @classmethod def __setup__(cls): super(SaleOpportunityEmployeeMonthly, cls).__setup__() cls._order.insert(0, ('year', 'DESC')) cls._order.insert(1, ('month.index', 'DESC')) cls._order.insert(2, ('employee', 'ASC')) @classmethod def table_query(cls): pool = Pool() Month = pool.get('ir.calendar.month') month = Month.__table__() query = super(SaleOpportunityEmployeeMonthly, cls).table_query() opportunity, = query.from_ type_id = cls.id.sql_type().base type_year = cls.year.sql_type().base year_column = Extract('YEAR', opportunity.start_date ).cast(type_year).as_('year') month_index = Extract('MONTH', opportunity.start_date) query.from_ = opportunity.join( month, condition=month_index == month.index) query.columns += ( Max(Extract('MONTH', opportunity.start_date) + Extract('YEAR', opportunity.start_date) * 100 + Coalesce(opportunity.employee, 0) * 1000000 ).cast(type_id).as_('id'), year_column, month.id.as_('month'), opportunity.employee, ) query.group_by = (year_column, month.id, opportunity.employee, opportunity.company) return query