diff --git a/pms_l10n_es/__manifest__.py b/pms_l10n_es/__manifest__.py index 97342139bf..b4dcea8497 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.3.0", + "version": "16.0.2.4.0", "author": "Commit [Sun], Odoo Community Association (OCA)", "license": "AGPL-3", "application": True, diff --git a/pms_l10n_es/models/__init__.py b/pms_l10n_es/models/__init__.py index a9c8706c2d..362d71e87e 100644 --- a/pms_l10n_es/models/__init__.py +++ b/pms_l10n_es/models/__init__.py @@ -6,5 +6,6 @@ from . import pms_checkin_partner from . import res_partner_id_number from . import pms_ses_communication +from . import pms_folio from . import pms_reservation from . import pms_tourism_classification diff --git a/pms_l10n_es/models/pms_checkin_partner.py b/pms_l10n_es/models/pms_checkin_partner.py index ea288f4124..df3d9ba366 100644 --- a/pms_l10n_es/models/pms_checkin_partner.py +++ b/pms_l10n_es/models/pms_checkin_partner.py @@ -9,6 +9,7 @@ CODE_SPAIN = "ES" CODE_NIF = "D" CODE_NIE = "N" +AGE_OF_MAJORITY = 18 _logger = logging.getLogger(__name__) @@ -66,6 +67,17 @@ def _compute_partner_document_data(self): record.support_number = last_update_document[0].support_number return res + def _is_minor(self): + """Whether the guest is under the age of majority. + + A guest with no birthdate yet is not considered a minor: their age is + unknown, not known to be under age. + """ + self.ensure_one() + return bool(self.birthdate_date) and self.birthdate_date > ( + fields.Date.today() - relativedelta(years=AGE_OF_MAJORITY) + ) + def _checkin_mandatory_fields(self): self.ensure_one() mandatory_fields = super()._checkin_mandatory_fields() @@ -81,25 +93,27 @@ def _checkin_mandatory_fields(self): ] ) - if self.birthdate_date: - # Checkins with age greater than 14 must have an identity document - if self.birthdate_date <= fields.Date.today() - relativedelta(years=14): - mandatory_fields.extend( - [ - "document_number", - "document_type", - "document_country_id", - ] - ) - # Checkins with age lower than 18 must have a relationship - # with another checkin partner - if self.birthdate_date > fields.Date.today() - relativedelta(years=18): - mandatory_fields.extend( - [ - "ses_partners_relationship", - "ses_related_checkin_partner_id", - ] - ) + # Checkins with age greater than 14 must have an identity document + if self.birthdate_date and self.birthdate_date <= ( + fields.Date.today() - relativedelta(years=14) + ): + mandatory_fields.extend( + [ + "document_number", + "document_type", + "document_country_id", + ] + ) + + # Minors must have a relationship with another checkin partner, unless + # the folio declares that they travel unaccompanied + if self._is_minor() and not self.folio_id.ses_unaccompanied_minors: + mandatory_fields.extend( + [ + "ses_partners_relationship", + "ses_related_checkin_partner_id", + ] + ) if self.country_id and self.country_id.code == CODE_SPAIN: mandatory_fields.extend( @@ -137,6 +151,10 @@ def _checkin_manual_fields(self, country=False): ) return manual_fields + def action_on_board(self): + self.folio_id._check_ses_unaccompanied_minors() + return super().action_on_board() + def get_document_vals(self): vals = super().get_document_vals() vals["support_number"] = self.support_number diff --git a/pms_l10n_es/models/pms_folio.py b/pms_l10n_es/models/pms_folio.py new file mode 100644 index 0000000000..77745f5848 --- /dev/null +++ b/pms_l10n_es/models/pms_folio.py @@ -0,0 +1,87 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PmsFolio(models.Model): + _inherit = "pms.folio" + + ses_all_guests_minors = fields.Boolean( + string="All guests are minors", + help="Every guest of the folio whose birthdate is known is under the " + "age of majority.", + compute="_compute_ses_all_guests_minors", + ) + ses_unaccompanied_minors = fields.Boolean( + string="Unaccompanied minors", + help="The minors travel on their own with the authorization of their " + "legal guardian. No relationship with an accompanying guest is " + "required on their checkin data.", + tracking=True, + ) + ses_minors_authorization = fields.Binary( + string="Guardian authorization", + help="Authorization signed by the legal guardian of the minors.", + ) + ses_minors_authorization_filename = fields.Char( + string="Guardian authorization filename", + ) + + def _ses_staying_checkin_partners(self): + """Guests of the folio that are actually staying. + + The guests are taken from the whole folio and not from a single + reservation, because the party is not split by room: the guardians may + be booked in one reservation and the minors in another one. Cancelled + and out of service reservations are left out, since their guests are not + part of the party the declaration talks about. + """ + self.ensure_one() + return self.checkin_partner_ids.filtered( + lambda checkin_partner: checkin_partner.reservation_id.state != "cancel" + and checkin_partner.reservation_id.reservation_type != "out" + ) + + @api.depends( + "checkin_partner_ids.birthdate_date", + "reservation_ids.state", + "reservation_ids.reservation_type", + ) + def _compute_ses_all_guests_minors(self): + for record in self: + # Guests with no birthdate yet are left out instead of blocking: + # otherwise an unfilled guest slot anywhere in the folio would hide + # the declaration, and the checkin data gets filled in any order. + guests_with_birthdate = record._ses_staying_checkin_partners().filtered( + "birthdate_date" + ) + record.ses_all_guests_minors = bool(guests_with_birthdate) and all( + checkin_partner._is_minor() for checkin_partner in guests_with_birthdate + ) + + def _check_ses_unaccompanied_minors(self): + """Refuse an unaccompanied minors declaration that the guests contradict. + + Every guest of the folio is taken into account, no matter whether they + already boarded, so the check does not depend on the order in which the + guests are checked in. A guest with no birthdate yet is not known to be + of age, so incomplete checkin data never raises here. + """ + for record in self: + if not record.ses_unaccompanied_minors: + continue + guests_of_age = record._ses_staying_checkin_partners().filtered( + lambda checkin_partner: checkin_partner.birthdate_date + and not checkin_partner._is_minor() + ) + if guests_of_age: + raise ValidationError( + _( + "%(guests)s is of age, so the unaccompanied minors " + "declaration of folio %(folio)s does not hold. Uncheck " + "the declaration or review the birthdates." + ) + % { + "guests": ", ".join(guests_of_age.mapped("name")), + "folio": record.name, + } + ) diff --git a/pms_l10n_es/models/pms_reservation.py b/pms_l10n_es/models/pms_reservation.py index b47176773d..a2ecd98660 100644 --- a/pms_l10n_es/models/pms_reservation.py +++ b/pms_l10n_es/models/pms_reservation.py @@ -46,6 +46,25 @@ class PmsReservation(models.Model): ], compute="_compute_ses_status_traveller_report", ) + # The unaccompanied minors declaration belongs to the folio, because the + # party is not split by room: the guardians may be booked in one reservation + # and the minors in another one. It is exposed here because the reservation + # is where it gets managed. + ses_all_guests_minors = fields.Boolean( + related="folio_id.ses_all_guests_minors", + ) + ses_unaccompanied_minors = fields.Boolean( + related="folio_id.ses_unaccompanied_minors", + readonly=False, + ) + ses_minors_authorization = fields.Binary( + related="folio_id.ses_minors_authorization", + readonly=False, + ) + ses_minors_authorization_filename = fields.Char( + related="folio_id.ses_minors_authorization_filename", + readonly=False, + ) @api.depends("pms_property_id", "preferred_room_id") def _compute_is_ses(self): diff --git a/pms_l10n_es/tests/__init__.py b/pms_l10n_es/tests/__init__.py index c2621d8492..bc8bf088bb 100644 --- a/pms_l10n_es/tests/__init__.py +++ b/pms_l10n_es/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_wizard_ine from . import test_res_partner from . import test_pms_ses_communication +from . import test_pms_checkin_partner diff --git a/pms_l10n_es/tests/test_pms_checkin_partner.py b/pms_l10n_es/tests/test_pms_checkin_partner.py new file mode 100644 index 0000000000..d9cca5f95e --- /dev/null +++ b/pms_l10n_es/tests/test_pms_checkin_partner.py @@ -0,0 +1,195 @@ +from dateutil.relativedelta import relativedelta + +from odoo import fields +from odoo.exceptions import ValidationError + +from .common import TestPms + + +class TestPmsCheckinPartnerUnaccompaniedMinors(TestPms): + """The unaccompanied minors declaration is scoped to the folio. + + The party is not split by room: the guardians may be booked in one + reservation and the minors in another one of the same folio. + """ + + def setUp(self): + super().setUp() + self.sale_channel_direct1 = self.env["pms.sale.channel"].create( + { + "name": "Door", + "channel_type": "direct", + } + ) + self.room_type = self.env["pms.room.type"].create( + { + "name": "Room type test", + "default_code": "DBL_Test", + "class_id": self.room_type_class1.id, + } + ) + self.room_1 = self.env["pms.room"].create( + { + "pms_property_id": self.pms_property1.id, + "name": "Room test 1", + "room_type_id": self.room_type.id, + "capacity": 2, + } + ) + self.room_2 = self.env["pms.room"].create( + { + "pms_property_id": self.pms_property1.id, + "name": "Room test 2", + "room_type_id": self.room_type.id, + "capacity": 2, + } + ) + self.today = fields.Date.today() + + def _birthdate_for_age(self, years): + return self.today - relativedelta(years=years) + + def _create_reservation(self, room, folio=None): + vals = { + "pms_property_id": self.pms_property1.id, + "room_type_id": self.room_type.id, + "preferred_room_id": room.id, + "checkin": self.today, + "checkout": self.today + relativedelta(days=2), + "adults": 1, + "sale_channel_origin_id": self.sale_channel_direct1.id, + "partner_name": "Test reservation", + } + if folio: + vals["folio_id"] = folio.id + return self.env["pms.reservation"].create(vals) + + def _create_folio_with_two_rooms(self, first_age, second_age): + """Two reservations in the same folio, one guest each.""" + first_reservation = self._create_reservation(self.room_1) + second_reservation = self._create_reservation( + self.room_2, folio=first_reservation.folio_id + ) + first_guest = first_reservation.checkin_partner_ids[0] + second_guest = second_reservation.checkin_partner_ids[0] + first_guest.birthdate_date = ( + self._birthdate_for_age(first_age) if first_age else False + ) + second_guest.birthdate_date = ( + self._birthdate_for_age(second_age) if second_age else False + ) + return first_reservation, second_reservation + + def test_all_guests_minors_when_every_guest_of_the_folio_is_under_age(self): + # ARRANGE / ACT + first_reservation, _second = self._create_folio_with_two_rooms(16, 14) + # ASSERT + self.assertTrue( + first_reservation.folio_id.ses_all_guests_minors, + "A folio whose every guest is under age should be flagged as " + "having only minors", + ) + + def test_not_all_guests_minors_when_the_guest_of_age_is_in_another_room(self): + # ARRANGE / ACT + # The guardians in one reservation and the minor in another one: the + # minor's own reservation holds nothing but a minor. + minors_reservation, _guardians = self._create_folio_with_two_rooms(16, 40) + # ASSERT + self.assertFalse( + minors_reservation.folio_id.ses_all_guests_minors, + "A guest of age in another reservation of the folio should count", + ) + + def test_all_guests_minors_ignores_the_guests_with_no_birthdate(self): + # ARRANGE / ACT + # An unfilled guest slot elsewhere in the folio must not hide the + # declaration: the checkin data gets filled in any order. + first_reservation, _second = self._create_folio_with_two_rooms(16, None) + # ASSERT + self.assertTrue( + first_reservation.folio_id.ses_all_guests_minors, + "Guests with no birthdate yet should not be taken into account", + ) + + def test_guest_of_age_in_a_cancelled_reservation_is_ignored(self): + # ARRANGE + minors_reservation, guardians_reservation = self._create_folio_with_two_rooms( + 16, 40 + ) + # ACT + guardians_reservation.action_cancel() + # ASSERT + self.assertTrue( + minors_reservation.folio_id.ses_all_guests_minors, + "Guests of a cancelled reservation are not part of the party", + ) + + def test_minor_requires_relationship_when_accompanied(self): + # ARRANGE + reservation = self._create_reservation(self.room_1) + checkin_partner = reservation.checkin_partner_ids[0] + checkin_partner.birthdate_date = self._birthdate_for_age(16) + # ACT + mandatory_fields = checkin_partner._checkin_mandatory_fields() + # ASSERT + self.assertIn( + "ses_partners_relationship", + mandatory_fields, + "A minor should require the relationship with another guest", + ) + self.assertIn( + "ses_related_checkin_partner_id", + mandatory_fields, + "A minor should require the guest they are related to", + ) + + def test_minor_does_not_require_relationship_when_unaccompanied(self): + # ARRANGE + reservation = self._create_reservation(self.room_1) + # Declared through the reservation on purpose: that is where it gets + # managed, even though it is stored on the folio. + reservation.ses_unaccompanied_minors = True + checkin_partner = reservation.checkin_partner_ids[0] + checkin_partner.birthdate_date = self._birthdate_for_age(16) + # ASSERT + self.assertTrue( + reservation.folio_id.ses_unaccompanied_minors, + "Declaring it on the reservation should store it on the folio", + ) + mandatory_fields = checkin_partner._checkin_mandatory_fields() + self.assertNotIn( + "ses_partners_relationship", + mandatory_fields, + "An unaccompanied minor should not require the relationship with " + "another guest", + ) + self.assertNotIn( + "ses_related_checkin_partner_id", + mandatory_fields, + "An unaccompanied minor should not require the guest they are " + "related to", + ) + + def test_unaccompanied_minors_blocked_by_a_guest_of_age_in_another_room(self): + # ARRANGE + # The minor boards first, and the guest of age is in another reservation + # of the folio: the inconsistency must be caught all the same. + minors_reservation, _guardians = self._create_folio_with_two_rooms(16, 40) + minors_reservation.folio_id.ses_unaccompanied_minors = True + minor = minors_reservation.checkin_partner_ids[0] + # ACT / ASSERT + # Matching the message on purpose: an incomplete checkin also raises + # ValidationError from the mandatory fields, so asserting the exception + # type alone would pass even without the consistency check. + with self.assertRaisesRegex(ValidationError, "unaccompanied minors"): + minor.action_on_board() + + def test_unaccompanied_minors_allowed_when_a_birthdate_is_missing(self): + # ARRANGE + # Nobody is known to be of age, so an incomplete folio must not be + # reported as inconsistent. + minors_reservation, _second = self._create_folio_with_two_rooms(16, None) + minors_reservation.folio_id.ses_unaccompanied_minors = True + # ACT / ASSERT + minors_reservation.folio_id._check_ses_unaccompanied_minors() diff --git a/pms_l10n_es/views/pms_reservation_views.xml b/pms_l10n_es/views/pms_reservation_views.xml index 654802ae46..bd507a136c 100644 --- a/pms_l10n_es/views/pms_reservation_views.xml +++ b/pms_l10n_es/views/pms_reservation_views.xml @@ -29,6 +29,24 @@ + + + + + + + + +