diff --git a/pms/models/pms_reservation.py b/pms/models/pms_reservation.py index 91a882b1b..2bc22d09f 100644 --- a/pms/models/pms_reservation.py +++ b/pms/models/pms_reservation.py @@ -367,6 +367,9 @@ class PmsReservation(models.Model): help="Field indicating type of cancellation. " "It can be 'late', 'intime' or 'noshow'", copy=False, + compute="_compute_cancelled_reason", + readonly=False, + store=True, selection=[("late", "Late"), ("intime", "In time"), ("noshow", "No Show")], tracking=True, ) @@ -582,6 +585,16 @@ class PmsReservation(models.Model): compute="_compute_discount", tracking=True, ) + + services_discount = fields.Float( + string="Services discount (€)", + help="Services discount", + readonly=False, + store=True, + digits=("Discount"), + compute="_compute_services_discount", + tracking=True, + ) date_order = fields.Date( string="Date Order", help="Order date of reservation", @@ -1146,10 +1159,12 @@ class PmsReservation(models.Model): for res in self: res.nights = len(res.reservation_line_ids) - @api.depends("service_ids.price_total") + @api.depends("service_ids.price_total", "services_discount") def _compute_price_services(self): for record in self: - record.price_services = sum(record.mapped("service_ids.price_total")) + record.price_services = ( + sum(record.mapped("service_ids.price_total")) - record.services_discount + ) @api.depends("price_services", "price_total") def _compute_price_room_services_set(self): @@ -1157,7 +1172,8 @@ class PmsReservation(models.Model): record.price_room_services_set = record.price_services + record.price_total @api.depends( - "reservation_line_ids.discount", "reservation_line_ids.cancel_discount" + "reservation_line_ids.discount", + "reservation_line_ids.cancel_discount", ) def _compute_discount(self): for record in self: @@ -1167,8 +1183,17 @@ class PmsReservation(models.Model): price = line.price - first_discount cancel_discount = price * ((line.cancel_discount or 0.0) * 0.01) discount += first_discount + cancel_discount + record.discount = discount + @api.depends("service_ids.discount") + def _compute_services_discount(self): + for record in self: + services_discount = 0 + for service in record.service_ids: + services_discount += service.discount + record.services_discount = services_discount + @api.depends("reservation_line_ids.price", "discount", "tax_ids") def _compute_amount_reservation(self): """ @@ -1660,37 +1685,39 @@ class PmsReservation(models.Model): if not record.allowed_cancel: raise UserError(_("This reservation cannot be cancelled")) else: - cancel_reason = ( - "intime" - if self._context.get("no_penalty", False) - else record.compute_cancelation_reason() - ) - if self._context.get("no_penalty", False): - _logger.info("Modified Reservation - No Penalty") - record.write({"state": "cancel", "cancelled_reason": cancel_reason}) - # record._compute_cancel_discount() + record.state = "cancel" record.folio_id._compute_amount() def action_assign(self): for record in self: record.to_assign = False - def compute_cancelation_reason(self): - self.ensure_one() - pricelist = self.pricelist_id - if pricelist and pricelist.cancelation_rule_id: - tz_property = self.pms_property_id.tz - today = fields.Date.context_today(self.with_context(tz=tz_property)) - days_diff = ( - fields.Date.from_string(self.checkin) - fields.Date.from_string(today) - ).days - if days_diff < 0: - return "noshow" - elif days_diff < pricelist.cancelation_rule_id.days_intime: - return "late" - else: - return "intime" - return False + @api.depends("state") + def _compute_cancelled_reason(self): + for record in self: + # self.ensure_one() + if record.state == "cancel": + pricelist = record.pricelist_id + if record._context.get("no_penalty", False): + record.cancelled_reason = "intime" + _logger.info("Modified Reservation - No Penalty") + elif pricelist and pricelist.cancelation_rule_id: + tz_property = record.pms_property_id.tz + today = fields.Date.context_today( + record.with_context(tz=tz_property) + ) + days_diff = ( + fields.Date.from_string(record.checkin) + - fields.Date.from_string(today) + ).days + if days_diff < 0: + record.cancelled_reason = "noshow" + elif days_diff < pricelist.cancelation_rule_id.days_intime: + record.cancelled_reason = "late" + else: + record.cancelled_reason = "intime" + else: + record.cancelled_reason = False def action_reservation_checkout(self): for record in self: diff --git a/pms/models/pms_reservation_line.py b/pms/models/pms_reservation_line.py index df504aa32..d0533224d 100644 --- a/pms/models/pms_reservation_line.py +++ b/pms/models/pms_reservation_line.py @@ -388,56 +388,52 @@ class PmsReservationLine(models.Model): def _compute_cancel_discount(self): for line in self: line.cancel_discount = 0 - # TODO: Review cancel logic - # reservation = line.reservation_id - # pricelist = reservation.pricelist_id - # if reservation.state == "cancel": - # if ( - # reservation.cancelled_reason - # and pricelist - # and pricelist.cancelation_rule_id - # ): - # date_start_dt = fields.Date.from_string( - # reservation.checkin - # ) - # date_end_dt = fields.Date.from_string( - # reservation.checkout - # ) - # days = abs((date_end_dt - date_start_dt).days) - # rule = pricelist.cancelation_rule_id - # if reservation.cancelled_reason == "late": - # discount = 100 - rule.penalty_late - # if rule.apply_on_late == "first": - # days = 1 - # elif rule.apply_on_late == "days": - # days = rule.days_late - # elif reservation.cancelled_reason == "noshow": - # discount = 100 - rule.penalty_noshow - # if rule.apply_on_noshow == "first": - # days = 1 - # elif rule.apply_on_noshow == "days": - # days = rule.days_late - 1 - # elif reservation.cancelled_reason == "intime": - # discount = 100 + reservation = line.reservation_id + pricelist = reservation.pricelist_id + if reservation.state == "cancel": + if ( + reservation.cancelled_reason + and pricelist + and pricelist.cancelation_rule_id + ): + checkin = fields.Date.from_string(reservation.checkin) + checkout = fields.Date.from_string(reservation.checkout) + days = abs((checkin - checkout).days) + rule = pricelist.cancelation_rule_id + discount = 0 + if reservation.cancelled_reason == "late": + discount = 100 - rule.penalty_late + if rule.apply_on_late == "first": + days = 1 + elif rule.apply_on_late == "days": + days = rule.days_late + elif reservation.cancelled_reason == "noshow": + discount = 100 - rule.penalty_noshow + if rule.apply_on_noshow == "first": + days = 1 + elif rule.apply_on_noshow == "days": + days = rule.days_late - 1 + elif reservation.cancelled_reason == "intime": + discount = 100 - # checkin = reservation.checkin - # dates = [] - # for i in range(0, days): - # dates.append( - # ( - # fields.Date.from_string(checkin) + timedelta(days=i) - # ).strftime(DEFAULT_SERVER_DATE_FORMAT) - # ) - # reservation.reservation_line_ids.filtered( - # lambda r: r.date in dates - # ).update({"cancel_discount": discount}) - # reservation.reservation_line_ids.filtered( - # lambda r: r.date not in dates - # ).update({"cancel_discount": 100}) - # else: - # reservation.reservation_line_ids.update({"cancel_discount": 0}) - # else: - # reservation.reservation_line_ids.update({"cancel_discount": 0}) + dates = [] + for i in range(0, days): + dates.append( + fields.Date.from_string( + fields.Date.from_string(checkin) + + datetime.timedelta(days=i) + ) + ) + reservation.reservation_line_ids.filtered( + lambda r: r.date in dates + ).update({"cancel_discount": discount}) + reservation.reservation_line_ids.filtered( + lambda r: r.date not in dates + ).update({"cancel_discount": 100}) + else: + reservation.reservation_line_ids.update({"cancel_discount": 0}) + else: + reservation.reservation_line_ids.update({"cancel_discount": 0}) @api.depends("room_id", "pms_property_id", "date", "occupies_availability") def _compute_avail_id(self): @@ -473,17 +469,6 @@ class PmsReservationLine(models.Model): if duplicated: raise ValidationError(_("Duplicated reservation line date")) - @api.constrains("state") - def constrains_service_cancel(self): - for record in self: - if record.state == "cancel": - room_services = record.reservation_id.service_ids - for service in room_services: - cancel_lines = service.service_line_ids.filtered( - lambda r: r.date == record.date - ) - cancel_lines.day_qty = 0 - @api.constrains("room_id") def _check_adults(self): for record in self.filtered("room_id"): diff --git a/pms/models/pms_service.py b/pms/models/pms_service.py index fe0f1fba9..858c0cfa7 100644 --- a/pms/models/pms_service.py +++ b/pms/models/pms_service.py @@ -168,7 +168,7 @@ class PmsService(models.Model): ) price_total = fields.Monetary( string="Total", - help="Total price without taxes", + help="Total price with taxes", readonly=True, store=True, compute="_compute_amount_service", @@ -181,6 +181,15 @@ class PmsService(models.Model): compute="_compute_amount_service", ) + discount = fields.Float( + string="Discount (€)", + help="Discount of total price", + readonly=False, + store=True, + digits=("Discount"), + compute="_compute_discount", + ) + # Compute and Search methods @api.depends("product_id") def _compute_tax_ids(self): @@ -380,7 +389,16 @@ class PmsService(models.Model): else: service.service_line_ids = False - # Default methods + @api.depends("service_line_ids.cancel_discount") + def _compute_discount(self): + for record in self: + discount = 0 + for line in record.service_line_ids: + first_discount = line.price_day_total * ((line.discount or 0.0) * 0.01) + price = line.price_day_total - first_discount + cancel_discount = price * ((line.cancel_discount or 0.0) * 0.01) + discount += first_discount + cancel_discount + record.discount = discount def name_get(self): result = [] diff --git a/pms/models/pms_service_line.py b/pms/models/pms_service_line.py index 5ba12f371..7e316d4ae 100644 --- a/pms/models/pms_service_line.py +++ b/pms/models/pms_service_line.py @@ -1,6 +1,8 @@ # Copyright 2017-2018 Alexandre Díaz # Copyright 2017 Dario Lodeiros # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import datetime + from odoo import _, api, fields, models from odoo.exceptions import ValidationError @@ -71,7 +73,7 @@ class PmsServiceLine(models.Model): ) price_day_total = fields.Monetary( string="Total", - help="Total price without taxes", + help="Total price with taxes", readonly=True, store=True, compute="_compute_day_amount_service", @@ -169,60 +171,35 @@ class PmsServiceLine(models.Model): record.discount = 0 # TODO: Refact method and allowed cancelled single days - @api.depends("service_id.reservation_id.cancelled_reason") + @api.depends("service_id.reservation_id.reservation_line_ids.cancel_discount") def _compute_cancel_discount(self): for line in self: line.cancel_discount = 0 - # TODO: Review cancel logic - # reservation = line.reservation_id.reservation_id - # pricelist = reservation.pricelist_id - # if reservation.state == "cancel": - # if ( - # reservation.cancel_reason - # and pricelist - # and pricelist.cancelation_rule_id - # ): - # date_start_dt = fields.Date.from_string( - # reservation.checkin - # ) - # date_end_dt = fields.Date.from_string( - # reservation.checkout - # ) - # days = abs((date_end_dt - date_start_dt).days) - # rule = pricelist.cancelation_rule_id - # if reservation.cancelled_reason == "late": - # discount = 100 - rule.penalty_late - # if rule.apply_on_late == "first": - # days = 1 - # elif rule.apply_on_late == "days": - # days = rule.days_late - # elif reservation.cancelled_reason == "noshow": - # discount = 100 - rule.penalty_noshow - # if rule.apply_on_noshow == "first": - # days = 1 - # elif rule.apply_on_noshow == "days": - # days = rule.days_late - 1 - # elif reservation.cancelled_reason == "intime": - # discount = 100 - - # checkin = reservation.checkin - # dates = [] - # for i in range(0, days): - # dates.append( - # ( - # fields.Date.from_string(checkin) + timedelta(days=i) - # ).strftime(DEFAULT_SERVER_DATE_FORMAT) - # ) - # reservation.reservation_line_ids.filtered( - # lambda r: r.date in dates - # ).update({"cancel_discount": discount}) - # reservation.reservation_line_ids.filtered( - # lambda r: r.date not in dates - # ).update({"cancel_discount": 100}) - # else: - # reservation.reservation_line_ids.update({"cancel_discount": 0}) - # else: - # reservation.reservation_line_ids.update({"cancel_discount": 0}) + reservation = line.reservation_id + if reservation.state == "cancel": + if ( + reservation.cancelled_reason + and reservation.pricelist_id + and reservation.pricelist_id.cancelation_rule_id + and reservation.reservation_line_ids.mapped("cancel_discount") + ): + if line.is_board_service: + consumed_date = ( + line.date + if line.product_id.consumed_on == "before" + else line.date + datetime.timedelta(days=-1) + ) + line.cancel_discount = ( + reservation.reservation_line_ids.filtered( + lambda l: l.date == consumed_date + ).cancel_discount + ) + else: + line.cancel_discount = 100 + else: + line.cancel_discount = 0 + else: + line.cancel_discount = 0 @api.depends("day_qty") def _compute_auto_qty(self): diff --git a/pms/tests/test_pms_reservation.py b/pms/tests/test_pms_reservation.py index e65e6ba47..179534974 100644 --- a/pms/tests/test_pms_reservation.py +++ b/pms/tests/test_pms_reservation.py @@ -2569,3 +2569,425 @@ class TestPmsReservations(TestPms): "Reservation from " + agency.name, "Partner name doesn't match with to the expected", ) + + @freeze_time("2010-11-10") + def test_cancel_discount_board_service(self): + """ + When a reservation is cancelled, service discount in case of board_services + must be equal to the discounts of each reservation_line. + + """ + + # ARRANGE + self.cancelation_rule = self.env["pms.cancelation.rule"].create( + { + "name": "Cancelation Rule Test", + "penalty_noshow": 50, + "apply_on_noshow": "all", + } + ) + + self.pricelist1.cancelation_rule_id = self.cancelation_rule.id + + self.product = self.env["product.product"].create( + { + "name": "Product test", + "per_day": True, + "consumed_on": "after", + } + ) + self.board_service = self.env["pms.service"].create( + { + "is_board_service": True, + "product_id": self.product.id, + } + ) + + self.room_type_double.list_price = 25 + reservation = self.env["pms.reservation"].create( + { + "checkin": fields.date.today() + datetime.timedelta(days=-3), + "checkout": fields.date.today() + datetime.timedelta(days=3), + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "pricelist_id": self.pricelist1.id, + "service_ids": [self.board_service.id], + } + ) + # ACTION + reservation.action_cancel() + reservation.flush() + + # ASSERT + self.assertEqual( + set(reservation.reservation_line_ids.mapped("cancel_discount")), + set(reservation.service_ids.service_line_ids.mapped("cancel_discount")), + "Cancel discount of reservation service lines must be the same " + "that reservation board services", + ) + + @freeze_time("2011-10-10") + def test_cancel_discount_reservation_line(self): + """ + When a reservation is cancelled, cancellation discount is given + by the cancellation rule associated with the reservation pricelist. + Each reservation_line calculates depending on the cancellation + reason which is the correspondig discount. In this case the + cancellation reason is'noshow' and the rule specifies that 50% must + be reducted every day, that is, on each of reseravtion_lines + """ + # ARRANGE + self.cancelation_rule = self.env["pms.cancelation.rule"].create( + { + "name": "Cancelation Rule Test", + "penalty_noshow": 50, + "apply_on_noshow": "all", + } + ) + + self.pricelist1.cancelation_rule_id = self.cancelation_rule.id + + self.room_type_double.list_price = 50 + reservation = self.env["pms.reservation"].create( + { + "checkin": fields.date.today() + datetime.timedelta(days=-3), + "checkout": fields.date.today() + datetime.timedelta(days=3), + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "pricelist_id": self.pricelist1.id, + } + ) + + # ACTION + reservation.action_cancel() + reservation.flush() + + # ASSERT + self.assertEqual( + set(reservation.reservation_line_ids.mapped("cancel_discount")), + {self.cancelation_rule.penalty_noshow}, + "Cancel discount of reservation_lines must be equal than cancellation rule penalty", + ) + + @freeze_time("2011-11-11") + def test_cancel_discount_service(self): + """ + When a reservation is cancelled, service discount in + services that are not board_services ALWAYS have to be 100%, + refardless of the cancellation rule associated with the pricelist + """ + # ARRANGE + self.cancelation_rule = self.env["pms.cancelation.rule"].create( + { + "name": "Cancelation Rule Test", + "penalty_noshow": 50, + "apply_on_noshow": "all", + } + ) + + self.pricelist1.cancelation_rule_id = self.cancelation_rule.id + + self.product = self.env["product.product"].create( + { + "name": "Product test", + "per_day": True, + "consumed_on": "after", + "is_extra_bed": True, + } + ) + self.service = self.env["pms.service"].create( + { + "is_board_service": False, + "product_id": self.product.id, + } + ) + + reservation = self.env["pms.reservation"].create( + { + "checkin": fields.date.today() + datetime.timedelta(days=-3), + "checkout": fields.date.today() + datetime.timedelta(days=3), + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "pricelist_id": self.pricelist1.id, + "service_ids": [self.service.id], + } + ) + + expected_cancel_discount = 100 + + # ACTION + reservation.action_cancel() + reservation.flush() + + # ASSERT + self.assertEqual( + {expected_cancel_discount}, + set(reservation.service_ids.service_line_ids.mapped("cancel_discount")), + "Cancel discount of services must be 100%", + ) + + @freeze_time("2011-06-06") + def test_discount_in_service(self): + """ + Discount in pms.service is calculated from the + discounts that each if its service lines has, + in this case when reservation is cancelled a + 50% cancellation discount is applied and + there aren't other different discounts + """ + + # ARRANGE + self.cancelation_rule = self.env["pms.cancelation.rule"].create( + { + "name": "Cancelation Rule Test", + "penalty_noshow": 50, + "apply_on_noshow": "all", + } + ) + + self.pricelist1.cancelation_rule_id = self.cancelation_rule.id + + self.product = self.env["product.product"].create( + { + "name": "Product test", + "per_day": True, + "consumed_on": "after", + } + ) + self.board_service = self.env["pms.service"].create( + { + "is_board_service": True, + "product_id": self.product.id, + } + ) + + self.room_type_double.list_price = 25 + reservation = self.env["pms.reservation"].create( + { + "checkin": fields.date.today() + datetime.timedelta(days=-3), + "checkout": fields.date.today() + datetime.timedelta(days=3), + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "pricelist_id": self.pricelist1.id, + "service_ids": [self.board_service.id], + } + ) + + # ACTION + reservation.action_cancel() + reservation.flush() + + expected_discount = sum( + sl.price_day_total * sl.cancel_discount / 100 + for sl in self.board_service.service_line_ids + ) + # ASSERT + self.assertEqual( + expected_discount, + self.board_service.discount, + "Service discount must be the sum of its services_lines discount", + ) + + @freeze_time("2011-11-11") + def test_services_discount_in_reservation(self): + """ + Services discount in reservation is equal to the sum of the discounts of all + its services, whether they are board_services or not + """ + # ARRANGE + self.cancelation_rule = self.env["pms.cancelation.rule"].create( + { + "name": "Cancelation Rule Test", + "penalty_noshow": 50, + "apply_on_noshow": "all", + } + ) + + self.pricelist1.cancelation_rule_id = self.cancelation_rule.id + + self.product1 = self.env["product.product"].create( + { + "name": "Product test1", + "per_day": True, + "consumed_on": "after", + "is_extra_bed": True, + } + ) + self.service = self.env["pms.service"].create( + { + "is_board_service": False, + "product_id": self.product1.id, + } + ) + self.service.flush() + self.product2 = self.env["product.product"].create( + { + "name": "Product test 2", + "per_person": True, + "consumed_on": "after", + } + ) + self.board_service = self.env["pms.service"].create( + { + "is_board_service": True, + "product_id": self.product2.id, + } + ) + + self.room_type_double.list_price = 25 + checkin = fields.date.today() + datetime.timedelta(days=-3) + checkout = fields.date.today() + datetime.timedelta(days=3) + reservation = self.env["pms.reservation"].create( + { + "checkin": checkin, + "checkout": checkout, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "pricelist_id": self.pricelist1.id, + "service_ids": [self.service.id, self.board_service.id], + } + ) + + # ACTION + reservation.action_cancel() + reservation.flush() + + expected_discount = sum(s.discount for s in reservation.service_ids) + + # ASSERT + self.assertEqual( + expected_discount, + reservation.services_discount, + "Services discount isn't the expected", + ) + + @freeze_time("2011-12-12") + def test_price_services_in_reservation(self): + """ + Service price total in a reservation corresponds to the sum of prices + of all its services less the total discount of that services + """ + # ARRANGE + self.cancelation_rule = self.env["pms.cancelation.rule"].create( + { + "name": "Cancelation Rule Test", + "penalty_noshow": 50, + "apply_on_noshow": "all", + } + ) + + self.pricelist1.cancelation_rule_id = self.cancelation_rule.id + + self.product1 = self.env["product.product"].create( + { + "name": "Product test1", + "per_day": True, + "consumed_on": "after", + "is_extra_bed": True, + } + ) + self.service = self.env["pms.service"].create( + { + "is_board_service": False, + "product_id": self.product1.id, + } + ) + self.service.flush() + self.product2 = self.env["product.product"].create( + { + "name": "Product test 2", + "per_person": True, + "consumed_on": "after", + } + ) + self.board_service = self.env["pms.service"].create( + { + "is_board_service": True, + "product_id": self.product2.id, + } + ) + + self.room_type_double.list_price = 25 + checkin = fields.date.today() + datetime.timedelta(days=-3) + checkout = fields.date.today() + datetime.timedelta(days=3) + reservation = self.env["pms.reservation"].create( + { + "checkin": checkin, + "checkout": checkout, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "pricelist_id": self.pricelist1.id, + "service_ids": [self.service.id, self.board_service.id], + } + ) + + # ACTION + reservation.action_cancel() + reservation.flush() + expected_price = ( + self.service.price_total + + self.board_service.price_total * reservation.adults + ) - reservation.services_discount + + # ASSERT + self.assertEqual( + expected_price, + reservation.price_services, + "Services price isn't the expected", + ) + + @freeze_time("2011-08-08") + def test_room_discount_in_reservation(self): + """ + Discount in pms.reservation is calculated from the + discounts that each if its reservation lines has, + in this case when reservation is cancelled a 50% + cancellation discount is applied and + there aren't other different discounts + """ + # ARRANGE + self.cancelation_rule = self.env["pms.cancelation.rule"].create( + { + "name": "Cancelation Rule Test", + "penalty_noshow": 50, + "apply_on_noshow": "all", + } + ) + + self.pricelist1.cancelation_rule_id = self.cancelation_rule.id + + self.room_type_double.list_price = 30 + checkin = fields.date.today() + datetime.timedelta(days=-3) + checkout = fields.date.today() + datetime.timedelta(days=3) + reservation = self.env["pms.reservation"].create( + { + "checkin": checkin, + "checkout": checkout, + "room_type_id": self.room_type_double.id, + "partner_id": self.partner1.id, + "pms_property_id": self.pms_property1.id, + "pricelist_id": self.pricelist1.id, + } + ) + + # ACTION + reservation.action_cancel() + reservation.flush() + + expected_discount = sum( + rl.price * rl.cancel_discount / 100 + for rl in reservation.reservation_line_ids + ) + + # ASSERT + self.assertEqual( + expected_discount, + reservation.discount, + "Room discount isn't the expected", + ) diff --git a/pms/views/pms_reservation_views.xml b/pms/views/pms_reservation_views.xml index 5b83161da..af3fd4b9a 100644 --- a/pms/views/pms_reservation_views.xml +++ b/pms/views/pms_reservation_views.xml @@ -405,6 +405,12 @@ widget="monetary" options="{'currency_field': 'currency_id'}" /> +