From c43240eaa0110f9a4c620cca0c5ed9a701045be9 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Tue, 4 Aug 2026 19:44:22 +0200 Subject: [PATCH 01/12] [IMP] pms_l10n_es: INE occupancy surveys for IRIA: fix hotel XML, add tourist apartments (EOAP) The INE replaced the ARCE platform with IRIA. IRIA questionnaire schemas are namespace-qualified, so the XML files generated so far are rejected at upload time (XSD error on line 1: cannot find the declaration of the root element). IRIA also removed the in-portal editing that ARCE allowed, so the generated file must be exact. Hotel survey (EOH) fixes: - Qualify the root element with the IRIA namespace, configurable through the ir.config_parameter pms_l10n_es.ine_xml_namespace_hotel. - Declare and encode the file as UTF-8 (it declared ISO-8859-1 while encoding UTF-8 bytes). - Print DIAS_ABIERTO_MES_REFERENCIA zero-padded to 2 digits and omit the URL element when the property has no website (both rejected by the IRIA schema). - Emit every decimal with exactly 2 digits and normalize the occupancy percentages in integer hundredths so the printed values add up to exactly 100.00: float artifacts have been a recurrent source of files rejected by the INE content validations. - New INE province literal on res.country.state: the survey header expects the province literal of the INE specification (max. 25 chars) and several Odoo state names exceed it. New tourist apartments survey (EOAP): - New survey_type on the INE categories and seed data for the tourist apartments categories (0-4 keys). The wizard picks the questionnaire format from the property INE category. - New apartment typology on rooms (studio / 2-4 pax / 4-6 pax / other), inferred from the room capacity when not set. - New informant fields on the property (required block in EOAP). - New XML builder with the EOAP blocks (CABECERA_APARTAMENTOS, INFORMANTE, CAPACIDAD, OCUPACION and PRECIOS per typology), sharing the guest movements and staff blocks with the hotel survey. The whole occupancy of each typology is reported under the normal rate with its ADR, which satisfies the INE content validations and has been accepted by the INE in a real submission. Tests validate both generated files against the current IRIA schemas and the exactness of the printed decimals and percentage sums. (cherry picked from commit 1bd8f953d9efd4343e3a1499a7603496060b3d14) Assisted-by: Claude Opus 5 --- .../data/pms.ine.tourism.type.category.csv | 111 +-- pms_l10n_es/data/res.country.state.csv | 106 +-- .../models/pms_ine_tourism_type_category.py | 10 + pms_l10n_es/models/pms_property.py | 18 + pms_l10n_es/models/pms_room.py | 27 + pms_l10n_es/models/res_country_state.py | 6 + .../tests/fixtures/iria_apartments_survey.xsd | 556 +++++++++++++++ .../tests/fixtures/iria_hotel_survey.xsd | 597 +++++++++++++++++ pms_l10n_es/tests/test_wizard_ine.py | 149 ++++ .../views/pms_ine_tourism_type_category.xml | 2 + pms_l10n_es/views/pms_property_views.xml | 4 + pms_l10n_es/views/pms_room_views.xml | 1 + pms_l10n_es/wizards/wizard_ine.py | 634 +++++++++++++----- pms_l10n_es/wizards/wizard_ine.xml | 5 +- 14 files changed, 1949 insertions(+), 277 deletions(-) create mode 100644 pms_l10n_es/tests/fixtures/iria_apartments_survey.xsd create mode 100644 pms_l10n_es/tests/fixtures/iria_hotel_survey.xsd diff --git a/pms_l10n_es/data/pms.ine.tourism.type.category.csv b/pms_l10n_es/data/pms.ine.tourism.type.category.csv index d67817264c..6892a04ce4 100644 --- a/pms_l10n_es/data/pms.ine.tourism.type.category.csv +++ b/pms_l10n_es/data/pms.ine.tourism.type.category.csv @@ -1,53 +1,58 @@ -"id","type","category" -"turism_category_1","Hoteles","H1" -"turism_category_2","Hoteles","H2" -"turism_category_3","Hoteles","H3" -"turism_category_4","Hoteles","H4" -"turism_category_5","Hoteles","H5" -"turism_category_6","Hoteles-apartamentos","AP" -"turism_category_7","Hoteles-apartamentos","HA" -"turism_category_8","Hoteles-apartamentos","HA1" -"turism_category_9","Hoteles-apartamentos","HA2" -"turism_category_10","Hoteles-apartamentos","HA3" -"turism_category_11","Hoteles-apartamentos","HA4" -"turism_category_12","Hoteles-apartamentos","HA5" -"turism_category_13","Hoteles-residencias","HR" -"turism_category_14","Hoteles-residencias","HR1" -"turism_category_15","Hoteles-residencias","HR2" -"turism_category_16","Hoteles-residencias","HR3" -"turism_category_17","Hoteles-residencias","HR4" -"turism_category_18","Hoteles-residencias","HR5" -"turism_category_19","Moteles","M1" -"turism_category_20","Moteles","M2" -"turism_category_21","Moteles","M3" -"turism_category_22","Paradores Nacionales","PN3" -"turism_category_23","Paradores Nacionales","PN4" -"turism_category_24","Paradores Nacionales","PN5" -"turism_category_25","Ciudades de vacaciones","CV1" -"turism_category_26","Ciudades de vacaciones","CV2" -"turism_category_27","Ciudades de vacaciones","CV3" -"turism_category_28","Residencias-apartamentos","RA1" -"turism_category_29","Residencias-apartamentos","RA2" -"turism_category_30","Residencias-apartamentos","RA3" -"turism_category_31","Residencias-apartamentos","RA4" -"turism_category_32","Hostales","HS" -"turism_category_33","Hostales","HS1" -"turism_category_34","Hostales","HS2" -"turism_category_35","Hostales","HS3" -"turism_category_36","Hostales","HSR" -"turism_category_37","Hostales","HSR1" -"turism_category_38","Hostales","HSR2" -"turism_category_39","Hostales","HSR3" -"turism_category_40","Hostales generales","HSE" -"turism_category_41","Casas de Huéspedes","CH" -"turism_category_42","Casas de Huéspedes","CH1" -"turism_category_43","Fondas","F1" -"turism_category_44","Fondas","F2" -"turism_category_45","Fondas","F3" -"turism_category_46","Pensiones","P" -"turism_category_47","Pensiones","P1" -"turism_category_48","Pensiones","P2" -"turism_category_49","Pensiones","P3" -"turism_category_50","Pensiones","PA" -"turism_category_51","Pensiones","PT" -"turism_category_52","Otros","Otras" +"id","type","category","survey_type" +"turism_category_1","Hoteles","H1","hotel" +"turism_category_2","Hoteles","H2","hotel" +"turism_category_3","Hoteles","H3","hotel" +"turism_category_4","Hoteles","H4","hotel" +"turism_category_5","Hoteles","H5","hotel" +"turism_category_6","Hoteles-apartamentos","AP","hotel" +"turism_category_7","Hoteles-apartamentos","HA","hotel" +"turism_category_8","Hoteles-apartamentos","HA1","hotel" +"turism_category_9","Hoteles-apartamentos","HA2","hotel" +"turism_category_10","Hoteles-apartamentos","HA3","hotel" +"turism_category_11","Hoteles-apartamentos","HA4","hotel" +"turism_category_12","Hoteles-apartamentos","HA5","hotel" +"turism_category_13","Hoteles-residencias","HR","hotel" +"turism_category_14","Hoteles-residencias","HR1","hotel" +"turism_category_15","Hoteles-residencias","HR2","hotel" +"turism_category_16","Hoteles-residencias","HR3","hotel" +"turism_category_17","Hoteles-residencias","HR4","hotel" +"turism_category_18","Hoteles-residencias","HR5","hotel" +"turism_category_19","Moteles","M1","hotel" +"turism_category_20","Moteles","M2","hotel" +"turism_category_21","Moteles","M3","hotel" +"turism_category_22","Paradores Nacionales","PN3","hotel" +"turism_category_23","Paradores Nacionales","PN4","hotel" +"turism_category_24","Paradores Nacionales","PN5","hotel" +"turism_category_25","Ciudades de vacaciones","CV1","hotel" +"turism_category_26","Ciudades de vacaciones","CV2","hotel" +"turism_category_27","Ciudades de vacaciones","CV3","hotel" +"turism_category_28","Residencias-apartamentos","RA1","hotel" +"turism_category_29","Residencias-apartamentos","RA2","hotel" +"turism_category_30","Residencias-apartamentos","RA3","hotel" +"turism_category_31","Residencias-apartamentos","RA4","hotel" +"turism_category_32","Hostales","HS","hotel" +"turism_category_33","Hostales","HS1","hotel" +"turism_category_34","Hostales","HS2","hotel" +"turism_category_35","Hostales","HS3","hotel" +"turism_category_36","Hostales","HSR","hotel" +"turism_category_37","Hostales","HSR1","hotel" +"turism_category_38","Hostales","HSR2","hotel" +"turism_category_39","Hostales","HSR3","hotel" +"turism_category_40","Hostales generales","HSE","hotel" +"turism_category_41","Casas de Huéspedes","CH","hotel" +"turism_category_42","Casas de Huéspedes","CH1","hotel" +"turism_category_43","Fondas","F1","hotel" +"turism_category_44","Fondas","F2","hotel" +"turism_category_45","Fondas","F3","hotel" +"turism_category_46","Pensiones","P","hotel" +"turism_category_47","Pensiones","P1","hotel" +"turism_category_48","Pensiones","P2","hotel" +"turism_category_49","Pensiones","P3","hotel" +"turism_category_50","Pensiones","PA","hotel" +"turism_category_51","Pensiones","PT","hotel" +"turism_category_52","Otros","Otras","hotel" +"turism_category_53","Apartamentos turísticos","0","apartments" +"turism_category_54","Apartamentos turísticos","1","apartments" +"turism_category_55","Apartamentos turísticos","2","apartments" +"turism_category_56","Apartamentos turísticos","3","apartments" +"turism_category_57","Apartamentos turísticos","4","apartments" diff --git a/pms_l10n_es/data/res.country.state.csv b/pms_l10n_es/data/res.country.state.csv index d72563a825..216df4048a 100644 --- a/pms_l10n_es/data/res.country.state.csv +++ b/pms_l10n_es/data/res.country.state.csv @@ -1,53 +1,53 @@ -"id","ine_code" -base.state_es_c,"ES111" -base.state_es_vi,"ES211" -base.state_es_ab,"ES421" -base.state_es_a,"ES521" -base.state_es_al,"ES611" -base.state_es_o,"ES120" -base.state_es_av,"ES411" -base.state_es_ba,"ES431" -base.state_es_pm,"ES530" -base.state_es_b,"ES511" -base.state_es_bu,"ES412" -base.state_es_cc,"ES432" -base.state_es_ca,"ES612" -base.state_es_s,"ES130" -base.state_es_cs,"ES522" -base.state_es_ce,"ES630" -base.state_es_cr,"ES422" -base.state_es_co,"ES613" -base.state_es_cu,"ES423" -base.state_es_gi,"ES512" -base.state_es_gr,"ES614" -base.state_es_gu,"ES424" -base.state_es_ss,"ES212" -base.state_es_h,"ES615" -base.state_es_hu,"ES241" -base.state_es_j,"ES616" -base.state_es_lo,"ES230" -base.state_es_gc,"ES701" -base.state_es_le,"ES413" -base.state_es_l,"ES513" -base.state_es_lu,"ES112" -base.state_es_m,"ES300" -base.state_es_ma,"ES617" -base.state_es_ml,"ES640" -base.state_es_mu,"ES620" -base.state_es_na,"ES220" -base.state_es_or,"ES113" -base.state_es_p,"ES414" -base.state_es_po,"ES114" -base.state_es_sa,"ES415" -base.state_es_tf,"ES702" -base.state_es_sg,"ES416" -base.state_es_se,"ES618" -base.state_es_so,"ES417" -base.state_es_t,"ES514" -base.state_es_te,"ES242" -base.state_es_to,"ES425" -base.state_es_v,"ES523" -base.state_es_va,"ES418" -base.state_es_bi,"ES213" -base.state_es_za,"ES419" -base.state_es_z,"ES243" +"id","ine_code","ine_tourism_province_name" +base.state_es_c,"ES111","A CORUÑA" +base.state_es_vi,"ES211","ARABA/ALAVA" +base.state_es_ab,"ES421","ALBACETE" +base.state_es_a,"ES521","ALICANTE" +base.state_es_al,"ES611","ALMERIA" +base.state_es_o,"ES120","ASTURIAS" +base.state_es_av,"ES411","AVILA" +base.state_es_ba,"ES431","BADAJOZ" +base.state_es_pm,"ES530","BALEARES" +base.state_es_b,"ES511","BARCELONA" +base.state_es_bu,"ES412","BURGOS" +base.state_es_cc,"ES432","CACERES" +base.state_es_ca,"ES612","CADIZ" +base.state_es_s,"ES130","CANTABRIA" +base.state_es_cs,"ES522","CASTELLON" +base.state_es_ce,"ES630","CEUTA" +base.state_es_cr,"ES422","CIUDAD REAL" +base.state_es_co,"ES613","CORDOBA" +base.state_es_cu,"ES423","CUENCA" +base.state_es_gi,"ES512","GIRONA" +base.state_es_gr,"ES614","GRANADA" +base.state_es_gu,"ES424","GUADALAJARA" +base.state_es_ss,"ES212","GUIPUZKOA" +base.state_es_h,"ES615","HUELVA" +base.state_es_hu,"ES241","HUESCA" +base.state_es_j,"ES616","JAEN" +base.state_es_lo,"ES230","LA RIOJA" +base.state_es_gc,"ES701","LAS PALMAS" +base.state_es_le,"ES413","LEON" +base.state_es_l,"ES513","LLEIDA" +base.state_es_lu,"ES112","LUGO" +base.state_es_m,"ES300","MADRID" +base.state_es_ma,"ES617","MALAGA" +base.state_es_ml,"ES640","MELILLA" +base.state_es_mu,"ES620","MURCIA" +base.state_es_na,"ES220","NAVARRA" +base.state_es_or,"ES113","OURENSE" +base.state_es_p,"ES414","PALENCIA" +base.state_es_po,"ES114","PONTEVEDRA" +base.state_es_sa,"ES415","SALAMANCA" +base.state_es_tf,"ES702","TENERIFE" +base.state_es_sg,"ES416","SEGOVIA" +base.state_es_se,"ES618","SEVILLA" +base.state_es_so,"ES417","SORIA" +base.state_es_t,"ES514","TARRAGONA" +base.state_es_te,"ES242","TERUEL" +base.state_es_to,"ES425","TOLEDO" +base.state_es_v,"ES523","VALENCIA" +base.state_es_va,"ES418","VALLADOLID" +base.state_es_bi,"ES213","VIZCAYA" +base.state_es_za,"ES419","ZAMORA" +base.state_es_z,"ES243","ZARAGOZA" diff --git a/pms_l10n_es/models/pms_ine_tourism_type_category.py b/pms_l10n_es/models/pms_ine_tourism_type_category.py index f547db761b..96bebabc19 100644 --- a/pms_l10n_es/models/pms_ine_tourism_type_category.py +++ b/pms_l10n_es/models/pms_ine_tourism_type_category.py @@ -7,6 +7,16 @@ class PmsIneTourismCategory(models.Model): type = fields.Char(required=True) category = fields.Char(required=True) + survey_type = fields.Selection( + selection=[ + ("hotel", "Hotel Occupancy Survey (EOH)"), + ("apartments", "Tourist Apartments Occupancy Survey (EOAP)"), + ], + default="hotel", + required=True, + help="INE occupancy survey this category belongs to. It determines " + "the XML questionnaire format generated by the INE wizard.", + ) def name_get(self): data = [] diff --git a/pms_l10n_es/models/pms_property.py b/pms_l10n_es/models/pms_property.py index 9d717002b9..945b2d5de4 100644 --- a/pms_l10n_es/models/pms_property.py +++ b/pms_l10n_es/models/pms_property.py @@ -48,6 +48,24 @@ class PmsProperty(models.Model): comodel_name="pms.ine.tourism.type.category", help="Hotel category in the Ministry of Tourism. Used for INE statistics.", ) + ine_informant_name = fields.Char( + string="INE Informant Name", + help="Contact person reported in the INFORMANTE block of the INE " + "Tourist Apartments Occupancy Survey (EOAP).", + ) + ine_informant_job = fields.Char( + string="INE Informant Job Position", + help="Job position of the INE informant (EOAP).", + ) + ine_informant_phone = fields.Char( + string="INE Informant Phone", + help="Phone of the INE informant (EOAP). If empty, the property " + "phone is used.", + ) + ine_informant_email = fields.Char( + string="INE Informant Email", + help="Email of the INE informant (EOAP).", + ) spanish_tourism_classification_id = fields.Many2one( comodel_name="pms.tourism.classification", string="Spanish Tourism Classification", diff --git a/pms_l10n_es/models/pms_room.py b/pms_l10n_es/models/pms_room.py index 0d2599a913..113441ab8f 100644 --- a/pms_l10n_es/models/pms_room.py +++ b/pms_l10n_es/models/pms_room.py @@ -8,6 +8,33 @@ class PmsRoom(models.Model): help="Take it into account to generate INE statistics", default=True, ) + ine_apartment_type = fields.Selection( + selection=[ + ("studio", "Studio"), + ("apt_2_4", "Apartment 2-4 pax"), + ("apt_4_6", "Apartment 4-6 pax"), + ("other", "Other apartments"), + ], + help="Accommodation unit typology used by the INE Tourist Apartments " + "Occupancy Survey (EOAP). Only used when the property reports to " + "the apartments survey. If empty, it is inferred from the room " + "capacity (2 pax: studio, up to 4: 2-4 pax, up to 6: 4-6 pax, " + "bigger: other).", + ) + + def ine_get_apartment_type(self): + """Return the EOAP typology, inferring it from capacity if unset.""" + self.ensure_one() + if self.ine_apartment_type: + return self.ine_apartment_type + if self.capacity <= 2: + return "studio" + if self.capacity <= 4: + return "apt_2_4" + if self.capacity <= 6: + return "apt_4_6" + return "other" + institution_independent_account = fields.Boolean( string="Independent account for institution (travel reports)", help="This room has an independent account", diff --git a/pms_l10n_es/models/res_country_state.py b/pms_l10n_es/models/res_country_state.py index 56eea2c925..a853c86793 100644 --- a/pms_l10n_es/models/res_country_state.py +++ b/pms_l10n_es/models/res_country_state.py @@ -5,6 +5,12 @@ class ResCountryState(models.Model): _inherit = "res.country.state" ine_code = fields.Char(string="INE State Code") + ine_tourism_province_name = fields.Char( + string="INE Tourism Province Name", + help="Province literal expected in the PROVINCIA element of the INE " + "occupancy survey XML files (max. 25 characters), as published in " + "the INE file specification.", + ) @api.constrains("ine_code") def _check_ine_code(self): diff --git a/pms_l10n_es/tests/fixtures/iria_apartments_survey.xsd b/pms_l10n_es/tests/fixtures/iria_apartments_survey.xsd new file mode 100644 index 0000000000..17e077d500 --- /dev/null +++ b/pms_l10n_es/tests/fixtures/iria_apartments_survey.xsd @@ -0,0 +1,556 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pms_l10n_es/tests/fixtures/iria_hotel_survey.xsd b/pms_l10n_es/tests/fixtures/iria_hotel_survey.xsd new file mode 100644 index 0000000000..481def31bb --- /dev/null +++ b/pms_l10n_es/tests/fixtures/iria_hotel_survey.xsd @@ -0,0 +1,597 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pms_l10n_es/tests/test_wizard_ine.py b/pms_l10n_es/tests/test_wizard_ine.py index ffe5f47122..a594542d21 100644 --- a/pms_l10n_es/tests/test_wizard_ine.py +++ b/pms_l10n_es/tests/test_wizard_ine.py @@ -1,8 +1,12 @@ +import base64 import datetime +from decimal import Decimal from freezegun import freeze_time +from lxml import etree from odoo.exceptions import ValidationError +from odoo.modules.module import get_module_resource from .common import TestPms @@ -1046,3 +1050,148 @@ def test_spain_arrivals_departures_pernoctations_by_date_no_state_raises_error( self.env["pms.ine.wizard"].ine_countries( start_date, end_date, self.pms_property1.id ) + + def _configure_ine_property(self, survey_type="hotel"): + self.company1.vat = "ESA12345674" + self.pms_property1.write( + { + "street": "Fake Street 123", + "zip": "28001", + "city": "Madrid", + "phone": "+34600000000", + "website": "https://www.example.com", + "ine_tourism_number": "REG-12345", + "ine_permanent_staff": 2, + } + ) + self.pms_property1.partner_id.state_id = self.env.ref("base.state_es_m") + if survey_type == "apartments": + self.pms_property1.ine_category_id = self.env.ref( + "pms_l10n_es.turism_category_57" + ) + self.pms_property1.write( + { + "ine_informant_name": "Informant Name", + "ine_informant_job": "Reception", + "ine_informant_email": "informant@example.com", + } + ) + else: + self.pms_property1.ine_category_id = self.env.ref( + "pms_l10n_es.turism_category_1" + ) + + def _generate_ine_document(self): + wizard = self.env["pms.ine.wizard"].new( + { + "pms_property_id": self.pms_property1.id, + "start_date": datetime.date(2021, 2, 1), + "end_date": datetime.date(2021, 2, 28), + } + ) + wizard.ine_generate_xml() + return etree.fromstring(base64.b64decode(wizard.txt_binary)) + + def _assert_valid_against_schema(self, document, fixture_name): + xsd_path = get_module_resource("pms_l10n_es", "tests/fixtures", fixture_name) + schema = etree.XMLSchema(etree.parse(xsd_path)) + self.assertTrue( + schema.validate(document), + "\n".join(str(error) for error in schema.error_log), + ) + + def test_generate_xml_hotel_validates_iria_schema(self): + """The hotel survey must carry the IRIA namespace and honor its XSD.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + # ACT + document = self._generate_ine_document() + # ASSERT + self.assertTrue( + document.tag.endswith("}ENCUESTA"), + "The hotel survey root element must be namespace-qualified", + ) + self._assert_valid_against_schema(document, "iria_hotel_survey.xsd") + + def test_generate_xml_hotel_percents_sum_exactly_100(self): + """Printed occupancy percentages must add up to exactly 100.00.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + # ACT + document = self._generate_ine_document() + # ASSERT + namespace = document.tag[1:].split("}")[0] + prices_tag = document.find("{%s}PRECIOS" % namespace) + total = sum( + Decimal(element.text) for element in prices_tag if "PCTN_" in element.tag + ) + self.assertEqual(total, Decimal("100.00")) + + def test_generate_xml_hotel_decimals_have_two_digits(self): + """All decimal values must be printed with exactly 2 decimals.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + # ACT + document = self._generate_ine_document() + # ASSERT + namespace = document.tag[1:].split("}")[0] + prices_tag = document.find("{%s}PRECIOS" % namespace) + for element in prices_tag: + self.assertRegex(element.text, r"^\d+\.\d{2}$") + + def test_generate_xml_apartments_validates_iria_schema(self): + """The apartments survey must carry the IRIA namespace and honor + its XSD.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property(survey_type="apartments") + # ACT + document = self._generate_ine_document() + # ASSERT + self.assertTrue( + document.tag.endswith("}APARTAMENTOS"), + "The apartments survey root element must be namespace-qualified", + ) + self._assert_valid_against_schema(document, "iria_apartments_survey.xsd") + + def test_apartments_capacity_typologies(self): + """Rooms are grouped by typology, inferring it from capacity when + it is not set explicitly.""" + # ARRANGE + self.room_double_1.ine_apartment_type = "apt_4_6" + rooms = self.env["pms.room"].search( + [ + ("pms_property_id", "=", self.pms_property1.id), + ("in_ine", "=", True), + ] + ) + # ACT + capacity = self.env["pms.ine.wizard"].ine_apartments_capacity( + self.pms_property1 + ) + # ASSERT + self.assertEqual(capacity["apt_4_6"]["units"], 1) + self.assertEqual( + sum(capacity[apartment_type]["units"] for apartment_type in capacity), + len(rooms), + ) + self.assertEqual( + sum(capacity[apartment_type]["seats"] for apartment_type in capacity), + sum(rooms.mapped("capacity")), + ) + + def test_apartments_missing_informant_raises(self): + """The apartments survey requires the informant contact data.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property(survey_type="apartments") + self.pms_property1.ine_informant_email = False + # ACT & ASSERT + with self.assertRaises( + ValidationError, + msg="Cannot generate the apartments survey without informant", + ): + self._generate_ine_document() diff --git a/pms_l10n_es/views/pms_ine_tourism_type_category.xml b/pms_l10n_es/views/pms_ine_tourism_type_category.xml index a84c48cb87..0680a4c9c7 100644 --- a/pms_l10n_es/views/pms_ine_tourism_type_category.xml +++ b/pms_l10n_es/views/pms_ine_tourism_type_category.xml @@ -9,6 +9,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/pms_l10n_es/views/pms_property_views.xml b/pms_l10n_es/views/pms_property_views.xml index fae8281b29..45043ca101 100644 --- a/pms_l10n_es/views/pms_property_views.xml +++ b/pms_l10n_es/views/pms_property_views.xml @@ -58,6 +58,10 @@ + + + + diff --git a/pms_l10n_es/views/pms_room_views.xml b/pms_l10n_es/views/pms_room_views.xml index 93d06e08b7..ab360a25a4 100644 --- a/pms_l10n_es/views/pms_room_views.xml +++ b/pms_l10n_es/views/pms_room_views.xml @@ -7,6 +7,7 @@ + diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index 7b7d3c0862..ec0894d5ef 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -10,6 +10,37 @@ # TODO: Review code (code iso ?) CODE_SPAIN = "ES" +# The INE moved the XML questionnaire upload from ARCE to IRIA. IRIA schemas +# are namespace-qualified (elementFormDefault="qualified"): files without the +# proper default namespace are rejected at upload time with an XSD error on +# line 1. The namespace URIs below are the targetNamespace values of the +# current schema versions and can be overridden through config parameters if +# the INE publishes a new questionnaire version. +INE_XML_NAMESPACE_PARAMS = { + "hotel": ( + "pms_l10n_es.ine_xml_namespace_hotel", + "https://iria.ine.es/schemas/ec7dbd57-d3ab-473a-a3e5-d32d8c1e17a0", + ), + "apartments": ( + "pms_l10n_es.ine_xml_namespace_apartments", + "https://iria.ine.es/schemas/15b6131c-259d-42ff-96a1-3fe0800dfdd1", + ), +} + +INE_APARTMENT_TYPES = ["studio", "apt_2_4", "apt_4_6", "other"] +INE_APARTMENT_XML_SUFFIXES = { + "studio": "ESTUDIO", + "apt_2_4": "2-4pax", + "apt_4_6": "4-6pax", + "other": "OTROS", +} +INE_APARTMENT_PRICE_TAGS = { + "studio": "ESTUDIOS", + "apt_2_4": "APARTAMENTOS_2-4pax", + "apt_4_6": "APARTAMENTOS_4-6pax", + "other": "OTROS", +} + class WizardIne(models.TransientModel): _name = "pms.ine.wizard" @@ -38,6 +69,35 @@ class WizardIne(models.TransientModel): adr = fields.Float(string="Range ADR") revpar = fields.Float(string="Range RevPAR") + @api.model + def _ine_get_extra_beds(self, pms_property_id, date): + """Extra beds occupied on a given date (INE criteria). + + Children occupying an extra bed are excluded because they have no + check-in partner data. + """ + extra_beds = 0 + extra_bed_service_lines = self.env["pms.service.line"].search( + [ + ("pms_property_id", "=", pms_property_id.id), + ("product_id.is_extra_bed", "=", True), + ("reservation_id.reservation_type", "=", "normal"), + ("reservation_id.state", "in", ["confirmed", "onboard", "done"]), + ("date", "=", date), + ] + ) + for ebsl in extra_bed_service_lines: + reservation_lines = ebsl.reservation_id.reservation_line_ids.filtered( + lambda x, ebsl=ebsl: x.date == ebsl.date + and x.room_id.in_ine + and x.occupies_availability + ) + if reservation_lines: + extra_beds += ( + ebsl.day_qty - reservation_lines.reservation_id.children_occupying + ) + return extra_beds + @api.model def ine_rooms(self, start_date, end_date, pms_property_id): """ @@ -120,32 +180,7 @@ def ine_rooms(self, start_date, end_date, pms_property_id): .mapped("room_id") ) - # service lines with extra beds - extra_bed_service_lines = self.env["pms.service.line"].search( - [ - ("pms_property_id", "=", pms_property_id.id), - ("product_id.is_extra_bed", "=", True), - ("reservation_id.reservation_type", "=", "normal"), - ("reservation_id.state", "in", ["confirmed", "onboard", "done"]), - ("date", "=", p_date), - ] - ) - - extra_beds = 0 - - # get num. extra beds - for ebsl in extra_bed_service_lines: - reservation_lines = ebsl.reservation_id.reservation_line_ids.filtered( - lambda x, ebsl=ebsl: x.date == ebsl.date - and x.room_id.in_ine - and x.occupies_availability - ) - if reservation_lines: - extra_beds += ( - ebsl.day_qty - - reservation_lines.reservation_id.children_occupying - ) - # children occuppying do not have checkin partner data + extra_beds = self._ine_get_extra_beds(pms_property_id, p_date) # search all rooms all_rooms = ( @@ -495,6 +530,54 @@ def check_ine_mandatory_fields(self, pms_property_id): if not pms_property_id.ine_category_id: raise ValidationError(_("The property category is not established.")) + province = ( + pms_property_id.partner_id.state_id.ine_tourism_province_name + or pms_property_id.partner_id.state_id.name + ) + if len(province) > 25: + raise ValidationError( + _( + "The province literal '%s' exceeds the 25 characters " + "allowed by the INE survey. Set the 'INE Tourism " + "Province Name' field on the property state.", + province, + ) + ) + + if pms_property_id.ine_category_id.survey_type == "apartments": + self._check_ine_apartments_mandatory_fields(pms_property_id) + + @api.model + def _check_ine_apartments_mandatory_fields(self, pms_property_id): + if not pms_property_id.ine_informant_name: + raise ValidationError(_("The INE informant name is not established.")) + if not pms_property_id.ine_informant_job: + raise ValidationError( + _("The INE informant job position is not established.") + ) + if not pms_property_id.ine_informant_email: + raise ValidationError(_("The INE informant email is not established.")) + + def _ine_get_xml_namespace(self, survey_type): + param, default = INE_XML_NAMESPACE_PARAMS[survey_type] + return self.env["ir.config_parameter"].sudo().get_param(param, default) + + @api.model + def _ine_format_decimal(self, value): + """Format decimal values with exactly 2 decimals. + + The INE schemas limit decimal fields to 2 fraction digits: emitting + raw float representations has historically produced rejected files. + """ + return "%.2f" % (value or 0) + + def _ine_get_province_name(self): + state = self.pms_property_id.partner_id.state_id + return state.ine_tourism_province_name or state.name + + def _ine_survey_type(self): + return self.pms_property_id.ine_category_id.survey_type or "hotel" + def ine_generate_xml(self): self.check_ine_mandatory_fields(self.pms_property_id) @@ -521,16 +604,48 @@ def ine_generate_xml(self): ) ) + if self._ine_survey_type() == "apartments": + survey_tag = self._ine_build_xml_apartments() + else: + survey_tag = self._ine_build_xml_hotel() + + xmlstr = '' + xmlstr += ET.tostring(survey_tag).decode("utf-8") + + self.txt_binary = base64.b64encode(xmlstr.encode("utf-8")) + self.txt_filename = ( + "INE_" + + str(self.start_date.month) + + "_" + + str(self.start_date.year) + + ".xml" + ) + + return { + "context": self.env.context, + "view_type": "form", + "view_mode": "form", + "res_model": "pms.ine.wizard", + "res_id": self.id, + "view_id": False, + "type": "ir.actions.act_window", + "target": "new", + } + + def _ine_build_xml_hotel(self): # INE XML - survey_tag = ET.Element("ENCUESTA") + survey_tag = ET.Element( + "ENCUESTA", {"xmlns": self._ine_get_xml_namespace("hotel")} + ) # INE XML -> PROPERTY header_tag = ET.SubElement(survey_tag, "CABECERA") date = ET.SubElement(header_tag, "FECHA_REFERENCIA") ET.SubElement(date, "MES").text = f"{self.start_date.month:02}" ET.SubElement(date, "ANYO").text = str(self.start_date.year) - ET.SubElement(header_tag, "DIAS_ABIERTO_MES_REFERENCIA").text = str( - calendar.monthrange(self.start_date.year, self.start_date.month)[1] + ET.SubElement(header_tag, "DIAS_ABIERTO_MES_REFERENCIA").text = ( + "%02d" + % (calendar.monthrange(self.start_date.year, self.start_date.month)[1]) ) ET.SubElement( header_tag, "RAZON_SOCIAL" @@ -549,9 +664,7 @@ def ine_generate_xml(self): ET.SubElement(header_tag, "CODIGO_POSTAL").text = self.pms_property_id.zip ET.SubElement(header_tag, "LOCALIDAD").text = self.pms_property_id.city ET.SubElement(header_tag, "MUNICIPIO").text = self.pms_property_id.city - ET.SubElement( - header_tag, "PROVINCIA" - ).text = self.pms_property_id.partner_id.state_id.name + ET.SubElement(header_tag, "PROVINCIA").text = self._ine_get_province_name() ET.SubElement( header_tag, "TELEFONO_1" ).text = self.pms_property_id.phone.replace(" ", "")[0:12] @@ -573,70 +686,12 @@ def ine_generate_xml(self): ET.SubElement(header_tag, "PLAZAS_DISPONIBLES_SIN_SUPLETORIAS").text = str( self.pms_property_id.ine_seats ) - ET.SubElement(header_tag, "URL").text = self.pms_property_id.website + if self.pms_property_id.website: + ET.SubElement(header_tag, "URL").text = self.pms_property_id.website[0:100] # INE XML -> GUESTS accommodation_tag = ET.SubElement(survey_tag, "ALOJAMIENTO") - - countries = self.ine_countries( - self.start_date, self.end_date, self.pms_property_id.id - ) - for key_country, value_country in countries.items(): - country = self.env["res.country"].search([("code", "=", key_country)]) - - if key_country != CODE_SPAIN: - residency_tag = ET.SubElement(accommodation_tag, "RESIDENCIA") - ET.SubElement(residency_tag, "ID_PAIS").text = country.code_alpha3 - - for key_date, value_dates in value_country.items(): - movement = ET.SubElement(residency_tag, "MOVIMIENTO") - ET.SubElement(movement, "N_DIA").text = f"{key_date.day:02}" - num_arrivals = ( - value_dates["arrivals"] if value_dates.get("arrivals") else 0 - ) - num_departures = ( - value_dates["departures"] - if value_dates.get("departures") - else 0 - ) - num_pernoctations = ( - value_dates["pernoctations"] - if value_dates.get("pernoctations") - else 0 - ) - - ET.SubElement(movement, "ENTRADAS").text = str(num_arrivals) - ET.SubElement(movement, "SALIDAS").text = str(num_departures) - ET.SubElement(movement, "PERNOCTACIONES").text = str( - num_pernoctations - ) - else: - for code_ine, value_state in value_country.items(): - residency_tag = ET.SubElement(accommodation_tag, "RESIDENCIA") - ET.SubElement(residency_tag, "ID_PROVINCIA_ISLA").text = code_ine - for key_date, value_dates in value_state.items(): - movement = ET.SubElement(residency_tag, "MOVIMIENTO") - ET.SubElement(movement, "N_DIA").text = f"{key_date.day:02}" - num_arrivals = ( - value_dates["arrivals"] - if value_dates.get("arrivals") - else 0 - ) - num_departures = ( - value_dates["departures"] - if value_dates.get("departures") - else 0 - ) - num_pernoctations = ( - value_dates["pernoctations"] - if value_dates.get("pernoctations") - else 0 - ) - ET.SubElement(movement, "ENTRADAS").text = str(num_arrivals) - ET.SubElement(movement, "SALIDAS").text = str(num_departures) - ET.SubElement(movement, "PERNOCTACIONES").text = str( - num_pernoctations - ) + self._ine_append_guest_movements(accommodation_tag) rooms_tag = ET.SubElement(survey_tag, "HABITACIONES") rooms = self.ine_rooms(self.start_date, self.end_date, self.pms_property_id) @@ -658,14 +713,14 @@ def ine_generate_xml(self): ) prices_tag = ET.SubElement(survey_tag, "PRECIOS") - ET.SubElement(prices_tag, "REVPAR_MENSUAL").text = str( + ET.SubElement(prices_tag, "REVPAR_MENSUAL").text = self._ine_format_decimal( self.ine_calculate_revpar( self.start_date, self.end_date, ) ) - ET.SubElement(prices_tag, "ADR_MENSUAL").text = str( + ET.SubElement(prices_tag, "ADR_MENSUAL").text = self._ine_format_decimal( self.ine_calculate_adr( self.start_date, self.end_date, @@ -738,64 +793,61 @@ def ine_generate_xml(self): total_percent = sum(percents.values()) if total_percent: - for group in percents: - percents[group] = round((percents[group] / total_percent) * 100, 2) - - sum_percentages = round(sum(percents.values()), 2) - adjustment = round(100 - sum_percentages, 2) - - if adjustment: - target_group = max(percents, key=percents.get) - percents[target_group] = round(percents[target_group] + adjustment, 2) + # Normalize in integer hundredths so that the percentages + # printed in the file add up to exactly 100.00: the INE + # content validation rejects files whose percentage columns + # exceed 100.00, and float rounding artifacts have caused + # rejected files in the past. + cents = { + group: int(round(value * 10000 / total_percent)) + for group, value in percents.items() + } + cents[max(cents, key=cents.get)] += 10000 - sum(cents.values()) + percents = {group: value / 100.0 for group, value in cents.items()} else: for group in percents: percents[group] = 0.0 - ET.SubElement(prices_tag, "ADR_TOUROPERADOR_TRADICIONAL").text = str( - adrs["tour_operator_offline"] - ) - ET.SubElement( - prices_tag, "PCTN_HABITACIONES_OCUPADAS_TOUROPERADOR_TRADICIONAL" - ).text = str(percents["tour_operator_offline"]) - ET.SubElement(prices_tag, "ADR_TOUROPERADOR_ONLINE").text = str( - adrs["tour_operator_online"] - ) - ET.SubElement( - prices_tag, "PCTN_HABITACIONES_OCUPADAS_TOUROPERADOR_ONLINE" - ).text = str(percents["tour_operator_online"]) - ET.SubElement(prices_tag, "ADR_EMPRESAS").text = str(adrs["companies"]) - ET.SubElement(prices_tag, "PCTN_HABITACIONES_OCUPADAS_EMPRESAS").text = str( - percents["companies"] - ) - ET.SubElement(prices_tag, "ADR_AGENCIA_DE_VIAJE_TRADICIONAL").text = str( - adrs["agencies"] - ) - ET.SubElement( - prices_tag, "PCTN_HABITACIONES_OCUPADAS_AGENCIA_TRADICIONAL" - ).text = str(percents["agencies"]) - ET.SubElement(prices_tag, "ADR_AGENCIA_DE_VIAJE_ONLINE").text = str( - adrs["otas"] - ) - ET.SubElement( - prices_tag, "PCTN_HABITACIONES_OCUPADAS_AGENCIA_ONLINE" - ).text = str(percents["otas"]) - ET.SubElement(prices_tag, "ADR_PARTICULARES").text = str(adrs["persons"]) - ET.SubElement(prices_tag, "PCTN_HABITACIONES_OCUPADAS_PARTICULARES").text = str( - percents["persons"] - ) - ET.SubElement(prices_tag, "ADR_GRUPOS").text = str(adrs["groups"]) - ET.SubElement(prices_tag, "PCTN_HABITACIONES_OCUPADAS_GRUPOS").text = str( - percents["groups"] - ) - ET.SubElement(prices_tag, "ADR_INTERNET").text = str(adrs["internet"]) - ET.SubElement(prices_tag, "PCTN_HABITACIONES_OCUPADAS_INTERNET").text = str( - percents["internet"] - ) - ET.SubElement(prices_tag, "ADR_OTROS").text = str(adrs["others"]) - ET.SubElement(prices_tag, "PCTN_HABITACIONES_OCUPADAS_OTROS").text = str( - percents["others"] - ) + hotel_price_tags = [ + ( + "tour_operator_offline", + "ADR_TOUROPERADOR_TRADICIONAL", + "PCTN_HABITACIONES_OCUPADAS_TOUROPERADOR_TRADICIONAL", + ), + ( + "tour_operator_online", + "ADR_TOUROPERADOR_ONLINE", + "PCTN_HABITACIONES_OCUPADAS_TOUROPERADOR_ONLINE", + ), + ("companies", "ADR_EMPRESAS", "PCTN_HABITACIONES_OCUPADAS_EMPRESAS"), + ( + "agencies", + "ADR_AGENCIA_DE_VIAJE_TRADICIONAL", + "PCTN_HABITACIONES_OCUPADAS_AGENCIA_TRADICIONAL", + ), + ( + "otas", + "ADR_AGENCIA_DE_VIAJE_ONLINE", + "PCTN_HABITACIONES_OCUPADAS_AGENCIA_ONLINE", + ), + ("persons", "ADR_PARTICULARES", "PCTN_HABITACIONES_OCUPADAS_PARTICULARES"), + ("groups", "ADR_GRUPOS", "PCTN_HABITACIONES_OCUPADAS_GRUPOS"), + ("internet", "ADR_INTERNET", "PCTN_HABITACIONES_OCUPADAS_INTERNET"), + ("others", "ADR_OTROS", "PCTN_HABITACIONES_OCUPADAS_OTROS"), + ] + for group, adr_tag, pctn_tag in hotel_price_tags: + ET.SubElement(prices_tag, adr_tag).text = self._ine_format_decimal( + adrs[group] + ) + ET.SubElement(prices_tag, pctn_tag).text = self._ine_format_decimal( + percents[group] + ) + self._ine_append_staff(survey_tag) + + return survey_tag + + def _ine_append_staff(self, survey_tag): staff_tag = ET.SubElement(survey_tag, "PERSONAL_OCUPADO") ET.SubElement(staff_tag, "PERSONAL_NO_REMUNERADO").text = str( self.pms_property_id.ine_unpaid_staff @@ -807,25 +859,273 @@ def ine_generate_xml(self): self.pms_property_id.ine_eventual_staff ) - xmlstr = '' - xmlstr += ET.tostring(survey_tag).decode("utf-8") - - self.txt_binary = base64.b64encode(str.encode(xmlstr)) - self.txt_filename = ( - "INE_" - + str(self.start_date.month) - + "_" - + str(self.start_date.year) - + ".xml" + @api.model + def ine_apartments_capacity(self, pms_property_id): + """ + Returns a dictionary: + { + apartment_type: { + 'units': number of accommodation units, + 'seats': number of seats (excluding extra beds), + 'room_ids': [pms.room ids], + }, + # ... one entry per INE_APARTMENT_TYPES + } + """ + result = { + apartment_type: {"units": 0, "seats": 0, "room_ids": []} + for apartment_type in INE_APARTMENT_TYPES + } + rooms = self.env["pms.room"].search( + [ + ("in_ine", "=", True), + ("pms_property_id", "=", pms_property_id.id), + ] ) + for room in rooms: + apartment_type = room.ine_get_apartment_type() + result[apartment_type]["units"] += 1 + result[apartment_type]["seats"] += room.capacity + result[apartment_type]["room_ids"].append(room.id) + return result - return { - "context": self.env.context, - "view_type": "form", - "view_mode": "form", - "res_model": "pms.ine.wizard", - "res_id": self.id, - "view_id": False, - "type": "ir.actions.act_window", - "target": "new", + @api.model + def ine_apartments_occupancy(self, start_date, end_date, pms_property_id): + """ + Returns a dictionary (only dates with occupancy or extra beds): + { + date: { + 'occupied': {apartment_type: number of occupied units}, + 'extra_beds': number of extra beds, + }, + # ... more dates } + Occupancy criteria mirror ine_rooms: reservation lines occupying + availability of normal reservations in confirmed/onboard/done state, + on INE rooms, with at least one valid check-in partner. + """ + occupancy = {} + for p_date in [ + start_date + datetime.timedelta(days=x) + for x in range(0, (end_date - start_date).days + 1) + ]: + day_rooms = ( + self.env["pms.reservation.line"] + .search( + [ + ("pms_property_id", "=", pms_property_id.id), + ("occupies_availability", "=", True), + ("reservation_id.reservation_type", "=", "normal"), + ("room_id.in_ine", "=", True), + ("date", "=", p_date), + ( + "reservation_id.state", + "in", + ["confirmed", "onboard", "done"], + ), + ] + ) + .filtered( + lambda r: len( + r.reservation_id.checkin_partner_ids.filtered( + lambda c: c.state + not in ["dummy", "draft", "cancel", "precheckin"] + ) + ) + > 0 + ) + .mapped("room_id") + ) + occupied = dict.fromkeys(INE_APARTMENT_TYPES, 0) + for room in day_rooms: + occupied[room.ine_get_apartment_type()] += 1 + extra_beds = self._ine_get_extra_beds(pms_property_id, p_date) + if any(occupied.values()) or extra_beds: + occupancy[p_date] = { + "occupied": occupied, + "extra_beds": int(extra_beds), + } + return occupancy + + def _ine_build_xml_apartments(self): + """Build the Tourist Apartments Occupancy Survey (EOAP) XML. + + Prices simplification: the whole occupancy of each typology is + reported under the normal rate (TARIFA_NORMAL = typology ADR, + PCTN_TARIFA_NORMAL = 100), which satisfies the INE content + validations (percentage sum must be 100 for occupied typologies). + """ + pms_property = self.pms_property_id + + survey_tag = ET.Element( + "APARTAMENTOS", {"xmlns": self._ine_get_xml_namespace("apartments")} + ) + + # EOAP XML -> CABECERA + header_tag = ET.SubElement(survey_tag, "CABECERA") + date = ET.SubElement(header_tag, "FECHA_REFERENCIA") + ET.SubElement(date, "MES").text = f"{self.start_date.month:02}" + ET.SubElement(date, "ANYO").text = str(self.start_date.year) + ET.SubElement(header_tag, "DIAS_ABIERTO_MES_REFERENCIA").text = ( + "%02d" + % (calendar.monthrange(self.start_date.year, self.start_date.month)[1]) + ) + ET.SubElement(header_tag, "RAZON_SOCIAL").text = pms_property.company_id.name + ET.SubElement(header_tag, "NOMBRE_ESTABLECIMIENTO").text = pms_property.name + ET.SubElement(header_tag, "CIF_NIF").text = self.ine_get_nif_cif( + pms_property.company_id.vat + ) + ET.SubElement(header_tag, "DIRECCION").text = pms_property.street + ET.SubElement(header_tag, "CODIGO_POSTAL").text = pms_property.zip + ET.SubElement(header_tag, "LOCALIDAD").text = pms_property.city + ET.SubElement(header_tag, "MUNICIPIO").text = pms_property.city + ET.SubElement(header_tag, "PROVINCIA").text = self._ine_get_province_name() + ET.SubElement(header_tag, "TELEFONO_1").text = pms_property.phone.replace( + " ", "" + )[0:13] + if pms_property.website: + ET.SubElement(header_tag, "URL").text = pms_property.website[0:100] + + # EOAP XML -> CABECERA_APARTAMENTOS + apartments_header_tag = ET.SubElement(survey_tag, "CABECERA_APARTAMENTOS") + ET.SubElement( + apartments_header_tag, "CATEGORIA" + ).text = pms_property.ine_category_id.category + + # EOAP XML -> INFORMANTE + informant_tag = ET.SubElement(survey_tag, "INFORMANTE") + ET.SubElement(informant_tag, "NOMBRE").text = pms_property.ine_informant_name + ET.SubElement(informant_tag, "CARGO").text = pms_property.ine_informant_job + ET.SubElement(informant_tag, "TELEFONOINF").text = ( + pms_property.ine_informant_phone or pms_property.phone + ).replace(" ", "")[0:13] + ET.SubElement(informant_tag, "EMAIL").text = pms_property.ine_informant_email + + # EOAP XML -> ALOJAMIENTO (shared with the hotel survey) + accommodation_tag = ET.SubElement(survey_tag, "ALOJAMIENTO") + self._ine_append_guest_movements(accommodation_tag) + + # EOAP XML -> CAPACIDAD + capacity = self.ine_apartments_capacity(pms_property) + capacity_tag = ET.SubElement(survey_tag, "CAPACIDAD") + for apartment_type in INE_APARTMENT_TYPES: + suffix = INE_APARTMENT_XML_SUFFIXES[apartment_type] + ET.SubElement(capacity_tag, "N_APARTAMENTOS_" + suffix).text = str( + capacity[apartment_type]["units"] + ) + for apartment_type in INE_APARTMENT_TYPES: + suffix = INE_APARTMENT_XML_SUFFIXES[apartment_type] + ET.SubElement( + capacity_tag, "PLAZAS_TOTALES_APARTAMENTOS_" + suffix + ).text = str(capacity[apartment_type]["seats"]) + + # EOAP XML -> OCUPACION + occupancy = self.ine_apartments_occupancy( + self.start_date, self.end_date, pms_property + ) + occupancy_tag = ET.SubElement(survey_tag, "OCUPACION") + for p_date in sorted(occupancy): + movement = ET.SubElement(occupancy_tag, "MOVIMIENTO") + ET.SubElement(movement, "N_DIA_AP").text = f"{p_date.day:02}" + for apartment_type in INE_APARTMENT_TYPES: + suffix = INE_APARTMENT_XML_SUFFIXES[apartment_type] + ET.SubElement(movement, "APARTAMENTOS_OCUPADOS_" + suffix).text = str( + occupancy[p_date]["occupied"][apartment_type] + ) + ET.SubElement(movement, "PLAZAS_SUPLETORIAS").text = str( + occupancy[p_date]["extra_beds"] + ) + + # EOAP XML -> PRECIOS + prices_tag = ET.SubElement(survey_tag, "PRECIOS") + for apartment_type in INE_APARTMENT_TYPES: + block_tag = ET.SubElement( + prices_tag, INE_APARTMENT_PRICE_TAGS[apartment_type] + ) + occupied_any = any( + day["occupied"][apartment_type] for day in occupancy.values() + ) + adr = 0.0 + if occupied_any: + adr = round( + pms_property._get_adr( + self.start_date, + self.end_date, + [ + ("room_id.in_ine", "=", True), + ("room_id", "in", capacity[apartment_type]["room_ids"]), + ], + ), + 2, + ) + if adr <= 0: + raise ValidationError( + _( + "The average daily rate of the occupied " + "apartment typology '%s' is zero. The INE " + "requires a price greater than zero for " + "occupied typologies.", + INE_APARTMENT_PRICE_TAGS[apartment_type], + ) + ) + ET.SubElement(block_tag, "TARIFA_NORMAL").text = self._ine_format_decimal( + adr + ) + ET.SubElement( + block_tag, "PCTN_TARIFA_NORMAL" + ).text = self._ine_format_decimal(100 if occupied_any else 0) + for tarifa_tag, pctn_tag in [ + ("TARIFA_FIN_DE_SEMANA", "PCTN_TARIFA_FIN_DE_SEMANA"), + ("TARIFA_TOUROPERADOR", "PCTN_TARIFA_TOUROPERADOR"), + ("TARIFA_OTRAS", "PCTN_TARIFA_OTRAS"), + ]: + ET.SubElement(block_tag, tarifa_tag).text = self._ine_format_decimal(0) + ET.SubElement(block_tag, pctn_tag).text = self._ine_format_decimal(0) + + # EOAP XML -> PERSONAL_OCUPADO + self._ine_append_staff(survey_tag) + + # informative wizard fields + self.ine_calculate_adr(self.start_date, self.end_date) + self.ine_calculate_revpar(self.start_date, self.end_date) + + return survey_tag + + def _ine_append_guest_movements(self, accommodation_tag): + """Fill the ALOJAMIENTO block (shared by all INE occupancy surveys).""" + countries = self.ine_countries( + self.start_date, self.end_date, self.pms_property_id.id + ) + for key_country, value_country in countries.items(): + if key_country != CODE_SPAIN: + country = self.env["res.country"].search([("code", "=", key_country)]) + residency_tag = ET.SubElement(accommodation_tag, "RESIDENCIA") + ET.SubElement(residency_tag, "ID_PAIS").text = country.code_alpha3 + for key_date, value_dates in value_country.items(): + movement = ET.SubElement(residency_tag, "MOVIMIENTO") + ET.SubElement(movement, "N_DIA").text = f"{key_date.day:02}" + ET.SubElement(movement, "ENTRADAS").text = str( + value_dates.get("arrivals") or 0 + ) + ET.SubElement(movement, "SALIDAS").text = str( + value_dates.get("departures") or 0 + ) + ET.SubElement(movement, "PERNOCTACIONES").text = str( + value_dates.get("pernoctations") or 0 + ) + else: + for code_ine, value_state in value_country.items(): + residency_tag = ET.SubElement(accommodation_tag, "RESIDENCIA") + ET.SubElement(residency_tag, "ID_PROVINCIA_ISLA").text = code_ine + for key_date, value_dates in value_state.items(): + movement = ET.SubElement(residency_tag, "MOVIMIENTO") + ET.SubElement(movement, "N_DIA").text = f"{key_date.day:02}" + ET.SubElement(movement, "ENTRADAS").text = str( + value_dates.get("arrivals") or 0 + ) + ET.SubElement(movement, "SALIDAS").text = str( + value_dates.get("departures") or 0 + ) + ET.SubElement(movement, "PERNOCTACIONES").text = str( + value_dates.get("pernoctations") or 0 + ) diff --git a/pms_l10n_es/wizards/wizard_ine.xml b/pms_l10n_es/wizards/wizard_ine.xml index 120f3ca139..e35790a56f 100644 --- a/pms_l10n_es/wizards/wizard_ine.xml +++ b/pms_l10n_es/wizards/wizard_ine.xml @@ -69,10 +69,7 @@ attrs="{'invisible': [('txt_filename','=', False)]}" > - + Send survey to Spanish National Institute of Statistics (INE) From aa7980b68f9c695d7c226f6cc68911a63d9b2dcc Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Wed, 5 Aug 2026 10:41:24 +0200 Subject: [PATCH 02/12] [FIX] pms_l10n_es: align INE reference data with the published INE annexes The INE publishes the survey annexes as XML files. Checking the seed data against them: - The province literal expected for province 48 is VIZCAKAIA (as published in the province annex), not VIZCAYA. - The hotel type/category table includes one pair that was missing. (cherry picked from commit 17032479c32368e319b5233ffa33fe06f684638b) Assisted-by: Claude Opus 5 --- pms_l10n_es/data/pms.ine.tourism.type.category.csv | 1 + pms_l10n_es/data/res.country.state.csv | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pms_l10n_es/data/pms.ine.tourism.type.category.csv b/pms_l10n_es/data/pms.ine.tourism.type.category.csv index 6892a04ce4..64fe95521e 100644 --- a/pms_l10n_es/data/pms.ine.tourism.type.category.csv +++ b/pms_l10n_es/data/pms.ine.tourism.type.category.csv @@ -51,6 +51,7 @@ "turism_category_50","Pensiones","PA","hotel" "turism_category_51","Pensiones","PT","hotel" "turism_category_52","Otros","Otras","hotel" +"turism_category_58","Hostales especiales","HSG","hotel" "turism_category_53","Apartamentos turísticos","0","apartments" "turism_category_54","Apartamentos turísticos","1","apartments" "turism_category_55","Apartamentos turísticos","2","apartments" diff --git a/pms_l10n_es/data/res.country.state.csv b/pms_l10n_es/data/res.country.state.csv index 216df4048a..07f04c1fee 100644 --- a/pms_l10n_es/data/res.country.state.csv +++ b/pms_l10n_es/data/res.country.state.csv @@ -48,6 +48,6 @@ base.state_es_te,"ES242","TERUEL" base.state_es_to,"ES425","TOLEDO" base.state_es_v,"ES523","VALENCIA" base.state_es_va,"ES418","VALLADOLID" -base.state_es_bi,"ES213","VIZCAYA" +base.state_es_bi,"ES213","VIZCAKAIA" base.state_es_za,"ES419","ZAMORA" base.state_es_z,"ES243","ZARAGOZA" From 2f94643b7aa184cccc3eecc3cc723d504670016d Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Wed, 5 Aug 2026 12:43:44 +0200 Subject: [PATCH 03/12] [FIX] pms_l10n_es: build the INE files after the published schema The INE confirmed that the reference schema of each survey is the one they publish, which declares no target namespace, and not the one the IRIA application generates for the questionnaire. Both variants have been accepted on upload so far (hotel files without namespace, an apartments file with it), so the files are built after the published schema and the qualified variant can be enabled per survey with a config parameter for the questionnaires that need it. This keeps the hotel file byte-compatible with what the establishments have been uploading successfully. (cherry picked from commit 03f7b5fd51a2c77f72e1a2872fb09d995acf298b) Assisted-by: Claude Opus 5 --- .../ine_apartments_survey_published.xsd | 674 ++++++++++++++++++ .../fixtures/ine_hotel_survey_published.xsd | 407 +++++++++++ pms_l10n_es/tests/test_wizard_ine.py | 57 +- pms_l10n_es/wizards/wizard_ine.py | 44 +- 4 files changed, 1145 insertions(+), 37 deletions(-) create mode 100644 pms_l10n_es/tests/fixtures/ine_apartments_survey_published.xsd create mode 100644 pms_l10n_es/tests/fixtures/ine_hotel_survey_published.xsd diff --git a/pms_l10n_es/tests/fixtures/ine_apartments_survey_published.xsd b/pms_l10n_es/tests/fixtures/ine_apartments_survey_published.xsd new file mode 100644 index 0000000000..b6e1f78be1 --- /dev/null +++ b/pms_l10n_es/tests/fixtures/ine_apartments_survey_published.xsd @@ -0,0 +1,674 @@ + + + + + + + + Datos de identificacion del establecimiento. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Datos de identificacion del establecimiento. + + + + + + + + + + + + + + + + + + + + Datos de la empresa gestora + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Mes y año de referencia de los datos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Codificación según ANEXO 4 + + + + + + + + + + Codificación A3 de la ISO eliminando España + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pms_l10n_es/tests/fixtures/ine_hotel_survey_published.xsd b/pms_l10n_es/tests/fixtures/ine_hotel_survey_published.xsd new file mode 100644 index 0000000000..15d9358612 --- /dev/null +++ b/pms_l10n_es/tests/fixtures/ine_hotel_survey_published.xsd @@ -0,0 +1,407 @@ + + + + + Encuesta de alojamiento turístico, precios y empleo. + + + + + + Datos de identificacion del establecimiento. + + + + + + Mes y año de referencia de los datos + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Codificación A3 de la ISO eliminando España + + + + + + + + + + + Codificación según ANEXO 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pms_l10n_es/tests/test_wizard_ine.py b/pms_l10n_es/tests/test_wizard_ine.py index a594542d21..afb7f07bca 100644 --- a/pms_l10n_es/tests/test_wizard_ine.py +++ b/pms_l10n_es/tests/test_wizard_ine.py @@ -1100,18 +1100,32 @@ def _assert_valid_against_schema(self, document, fixture_name): "\n".join(str(error) for error in schema.error_log), ) - def test_generate_xml_hotel_validates_iria_schema(self): - """The hotel survey must carry the IRIA namespace and honor its XSD.""" + def test_generate_xml_hotel_validates_published_schema(self): + """By default the hotel survey follows the schema published by the + INE, which declares no namespace.""" # ARRANGE self.ideal_scenario() self._configure_ine_property() # ACT document = self._generate_ine_document() # ASSERT - self.assertTrue( - document.tag.endswith("}ENCUESTA"), - "The hotel survey root element must be namespace-qualified", + self.assertEqual(document.tag, "ENCUESTA") + self._assert_valid_against_schema(document, "ine_hotel_survey_published.xsd") + + def test_generate_xml_hotel_qualified_namespace(self): + """When the questionnaire requires the namespace-qualified variant, + the namespace is taken from the config parameter.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + namespace = "https://iria.ine.es/schemas/ec7dbd57-d3ab-473a-a3e5-d32d8c1e17a0" + self.env["ir.config_parameter"].sudo().set_param( + "pms_l10n_es.ine_xml_namespace_hotel", namespace ) + # ACT + document = self._generate_ine_document() + # ASSERT + self.assertEqual(document.tag, "{%s}ENCUESTA" % namespace) self._assert_valid_against_schema(document, "iria_hotel_survey.xsd") def test_generate_xml_hotel_percents_sum_exactly_100(self): @@ -1122,8 +1136,7 @@ def test_generate_xml_hotel_percents_sum_exactly_100(self): # ACT document = self._generate_ine_document() # ASSERT - namespace = document.tag[1:].split("}")[0] - prices_tag = document.find("{%s}PRECIOS" % namespace) + prices_tag = document.find("PRECIOS") total = sum( Decimal(element.text) for element in prices_tag if "PCTN_" in element.tag ) @@ -1137,24 +1150,38 @@ def test_generate_xml_hotel_decimals_have_two_digits(self): # ACT document = self._generate_ine_document() # ASSERT - namespace = document.tag[1:].split("}")[0] - prices_tag = document.find("{%s}PRECIOS" % namespace) + prices_tag = document.find("PRECIOS") for element in prices_tag: self.assertRegex(element.text, r"^\d+\.\d{2}$") - def test_generate_xml_apartments_validates_iria_schema(self): - """The apartments survey must carry the IRIA namespace and honor - its XSD.""" + def test_generate_xml_apartments_validates_published_schema(self): + """By default the apartments survey follows the schema published by + the INE, which declares no namespace.""" # ARRANGE self.ideal_scenario() self._configure_ine_property(survey_type="apartments") # ACT document = self._generate_ine_document() # ASSERT - self.assertTrue( - document.tag.endswith("}APARTAMENTOS"), - "The apartments survey root element must be namespace-qualified", + self.assertEqual(document.tag, "APARTAMENTOS") + self._assert_valid_against_schema( + document, "ine_apartments_survey_published.xsd" ) + + def test_generate_xml_apartments_qualified_namespace(self): + """The namespace-qualified variant of the apartments survey, which is + the one accepted so far on upload, honors the questionnaire schema.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property(survey_type="apartments") + namespace = "https://iria.ine.es/schemas/15b6131c-259d-42ff-96a1-3fe0800dfdd1" + self.env["ir.config_parameter"].sudo().set_param( + "pms_l10n_es.ine_xml_namespace_apartments", namespace + ) + # ACT + document = self._generate_ine_document() + # ASSERT + self.assertEqual(document.tag, "{%s}APARTAMENTOS" % namespace) self._assert_valid_against_schema(document, "iria_apartments_survey.xsd") def test_apartments_capacity_typologies(self): diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index ec0894d5ef..bcc4becc94 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -10,21 +10,18 @@ # TODO: Review code (code iso ?) CODE_SPAIN = "ES" -# The INE moved the XML questionnaire upload from ARCE to IRIA. IRIA schemas -# are namespace-qualified (elementFormDefault="qualified"): files without the -# proper default namespace are rejected at upload time with an XSD error on -# line 1. The namespace URIs below are the targetNamespace values of the -# current schema versions and can be overridden through config parameters if -# the INE publishes a new questionnaire version. +# The INE replaced the ARCE platform with IRIA. Two variants of each survey +# schema coexist: the one published by the INE, which declares no target +# namespace and is the reference one, and the one the IRIA application +# generates for the questionnaire, which is namespace-qualified. The INE +# confirmed that the published schema is the one to follow, and both variants +# have been accepted on upload (the hotel file without namespace and the +# apartments file with it). Files are therefore built after the published +# schema, and the namespace can be set per survey through a config parameter +# for the questionnaires that require the qualified variant. INE_XML_NAMESPACE_PARAMS = { - "hotel": ( - "pms_l10n_es.ine_xml_namespace_hotel", - "https://iria.ine.es/schemas/ec7dbd57-d3ab-473a-a3e5-d32d8c1e17a0", - ), - "apartments": ( - "pms_l10n_es.ine_xml_namespace_apartments", - "https://iria.ine.es/schemas/15b6131c-259d-42ff-96a1-3fe0800dfdd1", - ), + "hotel": ("pms_l10n_es.ine_xml_namespace_hotel", ""), + "apartments": ("pms_l10n_es.ine_xml_namespace_apartments", ""), } INE_APARTMENT_TYPES = ["studio", "apt_2_4", "apt_4_6", "other"] @@ -558,9 +555,16 @@ def _check_ine_apartments_mandatory_fields(self, pms_property_id): if not pms_property_id.ine_informant_email: raise ValidationError(_("The INE informant email is not established.")) - def _ine_get_xml_namespace(self, survey_type): + def _ine_get_root_attrs(self, survey_type): + """Return the root element attributes for the survey. + + The default namespace is only declared when the questionnaire + requires the namespace-qualified schema variant (see + INE_XML_NAMESPACE_PARAMS). + """ param, default = INE_XML_NAMESPACE_PARAMS[survey_type] - return self.env["ir.config_parameter"].sudo().get_param(param, default) + namespace = self.env["ir.config_parameter"].sudo().get_param(param, default) + return {"xmlns": namespace} if namespace else {} @api.model def _ine_format_decimal(self, value): @@ -634,9 +638,7 @@ def ine_generate_xml(self): def _ine_build_xml_hotel(self): # INE XML - survey_tag = ET.Element( - "ENCUESTA", {"xmlns": self._ine_get_xml_namespace("hotel")} - ) + survey_tag = ET.Element("ENCUESTA", self._ine_get_root_attrs("hotel")) # INE XML -> PROPERTY header_tag = ET.SubElement(survey_tag, "CABECERA") @@ -957,9 +959,7 @@ def _ine_build_xml_apartments(self): """ pms_property = self.pms_property_id - survey_tag = ET.Element( - "APARTAMENTOS", {"xmlns": self._ine_get_xml_namespace("apartments")} - ) + survey_tag = ET.Element("APARTAMENTOS", self._ine_get_root_attrs("apartments")) # EOAP XML -> CABECERA header_tag = ET.SubElement(survey_tag, "CABECERA") From bf2d33381584082f2e45838bd5d4a1400e94bb8f Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Wed, 5 Aug 2026 18:26:29 +0200 Subject: [PATCH 04/12] [IMP] pms_l10n_es: guard the INE content validations found in the fleet Generating the file of every property with an INE category in the fleet and checking it against the schema published by the INE and its official content validations (XML_0..XML_17 of the web service documentation) surfaced three cases that build a file the INE rejects: - Properties whose phone has less than 9 digits: the schema requires between 9 and 13 characters in TELEFONO_1. - Periods without guest movements: the schema requires at least one RESIDENCIA under ALOJAMIENTO, so an empty block cannot be uploaded. - A client type reporting a rate with a zero percentage, or the other way around: the INE requires both to be consistent, on top of the percentages adding up to 100. The first two now raise a message explaining what to fix instead of building an invalid file, and the percentages are normalized keeping their consistency with the rates. The file must also carry the whole reference month even though the questionnaire asks for a single week (hotels) or fortnight (apartments), so a partial period is rejected up front. (cherry picked from commit 82e911e38a2f1b2a834e345b65ef9e9c846d71b7) Assisted-by: Claude Opus 5 --- pms_l10n_es/tests/test_wizard_ine.py | 82 ++++++++++++++++++++++++++++ pms_l10n_es/wizards/wizard_ine.py | 57 ++++++++++++++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/pms_l10n_es/tests/test_wizard_ine.py b/pms_l10n_es/tests/test_wizard_ine.py index afb7f07bca..5fa0ece6b1 100644 --- a/pms_l10n_es/tests/test_wizard_ine.py +++ b/pms_l10n_es/tests/test_wizard_ine.py @@ -1222,3 +1222,85 @@ def test_apartments_missing_informant_raises(self): msg="Cannot generate the apartments survey without informant", ): self._generate_ine_document() + + def test_generate_xml_requires_whole_month(self): + """The INE only accepts files covering the whole reference month.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + wizard = self.env["pms.ine.wizard"].new( + { + "pms_property_id": self.pms_property1.id, + "start_date": datetime.date(2021, 2, 1), + "end_date": datetime.date(2021, 2, 7), + } + ) + # ACT & ASSERT + with self.assertRaises( + ValidationError, + msg="Cannot generate the INE file for a partial month", + ): + wizard.ine_generate_xml() + + def test_generate_xml_requires_guest_movements(self): + """A period without guest movements cannot produce a valid file.""" + # ARRANGE + self._configure_ine_property() + wizard = self.env["pms.ine.wizard"].new( + { + "pms_property_id": self.pms_property1.id, + "start_date": datetime.date(2021, 2, 1), + "end_date": datetime.date(2021, 2, 28), + } + ) + # ACT & ASSERT + with self.assertRaises( + ValidationError, + msg="Cannot generate the INE file without guest movements", + ): + wizard.ine_generate_xml() + + def test_generate_xml_short_phone_raises(self): + """The survey schema requires at least 9 digits in the phone.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + self.pms_property1.phone = "12345" + # ACT & ASSERT + with self.assertRaises( + ValidationError, msg="Cannot generate the INE file with a short phone" + ): + self._generate_ine_document() + + def test_generate_xml_hotel_rate_and_percentage_are_consistent(self): + """Every client type with a rate reports a non-zero percentage and + vice versa, as the INE content validations require.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + # ACT + document = self._generate_ine_document() + # ASSERT + prices_tag = document.find("PRECIOS") + # the INE does not name both tags of a client type symmetrically + client_types = [ + ("ADR_TOUROPERADOR_TRADICIONAL", "TOUROPERADOR_TRADICIONAL"), + ("ADR_TOUROPERADOR_ONLINE", "TOUROPERADOR_ONLINE"), + ("ADR_EMPRESAS", "EMPRESAS"), + ("ADR_AGENCIA_DE_VIAJE_TRADICIONAL", "AGENCIA_TRADICIONAL"), + ("ADR_AGENCIA_DE_VIAJE_ONLINE", "AGENCIA_ONLINE"), + ("ADR_PARTICULARES", "PARTICULARES"), + ("ADR_GRUPOS", "GRUPOS"), + ("ADR_INTERNET", "INTERNET"), + ("ADR_OTROS", "OTROS"), + ] + for adr_tag, percent_key in client_types: + rate = Decimal(prices_tag.find(adr_tag).text) + percent = Decimal( + prices_tag.find("PCTN_HABITACIONES_OCUPADAS_" + percent_key).text + ) + self.assertEqual( + rate > 0, + percent > 0, + f"{adr_tag}: rate {rate} and percentage {percent} are inconsistent", + ) diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index bcc4becc94..6b9c106240 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -524,6 +524,15 @@ def check_ine_mandatory_fields(self, pms_property_id): if not pms_property_id.phone: raise ValidationError(_("The property phone is not established.")) + if len(pms_property_id.phone.replace(" ", "")) < 9: + raise ValidationError( + _( + "The property phone '%s' is too short: the INE survey " + "requires at least 9 digits.", + pms_property_id.phone, + ) + ) + if not pms_property_id.ine_category_id: raise ValidationError(_("The property category is not established.")) @@ -555,6 +564,27 @@ def _check_ine_apartments_mandatory_fields(self, pms_property_id): if not pms_property_id.ine_informant_email: raise ValidationError(_("The INE informant email is not established.")) + def _check_ine_period(self): + """The INE requires the whole month in the XML file. + + The questionnaire the establishment receives asks for a single week + (hotels) or fortnight (apartments), but the file must always carry + every day of the reference month. + """ + last_day = calendar.monthrange(self.start_date.year, self.start_date.month)[1] + if self.start_date.day != 1 or self.end_date != self.start_date.replace( + day=last_day + ): + raise ValidationError( + _( + "The INE file must contain the whole reference month, " + "regardless of the week or fortnight requested in the " + "questionnaire. Select from %(first)s to %(last)s.", + first=self.start_date.replace(day=1), + last=self.start_date.replace(day=last_day), + ) + ) + def _ine_get_root_attrs(self, survey_type): """Return the root element attributes for the survey. @@ -584,6 +614,7 @@ def _ine_survey_type(self): def ine_generate_xml(self): self.check_ine_mandatory_fields(self.pms_property_id) + self._check_ine_period() number_of_rooms = sum( self.env["pms.room"] @@ -800,11 +831,18 @@ def _ine_build_xml_hotel(self): # content validation rejects files whose percentage columns # exceed 100.00, and float rounding artifacts have caused # rejected files in the past. + # The INE also requires the percentage and the ADR of each client + # type to be consistent: a client type with a rate must report a + # non-zero percentage and vice versa. cents = { - group: int(round(value * 10000 / total_percent)) + group: int(round(value * 10000 / total_percent)) if adrs[group] else 0 for group, value in percents.items() } - cents[max(cents, key=cents.get)] += 10000 - sum(cents.values()) + for group, value in cents.items(): + if adrs[group] and not value: + cents[group] = 1 + if any(cents.values()): + cents[max(cents, key=cents.get)] += 10000 - sum(cents.values()) percents = {group: value / 100.0 for group, value in cents.items()} else: for group in percents: @@ -1092,7 +1130,11 @@ def _ine_build_xml_apartments(self): return survey_tag def _ine_append_guest_movements(self, accommodation_tag): - """Fill the ALOJAMIENTO block (shared by all INE occupancy surveys).""" + """Fill the ALOJAMIENTO block (shared by all INE occupancy surveys). + + The schema requires at least one RESIDENCIA, so a period without + guest movements cannot produce a valid file. + """ countries = self.ine_countries( self.start_date, self.end_date, self.pms_property_id.id ) @@ -1129,3 +1171,12 @@ def _ine_append_guest_movements(self, accommodation_tag): ET.SubElement(movement, "PERNOCTACIONES").text = str( value_dates.get("pernoctations") or 0 ) + + if not len(accommodation_tag): + raise ValidationError( + _( + "There are no guest movements in the selected period, so " + "the INE file cannot be generated: the survey schema " + "requires at least one place of residence." + ) + ) From 6c3beed4134dfb4e5a58917bd9635510cf4c6a8e Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Wed, 5 Aug 2026 19:06:34 +0200 Subject: [PATCH 05/12] [IMP] pms_l10n_es: keep the INE order number in the property The order number identifies the establishment in the INE questionnaire and is fixed, so it is stored in the property and prefilled in the INE wizard, where it can also be filled in for the first time. The control code is deliberately not stored: it belongs to a single questionnaire and is single use. (cherry picked from commit c3672ab90bea5cd6c1497ea9b0d19ceb399905f4) Assisted-by: Claude Opus 5 --- pms_l10n_es/models/pms_property.py | 7 ++++++ pms_l10n_es/tests/test_wizard_ine.py | 28 ++++++++++++++++++++++++ pms_l10n_es/views/pms_property_views.xml | 1 + pms_l10n_es/wizards/wizard_ine.py | 22 +++++++++++++++++++ pms_l10n_es/wizards/wizard_ine.xml | 1 + 5 files changed, 59 insertions(+) diff --git a/pms_l10n_es/models/pms_property.py b/pms_l10n_es/models/pms_property.py index 945b2d5de4..96e4919a61 100644 --- a/pms_l10n_es/models/pms_property.py +++ b/pms_l10n_es/models/pms_property.py @@ -48,6 +48,13 @@ class PmsProperty(models.Model): comodel_name="pms.ine.tourism.type.category", help="Hotel category in the Ministry of Tourism. Used for INE statistics.", ) + ine_order_number = fields.Char( + string="INE Order Number", + help="Order number of the INE questionnaire (11 characters). It is " + "fixed for each establishment, so it is kept here to submit the " + "questionnaire. The control code, on the contrary, changes with " + "every questionnaire and is single use, so it is never stored.", + ) ine_informant_name = fields.Char( string="INE Informant Name", help="Contact person reported in the INFORMANTE block of the INE " diff --git a/pms_l10n_es/tests/test_wizard_ine.py b/pms_l10n_es/tests/test_wizard_ine.py index 5fa0ece6b1..262d8cacbe 100644 --- a/pms_l10n_es/tests/test_wizard_ine.py +++ b/pms_l10n_es/tests/test_wizard_ine.py @@ -1304,3 +1304,31 @@ def test_generate_xml_hotel_rate_and_percentage_are_consistent(self): percent > 0, f"{adr_tag}: rate {rate} and percentage {percent} are inconsistent", ) + + def test_order_number_is_kept_in_the_property(self): + """The order number is fixed per establishment: it is prefilled from + the property and kept there when filled in the wizard.""" + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + wizard = self.env["pms.ine.wizard"].new( + { + "pms_property_id": self.pms_property1.id, + "start_date": datetime.date(2021, 2, 1), + "end_date": datetime.date(2021, 2, 28), + } + ) + self.assertFalse(wizard.ine_order_number) + # ACT + wizard.ine_order_number = "2026HOT0361" + wizard.ine_generate_xml() + # ASSERT + self.assertEqual(self.pms_property1.ine_order_number, "2026HOT0361") + prefilled = self.env["pms.ine.wizard"].new( + { + "pms_property_id": self.pms_property1.id, + "start_date": datetime.date(2021, 2, 1), + "end_date": datetime.date(2021, 2, 28), + } + ) + self.assertEqual(prefilled.ine_order_number, "2026HOT0361") diff --git a/pms_l10n_es/views/pms_property_views.xml b/pms_l10n_es/views/pms_property_views.xml index 45043ca101..6b861c53d7 100644 --- a/pms_l10n_es/views/pms_property_views.xml +++ b/pms_l10n_es/views/pms_property_views.xml @@ -48,6 +48,7 @@
+ diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index 6b9c106240..7740c1671e 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -66,6 +66,27 @@ class WizardIne(models.TransientModel): adr = fields.Float(string="Range ADR") revpar = fields.Float(string="Range RevPAR") + ine_order_number = fields.Char( + string="INE Order Number", + compute="_compute_ine_order_number", + readonly=False, + store=False, + help="Order number of the INE questionnaire. It comes from the " + "property when it is already known, and it is kept there when it " + "is filled in here.", + ) + + @api.depends("pms_property_id") + def _compute_ine_order_number(self): + for record in self: + record.ine_order_number = record.pms_property_id.ine_order_number + + def _store_ine_order_number(self): + """Keep the order number in the property: it does not change.""" + number = (self.ine_order_number or "").strip() + if number and number != self.pms_property_id.ine_order_number: + self.pms_property_id.sudo().ine_order_number = number + @api.model def _ine_get_extra_beds(self, pms_property_id, date): """Extra beds occupied on a given date (INE criteria). @@ -615,6 +636,7 @@ def _ine_survey_type(self): def ine_generate_xml(self): self.check_ine_mandatory_fields(self.pms_property_id) self._check_ine_period() + self._store_ine_order_number() number_of_rooms = sum( self.env["pms.room"] diff --git a/pms_l10n_es/wizards/wizard_ine.xml b/pms_l10n_es/wizards/wizard_ine.xml index e35790a56f..9336fdfc03 100644 --- a/pms_l10n_es/wizards/wizard_ine.xml +++ b/pms_l10n_es/wizards/wizard_ine.xml @@ -9,6 +9,7 @@
+
From 92ab29d03ab9e1fafb2403998a0af80706a6a290 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Fri, 7 Aug 2026 22:23:04 +0200 Subject: [PATCH 06/12] [IMP] pms_l10n_es: check the INE files before handing them over The establishment used to find out about a rejected file days later, on the INE portal, with a message naming an XML element. Two changes move that feedback to the moment the file is generated. The reference period is now expanded to the natural month of the start date instead of being rejected when it is not the whole month. A range reaching into the next month was the worst case: it repeats day numbers inside a place of residence, and the day is a key in the survey schema, so the INE answered with a duplicate key error that says nothing to a receptionist who only picked the wrong end date. The content validations the INE runs after the schema are now applied to the built file, and a failure names the day and the figures involved instead of the XML element. Checked against 97 files generated from two real fleets: the only two files reported are the two the INE would reject, both for overnight stays exceeding the seats declared in the property. The empty month error now points at the check-in data, which is where the survey is built from: several establishments with real occupancy and no completed check-ins would otherwise report an empty month. (cherry picked from commit 088e92e75a2b5845b89480b5573d2c419ce36154) Assisted-by: Claude Opus 5 --- pms_l10n_es/tests/test_wizard_ine.py | 79 ++++++++- pms_l10n_es/wizards/wizard_ine.py | 252 ++++++++++++++++++++++++--- 2 files changed, 304 insertions(+), 27 deletions(-) diff --git a/pms_l10n_es/tests/test_wizard_ine.py b/pms_l10n_es/tests/test_wizard_ine.py index 262d8cacbe..0cbc226cda 100644 --- a/pms_l10n_es/tests/test_wizard_ine.py +++ b/pms_l10n_es/tests/test_wizard_ine.py @@ -1,5 +1,6 @@ import base64 import datetime +import xml.etree.ElementTree as ET from decimal import Decimal from freezegun import freeze_time @@ -1223,24 +1224,90 @@ def test_apartments_missing_informant_raises(self): ): self._generate_ine_document() - def test_generate_xml_requires_whole_month(self): - """The INE only accepts files covering the whole reference month.""" + def test_generate_xml_expands_partial_month(self): + """A period shorter than the month is expanded to the whole month.""" # ARRANGE self.ideal_scenario() self._configure_ine_property() + wizard = self.env["pms.ine.wizard"].new( + { + "pms_property_id": self.pms_property1.id, + "start_date": datetime.date(2021, 2, 8), + "end_date": datetime.date(2021, 2, 14), + } + ) + # ACT + wizard.ine_generate_xml() + # ASSERT + self.assertEqual(wizard.start_date, datetime.date(2021, 2, 1)) + self.assertEqual(wizard.end_date, datetime.date(2021, 2, 28)) + + def test_generate_xml_range_crossing_months(self): + """A range reaching into the next month reports the first one only. + + Keeping both months repeats day numbers inside a place of residence + and the survey schema rejects the file, because the day is a key + there. + """ + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + namespace = "https://iria.ine.es/schemas/ec7dbd57-d3ab-473a-a3e5-d32d8c1e17a0" + self.env["ir.config_parameter"].sudo().set_param( + "pms_l10n_es.ine_xml_namespace_hotel", namespace + ) wizard = self.env["pms.ine.wizard"].new( { "pms_property_id": self.pms_property1.id, "start_date": datetime.date(2021, 2, 1), - "end_date": datetime.date(2021, 2, 7), + "end_date": datetime.date(2021, 3, 1), } ) + # ACT + wizard.ine_generate_xml() + document = etree.fromstring(base64.b64decode(wizard.txt_binary)) + # ASSERT + self.assertEqual(wizard.end_date, datetime.date(2021, 2, 28)) + for residency_tag in document.iter("{%s}RESIDENCIA" % namespace): + days = [ + movement_tag.findtext("{%s}N_DIA" % namespace) + for movement_tag in residency_tag + if movement_tag.tag.endswith("MOVIMIENTO") + ] + self.assertEqual(len(days), len(set(days)), "Repeated day in a residence") + self._assert_valid_against_schema(document, "iria_hotel_survey.xsd") + + def test_check_xml_content_detects_broken_daily_chain(self): + """Guests cannot vanish: the INE checks the daily chain per residence.""" + # ARRANGE + self._configure_ine_property() + wizard = self.env["pms.ine.wizard"].new( + { + "pms_property_id": self.pms_property1.id, + "start_date": datetime.date(2021, 2, 1), + "end_date": datetime.date(2021, 2, 28), + } + ) + survey_tag = ET.Element("ENCUESTA") + header_tag = ET.SubElement(survey_tag, "CABECERA") + ET.SubElement(header_tag, "DIAS_ABIERTO_MES_REFERENCIA").text = "28" + accommodation_tag = ET.SubElement(survey_tag, "ALOJAMIENTO") + residency_tag = ET.SubElement(accommodation_tag, "RESIDENCIA") + ET.SubElement(residency_tag, "ID_PROVINCIA_ISLA").text = "ES300" + for day, (arrivals, departures, stays) in { + 1: (2, 0, 2), + 2: (0, 0, 0), # two guests disappear without checking out + }.items(): + movement_tag = ET.SubElement(residency_tag, "MOVIMIENTO") + ET.SubElement(movement_tag, "N_DIA").text = "%02d" % day + ET.SubElement(movement_tag, "ENTRADAS").text = str(arrivals) + ET.SubElement(movement_tag, "SALIDAS").text = str(departures) + ET.SubElement(movement_tag, "PERNOCTACIONES").text = str(stays) # ACT & ASSERT with self.assertRaises( - ValidationError, - msg="Cannot generate the INE file for a partial month", + ValidationError, msg="A broken daily chain must be reported" ): - wizard.ine_generate_xml() + wizard._ine_check_xml_content(survey_tag) def test_generate_xml_requires_guest_movements(self): """A period without guest movements cannot produce a valid file.""" diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index 7740c1671e..7e08ac52b8 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -3,6 +3,7 @@ import datetime import math import xml.etree.ElementTree as ET +from collections import defaultdict from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -585,26 +586,24 @@ def _check_ine_apartments_mandatory_fields(self, pms_property_id): if not pms_property_id.ine_informant_email: raise ValidationError(_("The INE informant email is not established.")) - def _check_ine_period(self): - """The INE requires the whole month in the XML file. + def _ine_normalize_period(self): + """Expand the requested period to the whole reference month. - The questionnaire the establishment receives asks for a single week - (hotels) or fortnight (apartments), but the file must always carry - every day of the reference month. + The INE always expects the natural month, even though the + questionnaire asks for a single week (hotels) or fortnight + (apartments). The reference month is the one of the start date, and + the end date is ignored on purpose: a range reaching into the next + month repeats day numbers inside a RESIDENCIA and the survey schema + rejects the file, because the day is a key there. """ - last_day = calendar.monthrange(self.start_date.year, self.start_date.month)[1] - if self.start_date.day != 1 or self.end_date != self.start_date.replace( - day=last_day - ): - raise ValidationError( - _( - "The INE file must contain the whole reference month, " - "regardless of the week or fortnight requested in the " - "questionnaire. Select from %(first)s to %(last)s.", - first=self.start_date.replace(day=1), - last=self.start_date.replace(day=last_day), - ) - ) + first_day = self.start_date.replace(day=1) + last_day = self.start_date.replace( + day=calendar.monthrange(self.start_date.year, self.start_date.month)[1] + ) + if self.start_date != first_day: + self.start_date = first_day + if self.end_date != last_day: + self.end_date = last_day def _ine_get_root_attrs(self, survey_type): """Return the root element attributes for the survey. @@ -633,9 +632,213 @@ def _ine_get_province_name(self): def _ine_survey_type(self): return self.pms_property_id.ine_category_id.survey_type or "hotel" + def _ine_read_movements(self, residency_tag): + """Return {day: (arrivals, departures, overnight stays)}.""" + movements = {} + for movement_tag in residency_tag.findall("MOVIMIENTO"): + day = int(movement_tag.findtext("N_DIA")) + movements[day] = ( + int(movement_tag.findtext("ENTRADAS")), + int(movement_tag.findtext("SALIDAS")), + int(movement_tag.findtext("PERNOCTACIONES")), + ) + return movements + + def _ine_check_guest_movements(self, survey_tag, days_in_month): + """Validate the ALOJAMIENTO block, shared by every occupancy survey.""" + problems = [] + overnight_stays = defaultdict(int) + days_with_movement = set() + for residency_tag in survey_tag.findall("ALOJAMIENTO/RESIDENCIA"): + residence = residency_tag.findtext("ID_PAIS") or residency_tag.findtext( + "ID_PROVINCIA_ISLA" + ) + days = [ + int(movement_tag.findtext("N_DIA")) + for movement_tag in residency_tag.findall("MOVIMIENTO") + ] + for day in sorted({day for day in days if days.count(day) > 1}): + problems.append( + _( + "Place of residence %(residence)s: day %(day)s is " + "reported twice. The survey schema uses the day as a " + "key, so it can only appear once.", + residence=residence, + day=day, + ) + ) + movements = self._ine_read_movements(residency_tag) + previous_stays = None + for day in range(1, days_in_month + 1): + arrivals, departures, stays = movements.get(day, (0, 0, 0)) + if day in movements: + days_with_movement.add(day) + overnight_stays[day] += stays + if min(arrivals, departures, stays) < 0: + problems.append( + _( + "Place of residence %(residence)s, day %(day)s: " + "negative values are not allowed.", + residence=residence, + day=day, + ) + ) + if stays < arrivals: + problems.append( + _( + "Place of residence %(residence)s, day %(day)s: " + "%(stays)s overnight stays for %(arrivals)s " + "arrivals. Every guest checking in stays at " + "least that night.", + residence=residence, + day=day, + stays=stays, + arrivals=arrivals, + ) + ) + expected = (previous_stays or 0) + arrivals - departures + if previous_stays is not None and stays != expected: + problems.append( + _( + "Place of residence %(residence)s, day %(day)s: " + "%(stays)s overnight stays, but the previous day " + "had %(previous)s with %(arrivals)s arrivals and " + "%(departures)s departures, so the INE expects " + "%(expected)s.", + residence=residence, + day=day, + stays=stays, + previous=previous_stays, + arrivals=arrivals, + departures=departures, + expected=expected, + ) + ) + previous_stays = stays + return problems, overnight_stays, days_with_movement + + def _ine_check_hotel_rooms(self, survey_tag, overnight_stays): + """Validate the HABITACIONES block against the guest movements.""" + problems = [] + rooms_total = int(survey_tag.findtext("CABECERA/HABITACIONES") or 0) + seats = int( + survey_tag.findtext("CABECERA/PLAZAS_DISPONIBLES_SIN_SUPLETORIAS") or 0 + ) + for movement_tag in survey_tag.findall("HABITACIONES/HABITACIONES_MOVIMIENTO"): + day = int(movement_tag.findtext("HABITACIONES_N_DIA")) + double_as_double = int( + movement_tag.findtext("HABITACIONES_DOBLES_USO_DOBLE") + ) + occupied = ( + double_as_double + + int(movement_tag.findtext("HABITACIONES_DOBLES_USO_INDIVIDUAL")) + + int(movement_tag.findtext("HABITACIONES_OTRAS")) + ) + extra_beds = int(movement_tag.findtext("PLAZAS_SUPLETORIAS")) + stays = overnight_stays.get(day, 0) + if bool(occupied) != bool(stays): + problems.append( + _( + "Day %(day)s: %(rooms)s occupied rooms and " + "%(stays)s overnight stays. The INE expects both to " + "be zero or both greater than zero.", + day=day, + rooms=occupied, + stays=stays, + ) + ) + if occupied > stays: + problems.append( + _( + "Day %(day)s: %(rooms)s occupied rooms for only " + "%(stays)s overnight stays.", + day=day, + rooms=occupied, + stays=stays, + ) + ) + if stays and occupied == stays and double_as_double: + problems.append( + _( + "Day %(day)s: as many occupied rooms as overnight " + "stays, so no double room can be reported as used by " + "two people.", + day=day, + ) + ) + if stays > seats + extra_beds: + problems.append( + _( + "Day %(day)s: %(stays)s overnight stays exceed the " + "%(seats)s available seats plus %(extra)s extra " + "beds. Check the seats declared in the property.", + day=day, + stays=stays, + seats=seats, + extra=extra_beds, + ) + ) + if occupied > rooms_total: + problems.append( + _( + "Day %(day)s: %(rooms)s occupied rooms exceed the " + "%(total)s rooms of the establishment.", + day=day, + rooms=occupied, + total=rooms_total, + ) + ) + return problems + + def _ine_check_xml_content(self, survey_tag): + """Run the INE content validations before handing over the file. + + These are the checks the INE applies once the file passes the + schema. Running them here turns a rejection the establishment would + only find out about days later, on the INE portal, into an error + naming the day and the figures involved. + """ + days_in_month = calendar.monthrange( + self.start_date.year, self.start_date.month + )[1] + days_open = int( + survey_tag.findtext("CABECERA/DIAS_ABIERTO_MES_REFERENCIA") or 0 + ) + problems, overnight_stays, days_with_movement = self._ine_check_guest_movements( + survey_tag, days_in_month + ) + if days_open > days_in_month: + problems.append( + _( + "The file reports %(open)s days open in a month of " + "%(days)s days.", + open=days_open, + days=days_in_month, + ) + ) + if len(days_with_movement) > days_open: + problems.append( + _( + "The file reports guest movements on %(moved)s days but " + "only %(open)s days open.", + moved=len(days_with_movement), + open=days_open, + ) + ) + if survey_tag.tag == "ENCUESTA": + problems += self._ine_check_hotel_rooms(survey_tag, overnight_stays) + if problems: + raise ValidationError( + _( + "The INE would reject this file. Fix the following and " + "generate it again:\n\n- %s", + "\n- ".join(problems), + ) + ) + def ine_generate_xml(self): self.check_ine_mandatory_fields(self.pms_property_id) - self._check_ine_period() + self._ine_normalize_period() self._store_ine_order_number() number_of_rooms = sum( @@ -666,6 +869,8 @@ def ine_generate_xml(self): else: survey_tag = self._ine_build_xml_hotel() + self._ine_check_xml_content(survey_tag) + xmlstr = '' xmlstr += ET.tostring(survey_tag).decode("utf-8") @@ -1197,8 +1402,13 @@ def _ine_append_guest_movements(self, accommodation_tag): if not len(accommodation_tag): raise ValidationError( _( - "There are no guest movements in the selected period, so " + "There are no guest movements in %(month)s/%(year)s, so " "the INE file cannot be generated: the survey schema " - "requires at least one place of residence." + "requires at least one place of residence. The survey is " + "built from the check-in data, not from the bookings, so " + "check that the check-ins of the month are completed " + "before reporting the month as empty.", + month="%02d" % self.start_date.month, + year=self.start_date.year, ) ) From d0e16175857a66c5cf6caf2adc5c5006a7b5f25f Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Fri, 7 Aug 2026 23:21:52 +0200 Subject: [PATCH 07/12] [IMP] pms_l10n_es: tell the property what the INE survey is missing The configuration checks move from the wizard to the property, as ine_configuration_problems(), and report every missing field at once instead of only the first one. Two computed fields expose the result, ine_ready and ine_blocking_reasons, so the interface can list what to fix instead of offering a download that fails. The seats check joins them: it used to live in the middle of the file generation, and it is the most frequent blocker in the fleet, where establishments have the seats unset while their rooms already offer capacity. Spanish translations for the new messages are included, since the app shows them to the end user through the API. The help texts are left to Weblate. (cherry picked from commit 8f074a137866a41c814752d04b2eb4122a5a1709) Assisted-by: Claude Opus 5 --- pms_l10n_es/models/pms_property.py | 127 ++++++++++++++++++++++++++- pms_l10n_es/tests/test_wizard_ine.py | 39 ++++++++ pms_l10n_es/wizards/wizard_ine.py | 101 ++------------------- 3 files changed, 172 insertions(+), 95 deletions(-) diff --git a/pms_l10n_es/models/pms_property.py b/pms_l10n_es/models/pms_property.py index 96e4919a61..afe78fdcc6 100644 --- a/pms_l10n_es/models/pms_property.py +++ b/pms_l10n_es/models/pms_property.py @@ -1,4 +1,9 @@ -from odoo import fields, models +from odoo import _, api, fields, models + +# Maximum length of the province literal in the INE survey schemas. +INE_PROVINCE_MAX_LENGTH = 25 +# Minimum number of digits the INE survey schemas accept in a phone. +INE_PHONE_MIN_LENGTH = 9 class PmsProperty(models.Model): @@ -78,3 +83,123 @@ class PmsProperty(models.Model): string="Spanish Tourism Classification", help="Spanish tourism classification.", ) + ine_ready = fields.Boolean( + string="Ready for the INE survey", + compute="_compute_ine_ready", + help="Whether the property has everything the INE occupancy survey " + "needs. When it has not, the missing data is listed in the INE " + "configuration warnings.", + ) + ine_blocking_reasons = fields.Text( + string="INE Configuration Warnings", + compute="_compute_ine_ready", + help="What is missing before the INE occupancy survey can be built.", + ) + + @api.depends( + "name", + "street", + "zip", + "city", + "phone", + "company_id.name", + "company_id.vat", + "partner_id.state_id", + "ine_tourism_number", + "ine_category_id", + "ine_seats", + "ine_informant_name", + "ine_informant_job", + "ine_informant_email", + ) + def _compute_ine_ready(self): + for record in self: + problems = record.ine_configuration_problems() + record.ine_ready = not problems + record.ine_blocking_reasons = "\n".join(problems) + + def ine_configuration_problems(self): + """List what keeps the property from reporting to the INE. + + Returned as plain sentences so that both the wizard and the app can + show them: a survey rejected for a missing field is the most common + support request around the INE, and the establishment can only act + on it when it is told which field it is. + """ + self.ensure_one() + problems = [] + if not self.name: + problems.append(_("The property name is not established.")) + if not self.company_id.vat: + problems.append(_("The company VAT is not established.")) + if not self.company_id.name: + problems.append(_("The company name is not established.")) + if not self.ine_tourism_number: + problems.append(_("The property tourism number is not established.")) + if not self.street: + problems.append(_("The property street is not established.")) + if not self.zip: + problems.append(_("The property zip is not established.")) + if not self.city: + problems.append(_("The property city is not established.")) + if not self.partner_id.state_id: + problems.append(_("The property state is not established.")) + if not self.phone: + problems.append(_("The property phone is not established.")) + elif len(self.phone.replace(" ", "")) < INE_PHONE_MIN_LENGTH: + problems.append( + _( + "The property phone '%s' is too short: the INE survey " + "requires at least 9 digits.", + self.phone, + ) + ) + if not self.ine_category_id: + problems.append(_("The property category is not established.")) + province = ( + self.partner_id.state_id.ine_tourism_province_name + or self.partner_id.state_id.name + ) + if province and len(province) > INE_PROVINCE_MAX_LENGTH: + problems.append( + _( + "The province literal '%s' exceeds the 25 characters " + "allowed by the INE survey. Set the 'INE Tourism " + "Province Name' field on the property state.", + province, + ) + ) + problems += self._ine_seats_problems() + if self.ine_category_id.survey_type == "apartments": + problems += self._ine_informant_problems() + return problems + + def _ine_seats_problems(self): + """The declared seats cannot be below what the rooms already offer.""" + seats = sum( + self.env["pms.room"] + .search([("in_ine", "=", True), ("pms_property_id", "=", self.id)]) + .mapped("capacity") + ) + if seats <= self.ine_seats: + return [] + return [ + _( + "The rooms reported to the INE add up to %(seats)s seats, " + "more than the %(declared)s declared in the property. Set " + "the seats available excluding extra beds.", + seats=seats, + declared=self.ine_seats, + ) + ] + + def _ine_informant_problems(self): + """The Tourist Apartments survey (EOAP) carries an informant block.""" + problems = [] + if not self.ine_informant_name: + problems.append(_("The INE informant name is not established.")) + if not self.ine_informant_job: + problems.append(_("The INE informant job position is not established.")) + if not self.ine_informant_email: + problems.append(_("The INE informant email is not established.")) + return problems diff --git a/pms_l10n_es/tests/test_wizard_ine.py b/pms_l10n_es/tests/test_wizard_ine.py index 0cbc226cda..86cb62a597 100644 --- a/pms_l10n_es/tests/test_wizard_ine.py +++ b/pms_l10n_es/tests/test_wizard_ine.py @@ -1277,6 +1277,45 @@ def test_generate_xml_range_crossing_months(self): self.assertEqual(len(days), len(set(days)), "Repeated day in a residence") self._assert_valid_against_schema(document, "iria_hotel_survey.xsd") + def test_configuration_problems_list_every_missing_field(self): + """The property reports all the missing data at once, not the first.""" + # ARRANGE + self._configure_ine_property() + self.pms_property1.write( + { + "ine_tourism_number": False, + "city": False, + "phone": "123", + } + ) + # ACT + problems = self.pms_property1.ine_configuration_problems() + # ASSERT + self.assertFalse(self.pms_property1.ine_ready) + self.assertEqual(len(problems), 3, "\n".join(problems)) + self.assertEqual(self.pms_property1.ine_blocking_reasons, "\n".join(problems)) + + def test_configuration_problems_seats_below_room_capacity(self): + """Declaring fewer seats than the rooms offer breaks the survey.""" + # ARRANGE + self._configure_ine_property() + self.pms_property1.ine_seats = 0 + # ACT + problems = self.pms_property1.ine_configuration_problems() + # ASSERT + self.assertFalse(self.pms_property1.ine_ready) + self.assertTrue( + any("seats" in problem for problem in problems), "\n".join(problems) + ) + + def test_configuration_problems_empty_when_ready(self): + """A fully configured property reports nothing to fix.""" + # ARRANGE + self._configure_ine_property() + # ACT & ASSERT + self.assertEqual(self.pms_property1.ine_configuration_problems(), []) + self.assertTrue(self.pms_property1.ine_ready) + def test_check_xml_content_detects_broken_daily_chain(self): """Guests cannot vanish: the INE checks the daily chain per residence.""" # ARRANGE diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index 7e08ac52b8..d96e492a22 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -513,78 +513,14 @@ def ine_get_nif_cif(self, cif_nif): @api.model def check_ine_mandatory_fields(self, pms_property_id): - if not pms_property_id.name: - raise ValidationError(_("The property name is not established.")) + """Refuse to build the file when the property is not ready. - if not pms_property_id.company_id.vat: - raise ValidationError(_("The company VAT is not established.")) - - if not pms_property_id.company_id.name: - raise ValidationError(_("The company name is not established.")) - - if not pms_property_id.name: - raise ValidationError(_("The property name is not established.")) - - if not pms_property_id.ine_tourism_number: - raise ValidationError(_("The property tourism number is not established.")) - - if not pms_property_id.ine_tourism_number: - raise ValidationError(_("The property tourism number is not established.")) - - if not pms_property_id.street: - raise ValidationError(_("The property street is not established.")) - - if not pms_property_id.zip: - raise ValidationError(_("The property zip is not established.")) - - if not pms_property_id.city: - raise ValidationError(_("The property city is not established.")) - - if not pms_property_id.partner_id.state_id: - raise ValidationError(_("The property state is not established.")) - - if not pms_property_id.phone: - raise ValidationError(_("The property phone is not established.")) - - if len(pms_property_id.phone.replace(" ", "")) < 9: - raise ValidationError( - _( - "The property phone '%s' is too short: the INE survey " - "requires at least 9 digits.", - pms_property_id.phone, - ) - ) - - if not pms_property_id.ine_category_id: - raise ValidationError(_("The property category is not established.")) - - province = ( - pms_property_id.partner_id.state_id.ine_tourism_province_name - or pms_property_id.partner_id.state_id.name - ) - if len(province) > 25: - raise ValidationError( - _( - "The province literal '%s' exceeds the 25 characters " - "allowed by the INE survey. Set the 'INE Tourism " - "Province Name' field on the property state.", - province, - ) - ) - - if pms_property_id.ine_category_id.survey_type == "apartments": - self._check_ine_apartments_mandatory_fields(pms_property_id) - - @api.model - def _check_ine_apartments_mandatory_fields(self, pms_property_id): - if not pms_property_id.ine_informant_name: - raise ValidationError(_("The INE informant name is not established.")) - if not pms_property_id.ine_informant_job: - raise ValidationError( - _("The INE informant job position is not established.") - ) - if not pms_property_id.ine_informant_email: - raise ValidationError(_("The INE informant email is not established.")) + The checks live in the property, so that the interface can list + what is missing instead of waiting for the download to fail. + """ + problems = pms_property_id.ine_configuration_problems() + if problems: + raise ValidationError("\n".join(problems)) def _ine_normalize_period(self): """Expand the requested period to the whole reference month. @@ -841,29 +777,6 @@ def ine_generate_xml(self): self._ine_normalize_period() self._store_ine_order_number() - number_of_rooms = sum( - self.env["pms.room"] - .search( - [ - ("in_ine", "=", True), - ("pms_property_id", "=", self.pms_property_id.id), - ] - ) - .mapped("capacity") - ) - - if number_of_rooms > self.pms_property_id.ine_seats: - raise ValidationError( - _( - "The number of seats, excluding extra beds ({num_rooms})" - + " exceeds the number of seats " - "established in the property ({ine_seats})" - ).format( - num_rooms=str(number_of_rooms), - ine_seats=str(self.pms_property_id.ine_seats), - ) - ) - if self._ine_survey_type() == "apartments": survey_tag = self._ine_build_xml_apartments() else: From a97edd0fd543b90f048737a7818a26f0df3e8e40 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Fri, 7 Aug 2026 23:34:50 +0200 Subject: [PATCH 08/12] [IMP] pms_l10n_es: show the INE configuration warnings in the property The INE settings page now lists what is missing before the occupancy survey can be built, instead of leaving the operator to find it out when the download fails. (cherry picked from commit 75b14780ecaa27ed1dc588349754e3b128a44b82) Assisted-by: Claude Opus 5 --- pms_l10n_es/views/pms_property_views.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pms_l10n_es/views/pms_property_views.xml b/pms_l10n_es/views/pms_property_views.xml index 6b861c53d7..2488db3fb3 100644 --- a/pms_l10n_es/views/pms_property_views.xml +++ b/pms_l10n_es/views/pms_property_views.xml @@ -65,6 +65,20 @@
+
+ + +
From 248d680c0c74aff62587dbb469aa1ec7d4cd35c3 Mon Sep 17 00:00:00 2001 From: David Pacheco Date: Tue, 1 Sep 2026 12:17:13 +0200 Subject: [PATCH 09/12] [FIX] pms_l10n_es: report the extra beds the guests are sleeping in The INE counts as an extra bed every bed without a fixed character that is not among the declared places, cots included (survey methodology, 5.10), and it counts every guest who stays the night as a traveller, with no exception by age. The file, however, only reported the extra beds sold as a service, and hardly any establishment of the fleet has an extra bed product configured: a baby in a cot was counted as a traveller while the bed they slept in was not, and the survey was rejected as soon as the guests of a night exceeded the declared seats. Extra beds are now also derived from the guests registered in a room beyond the places it offers, and the larger of the two sources wins, so establishments that do sell the service keep reporting it. The declared seats are already required to be at least the capacity of the rooms reported to the INE, which makes the derived figure enough for the overnight stays to never exceed the seats plus the extra beds. Since a room whose capacity is set too low would then report an extra bed every single night, the wizard lists the nights it reported one. (cherry picked from commit 91727cf84254f63bdb527229201a1a4a2c322029) Assisted-by: Claude Opus 5 --- pms_l10n_es/tests/test_wizard_ine.py | 104 +++++++++++++++++++++++++++ pms_l10n_es/wizards/wizard_ine.py | 93 ++++++++++++++++++++++++ pms_l10n_es/wizards/wizard_ine.xml | 13 ++++ 3 files changed, 210 insertions(+) diff --git a/pms_l10n_es/tests/test_wizard_ine.py b/pms_l10n_es/tests/test_wizard_ine.py index 86cb62a597..705f70ba1b 100644 --- a/pms_l10n_es/tests/test_wizard_ine.py +++ b/pms_l10n_es/tests/test_wizard_ine.py @@ -1348,6 +1348,110 @@ def test_check_xml_content_detects_broken_daily_chain(self): ): wizard._ine_check_xml_content(survey_tag) + def test_extra_beds_from_guests_over_room_capacity(self): + """A guest beyond the places of the room sleeps in an extra bed. + + Most establishments never sell an extra bed as a service, so the + only trace of a cot is the guest registered in a room that does not + have a fixed bed for them. + """ + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + reservation = self.env["pms.reservation"].create( + { + "checkin": datetime.date(2021, 2, 10), + "checkout": datetime.date(2021, 2, 11), + "preferred_room_id": self.room_double_3.id, + "partner_id": self.partner_1.id, + "adults": 2, + # a child that does not take a place of the room: the + # commercial reading of a cot, and what the establishments + # of the fleet record for a baby + "children": 1, + "children_occupying": 0, + "pms_property_id": self.pms_property1.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + for partner in (self.partner_1, self.partner_2, self.partner_3): + checkin = self.env["pms.checkin.partner"].create( + { + "partner_id": partner.id, + "reservation_id": reservation.id, + "street": "Test street 1", + "city": "Test city", + "zip": "08001", + } + ) + with freeze_time("2021-02-10"): + checkin.action_on_board() + # ACT + extra_beds = self.env["pms.ine.wizard"]._ine_get_extra_beds( + self.pms_property1, datetime.date(2021, 2, 10) + ) + # ASSERT: three guests in a room offering two places + self.assertEqual(extra_beds, 1) + + def test_extra_beds_keep_the_sold_services(self): + """Selling the extra bed is still a valid way of reporting it.""" + # ARRANGE: reservation 4 carries an extra bed service + self.ideal_scenario() + # ACT + extra_beds = self.env["pms.ine.wizard"]._ine_get_extra_beds( + self.pms_property1, datetime.date(2021, 2, 2) + ) + # ASSERT + self.assertEqual(extra_beds, 1) + + def test_generate_xml_reports_the_extra_beds(self): + """The file carries the extra beds and stays within the seats. + + Overnight stays over the declared seats are rejected by the INE + (XML_12), and that is what happens when the guest is counted but + the bed they sleep in is not. + """ + # ARRANGE + self.ideal_scenario() + self._configure_ine_property() + self.pms_property1.ine_seats = 10 + reservation = self.env["pms.reservation"].create( + { + "checkin": datetime.date(2021, 2, 10), + "checkout": datetime.date(2021, 2, 11), + "preferred_room_id": self.room_double_3.id, + "partner_id": self.partner_1.id, + "adults": 2, + # a child that does not take a place of the room: the + # commercial reading of a cot, and what the establishments + # of the fleet record for a baby + "children": 1, + "children_occupying": 0, + "pms_property_id": self.pms_property1.id, + "sale_channel_origin_id": self.sale_channel_direct1.id, + } + ) + for partner in (self.partner_1, self.partner_2, self.partner_3): + checkin = self.env["pms.checkin.partner"].create( + { + "partner_id": partner.id, + "reservation_id": reservation.id, + "street": "Test street 1", + "city": "Test city", + "zip": "08001", + } + ) + with freeze_time("2021-02-10"): + checkin.action_on_board() + # ACT + document = self._generate_ine_document() + # ASSERT + movement_tag = document.find( + "HABITACIONES/HABITACIONES_MOVIMIENTO[HABITACIONES_N_DIA='10']" + ) + self.assertEqual(movement_tag.findtext("PLAZAS_SUPLETORIAS"), "1") + self._assert_valid_against_schema(document, "ine_hotel_survey_published.xsd") + def test_generate_xml_requires_guest_movements(self): """A period without guest movements cannot produce a valid file.""" # ARRANGE diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index d96e492a22..6ef800313e 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -67,6 +67,13 @@ class WizardIne(models.TransientModel): adr = fields.Float(string="Range ADR") revpar = fields.Float(string="Range RevPAR") + ine_notes = fields.Text( + string="INE Notes", + readonly=True, + help="What the generated file is worth a second look for, such as " + "the nights reported with an extra bed.", + ) + ine_order_number = fields.Char( string="INE Order Number", compute="_compute_ine_order_number", @@ -92,6 +99,63 @@ def _store_ine_order_number(self): def _ine_get_extra_beds(self, pms_property_id, date): """Extra beds occupied on a given date (INE criteria). + The INE counts as an extra bed every bed without a fixed character + that is not among the places the establishment has declared, cots + included (survey methodology, 5.10). Two sources are combined and + the larger one wins: the extra beds sold as a service, and the + guests registered in a room beyond the places it offers, who sleep + in an extra bed whether or not anybody recorded the service. + + The second source is what makes the file survive: most + establishments have no extra bed product at all, so on its own the + first one reports no extra beds and the INE rejects the file as + soon as the guests of a night exceed the declared seats. + """ + return max( + self._ine_get_sold_extra_beds(pms_property_id, date), + self._ine_get_unseated_guests(pms_property_id, date), + ) + + @api.model + def _ine_get_unseated_guests(self, pms_property_id, date): + """Guests registered beyond the places their rooms offer. + + The declared seats are the fixed beds, so anybody sleeping in the + establishment who is not covered by one of them is occupying an + extra bed. Counting them per reservation keeps a room short of + guests from hiding another one over its capacity. + """ + unseated = 0 + reservation_lines = self.env["pms.reservation.line"].search( + [ + ("pms_property_id", "=", pms_property_id.id), + ("date", "=", date), + ("occupies_availability", "=", True), + ("room_id.in_ine", "=", True), + ("reservation_id.reservation_type", "=", "normal"), + ("reservation_id.state", "in", ["confirmed", "onboard", "done"]), + ] + ) + for reservation in reservation_lines.mapped("reservation_id"): + seats = sum( + reservation_lines.filtered( + lambda line, reservation=reservation: line.reservation_id + == reservation + ).mapped("room_id.capacity") + ) + guests = len( + reservation.checkin_partner_ids.filtered( + lambda checkin: checkin.state + not in ["dummy", "draft", "cancel", "precheckin"] + ) + ) + unseated += max(0, guests - seats) + return unseated + + @api.model + def _ine_get_sold_extra_beds(self, pms_property_id, date): + """Extra beds sold as a service on a given date. + Children occupying an extra bed are excluded because they have no check-in partner data. """ @@ -726,6 +790,34 @@ def _ine_check_hotel_rooms(self, survey_tag, overnight_stays): ) return problems + def _ine_extra_bed_notes(self, survey_tag): + """List the nights reported with an extra bed. + + Reporting them is what keeps the file valid, but a room whose + capacity is set too low would report an extra bed every single + night, so they are listed instead of passing unnoticed. + """ + if survey_tag.tag == "ENCUESTA": + movement_tags = survey_tag.findall("HABITACIONES/HABITACIONES_MOVIMIENTO") + day_tag = "HABITACIONES_N_DIA" + else: + movement_tags = survey_tag.findall("OCUPACION/MOVIMIENTO") + day_tag = "N_DIA_AP" + days = [ + movement_tag.findtext(day_tag) + for movement_tag in movement_tags + if int(movement_tag.findtext("PLAZAS_SUPLETORIAS") or 0) + ] + if not days: + return False + return _( + "Extra beds reported on %(count)s nights (days %(days)s), for " + "guests registered beyond the places their room offers. Review " + "the capacity of those rooms if you did not expect it.", + count=len(days), + days=", ".join(days), + ) + def _ine_check_xml_content(self, survey_tag): """Run the INE content validations before handing over the file. @@ -783,6 +875,7 @@ def ine_generate_xml(self): survey_tag = self._ine_build_xml_hotel() self._ine_check_xml_content(survey_tag) + self.ine_notes = self._ine_extra_bed_notes(survey_tag) xmlstr = '' xmlstr += ET.tostring(survey_tag).decode("utf-8") diff --git a/pms_l10n_es/wizards/wizard_ine.xml b/pms_l10n_es/wizards/wizard_ine.xml index 9336fdfc03..7b0d4e2ee9 100644 --- a/pms_l10n_es/wizards/wizard_ine.xml +++ b/pms_l10n_es/wizards/wizard_ine.xml @@ -55,6 +55,19 @@ +
+
+ + + +
+
+