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.

430 lines
19 KiB

9 years ago
  1. # -*- coding: utf-8 -*-
  2. ##############################################################################
  3. #
  4. # mis_builder module for Odoo, Management Information System Builder
  5. # Copyright (C) 2014-2015 ACSONE SA/NV (<http://acsone.eu>)
  6. #
  7. # This file is a part of mis_builder
  8. #
  9. # mis_builder is free software: you can redistribute it and/or modify
  10. # it under the terms of the GNU Affero General Public License v3 or later
  11. # as published by the Free Software Foundation, either version 3 of the
  12. # License, or (at your option) any later version.
  13. #
  14. # mis_builder is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU Affero General Public License v3 or later for more details.
  18. #
  19. # You should have received a copy of the GNU Affero General Public License
  20. # v3 or later along with this program.
  21. # If not, see <http://www.gnu.org/licenses/>.
  22. #
  23. ##############################################################################
  24. import re
  25. from collections import defaultdict
  26. from openerp.exceptions import Warning
  27. from openerp import pooler
  28. from openerp.osv import expression
  29. from openerp.tools.safe_eval import safe_eval
  30. from openerp.tools.translate import _
  31. MODE_VARIATION = 'p'
  32. MODE_INITIAL = 'i'
  33. MODE_END = 'e'
  34. class AccountingExpressionProcessor(object):
  35. """ Processor for accounting expressions.
  36. Expressions of the form <field><mode>[accounts][optional move line domain]
  37. are supported, where:
  38. * field is bal, crd, deb
  39. * mode is i (initial balance), e (ending balance),
  40. p (moves over period)
  41. * accounts is a list of accounts, possibly containing % wildcards
  42. * an optional domain on move lines allowing filters on eg analytic
  43. accounts or journal
  44. Examples:
  45. * bal[70]: variation of the balance of moves on account 70
  46. over the period (it is the same as balp[70]);
  47. * bali[70,60]: balance of accounts 70 and 60 at the start of period;
  48. * bale[1%]: balance of accounts starting with 1 at end of period.
  49. How to use:
  50. * repeatedly invoke parse_expr() for each expression containing
  51. accounting variables as described above; this lets the processor
  52. group domains and modes and accounts;
  53. * when all expressions have been parsed, invoke done_parsing()
  54. to notify the processor that it can prepare to query (mainly
  55. search all accounts - children, consolidation - that will need to
  56. be queried;
  57. * for each period, call do_queries(), then call replace_expr() for each
  58. expression to replace accounting variables with their resulting value
  59. for the given period.
  60. How it works:
  61. * by accumulating the expressions before hand, it ensures to do the
  62. strict minimum number of queries to the database (for each period,
  63. one query per domain and mode);
  64. * it queries using the orm read_group which reduces to a query with
  65. sum on debit and credit and group by on account_id (note: it seems
  66. the orm then does one query per account to fetch the account
  67. name...);
  68. * additionally, one query per view/consolidation account is done to
  69. discover the children accounts.
  70. """
  71. ACC_RE = re.compile(r"(?P<field>\bbal|\bcrd|\bdeb)"
  72. r"(?P<mode>[pise])?"
  73. r"(?P<accounts>_[a-zA-Z0-9]+|\[.*?\])"
  74. r"(?P<domain>\[.*?\])?")
  75. def __init__(self, cursor):
  76. self.pool = pooler.get_pool(cursor.dbname)
  77. # before done_parsing: {(domain, mode): set(account_codes)}
  78. # after done_parsing: {(domain, mode): list(account_ids)}
  79. self._map_account_ids = defaultdict(set)
  80. self._account_ids_by_code = defaultdict(set)
  81. def _load_account_codes(self, cr, uid, account_codes, root_account,
  82. context=None):
  83. account_obj = self.pool['account.account']
  84. exact_codes = set()
  85. like_codes = set()
  86. for account_code in account_codes:
  87. if account_code in self._account_ids_by_code:
  88. continue
  89. if account_code is None:
  90. # by convention the root account is keyed as
  91. # None in _account_ids_by_code, so it is consistent
  92. # with what _parse_match_object returns for an
  93. # empty list of account codes, ie [None]
  94. exact_codes.add(root_account.code)
  95. elif '%' in account_code:
  96. like_codes.add(account_code)
  97. else:
  98. exact_codes.add(account_code)
  99. account_ids = account_obj.search(
  100. cr, uid,
  101. [('code', 'in', list(exact_codes)),
  102. ('parent_id', 'child_of', root_account.id)],
  103. context=context)
  104. for account in account_obj.browse(
  105. cr, uid, account_ids, context=context):
  106. if account.code == root_account.code:
  107. code = None
  108. else:
  109. code = account.code
  110. if account.type in ('view', 'consolidation'):
  111. self._account_ids_by_code[code].update(
  112. account_obj._get_children_and_consol(
  113. cr, uid,
  114. [account.id],
  115. context=context))
  116. else:
  117. self._account_ids_by_code[code].add(account.id)
  118. for like_code in like_codes:
  119. for account_id in account_obj.\
  120. search(cr, uid,
  121. [('code', 'like', like_code),
  122. ('parent_id', 'child_of', root_account.id)],
  123. context=context):
  124. account = account_obj.browse(cr, uid, account_id,
  125. context=context)
  126. if account.type in ('view', 'consolidation'):
  127. self._account_ids_by_code[like_code].update(
  128. account_obj._get_children_and_consol(
  129. cr, uid, [account.id], context=context))
  130. else:
  131. self._account_ids_by_code[like_code].add(account.id)
  132. def _parse_match_object(self, mo):
  133. """Split a match object corresponding to an accounting variable
  134. Returns field, mode, [account codes], (domain expression).
  135. """
  136. field, mode, account_codes, domain = mo.groups()
  137. if not mode:
  138. mode = MODE_VARIATION
  139. elif mode == 's':
  140. mode = MODE_END
  141. if account_codes.startswith('_'):
  142. account_codes = account_codes[1:]
  143. else:
  144. account_codes = account_codes[1:-1]
  145. if account_codes.strip():
  146. account_codes = [a.strip() for a in account_codes.split(',')]
  147. else:
  148. account_codes = [None]
  149. domain = domain or '[]'
  150. domain = tuple(safe_eval(domain))
  151. return field, mode, account_codes, domain
  152. def parse_expr(self, expr):
  153. """Parse an expression, extracting accounting variables.
  154. Domains and accounts are extracted and stored in the map
  155. so when all expressions have been parsed, we know which
  156. account codes to query for each domain and mode.
  157. """
  158. for mo in self.ACC_RE.finditer(expr):
  159. _, mode, account_codes, domain = self._parse_match_object(mo)
  160. key = (domain, mode)
  161. self._map_account_ids[key].update(account_codes)
  162. def done_parsing(self, cr, uid, root_account, context=None):
  163. """Load account codes and replace account codes by
  164. account ids in map."""
  165. for key, account_codes in self._map_account_ids.items():
  166. self._load_account_codes(cr, uid, account_codes, root_account,
  167. context=context)
  168. account_ids = set()
  169. for account_code in account_codes:
  170. account_ids.update(self._account_ids_by_code[account_code])
  171. self._map_account_ids[key] = list(account_ids)
  172. @classmethod
  173. def has_account_var(cls, expr):
  174. """Test if an string contains an accounting variable."""
  175. return bool(cls.ACC_RE.search(expr))
  176. def get_aml_domain_for_expr(self, cr, uid, expr,
  177. date_from, date_to,
  178. period_from, period_to,
  179. target_move, context=None):
  180. """ Get a domain on account.move.line for an expression.
  181. Prerequisite: done_parsing() must have been invoked.
  182. Returns a domain that can be used to search on account.move.line.
  183. """
  184. aml_domains = []
  185. date_domain_by_mode = {}
  186. for mo in self.ACC_RE.finditer(expr):
  187. field, mode, account_codes, domain = self._parse_match_object(mo)
  188. aml_domain = list(domain)
  189. account_ids = set()
  190. for account_code in account_codes:
  191. account_ids.update(self._account_ids_by_code[account_code])
  192. aml_domain.append(('account_id', 'in', tuple(account_ids)))
  193. if field == 'crd':
  194. aml_domain.append(('credit', '>', 0))
  195. elif field == 'deb':
  196. aml_domain.append(('debit', '>', 0))
  197. aml_domains.append(expression.normalize_domain(aml_domain))
  198. if mode not in date_domain_by_mode:
  199. date_domain_by_mode[mode] = \
  200. self.get_aml_domain_for_dates(cr, uid,
  201. date_from, date_to,
  202. period_from, period_to,
  203. mode, target_move,
  204. context=context)
  205. return expression.OR(aml_domains) + \
  206. expression.OR(date_domain_by_mode.values())
  207. def _period_has_moves(self, cr, uid, period, context=None):
  208. move_model = self.pool['account.move']
  209. return bool(move_model.search(cr, uid,
  210. [('period_id', '=', period.id)],
  211. limit=1, context=context))
  212. def _get_previous_opening_period(self, cr, uid, period, company_id,
  213. context=None):
  214. period_model = self.pool['account.period']
  215. period_ids = period_model.search(
  216. cr, uid,
  217. [('date_start', '<=', period.date_start),
  218. ('special', '=', True),
  219. ('company_id', '=', company_id)],
  220. order="date_start desc",
  221. limit=1,
  222. context=context)
  223. periods = period_model.browse(cr, uid, period_ids, context=context)
  224. return periods and periods[0]
  225. def _get_previous_normal_period(self, cr, uid, period, company_id,
  226. context=None):
  227. period_model = self.pool['account.period']
  228. period_ids = period_model.search(
  229. cr, uid,
  230. [('date_start', '<', period.date_start),
  231. ('special', '=', False),
  232. ('company_id', '=', company_id)],
  233. order="date_start desc",
  234. limit=1,
  235. context=context)
  236. periods = period_model.browse(cr, uid, period_ids, context=context)
  237. return periods and periods[0]
  238. def _get_first_normal_period(self, cr, uid, company_id, context=None):
  239. period_model = self.pool['account.period']
  240. period_ids = period_model.search(
  241. cr, uid,
  242. [('special', '=', False),
  243. ('company_id', '=', company_id)],
  244. order="date_start asc",
  245. limit=1,
  246. context=context)
  247. periods = period_model.browse(cr, uid, period_ids, context=context)
  248. return periods and periods[0]
  249. def _get_period_ids_between(self, cr, uid, period_from, period_to,
  250. company_id, context=None):
  251. period_model = self.pool['account.period']
  252. period_ids = period_model.search(
  253. cr, uid,
  254. [('date_start', '>=', period_from.date_start),
  255. ('date_stop', '<=', period_to.date_stop),
  256. ('special', '=', False),
  257. ('company_id', '=', company_id)],
  258. context=context)
  259. if period_from.special:
  260. period_ids.append(period_from.id)
  261. return period_ids
  262. def _get_period_company_ids(self, cr, uid, period_from, period_to,
  263. context=None):
  264. period_model = self.pool['account.period']
  265. period_ids = period_model.search(
  266. cr, uid,
  267. [('date_start', '>=', period_from.date_start),
  268. ('date_stop', '<=', period_to.date_stop),
  269. ('special', '=', False)],
  270. context=context)
  271. periods = period_model.browse(cr, uid, period_ids, context=context)
  272. return set([p.company_id.id for p in periods])
  273. def _get_period_ids_for_mode(self, cr, uid, period_from, period_to, mode,
  274. context=None):
  275. assert not period_from.special
  276. assert not period_to.special
  277. assert period_from.company_id == period_to.company_id
  278. assert period_from.date_start <= period_to.date_start
  279. period_ids = []
  280. for company_id in self._get_period_company_ids(cr, uid,
  281. period_from, period_to,
  282. context=context):
  283. if mode == MODE_VARIATION:
  284. period_ids.extend(self._get_period_ids_between(
  285. cr, uid,
  286. period_from, period_to, company_id,
  287. context=context))
  288. else:
  289. if mode == MODE_INITIAL:
  290. period_to = self._get_previous_normal_period(
  291. cr, uid,
  292. period_from, company_id,
  293. context=context)
  294. # look for opening period with moves
  295. opening_period = self._get_previous_opening_period(
  296. cr, uid,
  297. period_from, company_id,
  298. context=context)
  299. if opening_period and \
  300. self._period_has_moves(cr, uid, opening_period,
  301. context=context):
  302. # found opening period with moves
  303. if opening_period.date_start == period_from.date_start and\
  304. mode == MODE_INITIAL:
  305. # if the opening period has the same start date as
  306. # period_from, then we'll find the initial balance
  307. # in the initial period and that's it
  308. period_ids.append(opening_period.id)
  309. continue
  310. period_from = opening_period
  311. else:
  312. # no opening period with moves,
  313. # use very first normal period
  314. period_from = self._get_first_normal_period(
  315. cr, uid, company_id, context=context)
  316. if period_to:
  317. period_ids.extend(self._get_period_ids_between(
  318. cr, uid,
  319. period_from, period_to, company_id,
  320. context=context))
  321. return period_ids
  322. def get_aml_domain_for_dates(self, cr, uid, date_from, date_to,
  323. period_from, period_to,
  324. mode,
  325. target_move,
  326. context=None):
  327. if period_from and period_to:
  328. period_ids = self._get_period_ids_for_mode(
  329. cr, uid,
  330. period_from, period_to, mode,
  331. context=context)
  332. domain = [('period_id', 'in', period_ids)]
  333. else:
  334. if mode == MODE_VARIATION:
  335. domain = [('date', '>=', date_from), ('date', '<=', date_to)]
  336. else:
  337. raise Warning(_("Modes i and e are only applicable for "
  338. "fiscal periods"))
  339. if target_move == 'posted':
  340. domain.append(('move_id.state', '=', 'posted'))
  341. return expression.normalize_domain(domain)
  342. def do_queries(self, cr, uid, date_from, date_to, period_from, period_to,
  343. target_move, additional_move_line_filter=None,
  344. context=None):
  345. """Query sums of debit and credit for all accounts and domains
  346. used in expressions.
  347. This method must be executed after done_parsing().
  348. """
  349. aml_model = self.pool['account.move.line']
  350. # {(domain, mode): {account_id: (debit, credit)}}
  351. self._data = defaultdict(dict)
  352. domain_by_mode = {}
  353. for key in self._map_account_ids:
  354. domain, mode = key
  355. if mode not in domain_by_mode:
  356. domain_by_mode[mode] = \
  357. self.get_aml_domain_for_dates(cr, uid,
  358. date_from, date_to,
  359. period_from, period_to,
  360. mode, target_move,
  361. context=context)
  362. domain = list(domain) + domain_by_mode[mode]
  363. domain.append(('account_id', 'in', self._map_account_ids[key]))
  364. if additional_move_line_filter:
  365. domain.extend(additional_move_line_filter)
  366. # fetch sum of debit/credit, grouped by account_id
  367. accs = aml_model.read_group(cr, uid, domain,
  368. ['debit', 'credit', 'account_id'],
  369. ['account_id'],
  370. context=context)
  371. for acc in accs:
  372. self._data[key][acc['account_id'][0]] = \
  373. (acc['debit'] or 0.0, acc['credit'] or 0.0)
  374. def replace_expr(self, expr):
  375. """Replace accounting variables in an expression by their amount.
  376. Returns a new expression string.
  377. This method must be executed after do_queries().
  378. """
  379. def f(mo):
  380. field, mode, account_codes, domain = self._parse_match_object(mo)
  381. key = (domain, mode)
  382. account_ids_data = self._data[key]
  383. v = 0.0
  384. for account_code in account_codes:
  385. account_ids = self._account_ids_by_code[account_code]
  386. for account_id in account_ids:
  387. debit, credit = \
  388. account_ids_data.get(account_id, (0.0, 0.0))
  389. if field == 'bal':
  390. v += debit - credit
  391. elif field == 'deb':
  392. v += debit
  393. elif field == 'crd':
  394. v += credit
  395. return '(' + repr(v) + ')'
  396. return self.ACC_RE.sub(f, expr)