Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pms/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
=====

Expand Down
2 changes: 1 addition & 1 deletion pms/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{
"name": "PMS (Property Management System)",
"summary": "A property management system",
"version": "16.0.4.24.0",
"version": "16.0.4.26.0",
"development_status": "Beta",
"category": "Generic Modules/Property Management System",
"website": "https://github.com/OCA/pms",
Expand Down
5 changes: 5 additions & 0 deletions pms/demo/pms_master_data_no_update.xml
Original file line number Diff line number Diff line change
Expand Up @@ -326,34 +326,39 @@
<field name="room_type_id" ref="pms_room_type_single" />
<field name="ubication_id" ref="pms_ubication_1st_floor" />
<field name="capacity">1</field>
<field name="children_capacity">1</field>
<field name="pms_property_id" ref="pms.main_pms_property" />
</record>
<record id="pms_room_single_02" model="pms.room">
<field name="name">Single-107</field>
<field name="room_type_id" ref="pms_room_type_single" />
<field name="ubication_id" ref="pms_ubication_1st_floor" />
<field name="capacity">1</field>
<field name="children_capacity">1</field>
<field name="pms_property_id" ref="pms.main_pms_property" />
</record>
<record id="pms_room_single_03" model="pms.room">
<field name="name">Single-108</field>
<field name="room_type_id" ref="pms_room_type_single" />
<field name="ubication_id" ref="pms_ubication_1st_floor" />
<field name="capacity">1</field>
<field name="children_capacity">1</field>
<field name="pms_property_id" ref="pms.main_pms_property" />
</record>
<record id="pms_room_single_04" model="pms.room">
<field name="name">Single-109</field>
<field name="room_type_id" ref="pms_room_type_single" />
<field name="ubication_id" ref="pms_ubication_1st_floor" />
<field name="capacity">1</field>
<field name="children_capacity">1</field>
<field name="pms_property_id" ref="pms.main_pms_property" />
</record>
<record id="pms_room_single_05" model="pms.room">
<field name="name">Single-110</field>
<field name="room_type_id" ref="pms_room_type_single" />
<field name="ubication_id" ref="pms_ubication_1st_floor" />
<field name="capacity">1</field>
<field name="children_capacity">1</field>
<field name="pms_property_id" ref="pms.main_pms_property" />
</record>
<!-- double rooms (My Property) -->
Expand Down
21 changes: 21 additions & 0 deletions pms/migrations/16.0.4.25.0/pre-migration.py
Original file line number Diff line number Diff line change
@@ -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")])
1 change: 1 addition & 0 deletions pms/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 136 additions & 0 deletions pms/models/pms_pricelist_occupancy.py
Original file line number Diff line number Diff line change
@@ -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
33 changes: 24 additions & 9 deletions pms/models/pms_reservation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -1031,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 (
Expand All @@ -1044,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:
Expand Down Expand Up @@ -2215,9 +2218,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
Expand Down Expand Up @@ -2311,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:
Expand Down
44 changes: 32 additions & 12 deletions pms/models/pms_reservation_line.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,20 +231,32 @@ 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,
qty,
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.

Expand Down Expand Up @@ -685,23 +697,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,
)
)
Expand Down
Loading