From fb385844d491ca7cb1d2c3527d3f1e2976e918b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Krier?= Date: Sun, 11 Sep 2022 01:30:15 +0200 Subject: [PATCH] Refactor reporting issue11681 review417881003 --- CHANGELOG | 1 + __init__.py | 14 +- message.xml | 45 ++ opportunity.py | 210 +------- opportunity.xml | 161 ------ opportunity_reporting.py | 465 ++++++++++++++++++ opportunity_reporting.xml | 462 +++++++++++++++++ setup.py | 3 +- tests/scenario_sale_opportunity_reporting.rst | 149 ++++++ tryton.cfg | 1 + view/opportunity_employee_monthly_tree.xml | 26 - view/opportunity_employee_tree.xml | 25 - view/opportunity_monthly_tree.xml | 25 - view/opportunity_reporting_context_form.xml | 15 + ...ting_conversion_employee_graph_amount.xml} | 11 +- ...rting_conversion_employee_graph_number.xml | 8 + ...ity_reporting_conversion_employee_list.xml | 8 + ...ity_reporting_conversion_graph_amount.xml} | 4 +- ...ity_reporting_conversion_graph_number.xml} | 5 +- .../opportunity_reporting_conversion_list.xml | 13 + ...ng_conversion_time_series_graph_amount.xml | 8 + ...ng_conversion_time_series_graph_number.xml | 8 + ..._reporting_conversion_time_series_list.xml | 9 + ...portunity_reporting_main_graph_amount.xml} | 3 +- ...portunity_reporting_main_graph_number.xml} | 4 +- view/opportunity_reporting_main_list.xml | 16 + ...eporting_main_time_series_graph_amount.xml | 8 + ...eporting_main_time_series_graph_number.xml | 8 + ...tunity_reporting_main_time_series_list.xml | 10 + 29 files changed, 1256 insertions(+), 469 deletions(-) create mode 100644 opportunity_reporting.py create mode 100644 opportunity_reporting.xml create mode 100644 tests/scenario_sale_opportunity_reporting.rst delete mode 100644 view/opportunity_employee_monthly_tree.xml delete mode 100644 view/opportunity_employee_tree.xml delete mode 100644 view/opportunity_monthly_tree.xml create mode 100644 view/opportunity_reporting_context_form.xml rename view/{opportunity_employee_context_form.xml => opportunity_reporting_conversion_employee_graph_amount.xml} (55%) create mode 100644 view/opportunity_reporting_conversion_employee_graph_number.xml create mode 100644 view/opportunity_reporting_conversion_employee_list.xml rename view/{opportunity_monthly_graph2.xml => opportunity_reporting_conversion_graph_amount.xml} (70%) rename view/{opportunity_monthly_graph1.xml => opportunity_reporting_conversion_graph_number.xml} (73%) create mode 100644 view/opportunity_reporting_conversion_list.xml create mode 100644 view/opportunity_reporting_conversion_time_series_graph_amount.xml create mode 100644 view/opportunity_reporting_conversion_time_series_graph_number.xml create mode 100644 view/opportunity_reporting_conversion_time_series_list.xml rename view/{opportunity_employee_graph2.xml => opportunity_reporting_main_graph_amount.xml} (81%) rename view/{opportunity_employee_graph1.xml => opportunity_reporting_main_graph_number.xml} (76%) create mode 100644 view/opportunity_reporting_main_list.xml create mode 100644 view/opportunity_reporting_main_time_series_graph_amount.xml create mode 100644 view/opportunity_reporting_main_time_series_graph_number.xml create mode 100644 view/opportunity_reporting_main_time_series_list.xml 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 @@ + + +
+