From a9e23c101dec5679aeb71059fc1fb689c28726e6 Mon Sep 17 00:00:00 2001 From: Marco Colombo Date: Wed, 26 May 2021 14:11:45 +0200 Subject: [PATCH] [ADD] passaggio a xmlschema per l'import delle fatture --- l10n_it_fatturapa_in/__manifest__.py | 9 +- l10n_it_fatturapa_in/models/attachment.py | 2 +- .../tests/test_import_fatturapa_xml.py | 2 +- l10n_it_fatturapa_in/wizard/efattura.py | 175 ++++++++++++++++++ .../wizard/link_to_existing_invoice.py | 4 +- .../wizard/wizard_import_fatturapa.py | 18 +- 6 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 l10n_it_fatturapa_in/wizard/efattura.py diff --git a/l10n_it_fatturapa_in/__manifest__.py b/l10n_it_fatturapa_in/__manifest__.py index 6da283caad91..4cb79225af3c 100644 --- a/l10n_it_fatturapa_in/__manifest__.py +++ b/l10n_it_fatturapa_in/__manifest__.py @@ -28,5 +28,12 @@ 'security/ir.model.access.csv', 'security/rules.xml', ], - "installable": True + "installable": True, + 'external_dependencies': { + 'python': [ + 'elementpath', + 'xmlschema', + 'asn1crypto' + ], + } } diff --git a/l10n_it_fatturapa_in/models/attachment.py b/l10n_it_fatturapa_in/models/attachment.py index 6c0505e45929..2751c788f4ee 100644 --- a/l10n_it_fatturapa_in/models/attachment.py +++ b/l10n_it_fatturapa_in/models/attachment.py @@ -118,7 +118,7 @@ def extract_attachments(self, AttachmentsData, invoice_id): name = _("Attachment without name") else: name = attach.NomeAttachment - content = attach.Attachment + content = attach.Attachment.encode() _attach_dict = { 'name': name, 'datas': base64.b64encode(content), diff --git a/l10n_it_fatturapa_in/tests/test_import_fatturapa_xml.py b/l10n_it_fatturapa_in/tests/test_import_fatturapa_xml.py index af9d75b607bc..86ebbb46f5aa 100644 --- a/l10n_it_fatturapa_in/tests/test_import_fatturapa_xml.py +++ b/l10n_it_fatturapa_in/tests/test_import_fatturapa_xml.py @@ -665,7 +665,7 @@ def test_44_xml_import(self): self.assertTrue(len(invoice.invoice_line_ids) == 3) def test_45_xml_many_zeros(self): - res = self.run_wizard('test42', 'IT05979361218_016.xml') + res = self.run_wizard('test45', 'IT05979361218_016.xml') invoice_id = res.get('domain')[0][2][0] invoice = self.invoice_model.browse(invoice_id) self.assertEqual(invoice.amount_total, 18.07) diff --git a/l10n_it_fatturapa_in/wizard/efattura.py b/l10n_it_fatturapa_in/wizard/efattura.py new file mode 100644 index 000000000000..620912feb2e7 --- /dev/null +++ b/l10n_it_fatturapa_in/wizard/efattura.py @@ -0,0 +1,175 @@ +import re +import logging +import xmlschema +from lxml import etree +from datetime import datetime + +from odoo.modules.module import get_module_resource + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.DEBUG) + +XSD_SCHEMA = 'Schema_del_file_xml_FatturaPA_versione_1.2.1.xsd' + +_xsd_schema = get_module_resource('l10n_it_fatturapa', 'bindings', 'xsd', + XSD_SCHEMA) +_root = etree.parse(_xsd_schema) + +date_types = {} +datetime_types = {} + + +def get_parent_element(e): + for ancestor in e.iterancestors(): + if 'name' in ancestor.attrib: + return ancestor + + +def get_type_query(e): + return "//*[@type='%s']" % e.attrib['name'] + + +def collect_element(target, element, parent=None): + if parent is None: + parent = get_parent_element(element) + + path = '//%s/%s' % (parent.attrib['name'], element.attrib['name']) + mandatory = element.attrib.get('minOccurs') != '0' + if path not in target: + target[path] = mandatory + else: + assert target[path] == mandatory, \ + 'Element %s is already present with different minOccurs value' % \ + path + + +def collect_elements_by_type_query(target, query): + for element in _root.xpath(query): + parent_type = get_parent_element(element) + for parent in _root.xpath(get_type_query(parent_type)): + collect_element(target, element, parent) + + +def collect_elements_by_type(target, element_type): + collect_elements_by_type_query(target, get_type_query(element_type)) + + +def collect_types(): + # simpleType, we look at the base of restriction + for element_type in _root.findall('//{*}simpleType'): + base = element_type.find('{*}restriction').attrib['base'] + + if base == 'xs:date': + collect_elements_by_type(date_types, element_type) + elif base == 'xs:dateTime': + collect_elements_by_type(datetime_types, element_type) + + # complexType containing xs:date children + collect_elements_by_type_query(date_types, "//*[@type='xs:date']") + + # complexType containing xs:dateTime children + collect_elements_by_type_query(datetime_types, "//*[@type='xs:dateTime']") + + +def parse_datetime(s): + m = re.match(r'(.*?)(\+|-)(\d+):(\d+)', s) + if m: + s = "".join(m.group(1, 2, 3, 4)) + return datetime.strptime(s, '%Y-%m-%dT%H:%M:%S.%f%z') + + +def _fix_xmlstring(xml_string): + """Possono arrivare dallo SdI URL con entity/caratteri aggiuntivi, + tronchiamo all'URL corretto. + + Sulla sintassi, il W3.org dice: + In general, however, users should assume that the namespace URI is + simply a name, not the address of a document on the Web. + Tuttavia, in questo caso, non è un namespace name deciso arbitrariamente + dall'utente che ha generato l'XML, ma uno specifico URI, + http://www.w3.org/2000/09/xmldsig, perché quello è il namespace previsto + dalle specifiche, anche se il software dell SdI non fa la verifica + dell'URI. + + Il controllo effettuato è che l'URI che arriva inizi per + http://www.w3.org/2000/09/xmldsig, e si tronca il resto. + + HACK2: 0.0000000 rappresentato come 0E-7 da decimal.Decimal + """ + + # xmlns:ds="http://www.w3.org/2000/09/xmldsig#"" + xml_string = xml_string.decode() + # HACK#1 - url invalido + xml_string = re.sub(r'xmlns:ds="http://www.w3.org/2000/09/xmldsig([^"]*)"', + 'xmlns:ds="http://www.w3.org/2000/09/xmldsig#"', xml_string) + xml_string = re.sub(r"xmlns:ds='http://www.w3.org/2000/09/xmldsig([^']*)'", + "xmlns:ds='http://www.w3.org/2000/09/xmldsig#'", xml_string) + # HACK#2 - in attesa di fix su xmlschema + xml_string = re.sub(r">\s*0.0000000+\s*<", ">0.00<", xml_string) + return xml_string.encode() + + +def CreateFromDocument(xml_string): + # il codice seguente rimpiazza fatturapa.CreateFromDocument(xml_string) + class ObjectDict(object): + def __getattr__(self, attr): + try: + return getattr(self.__dict__, attr) + except AttributeError: + return None + + def __getitem__(self, *attr, **kwattr): + return self.__dict__.__getitem__(*attr, **kwattr) + + def __setitem__(self, *attr, **kwattr): + return self.__dict__.__setitem__(*attr, **kwattr) + + # TODO: crearlo una tantum? + validator = xmlschema.XMLSchema(_xsd_schema) + + xml_string = _fix_xmlstring(xml_string) + root = etree.fromstring(xml_string) + + problems = [] + tree = etree.ElementTree(root) + + # remove timezone from type `xs:date` if any or + # pyxb will fail to compare with + for path, mandatory in date_types.items(): + for element in root.xpath(path): + result = element.text.strip() + if len(result) > 10: + msg = 'removed timezone information from date only element ' \ + '%s: %s' % (tree.getpath(element), element.text) + problems.append(msg) + element.text = result[:10] + + # remove bogus dates accepted by ADE but not by python + for path, mandatory in datetime_types.items(): + for element in root.xpath(path): + try: + d = parse_datetime(element.text) + if d < parse_datetime('1970-01-01T00:00:00.000+0000'): + raise ValueError + except Exception as e: + element_path = tree.getpath(element) + if mandatory: + _logger.error('element %s is invalid but is mandatory: ' + '%s' % (element_path, element.text)) + else: + element.getparent().remove(element) + msg = 'removed invalid dateTime element %s: %s (%s)' % ( + element_path, element.text, e) + problems.append(msg) + _logger.warn(msg) + + # fix trailing spaces in + for pec in root.xpath("//PECDestinatario"): + pec.text = pec.text.rstrip() + + validat = validator.to_dict(tree, dict_class=ObjectDict) + setattr(validat, '_xmldoctor', problems) + return validat + + +collect_types() diff --git a/l10n_it_fatturapa_in/wizard/link_to_existing_invoice.py b/l10n_it_fatturapa_in/wizard/link_to_existing_invoice.py index 9d0b8bbe5ff8..7d1af1aae05f 100644 --- a/l10n_it_fatturapa_in/wizard/link_to_existing_invoice.py +++ b/l10n_it_fatturapa_in/wizard/link_to_existing_invoice.py @@ -2,12 +2,12 @@ from odoo import models, api, fields from odoo.tools.translate import _ from odoo.exceptions import UserError -from odoo.addons.l10n_it_fatturapa.bindings import fatturapa +from . import efattura def get_invoice_obj(fatturapa_attachment): xml_string = fatturapa_attachment.get_xml_string() - return fatturapa.CreateFromDocument(xml_string) + return efattura.CreateFromDocument(xml_string) class WizardLinkToInvoiceLine(models.TransientModel): diff --git a/l10n_it_fatturapa_in/wizard/wizard_import_fatturapa.py b/l10n_it_fatturapa_in/wizard/wizard_import_fatturapa.py index 4dcfd3717c70..c5dc49edac83 100644 --- a/l10n_it_fatturapa_in/wizard/wizard_import_fatturapa.py +++ b/l10n_it_fatturapa_in/wizard/wizard_import_fatturapa.py @@ -1,10 +1,11 @@ import logging +from datetime import datetime from odoo import models, api, fields from odoo.tools import float_is_zero from odoo.tools.translate import _ from odoo.exceptions import UserError -from odoo.addons.l10n_it_fatturapa.bindings import fatturapa +from . import efattura from odoo.addons.base_iban.models.res_partner_bank import pretty_iban _logger = logging.getLogger(__name__) @@ -395,7 +396,7 @@ def get_account_taxes(self, AliquotaIVA, Natura): def get_line_product(self, line, partner): product = None supplier_info = self.env['product.supplierinfo'] - if len(line.CodiceArticolo) == 1: + if len(line.CodiceArticolo or []) == 1: supplier_code = line.CodiceArticolo[0].CodiceValore supplier_infos = supplier_info.search([ ('product_code', '=', supplier_code), @@ -916,7 +917,9 @@ def invoiceCreate( e_invoice_received_date.date() else: e_invoice_received_date = fatturapa_attachment.create_date.date() - e_invoice_date = FatturaBody.DatiGenerali.DatiGeneraliDocumento.Data.date() + + e_invoice_date = datetime.strptime( + FatturaBody.DatiGenerali.DatiGeneraliDocumento.Data, '%Y-%m-%d').date() invoice_data = { 'e_invoice_received_date': e_invoice_received_date, @@ -1027,8 +1030,9 @@ def invoiceCreate( def set_vendor_bill_data(self, FatturaBody, invoice): if not invoice.date_invoice: invoice.update({ - 'date_invoice': - FatturaBody.DatiGenerali.DatiGeneraliDocumento.Data.date(), + 'date_invoice': datetime.strptime( + FatturaBody.DatiGenerali.DatiGeneraliDocumento.Data, + '%Y-%m-%d').date(), }) if not invoice.reference: invoice.update({ @@ -1176,7 +1180,7 @@ def set_activity_progress(self, FatturaBody, invoice_id): def _get_last_due_date(self, DatiPagamento): dates = [] - for PaymentLine in DatiPagamento: + for PaymentLine in DatiPagamento or []: details = PaymentLine.DettaglioPagamento if details: for dline in details: @@ -1438,7 +1442,7 @@ def check_invoice_amount(self, invoice, FatturaElettronicaBody): def get_invoice_obj(self, fatturapa_attachment): xml_string = fatturapa_attachment.get_xml_string() - return fatturapa.CreateFromDocument(xml_string) + return efattura.CreateFromDocument(xml_string) @api.multi def importFatturaPA(self):