diff --git a/account_statement_import/__init__.py b/account_statement_import/__init__.py index 0947ae71..172a3434 100644 --- a/account_statement_import/__init__.py +++ b/account_statement_import/__init__.py @@ -1,5 +1,3 @@ -# -*- encoding: utf-8 -*- - from . import account_bank_statement_import from . import account_journal diff --git a/account_statement_import/__manifest__.py b/account_statement_import/__manifest__.py index 6d8b9977..2fb62519 100644 --- a/account_statement_import/__manifest__.py +++ b/account_statement_import/__manifest__.py @@ -1,28 +1,27 @@ -# -*- encoding: utf-8 -*- # Copyright 2004-2020 Odoo S.A. # Licence LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). { - 'name': 'Account Bank Statement Import', - 'category': 'Accounting/Accounting', - 'version': '13.0.1.0.0', - 'license': 'LGPL-3', - 'depends': ['account'], - 'description': """Generic Wizard to Import Bank Statements. + "name": "Account Bank Statement Import", + "category": "Accounting/Accounting", + "version": "13.0.1.0.0", + "license": "LGPL-3", + "depends": ["account"], + "description": """Generic Wizard to Import Bank Statements. (This module does not include any type of import format.) OFX and QIF imports are available in Enterprise version.""", - 'author': 'Odoo SA', - 'data': [ - 'account_bank_statement_import_view.xml', - 'account_import_tip_data.xml', - 'wizard/journal_creation.xml', - 'views/account_bank_statement_import_templates.xml', + "author": "Odoo SA", + "data": [ + "account_bank_statement_import_view.xml", + "account_import_tip_data.xml", + "wizard/journal_creation.xml", + "views/account_bank_statement_import_templates.xml", ], - 'demo': [ - 'demo/partner_bank.xml', + "demo": [ + "demo/partner_bank.xml", ], - 'installable': True, - 'auto_install': True, + "installable": True, + "auto_install": True, } diff --git a/account_statement_import/account_bank_statement_import.py b/account_statement_import/account_bank_statement_import.py index 68339366..461f2df3 100644 --- a/account_statement_import/account_bank_statement_import.py +++ b/account_statement_import/account_bank_statement_import.py @@ -1,14 +1,14 @@ -# -*- coding: utf-8 -*- # Copyright 2004-2020 Odoo S.A. # Licence LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0). import base64 +import logging -from odoo import api, fields, models, _ +from odoo import _, api, fields, models from odoo.exceptions import UserError + from odoo.addons.base.models.res_bank import sanitize_account_number -import logging _logger = logging.getLogger(__name__) @@ -16,18 +16,27 @@ class AccountBankStatementLine(models.Model): _inherit = "account.bank.statement.line" # Ensure transactions can be imported only once (if the import format provides unique transaction ids) - unique_import_id = fields.Char(string='Import ID', readonly=True, copy=False) + unique_import_id = fields.Char(string="Import ID", readonly=True, copy=False) _sql_constraints = [ - ('unique_import_id', 'unique (unique_import_id)', 'A bank account transactions can be imported only once !') + ( + "unique_import_id", + "unique (unique_import_id)", + "A bank account transactions can be imported only once !", + ) ] class AccountBankStatementImport(models.TransientModel): - _name = 'account.bank.statement.import' - _description = 'Import Bank Statement' + _name = "account.bank.statement.import" + _description = "Import Bank Statement" - attachment_ids = fields.Many2many('ir.attachment', string='Files', required=True, help='Get you bank statements in electronic format from your bank and select them here.') + attachment_ids = fields.Many2many( + "ir.attachment", + string="Files", + required=True, + help="Get you bank statements in electronic format from your bank and select them here.", + ) def import_file(self): """ Process the file chosen in the wizard, create bank statement(s) and go to reconciliation. """ @@ -37,17 +46,31 @@ class AccountBankStatementImport(models.TransientModel): # Let the appropriate implementation module parse the file and return the required data # The active_id is passed in context in case an implementation module requires information about the wizard state (see QIF) for data_file in self.attachment_ids: - currency_code, account_number, stmts_vals = self.with_context(active_id=self.ids[0])._parse_file(base64.b64decode(data_file.datas)) + currency_code, account_number, stmts_vals = self.with_context( + active_id=self.ids[0] + )._parse_file(base64.b64decode(data_file.datas)) # Check raw data self._check_parsed_data(stmts_vals) # Try to find the currency and journal in odoo - currency, journal = self._find_additional_data(currency_code, account_number) + currency, journal = self._find_additional_data( + currency_code, account_number + ) # If no journal found, ask the user about creating one if not journal: # The active_id is passed in context so the wizard can call import_file again once the journal is created - return self.with_context(active_id=self.ids[0])._journal_creation_wizard(currency, account_number) - if not journal.default_debit_account_id or not journal.default_credit_account_id: - raise UserError(_('You have to set a Default Debit Account and a Default Credit Account for the journal: %s') % (journal.name,)) + return self.with_context( + active_id=self.ids[0] + )._journal_creation_wizard(currency, account_number) + if ( + not journal.default_debit_account_id + or not journal.default_credit_account_id + ): + raise UserError( + _( + "You have to set a Default Debit Account and a Default Credit Account for the journal: %s" + ) + % (journal.name,) + ) # Prepare statement data to be used for bank statements creation stmts_vals = self._complete_stmts_vals(stmts_vals, journal, account_number) # Create the bank statements @@ -55,108 +78,130 @@ class AccountBankStatementImport(models.TransientModel): statement_line_ids_all.extend(statement_line_ids) notifications_all.extend(notifications) # Now that the import worked out, set it as the bank_statements_source of the journal - if journal.bank_statements_source != 'file_import': + if journal.bank_statements_source != "file_import": # Use sudo() because only 'account.group_account_manager' # has write access on 'account.journal', but 'account.group_account_user' # must be able to import bank statement files - journal.sudo().bank_statements_source = 'file_import' + journal.sudo().bank_statements_source = "file_import" # Finally dispatch to reconciliation interface return { - 'type': 'ir.actions.client', - 'tag': 'bank_statement_reconciliation_view', - 'context': {'statement_line_ids': statement_line_ids_all, - 'company_ids': self.env.user.company_ids.ids, - 'notifications': notifications_all, + "type": "ir.actions.client", + "tag": "bank_statement_reconciliation_view", + "context": { + "statement_line_ids": statement_line_ids_all, + "company_ids": self.env.user.company_ids.ids, + "notifications": notifications_all, }, } def _journal_creation_wizard(self, currency, account_number): """ Calls a wizard that allows the user to carry on with journal creation """ return { - 'name': _('Journal Creation'), - 'type': 'ir.actions.act_window', - 'res_model': 'account.bank.statement.import.journal.creation', - 'view_mode': 'form', - 'target': 'new', - 'context': { - 'statement_import_transient_id': self.env.context['active_id'], - 'default_bank_acc_number': account_number, - 'default_name': _('Bank') + ' ' + account_number, - 'default_currency_id': currency and currency.id or False, - 'default_type': 'bank', - } + "name": _("Journal Creation"), + "type": "ir.actions.act_window", + "res_model": "account.bank.statement.import.journal.creation", + "view_mode": "form", + "target": "new", + "context": { + "statement_import_transient_id": self.env.context["active_id"], + "default_bank_acc_number": account_number, + "default_name": _("Bank") + " " + account_number, + "default_currency_id": currency and currency.id or False, + "default_type": "bank", + }, } def _parse_file(self, data_file): - """ Each module adding a file support must extends this method. It processes the file if it can, returns super otherwise, resulting in a chain of responsability. - This method parses the given file and returns the data required by the bank statement import process, as specified below. - rtype: triplet (if a value can't be retrieved, use None) - - currency code: string (e.g: 'EUR') - The ISO 4217 currency code, case insensitive - - account number: string (e.g: 'BE1234567890') - The number of the bank account which the statement belongs to - - bank statements data: list of dict containing (optional items marked by o) : - - 'name': string (e.g: '000000123') - - 'date': date (e.g: 2013-06-26) - -o 'balance_start': float (e.g: 8368.56) - -o 'balance_end_real': float (e.g: 8888.88) - - 'transactions': list of dict containing : - - 'name': string (e.g: 'KBC-INVESTERINGSKREDIET 787-5562831-01') - - 'date': date - - 'amount': float - - 'unique_import_id': string - -o 'account_number': string - Will be used to find/create the res.partner.bank in odoo - -o 'note': string - -o 'partner_name': string - -o 'ref': string + """Each module adding a file support must extends this method. It processes the file if it can, returns super otherwise, resulting in a chain of responsability. + This method parses the given file and returns the data required by the bank statement import process, as specified below. + rtype: triplet (if a value can't be retrieved, use None) + - currency code: string (e.g: 'EUR') + The ISO 4217 currency code, case insensitive + - account number: string (e.g: 'BE1234567890') + The number of the bank account which the statement belongs to + - bank statements data: list of dict containing (optional items marked by o) : + - 'name': string (e.g: '000000123') + - 'date': date (e.g: 2013-06-26) + -o 'balance_start': float (e.g: 8368.56) + -o 'balance_end_real': float (e.g: 8888.88) + - 'transactions': list of dict containing : + - 'name': string (e.g: 'KBC-INVESTERINGSKREDIET 787-5562831-01') + - 'date': date + - 'amount': float + - 'unique_import_id': string + -o 'account_number': string + Will be used to find/create the res.partner.bank in odoo + -o 'note': string + -o 'partner_name': string + -o 'ref': string """ - raise UserError(_('Could not make sense of the given file.\nDid you install the module to support this type of file ?')) + raise UserError( + _( + "Could not make sense of the given file.\nDid you install the module to support this type of file ?" + ) + ) def _check_parsed_data(self, stmts_vals): """ Basic and structural verifications """ if len(stmts_vals) == 0: - raise UserError(_('This file doesn\'t contain any statement.')) + raise UserError(_("This file doesn't contain any statement.")) no_st_line = True for vals in stmts_vals: - if vals['transactions'] and len(vals['transactions']) > 0: + if vals["transactions"] and len(vals["transactions"]) > 0: no_st_line = False break if no_st_line: - raise UserError(_('This file doesn\'t contain any transaction.')) + raise UserError(_("This file doesn't contain any transaction.")) def _check_journal_bank_account(self, journal, account_number): return journal.bank_account_id.sanitized_acc_number == account_number def _find_additional_data(self, currency_code, account_number): - """ Look for a res.currency and account.journal using values extracted from the - statement and make sure it's consistent. + """Look for a res.currency and account.journal using values extracted from the + statement and make sure it's consistent. """ company_currency = self.env.company.currency_id - journal_obj = self.env['account.journal'] + journal_obj = self.env["account.journal"] currency = None sanitized_account_number = sanitize_account_number(account_number) if currency_code: - currency = self.env['res.currency'].search([('name', '=ilike', currency_code)], limit=1) + currency = self.env["res.currency"].search( + [("name", "=ilike", currency_code)], limit=1 + ) if not currency: raise UserError(_("No currency found matching '%s'.") % currency_code) if currency == company_currency: currency = False - journal = journal_obj.browse(self.env.context.get('journal_id', [])) + journal = journal_obj.browse(self.env.context.get("journal_id", [])) if account_number: # No bank account on the journal : create one from the account number of the statement if journal and not journal.bank_account_id: journal.set_bank_account(account_number) # No journal passed to the wizard : try to find one using the account number of the statement elif not journal: - journal = journal_obj.search([('bank_account_id.sanitized_acc_number', '=', sanitized_account_number)]) + journal = journal_obj.search( + [ + ( + "bank_account_id.sanitized_acc_number", + "=", + sanitized_account_number, + ) + ] + ) # Already a bank account on the journal : check it's the same as on the statement else: - if not self._check_journal_bank_account(journal, sanitized_account_number): - raise UserError(_('The account of this statement (%s) is not the same as the journal (%s).') % (account_number, journal.bank_account_id.acc_number)) + if not self._check_journal_bank_account( + journal, sanitized_account_number + ): + raise UserError( + _( + "The account of this statement (%s) is not the same as the journal (%s)." + ) + % (account_number, journal.bank_account_id.acc_number) + ) # If importing into an existing journal, its currency must be the same as the bank statement if journal: @@ -164,82 +209,134 @@ class AccountBankStatementImport(models.TransientModel): if currency is None: currency = journal_currency if currency and currency != journal_currency: - statement_cur_code = not currency and company_currency.name or currency.name - journal_cur_code = not journal_currency and company_currency.name or journal_currency.name - raise UserError(_('The currency of the bank statement (%s) is not the same as the currency of the journal (%s).') % (statement_cur_code, journal_cur_code)) + statement_cur_code = ( + not currency and company_currency.name or currency.name + ) + journal_cur_code = ( + not journal_currency + and company_currency.name + or journal_currency.name + ) + raise UserError( + _( + "The currency of the bank statement (%s) is not the same as the currency of the journal (%s)." + ) + % (statement_cur_code, journal_cur_code) + ) # If we couldn't find / can't create a journal, everything is lost if not journal and not account_number: - raise UserError(_('Cannot find in which journal import this statement. Please manually select a journal.')) + raise UserError( + _( + "Cannot find in which journal import this statement. Please manually select a journal." + ) + ) return currency, journal def _complete_stmts_vals(self, stmts_vals, journal, account_number): for st_vals in stmts_vals: - st_vals['journal_id'] = journal.id - if not st_vals.get('reference'): - st_vals['reference'] = " ".join(self.attachment_ids.mapped('name')) - if st_vals.get('number'): - #build the full name like BNK/2016/00135 by just giving the number '135' - st_vals['name'] = journal.sequence_id.with_context(ir_sequence_date=st_vals.get('date')).get_next_char(st_vals['number']) - del(st_vals['number']) - for line_vals in st_vals['transactions']: - unique_import_id = line_vals.get('unique_import_id') + st_vals["journal_id"] = journal.id + if not st_vals.get("reference"): + st_vals["reference"] = " ".join(self.attachment_ids.mapped("name")) + if st_vals.get("number"): + # build the full name like BNK/2016/00135 by just giving the number '135' + st_vals["name"] = journal.sequence_id.with_context( + ir_sequence_date=st_vals.get("date") + ).get_next_char(st_vals["number"]) + del st_vals["number"] + for line_vals in st_vals["transactions"]: + unique_import_id = line_vals.get("unique_import_id") if unique_import_id: sanitized_account_number = sanitize_account_number(account_number) - line_vals['unique_import_id'] = (sanitized_account_number and sanitized_account_number + '-' or '') + str(journal.id) + '-' + unique_import_id + line_vals["unique_import_id"] = ( + ( + sanitized_account_number + and sanitized_account_number + "-" + or "" + ) + + str(journal.id) + + "-" + + unique_import_id + ) - if not line_vals.get('bank_account_id'): + if not line_vals.get("bank_account_id"): # Find the partner and his bank account or create the bank account. The partner selected during the # reconciliation process will be linked to the bank when the statement is closed. - identifying_string = line_vals.get('account_number') + identifying_string = line_vals.get("account_number") if identifying_string: - partner_bank = self.env['res.partner.bank'].search([('acc_number', '=', identifying_string)], limit=1) + partner_bank = self.env["res.partner.bank"].search( + [("acc_number", "=", identifying_string)], limit=1 + ) if partner_bank: - line_vals['bank_account_id'] = partner_bank.id - line_vals['partner_id'] = partner_bank.partner_id.id + line_vals["bank_account_id"] = partner_bank.id + line_vals["partner_id"] = partner_bank.partner_id.id return stmts_vals def _create_bank_statements(self, stmts_vals): """ Create new bank statements from imported values, filtering out already imported transactions, and returns data used by the reconciliation widget """ - BankStatement = self.env['account.bank.statement'] - BankStatementLine = self.env['account.bank.statement.line'] + BankStatement = self.env["account.bank.statement"] + BankStatementLine = self.env["account.bank.statement.line"] # Filter out already imported transactions and create statements statement_line_ids = [] ignored_statement_lines_import_ids = [] for st_vals in stmts_vals: filtered_st_lines = [] - for line_vals in st_vals['transactions']: - if 'unique_import_id' not in line_vals \ - or not line_vals['unique_import_id'] \ - or not bool(BankStatementLine.sudo().search([('unique_import_id', '=', line_vals['unique_import_id'])], limit=1)): + for line_vals in st_vals["transactions"]: + if ( + "unique_import_id" not in line_vals + or not line_vals["unique_import_id"] + or not bool( + BankStatementLine.sudo().search( + [("unique_import_id", "=", line_vals["unique_import_id"])], + limit=1, + ) + ) + ): filtered_st_lines.append(line_vals) else: - ignored_statement_lines_import_ids.append(line_vals['unique_import_id']) - if 'balance_start' in st_vals: - st_vals['balance_start'] += float(line_vals['amount']) + ignored_statement_lines_import_ids.append( + line_vals["unique_import_id"] + ) + if "balance_start" in st_vals: + st_vals["balance_start"] += float(line_vals["amount"]) if len(filtered_st_lines) > 0: # Remove values that won't be used to create records - st_vals.pop('transactions', None) + st_vals.pop("transactions", None) # Create the statement - st_vals['line_ids'] = [[0, False, line] for line in filtered_st_lines] + st_vals["line_ids"] = [[0, False, line] for line in filtered_st_lines] statement_line_ids.extend(BankStatement.create(st_vals).line_ids.ids) if len(statement_line_ids) == 0: - raise UserError(_('You already have imported that file.')) + raise UserError(_("You already have imported that file.")) # Prepare import feedback notifications = [] num_ignored = len(ignored_statement_lines_import_ids) if num_ignored > 0: - notifications += [{ - 'type': 'warning', - 'message': _("%d transactions had already been imported and were ignored.") % num_ignored if num_ignored > 1 else _("1 transaction had already been imported and was ignored."), - 'details': { - 'name': _('Already imported items'), - 'model': 'account.bank.statement.line', - 'ids': BankStatementLine.search([('unique_import_id', 'in', ignored_statement_lines_import_ids)]).ids + notifications += [ + { + "type": "warning", + "message": _( + "%d transactions had already been imported and were ignored." + ) + % num_ignored + if num_ignored > 1 + else _("1 transaction had already been imported and was ignored."), + "details": { + "name": _("Already imported items"), + "model": "account.bank.statement.line", + "ids": BankStatementLine.search( + [ + ( + "unique_import_id", + "in", + ignored_statement_lines_import_ids, + ) + ] + ).ids, + }, } - }] + ] return statement_line_ids, notifications diff --git a/account_statement_import/account_bank_statement_import_view.xml b/account_statement_import/account_bank_statement_import_view.xml index 6f523c52..f9e27e73 100644 --- a/account_statement_import/account_bank_statement_import_view.xml +++ b/account_statement_import/account_bank_statement_import_view.xml @@ -15,10 +15,25 @@