From 5cd709a665626321e1f9c71f3fad6b3020936569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Fri, 18 Jul 2014 16:44:17 +0200 Subject: [PATCH] [ADD] mis_builder: a module to build "Management Information System"-style reports Such reports combine accounting and operational data. This initial commit is a first proof-of-concept prototype. --- mis_builder/__init__.py | 24 ++ mis_builder/__openerp__.py | 53 +++ mis_builder/models/__init__.py | 24 ++ mis_builder/models/mis_builder.py | 466 ++++++++++++++++++++++ mis_builder/security/ir.model.access.csv | 1 + mis_builder/static/src/img/icon.png | Bin 0 -> 3464 bytes mis_builder/static/src/js/mis_builder.js | 26 ++ mis_builder/static/src/xml/mis_widget.xml | 18 + mis_builder/views/mis_builder.xml | 158 ++++++++ 9 files changed, 770 insertions(+) create mode 100644 mis_builder/__init__.py create mode 100644 mis_builder/__openerp__.py create mode 100644 mis_builder/models/__init__.py create mode 100644 mis_builder/models/mis_builder.py create mode 100644 mis_builder/security/ir.model.access.csv create mode 100644 mis_builder/static/src/img/icon.png create mode 100644 mis_builder/static/src/js/mis_builder.js create mode 100644 mis_builder/static/src/xml/mis_widget.xml create mode 100644 mis_builder/views/mis_builder.xml diff --git a/mis_builder/__init__.py b/mis_builder/__init__.py new file mode 100644 index 00000000..db1c497e --- /dev/null +++ b/mis_builder/__init__.py @@ -0,0 +1,24 @@ +#============================================================================== +# = +# mis_builder module for OpenERP, Management Information System Builder +# Copyright (C) 2014 ACSONE SA/NV () +# = +# This file is a part of mis_builder +# = +# mis_builder is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License v3 or later +# as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# = +# mis_builder is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License v3 or later for more details. +# = +# You should have received a copy of the GNU Affero General Public License +# v3 or later along with this program. +# If not, see . +# = +#============================================================================== + +import models diff --git a/mis_builder/__openerp__.py b/mis_builder/__openerp__.py new file mode 100644 index 00000000..097d261c --- /dev/null +++ b/mis_builder/__openerp__.py @@ -0,0 +1,53 @@ +#============================================================================== +# = +# mis_builder module for OpenERP, Management Information System Builder +# Copyright (C) 2014 ACSONE SA/NV () +# = +# This file is a part of mis_builder +# = +# mis_builder is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License v3 or later +# as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# = +# mis_builder is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License v3 or later for more details. +# = +# You should have received a copy of the GNU Affero General Public License +# v3 or later along with this program. +# If not, see . +# = +#============================================================================== +{ + 'name': 'mis builder', + 'version': '0.1', + 'category': 'Reporting', + 'description': """ + Management Information System Builder + """, + 'author': 'ACSONE SA/NV', + 'website': 'http://acsone.eu', + 'depends': ['account'], + 'data': [ + 'views/mis_builder.xml', + ], + 'test': [ + ], + 'demo': [ + ], + 'js': [ + 'static/src/js/*.js' + ], + 'qweb': [ + 'static/src/xml/*.xml' + ], + 'css': [ + 'static/src/css/*.css' + ], + 'installable': True, + 'application': True, + 'auto_install': False, + 'license': 'AGPL-3', +} diff --git a/mis_builder/models/__init__.py b/mis_builder/models/__init__.py new file mode 100644 index 00000000..4d7f1bd5 --- /dev/null +++ b/mis_builder/models/__init__.py @@ -0,0 +1,24 @@ +#============================================================================== +# = +# mis_builder module for OpenERP, Management Information System Builder +# Copyright (C) 2014 ACSONE SA/NV () +# = +# This file is a part of mis_builder +# = +# mis_builder is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License v3 or later +# as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# = +# mis_builder is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License v3 or later for more details. +# = +# You should have received a copy of the GNU Affero General Public License +# v3 or later along with this program. +# If not, see . +# = +#============================================================================== + +import mis_builder diff --git a/mis_builder/models/mis_builder.py b/mis_builder/models/mis_builder.py new file mode 100644 index 00000000..8d5a0f6e --- /dev/null +++ b/mis_builder/models/mis_builder.py @@ -0,0 +1,466 @@ +# vim: set fileencoding=utf-8 : +#============================================================================== +# = +# mis_builder module for OpenERP, Management Information System Builder +# Copyright (C) 2014 ACSONE SA/NV () +# = +# This file is a part of mis_builder +# = +# mis_builder is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License v3 or later +# as published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# = +# mis_builder is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License v3 or later for more details. +# = +# You should have received a copy of the GNU Affero General Public License +# v3 or later along with this program. +# If not, see . +# = +#============================================================================== + +from datetime import datetime, timedelta +from dateutil import parser +import traceback +from lxml import etree + +from openerp.osv import orm, fields +from openerp.tools.safe_eval import safe_eval +from openerp.tools.translate import _ +from openerp import tools + + +class AutoStruct(object): + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +def _get_selection_label(selection, value): + for v, l in selection: + if v == value: + return l + return '' + + +class mis_report_kpi(orm.Model): + """ A KPI is an element of a MIS report. + + In addition to a name and description, it has an expression + to compute it based on queries defined in the MIS report. + It also has various informations defining how to render it + (numeric or percentage or a string, a suffix, divider) and + how to render comparison of two values of the KPI. + KPI are ordered inside the MIS report, as some KPI expressions + can depend on other KPI that need to be computed before. + """ + + _name = 'mis.report.kpi' + + _columns = { + 'name': fields.char(size=32, required=True, + string='Name'), + 'description': fields.char(required=True, + string='Description', + translate=True), + 'expression': fields.char(required=True, + string='Expression'), + 'type': fields.selection([('num', _('Numeric')), + ('pct', _('Percentage')), + ('str', _('String'))], + required=True, + string='Type'), + 'divider': fields.selection([('1e-6', _('ยต')), + ('1e-3', _('m')), + ('1e3', _('k')), + ('1e6', _('M'))], + string='Factor'), + 'dp': fields.integer(string='Rounding'), + 'suffix': fields.char(size=16, string='Unit'), + 'compare_method': fields.selection([('diff', _('Difference')), + ('pct', _('Percentage')), + ('none', _('None'))], + required=True, + string='Comparison Method'), + 'sequence': fields.integer(string='Sequence'), + 'report_id': fields.many2one('mis.report', string='Report'), + } + + _defaults = { + 'type': 'num', + 'divider': '1', + 'dp': 0, + 'compare_method': 'pct', + } + + _order = 'sequence' + + # TODO: constraint to check name is a valid python identifier + # TODO: onchange type pct -> force comparison method = diff + # TODO: onchange type str -> divider, dp, suffix, compare_method read only + + def _render(self, kpi, value): + """ render a KPI value as a unicode string, ready for display """ + if kpi.type == 'num': + return self._render_num(value, + kpi.divider, kpi.dp, kpi.suffix) + elif kpi.type == 'pct': + return self._render_num(value, + 100, kpi.dp, '%') + else: + return unicode(value) + + def _render_comparison(self, kpi, value, base_value): + """ render the comparison of two KPI values, ready for display """ + if value is None or base_value is None: + return '' + if kpi.type == 'pct': + return self._render_num(value - base_value, + 0.01, kpi.dp, _('pp'), + sign='+') + elif kpi.type == 'num': + if kpi.compare_method == 'diff': + return self._render_num(value - base_value, + kpi.divider, kpi.dp, kpi.suffix, + sign='+') + elif kpi.compare_method == 'pct' and base_value != 0: + return self._render_num(value / base_value - base_value, + 0.01, kpi.dp, '%', + sign='+') + return '' + + def _render_num(self, value, divider, dp, suffix, sign='-'): + divider_label = _get_selection_label( + self._columns['divider'].selection, divider) + fmt = '{:%s,.%df}%s%s' % (sign, dp, divider_label, suffix or '') + value = round(value / float(divider or 1), dp) or 0 + return fmt.format(value) + + +class mis_report_query(orm.Model): + """ A query to fetch data for a MIS report. + + A query works on a model and has a domain and list of fields to fetch. + At runtime, the domain is expanded with a "and" on the date/datetime field. + """ + + _name = 'mis.report.query' + + _columns = { + 'name': fields.char(size=32, required=True, + string='Name'), + 'model_id': fields.many2one('ir.model', required=True, + string='Model'), + 'field_ids': fields.many2many('ir.model.fields', required=True, + string='Fields to fetch'), + 'date_field': fields.many2one('ir.model.fields', required=True, + string='Date field', + domain=[('ttype', 'in', ('date', 'datetime'))]), + 'domain': fields.char(string='Domain'), + 'report_id': fields.many2one('mis.report', string='Report'), + } + + _order = 'name' + + +class mis_report(orm.Model): + """ A MIS report template (without period information) + + The MIS report holds: + * an implicit query fetching allow the account balances; + for each account, the balance is stored in a variable named + bal_{code} where {code} is the account code + * a list of explicit queries; the result of each query is + stored in a variable with same name as a query, containing as list + of data structures populated with attributes for each fields to fetch + * a list of KPI to be evaluated based on the variables resulting + from the balance and queries + """ + + _name = 'mis.report' + + _columns = { + 'name': fields.char(size=32, required=True, + string='Name', translate=True), + 'description': fields.char(required=False, + string='Description', translate=True), + 'query_ids': fields.one2many('mis.report.query', 'report_id', + string='Queries'), + 'kpi_ids': fields.one2many('mis.report.kpi', 'report_id', + string='KPI\'s'), + } + + +class mis_report_instance_period(orm.Model): + """ A MIS report instance has the logic to compute + a report template for a give date period. + + Periods have a duration (day, week, fiscal period) and + are defined as an offset relative to a pivot date. + """ + + def _get_dates(self, cr, uid, ids, field_names, arg, context=None): + if isinstance(ids, (int, long)): + ids = [ids] + res = {} + for c in self.browse(cr, uid, ids, context=context): + d = parser.parse(c.report_instance_id.pivot_date) + if c.type == 'd': + date_from = d + timedelta(days=c.offset) + date_to = date_from + timedelta(days=c.duration - 1) + date_from = date_from.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + date_to = date_to.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + period_ids = None + elif c.type == 'w': + date_from = d - timedelta(d.weekday()) + date_from = date_from + timedelta(days=c.offset * 7) + date_to = date_from + timedelta(days=(7 * c.duration) - 1) + date_from = date_from.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + date_to = date_to.strftime(tools.DEFAULT_SERVER_DATE_FORMAT) + period_ids = None + elif c.type == 'fp': + # TODO: filter on company_id + # TODO: date! + period_obj = self.pool['account.period'] + all_period_ids = period_obj.search(cr, uid, + [('special', '=', False)], + order='date_start', + context=context) + current_period_ids = period_obj.search(cr, uid, + [('special', '=', False), + ('date_start', '<=', d), + ('date_stop', '>=', d)], + context=context) + if not current_period_ids: + raise orm.except_orm(_("Error!"), + _("No current fiscal period for %s") % d) + p = all_period_ids.index(current_period_ids[0]) + c.offset + if p < 0 or p >= len(all_period_ids): + raise orm.except_orm(_("Error!"), + _("No such fiscal period for %s " + "with offset %d") % (d, c.offset)) + period_ids = all_period_ids[p:p + c.duration] + periods = period_obj.browse(cr, uid, period_ids, + context=context) + date_from = periods[0].date_start + date_to = periods[-1].date_stop + else: + raise orm.except_orm(_("Error!"), + _("Unimplemented period type %s") % + (c.type,)) + res[c.id] = { + 'date_from': date_from, + 'date_to': date_to, + 'period_from': period_ids and period_ids[0], + 'period_to': period_ids and period_ids[-1], + } + return res + + _name = 'mis.report.instance.period' + + _columns = { + 'name': fields.char(size=32, required=True, + string='Name', translate=True), + 'type': fields.selection([('d', _('Day')), + ('w', _('Week')), + ('fp', _('Fiscal Period')), + # ('fy', _('Fiscal Year')) + ], + required=True, + string='Period type'), + 'offset': fields.integer(string='Offset', + help='Offset from current period'), + 'duration': fields.integer(string='Duration', + help='Number of periods'), + 'date_from': fields.function(_get_dates, + type='date', + multi="dates", + string="From"), + 'date_to': fields.function(_get_dates, + type='date', + multi="dates", + string="To"), + 'period_from': fields.function(_get_dates, + type='many2one', obj='account.period', + multi="dates", string="From period"), + 'period_to': fields.function(_get_dates, + type='many2one', obj='account.period', + multi="dates", string="To period"), + 'sequence': fields.integer(string='Sequence'), + 'report_instance_id': fields.many2one('mis.report.instance', + string='Report Instance'), + } + + _defaults = { + 'offset':-1, + 'duration': 1, + } + + _order = 'sequence' + + # TODO: constraint duration >= 1 + + def _fetch_balances(self, cr, uid, c, context=None): + """ fetch the general account balances for the given period + + returns a dictionary {bal_: account.balance} + """ + account_obj = self.pool['account.account'] + + search_ctx = dict(context) + if c.period_from: + search_ctx.update({'period_from': c.period_from, + 'period_to': c.period_to}) + else: + search_ctx.update({'date_from': c.date_from, + 'date_to': c.date_to}) + + # TODO: initial balance? + # TODO: draft or posted? + account_ids = account_obj.search(cr, uid, []) + account_datas = account_obj.read(cr, uid, account_ids, + ['code', 'balance'], + context=search_ctx) + balances = {} + + for account_data in account_datas: + # TODO: normalize code (strip special chars) + # TODO: company_id in key + key = 'bal_' + account_data['code'] + assert key not in balances + balances[key] = account_data['balance'] + + return balances + + def _fetch_queries(self, cr, uid, c, context): + res = {} + + report = c.report_instance_id.report_id + for query in report.query_ids: + obj = self.pool[query.model_id.model] + domain = query.domain and safe_eval(query.domain) or [] + if query.date_field.ttype == 'date': + domain.extend([(query.date_field.name, '>=', c.date_from), + (query.date_field.name, '<=', c.date_to)]) + else: + # TODO: datetime support (convert date to utc midnight) + # datetime_from = utc_midnight(date_from) + # datetime_to = utc_midnight(date_to + 1) + # domain.extend([(query.date_field.name, '>=', datetime_from), + # (query.date_field.name, '<', datetime_to)]) + raise orm.except_orm(_('Error!'), _('Not implemented')) + field_names = [field.name for field in query.field_ids] + obj_ids = obj.search(cr, uid, domain, + context=context) + obj_datas = obj.read(cr, uid, obj_ids, field_names, + context=context) + res[query.name] = [AutoStruct(**d) for d in obj_datas] + + return res + + def _compute(self, cr, uid, c, context=None): + if context is None: + context = {} + + kpi_obj = self.pool['mis.report.kpi'] + + res = {} + + localdict = { + 'registry': self.pool, + 'sum': sum, + 'min': min, + 'max': max, + 'len': len, + 'avg': lambda l: sum(l) / float(len(l)), + } + localdict.update(self._fetch_balances(cr, uid, c, context=context)) + localdict.update(self._fetch_queries(cr, uid, c, context=context)) + + for kpi in c.report_instance_id.report.kpi_ids: + try: + kpi_val = safe_eval(kpi.expression, localdict) + except ZeroDivisionError: + kpi_val = None + kpi_val_rendered = '#DIV/0' + kpi_val_comment = traceback.format_exc() + except: + kpi_val = None + kpi_val_rendered = '#ERR' + kpi_val_comment = traceback.format_exc() + else: + kpi_val_rendered = kpi_obj._render(kpi, kpi_val) + kpi_val_comment = None + + localdict[kpi.name] = kpi_val + + res[kpi.name] = { + 'val': kpi_val, + 'val_r': kpi_val_rendered, + 'val_c': kpi_val_comment, + } + + return res + + +class mis_report_instance(orm.Model): + """ The MIS report instance combines compute and + display a MIS report template for a set of periods """ + + # TODO: mechanism to add comparison columns + + def _get_pivot_date(self, cr, uid, ids, field_name, arg, context=None): + res = {} + for r in self.browse(cr, uid, ids, context=context): + if r.date: + res[r.id] = r.date + else: + res[r.id] = fields.date.context_today(self, cr, uid, + context=context) + return res + + _name = 'mis.report.instance' + + _columns = { + 'name': fields.char(size=32, required=True, + string='Name', translate=True), + 'description': fields.char(required=False, + string='Description', translate=True), + 'date': fields.date(string='Base date', + help='Report base date ' + '(leave empty to use current date)'), + 'pivot_date': fields.function(_get_pivot_date, + type='date', + string="Pivot date"), + 'report_id': fields.many2one('mis.report', + required=True, + string='Report'), + 'period_ids': fields.one2many('mis.report.instance.period', + 'report_instance_id', + required=True, + string='Periods'), + } + + def compute(self, cr, uid, _ids, context=None): + assert isinstance(_ids, (int, long)) + + r = self.browse(cr, uid, _ids, context=context) + + res = {} + + rows = [] + for kpi in r.report_id.kpi_ids: + rows.append(dict(name=kpi.name, + description=kpi.description)) + res['rows'] = rows + + cols = [] + for period in r.period_ids: + col = dict(name=period.name) + res['cols'] = cols + + return res diff --git a/mis_builder/security/ir.model.access.csv b/mis_builder/security/ir.model.access.csv new file mode 100644 index 00000000..8d4ee863 --- /dev/null +++ b/mis_builder/security/ir.model.access.csv @@ -0,0 +1 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" diff --git a/mis_builder/static/src/img/icon.png b/mis_builder/static/src/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..833ce9211046568a692f342c1d62fb10e134d4df GIT binary patch literal 3464 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 + +

Yo!

+ + + + + + + +
+ + +
+ +
+
+ \ No newline at end of file diff --git a/mis_builder/views/mis_builder.xml b/mis_builder/views/mis_builder.xml new file mode 100644 index 00000000..6299c6d2 --- /dev/null +++ b/mis_builder/views/mis_builder.xml @@ -0,0 +1,158 @@ + + + + + + mis.report.view.tree + mis.report + + + + + + + + + + mis.report.view.form + mis.report + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + mis.report.view.action + + mis.report + form + tree,form + + + + + + mis.report.instance.result.view.form + mis.report.instance + + +
+ +
+

+ +

+
+ + + + + +
+
+
+
+ + + mis.report.instance.result.view.action + + mis.report.instance + form + form + new + + + + mis.report.instance.view.tree + mis.report.instance + + + + + + + + + + + mis.report.instance.view.form + mis.report.instance + + +
+ +
+
+
+

+ +

+
+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ + + mis.report.instance.view.action + + mis.report.instance + form + tree,form + + + + +
+