Skip to content

16.0 pms long stay#13

Draft
DarioLodeiros wants to merge 3 commits into
16.0from
16.0-pms_long_stay
Draft

16.0 pms long stay#13
DarioLodeiros wants to merge 3 commits into
16.0from
16.0-pms_long_stay

Conversation

@DarioLodeiros
Copy link
Copy Markdown
Member

@DarioLodeiros DarioLodeiros commented Nov 30, 2025

This pull request introduces the new "PMS Long Stay Reservations" module, which adds comprehensive support for managing long-stay bookings in the PMS system. The module enables reservations to be automatically split into weekly or monthly segments, each managed as an independent reservation but linked together for cohesive management and billing. It extends core models, introduces new configuration options, and seamlessly integrates with existing PMS pricing and service logic.

CAUTION:
This PR need some changes in PMS base: OCA/pms#364

Key features and changes include:

Long Stay Reservation Functionality:

  • Added a new reservation type (long_stay) to pms.reservation and pms.folio, enabling the creation and management of long-stay bookings. When such a reservation is created, it is automatically split into weekly or monthly segments, each represented as a separate reservation and linked via a new long stay group. Each segment also receives an automatically generated service line for billing, with the timing configurable on the property. [1] [2]

Configuration and Model Extensions:

  • Extended pms.property with new configuration fields: week_start_day (to define the start of the week for splitting) and long_stay_billing_timing (to control when the service is invoiced for each segment).
  • Extended pms.room_type and product_template (via imports) to support long-stay specific configuration, such as period type and dedicated products.

New Data Models:

  • Introduced the new model pms.reservation.long.stay.group to group and reference all reservation segments belonging to the same long-stay booking. This model stores period type, original check-in/out, and links to all related reservations.

Module Metadata and Documentation:

  • Added a module manifest (__manifest__.py) detailing dependencies, authorship, and data files, and a comprehensive README explaining the purpose, usage, and configuration of the module. [1] [2]

Module Initialization:

  • Added __init__.py files to ensure all new models and extensions are properly loaded when the module is installed. [1] [2]

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 1, 2026

Diff Coverage

Diff: origin/16.0...HEAD, staged and unstaged changes

  • pms_long_stay/models/pms_folio.py (55.6%): Missing lines 19-22
  • pms_long_stay/models/pms_property.py (100%)
  • pms_long_stay/models/pms_reservation.py (19.1%): Missing lines 44-45,48-49,54,56-58,62-64,67,78,86,94,104-108,120,122,125,131-134,136,146,148-150,164,166-167,169-170,172,174-177,181,184,187-188,190,192,199,202,207,209,211-212,218,220,236,238-240,242,244-245,247-248,250-251,265,267-269,271,273-275,277,279-281,284-286,289,292,294,297,311,334,337
  • pms_long_stay/models/pms_reservation_long_stay_group.py (100%)
  • pms_long_stay/models/pms_room_type.py (54.8%): Missing lines 41-43,50-51,67-68,71,88,92,104-105,109-110,127,129-131,133
  • pms_long_stay/models/product_template.py (100%)

Summary

  • Total: 179 lines
  • Missing: 112 lines
  • Coverage: 37%

pms_long_stay/models/pms_folio.py

Lines 15-23

  15         """
  16         Extend base service pricing types to include 'long_stay' so that
  17         long stay reservations also use standard service pricing logic.
  18         """
! 19         types = list(super()._get_reservation_types_with_service_pricing())
! 20         if "long_stay" not in types:
! 21             types.append("long_stay")
! 22         return tuple(types)

pms_long_stay/models/pms_reservation.py

