diff --git a/account_statement_import_sheet_file/models/account_statement_import_sheet_parser.py b/account_statement_import_sheet_file/models/account_statement_import_sheet_parser.py index cfe9069e..4697525c 100644 --- a/account_statement_import_sheet_file/models/account_statement_import_sheet_parser.py +++ b/account_statement_import_sheet_file/models/account_statement_import_sheet_parser.py @@ -4,6 +4,8 @@ import itertools import logging +import math +import re from collections.abc import Iterable from datetime import datetime from decimal import Decimal @@ -83,8 +85,8 @@ class AccountStatementImportSheetParser(models.TransientModel): balance_end = last_line["balance"] data.update( { - "balance_start": float(balance_start), - "balance_end_real": float(balance_end), + "balance_start": balance_start, + "balance_end_real": balance_end, } ) transactions = list( @@ -336,14 +338,14 @@ class AccountStatementImportSheetParser(models.TransientModel): balance = None if debit_credit is not None: - amount = amount.copy_abs() + amount = abs(amount) if debit_credit == mapping.debit_value: amount = -amount if original_amount: - original_amount = self._parse_decimal( - original_amount, mapping - ).copy_sign(amount) + original_amount = math.copysign( + self._parse_decimal(original_amount, mapping), amount + ) else: original_amount = 0.0 if mapping.amount_inverse_sign: @@ -457,11 +459,18 @@ class AccountStatementImportSheetParser(models.TransientModel): @api.model def _parse_decimal(self, value, mapping): if isinstance(value, Decimal): - return value + return float(value) elif isinstance(value, float): - return Decimal(value) - value = value or "0" + return value thousands, decimal = mapping._get_float_separators() + # Remove all characters except digits, thousands separator, + # decimal separator, and signs + value = ( + re.sub( + r"[^\d\-+" + re.escape(thousands) + re.escape(decimal) + "]+", "", value + ) + or "0" + ) value = value.replace(thousands, "") value = value.replace(decimal, ".") - return Decimal(value) + return float(value) diff --git a/account_statement_import_sheet_file/tests/fixtures/sample_statement_en_empty_values.xlsx b/account_statement_import_sheet_file/tests/fixtures/sample_statement_en_empty_values.xlsx new file mode 100644 index 00000000..7b9a2193 Binary files /dev/null and b/account_statement_import_sheet_file/tests/fixtures/sample_statement_en_empty_values.xlsx differ diff --git a/account_statement_import_sheet_file/tests/test_account_statement_import_sheet_file.py b/account_statement_import_sheet_file/tests/test_account_statement_import_sheet_file.py index 08caf065..4c767609 100644 --- a/account_statement_import_sheet_file/tests/test_account_statement_import_sheet_file.py +++ b/account_statement_import_sheet_file/tests/test_account_statement_import_sheet_file.py @@ -3,7 +3,9 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from base64 import b64encode +from decimal import Decimal from os import path +from unittest.mock import Mock from odoo import fields from odoo.exceptions import UserError @@ -45,6 +47,12 @@ class TestAccountStatementImportSheetFile(common.TransactionCase): "account_type": "asset_current", } ) + self.parser = self.env["account.statement.import.sheet.parser"] + # Mock the mapping object to return predefined separators + self.mock_mapping_comma_dot = Mock() + self.mock_mapping_comma_dot._get_float_separators.return_value = (",", ".") + self.mock_mapping_dot_comma = Mock() + self.mock_mapping_dot_comma._get_float_separators.return_value = (".", ",") def _data_file(self, filename, encoding=None): mode = "rt" if encoding else "rb" @@ -538,3 +546,135 @@ class TestAccountStatementImportSheetFile(common.TransactionCase): line2 = statement.line_ids.filtered(lambda x: x.payment_ref == "LABEL 2") self.assertEqual(line2.amount, 1525.00) self.assertEqual(line2.amount_currency, 1000.00) + + def test_import_xlsx_empty_values(self): + sample_statement_map_empty_values = ( + self.AccountStatementImportSheetMapping.create( + { + "name": "Sample Statement with empty values", + "amount_type": "distinct_credit_debit", + "float_decimal_sep": "comma", + "delimiter": "n/a", + "no_header": 0, + "footer_lines_skip_count": 1, + "amount_inverse_sign": 0, + "header_lines_skip_count": 1, + "quotechar": '"', + "float_thousands_sep": "dot", + "reference_column": "REF", + "description_column": "DESCRIPTION", + "amount_credit_column": "DEBIT", + "amount_debit_column": "CREDIT", + "balance_column": "BALANCE", + "timestamp_format": "%d/%m/%Y", + "timestamp_column": "DATE", + } + ) + ) + journal = self.AccountJournal.create( + { + "name": "Bank 2", + "type": "bank", + "code": "BAN2", + "currency_id": self.currency_usd.id, + "suspense_account_id": self.suspense_account.id, + } + ) + data = self._data_file("fixtures/sample_statement_en_empty_values.xlsx") + wizard = self.AccountStatementImport.with_context(journal_id=journal.id).create( + { + "statement_filename": "fixtures/sample_statement_en_empty_values.xlsx", + "statement_file": data, + "sheet_mapping_id": sample_statement_map_empty_values.id, + } + ) + wizard.with_context( + account_statement_import_sheet_file_test=True + ).import_file_button() + statement = self.AccountBankStatement.search([("journal_id", "=", journal.id)]) + self.assertEqual(len(statement), 1) + self.assertEqual(len(statement.line_ids), 3) + + def test_parse_decimal(self): + # Define a series of test cases + test_cases = [ + ( + "1,234.56", + 1234.56, + self.mock_mapping_comma_dot, + ), # standard case with thousands separator + ( + "1,234,567.89", + 1234567.89, + self.mock_mapping_comma_dot, + ), # multiple thousands separators + ( + "-1,234.56", + -1234.56, + self.mock_mapping_comma_dot, + ), # negative value + ( + "$1,234.56", + 1234.56, + self.mock_mapping_comma_dot, + ), # prefixed with currency symbol + ( + "1,234.56 USD", + 1234.56, + self.mock_mapping_comma_dot, + ), # suffixed with currency code + ( + " 1,234.56 ", + 1234.56, + self.mock_mapping_comma_dot, + ), # leading and trailing spaces + ( + "not a number", + 0, + self.mock_mapping_comma_dot, + ), # non-numeric input + (" ", 0, self.mock_mapping_comma_dot), # empty string + ("", 0, self.mock_mapping_comma_dot), # empty space + ("USD", 0, self.mock_mapping_comma_dot), # empty dolar + ( + "12,34.56", + 1234.56, + self.mock_mapping_comma_dot, + ), # unusual thousand separator placement + ( + "1234,567.89", + 1234567.89, + self.mock_mapping_comma_dot, + ), # missing one separator + ( + "1234.567,89", + 1234567.89, + self.mock_mapping_dot_comma, + ), # inverted separators + ] + + for value, expected, mock_mapping in test_cases: + with self.subTest(value=value): + result = self.parser._parse_decimal(value, mock_mapping) + self.assertEqual(result, expected, f"Failed for value: {value}") + + def test_decimal_and_float_inputs(self): + # Test direct Decimal and float inputs + self.assertEqual( + self.parser._parse_decimal(-1234.56, self.mock_mapping_comma_dot), + -1234.56, + ) + self.assertEqual( + self.parser._parse_decimal(1234.56, self.mock_mapping_comma_dot), + 1234.56, + ) + self.assertEqual( + self.parser._parse_decimal( + Decimal("-1234.56"), self.mock_mapping_comma_dot + ), + -1234.56, + ) + self.assertEqual( + self.parser._parse_decimal(Decimal("1234.56"), self.mock_mapping_comma_dot), + 1234.56, + )