Files
tradon/modules/sale_opportunity/opportunity.py
2026-03-14 09:42:12 +00:00

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:])]