diff --git a/l10n_nl_tax_statement/__init__.py b/l10n_nl_tax_statement/__init__.py index 0650744f6..bf588bc8b 100644 --- a/l10n_nl_tax_statement/__init__.py +++ b/l10n_nl_tax_statement/__init__.py @@ -1 +1,2 @@ from . import models +from . import report diff --git a/l10n_nl_tax_statement/__manifest__.py b/l10n_nl_tax_statement/__manifest__.py index 032beed9b..81302a022 100644 --- a/l10n_nl_tax_statement/__manifest__.py +++ b/l10n_nl_tax_statement/__manifest__.py @@ -8,7 +8,7 @@ "license": "AGPL-3", "author": "Onestein, Odoo Community Association (OCA)", "website": "https://github.com/OCA/l10n-netherlands", - "depends": ["account", "date_range"], + "depends": ["account", "date_range", "report_xlsx"], "data": [ "security/ir.model.access.csv", "security/tax_statement_security_rule.xml", diff --git a/l10n_nl_tax_statement/readme/USAGE.rst b/l10n_nl_tax_statement/readme/USAGE.rst index 63737ff90..5545d6ea8 100644 --- a/l10n_nl_tax_statement/readme/USAGE.rst +++ b/l10n_nl_tax_statement/readme/USAGE.rst @@ -23,6 +23,10 @@ Printing a PDF report: #. If you need to print the report in PDF, open a statement form and click: `Print -> NL Tax Statement` +Exporting a XLS report: + +#. If you need to export the report in XLSX, open a statement form and click: `Print -> NL Tax Statement XLS export` + Multicompany fiscal unit: #. According the Dutch Tax Authority, for all the companies belonging to a diff --git a/l10n_nl_tax_statement/report/__init__.py b/l10n_nl_tax_statement/report/__init__.py new file mode 100644 index 000000000..a926a51c7 --- /dev/null +++ b/l10n_nl_tax_statement/report/__init__.py @@ -0,0 +1 @@ +from . import l10n_nl_tax_statement_xlsx diff --git a/l10n_nl_tax_statement/report/l10n_nl_tax_statement_xlsx.py b/l10n_nl_tax_statement/report/l10n_nl_tax_statement_xlsx.py new file mode 100644 index 000000000..d19c2ded3 --- /dev/null +++ b/l10n_nl_tax_statement/report/l10n_nl_tax_statement_xlsx.py @@ -0,0 +1,208 @@ +# Copyright 2023 Onestein () +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, models + + +class NLTaxStatementXlsx(models.AbstractModel): + _name = "report.l10n_nl_tax_statement.report_tax_statement_xlsx" + _description = "NL Tax Statement XLSX report" + _inherit = "report.report_xlsx.abstract" + + def generate_xlsx_report(self, workbook, data, objects): + # Initialize common report variables + report_data = { + "workbook": workbook, + "sheet": None, # main sheet which will contains report + "columns": self._get_report_columns(), # columns of the report + "row_pos": None, # row_pos must be incremented at each writing lines + "formats": None, + } + + # One sheet per statement + for obj in objects: + # Initialize per-sheet variables + report_name = obj.name + self._define_formats(obj, workbook, report_data) + report_data["sheet"] = workbook.add_worksheet(report_name) + self._set_column_width(report_data) + + # Fill report + report_data["row_pos"] = 0 + self._write_report_title(report_name, report_data) + self._generate_report_content(obj, report_data) + + def _get_report_filters(self, report): + return [ + [_("Date from"), report.from_date.strftime("%d/%m/%Y")], + [_("Date to"), report.to_date.strftime("%d/%m/%Y")], + ] + + def _set_column_width(self, report_data): + """Set width for all defined columns. + Columns are defined with `_get_report_columns` method. + """ + for position, column in report_data["columns"].items(): + report_data["sheet"].set_column(position, position, column["width"]) + + def _define_formats(self, obj, workbook, report_data): + """Add cell formats to current workbook. + Those formats can be used on all cell. + Available formats are : + * bold + * header_left + * header_right + * row_odd + * row_pair + * row_amount_odd + * row_amount_pair + """ + color_row_odd = "#FFFFFF" + color_row_pair = "#EEEEEE" + color_row_header = "#FFFFCC" + num_format = "#,##0." + "0" * obj.currency_id.decimal_places + + report_data["formats"] = { + "bold": workbook.add_format({"bold": True}), + "header_left": workbook.add_format( + { + "bold": True, + "align": "left", + "border": False, + "bg_color": color_row_header, + } + ), + "header_right": workbook.add_format( + { + "bold": True, + "align": "right", + "border": False, + "bg_color": color_row_header, + } + ), + "row_odd": workbook.add_format( + {"border": False, "bg_color": color_row_odd} + ), + "row_pair": workbook.add_format( + {"border": False, "bg_color": color_row_pair} + ), + "row_amount_odd": workbook.add_format( + {"border": False, "bg_color": color_row_odd} + ), + "row_amount_pair": workbook.add_format( + {"border": False, "bg_color": color_row_pair} + ), + } + report_data["formats"]["row_amount_odd"].set_num_format(num_format) + report_data["formats"]["row_amount_pair"].set_num_format(num_format) + + def _get_report_columns(self): + """Define the report columns used to generate report""" + return { + 0: {"header": _("Code"), "field": "code", "width": 5}, + 1: {"header": _("Name"), "field": "name", "width": 60}, + 2: {"header": _("Turnover"), "field": "omzet", "width": 14}, + 3: {"header": _("VAT"), "field": "btw", "width": 14}, + } + + def _write_report_title(self, title, report_data): + """Write report title on current line using all defined columns width. + Columns are defined with `_get_report_columns` method. + """ + report_data["sheet"].merge_range( + report_data["row_pos"], + 0, + report_data["row_pos"], + len(report_data["columns"]) - 1, + title, + report_data["formats"]["bold"], + ) + report_data["row_pos"] += 2 + + def _write_filters(self, filters, report_data, sep=" "): + """Write one line per filter, starting on current row""" + for title, value in filters: + report_data["sheet"].write_string( + report_data["row_pos"], + 1, + title + sep + value, + ) + report_data["row_pos"] += 1 + report_data["row_pos"] += 2 + + def format_line_from_obj(self, line, report_data, is_pair_line): + """Write statement line on current row""" + is_group = line["is_group"] + for col_pos, column in report_data["columns"].items(): + value = line[column["field"]] + + # Write code and name + if column["field"] in ["code", "name"]: + report_format = ( + report_data["formats"]["row_pair"] + if is_pair_line + else report_data["formats"]["row_odd"] + ) + report_format = ( + report_data["formats"]["header_left"] if is_group else report_format + ) + + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + value or "", + report_format, + ) + + # Write amount values + if column["field"] in ["omzet", "btw"]: + if is_group: + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + column["header"], + report_data["formats"]["header_right"], + ) + else: + to_display = ( + column["field"] == "omzet" and line.format_omzet is not False + ) or (column["field"] == "btw" and line.format_btw is not False) + if to_display: + report_format = ( + report_data["formats"]["row_amount_pair"] + if is_pair_line + else report_data["formats"]["row_amount_odd"] + ) + report_data["sheet"].write_number( + report_data["row_pos"], col_pos, float(value), report_format + ) + else: + report_format = ( + report_data["formats"]["row_pair"] + if is_pair_line + else report_data["formats"]["row_odd"] + ) + # Nothing to be displayed: empty cell + report_data["sheet"].write_string( + report_data["row_pos"], + col_pos, + "", + report_format, + ) + report_data["row_pos"] += 1 + + def _generate_report_content(self, obj, report_data): + """Write statement content""" + # Write filters + filters = self._get_report_filters(obj) + self._write_filters(filters, report_data) + + # Case no lines found + if not obj.line_ids: + return + + # Write report lines + is_pair_line = 0 + for line in obj.line_ids: + self.format_line_from_obj(line, report_data, is_pair_line) + is_pair_line = 1 if not is_pair_line and not line.is_group else 0 diff --git a/l10n_nl_tax_statement/tests/test_l10n_nl_vat_statement.py b/l10n_nl_tax_statement/tests/test_l10n_nl_vat_statement.py index 8cacdf924..ca9b79f53 100644 --- a/l10n_nl_tax_statement/tests/test_l10n_nl_vat_statement.py +++ b/l10n_nl_tax_statement/tests/test_l10n_nl_vat_statement.py @@ -163,6 +163,18 @@ def _create_test_invoice(self): self.invoice_1 = invoice_form.save() self.assertEqual(len(self.invoice_1.line_ids), 5) + def _check_export_xls(self, statement): + """Generate XLS report from action""" + report = "l10n_nl_tax_statement.action_report_tax_statement_xls_export" + self.report_action = self.env.ref(report) + self.assertEqual(self.report_action.report_type, "xlsx") + model = self.env["report.%s" % self.report_action["report_name"]].with_context( + active_model="l10n.nl.vat.statement" + ) + res = model.create_xlsx_report(statement.ids, data=None) + self.assertTrue(res[0]) + self.assertEqual(res[1], "xlsx") + def test_01_onchange(self): daterange_type = self.env["date.range.type"].create({"name": "Type 1"}) daterange = self.env["date.range"].create( @@ -192,6 +204,9 @@ def test_01_onchange(self): self.assertEqual(statement.unreported_move_from_date, new_date) self.assertEqual(statement.btw_total, 0.0) + # Export XLS without errors + self._check_export_xls(statement) + def test_02_post_final(self): # in draft self.assertEqual(self.statement_1.state, "draft") @@ -231,6 +246,9 @@ def test_02_post_final(self): self.assertEqual(self.statement_1.btw_total, 0.0) + # Export XLS without errors + self._check_export_xls(self.statement_1) + def test_03_reset(self): self.statement_1.reset() self.assertEqual(self.statement_1.state, "draft") @@ -243,6 +261,9 @@ def test_03_reset(self): self.assertTrue(line.view_base_lines()) self.assertTrue(line.view_tax_lines()) + # Export XLS without errors + self._check_export_xls(self.statement_1) + def test_04_write(self): self.statement_1.post() with self.assertRaises(UserError): @@ -250,6 +271,9 @@ def test_04_write(self): self.assertEqual(self.statement_1.btw_total, 0.0) + # Export XLS without errors + self._check_export_xls(self.statement_1) + def test_05_unlink_exception(self): self.statement_1.post() with self.assertRaises(UserError): @@ -288,6 +312,9 @@ def test_09_update_working(self): self.assertEqual(self.statement_1.btw_total, 21.0) self.assertEqual(self.statement_1.format_btw_total, "21.00") + # Export XLS without errors + self._check_export_xls(self.statement_1) + def test_10_line_unlink_exception(self): self.assertEqual(len(self.statement_1.line_ids.ids), 0) self.assertEqual(self.statement_1.btw_total, 0.0) @@ -333,6 +360,9 @@ def test_12_undeclared_invoice(self): self.assertTrue(line.view_base_lines()) self.assertTrue(line.view_tax_lines()) + # Export XLS without errors + self._check_export_xls(self.statement_1) + invoice2 = self.invoice_1.copy() invoice2.action_post() statement2 = self.env["l10n.nl.vat.statement"].create({"name": "Statement 2"}) @@ -362,6 +392,9 @@ def test_12_undeclared_invoice(self): with self.assertRaises(UserError): invoice_lines[0].date = fields.Date.today() + # Export XLS without errors + self._check_export_xls(statement2) + def test_13_no_previous_statement_posted(self): statement2 = self.env["l10n.nl.vat.statement"].create({"name": "Statement 2"}) statement2.statement_update() diff --git a/l10n_nl_tax_statement/views/report_tax_statement.xml b/l10n_nl_tax_statement/views/report_tax_statement.xml index b081daa83..33a663048 100644 --- a/l10n_nl_tax_statement/views/report_tax_statement.xml +++ b/l10n_nl_tax_statement/views/report_tax_statement.xml @@ -12,4 +12,21 @@ report + + + NL Tax Statement XLS export + l10n.nl.vat.statement + xlsx + l10n_nl_tax_statement.report_tax_statement_xlsx + l10n_nl_tax_statement.report_tax_statement_xlsx + + report +