Lines 40-71

  40         """
  41         if vals.get("reservation_type") != "long_stay":
  42             return super().create(vals)
  43 
! 44         if not vals.get("checkin") or not vals.get("checkout"):
! 45             raise ValidationError(
  46                 _("Check-in and Check-out are required for long stay reservations.")
  47             )
! 48         if not vals.get("room_type_id"):
! 49             raise ValidationError(
  50                 _("Room type is required for long stay reservations.")
  51             )
  52 
  53         # Create the initial reservation (will become the first segment)
! 54         master_reservation = super().create(vals)
  55 
! 56         room_type = master_reservation.room_type_id
! 57         if not room_type.long_stay_period:
! 58             raise ValidationError(
  59                 _("This room type has no long stay period configured.")
  60             )
  61 
! 62         period = room_type.long_stay_period
! 63         start = master_reservation.checkin
! 64         end = master_reservation.checkout
  65 
  66         # Create the group representing the whole original stay
! 67         group = self.env["pms.reservation.long.stay.group"].create(
  68             {
  69                 "name": "Long Stay %s"
  70                 % (master_reservation.name or master_reservation.id),
  71                 "period": period,

Lines 74-82

  74             }
  75         )
  76 
  77         # Link master to the group and mark as master
! 78         master_reservation.write(
  79             {
  80                 "long_stay_group_id": group.id,
  81                 "is_long_stay_master": True,
  82             }

Lines 82-90

  82             }
  83         )
  84 
  85         # Split the reservation: reuse master as first block, create the rest
! 86         master_reservation._split_long_stay_into_periods(
  87             period=period,
  88             start=start,
  89             end=end,
  90             group=group,

Lines 90-98

  90             group=group,
  91         )
  92 
  93         # The caller keeps working with the first segment (the reused master)
! 94         return master_reservation
  95 
  96     # ---------------------------------------------------------
  97     # HELPERS FOR PERIOD BOUNDARIES
  98     # ---------------------------------------------------------

Lines 100-112

  100     def _to_date(self, value):
  101         """
  102         Ensure we always work with date objects (not datetime).
  103         """
! 104         if isinstance(value, datetime):
! 105             return value.date()
! 106         if isinstance(value, date):
! 107             return value
! 108         raise ValueError("Unsupported type for date conversion: %s" % type(value))
  109 
  110     def _get_next_week_boundary_date(self, start_date):
  111         """
  112         Returns the end date of the weekly block based on hotel's configuration

Lines 116-129

  116         - week_start = monday   -> week ends on Sunday (6)
  117         - week_start = sunday   -> week ends on Saturday (5)
  118         - week_start = saturday -> week ends on Friday (4)
  119         """
! 120         self.ensure_one()
  121 
! 122         week_start = self.pms_property_id.week_start_day or "monday"
  123 
  124         # Python weekday(): Monday=0 ... Sunday=6
! 125         target_end_day = {
  126             "monday": 6,  # ends Sunday
  127             "sunday": 5,  # ends Saturday
  128             "saturday": 4,  # ends Friday
  129         }[week_start]

Lines 127-140

  127             "sunday": 5,  # ends Saturday
  128             "saturday": 4,  # ends Friday
  129         }[week_start]
  130 
! 131         weekday = start_date.weekday()
! 132         days_to_boundary = (target_end_day - weekday) % 7
! 133         if days_to_boundary == 0:
! 134             days_to_boundary = 7  # avoid zero-length interval
  135 
! 136         return start_date + timedelta(days=days_to_boundary)
  137 
  138     def _get_next_month_boundary_date(self, start_date):
  139         """
  140         Returns the end date of the monthly block.

Lines 142-154

  142         Example:
  143         - start 23 Jan -> boundary 1 Feb
  144         - start 5 Mar  -> boundary 1 Apr
  145         """
! 146         self.ensure_one()
  147 
! 148         base = start_date.replace(day=1)
! 149         next_month_first = base + relativedelta(months=1)
! 150         return next_month_first
  151 
  152     # ---------------------------------------------------------
  153     # SPLIT LOGIC
  154     # ---------------------------------------------------------

Lines 160-196

  160         All calculations are date-based (no time, no timezone).
  161         Additionally, each segment gets an automatic long stay service
  162         line using the room type's long stay product.
  163         """
! 164         self.ensure_one()
  165 
! 166         start_date = self._to_date(start)
! 167         end_date = self._to_date(end)
  168 
