From b319b0de6aafe11ad485f5219b26ef5aa7980086 Mon Sep 17 00:00:00 2001 From: Felipe Garcia Suez Date: Fri, 21 Aug 2026 20:47:37 +0000 Subject: [PATCH] [ADD] account_statement_import_sheet_file_ux: preview the file a mapping expects Most of the support around the statement sheet import comes from mappings that do not describe the file the bank exports, and the error the user gets back ('Date' is not in list) says nothing about which part of the mapping is wrong. Adds a "Preview Mapping" button on the sheet mapping that renders a sample sheet, with column letters and row numbers, built from the mapping as it is configured: where the header row must be, which columns must exist, and how dates and amounts must be written, with three sample transactions filled in. The sample can be downloaded as xlsx; every cell is written as text, the way the parser reads them, so the file can be imported with that very mapping to check it end to end. The preview also reads the mapping back in plain words and warns about the configurations that are going to fail, and a failed import now names the mapping and the missing column (or the date format that does not match) and offers a button that opens the mapping. Carries the remaining fixes as an extension instead of patching the base module: case and padding insensitive column matching, the decimal cast the xls parser needs, a clearer name and help for the header row number, and clearing the amount columns when the amount type changes. Depends on the xls and xlsx modules and auto installs with the base one, so a database that can import csv statements can also import spreadsheets. --- .../README.rst | 125 ++++++ .../__init__.py | 2 + .../__manifest__.py | 43 +++ ...account_statement_import_sheet_file_ux.pot | 332 ++++++++++++++++ .../models/__init__.py | 2 + .../account_statement_import_sheet_mapping.py | 361 ++++++++++++++++++ .../account_statement_import_sheet_parser.py | 31 ++ .../security/ir.model.access.csv | 2 + .../tests/__init__.py | 1 + .../tests/test_mapping_preview.py | 242 ++++++++++++ ...ent_import_sheet_mapping_preview_views.xml | 72 ++++ ...t_statement_import_sheet_mapping_views.xml | 25 ++ .../wizard/__init__.py | 2 + .../wizard/account_statement_import.py | 64 ++++ ..._statement_import_sheet_mapping_preview.py | 84 ++++ 15 files changed, 1388 insertions(+) create mode 100644 account_statement_import_sheet_file_ux/README.rst create mode 100644 account_statement_import_sheet_file_ux/__init__.py create mode 100644 account_statement_import_sheet_file_ux/__manifest__.py create mode 100644 account_statement_import_sheet_file_ux/i18n/account_statement_import_sheet_file_ux.pot create mode 100644 account_statement_import_sheet_file_ux/models/__init__.py create mode 100644 account_statement_import_sheet_file_ux/models/account_statement_import_sheet_mapping.py create mode 100644 account_statement_import_sheet_file_ux/models/account_statement_import_sheet_parser.py create mode 100644 account_statement_import_sheet_file_ux/security/ir.model.access.csv create mode 100644 account_statement_import_sheet_file_ux/tests/__init__.py create mode 100644 account_statement_import_sheet_file_ux/tests/test_mapping_preview.py create mode 100644 account_statement_import_sheet_file_ux/views/account_statement_import_sheet_mapping_preview_views.xml create mode 100644 account_statement_import_sheet_file_ux/views/account_statement_import_sheet_mapping_views.xml create mode 100644 account_statement_import_sheet_file_ux/wizard/__init__.py create mode 100644 account_statement_import_sheet_file_ux/wizard/account_statement_import.py create mode 100644 account_statement_import_sheet_file_ux/wizard/account_statement_import_sheet_mapping_preview.py 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..b6fcaca2 --- /dev/null +++ b/account_statement_import_sheet_file_ux/README.rst @@ -0,0 +1,125 @@ +.. |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. + +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. + +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..6728810a --- /dev/null +++ b/account_statement_import_sheet_file_ux/__manifest__.py @@ -0,0 +1,43 @@ +############################################################################## +# +# 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 and the column parser", + "author": "ADHOC SA", + "website": "www.adhoc.com.ar", + "license": "AGPL-3", + "images": [], + "depends": [ + "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..3571d009 --- /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-21 20:46+0000\n" +"PO-Revision-Date: 2026-08-21 20:46+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\n" +" 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/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..09a844ed --- /dev/null +++ b/account_statement_import_sheet_file_ux/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mapping_preview 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..b39054cf --- /dev/null +++ b/account_statement_import_sheet_file_ux/wizard/account_statement_import.py @@ -0,0 +1,64 @@ +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 _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", + }