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.

320 lines
14 KiB

9 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. import re
  5. from collections import defaultdict
  6. from openerp import fields
  7. from openerp.models import expression
  8. from openerp.tools.safe_eval import safe_eval
  9. from openerp.tools.float_utils import float_is_zero
  10. from .accounting_none import AccountingNone
  11. MODE_VARIATION = 'p'
  12. MODE_INITIAL = 'i'
  13. MODE_END = 'e'
  14. MODE_UNALLOCATED = 'u'
  15. class AccountingExpressionProcessor(object):
  16. """ Processor for accounting expressions.
  17. Expressions of the form <field><mode>[accounts][optional move line domain]
  18. are supported, where:
  19. * field is bal, crd, deb
  20. * mode is i (initial balance), e (ending balance),
  21. p (moves over period)
  22. * there is also a special u mode (unallocated P&L) which computes
  23. the sum from the beginning until the beginning of the fiscal year
  24. of the period; it is only meaningful for P&L accounts
  25. * accounts is a list of accounts, possibly containing % wildcards
  26. * an optional domain on move lines allowing filters on eg analytic
  27. accounts or journal
  28. Examples:
  29. * bal[70]: variation of the balance of moves on account 70
  30. over the period (it is the same as balp[70]);
  31. * bali[70,60]: balance of accounts 70 and 60 at the start of period;
  32. * bale[1%]: balance of accounts starting with 1 at end of period.
  33. How to use:
  34. * repeatedly invoke parse_expr() for each expression containing
  35. accounting variables as described above; this lets the processor
  36. group domains and modes and accounts;
  37. * when all expressions have been parsed, invoke done_parsing()
  38. to notify the processor that it can prepare to query (mainly
  39. search all accounts - children, consolidation - that will need to
  40. be queried;
  41. * for each period, call do_queries(), then call replace_expr() for each
  42. expression to replace accounting variables with their resulting value
  43. for the given period.
  44. How it works:
  45. * by accumulating the expressions before hand, it ensures to do the
  46. strict minimum number of queries to the database (for each period,
  47. one query per domain and mode);
  48. * it queries using the orm read_group which reduces to a query with
  49. sum on debit and credit and group by on account_id (note: it seems
  50. the orm then does one query per account to fetch the account
  51. name...);
  52. * additionally, one query per view/consolidation account is done to
  53. discover the children accounts.
  54. """
  55. ACC_RE = re.compile(r"(?P<field>\bbal|\bcrd|\bdeb)"
  56. r"(?P<mode>[piseu])?"
  57. r"(?P<accounts>_[a-zA-Z0-9]+|\[.*?\])"
  58. r"(?P<domain>\[.*?\])?")
  59. def __init__(self, env):
  60. self.env = env
  61. # before done_parsing: {(domain, mode): set(account_codes)}
  62. # after done_parsing: {(domain, mode): list(account_ids)}
  63. self._map_account_ids = defaultdict(set)
  64. # {account_code: account_id} where account_code can be
  65. # - None for all accounts
  66. # - NNN% for a like
  67. # - NNN for a code with an exact match
  68. self._account_ids_by_code = defaultdict(set)
  69. def _load_account_codes(self, account_codes, company):
  70. account_model = self.env['account.account']
  71. exact_codes = set()
  72. for account_code in account_codes:
  73. if account_code in self._account_ids_by_code:
  74. continue
  75. if account_code is None:
  76. # None means we want all accounts
  77. account_ids = account_model.\
  78. search([]).ids
  79. self._account_ids_by_code[account_code].update(account_ids)
  80. elif '%' in account_code:
  81. account_ids = account_model.\
  82. search([('code', '=like', account_code),
  83. ('company_id', '=', company.id)]).ids
  84. self._account_ids_by_code[account_code].update(account_ids)
  85. else:
  86. # search exact codes after the loop to do less queries
  87. exact_codes.add(account_code)
  88. for account in account_model.\
  89. search([('code', 'in', list(exact_codes)),
  90. ('company_id', '=', company.id)]):
  91. self._account_ids_by_code[account.code].add(account.id)
  92. def _parse_match_object(self, mo):
  93. """Split a match object corresponding to an accounting variable
  94. Returns field, mode, [account codes], (domain expression).
  95. """
  96. field, mode, account_codes, domain = mo.groups()
  97. if not mode:
  98. mode = MODE_VARIATION
  99. elif mode == 's':
  100. mode = MODE_END
  101. if account_codes.startswith('_'):
  102. account_codes = account_codes[1:]
  103. else:
  104. account_codes = account_codes[1:-1]
  105. if account_codes.strip():
  106. account_codes = [a.strip() for a in account_codes.split(',')]
  107. else:
  108. account_codes = [None] # None means we want all accounts
  109. domain = domain or '[]'
  110. domain = tuple(safe_eval(domain))
  111. return field, mode, account_codes, domain
  112. def parse_expr(self, expr):
  113. """Parse an expression, extracting accounting variables.
  114. Domains and accounts are extracted and stored in the map
  115. so when all expressions have been parsed, we know which
  116. account codes to query for each domain and mode.
  117. """
  118. for mo in self.ACC_RE.finditer(expr):
  119. _, mode, account_codes, domain = self._parse_match_object(mo)
  120. if mode == MODE_END:
  121. modes = (MODE_INITIAL, MODE_VARIATION)
  122. else:
  123. modes = (mode, )
  124. for mode in modes:
  125. key = (domain, mode)
  126. self._map_account_ids[key].update(account_codes)
  127. def done_parsing(self, company):
  128. """Load account codes and replace account codes by
  129. account ids in map."""
  130. for key, account_codes in self._map_account_ids.items():
  131. # TODO _load_account_codes could be done
  132. # for all account_codes at once (also in v8)
  133. self._load_account_codes(account_codes, company)
  134. account_ids = set()
  135. for account_code in account_codes:
  136. account_ids.update(self._account_ids_by_code[account_code])
  137. self._map_account_ids[key] = list(account_ids)
  138. @classmethod
  139. def has_account_var(cls, expr):
  140. """Test if an string contains an accounting variable."""
  141. return bool(cls.ACC_RE.search(expr))
  142. def get_aml_domain_for_expr(self, expr,
  143. date_from, date_to,
  144. target_move, company):
  145. """ Get a domain on account.move.line for an expression.
  146. Prerequisite: done_parsing() must have been invoked.
  147. Returns a domain that can be used to search on account.move.line.
  148. """
  149. aml_domains = []
  150. date_domain_by_mode = {}
  151. for mo in self.ACC_RE.finditer(expr):
  152. field, mode, account_codes, domain = self._parse_match_object(mo)
  153. aml_domain = list(domain)
  154. account_ids = set()
  155. for account_code in account_codes:
  156. account_ids.update(self._account_ids_by_code[account_code])
  157. aml_domain.append(('account_id', 'in', tuple(account_ids)))
  158. if field == 'crd':
  159. aml_domain.append(('credit', '>', 0))
  160. elif field == 'deb':
  161. aml_domain.append(('debit', '>', 0))
  162. aml_domains.append(expression.normalize_domain(aml_domain))
  163. if mode not in date_domain_by_mode:
  164. date_domain_by_mode[mode] = \
  165. self.get_aml_domain_for_dates(date_from, date_to,
  166. mode, target_move,
  167. company)
  168. return expression.OR(aml_domains) + \
  169. expression.OR(date_domain_by_mode.values())
  170. def get_aml_domain_for_dates(self, date_from, date_to,
  171. mode,
  172. target_move, company):
  173. if mode == MODE_VARIATION:
  174. domain = [('date', '>=', date_from), ('date', '<=', date_to)]
  175. elif mode in (MODE_INITIAL, MODE_END):
  176. # for income and expense account, sum from the beginning
  177. # of the current fiscal year only, for balance sheet accounts
  178. # sum from the beginning of time
  179. date_from_date = fields.Date.from_string(date_from)
  180. fy_date_from = \
  181. company.compute_fiscalyear_dates(date_from_date)['date_from']
  182. domain = ['|',
  183. ('date', '>=', fields.Date.to_string(fy_date_from)),
  184. ('user_type_id.include_initial_balance', '=', True)]
  185. if mode == MODE_INITIAL:
  186. domain.append(('date', '<', date_from))
  187. elif mode == MODE_END:
  188. domain.append(('date', '<=', date_to))
  189. elif mode == MODE_UNALLOCATED:
  190. date_from_date = fields.Date.from_string(date_from)
  191. fy_date_from = \
  192. company.compute_fiscalyear_dates(date_from_date)['date_from']
  193. domain = [('date', '<', fields.Date.to_string(fy_date_from)),
  194. ('user_type_id.include_initial_balance', '=', False)]
  195. if target_move == 'posted':
  196. domain.append(('move_id.state', '=', 'posted'))
  197. return expression.normalize_domain(domain)
  198. def do_queries(self, date_from, date_to,
  199. target_move, company, additional_move_line_filter=None):
  200. """Query sums of debit and credit for all accounts and domains
  201. used in expressions.
  202. This method must be executed after done_parsing().
  203. """
  204. aml_model = self.env['account.move.line']
  205. # {(domain, mode): {account_id: (debit, credit)}}
  206. self._data = defaultdict(dict)
  207. domain_by_mode = {}
  208. for key in self._map_account_ids:
  209. domain, mode = key
  210. if mode not in domain_by_mode:
  211. domain_by_mode[mode] = \
  212. self.get_aml_domain_for_dates(date_from, date_to,
  213. mode, target_move, company)
  214. domain = list(domain) + domain_by_mode[mode]
  215. domain.append(('account_id', 'in', self._map_account_ids[key]))
  216. if additional_move_line_filter:
  217. domain.extend(additional_move_line_filter)
  218. # fetch sum of debit/credit, grouped by account_id
  219. accs = aml_model.read_group(domain,
  220. ['debit', 'credit', 'account_id'],
  221. ['account_id'])
  222. for acc in accs:
  223. debit = acc['debit'] or 0.0
  224. credit = acc['credit'] or 0.0
  225. if mode in (MODE_INITIAL, MODE_UNALLOCATED) and \
  226. float_is_zero(debit-credit, precision_rounding=2):
  227. # in initial mode, ignore accounts with 0 balance
  228. continue
  229. self._data[key][acc['account_id'][0]] = (debit, credit)
  230. def replace_expr(self, expr, account_ids_filter=None):
  231. """Replace accounting variables in an expression by their amount.
  232. Returns a new expression string.
  233. This method must be executed after do_queries().
  234. """
  235. def s(field, mode, account_codes, domain):
  236. key = (domain, mode)
  237. account_ids_data = self._data[key]
  238. v = AccountingNone
  239. for account_code in account_codes:
  240. account_ids = self._account_ids_by_code[account_code]
  241. for account_id in account_ids:
  242. if account_ids_filter and \
  243. account_id not in account_ids_filter:
  244. continue
  245. debit, credit = \
  246. account_ids_data.get(account_id,
  247. (AccountingNone, AccountingNone))
  248. if field == 'bal':
  249. v += debit - credit
  250. elif field == 'deb':
  251. v += debit
  252. elif field == 'crd':
  253. v += credit
  254. # in initial balance mode, assume 0 is None
  255. # as it does not make sense to distinguish 0 from "no data"
  256. if v is not AccountingNone and \
  257. mode in (MODE_INITIAL, MODE_UNALLOCATED) and \
  258. float_is_zero(v, precision_rounding=2):
  259. v = AccountingNone
  260. return v
  261. def f(mo):
  262. field, mode, account_codes, domain = self._parse_match_object(mo)
  263. if mode == MODE_END:
  264. # split ending balance in initial+variation, so
  265. # if there is no move in period, we end up with AccountingNone
  266. v = s(field, MODE_INITIAL, account_codes, domain) + \
  267. s(field, MODE_VARIATION, account_codes, domain)
  268. else:
  269. v = s(field, mode, account_codes, domain)
  270. return '(' + repr(v) + ')'
  271. return self.ACC_RE.sub(f, expr)
  272. def get_accounts_in_expr(self, expr):
  273. """Get the ids of all accounts involved in an expression.
  274. This means only accounts which contribute data to the expression.
  275. Returns a set of account ids.
  276. This method must be executed after do_queries().
  277. """
  278. res = set()
  279. for mo in self.ACC_RE.finditer(expr):
  280. _, mode, account_codes, domain = self._parse_match_object(mo)
  281. key = (domain, mode)
  282. account_ids_data = self._data[key]
  283. for account_code in account_codes:
  284. account_ids = self._account_ids_by_code[account_code]
  285. for account_id in account_ids:
  286. if account_id in account_ids_data:
  287. res.add(account_id)
  288. return res