From 128ce1b36ba29ea61455a279218ee2fd02b298a7 Mon Sep 17 00:00:00 2001 From: jesusvmayor Date: Tue, 6 Oct 2026 15:32:47 +0200 Subject: [PATCH 1/4] [ADD] pms: children capacity in rooms Rooms declared how many people fit, with no way to say how many of those places are for children only, such as child bunk beds. Children were meant to be counted through the reservation children_occupying, but nothing kept that field in sync with children and it was left empty, so children did not count towards the room capacity at all. Rooms get children_capacity now. capacity keeps its meaning, the places where anyone can sleep, and children take those first, overflowing into the children places only once they are full. Adults never occupy them, so a room with free children places can still be full for adults. Whether children take a place at all is up to the property, through the pms.children_occupy_capacity system parameter, and off unless it says otherwise: counting them refuses reservations that properties have been taking all along, so it cannot be something an upgrade decides for them. children_occupying is dropped. A reservation could declare an occupying child without counting it in children, so the migration folds it into children before the column goes away. Assisted-by: Claude Opus 5 --- pms/README.rst | 16 +++ pms/__manifest__.py | 2 +- pms/demo/pms_master_data_no_update.xml | 5 + pms/migrations/16.0.4.25.0/pre-migration.py | 21 ++++ pms/models/pms_reservation.py | 14 +-- pms/models/pms_reservation_line.py | 26 ++-- pms/models/pms_room.py | 60 ++++++++-- pms/readme/CONFIGURE.rst | 16 +++ pms/static/description/index.html | 42 +++++-- pms/tests/test_pms_reservation.py | 125 +++++++++++++++++++- pms/views/pms_reservation_views.xml | 4 - pms/views/pms_room_views.xml | 2 + 12 files changed, 283 insertions(+), 50 deletions(-) create mode 100644 pms/migrations/16.0.4.25.0/pre-migration.py diff --git a/pms/README.rst b/pms/README.rst index e1d5d560ac..fa40b9e9b8 100644 --- a/pms/README.rst +++ b/pms/README.rst @@ -56,6 +56,22 @@ You will find the hotel settings in PMS Management > Configuration > Properties This module required additional configuration for company, accounting, invoicing and user privileges. +Children and room capacity +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default children take no place in the room, so only the adults are checked +against its capacity. Properties that want to count them can set the system +parameter ``pms.children_occupy_capacity`` to ``True``, in Settings > Technical +> System Parameters. + +Once it is on, children take the regular places of the room first and only +overflow into its children places, the ones where only a child can sleep, such +as child bunk beds. Adults never occupy those, so a room with free children +places can still be full. + +Mind that turning it on refuses reservations that the property may have been +taking until then. + Usage ===== diff --git a/pms/__manifest__.py b/pms/__manifest__.py index 8f3e7ce0ed..04e569dac9 100644 --- a/pms/__manifest__.py +++ b/pms/__manifest__.py @@ -4,7 +4,7 @@ { "name": "PMS (Property Management System)", "summary": "A property management system", - "version": "16.0.4.24.0", + "version": "16.0.4.25.0", "development_status": "Beta", "category": "Generic Modules/Property Management System", "website": "https://github.com/OCA/pms", diff --git a/pms/demo/pms_master_data_no_update.xml b/pms/demo/pms_master_data_no_update.xml index 8fe01cd73e..134cfd2b16 100644 --- a/pms/demo/pms_master_data_no_update.xml +++ b/pms/demo/pms_master_data_no_update.xml @@ -326,6 +326,7 @@ 1 + 1 @@ -333,6 +334,7 @@ 1 + 1 @@ -340,6 +342,7 @@ 1 + 1 @@ -347,6 +350,7 @@ 1 + 1 @@ -354,6 +358,7 @@ 1 + 1 diff --git a/pms/migrations/16.0.4.25.0/pre-migration.py b/pms/migrations/16.0.4.25.0/pre-migration.py new file mode 100644 index 0000000000..f90dc667b0 --- /dev/null +++ b/pms/migrations/16.0.4.25.0/pre-migration.py @@ -0,0 +1,21 @@ +# Copyright 2026 Commit [Sun] +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from openupgradelib import openupgrade + +# children_occupying held the children taking a place in the room, apart from +# children, which held them all. Nothing kept both in sync, so a reservation +# could declare an occupying child without counting it in children. Those +# children are folded into children before the column goes away, or they would +# be lost. +_FOLD_CHILDREN_OCCUPYING = """ + UPDATE pms_reservation + SET children = children_occupying + WHERE children_occupying > COALESCE(children, 0) +""" + + +@openupgrade.migrate() +def migrate(env, version): + if openupgrade.column_exists(env.cr, "pms_reservation", "children_occupying"): + openupgrade.logged_query(env.cr, _FOLD_CHILDREN_OCCUPYING) + openupgrade.drop_columns(env.cr, [("pms_reservation", "children_occupying")]) diff --git a/pms/models/pms_reservation.py b/pms/models/pms_reservation.py index 8a93d2df36..7158d55009 100644 --- a/pms/models/pms_reservation.py +++ b/pms/models/pms_reservation.py @@ -356,12 +356,9 @@ class PmsReservation(models.Model): compute="_compute_adults", tracking=True, ) - children_occupying = fields.Integer( - help="Number of children there in guest list whose presence counts", - ) children = fields.Integer( - help="Number total of children there in guest list," - "whose presence counts or not", + help="Number of children there in guest list. They take the regular " + "places of the room first, and only overflow into its children places", readonly=False, tracking=True, ) @@ -2215,9 +2212,10 @@ def write(self, vals): for record in self: record._check_services(vals) - # Only check if adult to avoid to check capacity in intermediate states - # (p.e. flush) that not take access to possible extra beds service in vals - if "adults" in vals: + # Only check on occupancy changes to avoid checking capacity in + # intermediate states (p.e. flush) that not take access to possible + # extra beds service in vals + if "adults" in vals or "children" in vals: record._check_capacity() # The room of the lines is recomputed from the preferred room, so # that write does not go through pms.reservation.line.write and the diff --git a/pms/models/pms_reservation_line.py b/pms/models/pms_reservation_line.py index faa7b44a02..5b6733ea9c 100644 --- a/pms/models/pms_reservation_line.py +++ b/pms/models/pms_reservation_line.py @@ -685,23 +685,31 @@ def _check_room_capacity(self): of the room (capacity + allowed extra beds) instead of the extra beds actually sold, to avoid false positives in intermediate states where the extra bed service is not created yet. + + Children count too: they take regular places first and only overflow + into the children places of the room. """ if self.env.context.get("avoid_capacity_check"): return for record in self: reservation = record.reservation_id - if not record.room_id or reservation.reservation_type == "out": + room = record.room_id + if not room or reservation.reservation_type == "out": continue - occupancy = reservation.adults + reservation.children_occupying - max_capacity = record.room_id.capacity + record.room_id.extra_beds_allowed - if occupancy > max_capacity: + if not room.fits_occupancy( + reservation.adults, reservation.children, room.extra_beds_allowed + ): raise ValidationError( _( - "The room %(room)s can't hold %(occupancy)s guests " - "(maximum capacity: %(capacity)s) (%(reservation)s)", - room=record.room_id.display_name, - occupancy=occupancy, - capacity=max_capacity, + "The room %(room)s can't hold %(adults)s adults and " + "%(children)s children (capacity: %(capacity)s, " + "children places: %(children_capacity)s) " + "(%(reservation)s)", + room=room.display_name, + adults=reservation.adults, + children=reservation.children, + capacity=room.capacity + room.extra_beds_allowed, + children_capacity=room.children_capacity, reservation=reservation.name, ) ) diff --git a/pms/models/pms_room.py b/pms/models/pms_room.py index d4502935a2..cb7ec0ed1e 100644 --- a/pms/models/pms_room.py +++ b/pms/models/pms_room.py @@ -4,6 +4,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.tools import str2bool class PmsRoom(models.Model): @@ -77,7 +78,14 @@ class PmsRoom(models.Model): check_pms_properties=True, ) capacity = fields.Integer( - help="The maximum number of people that can occupy a room" + help="Number of places where anyone can sleep, adults or children" + ) + children_capacity = fields.Integer( + help="Number of places where only children can sleep, such as child " + "bunk beds. Adults never occupy them, children occupy them only once " + "the regular capacity is full", + required=True, + default=0, ) extra_beds_allowed = fields.Integer( help="Number of extra beds allowed in room", @@ -314,16 +322,15 @@ def _check_adults(self, reservation, service_line_ids=False): and x.product_id.is_extra_bed is True ) num_extra_beds = sum(extra_beds.mapped("day_qty")) if extra_beds else 0 - if line.room_id: - if ( - reservation.adults + reservation.children_occupying - ) > line.room_id.get_capacity(num_extra_beds): - raise ValidationError( - _( - "Persons can't be higher than room capacity (%s)", - reservation.name, - ) + if line.room_id and not line.room_id.fits_occupancy( + reservation.adults, reservation.children, num_extra_beds + ): + raise ValidationError( + _( + "Persons can't be higher than room capacity (%s)", + reservation.name, ) + ) @api.constrains("short_name") def _check_short_name(self): @@ -430,3 +437,36 @@ def get_capacity(self, extra_bed=0): _("Extra beds can't be greater than allowed beds for this room") ) return record.capacity + extra_bed + + @api.model + def _children_occupy_capacity(self): + """Whether children take a place in the room. + + Off unless the property turns it on, which is how it worked until + now: counting them refuses reservations that properties have been + taking, so it is theirs to decide and not something an upgrade does. + """ + return str2bool( + self.env["ir.config_parameter"] + .sudo() + .get_param("pms.children_occupy_capacity"), + default=False, + ) + + def fits_occupancy(self, adults, children=0, extra_bed=0): + """Whether the room can hold the given guests. + + Children sleep in the regular capacity as well, and only overflow + into 'children_capacity' once it is full. Adults never occupy those + places, so a room with free children places can still be full. + + Whether children take a place at all is up to the property, see + _children_occupy_capacity. + """ + self.ensure_one() + capacity = self.get_capacity(extra_bed) + if adults > capacity: + return False + if not self._children_occupy_capacity(): + return True + return adults + children <= capacity + self.children_capacity diff --git a/pms/readme/CONFIGURE.rst b/pms/readme/CONFIGURE.rst index 940574e837..52c20657da 100644 --- a/pms/readme/CONFIGURE.rst +++ b/pms/readme/CONFIGURE.rst @@ -1,3 +1,19 @@ You will find the hotel settings in PMS Management > Configuration > Properties > Your Property. This module required additional configuration for company, accounting, invoicing and user privileges. + +Children and room capacity +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default children take no place in the room, so only the adults are checked +against its capacity. Properties that want to count them can set the system +parameter ``pms.children_occupy_capacity`` to ``True``, in Settings > Technical +> System Parameters. + +Once it is on, children take the regular places of the room first and only +overflow into its children places, the ones where only a child can sleep, such +as child bunk beds. Adults never occupy those, so a room with free children +places can still be full. + +Mind that turning it on refuses reservations that the property may have been +taking until then. diff --git a/pms/static/description/index.html b/pms/static/description/index.html index 584e80f0d6..daa00b91fd 100644 --- a/pms/static/description/index.html +++ b/pms/static/description/index.html @@ -383,13 +383,16 @@

PMS (Property Management System)

-

Usage

+

Usage

To use this module, please, read the complete user guide at roomdoo.com.

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -417,15 +433,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Commit [Sun]
-

Contributors

+

Contributors

  • Alexandre Díaz
  • Pablo Quesada
  • @@ -442,7 +458,7 @@

    Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association diff --git a/pms/tests/test_pms_reservation.py b/pms/tests/test_pms_reservation.py index 5fcb297d97..3bc109fbff 100644 --- a/pms/tests/test_pms_reservation.py +++ b/pms/tests/test_pms_reservation.py @@ -816,6 +816,39 @@ def test_split_reservation07(self): } ) + def _children_occupy_capacity(self): + """Count children towards the capacity, which is off by default.""" + self.env["ir.config_parameter"].sudo().set_param( + "pms.children_occupy_capacity", "True" + ) + + @freeze_time("2012-01-14") + def test_children_do_not_occupy_by_default(self): + """Children take no place unless the property says otherwise. + + Properties have been taking these reservations all along, so counting + children is theirs to turn on and never something an upgrade does. + """ + # NO ARRANGE + # ACT + reservation = self.env["pms.reservation"].create( + { + "adults": 2, + "children": 3, + "checkin": datetime.datetime.now(), + "checkout": datetime.datetime.now() + datetime.timedelta(days=1), + "preferred_room_id": self.room1.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "sale_channel_origin_id": self.sale_channel_direct.id, + } + ) + reservation.flush_recordset() + # ASSERT + self.assertEqual( + reservation.children, 3, "The children should fit in a double room" + ) + @freeze_time("2012-01-14") def test_manage_children_raise(self): # TEST CASE @@ -823,24 +856,106 @@ def test_manage_children_raise(self): Check if the error occurs when trying to put more people than the capacity of the room. -------------- - Create a reservation with a double room whose capacity is two and try to create - it with two adults and a child occupying the room. + Create a reservation with a double room whose capacity is two and no + children places, and try to create it with two adults and a child. """ - # NO ARRANGE + # ARRANGE + self._children_occupy_capacity() # ACT & ASSERT - with self.assertRaises( + with self.assertRaisesRegex( ValidationError, + "capacity", msg="The number of people is greater than the capacity of the room", ): reservation = self.env["pms.reservation"].create( { "adults": 2, - "children_occupying": 1, + "children": 1, "checkin": datetime.datetime.now(), "checkout": datetime.datetime.now() + datetime.timedelta(days=1), "preferred_room_id": self.room1.id, "partner_id": self.partner1.id, "pms_property_id": self.pms_property1.id, + "sale_channel_origin_id": self.sale_channel_direct.id, + } + ) + reservation.flush_recordset() + + @freeze_time("2012-01-14") + def test_children_use_children_capacity(self): + """ + Check that children overflow into the children places of the room. + -------------- + Create a room with two regular places and one children place, and a + reservation with two adults and one child: the child does not fit in + the regular places but takes the children one. + """ + # ARRANGE + self._children_occupy_capacity() + room = self.env["pms.room"].create( + { + "pms_property_id": self.pms_property1.id, + "name": "Double with bunk bed", + "room_type_id": self.room_type_double.id, + "capacity": 2, + "children_capacity": 1, + } + ) + # ACT + reservation = self.env["pms.reservation"].create( + { + "adults": 2, + "children": 1, + "checkin": datetime.datetime.now(), + "checkout": datetime.datetime.now() + datetime.timedelta(days=1), + "preferred_room_id": room.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "sale_channel_origin_id": self.sale_channel_direct.id, + } + ) + reservation.flush_recordset() + # ASSERT + self.assertEqual( + reservation.children, + 1, + "The child should take the children place of the room", + ) + + @freeze_time("2012-01-14") + def test_adults_do_not_use_children_capacity(self): + """ + Check that adults never take the children places of the room. + -------------- + Create a room with two regular places and two children places, and try + to book it for three adults: the room holds four guests but only two + of them can be adults. + """ + # ARRANGE + room = self.env["pms.room"].create( + { + "pms_property_id": self.pms_property1.id, + "name": "Double with two bunk beds", + "room_type_id": self.room_type_double.id, + "capacity": 2, + "children_capacity": 2, + } + ) + # ACT & ASSERT + with self.assertRaisesRegex( + ValidationError, + "capacity", + msg="Adults cannot occupy the children places of the room", + ): + reservation = self.env["pms.reservation"].create( + { + "adults": 3, + "checkin": datetime.datetime.now(), + "checkout": datetime.datetime.now() + datetime.timedelta(days=1), + "preferred_room_id": room.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "sale_channel_origin_id": self.sale_channel_direct.id, } ) reservation.flush_recordset() diff --git a/pms/views/pms_reservation_views.xml b/pms/views/pms_reservation_views.xml index 96b783bc38..9e711a25c0 100644 --- a/pms/views/pms_reservation_views.xml +++ b/pms/views/pms_reservation_views.xml @@ -443,10 +443,6 @@ name="children" attrs="{'invisible': [('reservation_type','in',('out'))]}" /> - diff --git a/pms/views/pms_room_views.xml b/pms/views/pms_room_views.xml index 913efbd9d0..30d4ee9b5d 100644 --- a/pms/views/pms_room_views.xml +++ b/pms/views/pms_room_views.xml @@ -50,6 +50,7 @@ attrs="{'invisible':[('parent_id', '=', False)]}" /> + @@ -245,6 +246,7 @@ + From 875b006a657df298e22d9cfd7aa3be0da52a995c Mon Sep 17 00:00:00 2001 From: jesusvmayor Date: Tue, 6 Oct 2026 15:32:57 +0200 Subject: [PATCH 2/4] [FIX] pms_l10n_es: stop subtracting children_occupying in INE The INE extra bed count subtracted the children occupying the room from the extra beds sold for that date. Nothing ever filled children_occupying in, so the subtraction always took zero, and the field is gone now. Assisted-by: Claude Opus 5 --- pms_l10n_es/wizards/wizard_ine.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pms_l10n_es/wizards/wizard_ine.py b/pms_l10n_es/wizards/wizard_ine.py index 7b7d3c0862..cfc12b4fe5 100644 --- a/pms_l10n_es/wizards/wizard_ine.py +++ b/pms_l10n_es/wizards/wizard_ine.py @@ -141,11 +141,7 @@ def ine_rooms(self, start_date, end_date, pms_property_id): 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 += ebsl.day_qty # search all rooms all_rooms = ( From 7b3773f6b17d8d2840de67f023c152d7229e4313 Mon Sep 17 00:00:00 2001 From: jesusvmayor Date: Tue, 6 Oct 2026 15:57:31 +0200 Subject: [PATCH 3/4] [ADD] pms: price a room type by its occupancy A pricelist gave a room type one price per night, whoever stayed in it. Selling a double for less when a single guest takes it, or charging for a third one, could only be done by hand on every reservation. Room types get the occupancy their rooms guarantee (max_occupancy, the smallest capacity among them, as any of them can be the one assigned) and the occupancy their price is given for (default_occupancy). A pricelist then carries, per room type, how much each guest above or below that default changes the price, as a percentage or as an amount. The modifier is linear, applying once per guest of difference, which is what channel managers derive from a default occupancy as well. A price asked for without an occupancy, or a room type with no modifiers, is the plain one of the pricelist, so this is transparent to everything pricing a room type without guests in hand. Changing the adults leaves the prices of the nights as they are, the same as changing the pricelist or the room type does: the form flags them as outdated, and updating them prices the nights for the adults staying. Nights added afterwards are priced for them already. A price is never rewritten behind the back of whoever wrote it, such as a channel manager writing what an OTA sold. The birthdate of the guests checking in never takes part in it: nobody reprices at the desk because a guest had a birthday. --- pms/__manifest__.py | 2 +- pms/models/__init__.py | 1 + pms/models/pms_pricelist_occupancy.py | 136 +++++++++++++++++++ pms/models/pms_reservation.py | 19 ++- pms/models/pms_reservation_line.py | 18 ++- pms/models/pms_room_type.py | 94 +++++++++++++ pms/models/product_pricelist.py | 8 ++ pms/models/product_pricelist_item.py | 16 ++- pms/security/ir.model.access.csv | 2 + pms/tests/__init__.py | 1 + pms/tests/test_pms_pricelist_occupancy.py | 154 ++++++++++++++++++++++ pms/tests/test_pms_reservation.py | 131 +++++++++++++++++- pms/views/pms_room_type_views.xml | 2 + pms/views/product_pricelist_views.xml | 44 +++++++ pms/wizards/pms_booking_engine.py | 19 ++- pms/wizards/pms_booking_engine_views.xml | 4 + 16 files changed, 643 insertions(+), 8 deletions(-) create mode 100644 pms/models/pms_pricelist_occupancy.py create mode 100644 pms/tests/test_pms_pricelist_occupancy.py diff --git a/pms/__manifest__.py b/pms/__manifest__.py index 04e569dac9..65587c7ab4 100644 --- a/pms/__manifest__.py +++ b/pms/__manifest__.py @@ -4,7 +4,7 @@ { "name": "PMS (Property Management System)", "summary": "A property management system", - "version": "16.0.4.25.0", + "version": "16.0.4.26.0", "development_status": "Beta", "category": "Generic Modules/Property Management System", "website": "https://github.com/OCA/pms", diff --git a/pms/models/__init__.py b/pms/models/__init__.py index adf6e66a5b..aa0a90b3c8 100644 --- a/pms/models/__init__.py +++ b/pms/models/__init__.py @@ -27,6 +27,7 @@ from . import pms_checkin_partner from . import product_pricelist from . import product_pricelist_item +from . import pms_pricelist_occupancy from . import res_partner from . import pms_sale_channel from . import mail_compose_message diff --git a/pms/models/pms_pricelist_occupancy.py b/pms/models/pms_pricelist_occupancy.py new file mode 100644 index 0000000000..4a0e43dfc3 --- /dev/null +++ b/pms/models/pms_pricelist_occupancy.py @@ -0,0 +1,136 @@ +# Copyright 2026 Commit [Sun] +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class PmsPricelistOccupancy(models.Model): + _name = "pms.pricelist.occupancy" + _description = "Occupancy price modifiers of a pricelist for a room type" + _check_pms_properties_auto = True + + pricelist_id = fields.Many2one( + string="Pricelist", + help="Pricelist these modifiers belong to", + comodel_name="product.pricelist", + required=True, + index=True, + ondelete="cascade", + check_pms_properties=True, + ) + room_type_id = fields.Many2one( + string="Room Type", + help="Room type these modifiers apply to", + comodel_name="pms.room.type", + required=True, + index=True, + ondelete="cascade", + check_pms_properties=True, + ) + pms_property_ids = fields.Many2many( + string="Properties", + help="Properties of the room type these modifiers apply to", + comodel_name="pms.property", + related="room_type_id.pms_property_ids", + ) + increase_mode = fields.Selection( + help="How to charge each guest above the default occupancy", + selection=[("percent", "Percentage"), ("amount", "Fixed amount")], + default="percent", + required=True, + ) + increase_value = fields.Float( + string="Increase", + help="Charged for each guest above the default occupancy of the room " + "type. Zero to charge the same price no matter how many they are", + digits=("Product Price"), + ) + decrease_mode = fields.Selection( + help="How to discount each guest below the default occupancy", + selection=[("percent", "Percentage"), ("amount", "Fixed amount")], + default="percent", + required=True, + ) + decrease_value = fields.Float( + string="Decrease", + help="Discounted for each guest below the default occupancy of the " + "room type. Zero to charge the same price no matter how many they are", + digits=("Product Price"), + ) + summary = fields.Char( + string="Per Guest", + help="What each guest of difference changes in the price", + compute="_compute_summary", + ) + + _sql_constraints = [ + ( + "pricelist_room_type_uniq", + "unique(pricelist_id, room_type_id)", + "A room type can only have one occupancy configuration per " "pricelist.", + ) + ] + + @api.depends("increase_mode", "increase_value", "decrease_mode", "decrease_value") + def _compute_summary(self): + """A one-liner for the list, so the form holds the detail.""" + for record in self: + parts = record._summary_parts() + record.summary = " / ".join(parts) if parts else _("Same price") + + def _summary_parts(self): + """The pieces of the summary, one per modifier that is set.""" + self.ensure_one() + parts = [] + if self.increase_value: + modifier = self._format_modifier(self.increase_mode, self.increase_value) + parts.append(f"+{modifier}") + if self.decrease_value: + modifier = self._format_modifier(self.decrease_mode, self.decrease_value) + parts.append(f"-{modifier}") + return parts + + def _format_modifier(self, mode, value): + self.ensure_one() + if mode == "percent": + return f"{value:g}%" + return f"{value:g} {self.pricelist_id.currency_id.symbol or ''}" + + @api.constrains("increase_value", "decrease_value") + def _check_values_not_negative(self): + """The direction is given by the field, so the value is an amount. + + A negative increase would silently discount, and the other way round. + """ + for record in self: + if record.increase_value < 0 or record.decrease_value < 0: + raise ValidationError( + _("Occupancy increase and decrease can't be negative.") + ) + + def _apply(self, price, difference): + """Price for an occupancy differing by `difference` guests. + + The modifier is linear: it applies once per guest of difference, which + is what channel managers derive from a default occupancy as well. + """ + self.ensure_one() + if difference > 0: + mode, value, guests, sign = ( + self.increase_mode, + self.increase_value, + difference, + 1, + ) + else: + mode, value, guests, sign = ( + self.decrease_mode, + self.decrease_value, + -difference, + -1, + ) + if not value: + return price + if mode == "percent": + return price * (1 + sign * value * guests / 100) + return price + sign * value * guests diff --git a/pms/models/pms_reservation.py b/pms/models/pms_reservation.py index 7158d55009..bda6cca9c7 100644 --- a/pms/models/pms_reservation.py +++ b/pms/models/pms_reservation.py @@ -1028,7 +1028,9 @@ def _compute_user_id(self): else: res.user_id = active_user_id or res.pms_property_id.user_id - @api.depends("pricelist_id", "room_type_id") + @api.depends( + lambda self: ["pricelist_id", "room_type_id", *self._occupancy_price_fields()] + ) def _compute_show_update_pricelist(self): for reservation in self: if ( @@ -1041,6 +1043,10 @@ def _compute_show_update_pricelist(self): reservation.room_type_id and reservation._origin.room_type_id != reservation.room_type_id ) + or any( + reservation._origin[field] != reservation[field] + for field in reservation._occupancy_price_fields() + ) ): reservation.show_update_pricelist = True else: @@ -2309,6 +2315,17 @@ def get_services_to_update_channel(self, vals): services_to_update_channel += service return services_to_update_channel + def _occupancy_price_fields(self): + """Fields the price of a night is derived from, besides the pricelist + and the room type. + + Changing them leaves the prices of the nights outdated, the same as + changing the pricelist or the room type does: the nights keep the price + they have and the reservation flags it, so that they are updated on + purpose. Modules pricing on more than the adults staying add their own. + """ + return ["adults"] + def update_prices(self): self.ensure_one() for line in self.reservation_line_ids: diff --git a/pms/models/pms_reservation_line.py b/pms/models/pms_reservation_line.py index 5b6733ea9c..1c3b15493a 100644 --- a/pms/models/pms_reservation_line.py +++ b/pms/models/pms_reservation_line.py @@ -231,7 +231,6 @@ def _get_pricelist_price(self): qty = 1.0 uom = product.uom_id currency = self.currency_id or self.order_id.company_id.currency_id - consumption_date = self.date price = pricelist_rule._compute_consumption_price( product, @@ -239,12 +238,25 @@ def _get_pricelist_price(self): uom, order_date, currency=currency, - consumption_date=consumption_date, - pms_property_id=self.pms_property_id.id, + **self._get_price_kwargs(), ) return price + def _get_price_kwargs(self): + """What the pricelist resolves the price of this night with. + + Kept apart so that modules pricing on more than the adults staying can + add to it without repeating how a night is priced. + """ + self.ensure_one() + return { + "consumption_date": self.date, + "pms_property_id": self.pms_property_id.id, + "occupancy": self.reservation_id.adults, + "pricelist": self.reservation_id.pricelist_id, + } + def _get_product_price_context(self): """Gives the context for product price computation. diff --git a/pms/models/pms_room_type.py b/pms/models/pms_room_type.py index fba64f3cb1..bf5a7c225d 100644 --- a/pms/models/pms_room_type.py +++ b/pms/models/pms_room_type.py @@ -73,6 +73,22 @@ class PmsRoomType(models.Model): compute="_compute_total_rooms_count", store=True, ) + max_occupancy = fields.Integer( + string="Max. Occupancy", + help="Occupancy the room type guarantees: the smallest capacity among " + "its rooms, as any of them can be the one assigned. Zero when the " + "room type has no rooms yet", + compute="_compute_max_occupancy", + store=True, + ) + default_occupancy = fields.Integer( + help="Occupancy the price of this room type is given for. Prices for " + "a different number of adults are derived from it. Zero to price the " + "room type regardless of its occupancy", + compute="_compute_default_occupancy", + store=True, + readonly=False, + ) default_max_avail = fields.Integer( string="Default Max. Availability", help="Maximum simultaneous availability on own Booking Engine " @@ -119,6 +135,52 @@ def _compute_total_rooms_count(self): for record in self: record.total_rooms_count = len(record.room_ids) + @api.depends("room_ids", "room_ids.active", "room_ids.capacity") + def _compute_max_occupancy(self): + """The smallest capacity among the rooms of the type. + + The smallest and not the largest one: any room of the type can be the + one assigned, so a larger capacity is not guaranteed and selling it + would end up in an overbooking. + """ + for record in self: + capacities = record.room_ids.mapped("capacity") + record.max_occupancy = min(capacities) if capacities else 0 + + @api.depends("max_occupancy") + def _compute_default_occupancy(self): + """Keep the occupancy the price is given for within reach of the rooms. + + It defaults to the maximum occupancy and follows it down when a smaller + room joins the type, as otherwise adding that room would be refused. A + room type priced for fewer guests than it holds keeps its own value. + """ + for record in self: + default_occupancy = record.default_occupancy or record.max_occupancy + if record.max_occupancy: + default_occupancy = min(default_occupancy, record.max_occupancy) + record.default_occupancy = default_occupancy + + @api.constrains("default_occupancy") + def _check_default_occupancy(self): + """A room type cannot be priced for more guests than it guarantees. + + Only on the value itself, never on max_occupancy: a smaller room + joining the type brings the default down (see + _compute_default_occupancy) and adding that room must not be refused. + Room types with no rooms are left alone, as they come before them. + """ + for record in self: + if record.max_occupancy and record.default_occupancy > record.max_occupancy: + raise ValidationError( + _( + "The default occupancy of %(room_type)s can't be " + "higher than the occupancy it guarantees (%(max)s)", + room_type=record.display_name, + max=record.max_occupancy, + ) + ) + @api.model def get_room_types_by_property(self, pms_property_id, default_code=None): """ @@ -216,6 +278,38 @@ def get_room_type_capacity(self, pms_property_id): ).mapped("capacity") return min(capacities) if any(capacities) else 0 + def _get_occupancy_rule(self, pricelist): + """The occupancy modifiers this pricelist holds for the room type.""" + self.ensure_one() + if not pricelist: + return self.env["pms.pricelist.occupancy"] + return self.env["pms.pricelist.occupancy"].search( + [ + ("pricelist_id", "=", pricelist.id), + ("room_type_id", "=", self.id), + ], + limit=1, + ) + + def _get_occupancy_price(self, price, occupancy, pricelist): + """Derive the price of the room type for a given number of adults. + + The price of a pricelist is the one for the default occupancy of the + room type, and the configured modifiers derive the rest from it. Room + types with no default occupancy, or pricelists with no modifiers for + them, are priced the same no matter how many guests stay. + """ + self.ensure_one() + if not occupancy or not self.default_occupancy: + return price + difference = occupancy - self.default_occupancy + if not difference: + return price + occupancy_rule = self._get_occupancy_rule(pricelist) + if not occupancy_rule: + return price + return max(occupancy_rule._apply(price, difference), 0) + @api.model def get_default_board_service(self, pms_property_id, room_type_id, pricelist_id): board_services_candidates = self.env["pms.board.service.room.type"].search( diff --git a/pms/models/product_pricelist.py b/pms/models/product_pricelist.py index b78c346163..764c24326f 100644 --- a/pms/models/product_pricelist.py +++ b/pms/models/product_pricelist.py @@ -71,6 +71,14 @@ class ProductPricelist(models.Model): help="If the pricelist is available in the PMS", default=False, ) + occupancy_ids = fields.One2many( + string="Occupancy Prices", + help="How the price of each room type changes with the number of " + "adults staying in it", + comodel_name="pms.pricelist.occupancy", + inverse_name="pricelist_id", + check_pms_properties=True, + ) def _get_applicable_rules_domain(self, products, date, **kwargs): domain = super()._get_applicable_rules_domain(products, date, **kwargs) diff --git a/pms/models/product_pricelist_item.py b/pms/models/product_pricelist_item.py index d3b02c2266..d303419234 100644 --- a/pms/models/product_pricelist_item.py +++ b/pms/models/product_pricelist_item.py @@ -218,7 +218,21 @@ def convert(p): product, quantity, uom, date, currency, **kwargs ) - return price + return self._apply_occupancy_price(product, price, **kwargs) + + def _apply_occupancy_price(self, product, price, **kwargs): + """Derive the price for the occupancy asked for, if any. + + The price of the rule is the one for the default occupancy of the room + type. Callers that do not ask for an occupancy get that price, so this + is transparent to anything pricing a room type without guests in hand. + """ + occupancy = kwargs.get("occupancy") + room_type = product.room_type_id if occupancy else False + if not room_type: + return price + pricelist = kwargs.get("pricelist") or self.pricelist_id + return room_type._get_occupancy_price(price, occupancy, pricelist) def _compute_base_consumption_price( self, product, quantity, uom, date, target_currency, **kwargs diff --git a/pms/security/ir.model.access.csv b/pms/security/ir.model.access.csv index 3f9e79aa4a..a8be9bc455 100644 --- a/pms/security/ir.model.access.csv +++ b/pms/security/ir.model.access.csv @@ -49,6 +49,7 @@ manager_access_pms_cancelation_rule,manager_access_pms_cancelation_rule,model_pm manager_access_availability,manager_access_availability,model_pms_availability_plan,pms.group_pms_manager,1,1,1,1 manager_access_pms_sale_channel,manager_access_pms_sale_channel,model_pms_sale_channel,pms.group_pms_manager,1,1,1,1 manager_access_pms_team_member,manager_access_pms_team_member,model_pms_team_member,pms.group_pms_manager,1,1,1,1 +manager_access_pms_pricelist_occupancy,manager_access_pms_pricelist_occupancy,model_pms_pricelist_occupancy,pms.group_pms_manager,1,1,1,1 user_access_pms_reservation_split_join_swap_wizard,user_access_pms_reservation_split_join_swap_wizard,model_pms_reservation_split_join_swap_wizard,pms.group_pms_user,1,1,1,1 user_access_pms_wizard_reservation_lines_split,user_access_pms_wizard_reservation_lines_split,model_pms_wizard_reservation_lines_split,pms.group_pms_user,1,1,1,1 user_access_pms_massive_changes_wizard,user_access_pms_massive_changes_wizard,model_pms_massive_changes_wizard,pms.group_pms_user,1,1,1,1 @@ -68,3 +69,4 @@ user_access_pms_precheckin_portal,user_access_pms_precheckin_portal,model_pms_ch user_access_pms_booking_duplicate,user_access_pms_booking_duplicate,model_pms_booking_duplicate,pms.group_pms_user,1,1,1,1 user_access_pms_reservation_duplicate,user_access_pms_reservation_duplicate,model_pms_reservation_duplicate,pms.group_pms_user,1,1,1,1 user_access_ir_pms_property,user_access_ir_pms_property,model_ir_pms_property,pms.group_pms_user,1,1,1,1 +user_access_pms_pricelist_occupancy,user_access_pms_pricelist_occupancy,model_pms_pricelist_occupancy,pms.group_pms_user,1,0,0,0 diff --git a/pms/tests/__init__.py b/pms/tests/__init__.py index 35cf87cc43..eb29c58b95 100644 --- a/pms/tests/__init__.py +++ b/pms/tests/__init__.py @@ -19,6 +19,7 @@ # along with this program. If not, see . # ############################################################################## +from . import test_pms_pricelist_occupancy from . import test_pms_reservation from . import test_pms_pricelist from . import test_pms_checkin_partner diff --git a/pms/tests/test_pms_pricelist_occupancy.py b/pms/tests/test_pms_pricelist_occupancy.py new file mode 100644 index 0000000000..1f722348f1 --- /dev/null +++ b/pms/tests/test_pms_pricelist_occupancy.py @@ -0,0 +1,154 @@ +import datetime + +from odoo.exceptions import ValidationError + +from .common import TestPms + + +class TestPmsPricelistOccupancy(TestPms): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.consumption_date = datetime.date(2012, 1, 14) + cls.room_type = cls.env["pms.room.type"].create( + { + "pms_property_ids": [cls.pms_property1.id], + "name": "Quadruple", + "default_code": "QDR", + "class_id": cls.room_type_class1.id, + } + ) + for number in range(1, 3): + cls.env["pms.room"].create( + { + "pms_property_id": cls.pms_property1.id, + "name": "Quadruple-%s" % number, + "room_type_id": cls.room_type.id, + "capacity": 4, + } + ) + cls.env["product.pricelist.item"].create( + { + "pricelist_id": cls.pricelist1.id, + "applied_on": "0_product_variant", + "product_id": cls.room_type.product_id.id, + "compute_price": "fixed", + "fixed_price": 100.0, + "date_start_consumption": cls.consumption_date, + "date_end_consumption": cls.consumption_date, + "pms_property_ids": [(6, 0, [cls.pms_property1.id])], + } + ) + + def _price(self, occupancy): + return self.pricelist1._get_product_price( + product=self.room_type.product_id, + quantity=1, + consumption_date=self.consumption_date, + pms_property_id=self.pms_property1.id, + occupancy=occupancy, + ) + + def _set_modifiers(self, **values): + return self.env["pms.pricelist.occupancy"].create( + dict( + values, + pricelist_id=self.pricelist1.id, + room_type_id=self.room_type.id, + ) + ) + + def test_default_occupancy_defaults_to_max(self): + """The room type is priced for the occupancy its rooms guarantee.""" + self.assertEqual(self.room_type.max_occupancy, 4) + self.assertEqual(self.room_type.default_occupancy, 4) + + def test_price_without_modifiers_is_the_pricelist_one(self): + """A pricelist with no modifiers prices the room type as before.""" + self.assertAlmostEqual(self._price(1), 100.0, places=2) + self.assertAlmostEqual(self._price(4), 100.0, places=2) + + def test_price_without_occupancy_is_the_pricelist_one(self): + """Callers that do not ask for an occupancy get the plain price.""" + self._set_modifiers(decrease_mode="percent", decrease_value=25.0) + self.assertAlmostEqual( + self.pricelist1._get_product_price( + product=self.room_type.product_id, + quantity=1, + consumption_date=self.consumption_date, + pms_property_id=self.pms_property1.id, + ), + 100.0, + places=2, + ) + + def test_decrease_below_default_occupancy(self): + """Each guest below the default occupancy discounts the price.""" + self._set_modifiers(decrease_mode="percent", decrease_value=25.0) + self.assertAlmostEqual(self._price(4), 100.0, places=2) + self.assertAlmostEqual(self._price(3), 75.0, places=2) + self.assertAlmostEqual(self._price(2), 50.0, places=2) + + def test_increase_above_default_occupancy(self): + """Each guest above the default occupancy charges more.""" + self.room_type.default_occupancy = 2 + self._set_modifiers(increase_mode="percent", increase_value=5.0) + self.assertAlmostEqual(self._price(2), 100.0, places=2) + self.assertAlmostEqual(self._price(3), 105.0, places=2) + + def test_modifier_is_linear_not_compound(self): + """Two guests of difference charge twice, they do not compound.""" + self.room_type.default_occupancy = 2 + self._set_modifiers(increase_mode="percent", increase_value=5.0) + self.assertAlmostEqual( + self._price(4), + 110.0, + places=2, + msg="A 5% increase for two guests is 10%, not 5% twice (110.25)", + ) + + def test_fixed_amount_modifier(self): + """The modifier can be an amount instead of a percentage.""" + self._set_modifiers(decrease_mode="amount", decrease_value=12.0) + self.assertAlmostEqual(self._price(3), 88.0, places=2) + self.assertAlmostEqual(self._price(2), 76.0, places=2) + + def test_price_never_goes_negative(self): + """A decrease larger than the price itself floors at zero.""" + self._set_modifiers(decrease_mode="amount", decrease_value=80.0) + self.assertAlmostEqual(self._price(1), 0.0, places=2) + + def test_no_modifiers_set_keeps_price(self): + """A configuration with no values set leaves the price alone.""" + self._set_modifiers() + self.assertAlmostEqual(self._price(1), 100.0, places=2) + + def test_zero_default_occupancy_prices_regardless_of_guests(self): + """A room type with no default occupancy is priced as a flat rate.""" + self.room_type.default_occupancy = 0 + self._set_modifiers(decrease_mode="percent", decrease_value=25.0) + self.assertAlmostEqual(self._price(1), 100.0, places=2) + + def test_default_occupancy_above_max_is_rejected(self): + """A room type cannot be priced for more guests than it guarantees.""" + with self.assertRaisesRegex(ValidationError, "guarantees"): + self.room_type.default_occupancy = 5 + + def test_room_type_without_rooms_accepts_any_default_occupancy(self): + """A room type is created before its rooms, so it is left alone.""" + room_type = self.env["pms.room.type"].create( + { + "pms_property_ids": [self.pms_property1.id], + "name": "Not built yet", + "default_code": "NBY", + "class_id": self.room_type_class1.id, + "default_occupancy": 3, + } + ) + self.assertEqual(room_type.max_occupancy, 0) + self.assertEqual(room_type.default_occupancy, 3) + + def test_negative_modifier_is_rejected(self): + """The direction comes from the field, so values are amounts.""" + with self.assertRaisesRegex(ValidationError, "negative"): + self._set_modifiers(decrease_mode="percent", decrease_value=-25.0) diff --git a/pms/tests/test_pms_reservation.py b/pms/tests/test_pms_reservation.py index 3bc109fbff..5fb9411f56 100644 --- a/pms/tests/test_pms_reservation.py +++ b/pms/tests/test_pms_reservation.py @@ -4,7 +4,7 @@ from odoo import fields from odoo.exceptions import UserError, ValidationError -from odoo.tests import tagged +from odoo.tests import Form, tagged from odoo.addons.account.tests.common import AccountTestInvoicingCommon @@ -960,6 +960,135 @@ def test_adults_do_not_use_children_capacity(self): ) reservation.flush_recordset() + def _occupancy_priced_reservation(self): + """A reservation of a room type priced for two adults, 20% less each.""" + self.room_type_double.default_occupancy = 2 + self.env["pms.pricelist.occupancy"].create( + { + "pricelist_id": self.pricelist1.id, + "room_type_id": self.room_type_double.id, + "decrease_mode": "percent", + "decrease_value": 20.0, + } + ) + return self.env["pms.reservation"].create( + { + "adults": 2, + "checkin": fields.date.today(), + "checkout": fields.date.today() + datetime.timedelta(days=1), + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pricelist_id": self.pricelist1.id, + "pms_property_id": self.pms_property1.id, + "sale_channel_origin_id": self.sale_channel_direct.id, + } + ) + + def test_changing_the_adults_keeps_the_prices(self): + """The nights keep their price, as they do when the pricelist or the + room type change: a price already given is only updated on purpose.""" + # ARRANGE + reservation = self._occupancy_priced_reservation() + price_for_two = reservation.reservation_line_ids[0].price + self.assertTrue( + price_for_two, "The night needs a price for the test to mean anything" + ) + # ACT + reservation.adults = 1 + # ASSERT + self.assertAlmostEqual( + reservation.reservation_line_ids[0].price, + price_for_two, + places=2, + msg="Changing the adults should not reprice the nights by itself", + ) + + def test_changing_the_adults_flags_the_prices_as_outdated(self): + """The form tells the prices no longer match the adults staying.""" + # ARRANGE + reservation = self._occupancy_priced_reservation() + form = Form(reservation) + self.assertFalse( + form.show_update_pricelist, "Nothing changed yet, nothing to update" + ) + # ACT + form.adults = 1 + # ASSERT + self.assertTrue( + form.show_update_pricelist, + "Changing the adults should offer to update the prices", + ) + + def test_updating_the_prices_follows_the_adults(self): + """Updating the prices on purpose prices the nights for the adults + staying, every time it is asked for.""" + # ARRANGE + reservation = self._occupancy_priced_reservation() + price_for_two = reservation.reservation_line_ids[0].price + # ACT & ASSERT + reservation.adults = 1 + reservation.update_prices() + self.assertAlmostEqual( + reservation.reservation_line_ids[0].price, + price_for_two * 0.8, + places=2, + msg="One adult below the default occupancy should discount 20%", + ) + reservation.adults = 2 + reservation.update_prices() + self.assertAlmostEqual( + reservation.reservation_line_ids[0].price, + price_for_two, + places=2, + msg="Going back to two adults should bring the price back", + ) + + def test_a_night_added_later_is_priced_for_the_adults(self): + """A night with no price yet is priced for the adults staying, while + the nights that had one keep it.""" + # ARRANGE + reservation = self._occupancy_priced_reservation() + price_for_two = reservation.reservation_line_ids[0].price + reservation.adults = 1 + # ACT + reservation.checkout = fields.date.today() + datetime.timedelta(days=2) + # ASSERT + nights = reservation.reservation_line_ids.sorted("date") + self.assertEqual(len(nights), 2, "The reservation should hold two nights") + self.assertAlmostEqual( + nights[0].price, + price_for_two, + places=2, + msg="The night that had a price should keep it", + ) + self.assertAlmostEqual( + nights[1].price, + price_for_two * 0.8, + places=2, + msg="The new night should be priced for one adult", + ) + + def test_prices_written_along_with_the_adults_are_kept(self): + """Whoever writes the adults and the prices of the nights together, + as a channel manager does with what an OTA sold, gets those prices.""" + # ARRANGE + reservation = self._occupancy_priced_reservation() + night = reservation.reservation_line_ids + # ACT + reservation.write( + { + "adults": 1, + "reservation_line_ids": [(1, night.id, {"price": 70.0})], + } + ) + # ASSERT + self.assertAlmostEqual( + night.price, + 70.0, + places=2, + msg="The price written should not be replaced by the pricelist one", + ) + def test_reservation_action_assign(self): """ Checks the correct operation of the assign method diff --git a/pms/views/pms_room_type_views.xml b/pms/views/pms_room_type_views.xml index a58b67c87c..a5415308c1 100644 --- a/pms/views/pms_room_type_views.xml +++ b/pms/views/pms_room_type_views.xml @@ -27,6 +27,8 @@ + + diff --git a/pms/views/product_pricelist_views.xml b/pms/views/product_pricelist_views.xml index 9a72a13f69..418d5498a5 100644 --- a/pms/views/product_pricelist_views.xml +++ b/pms/views/product_pricelist_views.xml @@ -107,6 +107,50 @@ + + + + + + + +
