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.

174 lines
7.5 KiB

  1. import re
  2. from collections import defaultdict
  3. class AccountingExpressionProcessor(object):
  4. """ Processor for accounting expressions.
  5. Expressions of the form <field><mode>[accounts][optional move line domain]
  6. are supported, where:
  7. * field is bal, crd, deb
  8. * mode is i (initial balance), e (ending balance), p (moves over period)
  9. * accounts is a list of accounts, possibly containing % wildcards
  10. * an optional domain on analytic lines allowing filters on eg analytic
  11. accounts or journal
  12. Examples:
  13. * bal[70]: balance of moves on account 70 over the period
  14. (it is the same as balp[70]);
  15. * bali[70,60]: initial balance of accounts 70 and 60;
  16. * bale[1%]: balance of accounts starting with 1 at end of period.
  17. How to use:
  18. * repeatedly invoke parse_expr() for each expression containing
  19. accounting variables as described above; this let the processor
  20. group domains and modes and accounts;
  21. * when all expressions have been parsed, invoke done_parsing()
  22. to notify the processor that he can prepare to query (mainly
  23. search all accounts - children, consolidation - that will need to
  24. be queried;
  25. * for each period, call do_queries(), the call replace_expr() for each
  26. expression to replace accounting variables with their resulting value
  27. for the given period.
  28. How it works:
  29. * by accumulating the expressions before hand, it ensure to do the
  30. strict minimum number of queries to the database (for each period,
  31. one query per domain and mode);
  32. * it queries using the orm read_group which reduces to a query with
  33. sum on debit and credit and group by on account_id (note: it seems
  34. the orm then does one query per account to fetch the account name...);
  35. * additionally, one query per view/consolidation account is done to
  36. discover the children accounts.
  37. """
  38. ACC_RE = re.compile(r"(?P<field>\bbal|\bcrd|\bdeb)"
  39. r"(?P<mode>[pise])?"
  40. r"(?P<accounts>_[0-9]+|\[.*?\])"
  41. r"(?P<domain>\[.*?\])?")
  42. def __init__(self, env):
  43. self.env = env
  44. self._map = defaultdict(set) # {(domain, mode): set(account_ids)}
  45. self._account_ids_by_code = defaultdict(set)
  46. def _load_account_codes(self, account_codes, account_domain):
  47. account_model = self.env['account.account']
  48. # TODO: account_obj is necessary because _get_children_and_consol
  49. # does not work in new API?
  50. account_obj = self.env.registry('account.account')
  51. exact_codes = set()
  52. like_codes = set()
  53. for account_code in account_codes:
  54. if account_code in self._account_ids_by_code:
  55. continue
  56. if '%' in account_code:
  57. like_codes.add(account_code)
  58. else:
  59. exact_codes.add(account_code)
  60. for account in account_model.\
  61. search([('code', 'in', list(exact_codes))] + account_domain):
  62. if account.type in ('view', 'consolidation'):
  63. self._account_ids_by_code[account.code].update(
  64. account_obj._get_children_and_consol(
  65. self.env.cr, self.env.uid,
  66. [account.id],
  67. self.env.context))
  68. else:
  69. self._account_ids_by_code[account.code].add(account.id)
  70. for like_code in like_codes:
  71. for account in account_model.\
  72. search([('code', 'like', like_code)] + account_domain):
  73. if account.type in ('view', 'consolidation'):
  74. self._account_ids_by_code[like_code].update(
  75. account_obj._get_children_and_consol(
  76. self.env.cr, self.env.uid,
  77. [account.id],
  78. self.env.context))
  79. else:
  80. self._account_ids_by_code[like_code].add(account.id)
  81. def _parse_mo(self, mo):
  82. """Split a match object corresponding to an accounting variable
  83. Returns field, mode, [account codes], [domain expression].
  84. """
  85. field, mode, account_codes, domain = mo.groups()
  86. if not mode:
  87. mode = 'p'
  88. elif mode == 's':
  89. mode = 'e'
  90. if account_codes.startswith('_'):
  91. account_codes = account_codes[1:]
  92. else:
  93. account_codes = account_codes[1:-1]
  94. account_codes = [a.strip() for a in account_codes.split(',')]
  95. domain = domain or '[]'
  96. domain = tuple(eval(domain)) # TODO: safe_eval
  97. return field, mode, account_codes, domain
  98. def parse_expr(self, expr):
  99. """Parse an expression, extracting accounting variables.
  100. Domains and accounts are extracted and stored in the map
  101. so when all expressions have been parsed, we know what to query.
  102. """
  103. for mo in self.ACC_RE.finditer(expr):
  104. field, mode, account_codes, domain = self._parse_mo(mo)
  105. key = (domain, mode)
  106. self._map[key].update(account_codes)
  107. def done_parsing(self, account_domain):
  108. # load account codes and replace account codes by account ids in _map
  109. for key, account_codes in self._map.items():
  110. self._load_account_codes(account_codes, account_domain)
  111. account_ids = set()
  112. for account_code in account_codes:
  113. account_ids.update(self._account_ids_by_code[account_code])
  114. self._map[key] = list(account_ids)
  115. def do_queries(self, period_domain, period_domain_i, period_domain_e):
  116. aml_model = self.env['account.move.line']
  117. self._data = {} # {(domain, mode): {account_id: (debit, credit)}}
  118. for key in self._map:
  119. self._data[key] = {}
  120. domain, mode = key
  121. if mode == 'p':
  122. domain = list(domain) + period_domain
  123. elif mode == 'i':
  124. domain = list(domain) + period_domain_i
  125. elif mode == 'e':
  126. domain = list(domain) + period_domain_e
  127. else:
  128. raise RuntimeError("unexpected mode %s" % (mode,))
  129. domain = [('account_id', 'in', self._map[key])] + domain
  130. accs = aml_model.read_group(domain,
  131. ['debit', 'credit', 'account_id'],
  132. ['account_id'])
  133. for acc in accs:
  134. self._data[key][acc['account_id'][0]] = \
  135. (acc['debit'], acc['credit'])
  136. def replace_expr(self, expr):
  137. """Replace accounting variables in an expression by their amount.
  138. Returns a new expression.
  139. This method must be executed after do_queries().
  140. """
  141. def f(mo):
  142. field, mode, account_codes, domain = self._parse_mo(mo)
  143. key = (domain, mode)
  144. account_ids_data = self._data[key]
  145. v = 0.0
  146. for account_code in account_codes:
  147. for account_id in self._account_ids_by_code[account_code]:
  148. debit, credit = account_ids_data.get(account_id, (0.0, 0.0))
  149. if field == 'deb':
  150. v += debit
  151. elif field == 'crd':
  152. v += credit
  153. elif field == 'bal':
  154. v += debit - credit
  155. return '(' + repr(v) + ')'
  156. return self.ACC_RE.sub(f, expr)