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.

442 lines
19 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 itertools import izip
  7. from openerp import fields
  8. from openerp.models import expression
  9. from openerp.tools.safe_eval import safe_eval
  10. from openerp.tools.float_utils import float_is_zero
  11. from .accounting_none import AccountingNone
  12. class AccountingExpressionProcessor(object):
  13. """ Processor for accounting expressions.
  14. Expressions of the form <field><mode>[accounts][optional move line domain]
  15. are supported, where:
  16. * field is bal, crd, deb
  17. * mode is i (initial balance), e (ending balance),
  18. p (moves over period)
  19. * there is also a special u mode (unallocated P&L) which computes
  20. the sum from the beginning until the beginning of the fiscal year
  21. of the period; it is only meaningful for P&L accounts
  22. * accounts is a list of accounts, possibly containing % wildcards
  23. * an optional domain on move lines allowing filters on eg analytic
  24. accounts or journal
  25. Examples:
  26. * bal[70]: variation of the balance of moves on account 70
  27. over the period (it is the same as balp[70]);
  28. * bali[70,60]: balance of accounts 70 and 60 at the start of period;
  29. * bale[1%]: balance of accounts starting with 1 at end of period.
  30. How to use:
  31. * repeatedly invoke parse_expr() for each expression containing
  32. accounting variables as described above; this lets the processor
  33. group domains and modes and accounts;
  34. * when all expressions have been parsed, invoke done_parsing()
  35. to notify the processor that it can prepare to query (mainly
  36. search all accounts - children, consolidation - that will need to
  37. be queried;
  38. * for each period, call do_queries(), then call replace_expr() for each
  39. expression to replace accounting variables with their resulting value
  40. for the given period.
  41. How it works:
  42. * by accumulating the expressions before hand, it ensures to do the
  43. strict minimum number of queries to the database (for each period,
  44. one query per domain and mode);
  45. * it queries using the orm read_group which reduces to a query with
  46. sum on debit and credit and group by on account_id (note: it seems
  47. the orm then does one query per account to fetch the account
  48. name...);
  49. * additionally, one query per view/consolidation account is done to
  50. discover the children accounts.
  51. """
  52. MODE_VARIATION = 'p'
  53. MODE_INITIAL = 'i'
  54. MODE_END = 'e'
  55. MODE_UNALLOCATED = 'u'
  56. _ACC_RE = re.compile(r"(?P<field>\bbal|\bcrd|\bdeb)"
  57. r"(?P<mode>[piseu])?"
  58. r"(?P<accounts>_[a-zA-Z0-9]+|\[.*?\])"
  59. r"(?P<domain>\[.*?\])?")
  60. def __init__(self, company):
  61. self.company = company
  62. self.dp = company.currency_id.decimal_places
  63. # before done_parsing: {(domain, mode): set(account_codes)}
  64. # after done_parsing: {(domain, mode): list(account_ids)}
  65. self._map_account_ids = defaultdict(set)
  66. # {account_code: account_id} where account_code can be
  67. # - None for all accounts
  68. # - NNN% for a like
  69. # - NNN for a code with an exact match
  70. self._account_ids_by_code = defaultdict(set)
  71. # smart ending balance (returns AccountingNone if there
  72. # are no moves in period and 0 initial balance), implies
  73. # a first query to get the initial balance and another
  74. # to get the variation, so it's a bit slower
  75. self.smart_end = True
  76. def _load_account_codes(self, account_codes):
  77. account_model = self.company.env['account.account']
  78. exact_codes = set()
  79. for account_code in account_codes:
  80. if account_code in self._account_ids_by_code:
  81. continue
  82. if account_code is None:
  83. # None means we want all accounts
  84. account_ids = account_model.\
  85. search([('company_id', '=', self.company.id)]).ids
  86. self._account_ids_by_code[account_code].update(account_ids)
  87. elif '%' in account_code:
  88. account_ids = account_model.\
  89. search([('code', '=like', account_code),
  90. ('company_id', '=', self.company.id)]).ids
  91. self._account_ids_by_code[account_code].update(account_ids)
  92. else:
  93. # search exact codes after the loop to do less queries
  94. exact_codes.add(account_code)
  95. for account in account_model.\
  96. search([('code', 'in', list(exact_codes)),
  97. ('company_id', '=', self.company.id)]):
  98. self._account_ids_by_code[account.code].add(account.id)
  99. def _parse_match_object(self, mo):
  100. """Split a match object corresponding to an accounting variable
  101. Returns field, mode, [account codes], (domain expression).
  102. """
  103. field, mode, account_codes, domain = mo.groups()
  104. if not mode:
  105. mode = self.MODE_VARIATION
  106. elif mode == 's':
  107. mode = self.MODE_END
  108. if account_codes.startswith('_'):
  109. account_codes = account_codes[1:]
  110. else:
  111. account_codes = account_codes[1:-1]
  112. if account_codes.strip():
  113. account_codes = [a.strip() for a in account_codes.split(',')]
  114. else:
  115. account_codes = [None] # None means we want all accounts
  116. domain = domain or '[]'
  117. domain = tuple(safe_eval(domain))
  118. return field, mode, account_codes, domain
  119. def parse_expr(self, expr):
  120. """Parse an expression, extracting accounting variables.
  121. Domains and accounts are extracted and stored in the map
  122. so when all expressions have been parsed, we know which
  123. account codes to query for each domain and mode.
  124. """
  125. for mo in self._ACC_RE.finditer(expr):
  126. _, mode, account_codes, domain = self._parse_match_object(mo)
  127. if mode == self.MODE_END and self.smart_end:
  128. modes = (self.MODE_INITIAL, self.MODE_VARIATION, self.MODE_END)
  129. else:
  130. modes = (mode, )
  131. for mode in modes:
  132. key = (domain, mode)
  133. self._map_account_ids[key].update(account_codes)
  134. def done_parsing(self):
  135. """Load account codes and replace account codes by
  136. account ids in map."""
  137. for key, account_codes in self._map_account_ids.items():
  138. # TODO _load_account_codes could be done
  139. # for all account_codes at once (also in v8)
  140. self._load_account_codes(account_codes)
  141. account_ids = set()
  142. for account_code in account_codes:
  143. account_ids.update(self._account_ids_by_code[account_code])
  144. self._map_account_ids[key] = list(account_ids)
  145. @classmethod
  146. def has_account_var(cls, expr):
  147. """Test if an string contains an accounting variable."""
  148. return bool(cls._ACC_RE.search(expr))
  149. def get_aml_domain_for_expr(self, expr,
  150. date_from, date_to,
  151. target_move,
  152. account_id=None):
  153. """ Get a domain on account.move.line for an expression.
  154. Prerequisite: done_parsing() must have been invoked.
  155. Returns a domain that can be used to search on account.move.line.
  156. """
  157. aml_domains = []
  158. date_domain_by_mode = {}
  159. for mo in self._ACC_RE.finditer(expr):
  160. field, mode, account_codes, domain = self._parse_match_object(mo)
  161. aml_domain = list(domain)
  162. account_ids = set()
  163. for account_code in account_codes:
  164. account_ids.update(self._account_ids_by_code[account_code])
  165. if not account_id:
  166. aml_domain.append(('account_id', 'in', tuple(account_ids)))
  167. else:
  168. # filter on account_id
  169. if account_id in account_ids:
  170. aml_domain.append(('account_id', '=', account_id))
  171. else:
  172. continue
  173. if field == 'crd':
  174. aml_domain.append(('credit', '>', 0))
  175. elif field == 'deb':
  176. aml_domain.append(('debit', '>', 0))
  177. aml_domains.append(expression.normalize_domain(aml_domain))
  178. if mode not in date_domain_by_mode:
  179. date_domain_by_mode[mode] = \
  180. self.get_aml_domain_for_dates(date_from, date_to,
  181. mode, target_move)
  182. assert aml_domains
  183. return expression.OR(aml_domains) + \
  184. expression.OR(date_domain_by_mode.values())
  185. def get_aml_domain_for_dates(self, date_from, date_to,
  186. mode,
  187. target_move):
  188. if mode == self.MODE_VARIATION:
  189. domain = [('date', '>=', date_from), ('date', '<=', date_to)]
  190. elif mode in (self.MODE_INITIAL, self.MODE_END):
  191. # for income and expense account, sum from the beginning
  192. # of the current fiscal year only, for balance sheet accounts
  193. # sum from the beginning of time
  194. date_from_date = fields.Date.from_string(date_from)
  195. fy_date_from = \
  196. self.company.\
  197. compute_fiscalyear_dates(date_from_date)['date_from']
  198. domain = ['|',
  199. ('date', '>=', fields.Date.to_string(fy_date_from)),
  200. ('user_type_id.include_initial_balance', '=', True)]
  201. if mode == self.MODE_INITIAL:
  202. domain.append(('date', '<', date_from))
  203. elif mode == self.MODE_END:
  204. domain.append(('date', '<=', date_to))
  205. elif mode == self.MODE_UNALLOCATED:
  206. date_from_date = fields.Date.from_string(date_from)
  207. fy_date_from = \
  208. self.company.\
  209. compute_fiscalyear_dates(date_from_date)['date_from']
  210. domain = [('date', '<', fields.Date.to_string(fy_date_from)),
  211. ('user_type_id.include_initial_balance', '=', False)]
  212. if target_move == 'posted':
  213. domain.append(('move_id.state', '=', 'posted'))
  214. return expression.normalize_domain(domain)
  215. def do_queries(self, date_from, date_to,
  216. target_move='posted', additional_move_line_filter=None):
  217. """Query sums of debit and credit for all accounts and domains
  218. used in expressions.
  219. This method must be executed after done_parsing().
  220. """
  221. aml_model = self.company.env['account.move.line']
  222. # {(domain, mode): {account_id: (debit, credit)}}
  223. self._data = defaultdict(dict)
  224. domain_by_mode = {}
  225. ends = []
  226. for key in self._map_account_ids:
  227. domain, mode = key
  228. if mode == self.MODE_END and self.smart_end:
  229. # postpone computation of ending balance
  230. ends.append((domain, mode))
  231. continue
  232. if mode not in domain_by_mode:
  233. domain_by_mode[mode] = \
  234. self.get_aml_domain_for_dates(date_from, date_to,
  235. mode, target_move)
  236. domain = list(domain) + domain_by_mode[mode]
  237. domain.append(('account_id', 'in', self._map_account_ids[key]))
  238. if additional_move_line_filter:
  239. domain.extend(additional_move_line_filter)
  240. # fetch sum of debit/credit, grouped by account_id
  241. accs = aml_model.read_group(domain,
  242. ['debit', 'credit', 'account_id'],
  243. ['account_id'])
  244. for acc in accs:
  245. debit = acc['debit'] or 0.0
  246. credit = acc['credit'] or 0.0
  247. if mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and \
  248. float_is_zero(debit-credit,
  249. precision_rounding=self.dp):
  250. # in initial mode, ignore accounts with 0 balance
  251. continue
  252. self._data[key][acc['account_id'][0]] = (debit, credit)
  253. # compute ending balances by summing initial and variation
  254. for key in ends:
  255. domain, mode = key
  256. initial_data = self._data[(domain, self.MODE_INITIAL)]
  257. variation_data = self._data[(domain, self.MODE_VARIATION)]
  258. account_ids = set(initial_data.keys()) | set(variation_data.keys())
  259. for account_id in account_ids:
  260. di, ci = initial_data.get(account_id,
  261. (AccountingNone, AccountingNone))
  262. dv, cv = variation_data.get(account_id,
  263. (AccountingNone, AccountingNone))
  264. self._data[key][account_id] = (di + dv, ci + cv)
  265. def replace_expr(self, expr):
  266. """Replace accounting variables in an expression by their amount.
  267. Returns a new expression string.
  268. This method must be executed after do_queries().
  269. """
  270. def f(mo):
  271. field, mode, account_codes, domain = self._parse_match_object(mo)
  272. key = (domain, mode)
  273. account_ids_data = self._data[key]
  274. v = AccountingNone
  275. for account_code in account_codes:
  276. account_ids = self._account_ids_by_code[account_code]
  277. for account_id in account_ids:
  278. debit, credit = \
  279. account_ids_data.get(account_id,
  280. (AccountingNone, AccountingNone))
  281. if field == 'bal':
  282. v += debit - credit
  283. elif field == 'deb':
  284. v += debit
  285. elif field == 'crd':
  286. v += credit
  287. # in initial balance mode, assume 0 is None
  288. # as it does not make sense to distinguish 0 from "no data"
  289. if v is not AccountingNone and \
  290. mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and \
  291. float_is_zero(v, precision_rounding=self.dp):
  292. v = AccountingNone
  293. return '(' + repr(v) + ')'
  294. return self._ACC_RE.sub(f, expr)
  295. def replace_exprs_by_account_id(self, exprs):
  296. """Replace accounting variables in a list of expression
  297. by their amount, iterating by accounts involved in the expression.
  298. yields account_id, replaced_expr
  299. This method must be executed after do_queries().
  300. """
  301. def f(mo):
  302. field, mode, account_codes, domain = self._parse_match_object(mo)
  303. key = (domain, mode)
  304. account_ids_data = self._data[key]
  305. debit, credit = \
  306. account_ids_data.get(account_id,
  307. (AccountingNone, AccountingNone))
  308. if field == 'bal':
  309. v = debit - credit
  310. elif field == 'deb':
  311. v = debit
  312. elif field == 'crd':
  313. v = credit
  314. # in initial balance mode, assume 0 is None
  315. # as it does not make sense to distinguish 0 from "no data"
  316. if v is not AccountingNone and \
  317. mode in (self.MODE_INITIAL, self.MODE_UNALLOCATED) and \
  318. float_is_zero(v, precision_rounding=self.dp):
  319. v = AccountingNone
  320. return '(' + repr(v) + ')'
  321. account_ids = set()
  322. for expr in exprs:
  323. for mo in self._ACC_RE.finditer(expr):
  324. field, mode, account_codes, domain = \
  325. self._parse_match_object(mo)
  326. key = (domain, mode)
  327. account_ids_data = self._data[key]
  328. for account_code in account_codes:
  329. for account_id in self._account_ids_by_code[account_code]:
  330. if account_id in account_ids_data:
  331. account_ids.add(account_id)
  332. for account_id in account_ids:
  333. yield account_id, [self._ACC_RE.sub(f, expr) for expr in exprs]
  334. @classmethod
  335. def _get_balances(cls, mode, company, date_from, date_to,
  336. target_move='posted'):
  337. expr = 'deb{mode}[], crd{mode}[]'.format(mode=mode)
  338. aep = AccountingExpressionProcessor(company)
  339. # disable smart_end to have the data at once, instead
  340. # of initial + variation
  341. aep.smart_end = False
  342. aep.parse_expr(expr)
  343. aep.done_parsing()
  344. aep.do_queries(date_from, date_to, target_move)
  345. return aep._data[((), mode)]
  346. @classmethod
  347. def get_balances_initial(cls, company, date, target_move='posted'):
  348. """ A convenience method to obtain the initial balances of all accounts
  349. at a given date.
  350. It is the same as get_balances_end(date-1).
  351. :param company:
  352. :param date:
  353. :param target_move: if 'posted', consider only posted moves
  354. Returns a dictionary: {account_id, (debit, credit)}
  355. """
  356. return cls._get_balances(cls.MODE_INITIAL, company,
  357. date, date, target_move)
  358. @classmethod
  359. def get_balances_end(cls, company, date, target_move='posted'):
  360. """ A convenience method to obtain the ending balances of all accounts
  361. at a given date.
  362. It is the same as get_balances_initial(date+1).
  363. :param company:
  364. :param date:
  365. :param target_move: if 'posted', consider only posted moves
  366. Returns a dictionary: {account_id, (debit, credit)}
  367. """
  368. return cls._get_balances(cls.MODE_END, company,
  369. date, date, target_move)
  370. @classmethod
  371. def get_balances_variation(cls, company, date_from, date_to,
  372. target_move='posted'):
  373. """ A convenience method to obtain the variation of the
  374. balances of all accounts over a period.
  375. :param company:
  376. :param date:
  377. :param target_move: if 'posted', consider only posted moves
  378. Returns a dictionary: {account_id, (debit, credit)}
  379. """
  380. return cls._get_balances(cls.MODE_VARIATION, company,
  381. date_from, date_to, target_move)
  382. @classmethod
  383. def get_unallocated_pl(cls, company, date, target_move='posted'):
  384. """ A convenience method to obtain the unallocated profit/loss
  385. of the previous fiscal years at a given date.
  386. :param company:
  387. :param date:
  388. :param target_move: if 'posted', consider only posted moves
  389. Returns a tuple (debit, credit)
  390. """
  391. # TODO shoud we include here the accounts of type "unaffected"
  392. # or leave that to the caller?
  393. bals = cls._get_balances(cls.MODE_UNALLOCATED, company,
  394. date, date, target_move)
  395. return tuple(map(sum, izip(*bals.values())))