675 lines
23 KiB
Python
675 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.
|
|
"Sales extension for managing leads and opportunities"
|
|
import datetime
|
|
from itertools import groupby
|
|
|
|
from sql.functions import CharLength
|
|
|
|
from trytond.i18n import gettext
|
|
from trytond.ir.attachment import AttachmentCopyMixin
|
|
from trytond.ir.note import NoteCopyMixin
|
|
from trytond.model import (
|
|
ChatMixin, Index, ModelSQL, ModelView, Workflow, fields, sequence_ordered)
|
|
from trytond.model.exceptions import AccessError
|
|
from trytond.modules.company.model import employee_field, set_employee
|
|
from trytond.modules.currency.fields import Monetary
|
|
from trytond.pool import Pool
|
|
from trytond.pyson import Bool, Eval, Get, If, In
|
|
from trytond.tools import firstline
|
|
from trytond.transaction import Transaction
|
|
|
|
|
|
class SaleOpportunity(
|
|
Workflow, ModelSQL, ModelView,
|
|
AttachmentCopyMixin, NoteCopyMixin, ChatMixin):
|
|
__name__ = "sale.opportunity"
|
|
_history = True
|
|
_rec_name = 'number'
|
|
|
|
_states_start = {
|
|
'readonly': Eval('state') != 'lead',
|
|
}
|
|
_states_stop = {
|
|
'readonly': Eval('state').in_(
|
|
['converted', 'won', 'lost', 'cancelled']),
|
|
}
|
|
|
|
number = fields.Char("Number", readonly=True, required=True)
|
|
reference = fields.Char("Reference")
|
|
party = fields.Many2One(
|
|
'party.party', "Party",
|
|
states={
|
|
'readonly': Eval('state').in_(['converted', 'lost', 'cancelled']),
|
|
'required': ~Eval('state').in_(['lead', 'lost', 'cancelled']),
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'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", states=_states_stop,
|
|
domain=[('party', '=', Eval('party', -1))],
|
|
help="The default address for the invoice and shipment.\n"
|
|
"Leave empty to use the default values.")
|
|
company = fields.Many2One(
|
|
'company.company', "Company", required=True,
|
|
states={
|
|
'readonly': _states_stop['readonly'] | Eval('party', True),
|
|
},
|
|
domain=[
|
|
('id', If(In('company', Eval('context', {})), '=', '!='),
|
|
Get(Eval('context', {}), 'company', 0)),
|
|
])
|
|
currency = fields.Many2One(
|
|
'currency.currency', "Currency", required=True, states=_states_start)
|
|
amount = Monetary(
|
|
"Amount", currency='currency', digits='currency',
|
|
states=_states_stop,
|
|
help='Estimated revenue amount.')
|
|
payment_term = fields.Many2One(
|
|
'account.invoice.payment_term', "Payment Term", ondelete='RESTRICT',
|
|
states={
|
|
'readonly': In(Eval('state'),
|
|
['converted', 'lost', 'cancelled']),
|
|
})
|
|
employee = fields.Many2One('company.employee', 'Employee',
|
|
states={
|
|
'readonly': _states_stop['readonly'],
|
|
'required': ~Eval('state').in_(['lead', 'lost', 'cancelled']),
|
|
},
|
|
domain=[('company', '=', Eval('company', -1))])
|
|
start_date = fields.Date("Start Date", required=True, states=_states_start)
|
|
end_date = fields.Date("End Date", states=_states_stop)
|
|
description = fields.Char('Description', states=_states_stop)
|
|
comment = fields.Text('Comment', states=_states_stop)
|
|
lines = fields.One2Many('sale.opportunity.line', 'opportunity', 'Lines',
|
|
states=_states_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']),
|
|
},
|
|
help="Percentage between 0 and 100.")
|
|
lost_reason = fields.Text('Reason for loss', states={
|
|
'invisible': Eval('state') != 'lost',
|
|
})
|
|
sales = fields.One2Many('sale.sale', 'origin', 'Sales')
|
|
|
|
converted_by = employee_field(
|
|
"Converted By", states=['converted', 'won', 'lost', 'cancelled'])
|
|
state = fields.Selection([
|
|
('lead', "Lead"),
|
|
('opportunity', "Opportunity"),
|
|
('converted', "Converted"),
|
|
('won', "Won"),
|
|
('lost', "Lost"),
|
|
('cancelled', "Cancelled"),
|
|
], "State", required=True, sort=False, readonly=True)
|
|
|
|
del _states_start
|
|
del _states_stop
|
|
|
|
@classmethod
|
|
def __register__(cls, module_name):
|
|
pool = Pool()
|
|
Company = pool.get('company.company')
|
|
transaction = Transaction()
|
|
cursor = transaction.connection.cursor()
|
|
sql_table = cls.__table__()
|
|
company = Company.__table__()
|
|
|
|
table = cls.__table_handler__(module_name)
|
|
currency_exists = table.column_exist('currency')
|
|
|
|
super().__register__(module_name)
|
|
|
|
# Migration from 6.4: store currency
|
|
if not currency_exists:
|
|
value = company.select(
|
|
company.currency,
|
|
where=(sql_table.company == company.id))
|
|
cursor.execute(*sql_table.update(
|
|
[sql_table.currency],
|
|
[value]))
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
cls.number.search_unaccented = False
|
|
cls.reference.search_unaccented = False
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls._sql_indexes.update({
|
|
Index(t, (t.reference, Index.Similarity())),
|
|
Index(t, (t.party, Index.Range())),
|
|
Index(
|
|
t,
|
|
(t.start_date, Index.Range(order='DESC')),
|
|
(t.end_date, Index.Range(order='DESC'))),
|
|
Index(
|
|
t, (t.state, Index.Equality(cardinality='low')),
|
|
where=t.state.in_(['lead', 'opportunity'])),
|
|
})
|
|
cls._order.insert(0, ('start_date', 'DESC'))
|
|
cls._transitions |= set((
|
|
('lead', 'opportunity'),
|
|
('lead', 'lost'),
|
|
('lead', 'cancelled'),
|
|
('lead', 'converted'),
|
|
('opportunity', 'converted'),
|
|
('opportunity', 'lead'),
|
|
('opportunity', 'lost'),
|
|
('opportunity', 'cancelled'),
|
|
('converted', 'won'),
|
|
('converted', 'lost'),
|
|
('won', 'converted'),
|
|
('lost', 'converted'),
|
|
('lost', 'lead'),
|
|
('cancelled', 'lead'),
|
|
))
|
|
cls._buttons.update({
|
|
'lead': {
|
|
'invisible': ~Eval('state').in_(
|
|
['cancelled', 'lost', 'opportunity']),
|
|
'icon': If(Eval('state').in_(['cancelled', 'lost']),
|
|
'tryton-undo', 'tryton-back'),
|
|
'depends': ['state'],
|
|
},
|
|
'opportunity': {
|
|
'pre_validate': [
|
|
If(~Eval('party'),
|
|
('party', '!=', None),
|
|
()),
|
|
If(~Eval('employee'),
|
|
('employee', '!=', None),
|
|
()),
|
|
],
|
|
'invisible': ~Eval('state').in_(['lead']),
|
|
'depends': ['state'],
|
|
},
|
|
'convert': {
|
|
'invisible': ~Eval('state').in_(['opportunity']),
|
|
'depends': ['state'],
|
|
},
|
|
'lost': {
|
|
'invisible': ~Eval('state').in_(['lead', 'opportunity']),
|
|
'depends': ['state'],
|
|
},
|
|
'cancel': {
|
|
'invisible': ~Eval('state').in_(['lead', 'opportunity']),
|
|
'depends': ['state'],
|
|
},
|
|
})
|
|
|
|
@classmethod
|
|
def order_number(cls, tables):
|
|
table, _ = tables[None]
|
|
return [CharLength(table.number), table.number]
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'lead'
|
|
|
|
@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')
|
|
|
|
@fields.depends('amount', 'company')
|
|
def on_change_company(self):
|
|
self.payment_term = self.default_payment_term(
|
|
company=self.company.id if self.company else None)
|
|
if not self.amount:
|
|
self.currency = self.default_currency(
|
|
company=self.company.id if self.company else None)
|
|
|
|
@classmethod
|
|
def default_currency(cls, **pattern):
|
|
pool = Pool()
|
|
Company = pool.get('company.company')
|
|
company = pattern.get('company')
|
|
if not company:
|
|
company = cls.default_company()
|
|
if company is not None and company >= 0:
|
|
return Company(company).currency.id
|
|
|
|
@staticmethod
|
|
def default_employee():
|
|
return Transaction().context.get('employee')
|
|
|
|
@classmethod
|
|
def default_payment_term(cls, **pattern):
|
|
pool = Pool()
|
|
Configuration = pool.get('account.configuration')
|
|
config = Configuration(1)
|
|
payment_term = config.get_multivalue(
|
|
'default_customer_payment_term', **pattern)
|
|
return payment_term.id if payment_term else None
|
|
|
|
def get_rec_name(self, name):
|
|
items = [self.number]
|
|
if self.reference:
|
|
items.append(f'[{self.reference}]')
|
|
return ' '.join(items)
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
_, operator, value = clause
|
|
if operator.startswith('!') or operator.startswith('not '):
|
|
bool_op = 'AND'
|
|
else:
|
|
bool_op = 'OR'
|
|
domain = [bool_op,
|
|
('number', operator, value),
|
|
('reference', operator, value),
|
|
]
|
|
return domain
|
|
|
|
@classmethod
|
|
def view_attributes(cls):
|
|
return super().view_attributes() + [
|
|
('/tree', 'visual', If(Eval('state') == 'cancelled', 'muted', '')),
|
|
]
|
|
|
|
def chat_language(self, audience='internal'):
|
|
language = super().chat_language(audience=audience)
|
|
if audience == 'public':
|
|
language = self.party.lang.code if self.party.lang else None
|
|
return language
|
|
|
|
@classmethod
|
|
def get_resources_to_copy(cls, name):
|
|
return {
|
|
'sale.sale',
|
|
}
|
|
|
|
@classmethod
|
|
def preprocess_values(cls, mode, values):
|
|
pool = Pool()
|
|
Configuration = pool.get('sale.configuration')
|
|
values = super().preprocess_values(mode, values)
|
|
if mode == 'create' and not values.get('number'):
|
|
company_id = values.get('company', cls.default_company())
|
|
if company_id is not None:
|
|
configuration = Configuration(1)
|
|
if sequence := configuration.get_multivalue(
|
|
'sale_opportunity_sequence', company=company_id):
|
|
values['number'] = sequence.get()
|
|
return values
|
|
|
|
@classmethod
|
|
def check_modification(
|
|
cls, mode, opportunities, values=None, external=False):
|
|
super().check_modification(
|
|
mode, opportunities, values=values, external=external)
|
|
if mode == 'delete':
|
|
for opportunity in opportunities:
|
|
if opportunity.state not in {'cancelled', 'draft'}:
|
|
raise AccessError(gettext(
|
|
'sale_opportunity.msg_opportunity_delete_cancel',
|
|
opportunity=opportunity.rec_name))
|
|
|
|
@classmethod
|
|
def copy(cls, opportunities, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('number', None)
|
|
default.setdefault('reference')
|
|
default.setdefault('sales', None)
|
|
default.setdefault('converted_by')
|
|
return super().copy(opportunities, default=default)
|
|
|
|
@fields.depends('party', 'amount', 'company')
|
|
def on_change_party(self):
|
|
self.payment_term = self.default_payment_term(
|
|
company=self.company.id if self.company else None)
|
|
if self.party:
|
|
if self.party.customer_payment_term:
|
|
self.payment_term = self.party.customer_payment_term
|
|
if not self.amount:
|
|
if self.party.customer_currency:
|
|
self.currency = self.party.customer_currency
|
|
|
|
def _get_sale_opportunity(self):
|
|
'''
|
|
Return sale for an opportunity
|
|
'''
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
sale = Sale(
|
|
description=self.description,
|
|
party=self.party,
|
|
contact=self.contact,
|
|
company=self.company,
|
|
comment=self.comment,
|
|
sale_date=None,
|
|
origin=self,
|
|
warehouse=Sale.default_warehouse(),
|
|
)
|
|
sale.on_change_party()
|
|
if self.address:
|
|
sale.invoice_address = sale.shipment_address = self.address
|
|
if self.payment_term:
|
|
sale.payment_term = self.payment_term
|
|
sale.currency = self.currency
|
|
return sale
|
|
|
|
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
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('lead')
|
|
def lead(cls, opportunities):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('opportunity')
|
|
def opportunity(cls, opportunities):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button_action('sale.act_sale_form')
|
|
@Workflow.transition('converted')
|
|
@set_employee('converted_by')
|
|
def convert(cls, opportunities):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
sales = [o.create_sale() for o in opportunities if not o.sales]
|
|
Sale.save(sales)
|
|
for sale in sales:
|
|
sale.origin.copy_resources_to(sale)
|
|
return {
|
|
'res_id': [s.id for s in sales],
|
|
}
|
|
|
|
@property
|
|
def is_forecast(self):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
with Transaction().set_context(company=self.company.id):
|
|
today = Date.today()
|
|
return self.end_date or datetime.date.max > today
|
|
|
|
@classmethod
|
|
@Workflow.transition('won')
|
|
def won(cls, opportunities):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
for company, c_opportunities in groupby(
|
|
opportunities, key=lambda o: o.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
cls.write([o for o in c_opportunities if o.is_forecast], {
|
|
'end_date': today,
|
|
'state': 'won',
|
|
})
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('lost')
|
|
def lost(cls, opportunities):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
for company, c_opportunities in groupby(
|
|
opportunities, key=lambda o: o.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
cls.write([o for o in c_opportunities if o.is_forecast], {
|
|
'end_date': today,
|
|
'state': 'lost',
|
|
})
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('cancelled')
|
|
def cancel(cls, opportunities):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
for company, c_opportunities in groupby(
|
|
opportunities, key=lambda o: o.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
cls.write([o for o in c_opportunities if o.is_forecast], {
|
|
'end_date': today,
|
|
'state': 'cancelled',
|
|
})
|
|
|
|
@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 SaleOpportunityLine(sequence_ordered(), ModelSQL, ModelView):
|
|
__name__ = "sale.opportunity.line"
|
|
_history = True
|
|
_states = {
|
|
'readonly': Eval('opportunity_state').in_(
|
|
['converted', 'won', 'lost', 'cancelled']),
|
|
}
|
|
|
|
opportunity = fields.Many2One(
|
|
'sale.opportunity', "Opportunity", ondelete='CASCADE', required=True,
|
|
states={
|
|
'readonly': _states['readonly'] & Bool(Eval('opportunity')),
|
|
})
|
|
opportunity_state = fields.Function(
|
|
fields.Selection('get_opportunity_states', "Opportunity State"),
|
|
'on_change_with_opportunity_state')
|
|
product = fields.Many2One(
|
|
'product.product', "Product",
|
|
domain=[
|
|
If(Eval('opportunity_state').in_(['lead', 'opportunity'])
|
|
& ~(Eval('quantity', 0) < 0),
|
|
('salable', '=', True),
|
|
()),
|
|
],
|
|
states=_states,
|
|
context={
|
|
'company': Eval('company', None),
|
|
},
|
|
depends=['company'])
|
|
product_uom_category = fields.Function(
|
|
fields.Many2One(
|
|
'product.uom.category', "Product UoM Category"),
|
|
'on_change_with_product_uom_category')
|
|
quantity = fields.Float(
|
|
"Quantity", digits='unit', required=True, states=_states)
|
|
unit = fields.Many2One(
|
|
'product.uom', "Unit",
|
|
domain=[
|
|
If(Eval('product_uom_category'),
|
|
('category', '=', Eval('product_uom_category', -1)),
|
|
('category', '=', -1)),
|
|
],
|
|
states={
|
|
'required': Bool(Eval('product')),
|
|
'readonly': _states['readonly'],
|
|
})
|
|
description = fields.Text("Description", states=_states)
|
|
summary = fields.Function(
|
|
fields.Char("Summary"), 'on_change_with_summary',
|
|
searcher='search_summary')
|
|
note = fields.Text("Note")
|
|
|
|
company = fields.Function(
|
|
fields.Many2One('company.company', "Company"),
|
|
'on_change_with_company')
|
|
|
|
del _states
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
cls.__access__.add('opportunity')
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
table_h = cls.__table_handler__(module)
|
|
super().__register__(module)
|
|
# Migration from 7.0: remove required on product and unit
|
|
table_h.not_null_action('product', 'remove')
|
|
table_h.not_null_action('unit', 'remove')
|
|
|
|
@classmethod
|
|
def get_opportunity_states(cls):
|
|
pool = Pool()
|
|
Opportunity = pool.get('sale.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('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
|
|
|
|
@fields.depends('product')
|
|
def on_change_with_product_uom_category(self, name=None):
|
|
return self.product.default_uom_category if self.product else None
|
|
|
|
@fields.depends('description')
|
|
def on_change_with_summary(self, name=None):
|
|
return firstline(self.description or '')
|
|
|
|
@classmethod
|
|
def search_summary(cls, name, clause):
|
|
return [('description', *clause[1:])]
|
|
|
|
@fields.depends('opportunity', '_parent_opportunity.company')
|
|
def on_change_with_company(self, name=None):
|
|
return self.opportunity.company if self.opportunity else None
|
|
|
|
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=self.description,
|
|
)
|
|
sale_line.on_change_product()
|
|
self._set_sale_line_quantity(sale_line)
|
|
sale_line.on_change_quantity()
|
|
return sale_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()
|
|
if self.product:
|
|
return (lang.format_number_symbol(
|
|
self.quantity or 0, self.unit, digits=self.unit.digits)
|
|
+ ' %s @ %s' % (
|
|
self.product.rec_name, self.opportunity.rec_name))
|
|
else:
|
|
return self.opportunity.rec_name
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
return [('product.rec_name',) + tuple(clause[1:])]
|