diff --git a/mis_builder/models/accounting_none.py b/mis_builder/models/accounting_none.py new file mode 100644 index 00000000..5249e540 --- /dev/null +++ b/mis_builder/models/accounting_none.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# © 2016 Thomas Binsfeld +# © 2016 ACSONE SA/NV () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +""" +Provides the AccountingNone singleton. + +AccountingNone is a null value that dissolves in basic arithmetic operations, +as illustrated in the examples below. In comparisons, AccountingNone behaves +the same as zero. + +>>> 1 + 1 +2 +>>> 1 + AccountingNone +1 +>>> AccountingNone + 1 +1 +>>> AccountingNone + None +AccountingNone +>>> +AccountingNone +AccountingNone +>>> -AccountingNone +AccountingNone +>>> -(AccountingNone) +AccountingNone +>>> AccountingNone - 1 +-1 +>>> 1 - AccountingNone +1 +>>> abs(AccountingNone) +AccountingNone +>>> AccountingNone - None +AccountingNone +>>> AccountingNone / 2 +0.0 +>>> 2 / AccountingNone +Traceback (most recent call last): + ... +ZeroDivisionError +>>> AccountingNone / AccountingNone +AccountingNone +>>> AccountingNone // 2 +0.0 +>>> 2 // AccountingNone +Traceback (most recent call last): + ... +ZeroDivisionError +>>> AccountingNone // AccountingNone +AccountingNone +>>> AccountingNone * 2 +0.0 +>>> 2 * AccountingNone +0.0 +>>> AccountingNone * AccountingNone +AccountingNone +>>> AccountingNone * None +AccountingNone +>>> None * AccountingNone +AccountingNone +>>> str(AccountingNone) +'' +>>> bool(AccountingNone) +False +>>> AccountingNone > 0 +False +>>> AccountingNone < 0 +False +>>> AccountingNone < 1 +True +>>> AccountingNone > 1 +False +>>> 0 < AccountingNone +False +>>> 0 > AccountingNone +False +>>> 1 < AccountingNone +False +>>> 1 > AccountingNone +True +>>> AccountingNone == 0 +True +>>> AccountingNone == 0.0 +True +>>> AccountingNone == None +True +""" + +__all__ = ['AccountingNone'] + + +class AccountingNoneType(object): + + def __add__(self, other): + if other is None: + return AccountingNone + return other + + __radd__ = __add__ + + def __sub__(self, other): + if other is None: + return AccountingNone + return -other + + def __rsub__(self, other): + if other is None: + return AccountingNone + return other + + def __iadd__(self, other): + if other is None: + return AccountingNone + return other + + def __isub__(self, other): + if other is None: + return AccountingNone + return -other + + def __abs__(self): + return self + + def __pos__(self): + return self + + def __neg__(self): + return self + + def __div__(self, other): + if other is AccountingNone: + return AccountingNone + return 0.0 + + def __rdiv__(self, other): + raise ZeroDivisionError + + def __floordiv__(self, other): + if other is AccountingNone: + return AccountingNone + return 0.0 + + def __rfloordiv__(self, other): + raise ZeroDivisionError + + def __truediv__(self, other): + if other is AccountingNone: + return AccountingNone + return 0.0 + + def __rtruediv__(self, other): + raise ZeroDivisionError + + def __mul__(self, other): + if other is None or other is AccountingNone: + return AccountingNone + return 0.0 + + __rmul__ = __mul__ + + def __repr__(self): + return 'AccountingNone' + + def __str__(self): + return '' + + def __nonzero__(self): + return False + + def __bool__(self): + return False + + def __eq__(self, other): + return other == 0 or other is None or other is AccountingNone + + def __lt__(self, other): + return 0 < other + + def __gt__(self, other): + return 0 > other + + +AccountingNone = AccountingNoneType() + + +if __name__ == '__main__': + import doctest + doctest.testmod() diff --git a/mis_builder/models/aep.py b/mis_builder/models/aep.py index ffa344ae..8562d863 100644 --- a/mis_builder/models/aep.py +++ b/mis_builder/models/aep.py @@ -9,6 +9,7 @@ from openerp.exceptions import Warning as UserError from openerp.models import expression from openerp.tools.safe_eval import safe_eval from openerp.tools.translate import _ +from .accounting_none import AccountingNone MODE_VARIATION = 'p' MODE_INITIAL = 'i' @@ -348,12 +349,13 @@ class AccountingExpressionProcessor(object): field, mode, account_codes, domain = self._parse_match_object(mo) key = (domain, mode) account_ids_data = self._data[key] - v = 0.0 + v = AccountingNone for account_code in account_codes: account_ids = self._account_ids_by_code[account_code] for account_id in account_ids: debit, credit = \ - account_ids_data.get(account_id, (0.0, 0.0)) + account_ids_data.get(account_id, + (AccountingNone, AccountingNone)) if field == 'bal': v += debit - credit elif field == 'deb': diff --git a/mis_builder/models/mis_builder.py b/mis_builder/models/mis_builder.py index 20271920..df44230e 100644 --- a/mis_builder/models/mis_builder.py +++ b/mis_builder/models/mis_builder.py @@ -16,6 +16,7 @@ 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 _logger = logging.getLogger(__name__) @@ -140,8 +141,8 @@ class MisReportKpi(models.Model): def render(self, lang_id, value): """ render a KPI value as a unicode string, ready for display """ assert len(self) == 1 - if value is None: - return '#N/A' + if value is None or value is AccountingNone: + return '' elif self.type == 'num': return self._render_num(lang_id, value, self.divider, self.dp, self.prefix, self.suffix) @@ -153,31 +154,45 @@ class MisReportKpi(models.Model): def render_comparison(self, lang_id, value, base_value, average_value, average_base_value): - """ render the comparison of two KPI values, ready for display """ + """ render the comparison of two KPI values, ready for display + + If the difference is 0, an empty string is returned. + """ assert len(self) == 1 - if value is None or base_value is None: - return '' + if value is None: + value = AccountingNone + if base_value is None: + base_value = AccountingNone if self.type == 'pct': - return self._render_num( - lang_id, - value - base_value, - 0.01, self.dp, '', _('pp'), sign='+') + delta = value - base_value + if delta and round(delta, self.dp) != 0: + return self._render_num( + lang_id, + delta, + 0.01, self.dp, '', _('pp'), + sign='+') elif self.type == 'num': - if average_value: + if value and average_value: value = value / float(average_value) - if average_base_value: + if base_value and average_base_value: base_value = base_value / float(average_base_value) if self.compare_method == 'diff': - return self._render_num( - lang_id, - value - base_value, - self.divider, self.dp, self.prefix, self.suffix, sign='+') - elif self.compare_method == 'pct': - if round(base_value, self.dp) != 0: + delta = value - base_value + if delta and round(delta, self.dp) != 0: return self._render_num( lang_id, - (value - base_value) / abs(base_value), - 0.01, self.dp, '', '%', sign='+') + delta, + self.divider, self.dp, self.prefix, self.suffix, + sign='+') + elif self.compare_method == 'pct': + 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( + lang_id, + delta, + 0.01, self.dp, '', '%', + sign='+') return '' def _render_num(self, lang_id, value, divider, @@ -497,6 +512,7 @@ class MisReportInstancePeriod(models.Model): 'max': _max, 'len': len, 'avg': _avg, + 'AccountingNone': AccountingNone, } localdict.update(self._fetch_queries()) @@ -544,7 +560,7 @@ class MisReportInstancePeriod(models.Model): AEP.has_account_var(kpi.expression)) res[kpi.name] = { - 'val': kpi_val, + 'val': None if kpi_val is AccountingNone else kpi_val, 'val_r': kpi_val_rendered, 'val_c': kpi_val_comment, 'style': kpi_style,