account_bank_statement.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. # -*- coding: utf-8 -*-
  2. from contextlib import contextmanager
  3. from odoo import api, fields, models, _, Command
  4. from odoo.exceptions import UserError
  5. from odoo.tools.misc import formatLang
  6. class AccountBankStatement(models.Model):
  7. _name = "account.bank.statement"
  8. _description = "Bank Statement"
  9. _order = "first_line_index desc"
  10. _check_company_auto = True
  11. name = fields.Char(
  12. string='Reference',
  13. copy=False,
  14. )
  15. # Used to hold the reference of the external mean that created this statement (name of imported file,
  16. # reference of online synchronization...)
  17. reference = fields.Char(
  18. string='External Reference',
  19. copy=False,
  20. )
  21. date = fields.Date(
  22. compute='_compute_date_index', store=True,
  23. index=True,
  24. )
  25. # The internal index of the first line of a statement, it is used for sorting the statements
  26. # The date field cannot be used as there might be more than one statement in one day.
  27. # keeping this order is important because the validity of the statements are based on their order
  28. first_line_index = fields.Char(
  29. comodel_name='account.bank.statement.line',
  30. compute='_compute_date_index', store=True, index=True,
  31. )
  32. balance_start = fields.Monetary(
  33. string='Starting Balance',
  34. compute='_compute_balance_start', store=True, readonly=False,
  35. )
  36. # Balance end is calculated based on the statement line amounts and real starting balance.
  37. balance_end = fields.Monetary(
  38. string='Computed Balance',
  39. compute='_compute_balance_end', store=True,
  40. )
  41. balance_end_real = fields.Monetary(
  42. string='Ending Balance',
  43. compute='_compute_balance_end_real', store=True, readonly=False,
  44. )
  45. company_id = fields.Many2one(
  46. comodel_name='res.company',
  47. related='journal_id.company_id', store=True,
  48. )
  49. currency_id = fields.Many2one(
  50. comodel_name='res.currency',
  51. compute='_compute_currency_id',
  52. )
  53. journal_id = fields.Many2one(
  54. comodel_name='account.journal',
  55. compute='_compute_journal_id', store=True,
  56. check_company=True,
  57. )
  58. line_ids = fields.One2many(
  59. comodel_name='account.bank.statement.line',
  60. inverse_name='statement_id',
  61. string='Statement lines',
  62. required=True,
  63. )
  64. # A statement assumed to be complete when the sum of encoded lines is equal to the difference between start and
  65. # end balances.
  66. is_complete = fields.Boolean(
  67. compute='_compute_is_complete', store=True,
  68. )
  69. # A statement is considered valid when the starting balance matches the ending balance of the previous statement.
  70. # The lines without statements are neglected because, either the user is using statements regularly, so they can
  71. # assume every line without statement is problematic, or they don't use them regularly, in that case statements are
  72. # working as checkpoints only and their validity is not important.
  73. # The first statement of a journal is always considered valid. The validity of the statement is based on other
  74. # statements, so one can say this is external integrity check were as is_complete is the internal integrity.
  75. is_valid = fields.Boolean(
  76. compute='_compute_is_valid',
  77. search='_search_is_valid',
  78. )
  79. problem_description = fields.Text(
  80. compute='_compute_problem_description',
  81. )
  82. attachment_ids = fields.Many2many(
  83. comodel_name='ir.attachment',
  84. string="Attachments",
  85. )
  86. # -------------------------------------------------------------------------
  87. # COMPUTE METHODS
  88. # -------------------------------------------------------------------------
  89. @api.depends('line_ids.internal_index', 'line_ids.state')
  90. def _compute_date_index(self):
  91. for stmt in self:
  92. sorted_lines = stmt.line_ids.sorted('internal_index')
  93. stmt.first_line_index = sorted_lines[:1].internal_index
  94. stmt.date = sorted_lines.filtered(lambda l: l.state == 'posted')[-1:].date
  95. @api.depends('create_date')
  96. def _compute_balance_start(self):
  97. for stmt in self.sorted(lambda x: x.first_line_index or '0'):
  98. journal_id = stmt.journal_id.id or stmt.line_ids.journal_id.id
  99. previous_line_with_statement = self.env['account.bank.statement.line'].search([
  100. ('internal_index', '<', stmt.first_line_index),
  101. ('journal_id', '=', journal_id),
  102. ('state', '=', 'posted'),
  103. ('statement_id', '!=', False),
  104. ], limit=1)
  105. balance_start = previous_line_with_statement.statement_id.balance_end_real
  106. lines_in_between_domain = [
  107. ('internal_index', '<', stmt.first_line_index),
  108. ('journal_id', '=', journal_id),
  109. ('state', '=', 'posted'),
  110. ]
  111. if previous_line_with_statement:
  112. lines_in_between_domain.append(('internal_index', '>', previous_line_with_statement.internal_index))
  113. # remove lines from previous statement (when multi-editing a line already in another statement)
  114. previous_st_lines = previous_line_with_statement.statement_id.line_ids
  115. lines_in_common = previous_st_lines.filtered(lambda l: l.id in stmt.line_ids._origin.ids)
  116. balance_start -= sum(lines_in_common.mapped('amount'))
  117. lines_in_between = self.env['account.bank.statement.line'].search(lines_in_between_domain)
  118. balance_start += sum(lines_in_between.mapped('amount'))
  119. stmt.balance_start = balance_start
  120. @api.depends('balance_start', 'line_ids.amount', 'line_ids.state')
  121. def _compute_balance_end(self):
  122. for stmt in self:
  123. lines = stmt.line_ids.filtered(lambda x: x.state == 'posted')
  124. stmt.balance_end = stmt.balance_start + sum(lines.mapped('amount'))
  125. @api.depends('balance_start')
  126. def _compute_balance_end_real(self):
  127. for stmt in self:
  128. stmt.balance_end_real = stmt.balance_end
  129. @api.depends('journal_id')
  130. def _compute_currency_id(self):
  131. for statement in self:
  132. statement.currency_id = statement.journal_id.currency_id or statement.company_id.currency_id
  133. @api.depends('line_ids.journal_id')
  134. def _compute_journal_id(self):
  135. for statement in self:
  136. statement.journal_id = statement.line_ids.journal_id
  137. @api.depends('balance_end', 'balance_end_real', 'line_ids.amount', 'line_ids.state')
  138. def _compute_is_complete(self):
  139. for stmt in self:
  140. stmt.is_complete = stmt.line_ids.filtered(lambda l: l.state == 'posted') and stmt.currency_id.compare_amounts(
  141. stmt.balance_end, stmt.balance_end_real) == 0
  142. @api.depends('balance_end', 'balance_end_real')
  143. def _compute_is_valid(self):
  144. # we extract the invalid statements, the statements with no lines and the first statement are not in the query
  145. # because they don't have a previous statement, so they are excluded from the join, and we consider them valid.
  146. # if we have extracted the valid ones, we would have to mark above-mentioned statements valid manually
  147. # For new statements, a sql query can't be used
  148. if len(self) == 1:
  149. self.is_valid = self._get_statement_validity()
  150. else:
  151. invalids = self.filtered(lambda s: s.id in self._get_invalid_statement_ids())
  152. invalids.is_valid = False
  153. (self - invalids).is_valid = True
  154. @api.depends('is_valid', 'is_complete')
  155. def _compute_problem_description(self):
  156. for stmt in self:
  157. description = None
  158. if not stmt.is_valid:
  159. description = _("The starting balance doesn't match the ending balance of the previous statement, or an earlier statement is missing.")
  160. elif not stmt.is_complete:
  161. description = _("The running balance (%s) doesn't match the specified ending balance.", formatLang(self.env, stmt.balance_end, currency_obj=stmt.currency_id))
  162. stmt.problem_description = description
  163. def _search_is_valid(self, operator, value):
  164. if operator not in ('=', '!=', '<>'):
  165. raise UserError(_('Operation not supported'))
  166. invalid_ids = self._get_invalid_statement_ids(all_statements=True)
  167. if operator in ('!=', '<>') and value or operator == '=' and not value:
  168. return [('id', 'in', invalid_ids)]
  169. return [('id', 'not in', invalid_ids)]
  170. # -------------------------------------------------------------------------
  171. # BUSINESS METHODS
  172. # -------------------------------------------------------------------------
  173. def _get_statement_validity(self):
  174. """ Compares the balance_start to the previous statements balance_end_real """
  175. self.ensure_one()
  176. previous = self.env['account.bank.statement'].search(
  177. [
  178. ('first_line_index', '<', self.first_line_index),
  179. ('journal_id', '=', self.journal_id.id),
  180. ],
  181. limit=1,
  182. order='first_line_index DESC',
  183. )
  184. return not previous or self.currency_id.compare_amounts(self.balance_start, previous.balance_end_real) == 0
  185. def _get_invalid_statement_ids(self, all_statements=None):
  186. """ Returns the statements that are invalid for _compute and _search methods."""
  187. self.env['account.bank.statement.line'].flush_model(['statement_id', 'internal_index'])
  188. self.env['account.bank.statement'].flush_model(['balance_start', 'balance_end_real', 'first_line_index'])
  189. self.env.cr.execute(f"""
  190. SELECT st.id
  191. FROM account_bank_statement st
  192. LEFT JOIN res_company co ON st.company_id = co.id
  193. LEFT JOIN account_journal j ON st.journal_id = j.id
  194. LEFT JOIN res_currency currency ON COALESCE(j.currency_id, co.currency_id) = currency.id,
  195. LATERAL (
  196. SELECT balance_end_real
  197. FROM account_bank_statement st_lookup
  198. WHERE st_lookup.first_line_index < st.first_line_index
  199. AND st_lookup.journal_id = st.journal_id
  200. ORDER BY st_lookup.first_line_index desc
  201. LIMIT 1
  202. ) prev
  203. WHERE ROUND(prev.balance_end_real, currency.decimal_places) != ROUND(st.balance_start, currency.decimal_places)
  204. {"" if all_statements else "AND st.id IN %(ids)s"}
  205. """, {
  206. 'ids': tuple(self.ids)
  207. })
  208. res = self.env.cr.fetchall()
  209. return [r[0] for r in res]
  210. # -------------------------------------------------------------------------
  211. # LOW-LEVEL METHODS
  212. # -------------------------------------------------------------------------
  213. @api.model
  214. def default_get(self, fields_list):
  215. # EXTENDS base
  216. defaults = super().default_get(fields_list)
  217. if 'line_ids' not in fields_list:
  218. return defaults
  219. active_ids = self._context.get('active_ids')
  220. context_split_line_id = self._context.get('split_line_id')
  221. context_st_line_id = self._context.get('st_line_id')
  222. lines = None
  223. # creating statements with split button
  224. if context_split_line_id:
  225. current_st_line = self.env['account.bank.statement.line'].browse(context_split_line_id)
  226. line_before = self.env['account.bank.statement.line'].search(
  227. domain=[
  228. ('internal_index', '<', current_st_line.internal_index),
  229. ('journal_id', '=', current_st_line.journal_id.id),
  230. ('statement_id', '!=', current_st_line.statement_id.id),
  231. ('statement_id', '!=', False),
  232. ],
  233. order='internal_index desc',
  234. limit=1,
  235. )
  236. lines = self.env['account.bank.statement.line'].search(
  237. domain=[
  238. ('internal_index', '<=', current_st_line.internal_index),
  239. ('internal_index', '>', line_before.internal_index or ''),
  240. ('journal_id', '=', current_st_line.journal_id.id),
  241. ],
  242. order='internal_index desc',
  243. )
  244. # single line edit
  245. elif context_st_line_id and len(active_ids) <= 1:
  246. lines = self.env['account.bank.statement.line'].browse(context_st_line_id)
  247. # multi edit
  248. elif context_st_line_id and len(active_ids) > 1:
  249. lines = self.env['account.bank.statement.line'].browse(active_ids).sorted()
  250. if len(lines.journal_id) > 1:
  251. raise UserError(_("A statement should only contain lines from the same journal."))
  252. # Check that the selected lines are contiguous
  253. indexes = lines.mapped('internal_index')
  254. count_lines_between = self.env['account.bank.statement.line'].search_count([
  255. ('internal_index', '>=', min(indexes)),
  256. ('internal_index', '<=', max(indexes)),
  257. ('journal_id', '=', lines.journal_id.id),
  258. ])
  259. if len(lines) != count_lines_between:
  260. raise UserError(_("Unable to create a statement due to missing transactions. You may want to reorder the transactions before proceeding."))
  261. if lines:
  262. defaults['line_ids'] = [Command.set(lines.ids)]
  263. return defaults
  264. @contextmanager
  265. def _check_attachments(self, container, values_list):
  266. attachments_to_fix_list = []
  267. for values in values_list:
  268. attachment_ids = set()
  269. for orm_command in values.get('attachment_ids', []):
  270. if orm_command[0] == Command.LINK:
  271. attachment_ids.add(orm_command[1])
  272. elif orm_command[0] == Command.SET:
  273. for attachment_id in orm_command[2]:
  274. attachment_ids.add(attachment_id)
  275. attachments = self.env['ir.attachment'].browse(list(attachment_ids))
  276. attachments_to_fix_list.append(attachments)
  277. yield
  278. for stmt, attachments in zip(container['records'], attachments_to_fix_list):
  279. attachments.write({'res_id': stmt.id, 'res_model': stmt._name})
  280. @api.model_create_multi
  281. def create(self, vals_list):
  282. container = {'records': self.env['account.bank.statement']}
  283. with self._check_attachments(container, vals_list):
  284. container['records'] = stmts = super().create(vals_list)
  285. return stmts
  286. def write(self, values):
  287. if len(self) != 1 and 'attachment_ids' in values:
  288. values.pop('attachment_ids')
  289. container = {'records': self}
  290. with self._check_attachments(container, [values]):
  291. result = super().write(values)
  292. return result