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.

430 lines
18 KiB

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