# -*- coding: utf-8 -*- import base64 from flectra import api, fields, models, _ from flectra.exceptions import UserError from flectra.addons.base.res.res_bank import sanitize_account_number import logging _logger = logging.getLogger(__name__) 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) _sql_constraints = [ ('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' data_file = fields.Binary(string='Bank Statement File', required=True, help='Get you bank statements in electronic format from your bank and select them here.') filename = fields.Char() @api.multi def import_file(self): """ Process the file chosen in the wizard, create bank statement(s) and go to reconciliation. """ self.ensure_one() # 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) currency_code, account_number, stmts_vals = self.with_context(active_id=self.ids[0])._parse_file(base64.b64decode(self.data_file)) # Check raw data self._check_parsed_data(stmts_vals) # Try to find the currency and journal in flectra 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,)) # 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 statement_ids, notifications = self._create_bank_statements(stmts_vals) # Now that the import worked out, set it as the bank_statements_source of the journal journal.bank_statements_source = 'file_import' # Finally dispatch to reconciliation interface action = self.env.ref('account.action_bank_reconcile_bank_statements') return { 'name': action.name, 'tag': action.tag, 'context': { 'statement_ids': statement_ids, 'notifications': notifications }, 'type': 'ir.actions.client', } 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_type': 'form', '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 flectra -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 ?')) 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.')) no_st_line = True for vals in stmts_vals: 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.')) 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. """ company_currency = self.env.user.company_id.currency_id 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) 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', [])) 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)]) # 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 importing into an existing journal, its currency must be the same as the bank statement if journal: journal_currency = journal.currency_id 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)) # 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.')) 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'] = self.filename 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 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. partner_id = False bank_account_id = False identifying_string = line_vals.get('account_number') if identifying_string: partner_bank = self.env['res.partner.bank'].search([('acc_number', '=', identifying_string)], limit=1) if partner_bank: bank_account_id = partner_bank.id partner_id = partner_bank.partner_id.id else: bank_account_id = self.env['res.partner.bank'].create({ 'acc_number': line_vals['account_number'], 'partner_id': False, }).id line_vals['partner_id'] = partner_id line_vals['bank_account_id'] = bank_account_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'] # Filter out already imported transactions and create statements statement_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)): 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']) if len(filtered_st_lines) > 0: # Remove values that won't be used to create records st_vals.pop('transactions', None) for line_vals in filtered_st_lines: line_vals.pop('account_number', None) # Create the satement st_vals['line_ids'] = [[0, False, line] for line in filtered_st_lines] statement_ids.append(BankStatement.create(st_vals).id) if len(statement_ids) == 0: raise UserError(_('You have already 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 } }] return statement_ids, notifications