185 lines
9.2 KiB
Python
185 lines
9.2 KiB
Python
import logging
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import _, api, fields, models, modules
|
|
from odoo.exceptions import RedirectWarning, UserError
|
|
from odoo.tools import exception_to_unicode
|
|
|
|
from odoo.addons.base.models.res_bank import sanitize_account_number
|
|
from odoo.addons.l10n_be_codaclean.tools.iap_api import get_error_message
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AccountJournal(models.Model):
|
|
_inherit = "account.journal"
|
|
|
|
def __get_bank_statements_available_sources(self):
|
|
# Extends 'account'
|
|
rslt = super().__get_bank_statements_available_sources()
|
|
rslt.append(("l10n_be_codaclean", _("Codaclean Synchronization")))
|
|
return rslt
|
|
|
|
@api.model
|
|
def _l10n_be_codaclean_fetch_coda_transactions(self, company):
|
|
if not company.l10n_be_codaclean_is_connected:
|
|
raise RedirectWarning(
|
|
message=_("Not connected to Codaclean. Please check your configuration."),
|
|
action=self.env.ref('account.action_account_config').id,
|
|
button_text=_("Go to settings"),
|
|
)
|
|
|
|
codaclean_journals = self.search([
|
|
("bank_statements_source", "=", "l10n_be_codaclean"),
|
|
("bank_acc_number", "!=", False),
|
|
("company_id", "=", company.id),
|
|
])
|
|
if not codaclean_journals:
|
|
return []
|
|
|
|
# We fetch only coda files with a statment date after
|
|
# - The most recent bank statement
|
|
# - The most recent bank statement line (if there are no bank statements)
|
|
# - The date 1 year ago (if we did not find bank statement lines either)
|
|
last_statement_date = dict(self.env["account.bank.statement"]._read_group(
|
|
domain=[('journal_id', 'in', codaclean_journals.ids)],
|
|
groupby=['journal_id'],
|
|
aggregates=['date:max'],
|
|
))
|
|
# Note that there can be entries with value `False` in last_statement_date
|
|
remaining_journals = self.browse([journal.id for journal in codaclean_journals if not last_statement_date.get(journal)])
|
|
last_statement_line_date = dict(self.env["account.bank.statement.line"]._read_group(
|
|
domain=[('journal_id', 'in', remaining_journals.ids)],
|
|
groupby=['journal_id'],
|
|
aggregates=['date:max'],
|
|
)) if remaining_journals else {}
|
|
date_one_year_ago = fields.Date.today() - relativedelta(years=1)
|
|
|
|
ibans = {} # {iban: last_date} where last_date is the date of the last bank statement or transaction
|
|
for journal in codaclean_journals:
|
|
iban = journal.bank_acc_number.replace(" ", "").upper()
|
|
# Note that there can be entries with value `False` in last_statement_date / last_statement_line_date
|
|
last_date = last_statement_date.get(journal) or last_statement_line_date.get(journal) or date_one_year_ago
|
|
ibans[iban] = min(ibans.get(iban) or last_date, last_date)
|
|
date_from = min(ibans.values()) or date_one_year_ago
|
|
|
|
# Format the dates
|
|
ibans = {iban: fields.Date.to_string(date) for iban, date in ibans.items()}
|
|
date_from = fields.Date.to_string(date_from)
|
|
|
|
result = company._l10n_be_codaclean_fetch_coda_files(date_from, ibans)
|
|
if not company.l10n_be_codaclean_iap_token:
|
|
# Modify the status in a new cursor to prevent the changes from being rolled back
|
|
with company.pool.cursor() as new_cr:
|
|
company = company.with_env(company.env(cr=new_cr))
|
|
company.l10n_be_codaclean_iap_token = False
|
|
if error := result.get("error", {}):
|
|
raise UserError(get_error_message(error))
|
|
|
|
return result.get('files', [])
|
|
|
|
@api.model
|
|
def _l10n_be_codaclean_import_coda_files(self, company, codas):
|
|
if not codas:
|
|
return []
|
|
|
|
statement_ids_all = []
|
|
skipped_bank_accounts = set()
|
|
acc_journal_map = {
|
|
# A same account number could be formatted differently in journal.acc_number and
|
|
# coda statement. Therefor we must match sanitized versions of both.
|
|
sanitize_account_number(acc_num):
|
|
journals for acc_num, journals in self._read_group(
|
|
[
|
|
("company_id", "=", company.id),
|
|
("bank_statements_source", "in", {"l10n_be_codaclean", "undefined"}),
|
|
],
|
|
['bank_acc_number'],
|
|
['id:recordset'],
|
|
)
|
|
}
|
|
for coda_b64, pdf_b64 in codas:
|
|
try:
|
|
coda_attachment = self.env["ir.attachment"].create({
|
|
"name": 'codaclean_coda.coda',
|
|
'type': 'binary',
|
|
'datas': coda_b64,
|
|
})
|
|
statement_ids = []
|
|
|
|
for currency, account_number, *data in self._parse_bank_statement_file(coda_attachment.raw):
|
|
if self._fields.get('extension_number'): # Only on stable because hard to extend code in new module.
|
|
extension_number, stmt_vals = data
|
|
else:
|
|
extension_number = None
|
|
stmt_vals = data[-1]
|
|
journals = [
|
|
journal for
|
|
journal in acc_journal_map.get(sanitize_account_number(account_number), [])
|
|
if (
|
|
(journal.currency_id and journal.currency_id.name == currency)
|
|
or not journal.currency_id
|
|
)
|
|
]
|
|
if len(journals) > 1 and extension_number:
|
|
journal = next((j for j in journals if j.extension_number == extension_number), False)
|
|
else:
|
|
journal = journals[0] if journals else None
|
|
if journal:
|
|
journal.bank_statements_source = "l10n_be_codaclean"
|
|
else:
|
|
skipped_bank_accounts.add(f"{account_number} ({currency})")
|
|
continue
|
|
stmt_vals = journal._complete_bank_statement_vals(stmt_vals, journal, account_number, coda_attachment)
|
|
statement_ids.extend(journal.with_context(skip_pdf_attachment_generation=True)._create_bank_statements(stmt_vals, raise_no_imported_file=False)[0])
|
|
if statement_ids:
|
|
statement_ids_all.extend(statement_ids)
|
|
# We can not add an attachment to multiple bank statements at once.
|
|
# (See function `write` of model 'account.bank.statement' in module 'account'.)
|
|
pdf_attachment = self.env['ir.attachment'].create({
|
|
'name': 'codaclean_pdf.pdf',
|
|
'type': 'binary',
|
|
'mimetype': 'application/pdf',
|
|
'datas': pdf_b64,
|
|
})
|
|
for statement in self.env['account.bank.statement'].browse(statement_ids):
|
|
statement.attachment_ids |= pdf_attachment
|
|
# We may have a lot of statements to import, so we commit after each so that a later error doesn't discard previous work
|
|
if not modules.module.current_test:
|
|
self.env.cr.commit()
|
|
except (UserError, ValueError) as e:
|
|
_logger.error("Error while importing Codaclean file: %s", e)
|
|
# We need to rollback here otherwise the next iteration will still have the error when trying to commit
|
|
self.env.cr.rollback()
|
|
if skipped_bank_accounts:
|
|
_logger.info("No journals were found for the following bank accounts parsed from the Coda files: %s", ','.join(skipped_bank_accounts))
|
|
return statement_ids_all
|
|
|
|
def l10n_be_codaclean_manually_fetch_coda_transactions(self):
|
|
self.ensure_one()
|
|
codas = self._l10n_be_codaclean_fetch_coda_transactions(self.company_id)
|
|
statement_ids = self._l10n_be_codaclean_import_coda_files(self.company_id, codas)
|
|
return self.env["account.bank.statement.line"]._action_open_bank_reconciliation_widget(
|
|
extra_domain=[("statement_id", "in", statement_ids)],
|
|
)
|
|
|
|
@api.model
|
|
def _l10n_be_codaclean_cron_fetch_coda_transactions(self):
|
|
coda_companies = self.env['res.company'].search([
|
|
('l10n_be_codaclean_is_connected', '=', True),
|
|
])
|
|
if not coda_companies:
|
|
_logger.info("No company is connected to Codaclean.")
|
|
return
|
|
for company in coda_companies:
|
|
# We want to avoid raising in the cron
|
|
try:
|
|
codas = self._l10n_be_codaclean_fetch_coda_transactions(company)
|
|
except (UserError, RedirectWarning) as e:
|
|
_logger.warning("Fetching coda files for company '%s' (id = %s) failed: %s", company.name, company.id, exception_to_unicode(e))
|
|
continue
|
|
_logger.info("%s coda files were fetched for company '%s' (id = %s).", len(codas), company.name, company.id)
|
|
statement_count = len(self._l10n_be_codaclean_import_coda_files(company, codas))
|
|
_logger.info("%s bank statements were imported for company '%s' (id = %s).", statement_count, company.name, company.id)
|