diff --git a/account_statement_import_sheet_file_ux/README.rst b/account_statement_import_sheet_file_ux/README.rst new file mode 100644 index 00000000..325a7610 --- /dev/null +++ b/account_statement_import_sheet_file_ux/README.rst @@ -0,0 +1,139 @@ +.. |company| replace:: ADHOC SA + +.. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png + :alt: ADHOC SA + :target: https://www.adhoc.com.ar + +.. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png + +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: https://www.gnu.org/licenses/agpl + :alt: License: AGPL-3 + +=============================== +Bank Statement Sheet Import UX +=============================== + +Most of the support around the statement sheet import comes from mappings that +do not match the file the bank exports: a renamed header, a header row on the +wrong line, the thousands separator the other way around. The error the user +gets back (``'Debits' is not in list``) says nothing about which part of the +mapping is wrong. + + * Adds a **"Preview Mapping"** button on the statement sheet mapping. It shows a + sample sheet, with column letters and row numbers, built from the mapping as + it is configured right now: where the header row must be, which columns must + exist, and how dates and amounts must be written. Three sample transactions + are filled in so the layout is unambiguous. + * The sample can be downloaded as an **xlsx** file. Every cell is written as + text, the way the parser reads them, so the sample file can be imported with + that very mapping to check it end to end before fighting with the real + statement. + * The preview reads the mapping back to the user in plain words (date format, + separators, ignored rows and columns) and **warns about the configurations + that are going to fail**: a header row number of 0 (the header row is then + read as a transaction too and the spreadsheet import fails), or column names + on a mapping declared as having no header line. + * Rewrites the error of a **failed import**. Instead of the bare + ``'Date' is not in list``, it names the mapping and the column that is + missing (or the date format that does not match), tells the user to check the + Columns section and the preview, and offers a button that opens the mapping + right there. + * Matches the configured column names **ignoring case and padding**, so a bank + that exports ``DATE`` one month and ``Date`` the next one does not break the + import. + * Renames the ``Header lines skip count`` field to **Header row number** and + explains what the number means, which is where most of the wrong mappings + come from. + * Clears the amount columns when the **Amount type** changes, so a mapping + cannot keep columns that no longer apply to it. + * Stops rendering the statement PDF while a file is being imported. With + Enterprise installed, creating a bank statement renders its PDF + synchronously, and on a statement of a few hundred lines that render is what + makes the import time out. The attachment is also of no use to somebody who + is importing the file they already have. + +Technical notes +=============== + + * The preview is built by ``account.statement.import.sheet.mapping._preview_layout()``, + which follows what the parser actually does: the header sits on + ``max(header_lines_skip_count, 1)`` and the transactions start right after + ``header_lines_skip_count``. Ignored rows carry a label so that they exist in + the exported file and the row numbers do not shift. + * With ``no_header`` the mapping holds column indexes, so each field lands on + the position it declares. With a header the file order is irrelevant to the + parser, so the preview lays the columns out in the order of the form and says + so. + * ``_get_column_indexes`` rewrites the header cells that match a configured + name to the configured spelling and delegates to the standard lookup, instead + of duplicating it. An unknown column still raises. + * ``_parse_decimal`` casts the raw ``int`` / ``float`` values the xls parser + hands over, which the standard method only handles for ``str``, ``float`` and + ``Decimal``. + * ``account.statement.import._parse_file`` catches the ``UserError`` the sheet + module raises and re-raises a ``RedirectWarning`` carrying the original text + plus the hint. The redirect action is built inline instead of pointing at a + preview record, because the failed transaction is rolled back and any record + created in that path would no longer exist when the user clicks the button. + * ``_create_bank_statements`` adds ``skip_pdf_attachment_generation`` to the + context. ``account_accountant`` reads that key in the ``create`` of + ``account.bank.statement`` to decide whether to call + ``action_generate_attachment()``; Odoo itself skips the render the same way + when importing statements (``l10n_be_codabox``, ``l10n_be_codaclean``). + Without Enterprise the flag is a no-op. It is injected on ``self`` rather + than on the values, because the base method builds the statement recordset + from ``self.env`` before extending its context with the per-statement + ``creation_context``, so the key survives without duplicating the method. + +Installation +============ + +Only install the module. It is auto installed as soon as +``account_statement_import_sheet_file`` is installed, and it pulls +``account_statement_import_sheet_file_xls`` and +``account_statement_import_sheet_file_xlsx`` with it, so a base that can import +csv statements can also import xls and xlsx ones. + +Configuration +============= + +This module does not need any configuration. + +Usage +===== + +Go to "Accounting > Configuration > Statement Sheet Mappings", open a mapping and +click "Preview Mapping". + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: http://runbot.adhoc.com.ar/ + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* |company| |icon| + +Contributors +------------ + +Maintainer +---------- + +|company_logo| + +This module is maintained by the |company|. + +To contribute to this module, please visit https://www.adhoc.com.ar. diff --git a/account_statement_import_sheet_file_ux/__init__.py b/account_statement_import_sheet_file_ux/__init__.py new file mode 100644 index 00000000..9b429614 --- /dev/null +++ b/account_statement_import_sheet_file_ux/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/account_statement_import_sheet_file_ux/__manifest__.py b/account_statement_import_sheet_file_ux/__manifest__.py new file mode 100644 index 00000000..87567cc1 --- /dev/null +++ b/account_statement_import_sheet_file_ux/__manifest__.py @@ -0,0 +1,45 @@ +############################################################################## +# +# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar) +# All Rights Reserved. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +{ + "name": "Bank Statement Sheet Import UX", + "version": "19.0.1.0.0", + "category": "Accounting", + "summary": "Preview of the expected file for a statement sheet mapping, " + "plus usability fixes on the mapping configuration, the column parser and " + "the statement import itself", + "author": "ADHOC SA", + "website": "www.adhoc.com.ar", + "license": "AGPL-3", + "images": [], + "depends": [ + "account_statement_import_file", + "account_statement_import_sheet_file", + "account_statement_import_sheet_file_xls", + "account_statement_import_sheet_file_xlsx", + ], + "data": [ + "security/ir.model.access.csv", + "views/account_statement_import_sheet_mapping_preview_views.xml", + "views/account_statement_import_sheet_mapping_views.xml", + ], + "installable": True, + "auto_install": ["account_statement_import_sheet_file"], + "application": False, +} diff --git a/account_statement_import_sheet_file_ux/i18n/account_statement_import_sheet_file_ux.pot b/account_statement_import_sheet_file_ux/i18n/account_statement_import_sheet_file_ux.pot new file mode 100644 index 00000000..4fbf1aa8 --- /dev/null +++ b/account_statement_import_sheet_file_ux/i18n/account_statement_import_sheet_file_ux.pot @@ -0,0 +1,332 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_statement_import_sheet_file_ux +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-26 16:43+0000\n" +"PO-Revision-Date: 2026-08-26 16:43+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__amount_type +msgid "Amount type" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"Amounts carry no separator at all: the last %s digits are read as the " +"decimals." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"Amounts use '%(thousands)s' as thousands separator and '%(decimal)s' as " +"decimal separator." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Amounts use '%s' as decimal separator and no thousands separator." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import_sheet_mapping +msgid "Bank Statement Import Sheet Mapping" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import_sheet_mapping_preview +msgid "Bank Statement Import Sheet Mapping Preview" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import_sheet_parser +msgid "Bank Statement Import Sheet Parser" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Bank fee" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "Check the mapping" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_preview_form +msgid "Close" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Columns ignored at the beginning of each row: %s." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__create_uid +msgid "Created by" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__create_date +msgid "Created on" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Customer A" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Dates are read with the format %s." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import__display_name +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__display_name +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__display_name +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_parser__display_name +msgid "Display Name" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_preview_form +msgid "Download Sample File" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__header_lines_skip_count +msgid "Header row number" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import__id +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__id +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__id +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_parser__id +msgid "ID" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Ignored row" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import +msgid "Import Bank Statement Files" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Incoming transfer" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__write_date +msgid "Last Updated on" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__mapping_id +msgid "Mapping" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Mapping preview" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__preview_html +msgid "Preview" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_form +msgid "Preview Mapping" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,help:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__header_lines_skip_count +msgid "" +"Row number where the column headers are located, the first row being 1. Use " +"the 'Preview Mapping' button to check how the file is expected to look with " +"the current configuration." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Rows ignored at the end of the file: %s." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Sample Bank" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__file_data +msgid "Sample File" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__file_name +msgid "Sample File Name" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Sample line" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_form +msgid "Show how the file is expected to look with this configuration" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,help:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__amount_type +msgid "" +"Simple value: use the signed amount in the amount column\n" +"Absolute value: use a same column for debit and credit (absolute value + indicate sign)\n" +"Distinct Credit/debit Column: use a distinct column for debit and credit" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import_sheet_mapping_preview.py:0 +msgid "Statement" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "Statement Sheet Mapping" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Supplier B" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Supplier payment" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "" +"The file could not be read with the mapping '%s'. Use the 'Preview Mapping' " +"button on the mapping to see the file it is expecting and compare it with " +"yours." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The file has no header line, so every column has to be a number (the first " +"column is 0). These fields hold a name instead and are not shown below: %s" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The file must have no header row: the mapping points at column positions, so" +" the order shown here is the one your file needs." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The header row number is 0. When importing a spreadsheet the header row is " +"then read as a transaction too and the import fails. Set it to 1 if the " +"headers are in the first row of the file." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "" +"The mapping '%(mapping)s' expects a column named '%(column)s' and the file " +"does not have it. Banks rename their columns from one export to the next: " +"check the Columns section of the mapping and use the 'Preview Mapping' " +"button to see the file the mapping is expecting." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "" +"The mapping '%(mapping)s' reads the dates with the format '%(format)s' and " +"the file writes them differently. Check the 'Timestamp format' field of the " +"mapping and use the 'Preview Mapping' button to see the file the mapping is " +"expecting." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The order of the columns in your file does not matter and extra columns are " +"ignored: only the header names have to match the ones shown here." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The sample file can be imported as is: use it to check the mapping before " +"fighting with the real statement." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.mapping_preview +msgid "" +"There is no column to preview yet: fill in the Columns section of the " +"mapping and open this preview again." +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.mapping_preview +msgid "This is how your file should look like:" +msgstr "" diff --git a/account_statement_import_sheet_file_ux/i18n/es.po b/account_statement_import_sheet_file_ux/i18n/es.po new file mode 100644 index 00000000..d59e569c --- /dev/null +++ b/account_statement_import_sheet_file_ux/i18n/es.po @@ -0,0 +1,333 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_statement_import_sheet_file_ux +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-26 16:43+0000\n" +"PO-Revision-Date: 2026-08-26 16:43+0000\n" +"Last-Translator: Carla Spiaggi \n" +"Language-Team: Spanish\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__amount_type +msgid "Amount type" +msgstr "Tipo de importe" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"Amounts carry no separator at all: the last %s digits are read as the " +"decimals." +msgstr "Los importes no llevan ningún separador: los últimos %s dígitos se leen como los decimales." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"Amounts use '%(thousands)s' as thousands separator and '%(decimal)s' as " +"decimal separator." +msgstr "Los importes usan «%(thousands)s» como separador de miles y «%(decimal)s» como separador decimal." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Amounts use '%s' as decimal separator and no thousands separator." +msgstr "Los importes usan «%s» como separador decimal y no llevan separador de miles." + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import_sheet_mapping +msgid "Bank Statement Import Sheet Mapping" +msgstr "Mapeo de hojas de importación de extractos bancarios" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import_sheet_mapping_preview +msgid "Bank Statement Import Sheet Mapping Preview" +msgstr "Previsualización del mapeo de hojas de importación de extractos bancarios" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import_sheet_parser +msgid "Bank Statement Import Sheet Parser" +msgstr "Analizador de hojas de importación de extractos bancarios" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Bank fee" +msgstr "Comisión bancaria" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "Check the mapping" +msgstr "Revisar el mapeo" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_preview_form +msgid "Close" +msgstr "Cerrar" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Columns ignored at the beginning of each row: %s." +msgstr "Columnas ignoradas al comienzo de cada fila: %s." + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Customer A" +msgstr "Cliente A" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Dates are read with the format %s." +msgstr "Las fechas se leen con el formato %s." + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import__display_name +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__display_name +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__display_name +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_parser__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_preview_form +msgid "Download Sample File" +msgstr "Descargar archivo de ejemplo" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__header_lines_skip_count +msgid "Header row number" +msgstr "Número de fila del encabezado" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import__id +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__id +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__id +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_parser__id +msgid "ID" +msgstr "" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Ignored row" +msgstr "Fila ignorada" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model,name:account_statement_import_sheet_file_ux.model_account_statement_import +msgid "Import Bank Statement Files" +msgstr "Importar ficheros de extracto bancario" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Incoming transfer" +msgstr "Transferencia recibida" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__mapping_id +msgid "Mapping" +msgstr "Mapeo" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Mapping preview" +msgstr "Previsualización del mapeo" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__preview_html +msgid "Preview" +msgstr "Previsualización" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_form +msgid "Preview Mapping" +msgstr "Previsualizar mapeo" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,help:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__header_lines_skip_count +msgid "" +"Row number where the column headers are located, the first row being 1. Use " +"the 'Preview Mapping' button to check how the file is expected to look with " +"the current configuration." +msgstr "Número de fila donde se ubican los encabezados de columna (la primera fila es la 1). Use el botón «Previsualizar mapeo» para verificar cómo se espera que sea el archivo con la configuración actual." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Rows ignored at the end of the file: %s." +msgstr "Filas ignoradas al final del archivo: %s." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Sample Bank" +msgstr "Banco de ejemplo" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__file_data +msgid "Sample File" +msgstr "Archivo de ejemplo" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,field_description:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping_preview__file_name +msgid "Sample File Name" +msgstr "Nombre del archivo de ejemplo" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Sample line" +msgstr "Línea de ejemplo" + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.account_statement_import_sheet_mapping_form +msgid "Show how the file is expected to look with this configuration" +msgstr "Muestra cómo se espera que sea el archivo con esta configuración" + +#. module: account_statement_import_sheet_file_ux +#: model:ir.model.fields,help:account_statement_import_sheet_file_ux.field_account_statement_import_sheet_mapping__amount_type +msgid "" +"Simple value: use the signed amount in the amount column\n" +"Absolute value: use a same column for debit and credit (absolute value + indicate sign)\n" +"Distinct Credit/debit Column: use a distinct column for debit and credit" +msgstr "Valor simple: se utiliza para el caso en que en una sola columna haya valores que suman y restan.\nValor absoluto: se utilizará para el caso en que exista una sola columna a la que queramos quitarle el signo.\nDistinta columna de crédito/débito: este es el caso en donde existe una columna para los valores que suman y otra para los valores que restan." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import_sheet_mapping_preview.py:0 +msgid "Statement" +msgstr "Extracto" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "Statement Sheet Mapping" +msgstr "Mapeo de importación de extractos" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Supplier B" +msgstr "Proveedor B" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "Supplier payment" +msgstr "Pago a proveedor" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "" +"The file could not be read with the mapping '%s'. Use the 'Preview Mapping' " +"button on the mapping to see the file it is expecting and compare it with " +"yours." +msgstr "No se pudo leer el archivo con el mapeo «%s». Use el botón «Previsualizar mapeo» del mapeo para ver el archivo que espera y compararlo con el suyo." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The file has no header line, so every column has to be a number (the first " +"column is 0). These fields hold a name instead and are not shown below: %s" +msgstr "El archivo no tiene línea de encabezado, así que cada columna tiene que ser un número (la primera columna es la 0). Estos campos tienen un nombre en su lugar y no se muestran abajo: %s" + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The file must have no header row: the mapping points at column positions, so" +" the order shown here is the one your file needs." +msgstr "El archivo no debe tener fila de encabezado: el mapeo apunta a posiciones de columna, así que el orden que se muestra acá es el que su archivo necesita." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The header row number is 0. When importing a spreadsheet the header row is " +"then read as a transaction too and the import fails. Set it to 1 if the " +"headers are in the first row of the file." +msgstr "El número de fila del encabezado es 0. Al importar una planilla, la fila del encabezado se lee además como una transacción y la importación falla. Póngalo en 1 si los encabezados están en la primera fila del archivo." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "" +"The mapping '%(mapping)s' expects a column named '%(column)s' and the file " +"does not have it. Banks rename their columns from one export to the next: " +"check the Columns section of the mapping and use the 'Preview Mapping' " +"button to see the file the mapping is expecting." +msgstr "El mapeo «%(mapping)s» espera una columna llamada «%(column)s» y el archivo no la tiene. Los bancos les cambian el nombre a sus columnas de una exportación a la siguiente: revise la sección Columnas del mapeo y use el botón «Previsualizar mapeo» para ver el archivo que el mapeo espera." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/wizard/account_statement_import.py:0 +msgid "" +"The mapping '%(mapping)s' reads the dates with the format '%(format)s' and " +"the file writes them differently. Check the 'Timestamp format' field of the " +"mapping and use the 'Preview Mapping' button to see the file the mapping is " +"expecting." +msgstr "El mapeo «%(mapping)s» lee las fechas con el formato «%(format)s» y el archivo las escribe de otra manera. Revise el campo «Formato de marca de tiempo» del mapeo y use el botón «Previsualizar mapeo» para ver el archivo que el mapeo espera." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The order of the columns in your file does not matter and extra columns are " +"ignored: only the header names have to match the ones shown here." +msgstr "El orden de las columnas de su archivo no importa y las columnas de más se ignoran: solo los nombres de los encabezados tienen que coincidir con los que se muestran acá." + +#. module: account_statement_import_sheet_file_ux +#. odoo-python +#: code:addons/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py:0 +msgid "" +"The sample file can be imported as is: use it to check the mapping before " +"fighting with the real statement." +msgstr "El archivo de ejemplo se puede importar tal cual: úselo para verificar el mapeo antes de lidiar con el extracto real." + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.mapping_preview +msgid "" +"There is no column to preview yet: fill in the Columns section of the " +"mapping and open this preview again." +msgstr "Todavía no hay ninguna columna para previsualizar: complete la sección Columnas del mapeo y vuelva a abrir esta previsualización." + +#. module: account_statement_import_sheet_file_ux +#: model_terms:ir.ui.view,arch_db:account_statement_import_sheet_file_ux.mapping_preview +msgid "This is how your file should look like:" +msgstr "Así debería verse su archivo:" diff --git a/account_statement_import_sheet_file_ux/models/__init__.py b/account_statement_import_sheet_file_ux/models/__init__.py new file mode 100644 index 00000000..e8fa2e2f --- /dev/null +++ b/account_statement_import_sheet_file_ux/models/__init__.py @@ -0,0 +1,2 @@ +from . import account_statement_import_sheet_mapping +from . import account_statement_import_sheet_parser diff --git a/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py b/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py new file mode 100644 index 00000000..98bd5e6c --- /dev/null +++ b/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py @@ -0,0 +1,361 @@ +from datetime import timedelta + +from odoo import api, fields, models + +# Number of sample transaction lines shown in the preview +PREVIEW_LINES = 3 +# Signed amounts of those lines, as they are meant to end up in Odoo +PREVIEW_AMOUNTS = [1500.0, -2750.5, 350.75] +PREVIEW_OPENING_BALANCE = 10000.0 + + +class AccountStatementImportSheetMapping(models.Model): + _inherit = "account.statement.import.sheet.mapping" + + header_lines_skip_count = fields.Integer( + string="Header row number", + help="Row number where the column headers are located, the first row " + "being 1. Use the 'Preview Mapping' button to check how the file is " + "expected to look with the current configuration.", + ) + amount_type = fields.Selection( + help="Simple value: use the signed amount in the amount column\n" + "Absolute value: use a same column for debit and credit " + "(absolute value + indicate sign)\n" + "Distinct Credit/debit Column: use a distinct column for debit and credit", + ) + + @api.onchange("amount_type") + def _clear_amount_columns(self): + """The columns of the previous amount type no longer apply.""" + self.amount_column = False + self.debit_credit_column = False + self.amount_debit_column = False + self.amount_credit_column = False + + def action_preview_mapping(self): + """Open a sample of the file this mapping expects.""" + self.ensure_one() + preview = self.env["account.statement.import.sheet.mapping.preview"].create({"mapping_id": self.id}) + return { + "type": "ir.actions.act_window", + "name": self.env._("Mapping preview"), + "res_model": preview._name, + "res_id": preview.id, + "view_mode": "form", + "target": "new", + } + + # Preview building blocks + + def _preview_column_field_names(self): + """Mapping fields that may define a column, in the order they are laid out.""" + self.ensure_one() + names = [ + "timestamp_column", + "transaction_id_column", + "reference_column", + "partner_name_column", + "description_column", + "notes_column", + ] + if self.amount_type == "distinct_credit_debit": + names += ["amount_debit_column", "amount_credit_column"] + else: + names += ["amount_column"] + if self.amount_type == "absolute_value": + names += ["debit_credit_column"] + return names + [ + "balance_column", + "currency_column", + "original_currency_column", + "original_amount_column", + "bank_name_column", + "bank_account_column", + ] + + def _preview_column_keys(self, field_name): + """Column names (or indexes) configured on a mapping field.""" + self.ensure_one() + return [key.strip() for key in (self[field_name] or "").split(",") if key.strip()] + + def _preview_format_date(self, date): + self.ensure_one() + try: + return date.strftime(self.timestamp_format) + except (ValueError, TypeError): + return date.isoformat() + + def _preview_format_amount(self, amount): + self.ensure_one() + thousands, decimal = self._get_float_separators() + if not decimal: + # Without a decimal separator the parser shifts the value according + # to the currency decimals, so the file must hold plain digits + decimals = self.env.company.currency_id.decimal_places + return str(round(amount * 10**decimals)) + # Format with the python separators first and swap them afterwards, so + # that both can be configured to the same character without clashing + text = f"{abs(amount):,.2f}".replace(",", "\x00").replace(".", decimal) + text = text.replace("\x00", thousands) + return f"-{text}" if amount < 0 else text + + def _preview_sample_values(self): + """Return {mapping field: one already formatted value per sample line}.""" + self.ensure_one() + # The file holds the inverse sign when the mapping inverts it on import + amounts = [-amount if self.amount_inverse_sign else amount for amount in PREVIEW_AMOUNTS] + balance = PREVIEW_OPENING_BALANCE + balances = [] + for amount in amounts: + balance += amount + balances.append(balance) + today = fields.Date.context_today(self) + dates = [today - timedelta(days=days) for days in reversed(range(PREVIEW_LINES))] + currency = self.env.company.currency_id + values = { + "timestamp_column": [self._preview_format_date(date) for date in dates], + "transaction_id_column": ["000123", "000124", "000125"], + "reference_column": ["REF-0001", "REF-0002", "REF-0003"], + "partner_name_column": [ + self.env._("Customer A"), + self.env._("Supplier B"), + self.env._("Sample Bank"), + ], + "description_column": [ + self.env._("Incoming transfer"), + self.env._("Supplier payment"), + self.env._("Bank fee"), + ], + "notes_column": [self.env._("Sample line")] * PREVIEW_LINES, + "balance_column": [self._preview_format_amount(balance) for balance in balances], + "currency_column": [currency.name] * PREVIEW_LINES, + "original_currency_column": ["EUR" if currency.name == "USD" else "USD"] * PREVIEW_LINES, + "original_amount_column": [self._preview_format_amount(abs(amount) / 1000) for amount in amounts], + "bank_name_column": [self.env._("Sample Bank")] * PREVIEW_LINES, + "bank_account_column": ["0000076500000000000001"] * PREVIEW_LINES, + } + if self.amount_type == "distinct_credit_debit": + values["amount_debit_column"] = [ + self._preview_format_amount(amount) if amount > 0 else "" for amount in amounts + ] + values["amount_credit_column"] = [ + self._preview_format_amount(-amount) if amount < 0 else "" for amount in amounts + ] + elif self.amount_type == "absolute_value": + values["amount_column"] = [self._preview_format_amount(abs(amount)) for amount in amounts] + values["debit_credit_column"] = [ + self.debit_value if amount < 0 else self.credit_value for amount in amounts + ] + else: + values["amount_column"] = [self._preview_format_amount(amount) for amount in amounts] + return values + + @api.model + def _preview_split_value(self, value, count): + """Spread a sample value over the columns a mapping field concatenates.""" + if count <= 1: + return [value] + words = value.split(" ") + if len(words) < count: + return [value] + [""] * (count - 1) + return words[: count - 1] + [" ".join(words[count - 1 :])] + + @api.model + def _preview_column_letter(self, index): + """Spreadsheet letter of a 0-based column index (0 -> A, 26 -> AA).""" + letters = "" + while True: + index, remainder = divmod(index, 26) + letters = chr(ord("A") + remainder) + letters + if not index: + return letters + index -= 1 + + def _preview_columns(self): + """Return the sample columns as {0-based index: (label, [value per line])}. + + Without a header the mapping holds column indexes, so each field lands on + the position it declares. With a header the file order is irrelevant to + the parser, so the columns are laid out in the order of the form. + """ + self.ensure_one() + sample_values = self._preview_sample_values() + columns = {} + position = 0 if self.no_header else self.offset_column + for field_name in self._preview_column_field_names(): + keys = self._preview_column_keys(field_name) + if not keys: + continue + values = sample_values.get(field_name, [""] * PREVIEW_LINES) + # One list of cells per column of this field, one cell per line + cells = list( + zip( + *[self._preview_split_value(value, len(keys)) for value in values], + strict=True, + ) + ) + for key, key_cells in zip(keys, cells, strict=True): + if self.no_header: + try: + index = int(key) + except ValueError: + # Not a column index: the mapping is misconfigured, the + # preview shows it as missing instead of failing here + continue + else: + index = position + position += 1 + columns[index] = (key, list(key_cells)) + return columns + + def _preview_layout(self): + """Return the sample sheet ready to be rendered or exported. + + The layout follows what the parser actually does with the mapping: the + header sits on ``max(header_lines_skip_count, 1)`` and the transactions + start right after ``header_lines_skip_count``. + """ + self.ensure_one() + columns = self._preview_columns() + width = max(columns) + 1 if columns else 0 + header_row = None if self.no_header else max(self.header_lines_skip_count, 1) + first_data_row = self.header_lines_skip_count + 1 if self.no_header else header_row + 1 + # Ignored rows carry a label so that they exist in the exported file: + # a fully empty row would not be there and the row numbers would shift + ignored_cells = [self.env._("Ignored row")] + [""] * (width - 1) if width else [] + rows = [] + for number in range(1, first_data_row): + if number == header_row: + cells = [columns[index][0] if index in columns else "" for index in range(width)] + else: + cells = list(ignored_cells) + rows.append( + { + "number": number, + "kind": "header" if number == header_row else "ignored", + "cells": cells, + } + ) + for line in range(PREVIEW_LINES): + rows.append( + { + "number": first_data_row + line, + "kind": "data", + "cells": [columns[index][1][line] if index in columns else "" for index in range(width)], + } + ) + for footer in range(self.footer_lines_skip_count): + rows.append( + { + "number": first_data_row + PREVIEW_LINES + footer, + "kind": "ignored", + "cells": list(ignored_cells), + } + ) + return { + "letters": [self._preview_column_letter(index) for index in range(width)], + "rows": rows, + "notes": self._preview_notes(), + "warnings": self._preview_warnings(), + } + + def _preview_notes(self): + """Read this mapping back to the user in plain words.""" + self.ensure_one() + thousands, decimal = self._get_float_separators() + notes = [] + if self.no_header: + notes.append( + self.env._( + "The file must have no header row: the mapping points at " + "column positions, so the order shown here is the one your " + "file needs." + ) + ) + else: + notes.append( + self.env._( + "The order of the columns in your file does not matter and " + "extra columns are ignored: only the header names have to " + "match the ones shown here." + ) + ) + notes.append(self.env._("Dates are read with the format %s.", self.timestamp_format)) + if not decimal: + notes.append( + self.env._( + "Amounts carry no separator at all: the last %s digits are " "read as the decimals.", + self.env.company.currency_id.decimal_places, + ) + ) + elif thousands: + notes.append( + self.env._( + "Amounts use '%(thousands)s' as thousands separator and " "'%(decimal)s' as decimal separator.", + thousands=thousands, + decimal=decimal, + ) + ) + else: + notes.append( + self.env._( + "Amounts use '%s' as decimal separator and no thousands " "separator.", + decimal, + ) + ) + if self.offset_column and not self.no_header: + notes.append( + self.env._( + "Columns ignored at the beginning of each row: %s.", + self.offset_column, + ) + ) + if self.footer_lines_skip_count: + notes.append( + self.env._( + "Rows ignored at the end of the file: %s.", + self.footer_lines_skip_count, + ) + ) + notes.append( + self.env._( + "The sample file can be imported as is: use it to check the " + "mapping before fighting with the real statement." + ) + ) + return notes + + def _preview_warnings(self): + """Configuration that is going to break the import, spelled out.""" + self.ensure_one() + warnings = [] + if not self.no_header and not self.header_lines_skip_count: + warnings.append( + self.env._( + "The header row number is 0. When importing a spreadsheet " + "the header row is then read as a transaction too and the " + "import fails. Set it to 1 if the headers are in the first " + "row of the file." + ) + ) + if self.no_header: + missing = [ + field_name + for field_name in self._preview_column_field_names() + for key in self._preview_column_keys(field_name) + if not key.lstrip("+-").isdigit() + ] + if missing: + warnings.append( + self.env._( + "The file has no header line, so every column has to be " + "a number (the first column is 0). These fields hold a " + "name instead and are not shown below: %s", + ", ".join( + self._fields[field_name].get_description(self.env)["string"] + for field_name in dict.fromkeys(missing) + ), + ) + ) + return warnings diff --git a/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_parser.py b/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_parser.py new file mode 100644 index 00000000..fef1c348 --- /dev/null +++ b/account_statement_import_sheet_file_ux/models/account_statement_import_sheet_parser.py @@ -0,0 +1,31 @@ +from decimal import Decimal + +from odoo import api, models + + +class AccountStatementImportSheetParser(models.TransientModel): + _inherit = "account.statement.import.sheet.parser" + + def _get_column_indexes(self, header, column_name, mapping): + """Match the configured column names ignoring case and padding. + + Banks change the capitalization of their headers between exports and the + whole import fails on a ``'Debit' is not in list`` error. Rewrite the + header cells that match a configured name to the configured spelling so + the standard lookup finds them. + """ + try: + return super()._get_column_indexes(header, column_name, mapping) + except ValueError: + configured = { + key.strip().lower(): key.strip() for key in (mapping[column_name] or "").split(",") if key.strip() + } + normalized = [configured.get(str(value).strip().lower(), value) for value in header] + return super()._get_column_indexes(normalized, column_name, mapping) + + @api.model + def _parse_decimal(self, value, mapping): + """Accept the raw numbers the xls parser hands over, not only strings.""" + if not isinstance(value, Decimal | float): + value = str(value) + return super()._parse_decimal(value, mapping) diff --git a/account_statement_import_sheet_file_ux/security/ir.model.access.csv b/account_statement_import_sheet_file_ux/security/ir.model.access.csv new file mode 100644 index 00000000..9443826f --- /dev/null +++ b/account_statement_import_sheet_file_ux/security/ir.model.access.csv @@ -0,0 +1,2 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_account_statement_import_sheet_mapping_preview,account.statement.import.sheet.mapping.preview:account.group_account_user,model_account_statement_import_sheet_mapping_preview,account.group_account_user,1,1,1,1 diff --git a/account_statement_import_sheet_file_ux/tests/__init__.py b/account_statement_import_sheet_file_ux/tests/__init__.py new file mode 100644 index 00000000..9b9aab95 --- /dev/null +++ b/account_statement_import_sheet_file_ux/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_mapping_preview +from . import test_skip_pdf_generation diff --git a/account_statement_import_sheet_file_ux/tests/test_mapping_preview.py b/account_statement_import_sheet_file_ux/tests/test_mapping_preview.py new file mode 100644 index 00000000..46945c46 --- /dev/null +++ b/account_statement_import_sheet_file_ux/tests/test_mapping_preview.py @@ -0,0 +1,242 @@ +from odoo.addons.account_statement_import_sheet_file_ux.models import ( + account_statement_import_sheet_mapping as mapping_module, +) +from odoo.exceptions import RedirectWarning +from odoo.tests.common import TransactionCase + + +class TestMappingPreview(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.parser = cls.env["account.statement.import.sheet.parser"] + cls.Mapping = cls.env["account.statement.import.sheet.mapping"] + cls.Preview = cls.env["account.statement.import.sheet.mapping.preview"] + cls.mapping_values = { + "name": "Test Mapping", + "timestamp_format": "%d/%m/%Y", + "timestamp_column": "Date", + "description_column": "Label", + "amount_type": "simple_value", + "amount_column": "Amount", + "balance_column": "Balance", + "float_thousands_sep": "dot", + "float_decimal_sep": "comma", + "header_lines_skip_count": 1, + } + cls.mapping = cls.Mapping.create(dict(cls.mapping_values)) + cls.journal = cls.env["account.journal"].create( + { + "name": "Bank UX", + "type": "bank", + "code": "BNKUX", + "currency_id": cls.env.company.currency_id.id, + } + ) + + def _layout(self, **values): + mapping = self.Mapping.create(dict(self.mapping_values, **values)) + return mapping, mapping._preview_layout() + + def _rows(self, layout, kind): + return [row for row in layout["rows"] if row["kind"] == kind] + + # Layout + + def test_header_row_follows_the_configured_row_number(self): + """The header lands where the parser looks for it, not one row off.""" + for row_number, expected in [(0, 1), (1, 1), (2, 2), (5, 5)]: + with self.subTest(header_lines_skip_count=row_number): + _mapping, layout = self._layout(header_lines_skip_count=row_number) + header_rows = self._rows(layout, "header") + self.assertEqual(len(header_rows), 1) + self.assertEqual(header_rows[0]["number"], expected) + self.assertEqual(self._rows(layout, "data")[0]["number"], expected + 1) + + def test_header_row_zero_is_warned_about(self): + """0 makes the spreadsheet parser read the header as a transaction.""" + _mapping, layout = self._layout(header_lines_skip_count=0) + self.assertTrue(layout["warnings"]) + _mapping, layout = self._layout(header_lines_skip_count=1) + self.assertFalse(layout["warnings"]) + + def test_columns_are_offset_and_footer_rows_are_ignored(self): + _mapping, layout = self._layout(offset_column=2, footer_lines_skip_count=2) + self.assertEqual(layout["letters"][:3], ["A", "B", "C"]) + header = self._rows(layout, "header")[0] + self.assertEqual(header["cells"][:3], ["", "", "Date"]) + # two leading ignored rows are not there, but the two footer ones are + self.assertEqual(len(self._rows(layout, "ignored")), 2) + + def test_sample_lines_are_the_configured_amount_type(self): + _mapping, layout = self._layout() + amounts = [row["cells"][2] for row in self._rows(layout, "data")] + self.assertEqual(amounts, ["1.500,00", "-2.750,50", "350,75"]) + _mapping, layout = self._layout( + amount_type="absolute_value", + debit_credit_column="D/C", + debit_value="D", + credit_value="C", + ) + data = self._rows(layout, "data") + self.assertEqual([row["cells"][2] for row in data], ["1.500,00", "2.750,50", "350,75"]) + self.assertEqual([row["cells"][3] for row in data], ["C", "D", "C"]) + _mapping, layout = self._layout( + amount_type="distinct_credit_debit", + amount_column=False, + amount_debit_column="Debit", + amount_credit_column="Credit", + ) + data = self._rows(layout, "data") + self.assertEqual([row["cells"][2] for row in data], ["1.500,00", "", "350,75"]) + self.assertEqual([row["cells"][3] for row in data], ["", "2.750,50", ""]) + + def test_no_header_places_columns_on_their_index(self): + _mapping, layout = self._layout( + no_header=True, + timestamp_column="0", + description_column="2", + amount_column="3", + balance_column="5", + ) + self.assertEqual(len(layout["letters"]), 6) + first = self._rows(layout, "data")[0] + self.assertEqual(first["cells"][1], "") + self.assertEqual(first["cells"][3], "1.500,00") + self.assertFalse(self._rows(layout, "header")) + + def test_no_header_with_column_names_is_warned_about(self): + _mapping, layout = self._layout(no_header=True) + self.assertTrue(layout["warnings"]) + self.assertFalse(layout["letters"]) + + def test_concatenated_columns_get_one_column_each(self): + _mapping, layout = self._layout(description_column="Label,Detail") + header = self._rows(layout, "header")[0] + self.assertEqual(header["cells"][1:3], ["Label", "Detail"]) + # the sample value is spread over both columns, not repeated + first = self._rows(layout, "data")[0] + self.assertTrue(first["cells"][1]) + self.assertTrue(first["cells"][2]) + + def test_amounts_without_decimal_separator_are_shifted(self): + _mapping, layout = self._layout(float_decimal_sep="none") + amounts = [row["cells"][2] for row in self._rows(layout, "data")] + self.assertEqual(amounts, ["150000", "-275050", "35075"]) + + def test_inverse_sign_is_reflected_in_the_file(self): + _mapping, layout = self._layout(amount_inverse_sign=True) + amounts = [row["cells"][2] for row in self._rows(layout, "data")] + self.assertEqual(amounts, ["-1.500,00", "2.750,50", "-350,75"]) + + # Wizard + + def test_preview_renders_a_table_and_an_xlsx(self): + preview = self.Preview.create({"mapping_id": self.mapping.id}) + self.assertIn(" + + + + account.statement.import.sheet.mapping.preview.form + account.statement.import.sheet.mapping.preview + +
+ + +
+
+ +
+
+
diff --git a/account_statement_import_sheet_file_ux/views/account_statement_import_sheet_mapping_views.xml b/account_statement_import_sheet_file_ux/views/account_statement_import_sheet_mapping_views.xml new file mode 100644 index 00000000..50044f28 --- /dev/null +++ b/account_statement_import_sheet_file_ux/views/account_statement_import_sheet_mapping_views.xml @@ -0,0 +1,25 @@ + + + + account.statement.import.sheet.mapping.form.ux + account.statement.import.sheet.mapping + + + + +
+
+
+
+
+
diff --git a/account_statement_import_sheet_file_ux/wizard/__init__.py b/account_statement_import_sheet_file_ux/wizard/__init__.py new file mode 100644 index 00000000..a81b5e58 --- /dev/null +++ b/account_statement_import_sheet_file_ux/wizard/__init__.py @@ -0,0 +1,2 @@ +from . import account_statement_import +from . import account_statement_import_sheet_mapping_preview diff --git a/account_statement_import_sheet_file_ux/wizard/account_statement_import.py b/account_statement_import_sheet_file_ux/wizard/account_statement_import.py new file mode 100644 index 00000000..ab8ab90b --- /dev/null +++ b/account_statement_import_sheet_file_ux/wizard/account_statement_import.py @@ -0,0 +1,82 @@ +import re + +from odoo import models +from odoo.exceptions import RedirectWarning, UserError + + +class AccountStatementImport(models.TransientModel): + _inherit = "account.statement.import" + + def _parse_file(self, data_file): + """Explain a failed sheet import instead of leaking the parser error. + + The standard message ("'Date' is not in list") says nothing about what + to do next, and the answer is almost always the same: the mapping does + not describe the file the bank exported. + """ + try: + return super()._parse_file(data_file) + except UserError as error: + mapping = self.sheet_mapping_id + if not mapping: + raise + raise RedirectWarning( + f"{error}\n\n{self._sheet_mapping_error_hint(mapping, str(error))}", + { + "type": "ir.actions.act_window", + "name": self.env._("Statement Sheet Mapping"), + "res_model": mapping._name, + "res_id": mapping.id, + "views": [(False, "form")], + "target": "current", + }, + self.env._("Check the mapping"), + ) from error + + def _create_bank_statements(self, stmts_vals, result): + """Do not render the statement PDF while importing. + + With Enterprise installed, creating a bank statement renders its PDF + synchronously (`account_accountant` hooks `create` unless the context + says otherwise). On a statement of a few hundred lines that render is + what makes the import time out, and the attachment is of no use to + somebody who is importing the file they already have. Odoo itself skips + it the same way when importing (`l10n_be_codabox`, `l10n_be_codaclean`). + + The context reaches the create because the base method builds the + statement recordset from `self.env` before extending its context. + """ + return super( + AccountStatementImport, + self.with_context(skip_pdf_attachment_generation=True), + )._create_bank_statements(stmts_vals, result) + + def _sheet_mapping_error_hint(self, mapping, message): + """Turn a raw parser error into something the user can act on.""" + self.ensure_one() + missing_column = re.search(r"'(.+?)' is not in list", message) + if missing_column: + return self.env._( + "The mapping '%(mapping)s' expects a column named " + "'%(column)s' and the file does not have it. Banks rename " + "their columns from one export to the next: check the Columns " + "section of the mapping and use the 'Preview Mapping' button " + "to see the file the mapping is expecting.", + mapping=mapping.name, + column=missing_column.group(1), + ) + if "does not match format" in message: + return self.env._( + "The mapping '%(mapping)s' reads the dates with the format " + "'%(format)s' and the file writes them differently. Check the " + "'Timestamp format' field of the mapping and use the 'Preview " + "Mapping' button to see the file the mapping is expecting.", + mapping=mapping.name, + format=mapping.timestamp_format, + ) + return self.env._( + "The file could not be read with the mapping '%s'. Use the " + "'Preview Mapping' button on the mapping to see the file it is " + "expecting and compare it with yours.", + mapping.name, + ) diff --git a/account_statement_import_sheet_file_ux/wizard/account_statement_import_sheet_mapping_preview.py b/account_statement_import_sheet_file_ux/wizard/account_statement_import_sheet_mapping_preview.py new file mode 100644 index 00000000..1e4affbf --- /dev/null +++ b/account_statement_import_sheet_file_ux/wizard/account_statement_import_sheet_mapping_preview.py @@ -0,0 +1,84 @@ +import base64 +import io +from urllib.parse import urlencode + +import xlsxwriter +from odoo import api, fields, models + + +class AccountStatementImportSheetMappingPreview(models.TransientModel): + _name = "account.statement.import.sheet.mapping.preview" + _description = "Bank Statement Import Sheet Mapping Preview" + + mapping_id = fields.Many2one( + comodel_name="account.statement.import.sheet.mapping", + string="Mapping", + required=True, + readonly=True, + ondelete="cascade", + ) + preview_html = fields.Html( + string="Preview", + compute="_compute_preview_html", + sanitize=False, + ) + file_data = fields.Binary(string="Sample File", readonly=True, attachment=False) + file_name = fields.Char(string="Sample File Name", readonly=True) + + @api.depends("mapping_id") + def _compute_preview_html(self): + for preview in self: + preview.preview_html = self.env["ir.qweb"]._render( + "account_statement_import_sheet_file_ux.mapping_preview", + preview.mapping_id._preview_layout(), + ) + + def _build_xlsx(self): + """Return the sample sheet as xlsx bytes. + + Every cell is written as text on purpose: that is how the parser reads + the file, so the sample can be imported with this very mapping. + """ + self.ensure_one() + layout = self.mapping_id._preview_layout() + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {"in_memory": True}) + sheet = workbook.add_worksheet(self.env._("Statement")) + header_format = workbook.add_format({"bold": True, "border": 1}) + for index in range(len(layout["letters"])): + sheet.set_column(index, index, 18) + for row in layout["rows"]: + for index, value in enumerate(row["cells"]): + if not value: + continue + sheet.write_string( + row["number"] - 1, + index, + value, + header_format if row["kind"] == "header" else None, + ) + workbook.close() + return output.getvalue() + + def action_download_xlsx(self): + self.ensure_one() + self.write( + { + "file_data": base64.b64encode(self._build_xlsx()), + "file_name": f"{self.mapping_id.name}.xlsx", + } + ) + params = urlencode( + { + "model": self._name, + "id": self.id, + "field": "file_data", + "filename": self.file_name, + "download": "true", + } + ) + return { + "type": "ir.actions.act_url", + "url": f"/web/content/?{params}", + "target": "self", + }