From 3b288372f78c7a1483356faa8132e3f6f7d7d89b Mon Sep 17 00:00:00 2001 From: "Pedro M. Baeza" Date: Wed, 25 Feb 2026 21:36:33 +0100 Subject: [PATCH 1/2] [REF] l10n_es_aeat_sii_match: Generalize its usage Moving the contents to the SII mixin and doing the required adaptations: - Don't browse, but keep Odoo recordset in variables. - Rename variables. - Adapt DB layout to contain a many2one reference for the specified model. - Start to support other period types (trimesters). - Refactor code. - Include a new method for building the match result dictionary, to avoid code duplication and encapsulate the logic in one place. - Make it multi-record applicable. With this work, we are also promoting to both co-author and maintainers. --- l10n_es_aeat_sii_match/README.rst | 13 +- l10n_es_aeat_sii_match/__manifest__.py | 4 +- l10n_es_aeat_sii_match/models/__init__.py | 1 + l10n_es_aeat_sii_match/models/account_move.py | 238 +-------------- .../models/aeat_sii_match_difference.py | 10 +- .../models/aeat_sii_match_report.py | 208 +++++--------- l10n_es_aeat_sii_match/models/sii_mixin.py | 271 ++++++++++++++++++ l10n_es_aeat_sii_match/readme/CONTRIBUTORS.md | 1 + .../static/description/index.html | 28 +- .../views/account_move_views.xml | 2 +- .../views/aeat_sii_match_report.xml | 5 +- 11 files changed, 388 insertions(+), 393 deletions(-) create mode 100644 l10n_es_aeat_sii_match/models/sii_mixin.py diff --git a/l10n_es_aeat_sii_match/README.rst b/l10n_es_aeat_sii_match/README.rst index c94866cb4e9..9ebdfb6ffc0 100644 --- a/l10n_es_aeat_sii_match/README.rst +++ b/l10n_es_aeat_sii_match/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - =============================================================== Sistema de comprobación y contraste de facturas enviadas al SII =============================================================== @@ -17,7 +13,7 @@ Sistema de comprobación y contraste de facturas enviadas al SII .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fl10n--spain-lightgray.png?logo=github @@ -69,6 +65,7 @@ Authors ------- * Studio73 +* Tecnativa Contributors ------------ @@ -85,6 +82,7 @@ Contributors - Víctor Martínez - David Bañón Gil + - Pedro M. Baeza Maintainers ----------- @@ -105,10 +103,13 @@ promote its widespread use. .. |maintainer-Reyes4711-S73| image:: https://github.com/Reyes4711-S73.png?size=40px :target: https://github.com/Reyes4711-S73 :alt: Reyes4711-S73 +.. |maintainer-pedrobaeza| image:: https://github.com/pedrobaeza.png?size=40px + :target: https://github.com/pedrobaeza + :alt: pedrobaeza Current `maintainers `__: -|maintainer-Abranes| |maintainer-Reyes4711-S73| +|maintainer-Abranes| |maintainer-Reyes4711-S73| |maintainer-pedrobaeza| This module is part of the `OCA/l10n-spain `_ project on GitHub. diff --git a/l10n_es_aeat_sii_match/__manifest__.py b/l10n_es_aeat_sii_match/__manifest__.py index eceb2c24dcf..b2ff9bdebbc 100644 --- a/l10n_es_aeat_sii_match/__manifest__.py +++ b/l10n_es_aeat_sii_match/__manifest__.py @@ -7,7 +7,7 @@ "version": "18.0.1.0.1", "category": "Accounting & Finance", "website": "https://github.com/OCA/l10n-spain", - "author": "Studio73, Odoo Community Association (OCA)", + "author": "Studio73,Tecnativa,Odoo Community Association (OCA)", "license": "AGPL-3", "external_dependencies": {"python": ["deepdiff<8"]}, "depends": ["l10n_es_aeat_sii_oca"], @@ -17,5 +17,5 @@ "views/aeat_sii_match_report.xml", ], "installable": True, - "maintainers": ["Abranes", "Reyes4711-S73"], + "maintainers": ["Abranes", "Reyes4711-S73", "pedrobaeza"], } diff --git a/l10n_es_aeat_sii_match/models/__init__.py b/l10n_es_aeat_sii_match/models/__init__.py index 348cf1120b1..55364b4f3a6 100644 --- a/l10n_es_aeat_sii_match/models/__init__.py +++ b/l10n_es_aeat_sii_match/models/__init__.py @@ -1,3 +1,4 @@ from . import account_move from . import aeat_sii_match_difference from . import aeat_sii_match_report +from . import sii_mixin diff --git a/l10n_es_aeat_sii_match/models/account_move.py b/l10n_es_aeat_sii_match/models/account_move.py index ffd92032d7b..d4f66a7a57c 100644 --- a/l10n_es_aeat_sii_match/models/account_move.py +++ b/l10n_es_aeat_sii_match/models/account_move.py @@ -1,121 +1,12 @@ -# Copyright 2018 Studio73 - Abraham Anes -# Copyright 2019 Studio73 - Pablo Fuentes -# Copyright 2022 Tecnativa - Pedro M. Baeza +# Copyright 2026 Tecnativa - Pedro M. Baeza # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import json - -from deepdiff import DeepDiff -from zeep.helpers import serialize_object - -from odoo import _, api, exceptions, fields, models -from odoo.modules.registry import Registry +from odoo import _, exceptions, models class AccountMove(models.Model): _inherit = "account.move" - sii_match_sent = fields.Text(string="SII match sent", copy=False, readonly=True) - sii_match_return = fields.Text( - string="SII match return", - copy=False, - readonly=True, - ) - sii_match_state = fields.Selection( - string="Match state", - readonly=True, - copy=False, - selection=[ - ("1", "No testable"), - ("2", "In process of contrast"), - ("3", "Not contrasted"), - ("4", "Partially contrasted"), - ("5", "Contrasted"), - ], - help="- No testable: The counterpart is not subscribed to SII " - "the record will not be contrasted.\n" - "- In process of contrast: AEAT is processing the data " - "soon will be a result.\n" - "- Not contrasted: The counterpart " - "has not sent the invoice to SII, " - "AEAT gives up to 4 months in order " - "to contrast the information.\n" - "- Partially contrasted: A invoice has been found " - "but some data is different.\n" - "- Contrasted: The counterpart has send " - "the invoice to SII, all is OK.", - ) - sii_contrast_state = fields.Selection( - string="AEAT contrast state", - readonly=True, - copy=False, - selection=[ - ("correct", "Correct"), - ("no_exist", "Doesn't exist"), - ("partially", "Partially correct"), - ], - ) - sii_match_difference_ids = fields.One2many( - string="SII match differences", - readonly=True, - copy=False, - comodel_name="l10n.es.aeat.sii.match.difference", - inverse_name="invoice_id", - ) - - def _get_diffs(self, odoo_values, sii_values): - sii_values = json.loads(json.dumps(serialize_object(sii_values))) - dp = self.env["decimal.precision"].precision_get("Account") - res = [] - if not DeepDiff: - raise exceptions.UserError( - _( - "You have not installed deepdiff library, " - "please install it in order to use this feature" - ) - ) - diff = DeepDiff(odoo_values, sii_values) - differences = diff.get("type_changes", {}) - differences.update(diff.get("values_changed", {})) - for label, value in list(differences.items()): - sii_value = value["new_value"] - odoo_value = value["old_value"] - label = label.split("['")[-1].replace("']", "") - if sii_value is not None: - # We made an explicit case for TipoImpositivo because we get - # always 2 numbers as strings, one with decimal point separator - # and another without - if label == "TipoImpositivo" or isinstance(odoo_value, float): - sii_value = round(float(sii_value), dp) - odoo_value = round(float(odoo_value), dp) - elif isinstance(odoo_value, str): - sii_value = sii_value.strip() - odoo_value = odoo_value.strip() - if sii_value != odoo_value: - res.append( - { - "sii_field": label, - "sii_return_field_value": sii_value, - "sii_sent_field_value": odoo_value, - } - ) - return res - - def _get_diffs_values(self, sii_values): - self.ensure_one() - res = [] - if self.aeat_content_sent: - odoo_values = json.loads(self.aeat_content_sent) - if self.move_type in ["out_invoice", "out_refund"]: - res += self._get_diffs( - odoo_values["FacturaExpedida"], sii_values["DatosFacturaEmitida"] - ) - elif self.move_type in ["in_invoice", "in_refund"]: - res += self._get_diffs( - odoo_values["FacturaRecibida"], sii_values["DatosFacturaRecibida"] - ) - return list((0, 0, r) for r in res) - def contrast_aeat(self): invalid_invoices = self.filtered( lambda invoice: not invoice.sii_csv @@ -131,127 +22,4 @@ def contrast_aeat(self): invoice_names=", ".join(i.name for i in invalid_invoices), ) ) - self._contrast_invoice_to_aeat() - - def _get_contrast_invoice_dict_out(self): - """Build dict with data to send to AEAT WS for invoice types: - out_invoice and out_refund. - :return: invoices (dict) : Dict XML with data for this invoice. - """ - self.ensure_one() - invoice_date = self._change_date_format(self.invoice_date) - partner = self.partner_id.commercial_partner_id - company = self.company_id - ejercicio = self.date.year - periodo = "%02d" % self.date.month - number = self.name - if self.thirdparty_invoice: - number = self.thirdparty_number - inv_dict = { - "FiltroConsulta": {}, - "PeriodoLiquidacion": {"Ejercicio": ejercicio, "Periodo": periodo}, - "IDFactura": { - "IDEmisorFactura": {"NIF": company.vat[2:]}, - "NumSerieFacturaEmisor": (number or "")[:60], - "FechaExpedicionFacturaEmisor": invoice_date, - }, - } - if not partner.aeat_simplified_invoice: - # Simplified invoices don't have counterpart - inv_dict["Contraparte"] = {"NombreRazon": partner.name[0:120]} - # Uso condicional de IDOtro/NIF - inv_dict["Contraparte"].update(self._get_sii_identifier()) - return inv_dict - - def _get_contrast_invoice_dict_in(self): - """Build dict with data to send to AEAT WS for invoice types: - in_invoice and in_refund. - :return: invoices (dict) : Dict XML with data for this invoice. - """ - self.ensure_one() - invoice_date = self._change_date_format(self.invoice_date) - ejercicio = self.date.year - periodo = "%02d" % self.date.month - inv_dict = { - "FiltroConsulta": {}, - "IDFactura": { - "IDEmisorFactura": { - "NombreRazon": self.partner_id.commercial_partner_id.name[0:120], - }, - "NumSerieFacturaEmisor": ((self.ref or "")[:60]), - "FechaExpedicionFacturaEmisor": invoice_date, - }, - "PeriodoLiquidacion": {"Ejercicio": ejercicio, "Periodo": periodo}, - } - # Uso condicional de IDOtro/NIF - ident = self._get_sii_identifier() - inv_dict["IDFactura"]["IDEmisorFactura"].update(ident) - return inv_dict - - def _get_contrast_invoice_dict(self): - self.ensure_one() - self._aeat_check_exceptions() - if self.move_type in ["out_invoice", "out_refund"]: - return self._get_contrast_invoice_dict_out() - elif self.move_type in ["in_invoice", "in_refund"]: - return self._get_contrast_invoice_dict_in() - return {} - - def _contrast_invoice_to_aeat(self): - for invoice in self.filtered(lambda i: i.state == "posted"): - serv = invoice._connect_aeat(invoice.move_type) - header = invoice._get_aeat_header(False, True) - inv_vals = {} - try: - inv_dict = invoice._get_contrast_invoice_dict() - inv_vals["sii_match_sent"] = json.dumps(inv_dict, indent=4) - res_line = False - if invoice.move_type in ["out_invoice", "out_refund"]: - res = serv.ConsultaLRFacturasEmitidas(header, inv_dict) - res_line = res["RegistroRespuestaConsultaLRFacturasEmitidas"][0] - elif invoice.move_type in ["in_invoice", "in_refund"]: - res = serv.ConsultaLRFacturasRecibidas(header, inv_dict) - res_line = res["RegistroRespuestaConsultaLRFacturasRecibidas"][0] - inv_vals.update( - {"sii_contrast_state": "no_exist", "sii_match_state": False} - ) - if res_line: - if res_line["DatosPresentacion"]["CSV"] == self.sii_csv: - cuadre_state = ( - res_line["EstadoFactura"]["EstadoCuadre"] - if res_line["EstadoFactura"] - else False - ) - if cuadre_state: - inv_vals.update( - { - "sii_match_state": res_line["EstadoFactura"][ - "EstadoCuadre" - ], - "sii_contrast_state": "correct", - } - ) - diffs = invoice._get_diffs_values(res_line) - if diffs: - inv_vals["sii_match_difference_ids"] = diffs - inv_vals.update({"sii_contrast_state": "partially"}) - invoice.sii_match_difference_ids.unlink() - inv_vals["sii_match_return"] = json.dumps( - serialize_object(res), indent=4 - ) - invoice.write(inv_vals) - except Exception as fault: - new_cr = Registry(self.env.cr.dbname).cursor() - env = api.Environment(new_cr, self.env.uid, self.env.context) - invoice = env["account.move"].browse(self.id) - inv_vals.update( - { - "sii_match_return": repr(fault), - "sii_contrast_state": False, - "sii_match_state": False, - } - ) - invoice.write(inv_vals) - new_cr.commit() - new_cr.close() - raise + return super().contrast_aeat() diff --git a/l10n_es_aeat_sii_match/models/aeat_sii_match_difference.py b/l10n_es_aeat_sii_match/models/aeat_sii_match_difference.py index ba425510010..45225b69735 100644 --- a/l10n_es_aeat_sii_match/models/aeat_sii_match_difference.py +++ b/l10n_es_aeat_sii_match/models/aeat_sii_match_difference.py @@ -9,9 +9,15 @@ class SiiMatchDifferences(models.Model): _name = "l10n.es.aeat.sii.match.difference" _description = "SII match difference" - invoice_id = fields.Many2one(string="Related invoice", comodel_name="account.move") + invoice_id = fields.Many2oneReference( + string="Document", model_field="model", readonly=True, index=True + ) + model = fields.Char(default="account.move") # the default keeps the retro-compat. report_id = fields.Many2one( - string="Related SII match report", comodel_name="l10n.es.aeat.sii.match.result" + string="Related SII match report", + comodel_name="l10n.es.aeat.sii.match.result", + ondelete="cascade", + index=True, ) sii_field = fields.Char(string="SII field name", copy=False) sii_return_field_value = fields.Char(string="SII return field value", copy=False) diff --git a/l10n_es_aeat_sii_match/models/aeat_sii_match_report.py b/l10n_es_aeat_sii_match/models/aeat_sii_match_report.py index bb30a052bca..ba6fe8821de 100644 --- a/l10n_es_aeat_sii_match/models/aeat_sii_match_report.py +++ b/l10n_es_aeat_sii_match/models/aeat_sii_match_report.py @@ -2,14 +2,13 @@ # Copyright 2019 Studio73 - Pablo Fuentes # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import copy import json +from calendar import monthrange from datetime import datetime -from dateutil.relativedelta import relativedelta from zeep.helpers import serialize_object -from odoo import _, api, exceptions, fields, models +from odoo import Command, _, api, exceptions, fields, models from odoo.exceptions import UserError from odoo.modules.registry import Registry @@ -22,10 +21,7 @@ class SiiMatchReport(models.Model): _name = "l10n.es.aeat.sii.match.report" _description = "AEAT SII match Report" - name = fields.Char( - string="Report identifier", - required=True, - ) + name = fields.Char(string="Report identifier", required=True) state = fields.Selection( selection=[ ("draft", "Draft"), @@ -55,9 +51,7 @@ class SiiMatchReport(models.Model): required=True, ) fiscalyear = fields.Integer( - string="Fiscal year", - required=True, - default=fields.Date.today().year, + string="Fiscal year", required=True, default=fields.Date.today().year ) company_id = fields.Many2one( comodel_name="res.company", @@ -65,10 +59,8 @@ class SiiMatchReport(models.Model): string="Company", required=True, ) - calculate_date = fields.Datetime( - string="Calculate date", - ) - sii_match_result = fields.One2many( + calculate_date = fields.Datetime(string="Calculate date") + result_ids = fields.One2many( comodel_name="l10n.es.aeat.sii.match.result", inverse_name="report_id", string="SII Match Result", @@ -81,50 +73,56 @@ class SiiMatchReport(models.Model): ) number_records = fields.Integer(string="Total records", readonly=True) number_records_both = fields.Integer( - string="Records in Odoo and SII", - readonly=True, - ) - number_records_odoo = fields.Integer( - string="Records only in Odoo", - readonly=True, - ) - number_records_sii = fields.Integer( - string="Records only in SII", - readonly=True, + string="Records in Odoo and SII", readonly=True ) + number_records_odoo = fields.Integer(string="Records only in Odoo", readonly=True) + number_records_sii = fields.Integer(string="Records only in SII", readonly=True) number_records_correct = fields.Integer( - string="Records correctly contrasted", - readonly=True, + string="Records correctly contrasted", readonly=True ) number_records_no_exist = fields.Integer( - string="Records without contrast", - readonly=True, + string="Records without contrast", readonly=True ) number_records_partially = fields.Integer( - string="Records partially correct", - readonly=True, + string="Records partially correct", readonly=True ) number_records_no_test = fields.Integer( - string="Records no testables", - readonly=True, + string="Records no testables", readonly=True ) number_records_in_process = fields.Integer( - string="Records in process of contrast", - readonly=True, + string="Records in process of contrast", readonly=True ) number_records_not_contrasted = fields.Integer( - string="Records not contasted", - readonly=True, + string="Records not contasted", readonly=True ) number_records_partially_contrasted = fields.Integer( - string="Records partially contrasted", - readonly=True, + string="Records partially contrasted", readonly=True ) number_records_contrasted = fields.Integer( - string="Records contrasted", - readonly=True, + string="Records contrasted", readonly=True ) + def _get_date_interval(self): + """Obtain the starting and ending dates for the selected period type.""" + self.ensure_one() + year = self.fiscalyear + if self.period_type in ("1T", "2T", "3T", "4T"): + # Trimestral + starting_month = 1 + (int(self.period_type[0]) - 1) * 3 + ending_month = starting_month + 2 + date_start = fields.Date.to_date(f"{year}-{starting_month}-01") + date_end = fields.Date.to_date( + f"{year}-{ending_month}-{monthrange(year, ending_month)[1]}" + ) + else: + # Mensual + month = int(self.period_type) + date_start = fields.Date.to_date(f"{year}-{self.period_type}-01") + date_end = fields.Date.to_date( + f"{year}-{month}-{monthrange(year, month)[1]}" + ) + return date_start, date_end + def _get_invoice_dict(self): self.ensure_one() inv_dict = { @@ -145,7 +143,7 @@ def _get_aeat_odoo_invoices_by_csv(self, sii_response): invoice_state = invoice["EstadoFactura"]["EstadoRegistro"] odoo_invoice = self.env["account.move"].search([("sii_csv", "=", csv)]) if odoo_invoice: - matched_invoices[odoo_invoice.id] = invoice + matched_invoices[odoo_invoice] = invoice elif invoice_state != "Anulada": left_invoices.append(invoice) return matched_invoices, left_invoices @@ -182,8 +180,8 @@ def _get_aeat_odoo_invoices_by_num(self, left_invoices, matched_invoices): break else: odoo_invoice = False # Don't match with any of them - if odoo_invoice and odoo_invoice.id not in list(matched_invoices.keys()): - matched_invoices[odoo_invoice.id] = invoice + if odoo_invoice and odoo_invoice not in list(matched_invoices.keys()): + matched_invoices[odoo_invoice] = invoice else: left_results.append(invoice) return matched_invoices, left_results @@ -197,95 +195,50 @@ def _get_aeat_odoo_invoices(self, sii_response): ) res = [] invoices_list = {} - for odoo_inv_id, invoice in list(matched_invoices.items()): - name = invoice["IDFactura"]["NumSerieFacturaEmisor"] - csv = invoice["DatosPresentacion"]["CSV"] - match_state = invoice["EstadoFactura"]["EstadoCuadre"] - odoo_invoice = self.env["account.move"].browse([odoo_inv_id]) - inv_location = "both" - contrast_state = "correct" - diffs = odoo_invoice._get_diffs_values(invoice) - if diffs: - contrast_state = "partially" - invoices_list[odoo_invoice.id] = { - "sii_match_return": json.dumps(str(invoice), indent=4), - "sii_match_state": match_state, - "sii_contrast_state": contrast_state, - "sii_match_difference_ids": copy.deepcopy(diffs), + for odoo_document, invoice in list(matched_invoices.items()): + vals = odoo_document._get_match_report_values(invoice) + res.append(vals) + invoices_list[odoo_document] = { + "sii_match_return": vals.pop("sii_match_return"), + "sii_match_state": vals["sii_match_state"], + "sii_contrast_state": vals["sii_contrast_state"], } - res.append( - { - "invoice": name, - "invoice_id": odoo_invoice.id, - "csv": csv, - "invoice_location": inv_location, - "sii_match_difference_ids": diffs, - "sii_match_state": match_state, - "sii_contrast_state": contrast_state, - } - ) for invoice in left_invoices: - name = invoice["IDFactura"]["NumSerieFacturaEmisor"] - csv = invoice["DatosPresentacion"]["CSV"] - match_state = invoice["EstadoFactura"]["EstadoCuadre"] - contrast_state = "no_exist" - inv_location = "sii" - diffs = [] - res.append( - { - "invoice": name, - "invoice_id": False, - "csv": csv, - "invoice_location": inv_location, - "sii_match_difference_ids": diffs, - "sii_match_state": match_state, - "sii_contrast_state": contrast_state, - } - ) + # We call the method with empty record for getting the expected result + vals = self.env["account.move"]._get_match_report_values(invoice) + vals.pop("sii_match_return") + res.append(vals) return res, invoices_list def _get_not_in_sii_invoices(self, invoices): self.ensure_one() - start_date = fields.Date.from_string(f"{self.fiscalyear}-{self.period_type}-01") - date_from = start_date - date_to = start_date + relativedelta(months=1) + date_start, date_end = self._get_date_interval() res = [] - inv_type = ( + inv_types = ( ["out_invoice", "out_refund"] if self.invoice_type == "out" else ["in_invoice", "in_refund"] ) - invoice_ids = self.env["account.move"].search( + prev_move_ids = [x.id for x in invoices.keys() if x._name == "account.move"] + invoices = self.env["account.move"].search( [ - ("date", ">=", date_from), - ("date", "<", date_to), + ("id", "not in", prev_move_ids), + ("date", ">=", date_start), + ("date", "<", date_end), ("company_id", "=", self.company_id.id), - ("id", "not in", list(invoices.keys())), - ("move_type", "in", inv_type), + ("move_type", "in", inv_types), + ("sii_enabled", "=", True), + ("state", "=", "posted"), ] ) - for invoice in invoice_ids.filtered("sii_enabled"): - if "out_invoice" in inv_type: - number = invoice.name or invoice.thirdparty_number or _("Draft") - else: - number = invoice.ref - res.append( - { - "invoice": number, - "invoice_id": invoice.id, - "sii_contrast_state": "no_exist", - "invoice_location": "odoo", - } - ) + for invoice in invoices: + res.append(invoice._get_match_report_values(False)) return res - def _update_odoo_invoices(self, invoices): + def _update_odoo_invoices(self, documents): self.ensure_one() - for invoice_id, values in list(invoices.items()): - invoice = self.env["account.move"].browse([invoice_id]) - invoice.sii_match_difference_ids.unlink() - invoice.write(values) - return [] + for document, values in documents.items(): + document.write(values) def _get_match_result_values(self, sii_response): self.ensure_one() @@ -343,7 +296,7 @@ def _get_match_result_values(self, sii_response): ), } vals = [ - (0, 0, i) + Command.create(i) for i in invoices if (i["sii_contrast_state"] != "correct" or i["sii_match_state"] == "4") ] @@ -371,6 +324,7 @@ def _get_invoices_from_sii(self): header = sii_match_report._get_aeat_header() match_vals = {} summary = {} + diffs = [] try: inv_dict = sii_match_report._get_invoice_dict() if sii_match_report.invoice_type == "out": @@ -380,10 +334,9 @@ def _get_invoices_from_sii(self): res = serv.ConsultaLRFacturasRecibidas(header, inv_dict) res_line = res["RegistroRespuestaConsultaLRFacturasRecibidas"] if res_line: - ( - match_vals["sii_match_result"], - summary, - ) = sii_match_report._get_match_result_values(res_line) + (diffs, summary) = sii_match_report._get_match_result_values( + res_line + ) match_vals.update( { "number_records": summary.get("total", 0), @@ -404,10 +357,7 @@ def _get_invoices_from_sii(self): "number_records_contrasted": summary.get("contrasted", 0), } ) - sii_match_report.sii_match_result.mapped( - "sii_match_difference_ids" - ).unlink() - sii_match_report.sii_match_result.unlink() + match_vals["result_ids"] = [Command.clear()] + diffs match_vals["state"] = "calculated" match_vals["calculate_date"] = fields.Datetime.now() sii_match_report.write(match_vals) @@ -443,19 +393,15 @@ def _get_aeat_header(self): def button_calculate(self): self._get_invoices_from_sii() - return [] def button_cancel(self): self.write({"state": "cancelled"}) - return [] def button_recover(self): self.write({"state": "draft"}) - return [] def button_confirm(self): self.write({"state": "done"}) - return [] def open_result(self): self.ensure_one() @@ -468,7 +414,7 @@ def open_result(self): "res_model": "l10n.es.aeat.sii.match.result", "views": [(tree_view and tree_view.id or False, "list"), (False, "form")], "type": "ir.actions.act_window", - "domain": [("id", "in", self.sii_match_result.ids)], + "domain": [("id", "in", self.result_ids.ids)], "context": {}, } @@ -490,7 +436,11 @@ def _get_selection_sii_match_state(self): ondelete="cascade", ) invoice = fields.Char() - invoice_id = fields.Many2one(string="Odoo invoice", comodel_name="account.move") + invoice_id = fields.Many2oneReference( + string="Document", model_field="model", readonly=True, index=True, required=True + ) + # the default keeps the retro-compatibility + model = fields.Char(default="account.move", required=True) csv = fields.Char(string="CSV") sii_match_state = fields.Selection( string="Match state", diff --git a/l10n_es_aeat_sii_match/models/sii_mixin.py b/l10n_es_aeat_sii_match/models/sii_mixin.py new file mode 100644 index 00000000000..bfefa23cb84 --- /dev/null +++ b/l10n_es_aeat_sii_match/models/sii_mixin.py @@ -0,0 +1,271 @@ +# Copyright 2018 Studio73 - Abraham Anes +# Copyright 2019 Studio73 - Pablo Fuentes +# Copyright 2022,2026 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json + +from deepdiff import DeepDiff +from zeep.helpers import serialize_object + +from odoo import Command, api, fields, models +from odoo.modules.registry import Registry +from odoo.tools import float_compare + + +class SiiMixin(models.AbstractModel): + _inherit = "sii.mixin" + + sii_match_sent = fields.Text(string="SII match sent", copy=False, readonly=True) + sii_match_return = fields.Text(string="SII match return", copy=False, readonly=True) + sii_match_state = fields.Selection( + string="Match state", + readonly=True, + copy=False, + selection=[ + ("1", "No testable"), + ("2", "In process of contrast"), + ("3", "Not contrasted"), + ("4", "Partially contrasted"), + ("5", "Contrasted"), + ], + help="- No testable: The counterpart is not subscribed to SII " + "the record will not be contrasted.\n" + "- In process of contrast: AEAT is processing the data " + "soon will be a result.\n" + "- Not contrasted: The counterpart " + "has not sent the invoice to SII, " + "AEAT gives up to 4 months in order " + "to contrast the information.\n" + "- Partially contrasted: A invoice has been found " + "but some data is different.\n" + "- Contrasted: The counterpart has send " + "the invoice to SII, all is OK.", + ) + sii_contrast_state = fields.Selection( + string="AEAT contrast state", + readonly=True, + copy=False, + selection=[ + ("correct", "Correct"), + ("no_exist", "Doesn't exist"), + ("partially", "Partially correct"), + ], + ) + sii_match_difference_ids = fields.One2many( + string="SII match differences", + readonly=True, + copy=False, + comodel_name="l10n.es.aeat.sii.match.difference", + inverse_name="invoice_id", + domain=lambda self: [("model", "=", self._name)], + ) + + def _get_diffs(self, odoo_values, sii_values): + sii_values = json.loads(json.dumps(serialize_object(sii_values))) + dp = self.env["decimal.precision"].precision_get("Account") + res = [] + diff = DeepDiff(odoo_values, sii_values) + differences = diff.get("type_changes", {}) + differences.update(diff.get("values_changed", {})) + for label, value in list(differences.items()): + sii_value = value["new_value"] + odoo_value = value["old_value"] + label = label.split("['")[-1].replace("']", "") + if sii_value is not None: + different = sii_value != odoo_value + # We made an explicit case for TipoImpositivo because we get + # always 2 numbers as strings, one with decimal point separator + # and another without + if label == "TipoImpositivo": + odoo_value = float(odoo_value) + if isinstance(odoo_value, float | int): + sii_value = float(sii_value) + different = ( + float_compare(odoo_value, sii_value, precision_digits=dp) != 0 + ) + elif isinstance(odoo_value, str): + sii_value = sii_value.strip() + odoo_value = odoo_value.strip() + different = sii_value != odoo_value + if different: + res.append( + { + "sii_field": label, + "sii_return_field_value": sii_value, + "sii_sent_field_value": odoo_value, + } + ) + return res + + def _get_diffs_values(self, sii_values): + self.ensure_one() + res = [] + if self.aeat_content_sent: + odoo_values = json.loads(self.aeat_content_sent) + mapping_key = self._get_mapping_key() + if mapping_key in ["out_invoice", "out_refund"]: + res += self._get_diffs( + odoo_values["FacturaExpedida"], sii_values["DatosFacturaEmitida"] + ) + elif mapping_key in ["in_invoice", "in_refund"]: + res += self._get_diffs( + odoo_values["FacturaRecibida"], sii_values["DatosFacturaRecibida"] + ) + return res + + def _get_match_report_values(self, inv_dict): + """Obtain from the AEAT returned dictionary, the matching result values with the + current Odoo document represented by `self`. This dictionary can be used for + writing values, dropping some keys, into `l10n.es.aeat.sii.match.result` or + into `sii.mixin` documents. + """ + if not inv_dict: + inv_dict = {} + # As we don't have .get method in zeep results, we have to go this way + name = False + csv = False + match_state = False + else: + name = inv_dict["IDFactura"]["NumSerieFacturaEmisor"] + csv = inv_dict["DatosPresentacion"]["CSV"] + match_state = ( + inv_dict["EstadoFactura"]["EstadoCuadre"] + if inv_dict["EstadoFactura"] + else False + ) + if match_state: + inv_location = "both" + contrast_state = "correct" + elif self: + inv_location = "odoo" + contrast_state = "no_exist" + elif inv_dict: + inv_location = "sii" + contrast_state = "no_exist" + diffs = self._get_diffs_values(inv_dict) if (inv_dict and self) else [] + if diffs: + contrast_state = "partially" + for diff in diffs: # Fill the document o2m values + diff["invoice_id"] = self.id + diff["model"] = self._name if self else False + vals = { + "invoice": name, + "invoice_id": self.id, + "model": self._name if self else False, + "csv": csv, + "invoice_location": inv_location, + "sii_contrast_state": contrast_state, + "sii_match_state": match_state, + "sii_match_difference_ids": [Command.create(x) for x in diffs], + } + if inv_dict: + vals["sii_match_return"] = json.dumps(str(inv_dict), indent=4) + return vals + + def contrast_aeat(self): + """Overridable hook for raising any error or filtering out records if needed.""" + for document in self: + document._contrast_invoice_to_aeat() + + def _get_contrast_invoice_dict_out(self): + """Build dict with data to send to AEAT WS for invoice types: + out_invoice and out_refund. + :return: invoices (dict) : Dict XML with data for this invoice. + """ + self.ensure_one() + invoice_date = self._change_date_format(self._get_document_date()) + partner = self._aeat_get_partner() + company = self.company_id + ejercicio = self._get_document_fiscal_year() + periodo = self._get_document_period() + number = self._get_document_serial_number() + inv_dict = { + "FiltroConsulta": {}, + "PeriodoLiquidacion": {"Ejercicio": ejercicio, "Periodo": periodo}, + "IDFactura": { + "IDEmisorFactura": { + "NIF": company.partner_id._parse_aeat_vat_info()[2] + }, + "NumSerieFacturaEmisor": (number or "")[:60], + "FechaExpedicionFacturaEmisor": invoice_date, + }, + } + if not self._is_aeat_simplified_invoice(): + # Simplified invoices don't have counterpart + inv_dict["Contraparte"] = {"NombreRazon": partner.name[0:120]} + # Uso condicional de IDOtro/NIF + inv_dict["Contraparte"].update(self._get_sii_identifier()) + return inv_dict + + def _get_contrast_invoice_dict_in(self): + """Build dict with data to send to AEAT WS for invoice types: + in_invoice and in_refund. + :return: invoices (dict) : Dict XML with data for this invoice. + """ + self.ensure_one() + invoice_date = self._change_date_format(self._get_document_date()) + ejercicio = self._get_document_fiscal_year() + periodo = self._get_document_period() + inv_dict = { + "FiltroConsulta": {}, + "IDFactura": { + "IDEmisorFactura": { + "NombreRazon": self._aeat_get_partner().name[0:120], + }, + "NumSerieFacturaEmisor": ((self.ref or "")[:60]), + "FechaExpedicionFacturaEmisor": invoice_date, + }, + "PeriodoLiquidacion": {"Ejercicio": ejercicio, "Periodo": periodo}, + } + # Uso condicional de IDOtro/NIF + ident = self._get_sii_identifier() + inv_dict["IDFactura"]["IDEmisorFactura"].update(ident) + return inv_dict + + def _get_contrast_invoice_dict(self): + self.ensure_one() + self._aeat_check_exceptions() + mapping_key = self._get_mapping_key() + if mapping_key in ["out_invoice", "out_refund"]: + return self._get_contrast_invoice_dict_out() + elif mapping_key in ["in_invoice", "in_refund"]: + return self._get_contrast_invoice_dict_in() + return {} + + def _contrast_invoice_to_aeat(self): + for document in self: + mapping_key = document._get_mapping_key() + serv = document._connect_aeat(mapping_key) + header = document._get_aeat_header(False, True) + inv_dict = document._get_contrast_invoice_dict() + inv_vals = {"sii_match_sent": json.dumps(inv_dict, indent=4)} + try: + res_line = False + if mapping_key in ["out_invoice", "out_refund"]: + res = serv.ConsultaLRFacturasEmitidas(header, inv_dict) + res_line = res["RegistroRespuestaConsultaLRFacturasEmitidas"][0] + elif mapping_key in ["in_invoice", "in_refund"]: + res = serv.ConsultaLRFacturasRecibidas(header, inv_dict) + res_line = res["RegistroRespuestaConsultaLRFacturasRecibidas"][0] + match_vals = self._get_match_report_values(res_line) + inv_vals["sii_match_return"] = match_vals["sii_match_return"] + inv_vals["sii_match_state"] = match_vals["sii_match_state"] + inv_vals["sii_contrast_state"] = match_vals["sii_contrast_state"] + inv_vals["sii_match_difference_ids"] = [Command.clear()] + match_vals[ + "sii_match_difference_ids" + ] + document.write(inv_vals) + except Exception as fault: + new_cr = Registry(self.env.cr.dbname).cursor() + env = api.Environment(new_cr, self.env.uid, self.env.context) + document = env[document._name].browse(document.id) + inv_vals = { + "sii_match_return": repr(fault), + "sii_contrast_state": False, + "sii_match_state": False, + } + document.write(inv_vals) + new_cr.commit() + new_cr.close() + raise diff --git a/l10n_es_aeat_sii_match/readme/CONTRIBUTORS.md b/l10n_es_aeat_sii_match/readme/CONTRIBUTORS.md index 8d7ef276fa2..dba45810177 100644 --- a/l10n_es_aeat_sii_match/readme/CONTRIBUTORS.md +++ b/l10n_es_aeat_sii_match/readme/CONTRIBUTORS.md @@ -7,3 +7,4 @@ - [Tecnativa](https://www.tecnativa.com): - Víctor Martínez - David Bañón Gil + - Pedro M. Baeza diff --git a/l10n_es_aeat_sii_match/static/description/index.html b/l10n_es_aeat_sii_match/static/description/index.html index 16e1958e132..c56e73c8bfd 100644 --- a/l10n_es_aeat_sii_match/static/description/index.html +++ b/l10n_es_aeat_sii_match/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Sistema de comprobación y contraste de facturas enviadas al SII -
+
+

Sistema de comprobación y contraste de facturas enviadas al SII

- - -Odoo Community Association - -
-

Sistema de comprobación y contraste de facturas enviadas al SII

-

Beta License: AGPL-3 OCA/l10n-spain Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/l10n-spain Translate me on Weblate Try me on Runboat

  • El módulo recupera los datos enviados a la AEAT mediante peticiones unitarias o por meses (como una declaración AEAT)
  • @@ -404,7 +399,7 @@

    Sistema de comprobación y contraste de facturas enviadas al SII

-

Bug Tracker

+

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 to smash it by providing a detailed and welcomed @@ -412,15 +407,16 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Studio73
  • +
  • Tecnativa
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -447,12 +444,11 @@

Maintainers

mission is to support the collaborative development of Odoo features and promote its widespread use.

Current maintainers:

-

Abranes Reyes4711-S73

+

Abranes Reyes4711-S73 pedrobaeza

This module is part of the OCA/l10n-spain project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
diff --git a/l10n_es_aeat_sii_match/views/account_move_views.xml b/l10n_es_aeat_sii_match/views/account_move_views.xml index ef0c22c77af..23be5964c8f 100644 --- a/l10n_es_aeat_sii_match/views/account_move_views.xml +++ b/l10n_es_aeat_sii_match/views/account_move_views.xml @@ -27,7 +27,7 @@ diff --git a/l10n_es_aeat_sii_match/views/aeat_sii_match_report.xml b/l10n_es_aeat_sii_match/views/aeat_sii_match_report.xml index 54ccaae3dec..8eb52537e91 100644 --- a/l10n_es_aeat_sii_match/views/aeat_sii_match_report.xml +++ b/l10n_es_aeat_sii_match/views/aeat_sii_match_report.xml @@ -117,7 +117,7 @@