diff --git a/mis_builder/models/mis_builder.py b/mis_builder/models/mis_builder.py index d5b0ff3a..5a179be9 100644 --- a/mis_builder/models/mis_builder.py +++ b/mis_builder/models/mis_builder.py @@ -2,7 +2,7 @@ # © 2014-2016 ACSONE SA/NV () # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from collections import OrderedDict +from collections import defaultdict, OrderedDict import datetime import dateutil from itertools import izip @@ -12,13 +12,13 @@ import time import pytz -from openerp import api, exceptions, fields, models, _ +from openerp import api, fields, models, _ +from openerp.exceptions import UserError from openerp.tools.safe_eval import safe_eval from .aep import AccountingExpressionProcessor as AEP from .aggregate import _sum, _avg, _min, _max from .accounting_none import AccountingNone -from openerp.exceptions import UserError from .simple_array import SimpleArray from .mis_safe_eval import mis_safe_eval, DataError @@ -72,6 +72,7 @@ class KpiMatrixCol(object): self.locals_dict = locals_dict self.colspan = subkpis and len(subkpis) or 1 self._subcols = [] + self.subkpis = subkpis if not subkpis: subcol = KpiMatrixSubCol(self, '', '', 0) self._subcols.append(subcol) @@ -102,6 +103,10 @@ class KpiMatrixSubCol(object): self.comment = comment self.index = index + @property + def subkpi(self): + return self.col.subkpis[self.index] + def iter_cells(self): for cells in self.col.iter_cell_tuples(): yield cells[self.index] @@ -133,26 +138,58 @@ class KpiMatrix(object): lang_model = env['res.lang'] lang_id = lang_model._lang_get(env.user.lang) self.lang = lang_model.browse(lang_id) - # data structures - self._kpi_rows = OrderedDict() # { kpi: KpiMatrixRow } - self._detail_rows = {} # { kpi: {account_id: KpiMatrixRow} } - self._cols = OrderedDict() # { period_key: KpiMatrixCol } self._account_model = env['account.account'] - self._account_names = {} # { account_id: account_name } + # data structures + # { kpi: KpiMatrixRow } + self._kpi_rows = OrderedDict() + # { kpi: {account_id: KpiMatrixRow} } + self._detail_rows = {} + # { period_key: KpiMatrixCol } + self._cols = OrderedDict() + # { period_key (left of comparison): [(period_key, base_period_key)] } + self._comparison_todo = defaultdict(list) + self._comparison_cols = defaultdict(list) + # { account_id: account_name } + self._account_names = {} def declare_kpi(self, kpi): + """ Declare a new kpi (row) in the matrix. + + Invoke this first for all kpi, in display order. + """ self._kpi_rows[kpi] = KpiMatrixRow(self, kpi) self._detail_rows[kpi] = {} def declare_period(self, period_key, description, comment, locals_dict, subkpis): + """ Declare a new period (column), giving it an identifier (key). + + Invoke this and declare_comparison in display order. + """ self._cols[period_key] = KpiMatrixCol(description, comment, locals_dict, subkpis) + def declare_comparison(self, period_key, base_period_key): + """ Declare a new comparison column. + + Invoke this and declare_period in display order. + """ + last_period_key = list(self._cols.keys())[-1] + self._comparison_todo[last_period_key].append( + (period_key, base_period_key)) + def set_values(self, kpi, period_key, vals): + """ Set values for a kpi and a period. + + Invoke this after declaring the kpi and the period. + """ self.set_values_detail_account(kpi, period_key, None, vals) def set_values_detail_account(self, kpi, period_key, account_id, vals): + """ Set values for a kpi and a period and a detail account. + + Invoke this after declaring the kpi and the period. + """ if not account_id: row = self._kpi_rows[kpi] else: @@ -178,7 +215,57 @@ class KpiMatrix(object): cell_tuple.append(cell) col._set_cell_tuple(row, cell_tuple) + def compute_comparisons(self): + """ Compute comparisons. + + Invoke this after setting all values. + """ + for pos_period_key, comparisons in self._comparison_todo.items(): + for period_key, base_period_key in comparisons: + col = self._cols[period_key] + base_col = self._cols[base_period_key] + common_subkpis = set(col.subkpis) & set(base_col.subkpis) + if not common_subkpis: + raise UserError('Columns {} and {} are not comparable'. + format(col.description, + base_col.description)) + description = u'{} vs {}'.\ + format(col.description, base_col.description) + comparison_col = KpiMatrixCol(description, None, + {}, col.subkpis) + for row in self.iter_rows(): + cell_tuple = col.get_cell_tuple_for_row(row) + base_cell_tuple = base_col.get_cell_tuple_for_row(row) + if cell_tuple is None and base_cell_tuple is None: + continue + if cell_tuple is None: + vals = [AccountingNone] * len(common_subkpis) + else: + vals = [cell.val for cell in cell_tuple + if cell.subcol.subkpi in common_subkpis] + if base_cell_tuple is None: + base_vals = [AccountingNone] * len(common_subkpis) + else: + base_vals = [cell.val for cell in base_cell_tuple + if cell.subcol.subkpi in common_subkpis] + comparison_cell_tuple = [] + for val, base_val, comparison_subcol in \ + izip(vals, + base_vals, + comparison_col.iter_subcols()): + # TODO FIXME average factors + delta, delta_r = row.kpi.compare_and_render( + self.lang, val, base_val, 1, 1) + comparison_cell_tuple.append(KpiMatrixCell( + row, comparison_subcol, delta, delta_r, None)) + comparison_col._set_cell_tuple(row, comparison_cell_tuple) + self._comparison_cols[pos_period_key].append(comparison_col) + def iter_rows(self): + """ Iterate rows in display order. + + yields KpiMatrixRow. + """ for kpi_row in self._kpi_rows.values(): yield kpi_row detail_rows = self._detail_rows[kpi_row.kpi].values() @@ -187,9 +274,21 @@ class KpiMatrix(object): yield detail_row def iter_cols(self): - return self._cols.values() + """ Iterate columns in display order. + + yields KpiMatrixCol: one for each period or comparison. + """ + for period_key, col in self._cols.items(): + yield col + for comparison_col in self._comparison_cols[period_key]: + yield comparison_col def iter_subcols(self): + """ Iterate sub columns in display order. + + yields KpiMatrixSubCol: one for each subkpi in each period + and comparison. + """ for col in self.iter_cols(): for subcol in col.iter_subcols(): yield subcol @@ -297,8 +396,7 @@ class MisReportKpi(models.Model): @api.constrains('name') def _check_name(self): if not _is_valid_python_var(self.name): - raise exceptions.Warning(_('The name must be a valid ' - 'python identifier')) + raise UserError(_('The name must be a valid python identifier')) @api.onchange('name') def _onchange_name(self): @@ -393,10 +491,12 @@ class MisReportKpi(models.Model): else: return unicode(value) - def render_comparison(self, lang, value, base_value, - average_value, average_base_value): + def compare_and_render(self, lang, value, base_value, + average_value, average_base_value): """ render the comparison of two KPI values, ready for display + Returns a tuple, with the numeric comparison and its string rendering. + If the difference is 0, an empty string is returned. """ assert len(self) == 1 @@ -407,7 +507,7 @@ class MisReportKpi(models.Model): if self.type == 'pct': delta = value - base_value if delta and round(delta, self.dp) != 0: - return self._render_num( + return delta, self._render_num( lang, delta, 0.01, self.dp, '', _('pp'), @@ -420,7 +520,7 @@ class MisReportKpi(models.Model): if self.compare_method == 'diff': delta = value - base_value if delta and round(delta, self.dp) != 0: - return self._render_num( + return delta, self._render_num( lang, delta, self.divider, self.dp, self.prefix, self.suffix, @@ -429,12 +529,12 @@ class MisReportKpi(models.Model): if base_value and round(base_value, self.dp) != 0: delta = (value - base_value) / abs(base_value) if delta and round(delta, self.dp) != 0: - return self._render_num( + return delta, self._render_num( lang, delta, 0.01, self.dp, '', '%', sign='+') - return '' + return 0, '' def _render_num(self, lang, value, divider, dp, prefix, suffix, sign='-'): @@ -471,8 +571,7 @@ class MisReportSubkpi(models.Model): @api.constrains('name') def _check_name(self): if not _is_valid_python_var(self.name): - raise exceptions.Warning(_('The name must be a valid ' - 'python identifier')) + raise UserError(_('The name must be a valid python identifier')) @api.onchange('name') def _onchange_name(self): @@ -564,8 +663,7 @@ class MisReportQuery(models.Model): @api.constrains('name') def _check_name(self): if not _is_valid_python_var(self.name): - raise exceptions.Warning(_('The name must be a valid ' - 'python identifier')) + raise UserError(_('The name must be a valid python identifier')) class MisReport(models.Model): @@ -723,15 +821,17 @@ class MisReport(models.Model): return res @api.multi - def _compute_period(self, kpi_matrix, - period_key, period_description, period_comment, - aep, - date_from, date_to, - target_move, - company, - subkpis_filter=None, - get_additional_move_line_filter=None, - get_additional_query_filter=None): + def _declare_and_compute_period(self, kpi_matrix, + period_key, + period_description, + period_comment, + aep, + date_from, date_to, + target_move, + company, + subkpis_filter=None, + get_additional_move_line_filter=None, + get_additional_query_filter=None): """ Evaluate a report for a given period, populating a KpiMatrix. :param kpi_matrix: the KpiMatrix object to be populated @@ -1220,7 +1320,7 @@ class MisReportInstance(models.Model): date_from = self._format_date(period.date_from) date_to = self._format_date(period.date_to) comment = _('from %s to %s') % (date_from, date_to) - self.report_id._compute_period( + self.report_id._declare_and_compute_period( kpi_matrix, period.id, period.name, @@ -1233,7 +1333,9 @@ class MisReportInstance(models.Model): period.subkpi_ids, period._get_additional_move_line_filter, period._get_additional_query_filter) - # TODO FIXME comparison columns + for comparison_column in period.comparison_column_ids: + kpi_matrix.declare_comparison(period.id, comparison_column.id) + kpi_matrix.compute_comparisons() header = [{'cols': []}, {'cols': []}] for col in kpi_matrix.iter_cols():