333 lines
14 KiB
Python
333 lines
14 KiB
Python
# Fusion Accounting - Journal Extensions for Bank Statement Import
|
|
# File-based import pipeline: parse, validate, create, reconcile
|
|
|
|
from odoo import models, tools, _
|
|
from odoo.addons.base.models.res_bank import sanitize_account_number
|
|
from odoo.exceptions import UserError, RedirectWarning
|
|
|
|
|
|
class FusionAccountJournal(models.Model):
|
|
"""Extends journals with a pluggable bank-statement file import
|
|
pipeline. Sub-modules register parsers by overriding
|
|
``_parse_bank_statement_file``."""
|
|
|
|
_inherit = "account.journal"
|
|
|
|
# ---- Available Import Formats ----
|
|
def _get_bank_statements_available_import_formats(self):
|
|
"""Return a list of supported file-format labels (e.g. 'OFX').
|
|
Override in sub-modules to register additional formats."""
|
|
return []
|
|
|
|
def __get_bank_statements_available_sources(self):
|
|
"""Append file-import option to the bank-statement source selector
|
|
when at least one import format is registered."""
|
|
sources = super(FusionAccountJournal, self).__get_bank_statements_available_sources()
|
|
known_formats = self._get_bank_statements_available_import_formats()
|
|
if known_formats:
|
|
known_formats.sort()
|
|
fmt_label = ', '.join(known_formats)
|
|
sources.append((
|
|
"file_import",
|
|
_("Manual (or import %(import_formats)s)", import_formats=fmt_label),
|
|
))
|
|
return sources
|
|
|
|
# ---- Document Upload Entry Point ----
|
|
def create_document_from_attachment(self, attachment_ids=None):
|
|
"""Route attachment uploads to the bank-statement importer when
|
|
the journal is of type bank, credit, or cash."""
|
|
target_journal = self or self.browse(self.env.context.get('default_journal_id'))
|
|
if target_journal.type in ('bank', 'credit', 'cash'):
|
|
uploaded_files = self.env['ir.attachment'].browse(attachment_ids)
|
|
if not uploaded_files:
|
|
raise UserError(_("No attachment was provided"))
|
|
return target_journal._import_bank_statement(uploaded_files)
|
|
return super().create_document_from_attachment(attachment_ids)
|
|
|
|
# ---- Core Import Pipeline ----
|
|
def _import_bank_statement(self, attachments):
|
|
"""Orchestrate the full import pipeline: parse -> validate ->
|
|
find journal -> complete values -> create statements -> reconcile.
|
|
|
|
Returns an action opening the reconciliation widget for the
|
|
newly imported lines."""
|
|
if any(not att.raw for att in attachments):
|
|
raise UserError(_("You uploaded an invalid or empty file."))
|
|
|
|
created_statement_ids = []
|
|
import_notifications = {}
|
|
import_errors = {}
|
|
|
|
for att in attachments:
|
|
try:
|
|
currency_code, acct_number, parsed_stmts = self._parse_bank_statement_file(att)
|
|
self._check_parsed_data(parsed_stmts, acct_number)
|
|
target_journal = self._find_additional_data(currency_code, acct_number)
|
|
|
|
if not target_journal.default_account_id:
|
|
raise UserError(
|
|
_('You must set a Default Account for the journal: %s', target_journal.name)
|
|
)
|
|
|
|
parsed_stmts = self._complete_bank_statement_vals(
|
|
parsed_stmts, target_journal, acct_number, att,
|
|
)
|
|
stmt_ids, _line_ids, notifs = self._create_bank_statements(parsed_stmts)
|
|
created_statement_ids.extend(stmt_ids)
|
|
|
|
# Auto-set the import source on the journal
|
|
if target_journal.bank_statements_source != 'file_import':
|
|
target_journal.sudo().bank_statements_source = 'file_import'
|
|
|
|
combined_msg = ""
|
|
for n in notifs:
|
|
combined_msg += f"{n['message']}"
|
|
if notifs:
|
|
import_notifications[att.name] = combined_msg
|
|
|
|
except (UserError, RedirectWarning) as exc:
|
|
import_errors[att.name] = exc.args[0]
|
|
|
|
statements = self.env['account.bank.statement'].browse(created_statement_ids)
|
|
lines_to_reconcile = statements.line_ids
|
|
|
|
if lines_to_reconcile:
|
|
cron_time_limit = tools.config['limit_time_real_cron'] or -1
|
|
effective_limit = cron_time_limit if 0 < cron_time_limit < 180 else 180
|
|
lines_to_reconcile._cron_try_auto_reconcile_statement_lines(
|
|
limit_time=effective_limit,
|
|
)
|
|
|
|
widget_action = self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget(
|
|
extra_domain=[('statement_id', 'in', statements.ids)],
|
|
default_context={
|
|
'search_default_not_matched': True,
|
|
'default_journal_id': statements[:1].journal_id.id,
|
|
'notifications': import_notifications,
|
|
},
|
|
)
|
|
|
|
if import_errors:
|
|
err_summary = _("The following files could not be imported:\n")
|
|
err_summary += "\n".join(
|
|
f"- {fname}: {msg}" for fname, msg in import_errors.items()
|
|
)
|
|
if statements:
|
|
self.env.cr.commit()
|
|
raise RedirectWarning(
|
|
err_summary, widget_action,
|
|
_('View successfully imported statements'),
|
|
)
|
|
else:
|
|
raise UserError(err_summary)
|
|
|
|
return widget_action
|
|
|
|
# ---- Parsing (Chain of Responsibility) ----
|
|
def _parse_bank_statement_file(self, attachment) -> tuple:
|
|
"""Parse *attachment* into structured statement data. Each module
|
|
that adds format support must extend this method and return
|
|
``super()`` if the format is not recognised.
|
|
|
|
:returns: ``(currency_code, account_number, statements_data)``
|
|
:raises RedirectWarning: when no parser can handle the file.
|
|
"""
|
|
raise RedirectWarning(
|
|
message=_("Could not interpret the uploaded file.\n"
|
|
"Do you have the appropriate import module installed?"),
|
|
action=self.env.ref('base.open_module_tree').id,
|
|
button_text=_("Go to Apps"),
|
|
additional_context={
|
|
'search_default_name': 'account_bank_statement_import',
|
|
'search_default_extra': True,
|
|
},
|
|
)
|
|
|
|
# ---- Validation ----
|
|
def _check_parsed_data(self, stmts_vals, acct_number):
|
|
"""Verify that the parsed data contains at least one statement
|
|
with at least one transaction."""
|
|
if not stmts_vals:
|
|
raise UserError(_(
|
|
"This file contains no statement for account %s.\n"
|
|
"If the file covers multiple accounts, import it on each one separately.",
|
|
acct_number,
|
|
))
|
|
has_transactions = any(
|
|
sv.get('transactions') for sv in stmts_vals
|
|
)
|
|
if not has_transactions:
|
|
raise UserError(_(
|
|
"This file contains no transaction for account %s.\n"
|
|
"If the file covers multiple accounts, import it on each one separately.",
|
|
acct_number,
|
|
))
|
|
|
|
# ---- Bank Account Matching ----
|
|
def _statement_import_check_bank_account(self, acct_number):
|
|
"""Compare *acct_number* against the journal's bank account,
|
|
accommodating special formats (CH, BNP France, LCL)."""
|
|
sanitised = self.bank_account_id.sanitized_acc_number.split(" ")[0]
|
|
# BNP France: 27-char IBAN vs 11-char local
|
|
if len(sanitised) == 27 and len(acct_number) == 11 and sanitised[:2].upper() == "FR":
|
|
return sanitised[14:-2] == acct_number
|
|
# Credit Lyonnais (LCL): 27-char IBAN vs 7-char local
|
|
if len(sanitised) == 27 and len(acct_number) == 7 and sanitised[:2].upper() == "FR":
|
|
return sanitised[18:-2] == acct_number
|
|
return sanitised == acct_number
|
|
|
|
def _find_additional_data(self, currency_code, acct_number):
|
|
"""Locate the matching journal based on currency and account
|
|
number, creating the bank account link if necessary."""
|
|
co_currency = self.env.company.currency_id
|
|
stmt_currency = None
|
|
normalised_acct = sanitize_account_number(acct_number)
|
|
|
|
if currency_code:
|
|
stmt_currency = self.env['res.currency'].search(
|
|
[('name', '=ilike', currency_code)], limit=1,
|
|
)
|
|
if not stmt_currency:
|
|
raise UserError(_("No currency found matching '%s'.", currency_code))
|
|
if stmt_currency == co_currency:
|
|
stmt_currency = False
|
|
|
|
target_journal = self
|
|
if acct_number:
|
|
if target_journal and not target_journal.bank_account_id:
|
|
target_journal.set_bank_account(acct_number)
|
|
elif not target_journal:
|
|
target_journal = self.search([
|
|
('bank_account_id.sanitized_acc_number', '=', normalised_acct),
|
|
])
|
|
if not target_journal:
|
|
partial = self.search([
|
|
('bank_account_id.sanitized_acc_number', 'ilike', normalised_acct),
|
|
])
|
|
if len(partial) == 1:
|
|
target_journal = partial
|
|
else:
|
|
if not self._statement_import_check_bank_account(normalised_acct):
|
|
raise UserError(_(
|
|
'The statement account (%(account)s) does not match '
|
|
'the journal account (%(journal)s).',
|
|
account=acct_number,
|
|
journal=target_journal.bank_account_id.acc_number,
|
|
))
|
|
|
|
if target_journal:
|
|
j_currency = target_journal.currency_id or target_journal.company_id.currency_id
|
|
if stmt_currency is None:
|
|
stmt_currency = j_currency
|
|
if stmt_currency and stmt_currency != j_currency:
|
|
raise UserError(_(
|
|
'Statement currency (%(code)s) differs from journal '
|
|
'currency (%(journal)s).',
|
|
code=(stmt_currency.name if stmt_currency else co_currency.name),
|
|
journal=(j_currency.name if j_currency else co_currency.name),
|
|
))
|
|
|
|
if not target_journal:
|
|
raise UserError(
|
|
_('Unable to determine the target journal. Please select one manually.')
|
|
)
|
|
return target_journal
|
|
|
|
# ---- Value Completion ----
|
|
def _complete_bank_statement_vals(self, stmts_vals, journal, acct_number, attachment):
|
|
"""Enrich raw parsed values with journal references, unique import
|
|
IDs, and partner-bank associations."""
|
|
for sv in stmts_vals:
|
|
if not sv.get('reference'):
|
|
sv['reference'] = attachment.name
|
|
for txn in sv['transactions']:
|
|
txn['journal_id'] = journal.id
|
|
|
|
uid = txn.get('unique_import_id')
|
|
if uid:
|
|
normalised = sanitize_account_number(acct_number)
|
|
prefix = f"{normalised}-" if normalised else ""
|
|
txn['unique_import_id'] = f"{prefix}{journal.id}-{uid}"
|
|
|
|
if not txn.get('partner_bank_id'):
|
|
ident_str = txn.get('account_number')
|
|
if ident_str:
|
|
if txn.get('partner_id'):
|
|
bank_match = self.env['res.partner.bank'].search([
|
|
('acc_number', '=', ident_str),
|
|
('partner_id', '=', txn['partner_id']),
|
|
])
|
|
else:
|
|
bank_match = self.env['res.partner.bank'].search([
|
|
('acc_number', '=', ident_str),
|
|
('company_id', 'in', (False, journal.company_id.id)),
|
|
])
|
|
if bank_match and len(bank_match) == 1:
|
|
txn['partner_bank_id'] = bank_match.id
|
|
txn['partner_id'] = bank_match.partner_id.id
|
|
return stmts_vals
|
|
|
|
# ---- Statement Creation ----
|
|
def _create_bank_statements(self, stmts_vals, raise_no_imported_file=True):
|
|
"""Create bank statements from the enriched values, skipping
|
|
duplicate transactions and generating PDF attachments for
|
|
complete statements.
|
|
|
|
:returns: ``(statement_ids, line_ids, notifications)``
|
|
"""
|
|
BankStmt = self.env['account.bank.statement']
|
|
BankStmtLine = self.env['account.bank.statement.line']
|
|
|
|
new_stmt_ids = []
|
|
new_line_ids = []
|
|
skipped_imports = []
|
|
|
|
for sv in stmts_vals:
|
|
accepted_txns = []
|
|
for txn in sv['transactions']:
|
|
uid = txn.get('unique_import_id')
|
|
already_exists = (
|
|
uid
|
|
and BankStmtLine.sudo().search(
|
|
[('unique_import_id', '=', uid)], limit=1,
|
|
)
|
|
)
|
|
if txn['amount'] != 0 and not already_exists:
|
|
accepted_txns.append(txn)
|
|
else:
|
|
skipped_imports.append(txn)
|
|
if sv.get('balance_start') is not None:
|
|
sv['balance_start'] += float(txn['amount'])
|
|
|
|
if accepted_txns:
|
|
sv.pop('transactions', None)
|
|
sv['line_ids'] = [[0, False, line] for line in accepted_txns]
|
|
new_stmt = BankStmt.with_context(
|
|
default_journal_id=self.id,
|
|
).create(sv)
|
|
if not new_stmt.name:
|
|
new_stmt.name = sv['reference']
|
|
new_stmt_ids.append(new_stmt.id)
|
|
new_line_ids.extend(new_stmt.line_ids.ids)
|
|
|
|
if new_stmt.is_complete and not self.env.context.get('skip_pdf_attachment_generation'):
|
|
new_stmt.action_generate_attachment()
|
|
|
|
if not new_line_ids and raise_no_imported_file:
|
|
raise UserError(_('You already have imported that file.'))
|
|
|
|
user_notifications = []
|
|
num_skipped = len(skipped_imports)
|
|
if num_skipped:
|
|
user_notifications.append({
|
|
'type': 'warning',
|
|
'message': (
|
|
_("%d transactions had already been imported and were ignored.", num_skipped)
|
|
if num_skipped > 1
|
|
else _("1 transaction had already been imported and was ignored.")
|
|
),
|
|
})
|
|
|
|
return new_stmt_ids, new_line_ids, user_notifications
|