! 169         current_start = start_date
! 170         segment_index = 0
  171 
! 172         while current_start < end_date:
  173             # Compute candidate boundary based on period type
! 174             if period == "weekly":
! 175                 current_end_candidate = self._get_next_week_boundary_date(current_start)
! 176             elif period == "monthly":
! 177                 current_end_candidate = self._get_next_month_boundary_date(
  178                     current_start
  179                 )
  180             else:
! 181                 current_end_candidate = end_date
  182 
  183             # Clip to final checkout
! 184             current_end = min(current_end_candidate, end_date)
  185 
  186             # Safety guard to avoid zero-length loops
! 187             if current_end <= current_start:
! 188                 break
  189 
! 190             if segment_index == 0:
  191                 # First segment: reuse current reservation
! 192                 self.write(
  193                     {
  194                         "checkin": current_start,
  195                         "checkout": current_end,
  196                     }

Lines 195-216

  195                         "checkout": current_end,
  196                     }
  197                 )
  198                 # Create long stay service for this segment
! 199                 self._create_long_stay_service_for_segment()
  200             else:
  201                 # Subsequent segments: create new reservations
! 202                 child_vals = self._prepare_long_stay_child_vals(
  203                     checkin=current_start,
  204                     checkout=current_end,
  205                     group=group,
  206                 )
! 207                 child_res = super().create(child_vals)
  208                 # Create long stay service for the new segment
! 209                 child_res._create_long_stay_service_for_segment()
  210 
! 211             current_start = current_end
! 212             segment_index += 1
  213 
  214     def _prepare_long_stay_child_vals(self, checkin, checkout, group):
  215         """
  216         Prepare values for child reservations based on the master reservation.

Lines 214-224

  214     def _prepare_long_stay_child_vals(self, checkin, checkout, group):
  215         """
  216         Prepare values for child reservations based on the master reservation.
  217         """
! 218         self.ensure_one()
  219 
! 220         return {
  221             "reservation_type": "long_stay",
  222             "long_stay_group_id": group.id,
  223             "room_type_id": self.room_type_id.id,
  224             "folio_id": self.folio_id.id,

Lines 232-255

  232     # ---------------------------------------------------------
  233     # SERVICE LONG STAY
  234     # --------------------------------------------------------
  235     def _get_long_stay_service_description(self):
! 236         self.ensure_one()
  237 
! 238         room_type = self.room_type_id
! 239         checkin_date = self._to_date(self.checkin)
! 240         period = room_type.long_stay_period or "monthly"
  241 
! 242         env_lang = self.env(context=dict(self.env.context, lang=self.lang))
  243 
! 244         month_label = format_date(env_lang, checkin_date)
! 245         room_name = room_type.display_name or ""
  246 
! 247         if period == "monthly":
! 248             return f"{month_label} - {room_name}"
  249 
! 250         week_index = ((checkin_date.day - 1) // 7) + 1
! 251         return f"S{week_index} {month_label} - {room_name}"
  252 
  253     def _create_long_stay_service_for_segment(self):
  254         """
  255         Creates the long stay service for this reservation segment.