+ + + + + + + + + + + + + + + + +
+
+
+
- - [('id', 'in',allowed_board_service_product_ids)] -
@@ -69,7 +48,6 @@ - diff --git a/pms/views/product_pricelist_views.xml b/pms/views/product_pricelist_views.xml index 418d5498a5..86029bef43 100644 --- a/pms/views/product_pricelist_views.xml +++ b/pms/views/product_pricelist_views.xml @@ -69,11 +69,6 @@ options="{'no_create': True,'no_open': True}" attrs="{'column_invisible': [('parent.is_pms_available', '=', False)]}" /> - @@ -98,16 +93,29 @@ options="{'no_create': True,'no_open': True}" attrs="{'column_invisible': [('parent.is_pms_available', '=', False)]}" /> - + + + + + + + +
@@ -90,4 +95,21 @@ sequence="15" parent="pms.pms_services_menu" /> + + + Board Services + product.template + tree,form + [('is_board_service', '=', True)] + {'default_is_board_service': True, 'default_detailed_type': 'service'} + + diff --git a/pms/views/reservation_portal_templates.xml b/pms/views/reservation_portal_templates.xml index 703fe512c1..fe1397de83 100644 --- a/pms/views/reservation_portal_templates.xml +++ b/pms/views/reservation_portal_templates.xml @@ -155,9 +155,9 @@ />
- + Board Service:
diff --git a/pms/wizards/pms_booking_duplicate.py b/pms/wizards/pms_booking_duplicate.py index 87c8a8f623..a58e762232 100644 --- a/pms/wizards/pms_booking_duplicate.py +++ b/pms/wizards/pms_booking_duplicate.py @@ -223,7 +223,7 @@ def _compute_line_ids(self): "preferred_room_id": reservation.preferred_room_id.id, "room_type_id": reservation.room_type_id.id, "pricelist_id": reservation.pricelist_id.id, - "board_service_room_id": reservation.board_service_room_id.id, + "board_service_id": reservation.board_service_id.id, "adults": reservation.adults, }, ) @@ -318,7 +318,7 @@ def create_folio(self): "partner_name": self.partner_name, "pricelist_id": res.pricelist_id.id, "pms_property_id": folio.pms_property_id.id, - "board_service_room_id": res.board_service_room_id.id, + "board_service_id": res.board_service_id.id, "adults": res.adults, "preferred_room_id": res.preferred_room_id.id, } @@ -373,17 +373,23 @@ def create_folio(self): ) res_vals["reservation_line_ids"] = line_vals new_reservation = self.env["pms.reservation"].create(res_vals) - # REVIEW: Board service overwrite prices - for service in new_reservation.service_ids.filtered("is_board_service"): + if not self.recompute_prices: + # The board services are created by the reservation, keep the + # price each one had in the reference reservation origin_services_board = ( res.reference_reservation_id.service_ids.filtered( "is_board_service" ) ) - if origin_services_board: - service.service_line_ids.price_unit = ( - origin_services_board.service_line_ids[0].price_unit - ) + for service in new_reservation.service_ids.filtered("is_board_service"): + origin_service = origin_services_board.filtered( + lambda s, service=service: s.product_id == service.product_id + and s.board_guest_type == service.board_guest_type + )[:1] + if origin_service.service_line_ids: + service.service_line_ids.price_unit = ( + origin_service.service_line_ids[0].price_unit + ) self.created_folio_ids = [(4, folio.id)] @@ -462,11 +468,11 @@ class PmsReservationDuplicate(models.TransientModel): help="Propertiy with access to the element;", related="booking_duplicate_id.pms_property_id", ) - board_service_room_id = fields.Many2one( + board_service_id = fields.Many2one( string="Board Service", - help="Board Service included in the room", - comodel_name="pms.board.service.room.type", - domain="[('pms_room_type_id','=',room_type_id)]", + help="Board service included in the room. If not set, Room Only", + comodel_name="product.template", + domain="[('is_board_service', '=', True)]", check_pms_properties=True, ) service_ids = fields.Many2many( @@ -548,7 +554,7 @@ def _compute_used_room_ids(self): - {record.preferred_room_id.id} ) - @api.depends("room_type_id", "board_service_room_id", "checkin", "checkout") + @api.depends("room_type_id", "board_service_id", "checkin", "checkout") def _compute_price_total(self): self.price_total = 0 for record in self.filtered("checkout"): diff --git a/pms/wizards/pms_booking_duplicate_views.xml b/pms/wizards/pms_booking_duplicate_views.xml index 5c5fc38b10..f82d34abac 100644 --- a/pms/wizards/pms_booking_duplicate_views.xml +++ b/pms/wizards/pms_booking_duplicate_views.xml @@ -112,7 +112,7 @@ diff --git a/pms/wizards/pms_booking_engine.py b/pms/wizards/pms_booking_engine.py index 9bee898ab8..6e035d4f78 100644 --- a/pms/wizards/pms_booking_engine.py +++ b/pms/wizards/pms_booking_engine.py @@ -260,7 +260,7 @@ def create_folio(self): "partner_name": record.partner_name, "pricelist_id": record.pricelist_id.id, "pms_property_id": folio.pms_property_id.id, - "board_service_room_id": line.board_service_room_id.id, + "board_service_id": line.board_service_id.id, "adults": line.adults, } reservation_values.append((0, 0, res_dict)) @@ -337,18 +337,22 @@ class AvailabilityWizard(models.TransientModel): help="Propertiy with access to the element;", related="booking_engine_id.pms_property_id", ) - board_service_room_id = fields.Many2one( + board_service_id = fields.Many2one( string="Board Service", - help="Board Service included in the room", - comodel_name="pms.board.service.room.type", - domain=""" - [ - ('pms_room_type_id','=',room_type_id), - ('pms_property_id','=',pms_property_id) - ] - """, + help="Board service included in the room. If not set, Room Only", + comodel_name="product.template", + domain="[('id', 'in', allowed_board_service_ids)]", + compute="_compute_board_service_id", + store=True, + readonly=False, check_pms_properties=True, ) + allowed_board_service_ids = fields.Many2many( + string="Allowed Board Services", + help="Board services the pricelist sells for the stay", + comodel_name="product.template", + compute="_compute_allowed_board_service_ids", + ) @api.onchange("rooms_selected_qty") def onchange_rooms_selected_qty(self): @@ -376,19 +380,41 @@ def _compute_rooms_available_qty(self): ) record.rooms_available_qty = pms_property.availability + @api.depends("checkin", "checkout") + def _compute_allowed_board_service_ids(self): + room_only = self.env["product.template"]._room_only_board_service() + for record in self: + pricelist = record.booking_engine_id.pricelist_id + if pricelist and record.pms_property_id: + record.allowed_board_service_ids = ( + pricelist._get_available_board_services( + record.pms_property_id, record.checkin, record.checkout + ) + - room_only + ) + else: + record.allowed_board_service_ids = False + + @api.depends("checkin", "checkout") + def _compute_board_service_id(self): + for record in self: + default = record.booking_engine_id.pricelist_id.default_board_service_id + if default in record.allowed_board_service_ids: + record.board_service_id = default + else: + record.board_service_id = False + @api.depends("room_type_id") def _compute_adults(self): for record in self: record.adults = record.room_type_id.max_occupancy - @api.depends( - "room_type_id", "board_service_room_id", "checkin", "checkout", "adults" - ) + @api.depends("room_type_id", "board_service_id", "checkin", "checkout", "adults") def _compute_price_per_room(self): for record in self: record.price_per_room = self._get_price_by_room_type( room_type_id=record.room_type_id.id, - board_service_room_id=record.board_service_room_id.id, + board_service_id=record.board_service_id.id, checkin=record.checkin, checkout=record.checkout, pricelist_id=record.booking_engine_id.pricelist_id.id, @@ -407,7 +433,7 @@ def _get_price_by_room_type( room_type_id, checkin, checkout, - board_service_room_id, + board_service_id, pricelist_id, pms_property_id, adults=False, @@ -440,13 +466,16 @@ def _get_price_by_room_type( pms_property.company_id, ) - if board_service_room_id: - board_service_room = self.env["pms.board.service.room.type"].browse( - board_service_room_id - ) - nights = (checkout - checkin).days + if board_service_id: + board_service = self.env["product.template"].browse(board_service_id) adults = adults or room_type.get_room_type_capacity(pms_property_id) - room_type_total_price_per_room += ( - board_service_room.amount * nights * adults - ) + for date_iterator in [ + checkin + datetime.timedelta(days=x) + for x in range(0, (checkout - checkin).days) + ]: + room_type_total_price_per_room += ( + board_service._get_board_service_price( + pricelist, pms_property, date_iterator, adults, 0 + ) + ) return room_type_total_price_per_room diff --git a/pms/wizards/pms_booking_engine_views.xml b/pms/wizards/pms_booking_engine_views.xml index 97d9258f11..c23897cdcc 100644 --- a/pms/wizards/pms_booking_engine_views.xml +++ b/pms/wizards/pms_booking_engine_views.xml @@ -107,8 +107,10 @@ name="adults" attrs="{'column_invisible': [('parent.reservation_type','!=','normal')]}" /> + -
@@ -67,19 +66,6 @@ options="{'no_create': True,'no_open': True}" attrs="{'invisible':[('apply_pricelists_on','!=','room_types'),('massive_changes_on', '=','pricelist')],'required': []}" /> - - -