diff --git a/CHANGELOG b/CHANGELOG
index 190dab63ec..d054058197 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,4 @@
+* Refactor reporting
* Use default customer payment term
* Allow to store currency on opportunity
diff --git a/__init__.py b/__init__.py
index 61e92b35d5..d202bb38a5 100644
--- a/__init__.py
+++ b/__init__.py
@@ -4,17 +4,21 @@
from trytond.pool import Pool
from . import (
- account, company, configuration, opportunity, party, product, sale)
+ account, company, configuration, opportunity, opportunity_reporting, party,
+ product, sale)
def register():
Pool.register(
opportunity.SaleOpportunity,
opportunity.SaleOpportunityLine,
- opportunity.SaleOpportunityEmployee,
- opportunity.SaleOpportunityEmployeeContext,
- opportunity.SaleOpportunityMonthly,
- opportunity.SaleOpportunityEmployeeMonthly,
+ opportunity_reporting.Context,
+ opportunity_reporting.Main,
+ opportunity_reporting.MainTimeseries,
+ opportunity_reporting.Conversion,
+ opportunity_reporting.ConversionTimeseries,
+ opportunity_reporting.ConversionEmployee,
+ opportunity_reporting.ConversionEmployeeTimeseries,
configuration.Configuration,
configuration.ConfigurationSequence,
sale.Sale,
diff --git a/message.xml b/message.xml
index 6f493980b5..86993604f5 100644
--- a/message.xml
+++ b/message.xml
@@ -9,5 +9,50 @@ this repository contains the full copyright notices and license terms. -->
You cannot modify the opportunity origin of the sale "%(sale)s".
+
+ Number of opportunities
+
+
+ Number Trend
+
+
+ Amount
+
+
+ Amount Trend
+
+
+ Converted
+
+
+ Conversion Rate
+
+
+ Conversion Trend
+
+
+ Converted Amount
+
+
+ Converted Amount Trend
+
+
+ Won
+
+
+ Winning Rate
+
+
+ Winning Trend
+
+
+ Won Amount
+
+
+ Won Amount Trend
+
+
+ Lost
+
diff --git a/opportunity.py b/opportunity.py
index aadcc7e4f4..18d557b8fb 100644
--- a/opportunity.py
+++ b/opportunity.py
@@ -4,10 +4,7 @@
import datetime
from itertools import groupby
-from sql import Literal, Null
-from sql.aggregate import Count, Max, Sum
-from sql.conditionals import Case, Coalesce
-from sql.functions import DateTrunc, Extract
+from sql import Null
from trytond.i18n import gettext
from trytond.ir.attachment import AttachmentCopyMixin
@@ -598,208 +595,3 @@ class SaleOpportunityLine(sequence_ordered(), ModelSQL, ModelView):
@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')
- amount = Monetary("Amount", currency='currency', digits='currency')
- converted_amount = Monetary(
- "Converted Amount", currency='currency', digits='currency')
- conversion_amount_rate = fields.Function(fields.Float(
- 'Conversion Amount Rate', digits=(1, 4)), 'get_conversion_amount_rate')
- won_amount = Monetary("Won Amount", currency='currency', digits='currency')
- 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_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('sale.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__ = 'sale.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__ = 'sale.opportunity_employee.context'
- start_date = fields.Date('Start Date')
- end_date = fields.Date('End Date')
-
-
-class MonthLabelMixin:
- __slots__ = ()
-
- month_label = fields.Function(fields.Char("Month"), 'get_month_label')
-
- @classmethod
- def order_month_label(cls, tables):
- table, _ = tables[None]
- return [table.month]
-
- def get_month_label(self, name):
- return self.month.strftime('%Y-%m')
-
-
-class SaleOpportunityMonthly(
- MonthLabelMixin, SaleOpportunityReportMixin, ModelSQL, ModelView):
- 'Sale Opportunity per Month'
- __name__ = 'sale.opportunity_monthly'
- month = fields.Date("Month")
-
- @classmethod
- def __setup__(cls):
- super(SaleOpportunityMonthly, cls).__setup__()
- cls._order.insert(0, ('month', 'DESC'))
-
- @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_
- month_timestamp = DateTrunc('MONTH', opportunity.start_date)
- id_ = Extract('EPOCH', month_timestamp)
- month = cls.month.sql_cast(month_timestamp)
- query.columns += (
- id_.as_('id'),
- month.as_('month'),
- )
- query.group_by = [id_, month, opportunity.company]
- return query
-
-
-class SaleOpportunityEmployeeMonthly(
- MonthLabelMixin, SaleOpportunityReportMixin, ModelSQL, ModelView):
- 'Sale Opportunity per Employee per Month'
- __name__ = 'sale.opportunity_employee_monthly'
- month = fields.Date("Month")
- employee = fields.Many2One('company.employee', 'Employee')
-
- @classmethod
- def __setup__(cls):
- super(SaleOpportunityEmployeeMonthly, cls).__setup__()
- cls._order.insert(1, ('month', '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_
- month_timestamp = DateTrunc('MONTH', opportunity.start_date)
- id_ = Extract('EPOCH', month_timestamp)
- month = cls.month.sql_cast(month_timestamp)
- query.columns += (
- id_.as_('id'),
- month.as_('month'),
- opportunity.employee,
- )
- query.group_by = [
- id_, month, opportunity.employee, opportunity.company]
- return query
diff --git a/opportunity.xml b/opportunity.xml
index 2869faef42..a0802f1d52 100644
--- a/opportunity.xml
+++ b/opportunity.xml
@@ -241,166 +241,5 @@ this repository contains the full copyright notices and license terms. -->
tree
opportunity_line_tree
-
-
- sale.opportunity_employee
- tree
- opportunity_employee_tree
-
-
-
- sale.opportunity_employee
- graph
- opportunity_employee_graph1
-
-
-
- sale.opportunity_employee
- graph
- opportunity_employee_graph2
-
-
-
- sale.opportunity_employee.context
- form
- opportunity_employee_context_form
-
-
-
- Opportunities per Employee
- sale.opportunity_employee
- sale.opportunity_employee.context
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- User in companies
-
-
-
-
-
-
-
-
-
-
-
- sale.opportunity_monthly
- tree
- opportunity_monthly_tree
-
-
-
- sale.opportunity_monthly
- graph
- opportunity_monthly_graph1
-
-
-
- sale.opportunity_monthly
- graph
- opportunity_monthly_graph2
-
-
-
- Opportunities per Month
- sale.opportunity_monthly
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- User in companies
-
-
-
-
-
-
-
-
-
- sale.opportunity_employee_monthly
- tree
- opportunity_employee_monthly_tree
-
-
-
- Opportunities per Employee per Month
- sale.opportunity_employee_monthly
-
-
-
-
-
-
-
-
-
-
- User in companies
-
-
-
-
-
-
-
-
diff --git a/opportunity_reporting.py b/opportunity_reporting.py
new file mode 100644
index 0000000000..bdbbb5ce63
--- /dev/null
+++ b/opportunity_reporting.py
@@ -0,0 +1,465 @@
+# 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 itertools import tee, zip_longest
+
+from dateutil.relativedelta import relativedelta
+from sql import Literal, Null, With
+from sql.aggregate import Count, Min, Sum
+from sql.conditionals import Case
+from sql.functions import CurrentTimestamp
+try:
+ import pygal
+except ImportError:
+ pygal = None
+
+from trytond.i18n import lazy_gettext
+from trytond.model import ModelSQL, ModelView, fields
+from trytond.modules.currency.fields import Monetary
+from trytond.pool import Pool
+from trytond.pyson import Eval, If
+from trytond.transaction import Transaction
+
+
+def pairwise(iterable):
+ a, b = tee(iterable)
+ next(b)
+ return zip_longest(a, b)
+
+
+class Abstract(ModelSQL):
+
+ company = fields.Many2One(
+ 'company.company', lazy_gettext('sale.msg_sale_reporting_company'))
+
+ number = fields.Integer(lazy_gettext('sale.msg_sale_reporting_number'),
+ help=lazy_gettext(
+ 'sale_opportunity.msg_sale_opportunity_reporting_number_help'))
+ number_trend = fields.Function(
+ fields.Char(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_number_trend')),
+ 'get_trend')
+
+ amount = Monetary(
+ lazy_gettext('sale_opportunity.msg_sale_opportunity_reporting_amount'),
+ currency='currency', digits='currency')
+ amount_trend = fields.Function(
+ fields.Char(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_amount_trend')),
+ 'get_trend')
+
+ converted = fields.Integer(
+ lazy_gettext(
+ 'sale_opportunity.msg_sale_opportunity_reporting_converted'))
+ conversion_rate = fields.Function(
+ fields.Float(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_conversion_rate'),
+ digits=(1, 4)), 'get_rate')
+ conversion_trend = fields.Function(
+ fields.Char(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_conversion_trend')),
+ 'get_trend')
+ converted_amount = Monetary(
+ lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_converted_amount'),
+ currency='currency', digits='currency')
+ converted_amount_trend = fields.Function(
+ fields.Char(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_converted_amount_trend')),
+ 'get_trend')
+
+ time_series = None
+
+ currency = fields.Function(
+ fields.Many2One(
+ 'currency.currency',
+ lazy_gettext('sale.msg_sale_reporting_currency')),
+ 'get_currency')
+
+ @classmethod
+ def table_query(cls):
+ from_item, tables, withs = cls._joins()
+ return from_item.select(*cls._columns(tables, withs),
+ where=cls._where(tables, withs),
+ group_by=cls._group_by(tables, withs),
+ with_=withs.values())
+
+ @classmethod
+ def _joins(cls):
+ pool = Pool()
+ Company = pool.get('company.company')
+ Currency = pool.get('currency.currency')
+ Opportunity = pool.get('sale.opportunity')
+ context = Transaction().context
+
+ tables = {}
+ company = context.get('company')
+ tables['opportunity'] = opportunity = Opportunity.__table__()
+ tables['opportunity.company'] = company = Company.__table__()
+ withs = {}
+ currency_opportunity = With(query=Currency.currency_rate_sql())
+ withs['currency_opportunity'] = currency_opportunity
+ currency_company = With(query=Currency.currency_rate_sql())
+ withs['currency_company'] = currency_company
+
+ from_item = (opportunity
+ .join(currency_opportunity,
+ condition=(
+ opportunity.currency == currency_opportunity.currency)
+ & (currency_opportunity.start_date <= opportunity.start_date)
+ & ((currency_opportunity.end_date == Null)
+ | (currency_opportunity.end_date > opportunity.start_date))
+ )
+ .join(company, condition=opportunity.company == company.id)
+ .join(currency_company,
+ condition=(company.currency == currency_company.currency)
+ & (currency_company.start_date <= opportunity.start_date)
+ & ((currency_company.end_date == Null)
+ | (currency_company.end_date > opportunity.start_date))
+ ))
+ return from_item, tables, withs
+
+ @classmethod
+ def _columns(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return [
+ cls._column_id(tables, withs).as_('id'),
+ Literal(0).as_('create_uid'),
+ CurrentTimestamp().as_('create_date'),
+ cls.write_uid.sql_cast(Literal(Null)).as_('write_uid'),
+ cls.write_date.sql_cast(Literal(Null)).as_('write_date'),
+ opportunity.company.as_('company'),
+ Count(Literal(1)).as_('number'),
+ Sum(opportunity.amount).as_('amount'),
+ Sum(Case(
+ (opportunity.state.in_(cls._converted_states()),
+ Literal(1)), else_=Literal(0))).as_('converted'),
+ Sum(Case(
+ (opportunity.state.in_(cls._converted_states()),
+ opportunity.amount),
+ else_=Literal(0))).as_('converted_amount'),
+ ]
+
+ @classmethod
+ def _column_id(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return Min(opportunity.id)
+
+ @classmethod
+ def _group_by(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return [opportunity.company]
+
+ @classmethod
+ def _where(cls, tables, withs):
+ context = Transaction().context
+ opportunity = tables['opportunity']
+
+ where = opportunity.company == context.get('company')
+
+ date = cls._column_date(tables, withs)
+ from_date = context.get('from_date')
+ if from_date:
+ where &= date >= from_date
+ to_date = context.get('to_date')
+ if to_date:
+ where &= date <= to_date
+ return where
+
+ @classmethod
+ def _column_date(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return opportunity.start_date
+
+ @classmethod
+ def _converted_states(cls):
+ return ['converted', 'won']
+
+ @classmethod
+ def _field_name_strip(cls, name, suffix):
+ name = name[:-len(suffix)]
+ return (name
+ .replace('conversion', 'converted')
+ .replace('winning', 'won'))
+
+ def get_rate(self, name):
+ if self.number:
+ digits = getattr(self.__class__, name).digits[1]
+ name = self._field_name_strip(name, '_rate')
+ value = float(getattr(self, name))
+ return round(value / self.number, digits)
+ else:
+ return 0.0
+
+ @property
+ def time_series_all(self):
+ delta = self._period_delta()
+ for ts, next_ts in pairwise(self.time_series or []):
+ yield ts
+ if delta and next_ts:
+ date = ts.date + delta
+ while date < next_ts.date:
+ yield None
+ date += delta
+
+ @classmethod
+ def _period_delta(cls):
+ context = Transaction().context
+ return {
+ 'year': relativedelta(years=1),
+ 'month': relativedelta(months=1),
+ 'day': relativedelta(days=1),
+ }.get(context.get('period'))
+
+ def get_trend(self, name):
+ name = self._field_name_strip(name, '_trend')
+ if pygal:
+ chart = pygal.Line()
+ chart.add('', [getattr(ts, name) if ts else 0
+ for ts in self.time_series_all])
+ return chart.render_sparktext()
+
+ def get_currency(self, name):
+ return self.company.currency.id
+
+
+class AbstractTimeseries(Abstract):
+
+ date = fields.Date(lazy_gettext('sale.msg_sale_reporting_date'))
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._order = [('date', 'ASC')]
+
+ @classmethod
+ def _columns(cls, tables, withs):
+ return super()._columns(tables, withs) + [
+ cls._column_date(tables, withs).as_('date')]
+
+ @classmethod
+ def _group_by(cls, tables, withs):
+ return super()._group_by(tables, withs) + [
+ cls._column_date(tables, withs)]
+
+
+class AbstractConversion(Abstract):
+
+ won = fields.Integer(
+ lazy_gettext('sale_opportunity.msg_sale_opportunity_reporting_won'))
+ winning_rate = fields.Function(
+ fields.Float(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_winning_rate'),
+ digits=(1, 4)),
+ 'get_rate')
+ winning_trend = fields.Function(
+ fields.Char(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_winning_trend')),
+ 'get_trend')
+ won_amount = Monetary(
+ lazy_gettext(
+ 'sale_opportunity.msg_sale_opportunity_reporting_won_amount'),
+ currency='currency', digits='currency')
+ won_amount_trend = fields.Function(
+ fields.Char(lazy_gettext(
+ 'sale_opportunity.'
+ 'msg_sale_opportunity_reporting_won_amount_trend')),
+ 'get_trend')
+
+ lost = fields.Integer(
+ lazy_gettext('sale_opportunity.msg_sale_opportunity_reporting_lost'))
+
+ @classmethod
+ def _columns(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return super()._columns(tables, withs) + [
+ Sum(Case(
+ (opportunity.state.in_(cls._won_states()),
+ Literal(1)), else_=Literal(0))).as_('won'),
+ Sum(Case(
+ (opportunity.state.in_(cls._won_states()),
+ opportunity.amount),
+ else_=Literal(0))).as_('won_amount'),
+ Sum(Case(
+ (opportunity.state.in_(cls._lost_states()),
+ Literal(1)), else_=Literal(0))).as_('lost'),
+ ]
+
+ @classmethod
+ def _column_date(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return opportunity.end_date
+
+ @classmethod
+ def _won_states(cls):
+ return ['won']
+
+ @classmethod
+ def _lost_states(cls):
+ return ['lost']
+
+ @classmethod
+ def _opportunity_states(cls):
+ return cls._converted_states() + cls._won_states() + cls._lost_states()
+
+
+class AbstractConversionTimeseries(AbstractConversion, AbstractTimeseries):
+ pass
+
+
+class Context(ModelView):
+ "Sale Opportunity Reporting Context"
+ __name__ = 'sale.opportunity.reporting.context'
+
+ company = fields.Many2One('company.company', "Company", required=True)
+ from_date = fields.Date("From Date",
+ domain=[
+ If(Eval('to_date') & Eval('from_date'),
+ ('from_date', '<=', Eval('to_date')),
+ ()),
+ ])
+ to_date = fields.Date("To Date",
+ domain=[
+ If(Eval('from_date') & Eval('to_date'),
+ ('to_date', '>=', Eval('from_date')),
+ ()),
+ ])
+ period = fields.Selection([
+ ('year', "Year"),
+ ('month', "Month"),
+ ('day', "Day"),
+ ], "Period", required=True)
+
+ @classmethod
+ def default_company(cls):
+ return Transaction().context.get('company')
+
+ @classmethod
+ def default_from_date(cls):
+ pool = Pool()
+ Date = pool.get('ir.date')
+ context = Transaction().context
+ if 'from_date' in context:
+ return context['from_date']
+ return Date.today() - relativedelta(years=1)
+
+ @classmethod
+ def default_to_date(cls):
+ pool = Pool()
+ Date = pool.get('ir.date')
+ context = Transaction().context
+ if 'to_date' in context:
+ return context['to_date']
+ return Date.today()
+
+ @classmethod
+ def default_period(cls):
+ return Transaction().context.get('period', 'month')
+
+
+class Main(Abstract, ModelView):
+ "Sale Opportunity Reporting"
+ __name__ = 'sale.opportunity.reporting.main'
+
+ time_series = fields.Function(fields.One2Many(
+ 'sale.opportunity.reporting.main.time_series', None,
+ lazy_gettext('sale.msg_sale_reporting_time_series')),
+ 'get_time_series')
+
+ def get_rec_name(self, name):
+ return ''
+
+ def get_time_series(self, name):
+ pool = Pool()
+ Timeseries = pool.get('sale.opportunity.reporting.main.time_series')
+ return [t.id for t in Timeseries.search([])]
+
+
+class MainTimeseries(AbstractTimeseries, ModelView):
+ "Sale Opportunity Reporting"
+ __name__ = 'sale.opportunity.reporting.main.time_series'
+
+
+class Conversion(AbstractConversion, ModelView):
+ "Sale Opportunity Reporting Conversion"
+ __name__ = 'sale.opportunity.reporting.conversion'
+
+ time_series = fields.Function(fields.One2Many(
+ 'sale.opportunity.reporting.conversion.time_series', None,
+ lazy_gettext('sale.msg_sale_reporting_time_series')),
+ 'get_time_series')
+
+ def get_rec_name(self, name):
+ return ''
+
+ def get_time_series(self, name):
+ pool = Pool()
+ Timeseries = pool.get(
+ 'sale.opportunity.reporting.conversion.time_series')
+ return [t.id for t in Timeseries.search([])]
+
+
+class ConversionTimeseries(AbstractConversionTimeseries, ModelView):
+ "Sale Opportunity Reporting Conversion"
+ __name__ = 'sale.opportunity.reporting.conversion.time_series'
+
+
+class EmployeeMixin:
+ __slots__ = ()
+
+ employee = fields.Many2One('company.employee', "Employee")
+
+ @classmethod
+ def _columns(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return super()._columns(tables, withs) + [
+ opportunity.employee.as_('employee')]
+
+ @classmethod
+ def _group_by(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return super()._group_by(tables, withs) + [
+ opportunity.employee]
+
+ def get_rec_name(self, name):
+ return self.employee.rec_name
+
+
+class ConversionEmployee(EmployeeMixin, AbstractConversion, ModelView):
+ "Sale Opportunity Reporting Conversion per Employee"
+ __name__ = 'sale.opportunity.reporting.conversion.employee'
+
+ time_series = fields.One2Many(
+ 'sale.opportunity.reporting.conversion.employee.time_series',
+ 'employee', lazy_gettext('sale.msg_sale_reporting_time_series'))
+
+ @classmethod
+ def __setup__(cls):
+ super().__setup__()
+ cls._order.insert(0, ('employee', 'ASC'))
+
+ @classmethod
+ def _column_id(cls, tables, withs):
+ opportunity = tables['opportunity']
+ return opportunity.employee
+
+ @classmethod
+ def _where(cls, tables, withs):
+ opportunity = tables['opportunity']
+ where = super()._where(tables, withs)
+ where &= opportunity.employee != Null
+ return where
+
+
+class ConversionEmployeeTimeseries(
+ EmployeeMixin, AbstractConversionTimeseries, ModelView):
+ "Sale Opportunity Reporting Conversion per Employee"
+ __name__ = 'sale.opportunity.reporting.conversion.employee.time_series'
diff --git a/opportunity_reporting.xml b/opportunity_reporting.xml
new file mode 100644
index 0000000000..c480427d7e
--- /dev/null
+++ b/opportunity_reporting.xml
@@ -0,0 +1,462 @@
+
+
+
+
+
+
+
+ sale.opportunity.reporting.context
+ form
+ opportunity_reporting_context_form
+
+
+
+
+
+ sale.opportunity.reporting.main
+ tree
+ opportunity_reporting_main_list
+
+
+
+ sale.opportunity.reporting.main
+ graph
+ opportunity_reporting_main_graph_number
+
+
+
+ sale.opportunity.reporting.main
+ graph
+ opportunity_reporting_main_graph_amount
+
+
+
+ Opportunities
+ sale.opportunity.reporting.main
+ sale.opportunity.reporting.context
+
+
+
+
+
+
+
+ tree_open
+
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.opportunity.reporting.main.time_series
+ tree
+ opportunity_reporting_main_time_series_list
+
+
+
+ sale.opportunity.reporting.main.time_series
+
+
+ opportunity_reporting_main_time_series_graph_number
+
+
+
+ sale.opportunity.reporting.main.time_series
+
+
+ opportunity_reporting_main_time_series_graph_amount
+
+
+
+ Opportunities
+ sale.opportunity.reporting.main.time_series
+ sale.opportunity.reporting.context
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tree_open
+ sale.opportunity.reporting.main,-1
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.opportunity.reporting.conversion
+ tree
+ opportunity_reporting_conversion_list
+
+
+
+ sale.opportunity.reporting.conversion
+ graph
+ opportunity_reporting_conversion_graph_number
+
+
+
+ sale.opportunity.reporting.conversion
+ graph
+ opportunity_reporting_conversion_graph_amount
+
+
+
+ Opportunity Conversions
+ sale.opportunity.reporting.conversion
+ sale.opportunity.reporting.context
+
+
+
+
+
+
+
+ tree_open
+
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.opportunity.reporting.conversion.time_series
+ tree
+ opportunity_reporting_conversion_time_series_list
+
+
+
+ sale.opportunity.reporting.conversion.time_series
+
+
+ opportunity_reporting_conversion_time_series_graph_number
+
+
+
+ sale.opportunity.reporting.conversion.time_series
+
+
+ opportunity_reporting_conversion_time_series_graph_amount
+
+
+
+ Opportunity Conversions
+ sale.opportunity.reporting.conversion.time_series
+ sale.opportunity.reporting.context
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tree_open
+ sale.opportunity.reporting.conversion,-1
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.opportunity.reporting.conversion.employee
+
+
+ opportunity_reporting_conversion_employee_list
+
+
+
+ sale.opportunity.reporting.conversion.employee
+
+
+ opportunity_reporting_conversion_employee_graph_number
+
+
+
+ sale.opportunity.reporting.conversion.employee
+
+
+ opportunity_reporting_conversion_employee_graph_amount
+
+
+
+ Opportunity Conversions per Employee
+ sale.opportunity.reporting.conversion.employee
+ sale.opportunity.reporting.context
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tree_open
+
+
+
+
+ tree_open
+ sale.opportunity.reporting.conversion,-1
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.opportunity.reporting.conversion.employee.time_series
+ tree
+ opportunity_reporting_conversion_time_series_list
+
+
+
+ sale.opportunity.reporting.conversion.employee.time_series
+ graph
+ opportunity_reporting_conversion_time_series_graph_number
+
+
+
+ sale.opportunity.reporting.conversion.employee.time_series
+ graph
+ opportunity_reporting_conversion_time_series_graph_amount
+
+
+
+ Opportunity Conversions per Employee
+ sale.opportunity.reporting.conversion.employee.time_series
+ sale.opportunity.reporting.context
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tree_open
+ sale.opportunity.reporting.conversion.employee,-1
+
+
+
+
+ User in companies
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/setup.py b/setup.py
index b0fd260ee7..9575571e6a 100755
--- a/setup.py
+++ b/setup.py
@@ -58,7 +58,7 @@ else:
if local_version:
version += '+' + '.'.join(local_version)
-requires = ['python-sql >= 0.4']
+requires = ['python-sql >= 0.4', 'python-dateutil']
for dep in info.get('depends', []):
if not re.match(r'(ir|res)(\W|$)', dep):
requires.append(get_require_version('trytond_%s' % dep))
@@ -138,6 +138,7 @@ setup(name=name,
python_requires='>=3.7',
install_requires=requires,
extras_require={
+ 'sparklines': ['pygal'],
'test': tests_require,
},
dependency_links=dependency_links,
diff --git a/tests/scenario_sale_opportunity_reporting.rst b/tests/scenario_sale_opportunity_reporting.rst
new file mode 100644
index 0000000000..023dcfd536
--- /dev/null
+++ b/tests/scenario_sale_opportunity_reporting.rst
@@ -0,0 +1,149 @@
+===================================
+Sale Opportunity Reporting Scenario
+===================================
+
+Imports::
+
+ >>> import datetime as dt
+ >>> from decimal import Decimal
+
+ >>> from proteus import Model, Wizard
+ >>> from trytond.tests.tools import activate_modules
+ >>> from trytond.modules.company.tests.tools import (
+ ... create_company, get_company)
+ >>> from trytond.modules.account.tests.tools import (
+ ... create_chart, get_accounts)
+
+ >>> today = dt.date.today()
+
+Activate modules::
+
+ >>> config = activate_modules('sale_opportunity')
+
+ >>> Employee = Model.get('company.employee')
+ >>> Opportunity = Model.get('sale.opportunity')
+ >>> Party = Model.get('party.party')
+ >>> ProductCategory = Model.get('product.category')
+
+Create company::
+
+ >>> _ = create_company()
+ >>> company = get_company()
+
+Create employees::
+
+ >>> employee1_party = Party(name="Employee 1")
+ >>> employee1_party.save()
+ >>> employee1 = Employee(party=employee1_party)
+ >>> employee1.save()
+
+ >>> employee2_party = Party(name="Employee 2")
+ >>> employee2_party.save()
+ >>> employee2 = Employee(party=employee2_party)
+ >>> employee2.save()
+
+Create parties::
+
+ >>> customer1 = Party(name="Customer 1")
+ >>> customer1.save()
+ >>> customer2 = Party(name="Customer 2")
+ >>> customer2.save()
+
+Create leads and opportunities::
+
+ >>> lead = Opportunity(party=customer1)
+ >>> lead.amount = Decimal('1000.00')
+ >>> lead.save()
+
+ >>> opportunity = Opportunity(party=customer1)
+ >>> opportunity.amount = Decimal('2000.00')
+ >>> opportunity.employee = employee1
+ >>> opportunity.click('opportunity')
+
+ >>> opportunity = Opportunity(party=customer2)
+ >>> opportunity.amount = Decimal('500.00')
+ >>> opportunity.employee = employee2
+ >>> opportunity.end_date = today
+ >>> opportunity.click('opportunity')
+ >>> opportunity.click('convert')
+
+ >>> opportunity = Opportunity(party=customer1)
+ >>> opportunity.amount = Decimal('700.00')
+ >>> opportunity.employee = employee1
+ >>> opportunity.click('opportunity')
+ >>> opportunity.click('convert')
+ >>> sale, = opportunity.sales
+ >>> line = sale.lines.new()
+ >>> line.quantity = 1
+ >>> line.unit_price = Decimal('800.00')
+ >>> sale.invoice_address, = sale.party.addresses
+ >>> sale.click('quote')
+ >>> sale.click('confirm')
+
+ >>> opportunity = Opportunity(party=customer2)
+ >>> opportunity.amount = Decimal('200.00')
+ >>> opportunity.employee = employee2
+ >>> opportunity.click('opportunity')
+ >>> opportunity.click('lost')
+
+Check opportunity reporting::
+
+ >>> Main = Model.get('sale.opportunity.reporting.main')
+ >>> context = dict(
+ ... from_date=today,
+ ... to_date=today,
+ ... period='month')
+ >>> with config.set_context(context=context):
+ ... reports = Main.find([])
+ >>> report, = reports
+ >>> report.number
+ 5
+ >>> report.amount
+ Decimal('4500.0')
+ >>> report.converted
+ 2
+ >>> report.conversion_rate
+ 0.4
+ >>> report.converted_amount == Decimal('1300.00')
+ True
+
+ >>> report, = report.time_series
+ >>> report.number
+ 5
+ >>> report.amount
+ Decimal('4500.0')
+ >>> report.converted
+ 2
+ >>> report.conversion_rate
+ 0.4
+ >>> report.converted_amount == Decimal('1300.00')
+ True
+
+
+Check conversion reporting::
+
+ >>> Conversion = Model.get('sale.opportunity.reporting.conversion')
+ >>> with config.set_context(context=context):
+ ... reports = Conversion.find([])
+ >>> report, = reports
+ >>> report.number
+ 3
+ >>> report.converted
+ 2
+ >>> report.won
+ 1
+ >>> report.winning_rate
+ 0.3333
+ >>> report.won_amount == Decimal('800.00')
+ True
+ >>> report.lost
+ 1
+ >>> len(report.time_series)
+ 1
+
+ >>> ConversionEmployee = Model.get(
+ ... 'sale.opportunity.reporting.conversion.employee')
+ >>> with config.set_context(context=context):
+ ... reports = ConversionEmployee.find([])
+ >>> len(reports)
+ 2
diff --git a/tryton.cfg b/tryton.cfg
index c5fd27eae5..28224d2bb6 100644
--- a/tryton.cfg
+++ b/tryton.cfg
@@ -13,4 +13,5 @@ xml:
opportunity.xml
party.xml
configuration.xml
+ opportunity_reporting.xml
message.xml
diff --git a/view/opportunity_employee_monthly_tree.xml b/view/opportunity_employee_monthly_tree.xml
deleted file mode 100644
index 24946e0971..0000000000
--- a/view/opportunity_employee_monthly_tree.xml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/view/opportunity_employee_tree.xml b/view/opportunity_employee_tree.xml
deleted file mode 100644
index 224a775020..0000000000
--- a/view/opportunity_employee_tree.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/view/opportunity_monthly_tree.xml b/view/opportunity_monthly_tree.xml
deleted file mode 100644
index f893247445..0000000000
--- a/view/opportunity_monthly_tree.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/view/opportunity_reporting_context_form.xml b/view/opportunity_reporting_context_form.xml
new file mode 100644
index 0000000000..dd66e4962e
--- /dev/null
+++ b/view/opportunity_reporting_context_form.xml
@@ -0,0 +1,15 @@
+
+
+
diff --git a/view/opportunity_employee_context_form.xml b/view/opportunity_reporting_conversion_employee_graph_amount.xml
similarity index 55%
rename from view/opportunity_employee_context_form.xml
rename to view/opportunity_reporting_conversion_employee_graph_amount.xml
index 029f1a04e5..3cf5f8e1cb 100644
--- a/view/opportunity_employee_context_form.xml
+++ b/view/opportunity_reporting_conversion_employee_graph_amount.xml
@@ -1,9 +1,8 @@
-
+
+
+
+
+
diff --git a/view/opportunity_reporting_conversion_employee_graph_number.xml b/view/opportunity_reporting_conversion_employee_graph_number.xml
new file mode 100644
index 0000000000..3cf5f8e1cb
--- /dev/null
+++ b/view/opportunity_reporting_conversion_employee_graph_number.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/view/opportunity_reporting_conversion_employee_list.xml b/view/opportunity_reporting_conversion_employee_list.xml
new file mode 100644
index 0000000000..043a7d0401
--- /dev/null
+++ b/view/opportunity_reporting_conversion_employee_list.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/view/opportunity_monthly_graph2.xml b/view/opportunity_reporting_conversion_graph_amount.xml
similarity index 70%
rename from view/opportunity_monthly_graph2.xml
rename to view/opportunity_reporting_conversion_graph_amount.xml
index b11d56e790..f2d2358f22 100644
--- a/view/opportunity_monthly_graph2.xml
+++ b/view/opportunity_reporting_conversion_graph_amount.xml
@@ -3,11 +3,9 @@
this repository contains the full copyright notices and license terms. -->
-
+
-
-
diff --git a/view/opportunity_monthly_graph1.xml b/view/opportunity_reporting_conversion_graph_number.xml
similarity index 73%
rename from view/opportunity_monthly_graph1.xml
rename to view/opportunity_reporting_conversion_graph_number.xml
index b7bd5c686c..1ecf9843be 100644
--- a/view/opportunity_monthly_graph1.xml
+++ b/view/opportunity_reporting_conversion_graph_number.xml
@@ -3,12 +3,11 @@
this repository contains the full copyright notices and license terms. -->
-
+
-
-
+
diff --git a/view/opportunity_reporting_conversion_list.xml b/view/opportunity_reporting_conversion_list.xml
new file mode 100644
index 0000000000..a601c868e5
--- /dev/null
+++ b/view/opportunity_reporting_conversion_list.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/opportunity_reporting_conversion_time_series_graph_amount.xml b/view/opportunity_reporting_conversion_time_series_graph_amount.xml
new file mode 100644
index 0000000000..5938277832
--- /dev/null
+++ b/view/opportunity_reporting_conversion_time_series_graph_amount.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/view/opportunity_reporting_conversion_time_series_graph_number.xml b/view/opportunity_reporting_conversion_time_series_graph_number.xml
new file mode 100644
index 0000000000..5938277832
--- /dev/null
+++ b/view/opportunity_reporting_conversion_time_series_graph_number.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/view/opportunity_reporting_conversion_time_series_list.xml b/view/opportunity_reporting_conversion_time_series_list.xml
new file mode 100644
index 0000000000..96ce450f82
--- /dev/null
+++ b/view/opportunity_reporting_conversion_time_series_list.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
diff --git a/view/opportunity_employee_graph2.xml b/view/opportunity_reporting_main_graph_amount.xml
similarity index 81%
rename from view/opportunity_employee_graph2.xml
rename to view/opportunity_reporting_main_graph_amount.xml
index 3686d76e8d..60c7e0608f 100644
--- a/view/opportunity_employee_graph2.xml
+++ b/view/opportunity_reporting_main_graph_amount.xml
@@ -3,11 +3,10 @@
this repository contains the full copyright notices and license terms. -->
-
+
-
diff --git a/view/opportunity_employee_graph1.xml b/view/opportunity_reporting_main_graph_number.xml
similarity index 76%
rename from view/opportunity_employee_graph1.xml
rename to view/opportunity_reporting_main_graph_number.xml
index f132cb96b6..a93a8c8d27 100644
--- a/view/opportunity_employee_graph1.xml
+++ b/view/opportunity_reporting_main_graph_number.xml
@@ -3,12 +3,10 @@
this repository contains the full copyright notices and license terms. -->
-
+
-
-
diff --git a/view/opportunity_reporting_main_list.xml b/view/opportunity_reporting_main_list.xml
new file mode 100644
index 0000000000..7fdcf5885f
--- /dev/null
+++ b/view/opportunity_reporting_main_list.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/view/opportunity_reporting_main_time_series_graph_amount.xml b/view/opportunity_reporting_main_time_series_graph_amount.xml
new file mode 100644
index 0000000000..5938277832
--- /dev/null
+++ b/view/opportunity_reporting_main_time_series_graph_amount.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/view/opportunity_reporting_main_time_series_graph_number.xml b/view/opportunity_reporting_main_time_series_graph_number.xml
new file mode 100644
index 0000000000..5938277832
--- /dev/null
+++ b/view/opportunity_reporting_main_time_series_graph_number.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/view/opportunity_reporting_main_time_series_list.xml b/view/opportunity_reporting_main_time_series_list.xml
new file mode 100644
index 0000000000..2b37dc7e25
--- /dev/null
+++ b/view/opportunity_reporting_main_time_series_list.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+