diff --git a/pms_l10n_es/README.rst b/pms_l10n_es/README.rst
index eb5ee39996..298b0fef7e 100644
--- a/pms_l10n_es/README.rst
+++ b/pms_l10n_es/README.rst
@@ -39,6 +39,91 @@ This module adds spanish localization support to property management system (PMS
.. contents::
:local:
+Configuration
+=============
+
+INE occupancy surveys
+~~~~~~~~~~~~~~~~~~~~~
+
+The INE runs a monthly occupancy survey per kind of establishment. Two of
+them accept an XML questionnaire and are the ones this module builds, the
+hotel establishments survey (EOH) and the tourist apartments one (EOAP).
+Which of the two is built comes from the INE category of the property, so
+there is nothing else to choose.
+
+On the property, under the INE settings:
+
+#. **Tourism number**: the registration number in the tourism registry.
+#. **Category**: the type and category the establishment is listed under in
+ the INE directory. Its survey type decides the questionnaire.
+#. **Beds available excluding extra beds**: the places of the directory,
+ counting fixed beds only.
+#. **Staff**: permanent, temporary and unpaid.
+#. **Order number**: eleven characters, printed on the questionnaire. It is
+ fixed for the establishment, so it is kept here once filled in. The
+ control code is single use and is never stored.
+#. **Informant**: name, job position, phone and email. Only the tourist
+ apartments survey carries them, where they are mandatory.
+
+The property lists what is still missing in *INE configuration warnings*,
+so the whole configuration can be completed before trying to build a file.
+
+Rooms reported to the survey are the ones flagged *In INE*. For the tourist
+apartments survey each of them also carries a typology (studio, 2-4 pax,
+4-6 pax or other); left empty, it is inferred from the capacity of the room.
+
+Provinces and countries are taken from the lists the INE publishes next to
+the survey schemas, which do not always match the ones of Odoo:
+
+* ``res.country.state`` carries the province literal of the specification,
+ limited to 25 characters, seeded for the 52 Spanish provinces.
+* ``res.country`` carries a code of its own for the countries the INE does
+ not code as ISO 3166-1 alpha-3 does.
+
+Files are built after the schema the INE publishes, which declares no
+namespace. A questionnaire asking for the namespace-qualified variant
+instead is served by setting the namespace in the system parameters
+``pms_l10n_es.ine_xml_namespace_hotel`` or
+``pms_l10n_es.ine_xml_namespace_apartments``.
+
+Usage
+=====
+
+INE occupancy surveys
+~~~~~~~~~~~~~~~~~~~~~
+
+Go to *Reservations > Generate INE file*, pick the property and a date in
+the month to report, and generate.
+
+The questionnaire the establishment receives asks for a single week
+(hotels) or fortnight (apartments), but the file always covers the whole
+natural month, so the period is expanded to the month of the start date.
+A file built for part of a month is rejected: the INE checks the daily
+chain of every place of residence, and the first day of a partial file
+never matches it.
+
+Before handing the file over, the same content rules the INE applies after
+the schema are run over it, and a file that would be rejected raises an
+error naming the day and the figures behind it instead of being
+downloaded.
+
+The file is then uploaded to the IRIA portal of the INE. Note that IRIA no
+longer allows editing a questionnaire once it has been uploaded.
+
+Known issues / Roadmap
+======================
+
+* Only the hotel establishments (EOH) and tourist apartments (EOAP)
+ occupancy surveys accept an XML questionnaire. The rural tourism,
+ campings and hostels surveys have no XML channel and are answered
+ through the INE portal.
+* The questionnaire is built here but still uploaded by hand to the IRIA
+ portal. The INE offers a web service to submit it, which needs no prior
+ registration, and it is not implemented yet.
+* ``DIAS_ABIERTO_MES_REFERENCIA`` is reported as the days of the month.
+ A seasonal establishment open for part of it declares more days than it
+ opened, which dilutes its own occupancy rate.
+
Bug Tracker
===========
@@ -67,6 +152,7 @@ Contributors
* Sara Lago
* Brais Abeijon
* Miguel Padin
+ * David Pacheco
Maintainers
~~~~~~~~~~~
diff --git a/pms_l10n_es/__manifest__.py b/pms_l10n_es/__manifest__.py
index b4dcea8497..e36657bad8 100644
--- a/pms_l10n_es/__manifest__.py
+++ b/pms_l10n_es/__manifest__.py
@@ -3,7 +3,7 @@
{
"name": "PMS Spanish Adaptation",
- "version": "16.0.2.4.0",
+ "version": "16.0.2.5.0",
"author": "Commit [Sun], Odoo Community Association (OCA)",
"license": "AGPL-3",
"application": True,
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..64fe95521e 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,59 @@
-"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_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"
+"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..07f04c1fee 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","VIZCAKAIA"
+base.state_es_za,"ES419","ZAMORA"
+base.state_es_z,"ES243","ZARAGOZA"
diff --git a/pms_l10n_es/models/__init__.py b/pms_l10n_es/models/__init__.py
index 362d71e87e..03b53f6a62 100644
--- a/pms_l10n_es/models/__init__.py
+++ b/pms_l10n_es/models/__init__.py
@@ -1,4 +1,5 @@
from . import pms_property
+from . import res_country
from . import res_country_state
from . import pms_ine_tourism_type_category
from . import pms_room
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..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):
@@ -48,8 +53,153 @@ 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 "
+ "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",
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/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.py b/pms_l10n_es/models/res_country.py
new file mode 100644
index 0000000000..881304f6bd
--- /dev/null
+++ b/pms_l10n_es/models/res_country.py
@@ -0,0 +1,36 @@
+from odoo import fields, models
+
+# The INE keeps its own country list for the occupancy surveys, published
+# next to the survey schemas. It follows ISO 3166-1 alpha-3 except where ISO
+# has no code at all: Kosovo is KOS for the INE and has no alpha-3 in Odoo,
+# so its ID_PAIS would come out empty and the file would be rejected by the
+# schema. Every other country in Odoo matches the INE list one to one, Spain
+# aside, which travels in ID_PROVINCIA_ISLA and is not in their list.
+#
+# This lives in the code rather than in a data file because the countries of
+# the base module carry noupdate, so a CSV row over them is silently ignored.
+# The ine_country_code field is left for whatever the INE adds later without
+# waiting for a release.
+INE_COUNTRY_CODES = {
+ "XK": "KOS",
+}
+
+
+class ResCountry(models.Model):
+ _inherit = "res.country"
+
+ ine_country_code = fields.Char(
+ string="INE Country Code",
+ help="Code expected in the ID_PAIS element of the INE occupancy "
+ "survey XML files, for the countries the INE does not code as ISO "
+ "3166-1 alpha-3 does. Leave it empty to use the alpha-3 code.",
+ )
+
+ def ine_get_country_code(self):
+ """Return the code the INE expects for the country of residence."""
+ self.ensure_one()
+ return (
+ self.ine_country_code
+ or INE_COUNTRY_CODES.get(self.code)
+ or self.code_alpha3
+ )
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/readme/CONFIGURE.rst b/pms_l10n_es/readme/CONFIGURE.rst
new file mode 100644
index 0000000000..c44de5260e
--- /dev/null
+++ b/pms_l10n_es/readme/CONFIGURE.rst
@@ -0,0 +1,43 @@
+INE occupancy surveys
+~~~~~~~~~~~~~~~~~~~~~
+
+The INE runs a monthly occupancy survey per kind of establishment. Two of
+them accept an XML questionnaire and are the ones this module builds, the
+hotel establishments survey (EOH) and the tourist apartments one (EOAP).
+Which of the two is built comes from the INE category of the property, so
+there is nothing else to choose.
+
+On the property, under the INE settings:
+
+#. **Tourism number**: the registration number in the tourism registry.
+#. **Category**: the type and category the establishment is listed under in
+ the INE directory. Its survey type decides the questionnaire.
+#. **Beds available excluding extra beds**: the places of the directory,
+ counting fixed beds only.
+#. **Staff**: permanent, temporary and unpaid.
+#. **Order number**: eleven characters, printed on the questionnaire. It is
+ fixed for the establishment, so it is kept here once filled in. The
+ control code is single use and is never stored.
+#. **Informant**: name, job position, phone and email. Only the tourist
+ apartments survey carries them, where they are mandatory.
+
+The property lists what is still missing in *INE configuration warnings*,
+so the whole configuration can be completed before trying to build a file.
+
+Rooms reported to the survey are the ones flagged *In INE*. For the tourist
+apartments survey each of them also carries a typology (studio, 2-4 pax,
+4-6 pax or other); left empty, it is inferred from the capacity of the room.
+
+Provinces and countries are taken from the lists the INE publishes next to
+the survey schemas, which do not always match the ones of Odoo:
+
+* ``res.country.state`` carries the province literal of the specification,
+ limited to 25 characters, seeded for the 52 Spanish provinces.
+* ``res.country`` carries a code of its own for the countries the INE does
+ not code as ISO 3166-1 alpha-3 does.
+
+Files are built after the schema the INE publishes, which declares no
+namespace. A questionnaire asking for the namespace-qualified variant
+instead is served by setting the namespace in the system parameters
+``pms_l10n_es.ine_xml_namespace_hotel`` or
+``pms_l10n_es.ine_xml_namespace_apartments``.
diff --git a/pms_l10n_es/readme/CONTRIBUTORS.rst b/pms_l10n_es/readme/CONTRIBUTORS.rst
index 9675a68b18..2ef2129761 100644
--- a/pms_l10n_es/readme/CONTRIBUTORS.rst
+++ b/pms_l10n_es/readme/CONTRIBUTORS.rst
@@ -5,3 +5,4 @@
* Sara Lago
* Brais Abeijon
* Miguel Padin
+ * David Pacheco
diff --git a/pms_l10n_es/readme/ROADMAP.rst b/pms_l10n_es/readme/ROADMAP.rst
new file mode 100644
index 0000000000..d844cfcdc3
--- /dev/null
+++ b/pms_l10n_es/readme/ROADMAP.rst
@@ -0,0 +1,10 @@
+* Only the hotel establishments (EOH) and tourist apartments (EOAP)
+ occupancy surveys accept an XML questionnaire. The rural tourism,
+ campings and hostels surveys have no XML channel and are answered
+ through the INE portal.
+* The questionnaire is built here but still uploaded by hand to the IRIA
+ portal. The INE offers a web service to submit it, which needs no prior
+ registration, and it is not implemented yet.
+* ``DIAS_ABIERTO_MES_REFERENCIA`` is reported as the days of the month.
+ A seasonal establishment open for part of it declares more days than it
+ opened, which dilutes its own occupancy rate.
diff --git a/pms_l10n_es/readme/USAGE.rst b/pms_l10n_es/readme/USAGE.rst
new file mode 100644
index 0000000000..3ef234efb1
--- /dev/null
+++ b/pms_l10n_es/readme/USAGE.rst
@@ -0,0 +1,20 @@
+INE occupancy surveys
+~~~~~~~~~~~~~~~~~~~~~
+
+Go to *Reservations > Generate INE file*, pick the property and a date in
+the month to report, and generate.
+
+The questionnaire the establishment receives asks for a single week
+(hotels) or fortnight (apartments), but the file always covers the whole
+natural month, so the period is expanded to the month of the start date.
+A file built for part of a month is rejected: the INE checks the daily
+chain of every place of residence, and the first day of a partial file
+never matches it.
+
+Before handing the file over, the same content rules the INE applies after
+the schema are run over it, and a file that would be rejected raises an
+error naming the day and the figures behind it instead of being
+downloaded.
+
+The file is then uploaded to the IRIA portal of the INE. Note that IRIA no
+longer allows editing a questionnaire once it has been uploaded.
diff --git a/pms_l10n_es/static/description/index.html b/pms_l10n_es/static/description/index.html
index 94cdba0cda..4918751df1 100644
--- a/pms_l10n_es/static/description/index.html
+++ b/pms_l10n_es/static/description/index.html
@@ -379,17 +379,104 @@
PMS Spanish Adaptation
Table of contents
+
+
+
+
+
The INE runs a monthly occupancy survey per kind of establishment. Two of
+them accept an XML questionnaire and are the ones this module builds, the
+hotel establishments survey (EOH) and the tourist apartments one (EOAP).
+Which of the two is built comes from the INE category of the property, so
+there is nothing else to choose.
+
On the property, under the INE settings:
+
+- Tourism number: the registration number in the tourism registry.
+- Category: the type and category the establishment is listed under in
+the INE directory. Its survey type decides the questionnaire.
+- Beds available excluding extra beds: the places of the directory,
+counting fixed beds only.
+- Staff: permanent, temporary and unpaid.
+- Order number: eleven characters, printed on the questionnaire. It is
+fixed for the establishment, so it is kept here once filled in. The
+control code is single use and is never stored.
+- Informant: name, job position, phone and email. Only the tourist
+apartments survey carries them, where they are mandatory.
+
+
The property lists what is still missing in INE configuration warnings,
+so the whole configuration can be completed before trying to build a file.
+
Rooms reported to the survey are the ones flagged In INE. For the tourist
+apartments survey each of them also carries a typology (studio, 2-4 pax,
+4-6 pax or other); left empty, it is inferred from the capacity of the room.
+
Provinces and countries are taken from the lists the INE publishes next to
+the survey schemas, which do not always match the ones of Odoo:
+
+- res.country.state carries the province literal of the specification,
+limited to 25 characters, seeded for the 52 Spanish provinces.
+- res.country carries a code of its own for the countries the INE does
+not code as ISO 3166-1 alpha-3 does.
+
+
Files are built after the schema the INE publishes, which declares no
+namespace. A questionnaire asking for the namespace-qualified variant
+instead is served by setting the namespace in the system parameters
+pms_l10n_es.ine_xml_namespace_hotel or
+pms_l10n_es.ine_xml_namespace_apartments.
+
+
+
+
+
+
+
Go to Reservations > Generate INE file, pick the property and a date in
+the month to report, and generate.
+
The questionnaire the establishment receives asks for a single week
+(hotels) or fortnight (apartments), but the file always covers the whole
+natural month, so the period is expanded to the month of the start date.
+A file built for part of a month is rejected: the INE checks the daily
+chain of every place of residence, and the first day of a partial file
+never matches it.
+
Before handing the file over, the same content rules the INE applies after
+the schema are run over it, and a file that would be rejected raises an
+error naming the day and the figures behind it instead of being
+downloaded.
+
The file is then uploaded to the IRIA portal of the INE. Note that IRIA no
+longer allows editing a questionnaire once it has been uploaded.
+
+
+
+
+
+- Only the hotel establishments (EOH) and tourist apartments (EOAP)
+occupancy surveys accept an XML questionnaire. The rural tourism,
+campings and hostels surveys have no XML channel and are answered
+through the INE portal.
+- The questionnaire is built here but still uploaded by hand to the IRIA
+portal. The INE offers a web service to submit it, which needs no prior
+registration, and it is not implemented yet.
+- DIAS_ABIERTO_MES_REFERENCIA is reported as the days of the month.
+A seasonal establishment open for part of it declares more days than it
+opened, which dilutes its own occupancy rate.
-
+
Bugs are tracked on GitHub Issues.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
@@ -397,15 +484,15 @@
Do not contact contributors directly about support or help with technical issues.
-
+
-
+
- Jose Luis Algara
- Commit [Sun] <https://www.commitsun.com>:
@@ -413,12 +500,13 @@
- Sara Lago
- Brais Abeijon
- Miguel Padin
+- David Pacheco
-
+
This module is maintained by the OCA.
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/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..e664edcd41 100644
--- a/pms_l10n_es/tests/test_wizard_ine.py
+++ b/pms_l10n_es/tests/test_wizard_ine.py
@@ -1,9 +1,15 @@
+import base64
import datetime
+import xml.etree.ElementTree as ET
+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 ..wizards.ine_content_checks import check_survey_content
from .common import TestPms
@@ -1046,3 +1052,561 @@ 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_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.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):
+ """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
+ prices_tag = document.find("PRECIOS")
+ 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
+ prices_tag = document.find("PRECIOS")
+ for element in prices_tag:
+ self.assertRegex(element.text, r"^\d+\.\d{2}$")
+
+ 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.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):
+ """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()
+
+ 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, 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_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
+ 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="A broken daily chain must be reported"
+ ):
+ wizard._ine_check_xml_content(survey_tag)
+
+ def test_content_rules_run_without_a_record(self):
+ """The rules only need the built file, so they read on their own."""
+ # ARRANGE
+ 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
+ problems = check_survey_content(survey_tag, 28)
+ # ASSERT: the message has to name the day, which is what the
+ # establishment needs to find the guest behind it
+ self.assertEqual(len(problems), 1)
+ self.assertIn("ES300", problems[0])
+ self.assertIn("2", problems[0])
+
+ def test_phone_keeps_the_thirteen_characters_of_the_schema(self):
+ """The survey schemas take up to 13 characters in TELEFONO_1."""
+ # ARRANGE
+ self.ideal_scenario()
+ self._configure_ine_property()
+ self.pms_property1.phone = "+34 971 30 41 24 9"
+ # ACT
+ document = self._generate_ine_document()
+ # ASSERT
+ self.assertEqual(document.findtext("CABECERA/TELEFONO_1"), "+349713041249")
+
+ 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_country_code_falls_back_to_the_alpha_3(self):
+ """Every country the INE lists is reached through its alpha-3."""
+ # ACT & ASSERT
+ self.assertEqual(
+ self.env["pms.ine.wizard"]._ine_get_country_code(self.country_italy),
+ "ITA",
+ )
+
+ def test_country_code_of_the_ine_wins_over_the_alpha_3(self):
+ """Kosovo has no alpha-3 in ISO, and the INE codes it as KOS."""
+ # ARRANGE
+ kosovo = self.env.ref("base.xk")
+ # ACT & ASSERT
+ self.assertFalse(kosovo.code_alpha3)
+ self.assertEqual(
+ self.env["pms.ine.wizard"]._ine_get_country_code(kosovo), "KOS"
+ )
+
+ def test_country_without_code_is_reported(self):
+ """A country with no code would empty ID_PAIS and fail the schema."""
+ # ARRANGE
+ country = self.env["res.country"].create(
+ {"name": "Country without codes", "code": "ZZ"}
+ )
+ # ACT & ASSERT
+ with self.assertRaises(
+ ValidationError, msg="A country with no INE code must be reported"
+ ):
+ self.env["pms.ine.wizard"]._ine_get_country_code(country)
+
+ 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",
+ )
+
+ 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_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..2488db3fb3 100644
--- a/pms_l10n_es/views/pms_property_views.xml
+++ b/pms_l10n_es/views/pms_property_views.xml
@@ -48,6 +48,7 @@
+
@@ -58,8 +59,26 @@
+
+
+
+
+
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/ine_content_checks.py b/pms_l10n_es/wizards/ine_content_checks.py
new file mode 100644
index 0000000000..91195193e8
--- /dev/null
+++ b/pms_l10n_es/wizards/ine_content_checks.py
@@ -0,0 +1,254 @@
+"""Content validations the INE applies to the occupancy survey files.
+
+The INE validates an uploaded questionnaire twice: first against the XML
+schema, and then against a list of content rules published in the survey
+web service specification, identified there as XML_0 to XML_17. A file
+that passes the schema and fails a content rule is rejected all the same,
+and the establishment only finds out on the IRIA portal, days later, with
+a message that names neither the guest nor the setting behind it.
+
+Running the same rules here turns that into an error raised while the file
+is being built, naming the day and the figures involved.
+
+The functions take the built XML and plain numbers, never a record, so the
+rules can be read and tested on their own. They return a list of sentences
+ready to show, empty when the file would be accepted.
+"""
+from collections import defaultdict
+
+from odoo import _
+
+# Tags shared by both surveys: the block of guest movements is identical in
+# the hotel and the tourist apartments questionnaires.
+ACCOMMODATION_XPATH = "ALOJAMIENTO/RESIDENCIA"
+HOTEL_ROOT_TAG = "ENCUESTA"
+
+
+def _read_movements(residency_tag):
+ """Return {day: (arrivals, departures, overnight stays)} of a residence."""
+ 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 _residence_name(residency_tag):
+ """Country or province the residence block stands for."""
+ return residency_tag.findtext("ID_PAIS") or residency_tag.findtext(
+ "ID_PROVINCIA_ISLA"
+ )
+
+
+def check_guest_movements(survey_tag, days_in_month):
+ """Validate the ALOJAMIENTO block, shared by every occupancy survey.
+
+ Covers the day as a key of the residence (XML_3), non negative figures
+ (XML_6), overnight stays against arrivals (XML_7) and the daily chain
+ of each residence (XML_8), which is the rule that rejects a file built
+ for a week instead of the whole month.
+
+ Returns the problems found, the overnight stays per day and the days
+ carrying movements, the last two being what the room checks need.
+ """
+ problems = []
+ overnight_stays = defaultdict(int)
+ days_with_movement = set()
+ for residency_tag in survey_tag.findall(ACCOMMODATION_XPATH):
+ residence = _residence_name(residency_tag)
+ 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 = _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 check_hotel_rooms(survey_tag, overnight_stays):
+ """Validate the HABITACIONES block against the guest movements.
+
+ Covers occupied rooms and overnight stays agreeing with each other
+ (XML_9, XML_10, XML_11), the overnight stays fitting in the declared
+ seats plus the extra beds (XML_12) and the occupied rooms fitting in
+ the rooms of the establishment (XML_13).
+ """
+ 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 check_survey_content(survey_tag, days_in_month):
+ """Return every content rule the built file would be rejected for.
+
+ Empty when the INE would accept it.
+ """
+ problems, overnight_stays, days_with_movement = check_guest_movements(
+ survey_tag, days_in_month
+ )
+ days_open = int(survey_tag.findtext("CABECERA/DIAS_ABIERTO_MES_REFERENCIA") or 0)
+ # XML_0: the establishment cannot be open more days than the month has.
+ 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,
+ )
+ )
+ # XML_2: no more days carrying guests than days open.
+ 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 == HOTEL_ROOT_TAG:
+ problems += check_hotel_rooms(survey_tag, overnight_stays)
+ return problems
+
+
+def extra_bed_nights(survey_tag):
+ """Days reported with an extra bed, as they appear in the file.
+
+ 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 back instead of passing unnoticed.
+ """
+ if survey_tag.tag == HOTEL_ROOT_TAG:
+ 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"
+ return [
+ movement_tag.findtext(day_tag)
+ for movement_tag in movement_tags
+ if int(movement_tag.findtext("PLAZAS_SUPLETORIAS") or 0)
+ ]
diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py
index 7b7d3c0862..d2d8517346 100644
--- a/pms_l10n_es/wizards/wizard_ine.py
+++ b/pms_l10n_es/wizards/wizard_ine.py
@@ -1,3 +1,28 @@
+"""INE occupancy surveys: build the questionnaire the establishment uploads.
+
+The INE runs one occupancy survey per kind of establishment. Two of them
+accept an XML questionnaire and are the ones built here, picked from the
+INE category of the property:
+
+- EOH, hotel establishments, root element ENCUESTA.
+- EOAP, tourist apartments, root element APARTAMENTOS.
+
+Both are monthly and cover the natural month, even though the
+questionnaire the establishment receives asks for a single week (hotels)
+or fortnight (apartments).
+
+The INE replaced the ARCE platform with IRIA, where the file can no longer
+be edited after uploading, so whatever is wrong in it comes back as a
+rejection. Two schema variants coexist: the one the INE publishes, which
+declares no namespace and is the reference, and the namespace-qualified
+one the IRIA application generates for a questionnaire. Both have been
+accepted on upload; see INE_XML_NAMESPACE_PARAMS.
+
+Building a file goes through three steps, in this order: the property is
+asked whether it has everything the survey needs (pms.property), the XML
+is built, and the content rules the INE applies after the schema are run
+over it (ine_content_checks).
+"""
import base64
import calendar
import datetime
@@ -7,9 +32,42 @@
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
+from .ine_content_checks import check_survey_content, extra_bed_nights
+
# TODO: Review code (code iso ?)
CODE_SPAIN = "ES"
+# 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", ""),
+ "apartments": ("pms_l10n_es.ine_xml_namespace_apartments", ""),
+}
+
+# Both survey schemas limit TELEFONO_1 to 13 characters.
+INE_PHONE_MAX_LENGTH = 13
+
+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 +96,120 @@ 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",
+ 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).
+
+ 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.
+ """
+ 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 +292,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 = (
@@ -459,78 +606,167 @@ 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."))
+ 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.
+
+ 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.
+ """
+ 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
- if not pms_property_id.company_id.name:
- raise ValidationError(_("The company name is not established."))
+ def _ine_get_root_attrs(self, survey_type):
+ """Return the root element attributes for the survey.
- if not pms_property_id.name:
- raise ValidationError(_("The property name is not established."))
+ 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]
+ namespace = self.env["ir.config_parameter"].sudo().get_param(param, default)
+ return {"xmlns": namespace} if namespace else {}
- if not pms_property_id.ine_tourism_number:
- raise ValidationError(_("The property tourism number is not established."))
+ @api.model
+ def _ine_format_decimal(self, value):
+ """Format decimal values with exactly 2 decimals.
- if not pms_property_id.ine_tourism_number:
- raise ValidationError(_("The property tourism number is not established."))
+ The INE schemas limit decimal fields to 2 fraction digits: emitting
+ raw float representations has historically produced rejected files.
+ """
+ return "%.2f" % (value or 0)
- if not pms_property_id.street:
- raise ValidationError(_("The property street is not established."))
+ def _ine_get_province_name(self):
+ state = self.pms_property_id.partner_id.state_id
+ return state.ine_tourism_province_name or state.name
- if not pms_property_id.zip:
- raise ValidationError(_("The property zip is not established."))
+ def _ine_survey_type(self):
+ return self.pms_property_id.ine_category_id.survey_type or "hotel"
- if not pms_property_id.city:
- raise ValidationError(_("The property city is not established."))
+ @api.model
+ def _ine_get_country_code(self, country):
+ """Code of the country of residence, as the INE expects it.
- if not pms_property_id.partner_id.state_id:
- raise ValidationError(_("The property state is not established."))
+ The INE keeps its own country list, which follows ISO 3166-1
+ alpha-3 except where ISO has no code: Kosovo is KOS for them and
+ has no alpha-3 at all in Odoo. Those exceptions live in
+ res.country.ine_country_code.
- if not pms_property_id.phone:
- raise ValidationError(_("The property phone is not established."))
+ A country with no code of its own would silently produce an empty
+ ID_PAIS and a file rejected by the schema, with nothing pointing at
+ the guest who caused it, so it is reported here instead.
+ """
+ code = country.ine_get_country_code()
+ if not code:
+ raise ValidationError(
+ _(
+ "The country of residence '%(country)s' has no code for "
+ "the INE survey. Set the INE country code on the "
+ "country, using the list published by the INE.",
+ country=country.name,
+ )
+ )
+ return code
- if not pms_property_id.ine_category_id:
- raise ValidationError(_("The property category is not established."))
+ def _ine_format_phone(self, phone):
+ """Phone as the survey schemas take it: no spaces, 13 characters."""
+ return (phone or "").replace(" ", "")[:INE_PHONE_MAX_LENGTH]
- def ine_generate_xml(self):
- self.check_ine_mandatory_fields(self.pms_property_id)
-
- number_of_rooms = sum(
- self.env["pms.room"]
- .search(
- [
- ("in_ine", "=", True),
- ("pms_property_id", "=", self.pms_property_id.id),
- ]
- )
- .mapped("capacity")
- )
+ def _ine_check_xml_content(self, survey_tag):
+ """Refuse to hand over a file the INE would reject.
- if number_of_rooms > self.pms_property_id.ine_seats:
+ The rules themselves live in ine_content_checks, away from the
+ wizard, because they only need the built XML.
+ """
+ days_in_month = calendar.monthrange(
+ self.start_date.year, self.start_date.month
+ )[1]
+ problems = check_survey_content(survey_tag, days_in_month)
+ if problems:
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),
+ "The INE would reject this file. Fix the following and "
+ "generate it again:\n\n- %s",
+ "\n- ".join(problems),
)
)
+ def _ine_extra_bed_notes(self, survey_tag):
+ """Tell back the nights reported with an extra bed."""
+ days = extra_bed_nights(survey_tag)
+ 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_generate_xml(self):
+ self.check_ine_mandatory_fields(self.pms_property_id)
+ self._ine_normalize_period()
+ self._store_ine_order_number()
+
+ if self._ine_survey_type() == "apartments":
+ survey_tag = self._ine_build_xml_apartments()
+ else:
+ 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")
+
+ self.txt_binary = base64.b64encode(xmlstr.encode("utf-8"))
+ self.txt_filename = "INE_%02d_%d.xml" % (
+ self.start_date.month,
+ self.start_date.year,
+ )
+
+ 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", self._ine_get_root_attrs("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,12 +785,10 @@ 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, "TELEFONO_1"
- ).text = self.pms_property_id.phone.replace(" ", "")[0:12]
+ ET.SubElement(header_tag, "PROVINCIA").text = self._ine_get_province_name()
+ ET.SubElement(header_tag, "TELEFONO_1").text = self._ine_format_phone(
+ self.pms_property_id.phone
+ )
ET.SubElement(
header_tag, "TIPO"
).text = self.pms_property_id.ine_category_id.type
@@ -573,70 +807,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 +834,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 +914,68 @@ 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.
+ # 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)) if adrs[group] else 0
+ for group, value in percents.items()
+ }
+ 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:
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 +987,291 @@ 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", self._ine_get_root_attrs("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 = self._ine_format_phone(
+ pms_property.phone
+ )
+ 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 = self._ine_format_phone(
+ pms_property.ine_informant_phone or pms_property.phone
+ )
+ 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).
+
+ 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
+ )
+ 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 = self._ine_get_country_code(country)
+ 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
+ )
+
+ if not len(accommodation_tag):
+ raise ValidationError(
+ _(
+ "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. 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,
+ )
+ )
diff --git a/pms_l10n_es/wizards/wizard_ine.xml b/pms_l10n_es/wizards/wizard_ine.xml
index 120f3ca139..7b0d4e2ee9 100644
--- a/pms_l10n_es/wizards/wizard_ine.xml
+++ b/pms_l10n_es/wizards/wizard_ine.xml
@@ -9,6 +9,7 @@
+
@@ -54,6 +55,19 @@
+
+