Lines 261-301

  261             * 'end'   -> last night of the segment (checkout - 1 day)
  262         - Price is computed using pms.service._get_price_unit_line()
  263         with the consumption_date set to the last night.
  264         """
! 265         self.ensure_one()
  266 
! 267         room_type = self.room_type_id
! 268         product_tmpl = room_type.long_stay_product_id
! 269         if not product_tmpl:
  270             # No long stay product configured for this room type
! 271             return
  272 
! 273         product = product_tmpl.product_variant_id
! 274         if not product:
! 275             return
  276 
! 277         property_rec = self.pms_property_id
  278 
! 279         checkin_date = self._to_date(self.checkin)
! 280         checkout_date = self._to_date(self.checkout)
! 281         last_night_date = checkout_date - timedelta(days=1)
  282 
  283         # Date used in the service line depends on billing timing configuration
! 284         billing_timing = property_rec.long_stay_billing_timing or "end"
! 285         if billing_timing == "start":
! 286             line_date = checkin_date
  287         else:
  288             # 'end' -> use the last night of the interval
! 289             line_date = last_night_date
  290 
  291         # Consumption date is always the last night of the stay
! 292         consumption_date = last_night_date
  293 
! 294         description = self._get_long_stay_service_description()
  295 
  296         # Try to keep sale channel consistent with the reservation/folio
! 297         sale_channel_id = (
  298             (
  299                 getattr(self, "sale_channel_origin_id", False)
  300                 and self.sale_channel_origin_id.id
  301             )

Lines 307-315

  307             or False
  308         )
  309 
  310         # Create the service with a single service line
! 311         service = self.env["pms.service"].create(
  312             {
  313                 "product_id": product.id,
  314                 "folio_id": self.folio_id.id,
  315                 "reservation_id": self.id,

Lines 330-338

  330             }
  331         )
  332 
  333         # Compute price using existing pricing method, passing the consumption_date
! 334         price = service._get_price_unit_line(date=consumption_date)
  335 
  336         # Update line price with computed value
! 337         service.service_line_ids.write({"price_unit": price})

pms_long_stay/models/pms_room_type.py

Lines 37-47

  37         """
  38         Ensure that both long_stay_period and long_stay_price
  39         are set together. Partial configuration is not allowed.
  40         """
! 41         for room_type in self:
! 42             if room_type.long_stay_period and not room_type.long_stay_price:
! 43                 raise ValidationError(
  44                     _(
  45                         "You must set a Long Stay Base Price when a Long Stay "
  46                         "Period is defined for room type '%s'."
  47                     )

Lines 46-55

  46                         "Period is defined for room type '%s'."
  47                     )
  48                     % room_type.display_name
  49                 )
! 50             if room_type.long_stay_price and not room_type.long_stay_period:
! 51                 raise ValidationError(
  52                     _(
  53                         "You must set a Long Stay Period when a Long Stay "
  54                         "Base Price is defined for room type '%s'."
  55                     )

Lines 63-75

  63 
  64         Example:
  65         "Double Room long stay monthly"
  66         """
! 67         self.ensure_one()
! 68         period_label = dict(self._fields["long_stay_period"].selection).get(
  69             self.long_stay_period, ""
  70         )
! 71         return f"{self.display_name} long stay {period_label.lower()}"
  72 
  73     def _create_or_update_long_stay_product(self):
  74         """
  75         Automatically create or update the product.template used for

Lines 84-96

  84         for room_type in self:
  85             # If long stay configuration is incomplete, deactivate product
  86             if not room_type.long_stay_period or not room_type.long_stay_price:
  87                 if room_type.long_stay_product_id:
! 88                     room_type.long_stay_product_id.active = False
  89                 continue
  90 
  91             # Build product values
! 92             vals = {
  93                 "name": room_type._get_long_stay_product_name(),
  94                 "is_long_stay_product": True,
  95                 "sale_ok": False,  # Not visible as a sellable service
  96                 "list_price": room_type.long_stay_price,

Lines 100-114

  100                 "categ_id": self.env.ref("pms.product_category_service").id,
  101             }
  102 
  103             # Update existing product
! 104             if room_type.long_stay_product_id:
! 105                 room_type.long_stay_product_id.write(vals)
  106 
  107             # Create new product
  108             else:
! 109                 product = ProductTemplate.create(vals)
! 110                 room_type.long_stay_product_id = product.id
  111 
  112     @api.model
  113     def create(self, vals):
  114         """

Lines 123-134

  123         """
  124         Extend write() to update or create the long stay product
  125         whenever the relevant configuration changes.
  126         """
! 127         res = super().write(vals)
  128 
! 129         tracked_fields = {"long_stay_period", "long_stay_price", "long_stay_tax_ids"}
! 130         if tracked_fields.intersection(vals.keys()):
! 131             self._create_or_update_long_stay_product()
  132 
! 133         return res

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant