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.

373 lines
16 KiB

  1. # -*- coding: utf-8 -*-
  2. """Framework for importing bank statement files."""
  3. import logging
  4. import base64
  5. from openerp import api, models, fields
  6. from openerp.tools.translate import _
  7. from openerp.exceptions import Warning
  8. _logger = logging.getLogger(__name__)
  9. class AccountBankStatementLine(models.Model):
  10. """Extend model account.bank.statement.line."""
  11. _inherit = "account.bank.statement.line"
  12. # Ensure transactions can be imported only once (if the import format
  13. # provides unique transaction ids)
  14. unique_import_id = fields.Char('Import ID', readonly=True, copy=False)
  15. _sql_constraints = [
  16. ('unique_import_id',
  17. 'unique (unique_import_id)',
  18. 'A bank account transactions can be imported only once !')
  19. ]
  20. class AccountBankStatementImport(models.TransientModel):
  21. """Extend model account.bank.statement."""
  22. _name = 'account.bank.statement.import'
  23. _description = 'Import Bank Statement'
  24. @api.model
  25. def _get_hide_journal_field(self):
  26. """ Return False if the journal_id can't be provided by the parsed
  27. file and must be provided by the wizard.
  28. See account_bank_statement_import_qif """
  29. return True
  30. journal_id = fields.Many2one(
  31. 'account.journal', string='Journal',
  32. help='Accounting journal related to the bank statement you\'re '
  33. 'importing. It has be be manually chosen for statement formats which '
  34. 'doesn\'t allow automatic journal detection (QIF for example).')
  35. hide_journal_field = fields.Boolean(
  36. string='Hide the journal field in the view',
  37. compute='_get_hide_journal_field')
  38. data_file = fields.Binary(
  39. 'Bank Statement File', required=True,
  40. help='Get you bank statements in electronic format from your bank '
  41. 'and select them here.')
  42. @api.multi
  43. def import_file(self):
  44. """ Process the file chosen in the wizard, create bank statement(s) and
  45. go to reconciliation."""
  46. self.ensure_one()
  47. data_file = base64.b64decode(self.data_file)
  48. statement_ids, notifications = self.with_context(
  49. active_id=self.id)._import_file(data_file)
  50. # dispatch to reconciliation interface
  51. action = self.env.ref(
  52. 'account.action_bank_reconcile_bank_statements')
  53. return {
  54. 'name': action.name,
  55. 'tag': action.tag,
  56. 'context': {
  57. 'statement_ids': statement_ids,
  58. 'notifications': notifications
  59. },
  60. 'type': 'ir.actions.client',
  61. }
  62. @api.model
  63. def _import_file(self, data_file):
  64. """ Create bank statement(s) from file."""
  65. # The appropriate implementation module returns the required data
  66. statement_ids = []
  67. notifications = []
  68. parse_result = self._parse_file(data_file)
  69. # Check for old version result, with separate currency and account
  70. if isinstance(parse_result, tuple) and len(parse_result) == 3:
  71. (currency_code, account_number, statements) = parse_result
  72. for stmt_vals in statements:
  73. stmt_vals['currency_code'] = currency_code
  74. stmt_vals['account_number'] = account_number
  75. else:
  76. statements = parse_result
  77. # Check raw data:
  78. self._check_parsed_data(statements)
  79. # Import all statements:
  80. for stmt_vals in statements:
  81. (statement_id, new_notifications) = (
  82. self._import_statement(stmt_vals))
  83. if statement_id:
  84. statement_ids.append(statement_id)
  85. notifications.append(new_notifications)
  86. if len(statement_ids) == 0:
  87. raise Warning(_('You have already imported that file.'))
  88. return statement_ids, notifications
  89. @api.model
  90. def _import_statement(self, stmt_vals):
  91. """Import a single bank-statement.
  92. Return ids of created statements and notifications.
  93. """
  94. currency_code = stmt_vals.pop('currency_code')
  95. account_number = stmt_vals.pop('account_number')
  96. # Try to find the bank account and currency in odoo
  97. currency_id = self._find_currency_id(currency_code)
  98. bank_account_id = self._find_bank_account_id(account_number)
  99. # Create the bank account if not already existing
  100. if not bank_account_id and account_number:
  101. journal_id = self.env.context.get('journal_id')
  102. company_id = self.env.user.company_id.id
  103. if journal_id:
  104. journal = self.env['account.journal'].browse(journal_id)
  105. company_id = journal.company_id.id
  106. bank_account_id = self._create_bank_account(
  107. account_number, company_id=company_id,
  108. currency_id=currency_id).id
  109. # Find or create the bank journal
  110. journal_id = self._get_journal(currency_id, bank_account_id)
  111. # By now journal and account_number must be known
  112. if not journal_id:
  113. raise Warning(_('Can not determine journal for import.'))
  114. # Prepare statement data to be used for bank statements creation
  115. stmt_vals = self._complete_statement(
  116. stmt_vals, journal_id, account_number)
  117. # Create the bank stmt_vals
  118. return self._create_bank_statement(stmt_vals)
  119. @api.model
  120. def _parse_file(self, data_file):
  121. """ Each module adding a file support must extends this method. It
  122. processes the file if it can, returns super otherwise, resulting in a
  123. chain of responsability.
  124. This method parses the given file and returns the data required by
  125. the bank statement import process, as specified below.
  126. - bank statements data: list of dict containing (optional
  127. items marked by o) :
  128. -o currency code: string (e.g: 'EUR')
  129. The ISO 4217 currency code, case insensitive
  130. -o account number: string (e.g: 'BE1234567890')
  131. The number of the bank account which the statement
  132. belongs to
  133. - 'name': string (e.g: '000000123')
  134. - 'date': date (e.g: 2013-06-26)
  135. -o 'balance_start': float (e.g: 8368.56)
  136. -o 'balance_end_real': float (e.g: 8888.88)
  137. - 'transactions': list of dict containing :
  138. - 'name': string
  139. (e.g: 'KBC-INVESTERINGSKREDIET 787-5562831-01')
  140. - 'date': date
  141. - 'amount': float
  142. - 'unique_import_id': string
  143. -o 'account_number': string
  144. Will be used to find/create the res.partner.bank
  145. in odoo
  146. -o 'note': string
  147. -o 'partner_name': string
  148. -o 'ref': string
  149. """
  150. raise Warning(_(
  151. 'Could not make sense of the given file.\n'
  152. 'Did you install the module to support this type of file?'
  153. ))
  154. @api.model
  155. def _check_parsed_data(self, statements):
  156. """ Basic and structural verifications """
  157. if len(statements) == 0:
  158. raise Warning(_('This file doesn\'t contain any statement.'))
  159. for stmt_vals in statements:
  160. if 'transactions' in stmt_vals and stmt_vals['transactions']:
  161. return
  162. # If we get here, no transaction was found:
  163. raise Warning(_('This file doesn\'t contain any transaction.'))
  164. @api.model
  165. def _find_currency_id(self, currency_code):
  166. """ Get res.currency ID."""
  167. if currency_code:
  168. currency_ids = self.env['res.currency'].search(
  169. [('name', '=ilike', currency_code)])
  170. if currency_ids:
  171. return currency_ids[0].id
  172. else:
  173. raise Warning(_(
  174. 'Statement has invalid currency code %s') % currency_code)
  175. # if no currency_code is provided, we'll use the company currency
  176. return self.env.user.company_id.currency_id.id
  177. @api.model
  178. def _find_bank_account_id(self, account_number):
  179. """ Get res.partner.bank ID """
  180. bank_account_id = None
  181. if account_number and len(account_number) > 4:
  182. bank_account_ids = self.env['res.partner.bank'].search(
  183. [('acc_number', '=', account_number)], limit=1)
  184. if bank_account_ids:
  185. bank_account_id = bank_account_ids[0].id
  186. return bank_account_id
  187. @api.model
  188. def _get_journal(self, currency_id, bank_account_id):
  189. """ Find the journal """
  190. bank_model = self.env['res.partner.bank']
  191. # Find the journal from context, wizard or bank account
  192. journal_id = self.env.context.get('journal_id') or self.journal_id.id
  193. if bank_account_id:
  194. bank_account = bank_model.browse(bank_account_id)
  195. if journal_id:
  196. if (bank_account.journal_id.id and
  197. bank_account.journal_id.id != journal_id):
  198. raise Warning(
  199. _('The account of this statement is linked to '
  200. 'another journal.'))
  201. if not bank_account.journal_id.id:
  202. bank_model.write({'journal_id': journal_id})
  203. else:
  204. if bank_account.journal_id.id:
  205. journal_id = bank_account.journal_id.id
  206. # If importing into an existing journal, its currency must be the same
  207. # as the bank statement. When journal has no currency, currency must
  208. # be equal to company currency.
  209. if journal_id and currency_id:
  210. journal_obj = self.env['account.journal'].browse(journal_id)
  211. if journal_obj.currency:
  212. journal_currency_id = journal_obj.currency.id
  213. if currency_id != journal_currency_id:
  214. # ALso log message with id's for technical analysis:
  215. _logger.warn(
  216. _('Statement currency id is %d,'
  217. ' but journal currency id = %d.'),
  218. currency_id,
  219. journal_currency_id
  220. )
  221. raise Warning(_(
  222. 'The currency of the bank statement is not '
  223. 'the same as the currency of the journal !'
  224. ))
  225. else:
  226. company_currency_id = self.env.user.company_id.currency_id.id
  227. if currency_id != company_currency_id:
  228. # ALso log message with id's for technical analysis:
  229. _logger.warn(
  230. _('Statement currency id is %d,'
  231. ' but company currency id = %d.'),
  232. currency_id,
  233. company_currency_id
  234. )
  235. raise Warning(_(
  236. 'The currency of the bank statement is not '
  237. 'the same as the company currency !'
  238. ))
  239. return journal_id
  240. @api.model
  241. @api.returns('res.partner.bank')
  242. def _create_bank_account(
  243. self, account_number, company_id=False, currency_id=False):
  244. """Automagically create bank account, when not yet existing."""
  245. try:
  246. bank_type = self.env.ref('base.bank_normal')
  247. bank_code = bank_type.code
  248. except ValueError:
  249. bank_code = 'bank'
  250. vals_acc = {
  251. 'acc_number': account_number,
  252. 'state': bank_code,
  253. }
  254. # Odoo users bank accounts (which we import statement from) have
  255. # company_id and journal_id set while 'counterpart' bank accounts
  256. # (from which statement transactions originate) don't.
  257. # Warning : if company_id is set, the method post_write of class
  258. # bank will create a journal
  259. if company_id:
  260. vals = self.env['res.partner.bank'].onchange_company_id(company_id)
  261. vals_acc.update(vals.get('value', {}))
  262. vals_acc['company_id'] = company_id
  263. # When the journal is created at same time of the bank account, we need
  264. # to specify the currency to use for the account.account and
  265. # account.journal
  266. return self.env['res.partner.bank'].with_context(
  267. default_currency_id=currency_id,
  268. default_currency=currency_id).create(vals_acc)
  269. @api.model
  270. def _complete_statement(self, stmt_vals, journal_id, account_number):
  271. """Complete statement from information passed."""
  272. stmt_vals['journal_id'] = journal_id
  273. for line_vals in stmt_vals['transactions']:
  274. unique_import_id = line_vals.get('unique_import_id', False)
  275. if unique_import_id:
  276. line_vals['unique_import_id'] = (
  277. (account_number and account_number + '-' or '') +
  278. unique_import_id
  279. )
  280. if not line_vals.get('bank_account_id'):
  281. # Find the partner and his bank account or create the bank
  282. # account. The partner selected during the reconciliation
  283. # process will be linked to the bank when the statement is
  284. # closed.
  285. partner_id = False
  286. bank_account_id = False
  287. account_number = line_vals.get('account_number')
  288. if account_number:
  289. bank_model = self.env['res.partner.bank']
  290. banks = bank_model.search(
  291. [('acc_number', '=', account_number)], limit=1)
  292. if banks:
  293. bank_account_id = banks[0].id
  294. partner_id = banks[0].partner_id.id
  295. else:
  296. bank_obj = self._create_bank_account(account_number)
  297. bank_account_id = bank_obj and bank_obj.id or False
  298. line_vals['partner_id'] = partner_id
  299. line_vals['bank_account_id'] = bank_account_id
  300. return stmt_vals
  301. @api.model
  302. def _create_bank_statement(self, stmt_vals):
  303. """ Create bank statement from imported values, filtering out
  304. already imported transactions, and return data used by the
  305. reconciliation widget
  306. """
  307. bs_model = self.env['account.bank.statement']
  308. bsl_model = self.env['account.bank.statement.line']
  309. # Filter out already imported transactions and create statement
  310. ignored_line_ids = []
  311. filtered_st_lines = []
  312. for line_vals in stmt_vals['transactions']:
  313. unique_id = (
  314. 'unique_import_id' in line_vals and
  315. line_vals['unique_import_id']
  316. )
  317. if not unique_id or not bool(bsl_model.sudo().search(
  318. [('unique_import_id', '=', unique_id)], limit=1)):
  319. filtered_st_lines.append(line_vals)
  320. else:
  321. ignored_line_ids.append(unique_id)
  322. statement_id = False
  323. if len(filtered_st_lines) > 0:
  324. # Remove values that won't be used to create records
  325. stmt_vals.pop('transactions', None)
  326. for line_vals in filtered_st_lines:
  327. line_vals.pop('account_number', None)
  328. # Create the statement
  329. stmt_vals['line_ids'] = [
  330. [0, False, line] for line in filtered_st_lines]
  331. statement_id = bs_model.create(stmt_vals).id
  332. # Prepare import feedback
  333. notifications = []
  334. num_ignored = len(ignored_line_ids)
  335. if num_ignored > 0:
  336. notifications += [{
  337. 'type': 'warning',
  338. 'message':
  339. _("%d transactions had already been imported and "
  340. "were ignored.") % num_ignored
  341. if num_ignored > 1
  342. else _("1 transaction had already been imported and "
  343. "was ignored."),
  344. 'details': {
  345. 'name': _('Already imported items'),
  346. 'model': 'account.bank.statement.line',
  347. 'ids': bsl_model.search(
  348. [('unique_import_id', 'in', ignored_line_ids)]).ids}
  349. }]
  350. return statement_id, notifications