diff --git a/pms/README.rst b/pms/README.rst index 532ad03192..694afcbfcd 100644 --- a/pms/README.rst +++ b/pms/README.rst @@ -56,11 +56,63 @@ You will find the hotel settings in PMS Management > Configuration > Properties This module required additional configuration for company, accounting, invoicing and user privileges. +Derived room type prices +~~~~~~~~~~~~~~~~~~~~~~~~ + +A room type can take its price from another one, e.g. a Superior room costing +10 more than the Double one, so that the pricelists only need the prices of +the latter. Set it in the *Derived Price* section of the room type: + +* *Price Derived From*: the room type the price is taken from. It must be + available in all the properties of the room type, and it can take its price + from a third one in turn (e.g. Family = Superior + 20), up to ten levels. +* *Price Variation Type* and *Price Variation*: a fixed amount, in the currency + of the pricelist, or a percentage of the base price. Negative values lower + it, and the price never goes below zero. +* *Price Rounding*: the multiple the prices derived by a percentage are rounded + to, e.g. 1 for whole amounts. Zero rounds them to the currency. +* *Derive in Pricelists*: the pricelists the price is derived in. Leave it + empty to derive it in all of them. + +Mind that a derived room type with no rule of its own in a pricelist it does +not derive in gets its sales price there. + Usage ===== To use this module, please, read the complete user guide at ``_. +Working with derived room type prices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The price of a room type is only derived from its base room type on the days +a pricelist has no rule of its own for it: + +* An item of the derived room type in the pricelist sets its price on its + dates: it overrides the derivation there, and the room type follows its base + again once the item is removed. +* Rules on a product category or on all products cover the room type as well, + so they win over the derivation. Keep them out of the pricelists the room + types derive in. +* The base room type is priced by the same pricelist, with its own rules or + derived in turn. With no rule at all, its sales price is the base. +* Pricelists based on another one through category or global rules (e.g. a + channel rate 10% above the standard one) apply on top of the derived price. + When they hold rules for the base room type only, the derived room type has + no rule there either: its price is derived from the price of the base room + type in that pricelist, which may not be what you want (e.g. a breakfast + surcharge per guest of the base room type). Give those pricelists rules for + the derived room types too, or use category or global rules there instead. +* The base price is computed for the date and the property of the stay only, + not for the guests: modules pricing by occupancy apply their rules to the + derived room type afterwards. +* Prices already set on reservations do not follow later changes of the base + price: use *Update prices* on the reservation to recompute them. +* Massive changes on pricelists leave the derived room types out of the price + changes when no room type is selected, so that they keep following their + base, except on the dates they have a price of their own, which is kept. + Select them explicitly to give them a price of their own. + Bug Tracker =========== diff --git a/pms/i18n/es.po b/pms/i18n/es.po index 5f74b88848..68e42adb82 100644 --- a/pms/i18n/es.po +++ b/pms/i18n/es.po @@ -1241,6 +1241,24 @@ msgstr "Debe especificarse un diario para la entidad adquirente %s." msgid "A payment acquirer is required to create a transaction." msgstr "Se requiere un adquirente de pagos para crear una transacción." +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#, python-format +msgid "A percentage variation must be greater than -100%." +msgstr "Una variación porcentual debe ser mayor que -100%." + +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#, python-format +msgid "" +"A room type cannot derive its price from itself, directly or through other " +"room types." +msgstr "" +"Un tipo de habitación no puede derivar su precio de sí mismo, ni " +"directamente ni a través de otros tipos de habitación." + #. module: pms #: model:ir.model.fields,help:pms.field_folio_advance_payment_inv__advance_payment_method msgid "" @@ -1439,6 +1457,15 @@ msgstr "Añadir al nombre de la habitación" msgid "Add to Folio" msgstr "Añadir a la Ficha" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_value +msgid "" +"Added to the price of the base room type, as an amount in the currency of " +"the pricelist or as a percentage of that price. Negative values lower it" +msgstr "" +"Se suma al precio del tipo de habitación base, como importe en la moneda de " +"la tarifa o como porcentaje de ese precio. Los valores negativos lo reducen" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_room_type__additional_product_tag_ids msgid "Additional Product Tag" @@ -3980,6 +4007,21 @@ msgid "" "order, etc." msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_pricelist_ids +msgid "Derive in Pricelists" +msgstr "Derivar en las tarifas" + +#. module: pms +#: model_terms:ir.ui.view,arch_db:pms.pms_room_type_view_form +msgid "Derived Price" +msgstr "Precio derivado" + +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derived_room_type_ids +msgid "Derived Room Types" +msgstr "Tipos de habitación derivados" + #. module: pms #: model:ir.model.fields,field_description:pms.field_folio_sale_line__name #: model:ir.model.fields,field_description:pms.field_pms_room_type__description @@ -4620,6 +4662,11 @@ msgstr "Nombre" msgid "Fiscal Position" msgstr "Posición Fiscal" +#. module: pms +#: model:ir.model.fields.selection,name:pms.selection__pms_room_type__price_derivation_type__amount +msgid "Fixed amount" +msgstr "Importe fijo" + #. module: pms #: model:ir.model.fields,field_description:pms.field_ir_pms_property__value_float msgid "Float Field Value" @@ -6533,6 +6580,11 @@ msgstr "" " el precio de la reserva original independientemente\n" " de lo marcado en la tarifa" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_massive_changes_wizard__price_derived_room_type_ids +msgid "Left Out (Derived Price)" +msgstr "Excluidos (precio derivado)" + #. module: pms #: model:ir.model,name:pms.model_pms_wizard_reservation_lines_split msgid "Lines available to split" @@ -8058,6 +8110,11 @@ msgstr "Detos del Checkin Pendientes" msgid "Pending arrival" msgstr "Llegada Pendiente" +#. module: pms +#: model:ir.model.fields.selection,name:pms.selection__pms_room_type__price_derivation_type__percent +msgid "Percentage" +msgstr "Porcentaje" + #. module: pms #: model:ir.model.fields,help:pms.field_pms_reservation__commission_percent msgid "Percentage corresponding to commission" @@ -8191,6 +8248,11 @@ msgstr "" msgid "Price" msgstr "Precio" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_base_room_type_id +msgid "Price Derived From" +msgstr "Precio derivado de" + #. module: pms #: model:ir.model.fields,field_description:pms.field_folio_sale_line__price_reduce msgid "Price Reduce" @@ -8206,6 +8268,11 @@ msgstr "" msgid "Price Reduce Tax included" msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_rounding +msgid "Price Rounding" +msgstr "Redondeo del precio" + #. module: pms #: model_terms:ir.ui.view,arch_db:pms.portal_my_reservation_detail msgid "Price Total" @@ -8216,6 +8283,16 @@ msgstr "Precio total" msgid "Price Unit" msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_value +msgid "Price Variation" +msgstr "Variación del precio" + +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_type +msgid "Price Variation Type" +msgstr "Tipo de variación del precio" + #. module: pms #: model_terms:ir.ui.view,arch_db:pms.pms_property_views_form msgid "Price and Availability Plans" @@ -8397,6 +8474,14 @@ msgstr "Tarifas para un canal de venta" msgid "Pricelists of the availability plan " msgstr "Tarifa del plan de disponibilidad" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_pricelist_ids +msgid "" +"Pricelists where the price is derived. Leave it empty to derive it in all of " +"them" +msgstr "" +"Tarifas en las que se deriva el precio. Déjelo vacío para derivarlo en todas" + #. module: pms #: model:ir.model.fields,help:pms.field_pms_board_service_room_type__pricelist_ids msgid "Pricelists where this Board Service is available" @@ -9581,6 +9666,15 @@ msgstr "Tipo de habitación para el que se aplica la regla de disponibilidad" msgid "Room type line in Booking Engine" msgstr "Línea de tipo de habitación en el Motor de Reservas" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_base_room_type_id +msgid "" +"Room type whose price is the base of this one in the pricelists without a " +"rule of their own for it" +msgstr "" +"Tipo de habitación cuyo precio sirve de base al de este en las tarifas que " +"no tienen una regla propia para él" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_massive_changes_wizard__board_service_room_type_ids msgid "Room type's board services" @@ -9591,6 +9685,37 @@ msgstr "Board services por tipo de habitación" msgid "Room types" msgstr "Tipo de habitación" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_massive_changes_wizard__room_type_ids +msgid "" +"Room types to apply the changes to. Leave it empty to apply them to all the " +"room types of the properties, except for the prices of the room types whose " +"price is derived from another one" +msgstr "" +"Tipos de habitación a los que aplicar los cambios. Déjelo vacío para " +"aplicarlos a todos los tipos de habitación de los hoteles, salvo los precios " +"de los tipos de habitación cuyo precio se deriva de otro" + +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_massive_changes_wizard__price_derived_room_type_ids +msgid "" +"Room types whose price is derived from another one in the selected " +"pricelists. As no room type is selected, the new price is not set on them: " +"they keep following the room type their price is derived from, except on the " +"dates they have a price of their own, which is kept. Select them explicitly " +"to give them a price of their own" +msgstr "" +"Tipos de habitación cuyo precio se deriva de otro en las tarifas " +"seleccionadas. Como no se ha seleccionado ningún tipo de habitación, el " +"nuevo precio no se les aplica: siguen al tipo de habitación del que derivan " +"su precio, salvo en las fechas en las que tienen un precio propio, que se " +"mantiene. Selecciónelos expresamente para darles un precio propio" + +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derived_room_type_ids +msgid "Room types whose price is derived from this one" +msgstr "Tipos de habitación cuyo precio se deriva de este" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_reservation__rooms msgid "Room/s" @@ -9661,6 +9786,15 @@ msgstr "Habitaciones que pertenecen al tipo de habitación." msgid "Rooms to create" msgstr "Salas para crear" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_rounding +msgid "" +"Round the prices derived by a percentage to a multiple of this value, e.g. 1 " +"for whole amounts. Zero rounds them to the currency" +msgstr "" +"Redondea los precios derivados con un porcentaje a un múltiplo de este " +"valor, p. ej. 1 para importes enteros. Con cero se redondean a la moneda" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_room_type__route_ids msgid "Routes" @@ -11200,6 +11334,21 @@ msgstr "" msgid "The price in a reservation line" msgstr "El precio en la linea de reserva" +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#: code:addons/pms/models/product_pricelist_item.py:0 +#, python-format +msgid "The price of %(room_type)s is derived through too many levels." +msgstr "El precio de %(room_type)s se deriva a través de demasiados niveles." + +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#, python-format +msgid "The price rounding can't be negative." +msgstr "El redondeo del precio no puede ser negativo." + #. module: pms #: model:ir.model.fields,help:pms.field_pms_folio__amount_untaxed msgid "The price without taxes on a folio" @@ -12477,6 +12626,15 @@ msgstr "" "Al configurar este campo, el folio será considerado como no facturado, aún " "cuando existan cantidades ordenadas pendientes de facturar." +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_type +msgid "" +"Whether the variation is a fixed amount or a percentage of the price of the " +"base room type" +msgstr "" +"Si la variación es un importe fijo o un porcentaje del precio del tipo de " +"habitación base" + #. module: pms #: model:pms.amenity,name:pms.pms_amenity_wi_fi msgid "Wi-Fi" diff --git a/pms/i18n/pms.pot b/pms/i18n/pms.pot index c17cd7a924..4bd55b553a 100644 --- a/pms/i18n/pms.pot +++ b/pms/i18n/pms.pot @@ -926,6 +926,22 @@ msgstr "" msgid "A payment acquirer is required to create a transaction." msgstr "" +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#, python-format +msgid "A percentage variation must be greater than -100%." +msgstr "" + +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#, python-format +msgid "" +"A room type cannot derive its price from itself, directly or through other " +"room types." +msgstr "" + #. module: pms #: model:ir.model.fields,help:pms.field_folio_advance_payment_inv__advance_payment_method msgid "" @@ -1114,6 +1130,13 @@ msgstr "" msgid "Add to Folio" msgstr "" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_value +msgid "" +"Added to the price of the base room type, as an amount in the currency of " +"the pricelist or as a percentage of that price. Negative values lower it" +msgstr "" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_room_type__additional_product_tag_ids msgid "Additional Product Tag" @@ -3625,6 +3648,21 @@ msgid "" "order, etc." msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_pricelist_ids +msgid "Derive in Pricelists" +msgstr "" + +#. module: pms +#: model_terms:ir.ui.view,arch_db:pms.pms_room_type_view_form +msgid "Derived Price" +msgstr "" + +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derived_room_type_ids +msgid "Derived Room Types" +msgstr "" + #. module: pms #: model:ir.model.fields,field_description:pms.field_folio_sale_line__name #: model:ir.model.fields,field_description:pms.field_pms_room_type__description @@ -4244,6 +4282,11 @@ msgstr "" msgid "Fiscal Position" msgstr "" +#. module: pms +#: model:ir.model.fields.selection,name:pms.selection__pms_room_type__price_derivation_type__amount +msgid "Fixed amount" +msgstr "" + #. module: pms #: model:ir.model.fields,field_description:pms.field_ir_pms_property__value_float msgid "Float Field Value" @@ -6077,6 +6120,11 @@ msgid "" " of what is marked in the rate" msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_massive_changes_wizard__price_derived_room_type_ids +msgid "Left Out (Derived Price)" +msgstr "" + #. module: pms #: model:ir.model,name:pms.model_pms_wizard_reservation_lines_split msgid "Lines available to split" @@ -7581,6 +7629,11 @@ msgstr "" msgid "Pending arrival" msgstr "" +#. module: pms +#: model:ir.model.fields.selection,name:pms.selection__pms_room_type__price_derivation_type__percent +msgid "Percentage" +msgstr "" + #. module: pms #: model:ir.model.fields,help:pms.field_pms_reservation__commission_percent msgid "Percentage corresponding to commission" @@ -7711,6 +7764,11 @@ msgstr "" msgid "Price" msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_base_room_type_id +msgid "Price Derived From" +msgstr "" + #. module: pms #: model:ir.model.fields,field_description:pms.field_folio_sale_line__price_reduce msgid "Price Reduce" @@ -7726,6 +7784,11 @@ msgstr "" msgid "Price Reduce Tax included" msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_rounding +msgid "Price Rounding" +msgstr "" + #. module: pms #: model_terms:ir.ui.view,arch_db:pms.portal_my_reservation_detail msgid "Price Total" @@ -7736,6 +7799,16 @@ msgstr "" msgid "Price Unit" msgstr "" +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_value +msgid "Price Variation" +msgstr "" + +#. module: pms +#: model:ir.model.fields,field_description:pms.field_pms_room_type__price_derivation_type +msgid "Price Variation Type" +msgstr "" + #. module: pms #: model_terms:ir.ui.view,arch_db:pms.pms_property_views_form msgid "Price and Availability Plans" @@ -7915,6 +7988,13 @@ msgstr "" msgid "Pricelists of the availability plan " msgstr "" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_pricelist_ids +msgid "" +"Pricelists where the price is derived. Leave it empty to derive it in all of" +" them" +msgstr "" + #. module: pms #: model:ir.model.fields,help:pms.field_pms_board_service_room_type__pricelist_ids msgid "Pricelists where this Board Service is available" @@ -9075,6 +9155,13 @@ msgstr "" msgid "Room type line in Booking Engine" msgstr "" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_base_room_type_id +msgid "" +"Room type whose price is the base of this one in the pricelists without a " +"rule of their own for it" +msgstr "" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_massive_changes_wizard__board_service_room_type_ids msgid "Room type's board services" @@ -9085,6 +9172,29 @@ msgstr "" msgid "Room types" msgstr "" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_massive_changes_wizard__room_type_ids +msgid "" +"Room types to apply the changes to. Leave it empty to apply them to all the " +"room types of the properties, except for the prices of the room types whose " +"price is derived from another one" +msgstr "" + +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_massive_changes_wizard__price_derived_room_type_ids +msgid "" +"Room types whose price is derived from another one in the selected " +"pricelists. As no room type is selected, the new price is not set on them: " +"they keep following the room type their price is derived from, except on the" +" dates they have a price of their own, which is kept. Select them explicitly" +" to give them a price of their own" +msgstr "" + +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derived_room_type_ids +msgid "Room types whose price is derived from this one" +msgstr "" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_reservation__rooms msgid "Room/s" @@ -9155,6 +9265,13 @@ msgstr "" msgid "Rooms to create" msgstr "" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_rounding +msgid "" +"Round the prices derived by a percentage to a multiple of this value, e.g. 1" +" for whole amounts. Zero rounds them to the currency" +msgstr "" + #. module: pms #: model:ir.model.fields,field_description:pms.field_pms_room_type__route_ids msgid "Routes" @@ -10635,6 +10752,21 @@ msgstr "" msgid "The price in a reservation line" msgstr "" +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#: code:addons/pms/models/product_pricelist_item.py:0 +#, python-format +msgid "The price of %(room_type)s is derived through too many levels." +msgstr "" + +#. module: pms +#. odoo-python +#: code:addons/pms/models/pms_room_type.py:0 +#, python-format +msgid "The price rounding can't be negative." +msgstr "" + #. module: pms #: model:ir.model.fields,help:pms.field_pms_folio__amount_untaxed msgid "The price without taxes on a folio" @@ -11826,6 +11958,13 @@ msgid "" "even when there may be ordered quantities pending to invoice." msgstr "" +#. module: pms +#: model:ir.model.fields,help:pms.field_pms_room_type__price_derivation_type +msgid "" +"Whether the variation is a fixed amount or a percentage of the price of the " +"base room type" +msgstr "" + #. module: pms #: model:pms.amenity,name:pms.pms_amenity_wi_fi msgid "Wi-Fi" diff --git a/pms/models/pms_reservation_line.py b/pms/models/pms_reservation_line.py index faa7b44a02..10ce9becce 100644 --- a/pms/models/pms_reservation_line.py +++ b/pms/models/pms_reservation_line.py @@ -241,6 +241,8 @@ def _get_pricelist_price(self): currency=currency, consumption_date=consumption_date, pms_property_id=self.pms_property_id.id, + # With no rule, a room type deriving its price needs the pricelist + pricelist=self.reservation_id.pricelist_id, ) return price diff --git a/pms/models/pms_room_type.py b/pms/models/pms_room_type.py index fba64f3cb1..7426668280 100644 --- a/pms/models/pms_room_type.py +++ b/pms/models/pms_room_type.py @@ -5,6 +5,7 @@ from odoo import _, api, fields, models from odoo.exceptions import ValidationError +from odoo.tools import float_round class PmsRoomType(models.Model): @@ -18,6 +19,9 @@ class PmsRoomType(models.Model): _inherits = {"product.product": "product_id"} _order = "sequence,default_code,name" _check_pms_properties_auto = True + # Room types a price can be derived through, from the priced one up to the + # root of its chain + _price_derivation_max_depth = 10 sequence = fields.Integer( help="Field used to change the position of the room types in tree view.", @@ -94,6 +98,53 @@ class PmsRoomType(models.Model): help="Minimum price for a room type", default=5.0, ) + price_base_room_type_id = fields.Many2one( + string="Price Derived From", + help="Room type whose price is the base of this one in the pricelists " + "without a rule of their own for it", + comodel_name="pms.room.type", + ondelete="restrict", + index=True, + check_pms_properties=True, + ) + price_derived_room_type_ids = fields.One2many( + string="Derived Room Types", + help="Room types whose price is derived from this one", + comodel_name="pms.room.type", + inverse_name="price_base_room_type_id", + check_pms_properties=True, + ) + price_derivation_type = fields.Selection( + string="Price Variation Type", + help="Whether the variation is a fixed amount or a percentage of the " + "price of the base room type", + selection=[("amount", "Fixed amount"), ("percent", "Percentage")], + default="amount", + required=True, + ) + price_derivation_value = fields.Float( + string="Price Variation", + help="Added to the price of the base room type, as an amount in the " + "currency of the pricelist or as a percentage of that price. Negative " + "values lower it", + digits="Product Price", + ) + price_derivation_rounding = fields.Float( + string="Price Rounding", + help="Round the prices derived by a percentage to a multiple of this " + "value, e.g. 1 for whole amounts. Zero rounds them to the currency", + digits="Product Price", + ) + price_derivation_pricelist_ids = fields.Many2many( + string="Derive in Pricelists", + help="Pricelists where the price is derived. Leave it empty to derive " + "it in all of them", + comodel_name="product.pricelist", + relation="pms_room_type_price_derivation_pricelist_rel", + column1="room_type_id", + column2="pricelist_id", + check_pms_properties=True, + ) def name_get(self): result = [] @@ -186,6 +237,51 @@ def _check_code_property_company_uniqueness(self): if other and other != rec: raise ValidationError(msg) + @api.constrains("price_base_room_type_id") + def _check_price_base_room_type_id(self): + """A price cannot be derived in a loop, nor through endless chains. + + Prices are derived on the fly, level by level, so a loop would never + end and the engine refuses to follow chains longer than + ``_price_derivation_max_depth``: refuse both when they are set up + rather than when selling. + """ + if not self._check_recursion(parent="price_base_room_type_id"): + raise ValidationError( + _( + "A room type cannot derive its price from itself, directly " + "or through other room types." + ) + ) + # All of them, also the archived ones and the ones out of the access + # of the current user + room_types = self.sudo().with_context(active_test=False) + for room_type in room_types | room_types._get_price_derivation_descendants(): + depth = room_type._get_price_derivation_depth() + if depth > self._price_derivation_max_depth: + raise ValidationError( + _( + "The price of %(room_type)s is derived through too many " + "levels.", + room_type=room_type.display_name, + ) + ) + + @api.constrains( + "price_derivation_type", "price_derivation_value", "price_derivation_rounding" + ) + def _check_price_derivation_values(self): + for room_type in self: + if ( + room_type.price_derivation_type == "percent" + and room_type.price_derivation_value <= -100 + ): + raise ValidationError( + _("A percentage variation must be greater than -100%.") + ) + if room_type.price_derivation_rounding < 0: + raise ValidationError(_("The price rounding can't be negative.")) + # ORM Overrides # TODO: Review Check product fields default values to room @api.model_create_multi @@ -235,3 +331,78 @@ def get_default_board_service(self, pms_property_id, room_type_id, pricelist_id) or False ) return board_service_default + + def _get_price_derivation(self, pricelist): + """The room types of self whose price is derived in the pricelist. + + A room type derives its price from its base room type in the + pricelists it is set to derive in, or in all of them when none is set. + This is only where the derivation applies: the engine still uses a + rule of the pricelist covering the room type whenever there is one. + Callers only rely on the result being empty or not, so the variation + can come from elsewhere (e.g. a rule per pricelist) in the future. + It is the same whoever asks: the configuration is read as stored. + """ + if not pricelist: + return self.browse() + # Read as stored: neither the access of the current user to the + # pricelists nor archiving them may empty a restriction, which would + # make the room type derive in all the pricelists + stored = self.sudo().with_context(active_test=False) + return self & stored.filtered( + lambda room_type: room_type.price_base_room_type_id + and ( + not room_type.price_derivation_pricelist_ids + or pricelist.id in room_type.price_derivation_pricelist_ids.ids + ) + ) + + def _derive_price(self, base_price, currency=None): + """Apply the price variation of the room type to the price of its base. + + The variation is an amount, in the currency of the pricelist, or a + percentage of the base price. Prices derived by a percentage are + rounded to the multiple set on the room type, if any, and the result + to the currency. It never goes below zero. + """ + self.ensure_one() + if self.price_derivation_type == "percent": + price = base_price * (1 + self.price_derivation_value / 100) + if self.price_derivation_rounding: + price = float_round( + price, precision_rounding=self.price_derivation_rounding + ) + else: + price = base_price + self.price_derivation_value + if currency: + price = currency.round(price) + return max(price, 0.0) + + def _get_price_derivation_descendants(self): + """The room types deriving their price from self, at any level. + + Breadth first, so every room type comes after the one its price is + derived from. The room types of self are not part of the result. + """ + descendants = self.browse() + derived = self.price_derived_room_type_ids + while derived: + # Guard against loops written bypassing the constraints + derived -= self | descendants + descendants |= derived + derived = derived.price_derived_room_type_ids + return descendants + + def _get_price_derivation_depth(self): + """How many room types the price goes through up to the root one. + + Counting stops past ``_price_derivation_max_depth``, which is all + callers need to know and keeps it finite on loops. + """ + self.ensure_one() + depth = 0 + base_room_type = self.price_base_room_type_id + while base_room_type and depth <= self._price_derivation_max_depth: + depth += 1 + base_room_type = base_room_type.price_base_room_type_id + return depth diff --git a/pms/models/product_pricelist_item.py b/pms/models/product_pricelist_item.py index d3b02c2266..8077386e1e 100644 --- a/pms/models/product_pricelist_item.py +++ b/pms/models/product_pricelist_item.py @@ -1,6 +1,7 @@ # Copyright 2017 Alexandre Díaz, Pablo Quesada, Darío Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import api, fields, models, tools +from odoo import _, api, fields, models, tools +from odoo.exceptions import UserError class ProductPricelistItem(models.Model): @@ -233,9 +234,19 @@ def _compute_base_consumption_price( :returns: base price, expressed in provided pricelist currency :rtype: float + + With no rule (empty self), a room type deriving its price from another + one gets it from the price of that room type instead of its list price. """ target_currency.ensure_one() + if not self: + price = self._compute_derived_room_type_price( + product, quantity, uom, date, target_currency, **kwargs + ) + if price is not None: + return price + rule_base = self.base or "list_price" if rule_base == "pricelist" and self.base_pricelist_id: price = self.base_pricelist_id._get_product_price( @@ -254,3 +265,84 @@ def _compute_base_consumption_price( price, target_currency, self.env.company, date, round=False ) return price + + def _compute_derived_room_type_price( + self, product, quantity, uom, date, target_currency, **kwargs + ): + """Price of a room type derived from the price of its base room type. + + Only reached when the pricelist has no rule at all for the product: + any rule covering the room type, its own items (overrides) as well as + generic rules, wins over the derivation. The base room type is priced + by the same pricelist, so it can derive its price in turn. + + :returns: the derived price, in the target currency, or None when the + product is not a room type deriving its price in the pricelist of + the computation (``kwargs["pricelist"]``) + """ + pricelist = kwargs.get("pricelist") + if ( + not pricelist + or not kwargs.get("consumption_date") + or not kwargs.get("pms_property_id") + or product._name != "product.product" + ): + return None + # Cheap lookup restricted to room types with a base one, as most + # products priced with no rule are not room types deriving their price. + # As superuser: the derivation is configuration, the price must not + # depend on what the user pricing can access + room_type = ( + self.env["pms.room.type"] + .sudo() + .with_context(active_test=False) + .search( + [ + ("product_id", "=", product.id), + ("price_base_room_type_id", "!=", False), + ], + limit=1, + ) + ) + if not room_type._get_price_derivation(pricelist): + return None + depth = kwargs.get("price_derivation_depth", 0) + if depth >= room_type._price_derivation_max_depth: + raise UserError( + _( + "The price of %(room_type)s is derived through too many levels.", + room_type=room_type.display_name, + ) + ) + base_product = room_type.price_base_room_type_id.product_id + base_price = pricelist._get_product_price( + base_product, + quantity, + uom=base_product.uom_id, + date=date, + price_derivation_depth=depth + 1, + **self._get_price_derivation_base_kwargs(kwargs), + ) + price = room_type._derive_price(base_price, pricelist.currency_id) + if base_product.uom_id != uom: + price = base_product.uom_id._compute_price(price, uom) + if pricelist.currency_id != target_currency: + price = pricelist.currency_id._convert( + price, target_currency, self.env.company, date, round=False + ) + return price + + @api.model + def _get_price_derivation_base_kwargs(self, kwargs): + """What the price of a base room type is computed with. + + The dimensions of the stay only: the base room type is priced for its + default occupancy, and whatever depends on the guests (occupancy, + children...) is applied afterwards to the derived room type, by the + computation of its own price. Extend it to forward other dimensions. + """ + return { + key: kwargs[key] + for key in ("consumption_date", "pms_property_id") + if key in kwargs + } diff --git a/pms/readme/CONFIGURE.rst b/pms/readme/CONFIGURE.rst index 940574e837..3c2fae83c6 100644 --- a/pms/readme/CONFIGURE.rst +++ b/pms/readme/CONFIGURE.rst @@ -1,3 +1,24 @@ 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. + +Derived room type prices +~~~~~~~~~~~~~~~~~~~~~~~~ + +A room type can take its price from another one, e.g. a Superior room costing +10 more than the Double one, so that the pricelists only need the prices of +the latter. Set it in the *Derived Price* section of the room type: + +* *Price Derived From*: the room type the price is taken from. It must be + available in all the properties of the room type, and it can take its price + from a third one in turn (e.g. Family = Superior + 20), up to ten levels. +* *Price Variation Type* and *Price Variation*: a fixed amount, in the currency + of the pricelist, or a percentage of the base price. Negative values lower + it, and the price never goes below zero. +* *Price Rounding*: the multiple the prices derived by a percentage are rounded + to, e.g. 1 for whole amounts. Zero rounds them to the currency. +* *Derive in Pricelists*: the pricelists the price is derived in. Leave it + empty to derive it in all of them. + +Mind that a derived room type with no rule of its own in a pricelist it does +not derive in gets its sales price there. diff --git a/pms/readme/USAGE.rst b/pms/readme/USAGE.rst index e6a897c6ad..b2391d39a2 100644 --- a/pms/readme/USAGE.rst +++ b/pms/readme/USAGE.rst @@ -1 +1,32 @@ To use this module, please, read the complete user guide at ``_. + +Working with derived room type prices +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The price of a room type is only derived from its base room type on the days +a pricelist has no rule of its own for it: + +* An item of the derived room type in the pricelist sets its price on its + dates: it overrides the derivation there, and the room type follows its base + again once the item is removed. +* Rules on a product category or on all products cover the room type as well, + so they win over the derivation. Keep them out of the pricelists the room + types derive in. +* The base room type is priced by the same pricelist, with its own rules or + derived in turn. With no rule at all, its sales price is the base. +* Pricelists based on another one through category or global rules (e.g. a + channel rate 10% above the standard one) apply on top of the derived price. + When they hold rules for the base room type only, the derived room type has + no rule there either: its price is derived from the price of the base room + type in that pricelist, which may not be what you want (e.g. a breakfast + surcharge per guest of the base room type). Give those pricelists rules for + the derived room types too, or use category or global rules there instead. +* The base price is computed for the date and the property of the stay only, + not for the guests: modules pricing by occupancy apply their rules to the + derived room type afterwards. +* Prices already set on reservations do not follow later changes of the base + price: use *Update prices* on the reservation to recompute them. +* Massive changes on pricelists leave the derived room types out of the price + changes when no room type is selected, so that they keep following their + base, except on the dates they have a price of their own, which is kept. + Select them explicitly to give them a price of their own. diff --git a/pms/static/description/index.html b/pms/static/description/index.html index f113520b35..78d36520e1 100644 --- a/pms/static/description/index.html +++ b/pms/static/description/index.html @@ -3,7 +3,7 @@ -PMS (Property Management System) +README.rst