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..a4ebcf6cff 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.26.0", "development_status": "Beta", "category": "Generic Modules/Property Management System", "website": "https://github.com/OCA/pms", @@ -38,6 +38,7 @@ "data/pms_modified_reservation_email_template.xml", "data/pms_cancelled_reservation_email_template.xml", "data/pms_precheckin_invitation_email_template.xml", + "data/pms_board_service_data.xml", "data/pms_data.xml", "data/traveller_report_paperformat.xml", "report/pms_folio.xml", @@ -54,8 +55,6 @@ "wizards/pms_booking_duplicate_views.xml", "views/pms_amenity_views.xml", "views/pms_amenity_type_views.xml", - "views/pms_board_service_views.xml", - "views/pms_board_service_room_type_views.xml", "views/pms_cancelation_rule_views.xml", "views/pms_checkin_partner_views.xml", "views/pms_ubication_views.xml", diff --git a/pms/data/pms_board_service_data.xml b/pms/data/pms_board_service_data.xml new file mode 100644 index 0000000000..e1d155d4b1 --- /dev/null +++ b/pms/data/pms_board_service_data.xml @@ -0,0 +1,38 @@ + + + + + Guest + always + radio + + + Adult + + 1 + + + Child + + 2 + + + + Room Only + RO + service + 0 + + + + + diff --git a/pms/data/pms_data.xml b/pms/data/pms_data.xml index e13cba8e5b..236d267c64 100644 --- a/pms/data/pms_data.xml +++ b/pms/data/pms_data.xml @@ -26,6 +26,10 @@ + diff --git a/pms/demo/pms_folio.xml b/pms/demo/pms_folio.xml index 7f07f2951f..ca13d10a05 100644 --- a/pms/demo/pms_folio.xml +++ b/pms/demo/pms_folio.xml @@ -15,7 +15,7 @@ 'checkin': (DateTime.today() + timedelta(days=14)), 'checkout': (DateTime.today() + timedelta(days=16)), 'adults': 2, - 'board_service_room_id': ref('pms_board_service_room_eco_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), 'partner_requests': 'Vegan breakfast', })]" /> @@ -85,7 +85,7 @@ 'checkin': (DateTime.today() + timedelta(days=14)), 'checkout': (DateTime.today() + timedelta(days=15)), 'adults': 3, - 'board_service_room_id': ref('pms_board_service_room_triple_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), })]" /> @@ -187,7 +187,7 @@ 'checkout': (DateTime.today() + timedelta(days=16)), 'adults': 1, 'children':1, - 'board_service_room_id': ref('pms_board_service_room_double_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), 'partner_requests': 'Vegan food.' })]" @@ -212,7 +212,7 @@ 'checkin': (DateTime.today() + timedelta(days=15)), 'checkout': (DateTime.today() + timedelta(days=16)), 'adults': 1, - 'board_service_room_id': ref('pms_board_service_room_triple_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), })]" /> @@ -292,7 +292,7 @@ 'checkout': (DateTime.today() + timedelta(days=17)), 'adults': 2, 'children':1, - 'board_service_room_id': ref('pms_board_service_room_triple_breakfast'), + 'board_service_id': ref('pms_board_service_breakfast'), 'partner_requests': 'Soy milf for breakfast.' })]" /> @@ -581,7 +581,7 @@ 'children': 1, 'arrival_hour': '12:00', 'departure_hour': '12:30', - 'board_service_room_id': ref('pms_board_service_room_double_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), 'partner_requests': 'allergic to nuts.' })]" /> @@ -666,7 +666,7 @@ 'checkin': (DateTime.today() + timedelta(days=23)), 'checkout': (DateTime.today() + timedelta(days=24)), 'adults': 1, - 'board_service_room_id': ref('pms_board_service_room_single_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), }), (0, 0, { 'pricelist_id': ref('product.list0'), @@ -674,7 +674,7 @@ 'checkin': (DateTime.today() + timedelta(days=23)), 'checkout': (DateTime.today() + timedelta(days=24)), 'adults': 1, - 'board_service_room_id': ref('pms_board_service_room_single_breakfast'), + 'board_service_id': ref('pms_board_service_breakfast'), 'sale_channel_origin_id': ref('pms.main_pms_sale_channel_phone') }), (0, 0, { @@ -683,7 +683,7 @@ 'checkin': (DateTime.today() + timedelta(days=23)), 'checkout': (DateTime.today() + timedelta(days=24)), 'adults': 1, - 'board_service_room_id': ref('pms_board_service_room_single_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), 'sale_channel_origin_id': ref('pms.main_pms_sale_channel_phone') })]" /> @@ -727,7 +727,7 @@ 'checkout': (DateTime.today() + timedelta(days=24)), 'adults': 2, 'children': 1, - 'board_service_room_id': ref('pms_board_service_room_triple_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), 'sale_channel_origin_id': ref('main_pms_sale_channel_mail') })]" /> @@ -755,7 +755,7 @@ 'adults': 1, 'arrival_hour': '17:00', 'departure_hour': '12:15', - 'board_service_room_id': ref('pms_board_service_room_single_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), }), (0, 0, { 'pricelist_id': ref('product.list0'), @@ -765,7 +765,7 @@ 'adults': 1, 'arrival_hour': '16:00', 'departure_hour': '11:15', - 'board_service_room_id': ref('pms_board_service_room_single_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), })]" /> @@ -796,7 +796,7 @@ 'adults': 1, 'arrival_hour': '17:00', 'departure_hour': '12:15', - 'board_service_room_id': ref('pms_board_service_room_double_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), 'partner_requests': 'Cold dinner 1st night' }), (0, 0, { @@ -858,7 +858,7 @@ 'checkin': (DateTime.today() + timedelta(days=25)), 'checkout': (DateTime.today() + timedelta(days=32)), 'adults': 1, - 'board_service_room_id': ref('pms_board_service_room_eco_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), 'partner_requests': 'Vegan food.' })]" /> @@ -887,7 +887,7 @@ 'checkin': (DateTime.today() + timedelta(days=25)), 'checkout': (DateTime.today() + timedelta(days=32)), 'adults': 1, - 'board_service_room_id': ref('pms_board_service_room_single_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), })]" /> @@ -914,7 +914,7 @@ 'checkin': (DateTime.today() + timedelta(days=25)), 'checkout': (DateTime.today() + timedelta(days=32)), 'adults': 1, - 'board_service_room_id': ref('pms_board_service_room_single_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), 'partner_requests': 'picnic for 1st lunch' })]" /> @@ -943,7 +943,7 @@ 'checkin': (DateTime.today() + timedelta(days=25)), 'checkout': (DateTime.today() + timedelta(days=31)), 'adults': 2, - 'board_service_room_id': ref('pms_board_service_room_double_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), }), (0, 0, { 'pricelist_id': ref('product.list0'), @@ -951,7 +951,7 @@ 'checkin': (DateTime.today() + timedelta(days=25)), 'checkout': (DateTime.today() + timedelta(days=32)), 'adults': 2, - 'board_service_room_id': ref('pms_board_service_room_double_half_board'), + 'board_service_id': ref('pms_board_service_half_board'), })]" /> @@ -975,7 +975,7 @@ 'checkout': (DateTime.today() + timedelta(days=27)), 'adults': 1, 'children': 2, - 'board_service_room_id': ref('pms_board_service_room_triple_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), 'partner_requests': 'Seafood meals are needed for at least one day' })]" /> @@ -1022,7 +1022,7 @@ 'children': 2, 'arrival_hour': '09:00', 'departure_hour': '11:00', - 'board_service_room_id': ref('pms_board_service_room_triple_full_board'), + 'board_service_id': ref('pms_board_service_full_board'), 'partner_requests': '1 extra towel needed.' })]" /> diff --git a/pms/demo/pms_master_data_no_update.xml b/pms/demo/pms_master_data_no_update.xml index 8fe01cd73e..30631fe487 100644 --- a/pms/demo/pms_master_data_no_update.xml +++ b/pms/demo/pms_master_data_no_update.xml @@ -7,10 +7,6 @@ Availability Plan Demo - - - - Alda Company @@ -326,6 +322,7 @@ 1 + 1 @@ -333,6 +330,7 @@ 1 + 1 @@ -340,6 +338,7 @@ 1 + 1 @@ -347,6 +346,7 @@ 1 + 1 @@ -354,6 +354,7 @@ 1 + 1 @@ -814,202 +815,79 @@ True True - - + + + BreakFast BB + 3.0 + service + False + True + True + True + after - + + Half Board HB + 11.0 + service + False + True + True + True - + Full Board FB + 19.0 + service + False + True + True + True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + name="attribute_line_ids" + eval="[(0, 0, { + 'attribute_id': ref('pms.product_attribute_board_guest'), + 'value_ids': [(6, 0, [ + ref('pms.product_attribute_value_board_guest_adult'), + ])], + })]" + /> - - - - + + + + @@ -3840,6 +3718,15 @@ + + diff --git a/pms/demo/pms_reservation.xml b/pms/demo/pms_reservation.xml index acc6fece84..e674ddd268 100644 --- a/pms/demo/pms_reservation.xml +++ b/pms/demo/pms_reservation.xml @@ -63,10 +63,7 @@ 1 - + done @@ -93,10 +90,7 @@ 1 - + done @@ -122,10 +116,7 @@ 1 - + done @@ -156,10 +147,7 @@ 1 - + done @@ -183,10 +171,7 @@ 1 - + done @@ -210,10 +195,7 @@ 1 - + done @@ -252,10 +234,7 @@ /> - + 1 - + done @@ -415,10 +391,7 @@ 1 - + done @@ -442,10 +415,7 @@ 1 - + done @@ -478,10 +448,7 @@ /> - + breakfast at 5:00 am @@ -503,10 +470,7 @@ /> - + 1 - + Breakfast to take away. @@ -540,10 +501,7 @@ 1 - + 2 - + 2 - + 2 - + Help needed with the luggage. @@ -776,10 +725,7 @@ 1 - + @@ -813,10 +759,7 @@ name="sale_channel_origin_id" ref="pms.main_pms_sale_channel_phone" /> - + @@ -849,10 +792,7 @@ 3 - + done @@ -891,10 +831,7 @@ /> - + 2 - + Celiac breakfast @@ -994,10 +928,7 @@ 3 - + 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/__init__.py b/pms/models/__init__.py index adf6e66a5b..43588592bf 100644 --- a/pms/models/__init__.py +++ b/pms/models/__init__.py @@ -5,7 +5,6 @@ from . import ir_http from . import ir_config_parameter -from . import pms_board_service_room_type from . import pms_property from . import res_users from . import pms_ubication @@ -23,19 +22,18 @@ from . import account_payment from . import pms_availability_plan from . import pms_availability_plan_rule +from . import pms_availability_plan_board_rule from . import pms_reservation_line 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 from . import pms_room_type_class from . import pms_room_closure_reason from . import pms_service_line -from . import pms_board_service -from . import pms_board_service_room_type_line -from . import pms_board_service_line from . import account_move_line from . import pms_cancelation_rule from . import folio_sale_line diff --git a/pms/models/pms_availability_plan.py b/pms/models/pms_availability_plan.py index d47b076e1d..8e2350f154 100644 --- a/pms/models/pms_availability_plan.py +++ b/pms/models/pms_availability_plan.py @@ -45,6 +45,13 @@ class PmsAvailabilityPlan(models.Model): inverse_name="availability_plan_id", check_pms_properties=True, ) + board_rule_ids = fields.One2many( + string="Board Service Rules", + help="Board services closed on some dates in the pricelists of the plan", + comodel_name="pms.availability.plan.board.rule", + inverse_name="availability_plan_id", + check_pms_properties=True, + ) active = fields.Boolean( help="If unchecked, it will allow you to hide the " diff --git a/pms/models/pms_availability_plan_board_rule.py b/pms/models/pms_availability_plan_board_rule.py new file mode 100644 index 0000000000..65f971eed8 --- /dev/null +++ b/pms/models/pms_availability_plan_board_rule.py @@ -0,0 +1,116 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + +from odoo import api, fields, models + + +class PmsAvailabilityPlanBoardRule(models.Model): + _name = "pms.availability.plan.board.rule" + _description = "Board service closing in an availability plan" + _order = ( + "availability_plan_id, pms_property_id, date_from, write_date desc, id desc" + ) + _check_pms_properties_auto = True + + availability_plan_id = fields.Many2one( + string="Availability Plan", + help="Availability plan whose pricelists the rule applies to", + required=True, + index=True, + comodel_name="pms.availability.plan", + ondelete="cascade", + check_pms_properties=True, + ) + pms_property_id = fields.Many2one( + string="Property", + help="Property the rule applies to", + required=True, + index=True, + comodel_name="pms.property", + ondelete="cascade", + check_pms_properties=True, + ) + board_service_id = fields.Many2one( + string="Board Service", + help="Board service the rule closes or opens", + required=True, + index=True, + comodel_name="product.template", + domain="[('is_board_service', '=', True)]", + ondelete="cascade", + check_pms_properties=True, + ) + date_from = fields.Date( + string="From", + help="First night the rule applies to", + required=True, + ) + date_to = fields.Date( + string="To", + help="Last night the rule applies to", + required=True, + ) + closed = fields.Boolean( + help="Whether the board service can't be sold on these nights. " + "Unchecked, it reopens what an earlier rule closes", + default=True, + ) + + _sql_constraints = [ + ( + "date_range", + "CHECK (date_to >= date_from)", + "The end date must be on or after the start date", + ), + ] + + @api.model + def _resolve_rules(self, availability_plan_id, pms_property_id, date_from, date_to): + """The rule that decides each night: rules can overlap, and the last + one written wins. + + TODO: once availability plans inherit from a parent plan, rank the + rules by the depth of their plan first, and by write_date within it. + + :return: {(board_service_id, date): rule}, a missing key means the + board service is open + """ + rules = self.search( + [ + ("availability_plan_id", "=", availability_plan_id), + ("pms_property_id", "=", pms_property_id), + ("date_from", "<=", date_to), + ("date_to", ">=", date_from), + ] + ).sorted(lambda rule: (rule.write_date, rule.id)) + winners = {} + for rule in rules: + night = max(rule.date_from, date_from) + last_night = min(rule.date_to, date_to) + while night <= last_night: + winners[(rule.board_service_id.id, night)] = rule + night += datetime.timedelta(days=1) + return winners + + @api.model + def _get_closed_board_services( + self, availability_plan_id, pms_property_id, checkin, checkout + ): + """Board services closed on any night of a stay""" + if checkout <= checkin: + return self.env["product.template"] + winners = self._resolve_rules( + availability_plan_id, + pms_property_id, + checkin, + checkout - datetime.timedelta(days=1), + ) + return self.env["product.template"].browse( + list( + { + board_id + for (board_id, _night), rule in winners.items() + if rule.closed + } + ) + ) diff --git a/pms/models/pms_board_service.py b/pms/models/pms_board_service.py deleted file mode 100644 index 7632e2d1b3..0000000000 --- a/pms/models/pms_board_service.py +++ /dev/null @@ -1,148 +0,0 @@ -# Copyright 2017 Dario Lodeiros -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - - -class PmsBoardService(models.Model): - _name = "pms.board.service" - _description = "Board Services" - _check_pms_properties_auto = True - - name = fields.Char( - string="Board Service Name", - help="Board Service Name", - required=True, - index=True, - size=64, - translate=True, - ) - default_code = fields.Char( - string="Board Service Code", - help="Unique Board Service identification code per property", - required=True, - ) - board_service_line_ids = fields.One2many( - string="Board Service Lines", - help="Services included in this Board Service", - comodel_name="pms.board.service.line", - inverse_name="pms_board_service_id", - ) - pms_property_ids = fields.Many2many( - string="Properties", - help="Properties with access to the element;" - " if not set, all properties can access", - required=False, - ondelete="restrict", - comodel_name="pms.property", - relation="pms_board_service_pms_property_rel", - column1="board_service_id", - column2="pms_property_id", - check_pms_properties=True, - ) - pms_board_service_room_type_ids = fields.One2many( - string="Board Services Room Type", - help="Board Services Room Type corresponding to this Board Service," - "One board service for several room types", - comodel_name="pms.board.service.room.type", - inverse_name="pms_board_service_id", - ) - amount = fields.Float( - help="Price for this Board Service. " - "It corresponds to the sum of his board service lines", - store=True, - digits=("Product Price"), - compute="_compute_board_amount", - ) - - show_detail_report = fields.Boolean( - help="True if you want that board service detail to be shown on the report", - ) - active = fields.Boolean( - default=True, - help="If unchecked, this board service will be archived and hidden from " - "selections without affecting historical records that reference it.", - ) - - def write(self, vals): - res = super().write(vals) - if "active" in vals: - new_state = vals["active"] - self.with_context(active_test=False).mapped("board_service_line_ids").write( - {"active": new_state} - ) - self.with_context(active_test=False).mapped( - "pms_board_service_room_type_ids" - ).write({"active": new_state}) - return res - - @api.depends("board_service_line_ids.amount") - def _compute_board_amount(self): - for record in self: - total = 0 - for service in record.board_service_line_ids: - total += service.amount - record.update({"amount": total}) - - @api.model - def get_unique_by_property_code(self, pms_property_id, default_code=None): - """ - :param pms_property_id: property ID - :param default_code: board service code (optional) - :return: - recordset of - - all the pms.board.service of the pms_property_id - if default_code not defined - - one or 0 pms.board.service if default_code defined - - ValidationError if more than one default_code found by - the same pms_property_id - """ - # TODO: similiar code as room.type -> unify - domain = [] - if default_code: - domain += ["&", ("default_code", "=", default_code)] - domain += [ - "|", - ("pms_property_ids", "in", pms_property_id), - ("pms_property_ids", "=", False), - ] - records = self.search(domain) - res, res_priority = {}, {} - for rec in records: - res_priority.setdefault(rec.default_code, -1) - priority = rec.pms_property_ids and 1 or 0 - if priority > res_priority[rec.default_code]: - res.setdefault(rec.default_code, rec.id) - res[rec.default_code], res_priority[rec.default_code] = rec.id, priority - elif priority == res_priority[rec.default_code]: - raise ValidationError( - _( - "Integrity error: There's multiple board services " - "with the same code %s and properties" - ) - % rec.default_code - ) - return self.browse(list(res.values())) - - @api.constrains("default_code", "pms_property_ids") - def _check_code_property_uniqueness(self): - # TODO: similiar code as room.type -> unify - msg = _( - "Already exists another Board Service with the same code and properties" - ) - for rec in self: - if not rec.pms_property_ids: - if self.search( - [ - ("id", "!=", rec.id), - ("default_code", "=", rec.default_code), - ("pms_property_ids", "=", False), - ] - ): - raise ValidationError(msg) - else: - for pms_property in rec.pms_property_ids: - other = rec.get_unique_by_property_code( - pms_property.id, rec.default_code - ) - if other and other != rec: - raise ValidationError(msg) diff --git a/pms/models/pms_board_service_line.py b/pms/models/pms_board_service_line.py deleted file mode 100644 index 93bf46bc16..0000000000 --- a/pms/models/pms_board_service_line.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright 2017 Dario Lodeiros -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - - -class PmsBoardServiceLine(models.Model): - _name = "pms.board.service.line" - _description = "Services on Board Service included" - _check_pms_properties_auto = True - - pms_board_service_id = fields.Many2one( - string="Board Service", - help="Board Service in which this line is included", - required=True, - index=True, - comodel_name="pms.board.service", - ondelete="cascade", - check_pms_properties=True, - ) - product_id = fields.Many2one( - string="Product", - help="Product associated with this board service line", - required=True, - index=True, - comodel_name="product.product", - check_pms_properties=True, - ) - pms_property_ids = fields.Many2many( - string="Properties", - help="Properties with access to the element;" - " if not set, all properties can access", - comodel_name="pms.property", - relation="pms_board_service_line_pms_property_rel", - column1="pms_board_service_line_id", - column2="pms_property_id", - store=True, - check_pms_properties=True, - ) - amount = fields.Float( - help="Price for this Board Service Line/Product", - default=lambda self: self._get_default_price(), - digits=("Product Price"), - ) - adults = fields.Boolean( - help="Apply service to adults", - default=False, - ) - children = fields.Boolean( - help="Apply service to children", - default=False, - ) - active = fields.Boolean( - default=True, - help="Lines of an archived board service are archived as well.", - ) - - def _get_default_price(self): - if self.product_id: - return self.product_id.list_price - - @api.onchange("product_id") - def onchange_product_id(self): - if self.product_id: - self.update({"amount": self.product_id.list_price}) - - @api.model_create_multi - def create(self, vals_list): - for vals in vals_list: - properties = False - if "pms_board_service_id" in vals: - board_service = self.env["pms.board.service"].browse( - vals["pms_board_service_id"] - ) - properties = board_service.pms_property_ids - if properties: - vals.update( - { - "pms_property_ids": properties, - } - ) - return super().create(vals_list) - - def write(self, vals): - properties = False - if "pms_board_service_id" in vals: - board_service = self.env["pms.board.service"].browse( - vals["pms_board_service_id"] - ) - properties = board_service.pms_property_ids - if properties: - vals.update( - { - "pms_property_ids": properties, - } - ) - return super().write(vals) - - @api.constrains("adults", "children") - def _check_adults_children(self): - for record in self: - if not record.adults and not record.children: - raise ValidationError(_("Adults or Children must be checked")) diff --git a/pms/models/pms_board_service_room_type.py b/pms/models/pms_board_service_room_type.py deleted file mode 100644 index 1e2d4a088f..0000000000 --- a/pms/models/pms_board_service_room_type.py +++ /dev/null @@ -1,177 +0,0 @@ -# Copyright 2017 Dario -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models -from odoo.exceptions import UserError - - -class PmsBoardServiceRoomType(models.Model): - _name = "pms.board.service.room.type" - _table = "pms_board_service_room_type_rel" - _rec_name = "pms_board_service_id" - _log_access = False - _description = "Board Service included in Room" - _check_pms_properties_auto = True - - pms_board_service_id = fields.Many2one( - string="Board Service", - help="Board Service corresponding to this Board Service Room Type", - required=True, - index=True, - comodel_name="pms.board.service", - ondelete="cascade", - check_pms_properties=True, - ) - pms_property_id = fields.Many2one( - string="Property", - help="Property with access to the element;" - " if not set, all property can access", - required=False, - index=True, - ondelete="restrict", - comodel_name="pms.property", - check_pms_properties=True, - store=True, - ) - pms_room_type_id = fields.Many2one( - string="Room Type", - help="Room Type for which this Board Service is available", - required=True, - index=True, - comodel_name="pms.room.type", - ondelete="cascade", - check_pms_properties=True, - ) - board_service_line_ids = fields.One2many( - string="Board Service Lines", - help="Services included in this Board Service", - comodel_name="pms.board.service.room.type.line", - inverse_name="pms_board_service_room_type_id", - required=True, - ) - amount = fields.Float( - help="Price for this Board Service. " - "It corresponds to the sum of his board service lines", - store=True, - digits=("Product Price"), - compute="_compute_board_amount", - ) - by_default = fields.Boolean( - string="Apply by Default", - help="Indicates if this board service is applied by default in the room type", - ) - pricelist_ids = fields.Many2many( - string="Pricelists", - help="Pricelists where this Board Service is available", - comodel_name="product.pricelist", - ) - active = fields.Boolean( - default=True, - help="If unchecked, this board service room type assignment will be " - "archived and hidden from selections without affecting historical " - "reservations that reference it.", - ) - - def write(self, vals): - if "pms_board_service_id" in vals and "board_service_line_ids" not in vals: - vals.update( - self.prepare_board_service_reservation_ids(vals["pms_board_service_id"]) - ) - res = super().write(vals) - if "active" in vals: - self.with_context(active_test=False).mapped("board_service_line_ids").write( - {"active": vals["active"]} - ) - return res - - @api.depends("board_service_line_ids.amount") - def _compute_board_amount(self): - for record in self: - total = 0 - for service in record.board_service_line_ids: - total += service.amount - record.update({"amount": total}) - - def name_get(self): - res = [] - for record in self: - name = "{} - {}".format( - record.pms_board_service_id.name, record.pms_room_type_id.name - ) - res.append((record.id, name)) - return res - - @api.constrains("by_default") - def constrains_duplicated_board_default(self): - for record in self: - if not record.by_default: - continue - default_boards = ( - record.pms_room_type_id.board_service_room_type_ids.filtered( - "by_default" - ) - ) - if any( - default_boards.filtered( - lambda board, record=record: board.id != record.id - and board.pms_property_id == record.pms_property_id - and board.pricelist_ids == record.pricelist_ids - ) - ): - raise UserError(_("""Only can set one default board service""")) - - def open_board_lines_form(self): - action = ( - self.env.ref("pms.action_pms_board_service_room_type_view").sudo().read()[0] - ) - action["views"] = [ - (self.env.ref("pms.pms_board_service_room_type_form").id, "form") - ] - action["res_id"] = self.id - action["target"] = "new" - return action - - def init(self): - self._cr.execute( - "SELECT indexname FROM pg_indexes WHERE indexname = %s", - ("pms_board_service_id_pms_room_type_id",), - ) - if not self._cr.fetchone(): - self._cr.execute( - "CREATE INDEX pms_board_service_id_pms_room_type_id \ - ON pms_board_service_room_type_rel \ - (pms_board_service_id, pms_room_type_id)" - ) - - @api.model_create_multi - def create(self, vals_list): - # properties = False - for vals in vals_list: - if "pms_board_service_id" in vals and "board_service_line_ids" not in vals: - vals.update( - self.prepare_board_service_reservation_ids( - vals["pms_board_service_id"] - ) - ) - return super().create(vals_list) - - @api.model - def prepare_board_service_reservation_ids(self, board_service_id): - """ - Prepare line to price products config - """ - cmds = [(5, 0, 0)] - board_service = self.env["pms.board.service"].browse(board_service_id) - for line in board_service.board_service_line_ids: - cmds.append( - ( - 0, - False, - { - "product_id": line.product_id.id, - "amount": line.amount, - "adults": line.adults, - "children": line.children, - }, - ) - ) - return {"board_service_line_ids": cmds} diff --git a/pms/models/pms_board_service_room_type_line.py b/pms/models/pms_board_service_room_type_line.py deleted file mode 100644 index c203db5c47..0000000000 --- a/pms/models/pms_board_service_room_type_line.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright 2017 Dario Lodeiros -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models -from odoo.exceptions import ValidationError - - -class PmsBoardServiceRoomTypeLine(models.Model): - _name = "pms.board.service.room.type.line" - _description = "Services on Board Service included in Room" - _check_pms_properties_auto = True - - # Fields declaration - pms_board_service_room_type_id = fields.Many2one( - string="Board Service Room", - help="Board Service Room Type in which this line is included", - required=True, - index=True, - comodel_name="pms.board.service.room.type", - ondelete="cascade", - ) - pms_property_id = fields.Many2one( - string="Property", - help="Property with access to the element;" - " if not set, all properties can access", - comodel_name="pms.property", - check_pms_properties=True, - related="pms_board_service_room_type_id.pms_property_id", - ) - product_id = fields.Many2one( - string="Product", - help="Product associated with this board service room type line", - comodel_name="product.product", - index=True, - readonly=True, - check_pms_properties=True, - domain="[('is_pms_available', '=', True)]", - ) - amount = fields.Float( - help="Price for this Board Service Room Type Line/Product", - default=lambda self: self._default_amount(), - digits=("Product Price"), - ) - adults = fields.Boolean( - help="Apply service to adults", - default=False, - ) - children = fields.Boolean( - help="Apply service to children", - default=False, - ) - active = fields.Boolean( - default=True, - help="Lines of an archived board service room type are archived as well.", - ) - - def _default_amount(self): - return self.product_id.list_price - - @api.constrains("adults", "children") - def _check_adults_children(self): - for record in self: - if not record.adults and not record.children: - raise ValidationError(_("Adults or Children must be checked")) 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 8a93d2df36..a21ae6beb7 100644 --- a/pms/models/pms_reservation.py +++ b/pms/models/pms_reservation.py @@ -72,23 +72,30 @@ class PmsReservation(models.Model): string="Sale Lines", copy=False, ) - board_service_room_id = fields.Many2one( + board_service_id = fields.Many2one( string="Board Service", - help="Board Service included in the room", + help="Board service included in the room. If not set, Room Only", readonly=False, store=True, - comodel_name="pms.board.service.room.type", - compute="_compute_board_service_room_id", - inverse="_inverse_board_service_room_id", + comodel_name="product.template", + domain="[('id', 'in', allowed_board_service_ids)]", + compute="_compute_board_service_id", + inverse="_inverse_board_service_id", index=True, tracking=True, 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", + ) # Manual override flag: # - False: system is allowed to auto-fill the field from the default rule. # - True: user has intervened (including clearing the value), # so do NOT auto-fill again. - board_service_room_manual = fields.Boolean( + board_service_manual = fields.Boolean( string="Board Service Manually Set", default=False, help=( @@ -356,12 +363,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, ) @@ -733,87 +737,39 @@ def _compute_check_adults(self): for record in self: record.check_adults = True - @api.depends( - "pricelist_id", - "room_type_id", - "pms_property_id", - "board_service_room_manual", - ) - def _compute_board_service_room_id(self): - """ - Compute default board service for the reservation. - - Behavior: - 1) If 'board_service_room_manual' is True, DO NOT auto-fill again. - This prevents the default from coming back after the user - leared it. - 2) Otherwise, if we have the needed inputs (pricelist + room type), - fetch the configured default and assign it when appropriate. - - Optional safety: - - If manual mode is True but the currently selected board service - is no longer compatible with the new room type, we clear it. - This avoids inconsistent data. - """ + @api.depends("pricelist_id", "pms_property_id", "checkin", "checkout") + def _compute_allowed_board_service_ids(self): + room_only = self.env["product.template"]._room_only_board_service() for reservation in self: - # 1) Respect the user's manual decision - # (including "I want it empty"). - if reservation.board_service_room_manual: - # Optional: if the current value becomes incompatible - # with the room type, clear it to avoid inconsistencies. - if ( - reservation.board_service_room_id - and reservation.board_service_room_id.pms_room_type_id - and reservation.room_type_id - and ( - reservation.board_service_room_id.pms_room_type_id - != reservation.room_type_id - ) - ): - reservation.board_service_room_id = False - continue - - # 2) Auto-fill only when we can compute the default. - if reservation.pricelist_id and reservation.room_type_id: - board_service_default = ( - reservation.room_type_id.get_default_board_service( - pms_property_id=reservation.pms_property_id.id, - room_type_id=reservation.room_type_id.id, - pricelist_id=reservation.pricelist_id.id, + if reservation.pricelist_id and reservation.pms_property_id: + reservation.allowed_board_service_ids = ( + reservation.pricelist_id._get_available_board_services( + reservation.pms_property_id, + reservation.checkin, + reservation.checkout, ) + - room_only ) - - # Assign default if empty OR if current value - # doesn't match the room type. - if not reservation.board_service_room_id or ( - reservation.board_service_room_id.pms_room_type_id - and ( - reservation.board_service_room_id.pms_room_type_id - != reservation.room_type_id - ) - ): - reservation.board_service_room_id = ( - board_service_default.id if board_service_default else False - ) - else: - # If we cannot compute (missing inputs), keep it empty. - if not reservation.board_service_room_id: - reservation.board_service_room_id = False + reservation.allowed_board_service_ids = False - def _inverse_board_service_room_id(self): - """ - Inverse method executed when the user manually - the computed field. - - Key idea: - - Any manual edit (setting a value OR clearing it) is considered - user decision. - - Once the user has decided, we set a flag so future recomputes - won't restore the default automatically. - """ + @api.depends("pricelist_id", "pms_property_id", "board_service_manual") + def _compute_board_service_id(self): + """The default board service of the pricelist, if it sells it for the + stay, unless the current one is still sold. Once the user chooses a + board service (even none), it's kept""" for reservation in self: - reservation.board_service_room_manual = True + if reservation.board_service_manual: + continue + allowed = reservation.allowed_board_service_ids + if reservation.board_service_id in allowed: + continue + default = reservation.pricelist_id.default_board_service_id + reservation.board_service_id = default if default in allowed else False + + def _inverse_board_service_id(self): + for reservation in self: + reservation.board_service_manual = True @api.depends("preferred_room_id") def _compute_room_type_id(self): @@ -941,44 +897,54 @@ def _compute_reservation_line_ids(self): if not reservation.reservation_line_ids: reservation.reservation_line_ids = False - @api.depends("board_service_room_id", "adults", "children") + @api.depends("board_service_id", "adults", "children") def _compute_board_service_ids(self): + """Keep a service for each product the board service makes the guests + consume: the ones already there are kept untouched (with their prices), + the missing ones are added and the ones no longer consumed removed. + Board services added by other means than the board service of the + reservation (without guest type) are left alone""" if self.env.context.get("skip_compute_board_service_ids", False): return for reservation in self: - board_services = [] - old_board_lines = reservation.service_ids.filtered_domain( - [ - ("is_board_service", "=", True), - ] + if reservation.board_service_id: + consumed = reservation.board_service_id._get_board_service_products( + reservation.adults, reservation.children + ) + else: + consumed = [] + board_services = reservation.service_ids.filtered( + lambda service: service.is_board_service and service.board_guest_type ) - # Avoid recalculating services if the boardservice has not changed - if ( - old_board_lines - and reservation.board_service_room_id - == reservation._origin.board_service_room_id - ): - return - if reservation.board_service_room_id: - board = self.env["pms.board.service.room.type"].browse( - reservation.board_service_room_id.id + to_keep = board_services.filtered( + lambda service, consumed=consumed: ( + service.product_id, + service.board_guest_type, ) - for line in board.board_service_line_ids: - if (reservation.adults and line.adults) or ( - reservation.children and line.children - ): - data = { - "product_id": line.product_id.id, - "is_board_service": True, - "folio_id": reservation.folio_id.id, - "reservation_id": reservation.id, - "board_service_line_id": line.id, - } - board_services.append((0, False, data)) - reservation.service_ids -= old_board_lines - reservation.service_ids = board_services - elif old_board_lines: - reservation.service_ids -= old_board_lines + in consumed + ) + kept = [ + (service.product_id, service.board_guest_type) for service in to_keep + ] + new_services = [ + ( + 0, + False, + { + "product_id": product.id, + "is_board_service": True, + "board_guest_type": guest_type, + "folio_id": reservation.folio_id.id, + "reservation_id": reservation.id, + }, + ) + for product, guest_type in consumed + if (product, guest_type) not in kept + ] + if board_services - to_keep: + reservation.service_ids -= board_services - to_keep + if new_services: + reservation.service_ids = new_services @api.depends("partner_id", "agency_id") def _compute_pricelist_id(self): @@ -1031,7 +997,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 ( @@ -1044,6 +1012,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: @@ -2055,6 +2027,7 @@ def name_get(self): @api.model_create_multi def create(self, vals_list): + reservation_states = [] for vals in vals_list: if vals.get("folio_id"): folio = self.env["pms.folio"].browse(vals["folio_id"]) @@ -2127,12 +2100,11 @@ def create(self, vals_list): ) # Avoid send state field in vals, with the propouse to # use action_confirm or action_cancel methods - reservation_state = False - if "state" in vals: - reservation_state = vals["state"] - vals.pop("state") + reservation_states.append(vals.pop("state", False)) records = super().create(vals_list) - for record in records: + for record, vals, reservation_state in zip( + records, vals_list, reservation_states, strict=True + ): record._check_capacity() if ( record.preconfirm and record.state == "draft" @@ -2149,7 +2121,7 @@ def create(self, vals_list): "service_ids": tourist_tax_services_cmds, } ) - return record + return records def write(self, vals): if ( @@ -2215,9 +2187,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 @@ -2271,7 +2244,7 @@ def _check_services(self, vals): # If we create a reservation with board service, compute_service_ids dont run # (compute with readonly to False), and # we must force it to compute the services linked with the board service: - if "board_service_room_id" in vals: + if "board_service_id" in vals: self._compute_board_service_ids() def get_folios_to_update_channel(self, vals): @@ -2311,6 +2284,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 faa7b44a02..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. @@ -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, ) ) 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/models/pms_room_type.py b/pms/models/pms_room_type.py index fba64f3cb1..294dd0b5cb 100644 --- a/pms/models/pms_room_type.py +++ b/pms/models/pms_room_type.py @@ -47,13 +47,6 @@ class PmsRoomType(models.Model): index=True, check_pms_properties=True, ) - board_service_room_type_ids = fields.One2many( - string="Board Services", - help="Board Service included in room type", - comodel_name="pms.board.service.room.type", - inverse_name="pms_room_type_id", - check_pms_properties=True, - ) room_amenity_ids = fields.Many2many( string="Room Type Amenities", help="List of amenities included in room type", @@ -73,6 +66,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 +128,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,22 +271,34 @@ def get_room_type_capacity(self, pms_property_id): ).mapped("capacity") return min(capacities) if any(capacities) else 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( + 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( [ - ("pms_room_type_id", "=", room_type_id), - ("by_default", "=", True), - ("pms_property_id", "=", pms_property_id), - ] + ("pricelist_id", "=", pricelist.id), + ("room_type_id", "=", self.id), + ], + limit=1, ) - board_service_default = ( - board_services_candidates.filtered( - lambda service: pricelist_id in service.pricelist_ids.ids - ) - or board_services_candidates.filtered( - lambda service: not service.pricelist_ids - ) - or False - ) - return board_service_default + + 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) diff --git a/pms/models/pms_service.py b/pms/models/pms_service.py index 6aeb84d908..b90c2af313 100644 --- a/pms/models/pms_service.py +++ b/pms/models/pms_service.py @@ -8,6 +8,8 @@ from odoo import _, api, fields, models +from .product_product import BOARD_GUEST_TYPES + class PmsService(models.Model): _name = "pms.service" @@ -137,11 +139,10 @@ class PmsService(models.Model): is_board_service = fields.Boolean( help="Indicates if the service is part of a board service", ) - board_service_line_id = fields.Many2one( - string="Board Service Line", - help="Board Service Line in which this service is included", - comodel_name="pms.board.service.room.type.line", - check_pms_properties=True, + board_guest_type = fields.Selection( + string="Board Service Guests", + help="Guests of the reservation the board service is counted by", + selection=BOARD_GUEST_TYPES, ) # Non-stored related field to allow portal user to # see the image of the product he has ordered @@ -580,19 +581,13 @@ def _service_day_qty(self): # TODO: Pass per_person to service line from product default_per_person # When the user modifies the quantity avoid overwriting if self.product_id.per_person: - if self.is_board_service: - qty = ( - self.reservation_id.adults - if self.board_service_line_id.adults - else 0 - ) - qty += ( - self.reservation_id.children - if self.board_service_line_id.children - else 0 - ) + reservation = self.reservation_id + if self.board_guest_type == "children": + qty = reservation.children + elif self.board_guest_type == "all": + qty = reservation.adults + reservation.children else: - qty = self.reservation_id.adults + qty = reservation.adults return qty def _get_price_unit_line(self, date=False): @@ -605,7 +600,6 @@ def _get_price_unit_line(self, date=False): partner = origin.partner_id pricelist = origin.pricelist_id product = self.product_id.with_context( - board_service_line_id=self.board_service_line_id.id, property=origin.pms_property_id.id, ) price = pricelist._get_product_price( @@ -614,7 +608,6 @@ def _get_price_unit_line(self, date=False): partner=partner.id, consumption_date=date, pms_property_id=origin.pms_property_id.id, - board_service_line_id=self.board_service_line_id.id, ) return self.env["account.tax"]._fix_tax_included_price_company( price, diff --git a/pms/models/product_pricelist.py b/pms/models/product_pricelist.py index b78c346163..2cbf0cc2d8 100644 --- a/pms/models/product_pricelist.py +++ b/pms/models/product_pricelist.py @@ -71,6 +71,33 @@ 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, + ) + board_service_ids = fields.Many2many( + string="Board Services", + help="Board services sold with this pricelist. " + "Without Room Only, every reservation must include one of them", + comodel_name="product.template", + relation="product_pricelist_board_service_rel", + column1="pricelist_id", + column2="product_tmpl_id", + domain="[('is_board_service', '=', True)]", + default=lambda self: self.env["product.template"]._room_only_board_service(), + ) + default_board_service_id = fields.Many2one( + string="Default Board Service", + help="Board service of the reservations of this pricelist unless " + "another one is chosen. If not set, Room Only", + comodel_name="product.template", + domain="[('id', 'in', board_service_ids), ('is_board_service', '=', True)]", + ondelete="restrict", + ) def _get_applicable_rules_domain(self, products, date, **kwargs): domain = super()._get_applicable_rules_domain(products, date, **kwargs) @@ -158,6 +185,51 @@ def _compute_price_rule(self, products, qty, uom=None, date=False, **kwargs): return results + @api.constrains("is_pms_available", "board_service_ids", "default_board_service_id") + def _check_default_board_service(self): + room_only = self.env["product.template"]._room_only_board_service() + for record in self.filtered("is_pms_available"): + default = record.default_board_service_id + if default == room_only: + raise ValidationError( + _( + "Room Only can't be the default board service, " + "leave the default empty instead" + ) + ) + if default and default not in record.board_service_ids: + raise ValidationError( + _( + "The default board service of %s must be one of " + "its board services" + ) + % record.name + ) + if not default and room_only not in record.board_service_ids: + raise ValidationError( + _( + "%s doesn't sell Room Only, so it needs a " + "default board service" + ) + % record.name + ) + + def _get_available_board_services(self, pms_property, checkin, checkout): + """Board services this pricelist sells for a stay in a property: its + own ones, less the ones its availability plan closes on any night""" + self.ensure_one() + board_services = self.board_service_ids.filtered( + lambda board: not board.pms_property_ids + or pms_property in board.pms_property_ids + ) + if self.availability_plan_id and checkin and checkout: + board_services -= self.env[ + "pms.availability.plan.board.rule" + ]._get_closed_board_services( + self.availability_plan_id.id, pms_property.id, checkin, checkout + ) + return board_services + @api.constrains("is_pms_available", "availability_plan_id") def _check_is_pms_available(self): for record in self: diff --git a/pms/models/product_pricelist_item.py b/pms/models/product_pricelist_item.py index d3b02c2266..dd64131d72 100644 --- a/pms/models/product_pricelist_item.py +++ b/pms/models/product_pricelist_item.py @@ -29,13 +29,6 @@ class ProductPricelistItem(models.Model): string="End Date Consumption", help="End date to apply daily pricelist items", ) - board_service_room_type_id = fields.Many2one( - string="Board Service", - help="Specify a Board services on Room Types.", - comodel_name="pms.board.service.room.type", - index=True, - check_pms_properties=True, - ) pricelist_id = fields.Many2one( string="Pricelist", help="Pricelist in which this item is included", @@ -51,17 +44,6 @@ class ProductPricelistItem(models.Model): index=True, check_pms_properties=True, ) - allowed_board_service_product_ids = fields.Many2many( - string="Allowed board service products", - comodel_name="product.product", - compute="_compute_allowed_board_service_product_ids", - ) - - allowed_board_service_room_type_ids = fields.Many2many( - string="Allowed board service room types", - comodel_name="pms.board.service.room.type", - compute="_compute_allowed_board_service_room_type_ids", - ) has_properties = fields.Boolean(compute="_compute_has_properties", store=True) @api.depends("pms_property_ids") @@ -70,56 +52,6 @@ def _compute_has_properties(self): for record in self: record.has_properties = len(record.pms_property_ids) != 0 - @api.depends("board_service_room_type_id") - def _compute_allowed_board_service_product_ids(self): - for record in self: - domain = [] - if record.board_service_room_type_id: - domain.append( - ( - "id", - "in", - record.board_service_room_type_id.board_service_line_ids.mapped( - "product_id" - ).ids, - ) - ) - allowed_board_service_product_ids = self.env["product.product"].search( - domain - ) - record.allowed_board_service_product_ids = allowed_board_service_product_ids - - @api.depends("product_id") - def _compute_allowed_board_service_room_type_ids(self): - for record in self: - allowed_board_service_room_type_ids = [] - all_board_service_room_type_ids = self.env[ - "pms.board.service.room.type" - ].search([]) - if record.product_id: - for board_service_room_type_id in all_board_service_room_type_ids: - if ( - record.product_id - in board_service_room_type_id.board_service_line_ids.mapped( - "product_id" - ) - ): - allowed_board_service_room_type_ids.append( - board_service_room_type_id.id - ) - else: - allowed_board_service_room_type_ids = ( - all_board_service_room_type_ids.ids - ) - domain = [] - if allowed_board_service_room_type_ids: - domain.append(("id", "in", allowed_board_service_room_type_ids)) - record.allowed_board_service_room_type_ids = ( - self.env["pms.board.service.room.type"].search(domain) - if domain - else False - ) - def write(self, vals): # Check that the price in product room types are not # minor that min price in room type defined @@ -218,7 +150,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/models/product_product.py b/pms/models/product_product.py index 51e4cf4fba..25715eaa13 100644 --- a/pms/models/product_product.py +++ b/pms/models/product_product.py @@ -1,39 +1,41 @@ from odoo import _, api, fields, models from odoo.exceptions import ValidationError +BOARD_GUEST_TYPES = [ + ("adults", "Adults"), + ("children", "Children"), + ("all", "Adults and children"), +] + class ProductProduct(models.Model): _inherit = "product.product" - board_price = fields.Float( - string="Board Service Price", - help="Get price on board service", - digits="Product Price", - compute="_compute_board_price", - ) - room_type_id = fields.Many2one( string="Room Type", comodel_name="pms.room.type", compute="_compute_room_type_id", ) + board_guest_type = fields.Selection( + string="Board Service Guests", + help="Guests that consume this variant of a board service, " + "given by its value of the Guest attribute", + selection=BOARD_GUEST_TYPES, + compute="_compute_board_guest_type", + ) - @api.depends_context("consumption_date", "board_service_line_id") - # pylint: disable=W8110 - def _compute_product_price(self): - super()._compute_product_price() - - @api.depends_context("consumption_date", "board_service_line_id") - def _compute_board_price(self): + @api.depends("product_template_attribute_value_ids") + def _compute_board_guest_type(self): + guest_types = { + self.env.ref("pms.product_attribute_value_board_guest_adult"): "adults", + self.env.ref("pms.product_attribute_value_board_guest_child"): "children", + } for record in self: - if self._context.get("board_service_line_id"): - record.board_price = ( - self.env["pms.board.service.room.type.line"] - .browse(self._context.get("board_service_line_id")) - .amount - ) - else: - record.board_price = False + values = ( + record.product_template_attribute_value_ids.product_attribute_value_id + ) + guest_value = values.filtered(lambda v: v in guest_types) + record.board_guest_type = guest_types.get(guest_value, "all") def _compute_room_type_id(self): for rec in self: @@ -50,10 +52,3 @@ def _compute_room_type_id(self): rec.room_type_id = room_type else: rec.room_type_id = False - - def price_compute( - self, price_type, uom=False, currency=False, company=None, date=False - ): - if self._context.get("board_service_line_id"): - price_type = "board_price" - return super().price_compute(price_type, uom, currency, company, date) diff --git a/pms/models/product_template.py b/pms/models/product_template.py index 64dfba2c13..d5326f85f0 100644 --- a/pms/models/product_template.py +++ b/pms/models/product_template.py @@ -5,7 +5,8 @@ import babel -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError class ProductTemplate(models.Model): @@ -62,6 +63,18 @@ class ProductTemplate(models.Model): help="Indicates if that product is available in PMS", default=True, ) + is_board_service = fields.Boolean( + string="Is a board service", + help="Indicates that the product is a board service (breakfast, half " + "board...) that pricelists sell and reservations include. With the " + "Guest attribute, adults and children consume their own variant", + default=False, + ) + board_show_detail_report = fields.Boolean( + string="Show board service detail on report", + help="If unchecked, the folio report adds the board service to the " + "price of the room instead of showing it on its own lines", + ) is_tourist_tax = fields.Boolean( string="Is tourist tax", help="Indicates if that product is a tourist tax", @@ -132,6 +145,80 @@ def write(self, vals): vals["consumed_on"] = "before" return super().write(vals) + @api.constrains("is_board_service", "attribute_line_ids") + def _check_board_service_attributes(self): + board_services = self.filtered("is_board_service") + if not board_services: + return + guest_attribute = self.env.ref("pms.product_attribute_board_guest") + room_only = self._room_only_board_service() + for record in board_services: + attributes = record.attribute_line_ids.attribute_id + if record == room_only and attributes: + raise ValidationError(_("Room Only can't have variants")) + if attributes - guest_attribute: + raise ValidationError( + _( + "A board service can only have the attribute %s, " + "otherwise a reservation can't tell which variant " + "a guest consumes" + ) + % guest_attribute.name + ) + + @api.constrains("is_board_service") + def _check_room_only_is_board_service(self): + if self._room_only_board_service() in self.filtered( + lambda record: not record.is_board_service + ): + raise ValidationError(_("Room Only must be a board service")) + + @api.ondelete(at_uninstall=False) + def _unlink_except_room_only(self): + if self._room_only_board_service() in self: + raise UserError(_("Room Only can't be deleted")) + + @api.model + def _room_only_board_service(self): + return ( + self.env.ref( + "pms.product_template_board_room_only", raise_if_not_found=False + ) + or self.browse() + ) + + def _get_board_service_products(self, adults, children): + """The products a stay with this board service consumes, each one with + the guests it is counted by: the variants of the guests that are + present. Room Only consumes nothing. + + :return: list of (product.product, guest type) + """ + self.ensure_one() + if self == self._room_only_board_service(): + return [] + guests = {"adults": adults, "children": children, "all": adults + children} + return [ + (product, product.board_guest_type) + for product in self.product_variant_ids + if guests[product.board_guest_type] + ] + + def _get_board_service_price(self, pricelist, pms_property, date, adults, children): + """Price of one night of this board service for the guests of a room""" + self.ensure_one() + guests = {"adults": adults, "children": children, "all": adults + children} + price = 0.0 + for product, guest_type in self._get_board_service_products(adults, children): + unit_price = pricelist._get_product_price( + product=product, + quantity=1, + consumption_date=date, + pms_property_id=pms_property.id, + ) + price += unit_price * (guests[guest_type] if product.per_person else 1) + return price + def _get_mmdd_selection(self): lang = self.env.lang or "en_US" days_by_month = { 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/report/pms_folio_templates.xml b/pms/report/pms_folio_templates.xml index cb3d457ab6..682b7b1cea 100644 --- a/pms/report/pms_folio_templates.xml +++ b/pms/report/pms_folio_templates.xml @@ -140,7 +140,7 @@ - - PMS Board Service Property Rule - - - - ['|',('pms_property_ids','=',False),('pms_property_ids', 'in', - user.pms_property_ids.ids)] - - - - PMS Board Service Line Property Rule - - - - ['|',('pms_property_ids','=',False),('pms_property_ids', 'in', - user.pms_property_ids.ids)] - - - - PMS Board Service Room Type Property Rule - - - - ['|',('pms_property_id','=',False),('pms_property_id', 'in', - user.pms_property_ids.ids)] - - PMS Cancelation Rule Property Rule @@ -169,6 +142,14 @@ user.pms_property_ids.ids)] + + PMS Board Service Availability Rule Property Rule + + + + [('pms_property_id', 'in', user.pms_property_ids.ids)] + + PMS Service Property Rule 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/__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/common.py b/pms/tests/common.py index 3909870aa4..08a363ea99 100644 --- a/pms/tests/common.py +++ b/pms/tests/common.py @@ -43,7 +43,62 @@ def setUpClass(cls): "default_code": "RTC1", } ) + cls.board_service_room_only = cls.env.ref( + "pms.product_template_board_room_only" + ) for pricelist in cls.env["product.pricelist"].search([]): if not pricelist.availability_plan_id: - pricelist.availability_plan_id = cls.availability_plan1.id - pricelist.is_pms_available = True + pricelist.write( + { + "availability_plan_id": cls.availability_plan1.id, + "is_pms_available": True, + "board_service_ids": [(4, cls.board_service_room_only.id)], + } + ) + + @classmethod + def _create_board_service(cls, name, guests=("adult",), **vals): + """A board service consumed by the given guests: one variant for + each of ("adult", "child"), or a single one for every guest if empty""" + attribute_lines = [] + if guests: + attribute_lines = [ + ( + 0, + 0, + { + "attribute_id": cls.env.ref( + "pms.product_attribute_board_guest" + ).id, + "value_ids": [ + ( + 6, + 0, + [ + cls.env.ref( + f"pms.product_attribute_value_board_guest_{guest}" + ).id + for guest in guests + ], + ) + ], + }, + ) + ] + return cls.env["product.template"].create( + dict( + { + "name": name, + "type": "service", + "is_board_service": True, + "attribute_line_ids": attribute_lines, + }, + **vals, + ) + ) + + @staticmethod + def _board_service_variant(board_service, guest_type): + return board_service.product_variant_ids.filtered( + lambda product: product.board_guest_type == guest_type + ) diff --git a/pms/tests/test_pms_board_service.py b/pms/tests/test_pms_board_service.py index 7e85814781..c6249650a0 100644 --- a/pms/tests/test_pms_board_service.py +++ b/pms/tests/test_pms_board_service.py @@ -1,7 +1,8 @@ -# Copyright 2021 Eric Antones # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime -from odoo.exceptions import ValidationError +from odoo import fields +from odoo.exceptions import UserError, ValidationError from .common import TestPms @@ -10,476 +11,465 @@ class TestBoardService(TestPms): @classmethod def setUpClass(cls): super().setUpClass() - cls.company2 = cls.env["res.company"].create( + cls.room_type = cls.env["pms.room.type"].create( { - "name": "Company 2", + "pms_property_ids": [cls.pms_property1.id], + "name": "Triple", + "default_code": "TRP", + "class_id": cls.room_type_class1.id, } ) - cls.pms_property3 = cls.env["pms.property"].create( + cls.room = cls.env["pms.room"].create( { - "name": "Property 3", - "company_id": cls.company2.id, - "default_pricelist_id": cls.pricelist1.id, + "pms_property_id": cls.pms_property1.id, + "name": "Triple 101", + "room_type_id": cls.room_type.id, + "capacity": 3, } ) + cls.partner = cls.env["res.partner"].create({"name": "Guest"}) + cls.sale_channel = cls.env["pms.sale.channel"].create( + {"name": "Door", "channel_type": "direct"} + ) + # Breakfast with its own price for children + cls.breakfast = cls._create_board_service( + "Breakfast", + guests=("adult", "child"), + list_price=10, + per_day=True, + per_person=True, + ) + cls.adult_breakfast = cls._board_service_variant(cls.breakfast, "adults") + cls.child_breakfast = cls._board_service_variant(cls.breakfast, "children") + cls.pricelist1.write( + {"is_pms_available": True, "board_service_ids": [(4, cls.breakfast.id)]} + ) + cls.checkin = fields.date.today() + datetime.timedelta(days=10) + cls.checkout = cls.checkin + datetime.timedelta(days=2) - def test_create_bs_one_company_inconsistent_code(self): - """ - Creation of board service with the same code as an existing one - belonging to the same property should fail. - - PRE: - board service bs1 exists - - board_service1 has code c1 - - board_service1 has pms_property1 - - pms_property1 has company company1 - ACT: - create a new board_service2 - - board_service2 has code c1 - - board_service2 has pms_property1 - - pms_property1 has company company1 - POST: - Integrity error: the room type already exists - - board_service2 not created - """ - # ARRANGE - # board_service1 - self.env["pms.board.service"].create( - { - "name": "Board service bs1", - "default_code": "c1", - "pms_property_ids": [(6, 0, [self.pms_property1.id])], - } - ) - # ACT & ASSERT - with self.assertRaises( - ValidationError, msg="The board service has been created and it shouldn't" - ): - # board_service2 - self.env["pms.board.service"].create( + def _create_reservation(self, **vals): + return self.env["pms.reservation"].create( + dict( { - "name": "Board service bs2", - "default_code": "c1", - "pms_property_ids": [(6, 0, [self.pms_property1.id])], - } + "checkin": self.checkin, + "checkout": self.checkout, + "room_type_id": self.room_type.id, + "pms_property_id": self.pms_property1.id, + "partner_id": self.partner.id, + "pricelist_id": self.pricelist1.id, + "sale_channel_origin_id": self.sale_channel.id, + "adults": 2, + }, + **vals, ) + ) - def test_create_bs_several_companies_inconsistent_code(self): - """ - Creation of board service with properties and one of its - properties has the same code on its board services should fail. + def _board_services(self, reservation): + return { + (service.product_id, service.board_guest_type): set( + service.service_line_ids.mapped("day_qty") + ) + for service in reservation.service_ids.filtered("is_board_service") + } - PRE: - board service bs1 exists - - board_service1 has code c1 - - board_service1 has property pms_property1 - - pms_property1 has company company1 - ACT: - create a new board_service2 - - board_service2 has code c1 - - board_service2 has property pms_property1, pms_property2, - pms_property3 - - pms_property1, pms_property2 has company company1 - - pms_property3 has company company2 - POST: - Integrity error: the board service already exists - - board_service2 not created + # CONSUMPTION + def test_each_guest_consumes_its_variant(self): """ - # ARRANGE - self.pms_property2 = self.env["pms.property"].create( - { - "name": "Property 2", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } + Adults and children consume the variant of the board service for + them, each one counted by its own guests. + """ + # ACT + reservation = self._create_reservation( + adults=2, children=1, board_service_id=self.breakfast.id ) - # board_service1 - self.env["pms.board.service"].create( + # ASSERT + self.assertEqual( + self._board_services(reservation), { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": [(6, 0, [self.pms_property1.id])], - } + (self.adult_breakfast, "adults"): {2}, + (self.child_breakfast, "children"): {1}, + }, ) - # ACT & ASSERT - with self.assertRaises( - ValidationError, msg="The board service has been created and it shouldn't" - ): - # board_service2 - self.env["pms.board.service"].create( - { - "name": "Board service bs2", - "default_code": "c1", - "pms_property_ids": [ - ( - 6, - 0, - [ - self.pms_property1.id, - self.pms_property2.id, - self.pms_property3.id, - ], - ) - ], - } - ) - def test_search_bs_code_same_company_several_properties(self): + def test_board_service_without_guest_attribute_for_every_guest(self): """ - Checks the search for a board service by code when the board service - belongs to properties of the same company - - PRE: - board service bs1 exists - - board_service1 has code c1 - - board_service1 has 2 properties pms_property1 and pms_property2 - - pms_property_1 and pms_property2 have the same company company1 - ACT: - search board service with code c1 and pms_property1 - - pms_property1 has company company1 - POST: - only board_service1 board service found + A board service without the Guest attribute is a single product that + counts adults and children. """ # ARRANGE - self.pms_property2 = self.env["pms.property"].create( - { - "name": "Property 2", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } - ) - board_service1 = self.env["pms.board.service"].create( - { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": [ - (6, 0, [self.pms_property1.id, self.pms_property2.id]) - ], - } + breakfast = self._create_board_service( + "Breakfast for all", guests=(), per_day=True, per_person=True ) # ACT - board_services = self.env["pms.board.service"].get_unique_by_property_code( - self.pms_property1.id, "c1" + reservation = self._create_reservation( + adults=2, children=1, board_service_id=breakfast.id ) # ASSERT self.assertEqual( - board_services.id, - board_service1.id, - "Expected board service not found", + self._board_services(reservation), + {(breakfast.product_variant_id, "all"): {3}}, ) - def test_search_bs_code_several_companies_several_properties_not_found(self): + def test_adults_only_board_service_leaves_children_out(self): """ - Checks the search for a board service by code when the board service - belongs to properties with different companies - - PRE: - board service bs1 exists - - board_service1 has code c1 - - board_service1 has 2 properties pms_property1 and pms_property3 - - pms_property1 and pms_property3 have different companies - - pms_property1 have company company1 and pms_property3 have company2 - ACT: - search board service with code c1 and property pms_property1 - - pms_property1 has company company1 - POST: - only board_service1 room type found + A board service only with the Adult value isn't consumed by children. """ # ARRANGE - bs1 = self.env["pms.board.service"].create( - { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": [ - (6, 0, [self.pms_property1.id, self.pms_property3.id]) - ], - } + half_board = self._create_board_service( + "Half Board", guests=("adult",), per_day=True, per_person=True ) # ACT - board_services = self.env["pms.board.service"].get_unique_by_property_code( - self.pms_property1.id, "c1" + reservation = self._create_reservation( + adults=2, children=1, board_service_id=half_board.id ) # ASSERT - self.assertEqual(board_services.id, bs1.id, "Expected board service not found") + self.assertEqual( + self._board_services(reservation), + {(half_board.product_variant_id, "adults"): {2}}, + ) - def test_search_bs_code_no_result(self): + def test_children_variant_follows_the_children_of_the_reservation(self): """ - Search for a specific board service code and its property. - The board service exists but not in the property given. - - PRE: - board_service1 exists - - board_service1 has code c1 - - board_service1 with 2 properties pms_property1 and pms_property2 - - pms_property1 and pms_property2 have same company company1 - ACT: - search board service with code c1 and property pms_property3 - - pms_property3 have company company2 - POST: - no room type found + Adding children adds the variant for them without touching the adults + one, and removing them removes it. """ # ARRANGE - self.pms_property2 = self.env["pms.property"].create( - { - "name": "Property 2", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } + reservation = self._create_reservation( + adults=2, board_service_id=self.breakfast.id ) - # board_service1 - self.env["pms.board.service"].create( + adult_service = reservation.service_ids.filtered("is_board_service") + # ACT & ASSERT + reservation.children = 1 + self.assertEqual( + self._board_services(reservation), { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": [ - (6, 0, [self.pms_property1.id, self.pms_property2.id]) - ], - } + (self.adult_breakfast, "adults"): {2}, + (self.child_breakfast, "children"): {1}, + }, ) + self.assertIn(adult_service, reservation.service_ids) + reservation.children = 0 + self.assertEqual( + self._board_services(reservation), + {(self.adult_breakfast, "adults"): {2}}, + ) + self.assertIn(adult_service, reservation.service_ids) + + def test_room_only_never_consumes_anything(self): + """ + Room Only exists to sell rooms without board service: it never adds a + service to a reservation. + """ # ACT - board_services = self.env["pms.board.service"].get_unique_by_property_code( - self.pms_property3.id, "c1" + reservation = self._create_reservation( + board_service_id=self.board_service_room_only.id ) # ASSERT - self.assertFalse( - board_services, "Board service found but it should not have found any" - ) + self.assertFalse(reservation.service_ids.filtered("is_board_service")) - def test_search_bs_code_present_all_companies_and_properties(self): + # PRICES + def test_variants_priced_by_the_pricelist(self): """ - Search for a specific board service and its property. - The board service exists without property, then - the search foundS the result. - - PRE: - board_service1 exists - - board_service1 has code c1 - - board_service1 properties are null - ACT: - search board service with code c1 and property pms_property1 - - pms_property1 have company company1 - POST: - only board_service1 board service found + Each variant of a board service takes the price of the pricelist. """ # ARRANGE - board_service1 = self.env["pms.board.service"].create( - { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": False, - } - ) + for product, price in ((self.adult_breakfast, 12), (self.child_breakfast, 6)): + self.env["product.pricelist.item"].create( + { + "pricelist_id": self.pricelist1.id, + "applied_on": "0_product_variant", + "product_id": product.id, + "compute_price": "fixed", + "fixed_price": price, + } + ) # ACT - board_services = self.env["pms.board.service"].get_unique_by_property_code( - self.pms_property1.id, "c1" + reservation = self._create_reservation( + adults=2, children=1, board_service_id=self.breakfast.id ) # ASSERT + prices = { + service.product_id: set(service.service_line_ids.mapped("price_unit")) + for service in reservation.service_ids.filtered("is_board_service") + } self.assertEqual( - board_services.id, - board_service1.id, - "Expected board service not found", + prices, {self.adult_breakfast: {12}, self.child_breakfast: {6}} + ) + self.assertEqual( + self.breakfast._get_board_service_price( + self.pricelist1, self.pms_property1, self.checkin, 2, 1 + ), + 30, ) - def test_search_bs_code_several_companies_several_properties(self): - """ - Search for a specific board service and its property. - There is one board service without properties and - another one with the same code that belongs to 2 properties - (from different companies) - The search founds only the board service that match the - property given. - - PRE: - board_service1 exists - - board_service1 has code c1 - - board_service1 has 2 properties pms_property1 and pms_property3 - - pms_property1 and pms_property2 have the same company company1 - - board service board_service2 exists - - board_service2 has code c1 - - board_service2 has no properties - ACT: - search board service with code c1 and property pms_property1 - - pms_property1 have company company1 - POST: - only board_service1 board service found + def test_children_price_extra(self): + """ + Without pricelist rules, the variant for children takes the price of + the board service plus the extra of its Guest value. """ # ARRANGE - board_service1 = self.env["pms.board.service"].create( - { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": [ - (6, 0, [self.pms_property1.id, self.pms_property3.id]) - ], - } - ) - # board_service2 - self.env["pms.board.service"].create( - { - "name": "Board service bs2", - "default_code": "c1", - "pms_property_ids": False, - } - ) + self.breakfast.attribute_line_ids.product_template_value_ids.filtered( + lambda value: value.product_attribute_value_id + == self.env.ref("pms.product_attribute_value_board_guest_child") + ).price_extra = -4 # ACT - board_services = self.env["pms.board.service"].get_unique_by_property_code( - self.pms_property1.id, "c1" + reservation = self._create_reservation( + adults=2, children=1, board_service_id=self.breakfast.id ) # ASSERT - self.assertEqual( - board_services.id, - board_service1.id, - "Expected board service not found", + child_service = reservation.service_ids.filtered( + lambda service: service.product_id == self.child_breakfast ) + self.assertEqual(set(child_service.service_line_ids.mapped("price_unit")), {6}) - def test_search_bs_code_same_companies_several_properties(self): - """ - Search for a specific board service and its property. - There is one board service without properties and - another one with the same code that belongs to 2 properties - (same company). - The search founds only the board service that match the - property given. - - PRE: - board_service1 exists - - board_service1 has code c1 - - board_service1 has property pms_property1 - - pms_property1 have the company company1 - - board service board_service2 exists - - board_service2 has code c1 - - board_service2 has no properties - ACT: - search board service with code c1 and pms_property2 - - pms_property2 have company company1 - POST: - only board_service2 board service found + # CONFIGURATION + def test_board_service_only_takes_the_guest_attribute(self): + """ + A board service can't have other attributes: a reservation couldn't + tell which variant a guest consumes. """ # ARRANGE - self.pms_property2 = self.env["pms.property"].create( + size = self.env["product.attribute"].create( { - "name": "Property 2", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, + "name": "Size", + "value_ids": [(0, 0, {"name": "S"}), (0, 0, {"name": "L"})], } ) - self.env["pms.board.service"].create( + # ACT & ASSERT + with self.assertRaisesRegex(ValidationError, "can only have the attribute"): + self.breakfast.attribute_line_ids = [ + ( + 0, + 0, + { + "attribute_id": size.id, + "value_ids": [(6, 0, size.value_ids.ids)], + }, + ) + ] + + def test_room_only_is_protected(self): + """ + Room Only can't be deleted, have variants or stop being a board service. + """ + with self.assertRaisesRegex( + UserError, "can't be deleted" + ), self.env.cr.savepoint(): + self.board_service_room_only.unlink() + with self.assertRaisesRegex( + ValidationError, "must be a board service" + ), self.env.cr.savepoint(): + self.board_service_room_only.is_board_service = False + with self.assertRaisesRegex( + ValidationError, "can't have variants" + ), self.env.cr.savepoint(): + self.board_service_room_only.attribute_line_ids = [ + ( + 0, + 0, + { + "attribute_id": self.env.ref( + "pms.product_attribute_board_guest" + ).id, + "value_ids": [ + ( + 6, + 0, + [ + self.env.ref( + "pms.product_attribute_value_board_guest_adult" + ).id + ], + ) + ], + }, + ) + ] + + def test_new_pricelist_sells_room_only(self): + """ + A new pricelist sells rooms without board service. + """ + pricelist = self.env["product.pricelist"].create( { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": [(6, 0, [self.pms_property1.id])], + "name": "New pricelist", + "availability_plan_id": self.availability_plan1.id, + "is_pms_available": True, } ) - board_service2 = self.env["pms.board.service"].create( + self.assertEqual(pricelist.board_service_ids, self.board_service_room_only) + + def test_pricelist_default_board_service_must_be_sold(self): + """ + The default board service of a pricelist must be one of its own. + """ + half_board = self._create_board_service("Half Board") + with self.assertRaisesRegex(ValidationError, "must be one of"): + self.pricelist1.default_board_service_id = half_board + + def test_pricelist_without_room_only_needs_default(self): + """ + A pricelist that doesn't sell Room Only must say which board service + its reservations include. + """ + with self.assertRaisesRegex( + ValidationError, "needs a default" + ), self.env.cr.savepoint(): + self.pricelist1.board_service_ids = [(6, 0, self.breakfast.ids)] + self.pricelist1.write( { - "name": "Board service bs2", - "default_code": "c1", - "pms_property_ids": False, + "board_service_ids": [(6, 0, self.breakfast.ids)], + "default_board_service_id": self.breakfast.id, } ) + + def test_room_only_cant_be_the_default(self): + """ + Room Only is the absence of a board service, so it isn't a default. + """ + with self.assertRaisesRegex(ValidationError, "leave the default empty"): + self.pricelist1.default_board_service_id = self.board_service_room_only + + # DEFAULT OF THE RESERVATION + def test_reservation_takes_the_default_of_the_pricelist(self): + """ + A reservation includes the default board service of its pricelist. + """ + # ARRANGE + self.pricelist1.default_board_service_id = self.breakfast # ACT - board_services = self.env["pms.board.service"].get_unique_by_property_code( - self.pms_property2.id, "c1" - ) + reservation = self._create_reservation() # ASSERT - self.assertEqual( - board_services.id, - board_service2.id, - "Expected board service not found", - ) + self.assertEqual(reservation.board_service_id, self.breakfast) + self.assertTrue(reservation.service_ids.filtered("is_board_service")) - def test_search_bs_code_no_properties(self): + def test_reservation_keeps_the_board_service_chosen(self): """ - Search for a specific board service and its property. - There is one board service without properties and - another one with the same code that belongs to one property. - The search founds only the board service that match the - property given that it's not the same as the 2nd one. - - PRE: - board_service1 exists - - board_service1 has code c1 - - board_service1 has property pms_property1 - - pms_property1 have the company company1 - - board service board_service2 exists - - board_service2 has code c1 - - board_service2 has no properties - ACT: - search board service with code c1 and property pms_property3 - - pms_property3 have company company2 - POST: - only board_service2 board service found + Once chosen, even to sell Room Only, the board service of the + reservation doesn't come back to the default of the pricelist. """ # ARRANGE - # board_service1 - self.env["pms.board.service"].create( - { - "name": "Board service bs1", - "default_code": "c1", - "pms_property_ids": [(6, 0, [self.pms_property1.id])], - } - ) - board_service2 = self.env["pms.board.service"].create( + self.pricelist1.default_board_service_id = self.breakfast + reservation = self._create_reservation() + pricelist2 = self.env["product.pricelist"].create( { - "name": "Board service bs2", - "default_code": "c1", - "pms_property_ids": False, + "name": "Pricelist 2", + "availability_plan_id": self.availability_plan1.id, + "is_pms_available": True, + "board_service_ids": [ + (6, 0, (self.board_service_room_only | self.breakfast).ids) + ], + "default_board_service_id": self.breakfast.id, } ) # ACT - board_services = self.env["pms.board.service"].get_unique_by_property_code( - self.pms_property3.id, "c1" - ) + reservation.board_service_id = False + reservation.pricelist_id = pricelist2 # ASSERT - self.assertEqual( - board_services.id, - board_service2.id, - "Expected board service not found", - ) + self.assertTrue(reservation.board_service_manual) + self.assertFalse(reservation.board_service_id) + self.assertFalse(reservation.service_ids.filtered("is_board_service")) - def test_archive_board_service_propagates_to_children(self): + def test_default_closed_for_the_stay_is_not_applied(self): """ - Archiving a board service must cascade ``active=False`` to its - lines and to its room-type assignments (and their lines), so that - the archived regime fully disappears from operative selections - while historical references remain in place. - - PRE: - board_service bs1 is active - - bs1 has one line (bsl) - - bs1 has one room-type assignment (bsrt) with one line (bsrtl) - ACT: - bs1.active = False - POST: - bsl.active == False - - bsrt.active == False - - bsrtl.active == False - - reactivating bs1 propagates back to all descendants + The default of the pricelist isn't applied to a stay with a night on + which the availability plan closes it. """ # ARRANGE - product = self.env["product.product"].create( - {"name": "Bs Product", "is_pms_available": True} - ) - room_type = self.env["pms.room.type"].create( - { - "pms_property_ids": [(6, 0, [self.pms_property1.id])], - "name": "Room Type Archive Test", - "default_code": "RTAT", - "class_id": self.room_type_class1.id, - "list_price": 30.0, - } - ) - board_service = self.env["pms.board.service"].create( + self.pricelist1.default_board_service_id = self.breakfast + self.env["pms.availability.plan.board.rule"].create( { - "name": "Bs Archive Test", - "default_code": "BSAT", - "pms_property_ids": [(6, 0, [self.pms_property1.id])], - "board_service_line_ids": [ - (0, 0, {"product_id": product.id, "adults": True}), - ], + "availability_plan_id": self.availability_plan1.id, + "pms_property_id": self.pms_property1.id, + "board_service_id": self.breakfast.id, + "date_from": self.checkin + datetime.timedelta(days=1), + "date_to": self.checkin + datetime.timedelta(days=1), } ) - bsrt = self.env["pms.board.service.room.type"].create( + # ACT + reservation = self._create_reservation() + # ASSERT + self.assertFalse(reservation.board_service_id) + self.assertNotIn(self.breakfast, reservation.allowed_board_service_ids) + + # CLOSINGS + def _close(self, board_service, date_from, date_to, closed=True): + return self.env["pms.availability.plan.board.rule"].create( { - "pms_board_service_id": board_service.id, - "pms_room_type_id": room_type.id, + "availability_plan_id": self.availability_plan1.id, "pms_property_id": self.pms_property1.id, + "board_service_id": board_service.id, + "date_from": date_from, + "date_to": date_to, + "closed": closed, } ) - # ACT — archive - board_service.active = False + def _available(self, checkin, checkout): + return self.pricelist1._get_available_board_services( + self.pms_property1, checkin, checkout + ) - # ASSERT — propagation downwards - bs_line = board_service.with_context(active_test=False).board_service_line_ids - bsrt_line = bsrt.with_context(active_test=False).board_service_line_ids - self.assertFalse(bs_line.active, "Board service line was not archived") - self.assertFalse( - bsrt.with_context(active_test=False).active, - "Board service room type was not archived", - ) - self.assertFalse( - bsrt_line.active, - "Board service room type line was not archived", + def test_closing_room_only_forces_a_board_service(self): + """ + Closing Room Only on some dates leaves only the board services. + """ + # ARRANGE + day = datetime.timedelta(days=1) + self._close(self.board_service_room_only, self.checkin, self.checkin + day) + # ACT & ASSERT + self.assertEqual(self._available(self.checkin, self.checkout), self.breakfast) + self.assertEqual( + self._available(self.checkout, self.checkout + day), + self.board_service_room_only | self.breakfast, ) - # ACT — reactivate - board_service.with_context(active_test=False).active = True + def test_closing_on_checkout_night_does_not_apply(self): + """ + The night of the checkout isn't part of the stay. + """ + # ARRANGE + self._close(self.breakfast, self.checkout, self.checkout) + # ACT & ASSERT + self.assertIn(self.breakfast, self._available(self.checkin, self.checkout)) - # ASSERT — propagation back - self.assertTrue(bs_line.active, "Board service line was not reactivated") - self.assertTrue(bsrt.active, "Board service room type was not reactivated") - self.assertTrue( - bsrt_line.active, - "Board service room type line was not reactivated", + def test_later_rule_reopens_a_closing(self): + """ + The last rule written decides each night, so a rule not closed + reopens what an earlier one closes. + """ + # ARRANGE + day = datetime.timedelta(days=1) + self._close(self.breakfast, self.checkin - 10 * day, self.checkout + 10 * day) + self._close(self.breakfast, self.checkin, self.checkout - day, closed=False) + # ACT & ASSERT + self.assertIn(self.breakfast, self._available(self.checkin, self.checkout)) + self.assertNotIn( + self.breakfast, self._available(self.checkin, self.checkout + day) + ) + + def test_board_service_of_another_property_is_not_available(self): + """ + A pricelist only sells, in a property, the board services available + for it. + """ + # ARRANGE + pms_property2 = self.env["pms.property"].create( + { + "name": "Property 2", + "company_id": self.company1.id, + "default_pricelist_id": self.pricelist1.id, + } + ) + dinner = self._create_board_service( + "Dinner", pms_property_ids=[pms_property2.id] + ) + self.pricelist1.board_service_ids = [(4, dinner.id)] + # ACT & ASSERT + self.assertNotIn(dinner, self._available(self.checkin, self.checkout)) + self.assertIn( + dinner, + self.pricelist1._get_available_board_services( + pms_property2, self.checkin, self.checkout + ), ) diff --git a/pms/tests/test_pms_booking_engine.py b/pms/tests/test_pms_booking_engine.py index d3250803c9..3be783886b 100644 --- a/pms/tests/test_pms_booking_engine.py +++ b/pms/tests/test_pms_booking_engine.py @@ -696,35 +696,15 @@ def test_price_total_with_board_service(self): checkin = fields.date.today() checkout = fields.date.today() + datetime.timedelta(days=1) - self.product_test1 = self.env["product.product"].create( - { - "name": "Test Product 1", - "per_day": True, - "consumed_on": "after", - } - ) - self.board_service_test = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TBS", - } - ) - self.env["pms.board.service.line"].create( - { - "pms_board_service_id": self.board_service_test.id, - "product_id": self.product_test1.id, - "amount": 8, - "adults": True, - } - ) - self.board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.test_room_type_double.id, - "pms_board_service_id": self.board_service_test.id, - "pms_property_id": self.pms_property1.id, - } - ) - # self.board_service_room_type.flush_recordset() + self.board_service_test = self._create_board_service( + "Test Board Service", + default_code="TBS", + list_price=8, + per_day=True, + per_person=True, + consumed_on="after", + ) + self.pricelist1.board_service_ids = [(4, self.board_service_test.id)] # ACT booking_engine = self.env["pms.booking.engine"].create( { @@ -742,21 +722,15 @@ def test_price_total_with_board_service(self): ) lines_availability_test[0].rooms_selected_qty = 1 - lines_availability_test[ - 0 - ].board_service_room_id = self.board_service_room_type.id + lines_availability_test[0].board_service_id = self.board_service_test self.test_room_type_double.list_price = 25 room_price = self.test_room_type_double.list_price days = (checkout - checkin).days - board_service_price = self.board_service_test.amount - room_capacity = self.test_room_type_double.get_room_type_capacity( - self.pms_property1.id - ) - expected_price = room_price * days + ( - board_service_price * room_capacity * days - ) + board_service_price = self.board_service_test.list_price + adults = lines_availability_test[0].adults + expected_price = room_price * days + (board_service_price * adults * days) # ASSERT self.assertEqual( @@ -776,34 +750,15 @@ def _test_board_service_discount(self): checkin = fields.date.today() checkout = fields.date.today() + datetime.timedelta(days=1) - self.product_test1 = self.env["product.product"].create( - { - "name": "Test Product 1", - "per_day": True, - "consumed_on": "after", - } - ) - self.board_service_test = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TBS", - } - ) - self.env["pms.board.service.line"].create( - { - "pms_board_service_id": self.board_service_test.id, - "product_id": self.product_test1.id, - "amount": 8, - "adults": True, - } - ) - self.board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.test_room_type_double.id, - "pms_board_service_id": self.board_service_test.id, - "pms_property_id": self.pms_property1.id, - } + self.board_service_test = self._create_board_service( + "Test Board Service", + default_code="TBS", + list_price=8, + per_day=True, + per_person=True, + consumed_on="after", ) + self.pricelist1.board_service_ids = [(4, self.board_service_test.id)] discount = 15 booking_engine = self.env["pms.booking.engine"].create( @@ -822,9 +777,7 @@ def _test_board_service_discount(self): lambda r: r.room_type_id.id == self.test_room_type_double.id ) lines_availability_test[0].rooms_selected_qty = 1 - lines_availability_test[ - 0 - ].board_service_room_id = self.board_service_room_type.id + lines_availability_test[0].board_service_id = self.board_service_test # ACT booking_engine.create_folio() diff --git a/pms/tests/test_pms_folio_invoice.py b/pms/tests/test_pms_folio_invoice.py index cc76801d2d..9583dc6ee8 100644 --- a/pms/tests/test_pms_folio_invoice.py +++ b/pms/tests/test_pms_folio_invoice.py @@ -476,33 +476,8 @@ def test_price_invoice_board_service(self): Test create and invoice from the Folio, and check the related amounts with board service linked """ - self.product1 = self.env["product.product"].create( - { - "name": "Test Product 1", - } - ) - - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Test Board Service 1", - "default_code": "CB1", - } - ) - self.board_service_line1 = self.env["pms.board.service.line"].create( - { - "product_id": self.product1.id, - "pms_board_service_id": self.board_service1.id, - "amount": 10, - "adults": True, - } - ) - - self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service1.id, - "pms_property_id": self.property.id, - } + self.board_service1 = self._create_board_service( + "Test Board Service 1", default_code="CB1", list_price=10 ) self.reservation1 = self.env["pms.reservation"].create( { @@ -512,7 +487,7 @@ def test_price_invoice_board_service(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.partner_id.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -534,33 +509,8 @@ def test_qty_invoiced_board_service(self): Test create and invoice from the Folio, and check qty invoiced with board service linked. """ - self.product1 = self.env["product.product"].create( - { - "name": "Test Product 1", - } - ) - - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Test Board Service 1", - "default_code": "CB1", - } - ) - self.board_service_line1 = self.env["pms.board.service.line"].create( - { - "product_id": self.product1.id, - "pms_board_service_id": self.board_service1.id, - "amount": 10, - "adults": True, - } - ) - - self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service1.id, - "pms_property_id": self.property.id, - } + self.board_service1 = self._create_board_service( + "Test Board Service 1", default_code="CB1", list_price=10 ) self.reservation1 = self.env["pms.reservation"].create( { @@ -570,7 +520,7 @@ def test_qty_invoiced_board_service(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.partner_id.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -594,33 +544,8 @@ def test_qty_to_invoice_board_service(self): Test create and invoice from the Folio, and check qty to invoice with board service linked """ - self.product1 = self.env["product.product"].create( - { - "name": "Test Product 1", - } - ) - - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Test Board Service 1", - "default_code": "CB1", - } - ) - self.board_service_line1 = self.env["pms.board.service.line"].create( - { - "product_id": self.product1.id, - "pms_board_service_id": self.board_service1.id, - "amount": 10, - "adults": True, - } - ) - - self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service1.id, - "pms_property_id": self.property.id, - } + self.board_service1 = self._create_board_service( + "Test Board Service 1", default_code="CB1", list_price=10 ) self.reservation1 = self.env["pms.reservation"].create( { @@ -630,7 +555,7 @@ def test_qty_to_invoice_board_service(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.partner_id.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) diff --git a/pms/tests/test_pms_folio_sale_line.py b/pms/tests/test_pms_folio_sale_line.py index ae35e24094..39dba88e9f 100644 --- a/pms/tests/test_pms_folio_sale_line.py +++ b/pms/tests/test_pms_folio_sale_line.py @@ -54,28 +54,11 @@ def setUpClass(cls): "consumed_on": "after", } ) - cls.board_service_test = cls.board_service = cls.env[ - "pms.board.service" - ].create( - { - "name": "Test Board Service", - "default_code": "TPS", - } - ) - cls.env["pms.board.service.line"].create( - { - "pms_board_service_id": cls.board_service_test.id, - "product_id": cls.product_test1.id, - "amount": 8, - "adults": True, - } - ) - cls.board_service_room_type = cls.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": cls.room_type_double.id, - "pms_board_service_id": cls.board_service_test.id, - "pms_property_id": cls.pms_property1.id, - } + cls.board_service_test = cls.board_service = cls._create_board_service( + "Test Board Service", + default_code="TPS", + list_price=8, + per_day=True, ) cls.extra_service = cls.env["pms.service"].create( { @@ -434,7 +417,7 @@ def test_comp_fsl_board_services_all_same_group(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -473,7 +456,7 @@ def test_comp_fsl_board_services_different_prices(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -513,7 +496,7 @@ def test_comp_fsl_board_services_different_discount(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -556,7 +539,7 @@ def test_comp_fsl_board_services_different_cancel_discount(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -598,7 +581,7 @@ def test_comp_fsl_board_services_one_full_cancel_discount(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -641,7 +624,7 @@ def test_comp_fsl_board_services_increase_stay(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -684,7 +667,7 @@ def test_comp_fsl_board_services_decrease_stay(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -728,7 +711,7 @@ def test_comp_fsl_board_services_same_stay(self): "adults": 2, "room_type_id": self.room_type_double.id, "partner_id": self.env.ref("base.res_partner_12").id, - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) diff --git a/pms/tests/test_pms_multiproperty.py b/pms/tests/test_pms_multiproperty.py index e6af2ef632..6b1933d155 100644 --- a/pms/tests/test_pms_multiproperty.py +++ b/pms/tests/test_pms_multiproperty.py @@ -255,443 +255,66 @@ def test_check_property_availability_room_type(self): "pms_property_id" ] - # BOARD SERVICE LINE - def test_pms_bsl_product_property_integrity(self): + # BOARD SERVICE + def test_reservation_board_service_property_integrity(self): """ - Creation of a board service line without property, of a product - only available for a specific property. + A reservation can't include a board service only available for + another property. """ # ARRANGE - product1 = self.env["product.product"].create( - {"name": "Product", "pms_property_ids": [self.pms_property1.id]} + board_service = self._create_board_service( + "Board Service", pms_property_ids=[self.pms_property2.id] ) - board_service1 = self.env["pms.board.service"].create( + room_type = self.env["pms.room.type"].create( { - "name": "Board Service", - "default_code": "CB", - } - ) - # ACT & ASSERT - with self.assertRaises( - UserError, msg="Board service line shouldnt be created." - ): - self.env["pms.board.service.line"].create( - { - "product_id": product1.id, - "pms_board_service_id": board_service1.id, - "adults": True, - } - ) - - def test_pms_bsl_board_service_property_integrity(self): - """ - Creation of a board service line without property, of board service - only available for a specific property. - """ - # ARRANGE - pms_property2 = self.env["pms.property"].create( - { - "name": "Property 1", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } - ) - product1 = self.env["product.product"].create( - {"name": "Product", "pms_property_ids": [self.pms_property1.id]} - ) - - board_service1 = self.env["pms.board.service"].create( - { - "name": "Board Service", - "default_code": "CB", - "pms_property_ids": [pms_property2.id], - } - ) - # ACT & ASSERT - with self.assertRaises( - UserError, msg="Board service line shouldnt be created." - ): - self.env["pms.board.service.line"].create( - { - "product_id": product1.id, - "pms_board_service_id": board_service1.id, - "adults": True, - } - ) - - def test_pms_bsl_board_service_line_prop_integrity(self): - """ - Creation of a board service line with a specific property, - of board service without property. - """ - # ARRANGE - pms_property2 = self.env["pms.property"].create( - { - "name": "Property 1", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } - ) - product1 = self.env["product.product"].create( - {"name": "Product", "pms_property_ids": [self.pms_property1.id]} - ) - board_service1 = self.env["pms.board.service"].create( - { - "name": "Board Service", - "default_code": "CB", - } - ) - # ACT & ASSERT - with self.assertRaises( - UserError, msg="Board service line shouldnt be created." - ): - self.env["pms.board.service.line"].create( - { - "product_id": product1.id, - "pms_board_service_id": board_service1.id, - "pms_property_ids": [pms_property2.id], - "adults": True, - } - ) - - # BOARD SERVICE ROOM TYPE - def test_create_rt_props_gt_bs_props(self): - """ - Create board service for a room type and the room type - have MORE properties than the board service. - Record of board_service_room_type should contain the - board service properties. - """ - # ARRANGE - pms_property2 = self.env["pms.property"].create( - { - "name": "Property 2", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } - ) - - room_type_double = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id, pms_property2.id], - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - "list_price": 25, - } - ) - board_service_test = self.board_service = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TPS", - "pms_property_ids": [self.pms_property1.id], - } - ) - # ACT - new_bsrt = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type_double.id, - "pms_board_service_id": board_service_test.id, - "pms_property_id": self.pms_property1.id, - } - ) - # ASSERT - self.assertIn( - new_bsrt.pms_property_id.id, - board_service_test.pms_property_ids.ids, - "Record of board_service_room_type should contain the" - " board service properties.", - ) - - def test_create_rt_props_lt_bs_props(self): - """ - Create board service for a room type and the room type - have LESS properties than the board service. - Record of board_service_room_type should contain the - room types properties. - """ - # ARRANGE - pms_property2 = self.env["pms.property"].create( - { - "name": "Property 2", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } - ) - room_type1 = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - "list_price": 25, - } - ) - board_service1 = self.board_service = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TPS", - "pms_property_ids": [self.pms_property1.id, pms_property2.id], - } - ) - # ACT - new_bsrt = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type1.id, - "pms_board_service_id": board_service1.id, - "pms_property_id": self.pms_property1.id, - } - ) - # ASSERT - self.assertIn( - new_bsrt.pms_property_id.id, - room_type1.pms_property_ids.ids, - "Record of board_service_room_type should contain the" - " room types properties.", - ) - - def _test_create_rt_props_eq_bs_props(self): - """ - Create board service for a room type and the room type - have THE SAME properties than the board service. - Record of board_service_room_type should contain the - room types properties that matchs with the board - service properties - """ - # ARRANGE - room_type1 = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - "list_price": 25, - } - ) - board_service1 = self.board_service = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TPS", "pms_property_ids": [self.pms_property1.id], - } - ) - # ACT - new_bsrt = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type1.id, - "pms_board_service_id": board_service1.id, - "pms_property_id": self.pms_property1.id, - } - ) - # ASSERT - self.assertIn( - new_bsrt.pms_property_ids.ids == room_type1.pms_property_ids.ids - and new_bsrt.pms_property_ids.ids == board_service1.pms_property_ids.ids, - "Record of board_service_room_type should contain the room " - "types properties and matchs with the board service properties", - ) - - def test_create_rt_no_props_and_bs_props(self): - """ - Create board service for a room type and the room type - hasn't properties but the board services. - Record of board_service_room_type should contain the - board service properties. - """ - # ARRANGE - room_type1 = self.env["pms.room.type"].create( - { - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - "list_price": 25, - } - ) - board_service1 = self.board_service = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TPS", - "pms_property_ids": [self.pms_property1.id], - } - ) - # ACT - new_bsrt = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type1.id, - "pms_board_service_id": board_service1.id, - "pms_property_id": self.pms_property1.id, - } - ) - # ASSERT - self.assertIn( - new_bsrt.pms_property_id.id, - board_service1.pms_property_ids.ids, - "Record of board_service_room_type should contain the" - " board service properties.", - ) - - def test_create_rt_props_and_bs_no_props(self): - """ - Create board service for a room type and the board service - hasn't properties but the room type. - Record of board_service_room_type should contain the - room type properties. - """ - # ARRANGE - pms_property2 = self.env["pms.property"].create( - { - "name": "Property 2", - "company_id": self.company1.id, - "default_pricelist_id": self.pricelist1.id, - } - ) - room_type1 = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id, pms_property2.id], - "name": "Double Test", - "default_code": "DBL_Test", + "name": "Single", + "default_code": "SIN", "class_id": self.room_type_class1.id, - "list_price": 25, } ) - board_service1 = self.board_service = self.env["pms.board.service"].create( + self.env["pms.room"].create( { - "name": "Test Board Service", - "default_code": "TPS", - "pms_property_ids": [self.pms_property1.id], - } - ) - # ACT - new_bsrt = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type1.id, - "pms_board_service_id": board_service1.id, + "name": "Room 101", "pms_property_id": self.pms_property1.id, + "room_type_id": room_type.id, } ) - # ASSERT - self.assertIn( - new_bsrt.pms_property_id.id, - room_type1.pms_property_ids.ids, - "Record of board_service_room_type should contain the" - " room type properties.", - ) - - def test_create_rt_no_props_and_bs_no_props(self): - """ - Create board service for a room type and the board service - has no properties and neither does the room type - Record of board_service_room_type shouldnt contain properties. - """ - # ARRANGE - - room_type1 = self.env["pms.room.type"].create( - { - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - "list_price": 25, - } - ) - board_service1 = self.board_service = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TPS", - } - ) - # ACT - new_bsrt = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type1.id, - "pms_board_service_id": board_service1.id, - } - ) - # ASSERT - self.assertFalse( - new_bsrt.pms_property_id.id, - "Record of board_service_room_type shouldnt contain properties.", - ) - - def test_pms_bsrtl_product_property_integrity(self): - """ - Creation of a board service room type line without property, of a product - only available for a specific property. - """ - # ARRANGE - - product1 = self.env["product.product"].create( - {"name": "Product", "pms_property_ids": self.pms_property1} - ) - board_service1 = self.env["pms.board.service"].create( - { - "name": "Board Service", - "default_code": "CB", - } - ) - room_type1 = self.env["pms.room.type"].create( - { - "name": "Room Type", - "default_code": "Type1", - "class_id": self.room_type_class1.id, - } - ) - board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_board_service_id": board_service1.id, - "pms_room_type_id": room_type1.id, - } + sale_channel = self.env["pms.sale.channel"].create( + {"name": "Door", "channel_type": "direct"} ) - # ACT & ASSERT - with self.assertRaises( - UserError, msg="Board service room type line shouldnt be created." - ): - self.env["pms.board.service.room.type.line"].create( + with self.assertRaisesRegex(UserError, "board_service_id"): + self.env["pms.reservation"].create( { - "pms_board_service_room_type_id": board_service_room_type1.id, - "product_id": product1.id, + "checkin": fields.date.today(), + "checkout": fields.date.today() + datetime.timedelta(days=1), + "pms_property_id": self.pms_property1.id, + "room_type_id": room_type.id, + "partner_name": "Guest", + "sale_channel_origin_id": sale_channel.id, + "board_service_id": board_service.id, } ) - def test_pms_bsrtl_board_service_line_prop_integrity(self): + def test_board_rule_board_service_property_integrity(self): """ - Creation of a board service room type line with a specific property, - of board service without property. + An availability plan can't close, in a property, a board service only + available for another property. """ # ARRANGE - product1 = self.env["product.product"].create( - {"name": "Product", "pms_property_ids": [self.pms_property1.id]} - ) - board_service1 = self.env["pms.board.service"].create( - { - "name": "Board Service", - "default_code": "CB", - } - ) - - room_type1 = self.env["pms.room.type"].create( - { - "name": "Room Type", - "default_code": "Type1", - "class_id": self.room_type_class1.id, - } + board_service = self._create_board_service( + "Board Service", pms_property_ids=[self.pms_property2.id] ) - board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_board_service_id": board_service1.id, - "pms_room_type_id": room_type1.id, - } - ) - # ACT & ASSERT - with self.assertRaises( - UserError, msg="Board service line shouldnt be created." - ): - self.env["pms.board.service.room.type.line"].create( + with self.assertRaisesRegex(UserError, "board_service_id"): + self.env["pms.availability.plan.board.rule"].create( { - "product_id": product1.id, - "pms_property_id": self.pms_property2.id, - "pms_board_service_room_type_id": board_service_room_type1.id, + "availability_plan_id": self.availability_plan1.id, + "pms_property_id": self.pms_property1.id, + "board_service_id": board_service.id, + "date_from": fields.date.today(), + "date_to": fields.date.today(), } ) @@ -832,11 +455,10 @@ def _test_multiproperty_checks(self): +---------------+------+------+------+----+----+ """ # ARRANGE - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Board Service Test", - "default_code": "CB", - } + self.board_service1 = self._create_board_service( + "Board Service Test", + default_code="CB", + pms_property_ids=[self.pms_property2.id, self.pms_property3.id], ) host1 = self.env["res.partner"].create( { @@ -894,18 +516,11 @@ def _test_multiproperty_checks(self): } ) - board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_board_service_id": self.board_service1.id, - "pms_room_type_id": room_type_test.id, - "pms_property_ids": [self.pms_property2.id, self.pms_property3.id], - } - ) test_cases = [ {"preferred_room_id": room.id}, {"room_type_id": room_type_test.id}, {"pricelist_id": pricelist2.id}, - {"board_service_room_id": board_service_room_type1.id}, + {"board_service_id": self.board_service1.id}, ] for test_case in test_cases: diff --git a/pms/tests/test_pms_pricelist.py b/pms/tests/test_pms_pricelist.py index f11bff8536..22748c9f6d 100644 --- a/pms/tests/test_pms_pricelist.py +++ b/pms/tests/test_pms_pricelist.py @@ -58,35 +58,14 @@ def setUpClass(cls): "is_pms_available": True, } ) - # product.product 1 - cls.product1 = cls.env["product.product"].create( - {"name": "Test Breakfast", "per_day": True, "consumed_on": "after"} - ) - - # pms.board.service - cls.board_service1 = cls.env["pms.board.service"].create( - { - "name": "Test Only Breakfast", - "default_code": "CB1", - } - ) - # pms.board.service.line - cls.board_service_line1 = cls.env["pms.board.service.line"].create( - { - "product_id": cls.product1.id, - "pms_board_service_id": cls.board_service1.id, - "adults": True, - } - ) - - # pms.board.service.room.type - cls.board_service_room_type1 = cls.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": cls.room_type1.id, - "pms_board_service_id": cls.board_service1.id, - "pms_property_id": cls.pms_property1.id, - } + # board service, consumed by adults + cls.board_service1 = cls._create_board_service( + "Test Only Breakfast", + default_code="CB1", + per_day=True, + consumed_on="after", ) + cls.product1 = cls._board_service_variant(cls.board_service1, "adults") cls.partner1 = cls.env["res.partner"].create({"name": "Carles"}) @@ -120,7 +99,6 @@ def test_board_service_pricelist_item_apply_sale_dates(self): "compute_price": "fixed", "applied_on": "0_product_variant", "product_id": self.product1.id, - "board_service_room_type_id": self.board_service_room_type1.id, "fixed_price": expected_price, "pms_property_ids": [self.pms_property1.id], } @@ -134,7 +112,7 @@ def test_board_service_pricelist_item_apply_sale_dates(self): "preferred_room_id": self.room1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist2.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -168,7 +146,6 @@ def test_board_service_pricelist_item_not_apply_sale_dates(self): "compute_price": "fixed", "applied_on": "0_product_variant", "product_id": self.product1.id, - "board_service_room_type_id": self.board_service_room_type1.id, "fixed_price": not_expected_price, "pms_property_ids": [self.pms_property1.id], } @@ -182,7 +159,7 @@ def test_board_service_pricelist_item_not_apply_sale_dates(self): "preferred_room_id": self.room1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist2.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -213,7 +190,6 @@ def test_board_service_pricelist_item_apply_consumption_dates(self): "compute_price": "fixed", "applied_on": "0_product_variant", "product_id": self.product1.id, - "board_service_room_type_id": self.board_service_room_type1.id, "fixed_price": expected_price, "pms_property_ids": [self.pms_property1.id], } @@ -227,7 +203,7 @@ def test_board_service_pricelist_item_apply_consumption_dates(self): "preferred_room_id": self.room1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist2.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -262,7 +238,6 @@ def test_board_service_pricelist_item_not_apply_consumption_dates(self): "compute_price": "fixed", "applied_on": "0_product_variant", "product_id": self.product1.id, - "board_service_room_type_id": self.board_service_room_type1.id, "fixed_price": not_expected_price, "pms_property_ids": [self.pms_property1.id], } @@ -276,7 +251,7 @@ def test_board_service_pricelist_item_not_apply_consumption_dates(self): "preferred_room_id": self.room1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist2.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) 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 5fcb297d97..bd69cabfd4 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 @@ -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,28 +856,239 @@ 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() + 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 @@ -3173,35 +3417,13 @@ def test_commission_amount_with_board_service(self): } ) self.service.flush_recordset() - self.product_test1 = self.env["product.product"].create( - { - "name": "Test Product 1", - "per_day": True, - "consumed_on": "after", - } - ) - self.board_service_test = self.env["pms.board.service"].create( - { - "name": "Test Board Service", - "default_code": "TPS", - "pms_property_ids": [self.pms_property1.id], - } - ) - - self.env["pms.board.service.line"].create( - { - "pms_board_service_id": self.board_service_test.id, - "product_id": self.product_test1.id, - "amount": 8, - "adults": True, - } - ) - self.board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service_test.id, - "pms_property_id": self.pms_property1.id, - } + self.board_service_test = self._create_board_service( + "Test Board Service", + default_code="TPS", + list_price=8, + per_day=True, + consumed_on="after", + pms_property_ids=[self.pms_property1.id], ) checkin = fields.date.today() checkout = checkin + datetime.timedelta(days=11) @@ -3219,7 +3441,7 @@ def test_commission_amount_with_board_service(self): reservation = self.env["pms.reservation"].create(reservation_vals) reservation.write( { - "board_service_room_id": self.board_service_room_type.id, + "board_service_id": self.board_service_test.id, } ) diff --git a/pms/tests/test_pms_room_type.py b/pms/tests/test_pms_room_type.py index 5be903ee20..e3317262e2 100644 --- a/pms/tests/test_pms_room_type.py +++ b/pms/tests/test_pms_room_type.py @@ -816,91 +816,6 @@ def test_rooom_type_creation_consistency_class(self): # ASSERT self.assertTrue(new_room_type.id, "Room type creation should be successful.") - def test_check_board_service_property_integrity(self): - # ARRANGE - room_type = self.env["pms.room.type"].create( - { - "name": "Room Type", - "default_code": "Type1", - "pms_property_ids": self.pms_property1, - "class_id": self.room_type_class1.id, - } - ) - board_service = self.env["pms.board.service"].create( - { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": self.pms_property1, - } - ) - # ACT & ASSERT - with self.assertRaises(UserError, msg="Board service created and shouldn't."): - self.env["pms.board.service.room.type"].create( - { - "pms_board_service_id": board_service.id, - "pms_room_type_id": room_type.id, - "pms_property_id": self.pms_property2.id, - } - ) - - def test_room_type_board_service_by_default_constraint(self): - room_type = self.env["pms.room.type"].create( - { - "name": "Room Type", - "default_code": "Type1", - "pms_property_ids": self.pms_property1, - "class_id": self.room_type_class1.id, - } - ) - board_service_default = self.env["pms.board.service"].create( - { - "name": "Board service 1", - "default_code": "c1", - "pms_property_ids": self.pms_property1, - } - ) - board_service_not_default = self.env["pms.board.service"].create( - { - "name": "Board service 2", - "default_code": "c2", - "pms_property_ids": self.pms_property1, - } - ) - board_service_second_default = self.env["pms.board.service"].create( - { - "name": "Board service 3", - "default_code": "c3", - "pms_property_ids": self.pms_property1, - } - ) - self.env["pms.board.service.room.type"].create( - { - "pms_board_service_id": board_service_default.id, - "pms_room_type_id": room_type.id, - "pms_property_id": self.pms_property1.id, - "by_default": True, - } - ) - self.env["pms.board.service.room.type"].create( - { - "pms_board_service_id": board_service_not_default.id, - "pms_room_type_id": room_type.id, - "pms_property_id": self.pms_property1.id, - } - ) - with self.assertRaises( - UserError, - msg="Only can set one default board service", - ): - self.env["pms.board.service.room.type"].create( - { - "pms_board_service_id": board_service_second_default.id, - "pms_room_type_id": room_type.id, - "pms_property_id": self.pms_property1.id, - "by_default": True, - } - ) - def test_check_amenities_property_integrity(self): self.amenity1 = self.env["pms.amenity"].create( {"name": "Amenity", "pms_property_ids": self.pms_property1} diff --git a/pms/tests/test_pms_service.py b/pms/tests/test_pms_service.py index 193ee099c3..dce67425ee 100644 --- a/pms/tests/test_pms_service.py +++ b/pms/tests/test_pms_service.py @@ -71,34 +71,11 @@ def test_reservation_sale_origin_in_board_service(self): --> service.sale_channel_origin_id? It must be Door """ # ARRANGE - self.product1 = self.env["product.product"].create( - { - "name": "Test Product 1", - } - ) - - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Test Board Service 1", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - self.board_service_line1 = self.env["pms.board.service.line"].create( - { - "product_id": self.product1.id, - "pms_board_service_id": self.board_service1.id, - "amount": 10, - "adults": True, - } - ) - - self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service1.id, - "pms_property_id": self.pms_property1.id, - } + self.board_service1 = self._create_board_service( + "Test Board Service 1", + default_code="CB1", + list_price=10, + pms_property_ids=[self.pms_property1.id], ) # ACT self.reservation = self.env["pms.reservation"].create( @@ -109,7 +86,7 @@ def test_reservation_sale_origin_in_board_service(self): "partner_id": self.partner1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist1.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_door.id, "adults": 2, } @@ -141,34 +118,11 @@ def test_change_origin_board_service_not_change_reservation_origin(self): """ # ARRANGE - self.product1 = self.env["product.product"].create( - { - "name": "Test Product 1", - } - ) - - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Test Board Service 1", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - self.board_service_line1 = self.env["pms.board.service.line"].create( - { - "product_id": self.product1.id, - "pms_board_service_id": self.board_service1.id, - "amount": 10, - "adults": True, - } - ) - - self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service1.id, - "pms_property_id": self.pms_property1.id, - } + self.board_service1 = self._create_board_service( + "Test Board Service 1", + default_code="CB1", + list_price=10, + pms_property_ids=[self.pms_property1.id], ) self.reservation = self.env["pms.reservation"].create( @@ -179,7 +133,7 @@ def test_change_origin_board_service_not_change_reservation_origin(self): "partner_id": self.partner1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist1.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_door.id, } ) @@ -214,34 +168,11 @@ def test_change_origin_board_service_in_sale_channels(self): Reservation.folio_id.sale_channel_ids = {Door, Phone} """ # ARRANGE - self.product1 = self.env["product.product"].create( - { - "name": "Test Product 1", - } - ) - - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Test Board Service 1", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - self.board_service_line1 = self.env["pms.board.service.line"].create( - { - "product_id": self.product1.id, - "pms_board_service_id": self.board_service1.id, - "amount": 10, - "adults": True, - } - ) - - self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service1.id, - "pms_property_id": self.pms_property1.id, - } + self.board_service1 = self._create_board_service( + "Test Board Service 1", + default_code="CB1", + list_price=10, + pms_property_ids=[self.pms_property1.id], ) self.reservation = self.env["pms.reservation"].create( { @@ -251,7 +182,7 @@ def test_change_origin_board_service_in_sale_channels(self): "partner_id": self.partner1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist1.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_door.id, "adults": 2, } @@ -296,34 +227,11 @@ def test_change_origin_reservation_change_origin_services(self): """ # ARRANGE - self.product1 = self.env["product.product"].create( - { - "name": "Test Product 1", - } - ) - - self.board_service1 = self.env["pms.board.service"].create( - { - "name": "Test Board Service 1", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - self.board_service_line1 = self.env["pms.board.service.line"].create( - { - "product_id": self.product1.id, - "pms_board_service_id": self.board_service1.id, - "amount": 10, - "adults": True, - } - ) - - self.board_service_room_type1 = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type_double.id, - "pms_board_service_id": self.board_service1.id, - "pms_property_id": self.pms_property1.id, - } + self.board_service1 = self._create_board_service( + "Test Board Service 1", + default_code="CB1", + list_price=10, + pms_property_ids=[self.pms_property1.id], ) self.reservation = self.env["pms.reservation"].create( @@ -334,7 +242,7 @@ def test_change_origin_reservation_change_origin_services(self): "partner_id": self.partner1.id, "pms_property_id": self.pms_property1.id, "pricelist_id": self.pricelist1.id, - "board_service_room_id": self.board_service_room_type1.id, + "board_service_id": self.board_service1.id, "sale_channel_origin_id": self.sale_channel_door.id, "adults": 2, } diff --git a/pms/tests/test_pms_wizard_massive_changes.py b/pms/tests/test_pms_wizard_massive_changes.py index 04de59d4d9..e914297bd8 100644 --- a/pms/tests/test_pms_wizard_massive_changes.py +++ b/pms/tests/test_pms_wizard_massive_changes.py @@ -776,413 +776,51 @@ def test_several_room_types_pricelist(self): "The wizard should create as many items as room types given.", ) - def test_one_board_service_room_type_no_board_service(self): + def test_board_service_variant_price_overwrites_only_its_items(self): """ - Call to wizard with one board service room type and no - board service. - The wizard must create as many pricelist items as there - are services on the given board service. + A board service variant is priced as a service. Pricing it again on + the same dates replaces its previous price and leaves the prices of + the other products of those dates untouched. """ # ARRANGE - room_type_single = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - } - ) - board_service_only_breakfast = self.env["pms.board.service"].create( - { - "name": "Test Only Breakfast", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - service_breakfast = self.env["product.product"].create( - {"name": "Test Breakfast"} - ) - board_service_single = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type_single.id, - "pms_board_service_id": board_service_only_breakfast.id, - "pms_property_id": self.pms_property1.id, - } - ) - board_service_line_single_1 = self.env["pms.board.service.line"].create( - { - "product_id": service_breakfast.id, - "pms_board_service_id": board_service_only_breakfast.id, - "adults": True, - } + board_service = self._create_board_service( + "Test Breakfast", guests=("adult", "child") ) + child_breakfast = self._board_service_variant(board_service, "children") + adult_breakfast = self._board_service_variant(board_service, "adults") date_from = fields.date.today() date_to = fields.date.today() - wizard_result = self.env["pms.massive.changes.wizard"].create( - { - "massive_changes_on": "pricelist", - "pricelist_ids": [(6, 0, [self.pricelist1.id])], - "apply_pricelists_on": "board_services", - "board_service_room_type_ids": [ - ( - 6, - 0, - [board_service_single.id], - ) - ], - "pms_property_ids": [self.pms_property1.id], - "start_date": date_from, - "end_date": date_to, - "date_types": "consumption_dates", - } - ) - # ACT - wizard_result.apply_massive_changes() - - items_created = self.env["product.pricelist.item"].search( - [ - ("pricelist_id", "=", self.pricelist1.id), - ("pms_property_ids", "=", self.pms_property1.id), - ("product_id", "=", board_service_line_single_1.product_id.id), - ] - ) - # ASSERT - self.assertIn( - service_breakfast, - items_created.mapped("product_id"), - "The wizard must create as many pricelist items as there " - "are services on the given board service.", - ) - def test_one_board_service_room_type_with_board_service(self): - """ - Call to wizard with one board service room type and - board service. - The wizard must create one pricelist items with - the board service given. - """ - # ARRANGE - room_type_single = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - } - ) - board_service_only_breakfast = self.env["pms.board.service"].create( - { - "name": "Test Only Breakfast", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - service_breakfast = self.env["product.product"].create( - {"name": "Test Breakfast"} - ) - board_service_single = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type_single.id, - "pms_board_service_id": board_service_only_breakfast.id, - "pms_property_id": self.pms_property1.id, - } - ) - board_service_line_single_1 = self.env["pms.board.service.line"].create( - { - "product_id": service_breakfast.id, - "pms_board_service_id": board_service_only_breakfast.id, - "adults": True, - } - ) - date_from = fields.date.today() - date_to = fields.date.today() - wizard_result = self.env["pms.massive.changes.wizard"].create( - { - "massive_changes_on": "pricelist", - "pricelist_ids": [(6, 0, [self.pricelist1.id])], - "apply_pricelists_on": "board_services", - "board_service_room_type_ids": [ - ( - 6, - 0, - [board_service_single.id], - ) - ], - "board_service": board_service_line_single_1.product_id.id, - "pms_property_ids": [self.pms_property1.id], - "start_date": date_from, - "end_date": date_to, - "date_types": "consumption_dates", - } - ) - # ACT - wizard_result.apply_massive_changes() - - items_created = self.env["product.pricelist.item"].search( - [ - ("pricelist_id", "=", self.pricelist1.id), - ("pms_property_ids", "=", self.pms_property1.id), - ("product_id", "=", board_service_line_single_1.product_id.id), - ] - ) - # ASSERT - self.assertIn( - service_breakfast, - items_created.mapped("product_id"), - "The wizard must create one pricelist items with " - " the board service given.", - ) - - def test_several_board_service_room_type_no_board_service(self): - """ - Call to wizard with several board service room type and no - board service. - The wizard must create as many pricelist items as there - are services on the given board services. - """ - # ARRANGE - room_type_single = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "SNG_Test", - "class_id": self.room_type_class1.id, - } - ) - room_type_double = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - } - ) - board_service_only_breakfast = self.env["pms.board.service"].create( - { - "name": "Test Only Breakfast", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - board_service_half_board = self.env["pms.board.service"].create( - { - "name": "Test Half Board", - "default_code": "CB2", - "pms_property_ids": [self.pms_property1.id], - } - ) - service_breakfast = self.env["product.product"].create( - {"name": "Test Breakfast"} - ) - service_dinner = self.env["product.product"].create({"name": "Test Dinner"}) - board_service_single = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type_single.id, - "pms_board_service_id": board_service_only_breakfast.id, - "pms_property_id": self.pms_property1.id, - } - ) - board_service_double = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type_double.id, - "pms_board_service_id": board_service_half_board.id, - "pms_property_id": self.pms_property1.id, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": service_breakfast.id, - "pms_board_service_id": board_service_only_breakfast.id, - "adults": True, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": service_breakfast.id, - "pms_board_service_id": board_service_half_board.id, - "adults": True, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": service_dinner.id, - "pms_board_service_id": board_service_half_board.id, - "adults": True, - } - ) - date_from = fields.date.today() - date_to = fields.date.today() - product_ids_expected = ( - board_service_double.pms_board_service_id.board_service_line_ids.mapped( - "product_id" - ).ids - + board_service_single.pms_board_service_id.board_service_line_ids.mapped( - "product_id" - ).ids - ) - wizard_result = self.env["pms.massive.changes.wizard"].create( - { - "massive_changes_on": "pricelist", - "pricelist_ids": [(6, 0, [self.pricelist1.id])], - "apply_pricelists_on": "board_services", - "board_service_room_type_ids": [ - ( - 6, - 0, - [ - board_service_single.id, - board_service_double.id, - ], - ) - ], - "pms_property_ids": [self.pms_property1.id], - "start_date": date_from, - "end_date": date_to, - "date_types": "consumption_dates", - } - ) + def price_service(service, price): + self.env["pms.massive.changes.wizard"].create( + { + "massive_changes_on": "pricelist", + "pricelist_ids": [(6, 0, [self.pricelist1.id])], + "apply_pricelists_on": "service", + "service": service.id, + "price": price, + "pms_property_ids": [self.pms_property1.id], + "start_date": date_from, + "end_date": date_to, + "date_types": "consumption_dates", + } + ).apply_massive_changes() + + price_service(adult_breakfast, 10) + price_service(child_breakfast, 5) # ACT - wizard_result.apply_massive_changes() - items_created = self.env["product.pricelist.item"].search( - [ - ("pricelist_id", "=", self.pricelist1.id), - ("pms_property_ids", "=", self.pms_property1.id), - ("product_id", "in", product_ids_expected), - ] - ) + price_service(child_breakfast, 4) # ASSERT - self.assertEqual( - set(product_ids_expected), - set(items_created.mapped("product_id").ids), - "The wizard should create as many pricelist items as there" - " are services on the given board services.", - ) - - def test_several_board_service_room_type_with_board_service(self): - """ - Call to wizard with several board service room types and - board service. - The wizard must create as many pricelist items as there - are services on the given board services. - """ - # ARRANGE - room_type_single = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "SNG_Test", - "class_id": self.room_type_class1.id, - } - ) - room_type_double = self.env["pms.room.type"].create( - { - "pms_property_ids": [self.pms_property1.id], - "name": "Double Test", - "default_code": "DBL_Test", - "class_id": self.room_type_class1.id, - } - ) - board_service_only_breakfast = self.env["pms.board.service"].create( - { - "name": "Test Only Breakfast", - "default_code": "CB1", - "pms_property_ids": [self.pms_property1.id], - } - ) - board_service_half_board = self.env["pms.board.service"].create( - { - "name": "Test Half Board", - "default_code": "CB2", - "pms_property_ids": [self.pms_property1.id], - } - ) - service_breakfast = self.env["product.product"].create( - {"name": "Test Breakfast"} - ) - service_dinner = self.env["product.product"].create({"name": "Test Dinner"}) - board_service_single = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type_single.id, - "pms_board_service_id": board_service_only_breakfast.id, - "pms_property_id": self.pms_property1.id, - } - ) - board_service_double = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": room_type_double.id, - "pms_board_service_id": board_service_half_board.id, - "pms_property_id": self.pms_property1.id, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": service_breakfast.id, - "pms_board_service_id": board_service_only_breakfast.id, - "adults": True, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": service_breakfast.id, - "pms_board_service_id": board_service_half_board.id, - "adults": True, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": service_dinner.id, - "pms_board_service_id": board_service_half_board.id, - "adults": True, - } - ) - date_from = fields.date.today() - date_to = fields.date.today() - board_service_id_double = board_service_double.pms_board_service_id - board_service_id_single = board_service_single.pms_board_service_id - product_ids_expected = list( - set(board_service_id_double.board_service_line_ids.mapped("product_id").ids) - & set( - board_service_id_single.board_service_line_ids.mapped("product_id").ids - ) - ) - wizard_result = self.env["pms.massive.changes.wizard"].create( - { - "massive_changes_on": "pricelist", - "pricelist_ids": [(6, 0, [self.pricelist1.id])], - "apply_pricelists_on": "board_services", - "board_service_room_type_ids": [ - ( - 6, - 0, - [ - board_service_single.id, - board_service_double.id, - ], - ) - ], - "board_service": service_breakfast.id, - "pms_property_ids": [self.pms_property1.id], - "start_date": date_from, - "end_date": date_to, - "date_types": "consumption_dates", - } - ) - # ACT - wizard_result.apply_massive_changes() - - items_created = self.env["product.pricelist.item"].search( + items = self.env["product.pricelist.item"].search( [ ("pricelist_id", "=", self.pricelist1.id), - ("pms_property_ids", "=", self.pms_property1.id), - ("product_id", "in", product_ids_expected), + ("product_id", "in", board_service.product_variant_ids.ids), ] ) - # ASSERT self.assertEqual( - set(product_ids_expected), - set(items_created.mapped("product_id").ids), - "The wizard should create as many pricelist items as there" - " are services on the given board services.", + {(item.product_id, item.fixed_price) for item in items}, + {(adult_breakfast, 10), (child_breakfast, 4)}, + "Pricing a board service variant should only replace its own price", ) def test_service(self): diff --git a/pms/tests/test_product_template.py b/pms/tests/test_product_template.py index 8c2cd3af3a..c8058db8bd 100644 --- a/pms/tests/test_product_template.py +++ b/pms/tests/test_product_template.py @@ -26,12 +26,6 @@ def setUpClass(cls): } ) cls.partner = cls.env["res.partner"].create({"name": "partner1"}) - cls.board_service = cls.env["pms.board.service"].create( - { - "name": "Board service test", - "default_code": "BST", - } - ) # create a sale channel cls.sale_channel_direct1 = cls.env["pms.sale.channel"].create( { @@ -47,25 +41,10 @@ def test_bs_consumed_on_after(self): Date of service line with consumed on 'after' should match checkout date. """ # ARRANGE - product = self.env["product.product"].create( - { - "name": "Product test", - "per_day": True, - "consumed_on": "after", - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": product.id, - "pms_board_service_id": self.board_service.id, - "adults": True, - } - ) - board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type.id, - "pms_board_service_id": self.board_service.id, - } + board_service = self._create_board_service( + "Product test", + per_day=True, + consumed_on="after", ) date_checkin = fields.date.today() date_checkout = fields.date.today() + datetime.timedelta(days=1) @@ -77,7 +56,7 @@ def test_bs_consumed_on_after(self): "room_type_id": self.room_type.id, "pms_property_id": self.pms_property1.id, "partner_id": self.partner.id, - "board_service_room_id": board_service_room_type.id, + "board_service_id": board_service.id, "sale_channel_origin_id": self.sale_channel_direct1.id, "adults": 2, } @@ -96,25 +75,10 @@ def test_bs_consumed_on_before(self): Date of service line with consumed on 'before' should match checkin date. """ # ARRANGE - product = self.env["product.product"].create( - { - "name": "Product test", - "per_day": True, - "consumed_on": "before", - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": product.id, - "pms_board_service_id": self.board_service.id, - "adults": True, - } - ) - board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type.id, - "pms_board_service_id": self.board_service.id, - } + board_service = self._create_board_service( + "Product test", + per_day=True, + consumed_on="before", ) date_checkin = fields.date.today() date_checkout = fields.date.today() + datetime.timedelta(days=1) @@ -126,7 +90,7 @@ def test_bs_consumed_on_before(self): "room_type_id": self.room_type.id, "pms_property_id": self.pms_property1.id, "partner_id": self.partner.id, - "board_service_room_id": board_service_room_type.id, + "board_service_id": board_service.id, "sale_channel_origin_id": self.sale_channel_direct1.id, "adults": 2, } @@ -145,27 +109,13 @@ def test_bs_daily_limit_equal(self): Reservation should created succesfully. """ # ARRANGE - product = self.env["product.product"].create( - { - "name": "Product test", - "per_day": True, - "daily_limit": 2, - "per_person": True, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": product.id, - "pms_board_service_id": self.board_service.id, - "adults": True, - } - ) - board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type.id, - "pms_board_service_id": self.board_service.id, - } + board_service = self._create_board_service( + "Product test", + per_day=True, + per_person=True, ) + # The daily limit is configured per property + board_service.with_context(property=self.pms_property1.id).daily_limit = 2 date_checkin = fields.date.today() date_checkout = fields.date.today() + datetime.timedelta(days=1) # ACT @@ -176,7 +126,7 @@ def test_bs_daily_limit_equal(self): "room_type_id": self.room_type.id, "pms_property_id": self.pms_property1.id, "partner_id": self.partner.id, - "board_service_room_id": board_service_room_type.id, + "board_service_id": board_service.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -196,27 +146,13 @@ def test_bs_daily_limit_lower(self): """ # ARRANGE self.room.capacity = 1 - product = self.env["product.product"].create( - { - "name": "Product test", - "per_day": True, - "daily_limit": 2, - "per_person": True, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": product.id, - "pms_board_service_id": self.board_service.id, - "adults": True, - } - ) - board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type.id, - "pms_board_service_id": self.board_service.id, - } + board_service = self._create_board_service( + "Product test", + per_day=True, + per_person=True, ) + # The daily limit is configured per property + board_service.with_context(property=self.pms_property1.id).daily_limit = 2 date_checkin = fields.date.today() date_checkout = fields.date.today() + datetime.timedelta(days=1) # ACT @@ -227,7 +163,7 @@ def test_bs_daily_limit_lower(self): "room_type_id": self.room_type.id, "pms_property_id": self.pms_property1.id, "partner_id": self.partner.id, - "board_service_room_id": board_service_room_type.id, + "board_service_id": board_service.id, "sale_channel_origin_id": self.sale_channel_direct1.id, } ) @@ -248,29 +184,15 @@ def test_bs_daily_limit_greater(self): Reservation creation should fail. """ # ARRANGE - product = self.env["product.product"].create( - { - "name": "Product test", - "per_day": True, - "type": "service", - "daily_limit": 1, - "list_price": 15.0, - "per_person": True, - } - ) - self.env["pms.board.service.line"].create( - { - "product_id": product.id, - "pms_board_service_id": self.board_service.id, - "adults": True, - } - ) - board_service_room_type = self.env["pms.board.service.room.type"].create( - { - "pms_room_type_id": self.room_type.id, - "pms_board_service_id": self.board_service.id, - } - ) + board_service = self._create_board_service( + "Product test", + per_day=True, + type="service", + list_price=15.0, + per_person=True, + ) + # The daily limit is configured per property + board_service.with_context(property=self.pms_property1.id).daily_limit = 1 date_checkin = fields.date.today() date_checkout = fields.date.today() + datetime.timedelta(days=1) # ACT & ASSERT @@ -284,7 +206,7 @@ def test_bs_daily_limit_greater(self): "room_type_id": self.room_type.id, "pms_property_id": self.pms_property1.id, "partner_id": self.partner.id, - "board_service_room_id": board_service_room_type.id, + "board_service_id": board_service.id, "adults": 2, "sale_channel_origin_id": self.sale_channel_direct1.id, } diff --git a/pms/views/pms_availability_plan_views.xml b/pms/views/pms_availability_plan_views.xml index 8aa44afecf..516a86cc55 100644 --- a/pms/views/pms_availability_plan_views.xml +++ b/pms/views/pms_availability_plan_views.xml @@ -42,6 +42,22 @@ + + + + + + + + + + diff --git a/pms/views/pms_board_service_room_type_views.xml b/pms/views/pms_board_service_room_type_views.xml deleted file mode 100644 index 745efc1552..0000000000 --- a/pms/views/pms_board_service_room_type_views.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - pms.board.service.room.type.form - pms.board.service.room.type - -
- - - - - - - - - - - - - - - - - - -
-
-
- - Board Service - pms.board.service.room.type - form - -
diff --git a/pms/views/pms_board_service_views.xml b/pms/views/pms_board_service_views.xml deleted file mode 100644 index 82754734fe..0000000000 --- a/pms/views/pms_board_service_views.xml +++ /dev/null @@ -1,81 +0,0 @@ - - - - pms.board.service.form - pms.board.service - -
- - - - - - - - - - - - - - - - - - - - -
-
-
- - pms.board.service.tree - pms.board.service - - - - - - - - - - - - pms.board.service.search - pms.board.service - - - - - - - - - - Board Services - pms.board.service - tree,form - - - -
diff --git a/pms/views/pms_reservation_views.xml b/pms/views/pms_reservation_views.xml index 96b783bc38..946f832d68 100644 --- a/pms/views/pms_reservation_views.xml +++ b/pms/views/pms_reservation_views.xml @@ -443,17 +443,13 @@ name="children" attrs="{'invisible': [('reservation_type','in',('out'))]}" /> - - + - + + + @@ -42,35 +44,6 @@ - - - - - - - - - -
- - [('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 9a72a13f69..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,15 +93,72 @@ options="{'no_create': True,'no_open': True}" attrs="{'column_invisible': [('parent.is_pms_available', '=', False)]}" /> - + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+
+
+
- + 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 ec20f0b497..6e035d4f78 100644 --- a/pms/wizards/pms_booking_engine.py +++ b/pms/wizards/pms_booking_engine.py @@ -260,7 +260,8 @@ 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)) folio.write( @@ -314,6 +315,13 @@ class AvailabilityWizard(models.TransientModel): compute="_compute_rooms_available_qty", ) rooms_selected_qty = fields.Integer(string="Number of Rooms Selected") + adults = fields.Integer( + help="Number of adults staying in each room, which the price is " + "derived from. Defaults to the occupancy the room type guarantees", + compute="_compute_adults", + store=True, + readonly=False, + ) price_per_room = fields.Float( string="Price per room", help="Price per room in folio", @@ -329,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): @@ -368,16 +380,46 @@ def _compute_rooms_available_qty(self): ) record.rooms_available_qty = pms_property.availability - @api.depends("room_type_id", "board_service_room_id", "checkin", "checkout") + @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_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, pms_property_id=record.booking_engine_id.pms_property_id.id, + adults=record.adults, ) @api.depends("price_per_room", "rooms_selected_qty") @@ -391,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, @@ -413,6 +455,7 @@ def _get_price_by_room_type( quantity=1, consumption_date=date_iterator, pms_property_id=pms_property_id, + occupancy=adults, ) room_type_total_price_per_room += self.env[ "account.tax" @@ -423,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 0ac1c01eb4..c23897cdcc 100644 --- a/pms/wizards/pms_booking_engine_views.xml +++ b/pms/wizards/pms_booking_engine_views.xml @@ -104,7 +104,13 @@ force_save="1" /> + + -
@@ -67,19 +66,6 @@ options="{'no_create': True,'no_open': True}" attrs="{'invisible':[('apply_pricelists_on','!=','room_types'),('massive_changes_on', '=','pricelist')],'required': []}" /> - - - 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 = (