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.

344 lines
15 KiB

  1. import re
  2. from collections import defaultdict
  3. from openerp.osv import expression
  4. from openerp.tools.safe_eval import safe_eval
  5. MODE_VARIATION = 'p'
  6. MODE_INITIAL = 'i'
  7. MODE_END = 'e'
  8. class AccountingExpressionProcessor(object):
  9. """ Processor for accounting expressions.
  10. Expressions of the form <field><mode>[accounts][optional move line domain]
  11. are supported, where:
  12. * field is bal, crd, deb
  13. * mode is i (initial balance), e (ending balance),
  14. p (moves over period)
  15. * accounts is a list of accounts, possibly containing % wildcards
  16. * an optional domain on analytic lines allowing filters on eg analytic
  17. accounts or journal
  18. Examples:
  19. * bal[70]: balance of moves on account 70 over the period
  20. (it is the same as balp[70]);
  21. * bali[70,60]: initial balance of accounts 70 and 60;
  22. * bale[1%]: balance of accounts starting with 1 at end of period.
  23. How to use:
  24. * repeatedly invoke parse_expr() for each expression containing
  25. accounting variables as described above; this let the processor
  26. group domains and modes and accounts;
  27. * when all expressions have been parsed, invoke done_parsing()
  28. to notify the processor that it can prepare to query (mainly
  29. search all accounts - children, consolidation - that will need to
  30. be queried;
  31. * for each period, call do_queries(), then call replace_expr() for each
  32. expression to replace accounting variables with their resulting value
  33. for the given period.
  34. How it works:
  35. * by accumulating the expressions before hand, it ensure to do the
  36. strict minimum number of queries to the database (for each period,
  37. one query per domain and mode);
  38. * it queries using the orm read_group which reduces to a query with
  39. sum on debit and credit and group by on account_id (note: it seems
  40. the orm then does one query per account to fetch the account
  41. name...);
  42. * additionally, one query per view/consolidation account is done to
  43. discover the children accounts.
  44. """
  45. ACC_RE = re.compile(r"(?P<field>\bbal|\bcrd|\bdeb)"
  46. r"(?P<mode>[pise])?"
  47. r"(?P<accounts>_[0-9]+|\[.*?\])"
  48. r"(?P<domain>\[.*?\])?")
  49. def __init__(self, env):
  50. self.env = env
  51. # before done_parsing: {(domain, mode): set(account_codes)}
  52. # after done_parsing: {(domain, mode): set(account_ids)}
  53. self._map_account_ids = defaultdict(set)
  54. self._set_all_accounts = set() # set((domain, mode))
  55. self._account_ids_by_code = defaultdict(set)
  56. def _load_account_codes(self, account_codes, account_domain):
  57. account_model = self.env['account.account']
  58. # TODO: account_obj is necessary because _get_children_and_consol
  59. # does not work in new API?
  60. account_obj = self.env.registry('account.account')
  61. exact_codes = set()
  62. like_codes = set()
  63. for account_code in account_codes:
  64. if account_code in self._account_ids_by_code:
  65. continue
  66. if '%' in account_code:
  67. like_codes.add(account_code)
  68. else:
  69. exact_codes.add(account_code)
  70. for account in account_model.\
  71. search([('code', 'in', list(exact_codes))] + account_domain):
  72. if account.type in ('view', 'consolidation'):
  73. self._account_ids_by_code[account.code].update(
  74. account_obj._get_children_and_consol(
  75. self.env.cr, self.env.uid,
  76. [account.id],
  77. self.env.context))
  78. else:
  79. self._account_ids_by_code[account.code].add(account.id)
  80. for like_code in like_codes:
  81. for account in account_model.\
  82. search([('code', 'like', like_code)] + account_domain):
  83. if account.type in ('view', 'consolidation'):
  84. self._account_ids_by_code[like_code].update(
  85. account_obj._get_children_and_consol(
  86. self.env.cr, self.env.uid,
  87. [account.id],
  88. self.env.context))
  89. else:
  90. self._account_ids_by_code[like_code].add(account.id)
  91. def _parse_match_object(self, mo):
  92. """Split a match object corresponding to an accounting variable
  93. Returns field, mode, [account codes], [domain expression].
  94. """
  95. field, mode, account_codes, domain = mo.groups()
  96. if not mode:
  97. mode = MODE_VARIATION
  98. elif mode == 's':
  99. mode = MODE_END
  100. if account_codes.startswith('_'):
  101. account_codes = account_codes[1:]
  102. else:
  103. account_codes = account_codes[1:-1]
  104. if account_codes.strip():
  105. account_codes = [a.strip() for a in account_codes.split(',')]
  106. else:
  107. account_codes = None
  108. domain = domain or '[]'
  109. domain = tuple(safe_eval(domain))
  110. return field, mode, account_codes, domain
  111. def parse_expr(self, expr):
  112. """Parse an expression, extracting accounting variables.
  113. Domains and accounts are extracted and stored in the map
  114. so when all expressions have been parsed, we know what to query.
  115. """
  116. for mo in self.ACC_RE.finditer(expr):
  117. _, mode, account_codes, domain = self._parse_match_object(mo)
  118. key = (domain, mode)
  119. if account_codes:
  120. self._map_account_ids[key].update(account_codes)
  121. else:
  122. self._set_all_accounts.add(key)
  123. def done_parsing(self, account_domain):
  124. # load account codes and replace account codes by account ids in _map
  125. for key, account_codes in self._map_account_ids.items():
  126. self._load_account_codes(account_codes, account_domain)
  127. account_ids = set()
  128. for account_code in account_codes:
  129. account_ids.update(self._account_ids_by_code[account_code])
  130. self._map_account_ids[key] = list(account_ids)
  131. def get_aml_domain_for_expr(self, expr):
  132. """ Get a domain on account.move.line for an expression.
  133. Only accounting expression in mode 'p' and 'e' are considered.
  134. Prerequisite: done_parsing() must have been invoked.
  135. Returns a domain that can be used to search on account.move.line.
  136. """
  137. aml_domains = []
  138. for mo in self.ACC_RE.finditer(expr):
  139. field, mode, account_codes, domain = self._parse_match_object(mo)
  140. if mode == MODE_INITIAL:
  141. continue
  142. aml_domain = list(domain)
  143. if account_codes:
  144. account_ids = set()
  145. for account_code in account_codes:
  146. account_ids.update(self._account_ids_by_code[account_code])
  147. aml_domain.append(('account_id', 'in', tuple(account_ids)))
  148. if field == 'crd':
  149. aml_domain.append(('credit', '>', 0))
  150. elif field == 'deb':
  151. aml_domain.append(('debit', '>', 0))
  152. aml_domains.append(expression.normalize_domain(aml_domain))
  153. return expression.OR(aml_domains)
  154. def get_aml_domain_for_dates(self, date_start, date_end, mode):
  155. if mode != MODE_VARIATION:
  156. raise RuntimeError("") # TODO
  157. return [('date', '>=', date_start), ('date', '<=', date_end)]
  158. def _period_has_moves(self, period):
  159. move_model = self.env['account.move']
  160. return bool(move_model.search([('period_id', '=', period.id)],
  161. limit=1))
  162. def _get_previous_opening_period(self, period, company_id):
  163. period_model = self.env['account.period']
  164. periods = period_model.search(
  165. [('date_start', '<=', period.date_start),
  166. ('special', '=', True),
  167. ('company_id', '=', company_id)],
  168. order="date_start desc",
  169. limit=1)
  170. return periods and periods[0]
  171. def _get_previous_normal_period(self, period, company_id):
  172. period_model = self.env['account.period']
  173. periods = period_model.search(
  174. [('date_start', '<', period.date_start),
  175. ('special', '=', False),
  176. ('company_id', '=', company_id)],
  177. order="date_start desc",
  178. limit=1)
  179. return periods and periods[0]
  180. def _get_first_normal_period(self, company_id):
  181. period_model = self.env['account.period']
  182. periods = period_model.search(
  183. [('special', '=', False),
  184. ('company_id', '=', company_id)],
  185. order="date_start asc",
  186. limit=1)
  187. return periods and periods[0]
  188. def _get_period_ids_between(self, period_from, period_to, company_id):
  189. period_model = self.env['account.period']
  190. periods = period_model.search(
  191. [('date_start', '>=', period_from.date_start),
  192. ('date_stop', '<=', period_to.date_stop),
  193. ('special', '=', False),
  194. ('company_id', '=', company_id)])
  195. period_ids = [p.id for p in periods]
  196. if period_from.special:
  197. period_ids.append(period_from.id)
  198. return period_ids
  199. def _get_period_company_ids(self, period_from, period_to):
  200. period_model = self.env['account.period']
  201. periods = period_model.search(
  202. [('date_start', '>=', period_from.date_start),
  203. ('date_stop', '<=', period_to.date_stop),
  204. ('special', '=', False)])
  205. return set([p.company_id.id for p in periods])
  206. def _get_period_ids_for_mode(self, period_from, period_to, mode):
  207. assert not period_from.special
  208. assert not period_to.special
  209. assert period_from.company_id == period_to.company_id
  210. assert period_from.date_start <= period_to.date_start
  211. period_ids = []
  212. for company_id in self._get_period_company_ids(period_from, period_to):
  213. if mode == MODE_VARIATION:
  214. period_ids.extend(self._get_period_ids_between(
  215. period_from, period_to, company_id))
  216. else:
  217. if mode == MODE_INITIAL:
  218. period_to = self._get_previous_normal_period(
  219. period_from, company_id)
  220. # look for opening period with moves
  221. opening_period = self._get_previous_opening_period(
  222. period_from, company_id)
  223. if opening_period and \
  224. self._period_has_moves(opening_period[0]):
  225. # found opening period with moves
  226. if opening_period.date_start == period_from.date_start and \
  227. mode == MODE_INITIAL:
  228. # if the opening period has the same start date as
  229. # period_from, the we'll find the initial balance
  230. # in the initial period and that's it
  231. period_ids.append(opening_period[0].id)
  232. continue
  233. period_from = opening_period[0]
  234. else:
  235. # no opening period with moves,
  236. # use very first normal period
  237. period_from = self._get_first_normal_period(company_id)
  238. if period_to:
  239. period_ids.extend(self._get_period_ids_between(
  240. period_from, period_to, company_id))
  241. return period_ids
  242. def get_aml_domain_for_periods(self, period_from, period_to, mode):
  243. period_ids = self._get_period_ids_for_mode(
  244. period_from, period_to, mode)
  245. return [('period_id', 'in', period_ids)]
  246. def do_queries(self, period_domain, period_domain_i, period_domain_e):
  247. aml_model = self.env['account.move.line']
  248. # {(domain, mode): {account_id: (debit, credit)}}
  249. self._data = defaultdict(dict)
  250. # fetch sum of debit/credit, grouped by account_id
  251. for key in self._map_account_ids:
  252. domain, mode = key
  253. if mode == MODE_VARIATION:
  254. domain = list(domain) + period_domain
  255. elif mode == MODE_INITIAL:
  256. domain = list(domain) + period_domain_i
  257. elif mode == MODE_END:
  258. domain = list(domain) + period_domain_e
  259. else:
  260. raise RuntimeError("unexpected mode %s" % (mode,))
  261. domain.append(('account_id', 'in', self._map_account_ids[key]))
  262. accs = aml_model.read_group(domain,
  263. ['debit', 'credit', 'account_id'],
  264. ['account_id'])
  265. for acc in accs:
  266. self._data[key][acc['account_id'][0]] = \
  267. (acc['debit'] or 0.0, acc['credit'] or 0.0)
  268. # fetch sum of debit/credit for expressions with no account
  269. for key in self._set_all_accounts:
  270. domain, mode = key
  271. if mode == MODE_VARIATION:
  272. domain = list(domain) + period_domain
  273. elif mode == MODE_INITIAL:
  274. domain = list(domain) + period_domain_i
  275. elif mode == MODE_END:
  276. domain = list(domain) + period_domain_e
  277. else:
  278. raise RuntimeError("unexpected mode %s" % (mode,))
  279. accs = aml_model.read_group(domain,
  280. ['debit', 'credit'],
  281. [])
  282. assert len(accs) == 1
  283. self._data[key][None] = \
  284. (accs[0]['debit'] or 0.0, accs[0]['credit'] or 0.0)
  285. def replace_expr(self, expr):
  286. """Replace accounting variables in an expression by their amount.
  287. Returns a new expression.
  288. This method must be executed after do_queries().
  289. """
  290. def f(mo):
  291. field, mode, account_codes, domain = self._parse_match_object(mo)
  292. key = (domain, mode)
  293. account_ids_data = self._data[key]
  294. v = 0.0
  295. for account_code in account_codes or [None]:
  296. if account_code:
  297. account_ids = self._account_ids_by_code[account_code]
  298. else:
  299. account_ids = [None]
  300. for account_id in account_ids:
  301. debit, credit = \
  302. account_ids_data.get(account_id, (0.0, 0.0))
  303. if field == 'bal':
  304. v += debit - credit
  305. elif field == 'deb':
  306. v += debit
  307. elif field == 'crd':
  308. v += credit
  309. return '(' + repr(v) + ')'
  310. return self.ACC_RE.sub(f, expr)