You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1111 lines
43 KiB

9 years ago
9 years ago
9 years ago
10 years ago
9 years ago
10 years ago
  1. # -*- coding: utf-8 -*-
  2. # © 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
  3. # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
  4. from collections import defaultdict, OrderedDict
  5. import datetime
  6. import dateutil
  7. import logging
  8. import re
  9. import time
  10. import traceback
  11. import pytz
  12. from openerp import api, exceptions, fields, models, _
  13. from openerp.tools.safe_eval import safe_eval
  14. from .aep import AccountingExpressionProcessor as AEP
  15. from .aggregate import _sum, _avg, _min, _max
  16. from .accounting_none import AccountingNone
  17. from openerp.exceptions import UserError
  18. from .simple_array import SimpleArray
  19. _logger = logging.getLogger(__name__)
  20. class DataError(Exception):
  21. def __init__(self, name, msg):
  22. self.name = name
  23. self.msg = msg
  24. class AutoStruct(object):
  25. def __init__(self, **kwargs):
  26. for k, v in kwargs.items():
  27. setattr(self, k, v)
  28. class ExplodedKpiItem(object):
  29. def __init__(self, account_id):
  30. pass
  31. class KpiMatrix(object):
  32. def __init__(self):
  33. # { period: {kpi: vals}
  34. self._kpi_vals = defaultdict(dict)
  35. # { period: {kpi: {account_id: vals}}}
  36. self._kpi_exploded_vals = defaultdict(dict)
  37. # { period: localdict }
  38. self._localdict = {}
  39. # { kpi: set(account_ids) }
  40. self._kpis = OrderedDict()
  41. def set_kpi_vals(self, period, kpi, vals):
  42. self._kpi_vals[period][kpi] = vals
  43. if kpi not in self._kpis:
  44. self._kpis[kpi] = set()
  45. def set_kpi_exploded_vals(self, period, kpi, account_id, vals):
  46. exploded_vals = self._kpi_exploded_vals[period]
  47. if kpi not in exploded_vals:
  48. exploded_vals[kpi] = {}
  49. exploded_vals[kpi][account_id] = vals
  50. self._kpis[kpi].add(account_id)
  51. def set_localdict(self, period, localdict):
  52. self._localdict[period] = localdict
  53. def iter_kpi_vals(self, period):
  54. for kpi, vals in self._kpi_vals[period].iteritems():
  55. yield kpi.name, kpi, vals
  56. kpi_exploded_vals = self._kpi_exploded_vals[period]
  57. if kpi not in kpi_exploded_vals:
  58. continue
  59. for account_id, account_id_vals in \
  60. kpi_exploded_vals[kpi].iteritems():
  61. yield "%s:%s" % (kpi.name, account_id), kpi, account_id_vals
  62. def iter_kpis(self):
  63. for kpi, account_ids in self._kpis.iteritems():
  64. yield kpi.name, kpi
  65. for account_id in account_ids:
  66. yield "%s:%s" % (kpi.name, account_id), kpi
  67. def _get_selection_label(selection, value):
  68. for v, l in selection:
  69. if v == value:
  70. return l
  71. return ''
  72. def _utc_midnight(d, tz_name, add_day=0):
  73. d = fields.Datetime.from_string(d) + datetime.timedelta(days=add_day)
  74. utc_tz = pytz.timezone('UTC')
  75. context_tz = pytz.timezone(tz_name)
  76. local_timestamp = context_tz.localize(d, is_dst=False)
  77. return fields.Datetime.to_string(local_timestamp.astimezone(utc_tz))
  78. def _python_var(var_str):
  79. return re.sub(r'\W|^(?=\d)', '_', var_str).lower()
  80. def _is_valid_python_var(name):
  81. return re.match("[_A-Za-z][_a-zA-Z0-9]*$", name)
  82. class MisReportKpi(models.Model):
  83. """ A KPI is an element (ie a line) of a MIS report.
  84. In addition to a name and description, it has an expression
  85. to compute it based on queries defined in the MIS report.
  86. It also has various informations defining how to render it
  87. (numeric or percentage or a string, a prefix, a suffix, divider) and
  88. how to render comparison of two values of the KPI.
  89. KPI's have a sequence and are ordered inside the MIS report.
  90. """
  91. _name = 'mis.report.kpi'
  92. name = fields.Char(size=32, required=True,
  93. string='Name')
  94. description = fields.Char(required=True,
  95. string='Description',
  96. translate=True)
  97. multi = fields.Boolean()
  98. expression = fields.Char(
  99. compute='_compute_expression',
  100. inverse='_inverse_expression')
  101. expression_ids = fields.One2many('mis.report.kpi.expression', 'kpi_id')
  102. default_css_style = fields.Char(string='Default CSS style')
  103. css_style = fields.Char(string='CSS style expression')
  104. type = fields.Selection([('num', _('Numeric')),
  105. ('pct', _('Percentage')),
  106. ('str', _('String'))],
  107. required=True,
  108. string='Type',
  109. default='num')
  110. divider = fields.Selection([('1e-6', _('µ')),
  111. ('1e-3', _('m')),
  112. ('1', _('1')),
  113. ('1e3', _('k')),
  114. ('1e6', _('M'))],
  115. string='Factor',
  116. default='1')
  117. dp = fields.Integer(string='Rounding', default=0)
  118. prefix = fields.Char(size=16, string='Prefix')
  119. suffix = fields.Char(size=16, string='Suffix')
  120. compare_method = fields.Selection([('diff', _('Difference')),
  121. ('pct', _('Percentage')),
  122. ('none', _('None'))],
  123. required=True,
  124. string='Comparison Method',
  125. default='pct')
  126. sequence = fields.Integer(string='Sequence', default=100)
  127. report_id = fields.Many2one('mis.report',
  128. string='Report',
  129. ondelete='cascade')
  130. _order = 'sequence, id'
  131. @api.one
  132. @api.constrains('name')
  133. def _check_name(self):
  134. if not _is_valid_python_var(self.name):
  135. raise exceptions.Warning(_('The name must be a valid '
  136. 'python identifier'))
  137. @api.onchange('name')
  138. def _onchange_name(self):
  139. if self.name and not _is_valid_python_var(self.name):
  140. return {
  141. 'warning': {
  142. 'title': 'Invalid name %s' % self.name,
  143. 'message': 'The name must be a valid python identifier'
  144. }
  145. }
  146. @api.multi
  147. def _compute_expression(self):
  148. for kpi in self:
  149. kpi.expression = ''
  150. for expression in kpi.expression_ids:
  151. if expression.subkpi_id:
  152. kpi.expression += '%s :\n' % expression.subkpi_id.name
  153. kpi.expression += '%s\n' % expression.name
  154. @api.multi
  155. def _inverse_expression(self):
  156. for kpi in self:
  157. if kpi.multi:
  158. raise UserError('Can not update a multi kpi from the kpi line')
  159. if kpi.expression_ids:
  160. kpi.expression_ids[0].write({
  161. 'name': kpi.expression,
  162. 'subkpi_id': None})
  163. for expression in kpi.expression_ids[1:]:
  164. expression.unlink()
  165. else:
  166. kpi.write({
  167. 'expression_ids': [(0, 0, {
  168. 'name': kpi.expression
  169. })]
  170. })
  171. @api.onchange('multi')
  172. def _onchange_multi(self):
  173. for kpi in self:
  174. if not kpi.multi:
  175. if kpi.expression_ids:
  176. kpi.expression = kpi.expression_ids[0].name
  177. else:
  178. kpi.expression = None
  179. else:
  180. expressions = []
  181. for subkpi in kpi.report_id.subkpi_ids:
  182. expressions.append((0, 0, {
  183. 'name': kpi.expression,
  184. 'subkpi_id': subkpi.id,
  185. }))
  186. kpi.expression_ids = expressions
  187. @api.onchange('description')
  188. def _onchange_description(self):
  189. """ construct name from description """
  190. if self.description and not self.name:
  191. self.name = _python_var(self.description)
  192. @api.onchange('type')
  193. def _onchange_type(self):
  194. if self.type == 'num':
  195. self.compare_method = 'pct'
  196. self.divider = '1'
  197. self.dp = 0
  198. elif self.type == 'pct':
  199. self.compare_method = 'diff'
  200. self.divider = '1'
  201. self.dp = 0
  202. elif self.type == 'str':
  203. self.compare_method = 'none'
  204. self.divider = ''
  205. self.dp = 0
  206. def render(self, lang_id, value):
  207. """ render a KPI value as a unicode string, ready for display """
  208. assert len(self) == 1
  209. if value is None or value == AccountingNone:
  210. return ''
  211. elif self.type == 'num':
  212. return self._render_num(lang_id, value, self.divider,
  213. self.dp, self.prefix, self.suffix)
  214. elif self.type == 'pct':
  215. return self._render_num(lang_id, value, 0.01,
  216. self.dp, '', '%')
  217. else:
  218. return unicode(value)
  219. def render_comparison(self, lang_id, value, base_value,
  220. average_value, average_base_value):
  221. """ render the comparison of two KPI values, ready for display
  222. If the difference is 0, an empty string is returned.
  223. """
  224. assert len(self) == 1
  225. if value is None:
  226. value = AccountingNone
  227. if base_value is None:
  228. base_value = AccountingNone
  229. if self.type == 'pct':
  230. delta = value - base_value
  231. if delta and round(delta, self.dp) != 0:
  232. return self._render_num(
  233. lang_id,
  234. delta,
  235. 0.01, self.dp, '', _('pp'),
  236. sign='+')
  237. elif self.type == 'num':
  238. if value and average_value:
  239. value = value / float(average_value)
  240. if base_value and average_base_value:
  241. base_value = base_value / float(average_base_value)
  242. if self.compare_method == 'diff':
  243. delta = value - base_value
  244. if delta and round(delta, self.dp) != 0:
  245. return self._render_num(
  246. lang_id,
  247. delta,
  248. self.divider, self.dp, self.prefix, self.suffix,
  249. sign='+')
  250. elif self.compare_method == 'pct':
  251. if base_value and round(base_value, self.dp) != 0:
  252. delta = (value - base_value) / abs(base_value)
  253. if delta and round(delta, self.dp) != 0:
  254. return self._render_num(
  255. lang_id,
  256. delta,
  257. 0.01, self.dp, '', '%',
  258. sign='+')
  259. return ''
  260. def _render_num(self, lang_id, value, divider,
  261. dp, prefix, suffix, sign='-'):
  262. divider_label = _get_selection_label(
  263. self._columns['divider'].selection, divider)
  264. if divider_label == '1':
  265. divider_label = ''
  266. # format number following user language
  267. value = round(value / float(divider or 1), dp) or 0
  268. value = self.env['res.lang'].browse(lang_id).format(
  269. '%%%s.%df' % (sign, dp),
  270. value,
  271. grouping=True)
  272. value = u'%s\N{NARROW NO-BREAK SPACE}%s\N{NO-BREAK SPACE}%s%s' % \
  273. (prefix or '', value, divider_label, suffix or '')
  274. value = value.replace('-', u'\N{NON-BREAKING HYPHEN}')
  275. return value
  276. class MisReportSubkpi(models.Model):
  277. _name = 'mis.report.subkpi'
  278. _order = 'sequence'
  279. sequence = fields.Integer()
  280. report_id = fields.Many2one('mis.report')
  281. name = fields.Char(required=True)
  282. expression_ids = fields.One2many('mis.report.kpi.expression', 'subkpi_id')
  283. @api.multi
  284. def unlink(self):
  285. for subkpi in self:
  286. subkpi.expression_ids.unlink()
  287. return super(MisReportSubkpi, self).unlink()
  288. class MisReportKpiExpression(models.Model):
  289. """ A KPI Expression is an expression of a line of a MIS report Kpi.
  290. It's used to compute the kpi value.
  291. """
  292. _name = 'mis.report.kpi.expression'
  293. _order = 'sequence, name'
  294. sequence = fields.Integer(
  295. related='subkpi_id.sequence',
  296. store=True,
  297. readonly=True)
  298. name = fields.Char(string='Expression')
  299. kpi_id = fields.Many2one('mis.report.kpi')
  300. subkpi_id = fields.Many2one(
  301. 'mis.report.subkpi',
  302. readonly=True)
  303. class MisReportQuery(models.Model):
  304. """ A query to fetch arbitrary data for a MIS report.
  305. A query works on a model and has a domain and list of fields to fetch.
  306. At runtime, the domain is expanded with a "and" on the date/datetime field.
  307. """
  308. _name = 'mis.report.query'
  309. @api.one
  310. @api.depends('field_ids')
  311. def _compute_field_names(self):
  312. field_names = [field.name for field in self.field_ids]
  313. self.field_names = ', '.join(field_names)
  314. name = fields.Char(size=32, required=True,
  315. string='Name')
  316. model_id = fields.Many2one('ir.model', required=True,
  317. string='Model')
  318. field_ids = fields.Many2many('ir.model.fields', required=True,
  319. string='Fields to fetch')
  320. field_names = fields.Char(compute='_compute_field_names',
  321. string='Fetched fields name')
  322. aggregate = fields.Selection([('sum', _('Sum')),
  323. ('avg', _('Average')),
  324. ('min', _('Min')),
  325. ('max', _('Max'))],
  326. string='Aggregate')
  327. date_field = fields.Many2one('ir.model.fields', required=True,
  328. string='Date field',
  329. domain=[('ttype', 'in',
  330. ('date', 'datetime'))])
  331. domain = fields.Char(string='Domain')
  332. report_id = fields.Many2one('mis.report', string='Report',
  333. ondelete='cascade')
  334. _order = 'name'
  335. @api.one
  336. @api.constrains('name')
  337. def _check_name(self):
  338. if not _is_valid_python_var(self.name):
  339. raise exceptions.Warning(_('The name must be a valid '
  340. 'python identifier'))
  341. class MisReport(models.Model):
  342. """ A MIS report template (without period information)
  343. The MIS report holds:
  344. * a list of explicit queries; the result of each query is
  345. stored in a variable with same name as a query, containing as list
  346. of data structures populated with attributes for each fields to fetch;
  347. when queries have an aggregate method and no fields to group, it returns
  348. a data structure with the aggregated fields
  349. * a list of KPI to be evaluated based on the variables resulting
  350. from the accounting data and queries (KPI expressions can references
  351. queries and accounting expression - see AccoutingExpressionProcessor)
  352. """
  353. _name = 'mis.report'
  354. name = fields.Char(required=True,
  355. string='Name', translate=True)
  356. description = fields.Char(required=False,
  357. string='Description', translate=True)
  358. query_ids = fields.One2many('mis.report.query', 'report_id',
  359. string='Queries',
  360. copy=True)
  361. kpi_ids = fields.One2many('mis.report.kpi', 'report_id',
  362. string='KPI\'s',
  363. copy=True)
  364. subkpi_ids = fields.One2many(
  365. 'mis.report.subkpi',
  366. 'report_id',
  367. string="Sub KPI")
  368. @api.one
  369. def copy(self, default=None):
  370. default = dict(default or {})
  371. default['name'] = _('%s (copy)') % self.name
  372. return super(MisReport, self).copy(default)
  373. # TODO: kpi name cannot be start with query name
  374. @api.multi
  375. def _prepare_aep(self, company):
  376. self.ensure_one()
  377. aep = AEP(self.env)
  378. for kpi in self.kpi_ids:
  379. aep.parse_expr(kpi.expression)
  380. aep.done_parsing(company)
  381. return aep
  382. @api.multi
  383. def _fetch_queries(self, date_from, date_to,
  384. get_additional_query_filter=None):
  385. self.ensure_one()
  386. res = {}
  387. for query in self.query_ids:
  388. model = self.env[query.model_id.model]
  389. eval_context = {
  390. 'env': self.env,
  391. 'time': time,
  392. 'datetime': datetime,
  393. 'dateutil': dateutil,
  394. # deprecated
  395. 'uid': self.env.uid,
  396. 'context': self.env.context,
  397. }
  398. domain = query.domain and \
  399. safe_eval(query.domain, eval_context) or []
  400. if get_additional_query_filter:
  401. domain.extend(get_additional_query_filter(query))
  402. if query.date_field.ttype == 'date':
  403. domain.extend([(query.date_field.name, '>=', date_from),
  404. (query.date_field.name, '<=', date_to)])
  405. else:
  406. datetime_from = _utc_midnight(
  407. date_from, self._context.get('tz', 'UTC'))
  408. datetime_to = _utc_midnight(
  409. date_to, self._context.get('tz', 'UTC'), add_day=1)
  410. domain.extend([(query.date_field.name, '>=', datetime_from),
  411. (query.date_field.name, '<', datetime_to)])
  412. field_names = [f.name for f in query.field_ids]
  413. if not query.aggregate:
  414. data = model.search_read(domain, field_names)
  415. res[query.name] = [AutoStruct(**d) for d in data]
  416. elif query.aggregate == 'sum':
  417. data = model.read_group(
  418. domain, field_names, [])
  419. s = AutoStruct(count=data[0]['__count'])
  420. for field_name in field_names:
  421. v = data[0][field_name]
  422. setattr(s, field_name, v)
  423. res[query.name] = s
  424. else:
  425. data = model.search_read(domain, field_names)
  426. s = AutoStruct(count=len(data))
  427. if query.aggregate == 'min':
  428. agg = _min
  429. elif query.aggregate == 'max':
  430. agg = _max
  431. elif query.aggregate == 'avg':
  432. agg = _avg
  433. for field_name in field_names:
  434. setattr(s, field_name,
  435. agg([d[field_name] for d in data]))
  436. res[query.name] = s
  437. return res
  438. @api.multi
  439. def _compute(self, kpi_matrix, period_key,
  440. lang_id, aep,
  441. date_from, date_to,
  442. target_move,
  443. company,
  444. subkpis_filter,
  445. get_additional_move_line_filter=None,
  446. get_additional_query_filter=None):
  447. """ Evaluate a report for a given period, populating a KpiMatrix.
  448. :param kpi_matrix: the KpiMatrix object to be populated
  449. :param kpi_matrix_period: the period key to use when populating
  450. the KpiMatrix
  451. :param lang_id: id of a res.lang object
  452. :param aep: an AccountingExpressionProcessor instance created
  453. using _prepare_aep()
  454. :param date_from, date_to: the starting and ending date
  455. :param target_move: all|posted
  456. :param company:
  457. :param get_additional_move_line_filter: a bound method that takes
  458. no arguments and returns
  459. a domain compatible with
  460. account.move.line
  461. :param get_additional_query_filter: a bound method that takes a single
  462. query argument and returns a
  463. domain compatible with the query
  464. underlying model
  465. For each kpi, it calls set_kpi_vals and set_kpi_exploded_vals
  466. with vals being a tuple with the evaluation
  467. result for sub-kpis, or a DataError object if the evaluation failed.
  468. When done, it also calls set_localdict to store the local values
  469. that served for the computation of the period.
  470. """
  471. self.ensure_one()
  472. localdict = {
  473. 'registry': self.pool,
  474. 'sum': _sum,
  475. 'min': _min,
  476. 'max': _max,
  477. 'len': len,
  478. 'avg': _avg,
  479. 'AccountingNone': AccountingNone,
  480. }
  481. localdict.update(self._fetch_queries(
  482. date_from, date_to, get_additional_query_filter))
  483. additional_move_line_filter = None
  484. if get_additional_move_line_filter:
  485. additional_move_line_filter = get_additional_move_line_filter()
  486. aep.do_queries(date_from, date_to,
  487. target_move,
  488. company,
  489. additional_move_line_filter)
  490. compute_queue = self.kpi_ids
  491. recompute_queue = []
  492. while True:
  493. for kpi in compute_queue:
  494. vals = []
  495. has_error = False
  496. for expression in kpi.expression_ids:
  497. if expression.subkpi_id and \
  498. subkpis_filter and \
  499. expression.subkpi_id not in subkpis_filter:
  500. continue
  501. try:
  502. kpi_eval_expression = aep.replace_expr(expression.name)
  503. vals.append(safe_eval(kpi_eval_expression, localdict))
  504. except ZeroDivisionError:
  505. has_error = True
  506. vals.append(DataError(
  507. '#DIV/0',
  508. '\n\n%s' % (traceback.format_exc(),)))
  509. except (NameError, ValueError):
  510. has_error = True
  511. recompute_queue.append(kpi)
  512. vals.append(DataError(
  513. '#ERR',
  514. '\n\n%s' % (traceback.format_exc(),)))
  515. except:
  516. has_error = True
  517. vals.append(DataError(
  518. '#ERR',
  519. '\n\n%s' % (traceback.format_exc(),)))
  520. if len(vals) == 1 and isinstance(vals[0], SimpleArray):
  521. vals = vals[0]
  522. else:
  523. vals = SimpleArray(vals)
  524. kpi_matrix.set_kpi_vals(period_key, kpi, vals)
  525. if has_error:
  526. continue
  527. # no error, set it in localdict so it can be used
  528. # in computing other kpis
  529. localdict[kpi.name] = vals
  530. # let's compute the exploded values by account
  531. # we assume there will be no errors, because it is a
  532. # the same as the kpi, just filtered on one account;
  533. # I'd say if we have an exception in this part, it's bug...
  534. # TODO FIXME: do this only if requested for this KPI
  535. for account_id in aep.get_accounts_in_expr(kpi.expression):
  536. account_id_vals = []
  537. for expression in kpi.expression_ids:
  538. if expression.subkpi_id and \
  539. subkpis_filter and \
  540. expression.subkpi_id not in subkpis_filter:
  541. continue
  542. kpi_eval_expression = \
  543. aep.replace_expr(expression.name,
  544. account_ids_filter=[account_id])
  545. account_id_vals.\
  546. append(safe_eval(kpi_eval_expression, localdict))
  547. kpi_matrix.set_kpi_exploded_vals(period_key, kpi,
  548. account_id,
  549. account_id_vals)
  550. if len(recompute_queue) == 0:
  551. # nothing to recompute, we are done
  552. break
  553. if len(recompute_queue) == len(compute_queue):
  554. # could not compute anything in this iteration
  555. # (ie real Value errors or cyclic dependency)
  556. # so we stop trying
  557. break
  558. # try again
  559. compute_queue = recompute_queue
  560. recompute_queue = []
  561. kpi_matrix.set_localdict(period_key, localdict)
  562. class MisReportInstancePeriod(models.Model):
  563. """ A MIS report instance has the logic to compute
  564. a report template for a given date period.
  565. Periods have a duration (day, week, fiscal period) and
  566. are defined as an offset relative to a pivot date.
  567. """
  568. @api.one
  569. @api.depends('report_instance_id.pivot_date', 'type', 'offset', 'duration')
  570. def _compute_dates(self):
  571. self.date_from = False
  572. self.date_to = False
  573. self.valid = False
  574. d = fields.Date.from_string(self.report_instance_id.pivot_date)
  575. if self.type == 'd':
  576. date_from = d + datetime.timedelta(days=self.offset)
  577. date_to = date_from + \
  578. datetime.timedelta(days=self.duration - 1)
  579. self.date_from = fields.Date.to_string(date_from)
  580. self.date_to = fields.Date.to_string(date_to)
  581. self.valid = True
  582. elif self.type == 'w':
  583. date_from = d - datetime.timedelta(d.weekday())
  584. date_from = date_from + datetime.timedelta(days=self.offset * 7)
  585. date_to = date_from + \
  586. datetime.timedelta(days=(7 * self.duration) - 1)
  587. self.date_from = fields.Date.to_string(date_from)
  588. self.date_to = fields.Date.to_string(date_to)
  589. self.valid = True
  590. elif self.type == 'date_range':
  591. date_range_obj = self.env['date.range']
  592. current_periods = date_range_obj.search(
  593. [('type_id', '=', self.date_range_type_id.id),
  594. ('date_start', '<=', d),
  595. ('date_end', '>=', d),
  596. ('company_id', '=', self.report_instance_id.company_id.id)])
  597. if current_periods:
  598. all_periods = date_range_obj.search(
  599. [('type_id', '=', self.date_range_type_id.id),
  600. ('company_id', '=',
  601. self.report_instance_id.company_id.id)],
  602. order='date_start')
  603. all_period_ids = [p.id for p in all_periods]
  604. p = all_period_ids.index(current_periods[0].id) + self.offset
  605. if p >= 0 and p + self.duration <= len(all_period_ids):
  606. periods = all_periods[p:p + self.duration]
  607. self.date_from = periods[0].date_start
  608. self.date_to = periods[-1].date_end
  609. self.valid = True
  610. _name = 'mis.report.instance.period'
  611. name = fields.Char(size=32, required=True,
  612. string='Description', translate=True)
  613. type = fields.Selection([('d', _('Day')),
  614. ('w', _('Week')),
  615. ('date_range', _('Date Range'))
  616. ],
  617. required=True,
  618. string='Period type')
  619. date_range_type_id = fields.Many2one(
  620. comodel_name='date.range.type', string='Date Range Type')
  621. offset = fields.Integer(string='Offset',
  622. help='Offset from current period',
  623. default=-1)
  624. duration = fields.Integer(string='Duration',
  625. help='Number of periods',
  626. default=1)
  627. date_from = fields.Date(compute='_compute_dates', string="From")
  628. date_to = fields.Date(compute='_compute_dates', string="To")
  629. valid = fields.Boolean(compute='_compute_dates',
  630. type='boolean',
  631. string='Valid')
  632. sequence = fields.Integer(string='Sequence', default=100)
  633. report_instance_id = fields.Many2one('mis.report.instance',
  634. string='Report Instance',
  635. ondelete='cascade')
  636. comparison_column_ids = fields.Many2many(
  637. comodel_name='mis.report.instance.period',
  638. relation='mis_report_instance_period_rel',
  639. column1='period_id',
  640. column2='compare_period_id',
  641. string='Compare with')
  642. normalize_factor = fields.Integer(
  643. string='Factor',
  644. help='Factor to use to normalize the period (used in comparison',
  645. default=1)
  646. subkpi_ids = fields.Many2many(
  647. 'mis.report.subkpi',
  648. string="Sub KPI")
  649. _order = 'sequence, id'
  650. _sql_constraints = [
  651. ('duration', 'CHECK (duration>0)',
  652. 'Wrong duration, it must be positive!'),
  653. ('normalize_factor', 'CHECK (normalize_factor>0)',
  654. 'Wrong normalize factor, it must be positive!'),
  655. ('name_unique', 'unique(name, report_instance_id)',
  656. 'Period name should be unique by report'),
  657. ]
  658. @api.multi
  659. def _get_additional_move_line_filter(self):
  660. """ Prepare a filter to apply on all move lines
  661. This filter is applied with a AND operator on all
  662. accounting expression domains. This hook is intended
  663. to be inherited, and is useful to implement filtering
  664. on analytic dimensions or operational units.
  665. Returns an Odoo domain expression (a python list)
  666. compatible with account.move.line."""
  667. self.ensure_one()
  668. return []
  669. @api.multi
  670. def _get_additional_query_filter(self, query):
  671. """ Prepare an additional filter to apply on the query
  672. This filter is combined to the query domain with a AND
  673. operator. This hook is intended
  674. to be inherited, and is useful to implement filtering
  675. on analytic dimensions or operational units.
  676. Returns an Odoo domain expression (a python list)
  677. compatible with the model of the query."""
  678. self.ensure_one()
  679. return []
  680. @api.multi
  681. def drilldown(self, expr):
  682. self.ensure_one()
  683. # TODO FIXME: drilldown by account
  684. if AEP.has_account_var(expr):
  685. aep = AEP(self.env)
  686. aep.parse_expr(expr)
  687. aep.done_parsing(self.report_instance_id.company_id)
  688. domain = aep.get_aml_domain_for_expr(
  689. expr,
  690. self.date_from, self.date_to,
  691. self.report_instance_id.target_move,
  692. self.report_instance_id.company_id)
  693. domain.extend(self._get_additional_move_line_filter())
  694. return {
  695. 'name': expr + ' - ' + self.name,
  696. 'domain': domain,
  697. 'type': 'ir.actions.act_window',
  698. 'res_model': 'account.move.line',
  699. 'views': [[False, 'list'], [False, 'form']],
  700. 'view_type': 'list',
  701. 'view_mode': 'list',
  702. 'target': 'current',
  703. }
  704. else:
  705. return False
  706. @api.multi
  707. def _compute(self, kpi_matrix, lang_id, aep):
  708. """ Compute and render a mis report instance period
  709. It returns a dictionary keyed on kpi.name with a list of dictionaries
  710. with the following values (one item in the list for each subkpi):
  711. * val: the evaluated kpi, or None if there is no data or an error
  712. * val_r: the rendered kpi as a string, or #ERR, #DIV
  713. * val_c: a comment (explaining the error, typically)
  714. * style: the css style of the kpi
  715. (may change in the future!)
  716. * prefix: a prefix to display in front of the rendered value
  717. * suffix: a prefix to display after rendered value
  718. * dp: the decimal precision of the kpi
  719. * is_percentage: true if the kpi is of percentage type
  720. (may change in the future!)
  721. * expr: the kpi expression
  722. * drilldown: true if the drilldown method of
  723. mis.report.instance.period is going to do something
  724. useful in this kpi
  725. """
  726. self.ensure_one()
  727. # first invoke the compute method on the mis report template
  728. # passing it all the information regarding period and filters
  729. self.report_instance_id.report_id._compute(
  730. kpi_matrix, self,
  731. lang_id, aep,
  732. self.date_from, self.date_to,
  733. self.report_instance_id.target_move,
  734. self.report_instance_id.company_id,
  735. self.subkpi_ids,
  736. self._get_additional_move_line_filter,
  737. self._get_additional_query_filter,
  738. )
  739. # second, render it to something that can be used by the widget
  740. res = {}
  741. for kpi_name, kpi, vals in kpi_matrix.iter_kpi_vals(self):
  742. res[kpi_name] = []
  743. try:
  744. # TODO FIXME check css_style evaluation wrt subkpis
  745. kpi_style = None
  746. if kpi.css_style:
  747. kpi_style = safe_eval(kpi.css_style,
  748. kpi_matrix.get_localdict(self))
  749. except:
  750. _logger.warning("error evaluating css stype expression %s",
  751. kpi.css_style, exc_info=True)
  752. kpi_style = None
  753. default_vals = {
  754. 'style': kpi_style,
  755. 'prefix': kpi.prefix,
  756. 'suffix': kpi.suffix,
  757. 'dp': kpi.dp,
  758. 'is_percentage': kpi.type == 'pct',
  759. 'period_id': self.id,
  760. 'expr': kpi.expression, # TODO FIXME
  761. }
  762. for idx, subkpi_val in enumerate(vals):
  763. vals = default_vals.copy()
  764. if isinstance(subkpi_val, DataError):
  765. vals.update({
  766. 'val': subkpi_val.name,
  767. 'val_r': subkpi_val.name,
  768. 'val_c': subkpi_val.msg,
  769. 'drilldown': False,
  770. })
  771. else:
  772. # TODO FIXME: has_account_var on each subkpi expression?
  773. drilldown = (subkpi_val is not AccountingNone and
  774. AEP.has_account_var(kpi.expression))
  775. if kpi.multi:
  776. expression = kpi.expression_ids[idx].name
  777. else:
  778. expression = kpi.expression
  779. # TODO FIXME: check we have meaningfulname for exploded
  780. # kpis
  781. comment = kpi_name + " = " + expression
  782. vals.update({
  783. 'val': (None
  784. if subkpi_val is AccountingNone
  785. else subkpi_val),
  786. 'val_r': kpi.render(lang_id, subkpi_val),
  787. 'val_c': comment,
  788. 'drilldown': drilldown,
  789. })
  790. res[kpi_name].append(vals)
  791. return res
  792. class MisReportInstance(models.Model):
  793. """The MIS report instance combines everything to compute
  794. a MIS report template for a set of periods."""
  795. @api.one
  796. @api.depends('date')
  797. def _compute_pivot_date(self):
  798. if self.date:
  799. self.pivot_date = self.date
  800. else:
  801. self.pivot_date = fields.Date.context_today(self)
  802. @api.model
  803. def _default_company(self):
  804. return self.env['res.company'].\
  805. _company_default_get('mis.report.instance')
  806. _name = 'mis.report.instance'
  807. name = fields.Char(required=True,
  808. string='Name', translate=True)
  809. description = fields.Char(required=False,
  810. string='Description', translate=True)
  811. date = fields.Date(string='Base date',
  812. help='Report base date '
  813. '(leave empty to use current date)')
  814. pivot_date = fields.Date(compute='_compute_pivot_date',
  815. string="Pivot date")
  816. report_id = fields.Many2one('mis.report',
  817. required=True,
  818. string='Report')
  819. period_ids = fields.One2many('mis.report.instance.period',
  820. 'report_instance_id',
  821. required=True,
  822. string='Periods',
  823. copy=True)
  824. target_move = fields.Selection([('posted', 'All Posted Entries'),
  825. ('all', 'All Entries')],
  826. string='Target Moves',
  827. required=True,
  828. default='posted')
  829. company_id = fields.Many2one(comodel_name='res.company',
  830. string='Company',
  831. default=_default_company,
  832. required=True)
  833. landscape_pdf = fields.Boolean(string='Landscape PDF')
  834. @api.one
  835. def copy(self, default=None):
  836. default = dict(default or {})
  837. default['name'] = _('%s (copy)') % self.name
  838. return super(MisReportInstance, self).copy(default)
  839. def _format_date(self, lang_id, date):
  840. # format date following user language
  841. date_format = self.env['res.lang'].browse(lang_id).date_format
  842. return datetime.datetime.strftime(
  843. fields.Date.from_string(date), date_format)
  844. @api.multi
  845. def preview(self):
  846. assert len(self) == 1
  847. view_id = self.env.ref('mis_builder.'
  848. 'mis_report_instance_result_view_form')
  849. return {
  850. 'type': 'ir.actions.act_window',
  851. 'res_model': 'mis.report.instance',
  852. 'res_id': self.id,
  853. 'view_mode': 'form',
  854. 'view_type': 'form',
  855. 'view_id': view_id.id,
  856. 'target': 'current',
  857. }
  858. @api.multi
  859. def print_pdf(self):
  860. self.ensure_one()
  861. return {
  862. 'name': 'MIS report instance QWEB PDF report',
  863. 'model': 'mis.report.instance',
  864. 'type': 'ir.actions.report.xml',
  865. 'report_name': 'mis_builder.report_mis_report_instance',
  866. 'report_type': 'qweb-pdf',
  867. 'context': self.env.context,
  868. }
  869. @api.multi
  870. def export_xls(self):
  871. self.ensure_one()
  872. return {
  873. 'name': 'MIS report instance XLSX report',
  874. 'model': 'mis.report.instance',
  875. 'type': 'ir.actions.report.xml',
  876. 'report_name': 'mis.report.instance.xlsx',
  877. 'report_type': 'xlsx',
  878. 'context': self.env.context,
  879. }
  880. @api.multi
  881. def display_settings(self):
  882. assert len(self.ids) <= 1
  883. view_id = self.env.ref('mis_builder.mis_report_instance_view_form')
  884. return {
  885. 'type': 'ir.actions.act_window',
  886. 'res_model': 'mis.report.instance',
  887. 'res_id': self.id if self.id else False,
  888. 'view_mode': 'form',
  889. 'view_type': 'form',
  890. 'views': [(view_id.id, 'form')],
  891. 'view_id': view_id.id,
  892. 'target': 'current',
  893. }
  894. @api.multi
  895. def compute(self):
  896. self.ensure_one()
  897. aep = self.report_id._prepare_aep(self.company_id)
  898. # fetch user language only once
  899. # TODO: is this necessary?
  900. lang = self.env.user.lang
  901. if not lang:
  902. lang = 'en_US'
  903. lang_id = self.env['res.lang'].search([('code', '=', lang)]).id
  904. # compute kpi values for each period
  905. kpi_values_by_period_ids = {}
  906. kpi_matrix = KpiMatrix()
  907. for period in self.period_ids:
  908. if not period.valid:
  909. continue
  910. kpi_values = period._compute(kpi_matrix, lang_id, aep)
  911. kpi_values_by_period_ids[period.id] = kpi_values
  912. # prepare header and content
  913. header = [{
  914. 'kpi_name': '',
  915. 'cols': []
  916. }, {
  917. 'kpi_name': '',
  918. 'cols': []
  919. }]
  920. content = []
  921. rows_by_kpi_name = {}
  922. for kpi_name, kpi in kpi_matrix.iter_kpis():
  923. rows_by_kpi_name[kpi_name] = {
  924. # TODO FIXME
  925. 'kpi_name': kpi.description if ':' not in kpi_name else kpi_name,
  926. 'cols': [],
  927. 'default_style': kpi.default_css_style
  928. }
  929. content.append(rows_by_kpi_name[kpi_name])
  930. # populate header and content
  931. for period in self.period_ids:
  932. if not period.valid:
  933. continue
  934. # add the column header
  935. if period.duration > 1 or period.type == 'w':
  936. # from, to
  937. date_from = self._format_date(lang_id, period.date_from)
  938. date_to = self._format_date(lang_id, period.date_to)
  939. header_date = _('from %s to %s') % (date_from, date_to)
  940. else:
  941. header_date = self._format_date(lang_id, period.date_from)
  942. subkpis = period.subkpi_ids or \
  943. period.report_instance_id.report_id.subkpi_ids
  944. header[0]['cols'].append(dict(
  945. name=period.name,
  946. date=header_date,
  947. colspan=len(subkpis) or 1,
  948. ))
  949. if subkpis:
  950. for subkpi in subkpis:
  951. header[1]['cols'].append(dict(
  952. name=subkpi.name,
  953. colspan=1,
  954. ))
  955. else:
  956. header[1]['cols'].append(dict(
  957. name="",
  958. colspan=1,
  959. ))
  960. # add kpi values
  961. kpi_values = kpi_values_by_period_ids[period.id]
  962. for kpi_name in kpi_values:
  963. rows_by_kpi_name[kpi_name]['cols'] += kpi_values[kpi_name]
  964. # add comparison columns
  965. for compare_col in period.comparison_column_ids:
  966. compare_kpi_values = \
  967. kpi_values_by_period_ids.get(compare_col.id)
  968. if compare_kpi_values:
  969. # add the comparison column header
  970. header[0]['cols'].append(
  971. dict(name=_('%s vs %s') % (period.name,
  972. compare_col.name),
  973. date=''))
  974. # add comparison values
  975. for kpi in self.report_id.kpi_ids:
  976. rows_by_kpi_name[kpi.name]['cols'].append({
  977. 'val_r': kpi.render_comparison(
  978. lang_id,
  979. kpi_values[kpi.name]['val'],
  980. compare_kpi_values[kpi.name]['val'],
  981. period.normalize_factor,
  982. compare_col.normalize_factor)
  983. })
  984. return {
  985. 'header': header,
  986. 'content': content,
  987. }