diff --git a/addons/dine360_reservation/__manifest__.py b/addons/dine360_reservation/__manifest__.py index 81ef4f0..72ea698 100644 --- a/addons/dine360_reservation/__manifest__.py +++ b/addons/dine360_reservation/__manifest__.py @@ -16,8 +16,12 @@ 'security/ir.model.access.csv', 'data/reservation_sequence.xml', 'data/reservation_cron.xml', + 'data/reservation_schedule_data.xml', 'data/website_menu.xml', 'views/reservation_views.xml', + 'views/restaurant_table_views.xml', + 'views/reservation_schedule_views.xml', + 'views/reservation_holiday_views.xml', 'views/reservation_templates.xml', 'views/menu_items.xml', ], diff --git a/addons/dine360_reservation/controllers/__pycache__/main.cpython-310.pyc b/addons/dine360_reservation/controllers/__pycache__/main.cpython-310.pyc index 6522f02..b4c5937 100644 Binary files a/addons/dine360_reservation/controllers/__pycache__/main.cpython-310.pyc and b/addons/dine360_reservation/controllers/__pycache__/main.cpython-310.pyc differ diff --git a/addons/dine360_reservation/controllers/main.py b/addons/dine360_reservation/controllers/main.py index 8d5888d..70ce2de 100644 --- a/addons/dine360_reservation/controllers/main.py +++ b/addons/dine360_reservation/controllers/main.py @@ -7,47 +7,260 @@ class TableReservationController(http.Controller): @http.route(['/reservation'], type='http', auth="public", website=True) def reservation_form(self, **post): - floors = request.env['restaurant.floor'].sudo().search([]) - tables = request.env['restaurant.table'].sudo().search([]) + schedule = request.env['reservation.schedule'].sudo().search([]) return request.render("dine360_reservation.reservation_page_template", { - 'floors': floors, - 'tables': tables, + 'schedule': schedule }) + def _get_slot_duration(self, schedule, time_float): + """ Returns the slot duration for a given time based on peak hour settings """ + if not schedule: + return 1.0 + for peak in schedule.peak_hour_ids: + if peak.start_time <= time_float < peak.end_time: + return float(peak.slot_duration) + return float(schedule.default_slot_duration or 1.0) + + def _find_best_tables(self, floor, available_floor_tables, num_people): + """ Find the best combination of nearby tables on a floor, preferring same zone """ + # Case 1: Single table fits + singles = [t for t in available_floor_tables if t.min_party_size <= num_people <= t.max_party_size] + if singles: + best_single = min(singles, key=lambda t: t.max_party_size) + return [best_single] + + # Case 2: Merge needed + # Group tables by zone + zones = {} + for t in available_floor_tables: + z = t.zone or 'Default' + if z not in zones: zones[z] = [] + zones[z].append(t) + + best_overall_combo = [] + min_overall_dist = float('inf') + + # Try merging within each zone first + for zone_name, zone_tables in zones.items(): + if sum(t.max_party_size for t in zone_tables) < num_people: + continue + + for start_table in zone_tables: + current_combo = [start_table] + current_cap = start_table.max_party_size + others = [t for t in zone_tables if t.id != start_table.id] + + while current_cap < num_people and others: + nearest = min(others, key=lambda o: min( + (((o.position_h or 0) - (c.position_h or 0))**2 + ((o.position_v or 0) - (c.position_v or 0))**2)**0.5 + for c in current_combo + )) + current_combo.append(nearest) + current_cap += nearest.max_party_size + others.remove(nearest) + + if current_cap >= num_people: + dist_sum = 0 + for i in range(len(current_combo)): + for j in range(i + 1, len(current_combo)): + t1, t2 = current_combo[i], current_combo[j] + dist_sum += (((t1.position_h or 0) - (t2.position_h or 0))**2 + ((t1.position_v or 0) - (t2.position_v or 0))**2)**0.5 + + if dist_sum < min_overall_dist: + min_overall_dist = dist_sum + best_overall_combo = current_combo + + # If no same-zone combo found, use the best combo across the whole floor + if not best_overall_combo: + for start_table in available_floor_tables: + current_combo = [start_table] + current_cap = start_table.max_party_size + others = [t for t in available_floor_tables if t.id != start_table.id] + while current_cap < num_people and others: + nearest = min(others, key=lambda o: min( + (((o.position_h or 0) - (c.position_h or 0))**2 + ((o.position_v or 0) - (c.position_v or 0))**2)**0.5 + for c in current_combo + )) + current_combo.append(nearest) + current_cap += nearest.max_party_size + others.remove(nearest) + if current_cap >= num_people: + dist_sum = 0 + for i in range(len(current_combo)): + for j in range(i + 1, len(current_combo)): + t1, t2 = current_combo[i], current_combo[j] + dist_sum += (((t1.position_h or 0) - (t2.position_h or 0))**2 + ((t1.position_v or 0) - (t2.position_v or 0))**2)**0.5 + if dist_sum < min_overall_dist: + min_overall_dist = dist_sum + best_overall_combo = current_combo + + return best_overall_combo + + @http.route(['/reservation/get_slots'], type='json', auth="public", website=True) + def get_available_slots(self, date_str, num_people): + try: + res_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date() + num_people = int(num_people) + except: + return {'error': 'Invalid date or guest count'} + + # 1. Check for Holiday + holiday = request.env['reservation.holiday'].sudo().search([('date', '=', res_date)], limit=1) + if holiday and holiday.is_closed: + return {'error': 'The restaurant is closed on this day for %s.' % holiday.name} + + # 2. Get Schedule + day = str(res_date.weekday()) + schedule = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1) + if not schedule or schedule.is_closed: + return {'error': 'Reservations are not available on this day.'} + + # Determine operating hours + opening = holiday.opening_time if (holiday and holiday.opening_time) else schedule.opening_time + closing = holiday.closing_time if (holiday and holiday.closing_time) else schedule.closing_time + + # 3. Generate Slots (intervals based on table duration) + slots = [] + current_time = opening + restaurant_tz = pytz.timezone('America/Toronto') + now_local = datetime.datetime.now(restaurant_tz).replace(tzinfo=None) + + all_tables = request.env['restaurant.table'].sudo().search([('is_reservation_enabled', '=', True)]) + floors = request.env['restaurant.floor'].sudo().search([]) + + while current_time < closing: + duration = self._get_slot_duration(schedule, current_time) + slot_start_dt = datetime.datetime.combine(res_date, datetime.time(int(current_time), int((current_time % 1) * 60))) + slot_end_dt = slot_start_dt + datetime.timedelta(hours=duration) + + if slot_start_dt > now_local: + # Check for Break + is_in_break = False + if schedule.has_break: + if current_time < schedule.break_end_time and (current_time + duration) > schedule.break_start_time: + is_in_break = True + + if not is_in_break: + best_available_combo = [] + + for floor in floors: + floor_tables = all_tables.filtered(lambda t: t.floor_id == floor) + available_on_floor = [] + for table in floor_tables: + overlap = request.env['restaurant.reservation'].sudo().search([ + ('table_ids', 'in', [table.id]), + ('state', 'in', ['confirmed', 'draft']), + ('start_time', '<', slot_end_dt), + ('end_time', '>', slot_start_dt), + ]) + if not overlap: + available_on_floor.append(table) + + combo = self._find_best_tables(floor, available_on_floor, num_people) + if combo and (not best_available_combo or len(combo) < len(best_available_combo)): + best_available_combo = combo + if len(combo) == 1: break # Perfect match found + + if best_available_combo: + hours_int = int(current_time) + mins_int = int((current_time % 1) * 60) + ampm = "AM" if hours_int < 12 else "PM" + disp_h = hours_int if hours_int <= 12 else hours_int - 12 + if disp_h == 0: disp_h = 12 + + table_names = ", ".join([t.name for t in best_available_combo]) + floor_name = best_available_combo[0].floor_id.name + + slots.append({ + 'time': f"{hours_int:02d}:{mins_int:02d}", + 'display': f"{disp_h}:{mins_int:02d} {ampm}", + 'full_dt': slot_start_dt.strftime('%Y-%m-%dT%H:%M'), + 'tables': f"{floor_name} - {table_names}" + }) + + # Use the calculated duration as the interval to respect user configuration + current_time += duration + + return {'slots': slots} + @http.route(['/reservation/submit'], type='http', auth="public", website=True, methods=['POST'], csrf=True) def reservation_submit(self, **post): - # Extract data from post + # Extract data customer_name = post.get('customer_name') phone = post.get('phone') email = post.get('email') - floor_id = int(post.get('floor_id')) - table_id = int(post.get('table_id')) - start_time_str = post.get('start_time') # Format: 2024-05-20T18:00 num_people = int(post.get('num_people', 1)) + start_time_str = post.get('start_time') + + if not start_time_str: + return request.render("dine360_reservation.reservation_page_template", {'error': 'Please select a time slot.'}) - # Convert start_time to datetime object and localize to restaurant timezone (Brampton) + # Convert start_time to datetime object and localize to restaurant timezone (America/Toronto) restaurant_tz = pytz.timezone('America/Toronto') - local_start = datetime.datetime.strptime(start_time_str, '%Y-%m-%dT%H:%M') - local_start = restaurant_tz.localize(local_start) + try: + local_start = datetime.datetime.strptime(start_time_str, '%Y-%m-%dT%H:%M') + local_start = restaurant_tz.localize(local_start) + # Convert to UTC for Odoo + start_time = local_start.astimezone(pytz.utc).replace(tzinfo=None) + except Exception: + return request.render("dine360_reservation.reservation_page_template", { + 'error': _("Invalid date or time format."), + 'post': post, + }) - # Convert to UTC for Odoo - start_time = local_start.astimezone(pytz.utc).replace(tzinfo=None) + res_date = local_start.date() # Use local_start for date to get correct weekday - # Standard duration of 1 hour for now - end_time = start_time + datetime.timedelta(hours=1) + # Determine Duration + day = str(res_date.weekday()) + schedule = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1) + time_float = local_start.hour + local_start.minute / 60.0 + duration = self._get_slot_duration(schedule, time_float) + end_time = start_time + datetime.timedelta(hours=duration) - # Create reservation + # FIND TABLES (Nearest Merge) + assigned_tables = [] + target_floor = False + + all_floors = request.env['restaurant.floor'].sudo().search([]) + for floor in all_floors: + floor_tables = request.env['restaurant.table'].sudo().search([ + ('floor_id', '=', floor.id), + ('is_reservation_enabled', '=', True) + ]) + + available_tables = [] + for table in floor_tables: + overlap = request.env['restaurant.reservation'].sudo().search([ + ('table_ids', 'in', [table.id]), + ('state', 'in', ['confirmed', 'draft']), + ('start_time', '<', end_time), + ('end_time', '>', start_time), + ]) + if not overlap: + available_tables.append(table) + + combo = self._find_best_tables(floor, available_tables, num_people) + if combo and (not assigned_tables or len(combo) < len(assigned_tables)): + assigned_tables = combo + target_floor = floor + if len(combo) == 1: break + + if not assigned_tables: + return request.render("dine360_reservation.reservation_page_template", {'error': 'Sorry, no tables available for this time/group size.'}) + + # Create Reservation try: reservation = request.env['restaurant.reservation'].sudo().create({ 'customer_name': customer_name, 'phone': phone, 'email': email, - 'floor_id': floor_id, - 'table_id': table_id, + 'num_people': num_people, + 'floor_id': target_floor.id, + 'table_ids': [(6, 0, [t.id for t in assigned_tables])], + 'table_id': assigned_tables[0].id, 'start_time': start_time, 'end_time': end_time, - 'num_people': num_people, - 'state': 'draft' + 'state': 'confirmed' # Direct confirmation from website }) return request.render("dine360_reservation.reservation_success_template", { 'reservation': reservation, @@ -55,7 +268,5 @@ class TableReservationController(http.Controller): except Exception as e: return request.render("dine360_reservation.reservation_page_template", { 'error': str(e), - 'floors': request.env['restaurant.floor'].sudo().search([]), - 'tables': request.env['restaurant.table'].sudo().search([]), 'post': post, }) diff --git a/addons/dine360_reservation/data/reservation_schedule_data.xml b/addons/dine360_reservation/data/reservation_schedule_data.xml new file mode 100644 index 0000000..4522cde --- /dev/null +++ b/addons/dine360_reservation/data/reservation_schedule_data.xml @@ -0,0 +1,40 @@ + + + + + 0 + 12.0 + 21.0 + + + 1 + 12.0 + 21.0 + + + 2 + 12.0 + 21.0 + + + 3 + 12.0 + 21.0 + + + 4 + 12.0 + 23.0 + + + 5 + 12.0 + 23.0 + + + 6 + 12.0 + 21.0 + + + diff --git a/addons/dine360_reservation/models/__init__.py b/addons/dine360_reservation/models/__init__.py index 7c07528..efba3da 100644 --- a/addons/dine360_reservation/models/__init__.py +++ b/addons/dine360_reservation/models/__init__.py @@ -1 +1,5 @@ from . import restaurant_reservation +from . import restaurant_table +from . import reservation_schedule +from . import reservation_holiday +from . import reservation_peak_hour diff --git a/addons/dine360_reservation/models/__pycache__/__init__.cpython-310.pyc b/addons/dine360_reservation/models/__pycache__/__init__.cpython-310.pyc index a7b05de..12bdb0d 100644 Binary files a/addons/dine360_reservation/models/__pycache__/__init__.cpython-310.pyc and b/addons/dine360_reservation/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/dine360_reservation/models/__pycache__/reservation_holiday.cpython-310.pyc b/addons/dine360_reservation/models/__pycache__/reservation_holiday.cpython-310.pyc new file mode 100644 index 0000000..6d99c1a Binary files /dev/null and b/addons/dine360_reservation/models/__pycache__/reservation_holiday.cpython-310.pyc differ diff --git a/addons/dine360_reservation/models/__pycache__/reservation_peak_hour.cpython-310.pyc b/addons/dine360_reservation/models/__pycache__/reservation_peak_hour.cpython-310.pyc new file mode 100644 index 0000000..b9e7637 Binary files /dev/null and b/addons/dine360_reservation/models/__pycache__/reservation_peak_hour.cpython-310.pyc differ diff --git a/addons/dine360_reservation/models/__pycache__/reservation_schedule.cpython-310.pyc b/addons/dine360_reservation/models/__pycache__/reservation_schedule.cpython-310.pyc new file mode 100644 index 0000000..6e5535e Binary files /dev/null and b/addons/dine360_reservation/models/__pycache__/reservation_schedule.cpython-310.pyc differ diff --git a/addons/dine360_reservation/models/__pycache__/restaurant_reservation.cpython-310.pyc b/addons/dine360_reservation/models/__pycache__/restaurant_reservation.cpython-310.pyc index 628bbd5..b2bf0a2 100644 Binary files a/addons/dine360_reservation/models/__pycache__/restaurant_reservation.cpython-310.pyc and b/addons/dine360_reservation/models/__pycache__/restaurant_reservation.cpython-310.pyc differ diff --git a/addons/dine360_reservation/models/__pycache__/restaurant_table.cpython-310.pyc b/addons/dine360_reservation/models/__pycache__/restaurant_table.cpython-310.pyc new file mode 100644 index 0000000..f513e3c Binary files /dev/null and b/addons/dine360_reservation/models/__pycache__/restaurant_table.cpython-310.pyc differ diff --git a/addons/dine360_reservation/models/reservation_holiday.py b/addons/dine360_reservation/models/reservation_holiday.py new file mode 100644 index 0000000..238c358 --- /dev/null +++ b/addons/dine360_reservation/models/reservation_holiday.py @@ -0,0 +1,26 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + +class ReservationHoliday(models.Model): + _name = 'reservation.holiday' + _description = 'Reservation Holiday/Override' + _order = 'date desc' + + name = fields.Char(string='Description', required=True, placeholder="e.g. Christmas Day") + date = fields.Date(string='Date', required=True) + + is_closed = fields.Boolean(string='Closed for Reservations', default=True) + + opening_time = fields.Float(string='Override Opening Time', help="Leave empty to use regular schedule if not closed") + closing_time = fields.Float(string='Override Closing Time') + + @api.constrains('opening_time', 'closing_time', 'is_closed') + def _check_times(self): + for rec in self: + if not rec.is_closed and (rec.opening_time or rec.closing_time): + if rec.opening_time >= rec.closing_time: + raise ValidationError(_("Override Opening time must be before closing time.")) + + _sql_constraints = [ + ('date_unique', 'unique(date)', 'An override for this date already exists!') + ] diff --git a/addons/dine360_reservation/models/reservation_peak_hour.py b/addons/dine360_reservation/models/reservation_peak_hour.py new file mode 100644 index 0000000..6e397c2 --- /dev/null +++ b/addons/dine360_reservation/models/reservation_peak_hour.py @@ -0,0 +1,23 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + +class ReservationPeakHour(models.Model): + _name = 'reservation.peak.hour' + _description = 'Restaurant Peak Hour Duration' + + schedule_id = fields.Many2one('reservation.schedule', string='Schedule', ondelete='cascade') + start_time = fields.Float(string='Peak Start Time', required=True) + end_time = fields.Float(string='Peak End Time', required=True) + + slot_duration = fields.Selection([ + ('0.5', '30 Minutes'), + ('1.0', '1 Hour'), + ('1.5', '1.5 Hours'), + ('2.0', '2 Hours'), + ], string='Peak Slot Duration', default='1.0', required=True) + + @api.constrains('start_time', 'end_time') + def _check_times(self): + for rec in self: + if rec.start_time >= rec.end_time: + raise ValidationError(_("Peak start time must be before end time.")) diff --git a/addons/dine360_reservation/models/reservation_schedule.py b/addons/dine360_reservation/models/reservation_schedule.py new file mode 100644 index 0000000..662eb67 --- /dev/null +++ b/addons/dine360_reservation/models/reservation_schedule.py @@ -0,0 +1,78 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + +class ReservationSchedule(models.Model): + _name = 'reservation.schedule' + _description = 'Restaurant Reservation Schedule' + _order = 'day asc' + + day = fields.Selection([ + ('0', 'Monday'), + ('1', 'Tuesday'), + ('2', 'Wednesday'), + ('3', 'Thursday'), + ('4', 'Friday'), + ('5', 'Saturday'), + ('6', 'Sunday') + ], string='Day of Week', required=True) + + opening_time = fields.Float(string='Opening Time', default=12.0, help="Format: 14.5 for 2:30 PM") + closing_time = fields.Float(string='Closing Time', default=22.0) + + has_break = fields.Boolean(string='Has Break Time', default=False) + break_start_time = fields.Float(string='Break Start Time') + break_end_time = fields.Float(string='Break End Time') + + is_closed = fields.Boolean(string='Closed for Reservations', default=False) + + default_slot_duration = fields.Selection([ + ('0.5', '30 Minutes'), + ('1.0', '1 Hour'), + ('1.5', '1.5 Hours'), + ('2.0', '2 Hours'), + ('3.0', '3 Hours'), + ], string='Default Slot Duration', default='1.0', required=True) + + peak_hour_ids = fields.One2many('reservation.peak.hour', 'schedule_id', string='Peak Hour Slot Overrides') + + display_opening = fields.Char(compute='_compute_display_times') + display_closing = fields.Char(compute='_compute_display_times') + display_break = fields.Char(compute='_compute_display_times') + + def _compute_display_times(self): + for rec in self: + rec.display_opening = self._float_to_time_str(rec.opening_time) + rec.display_closing = self._float_to_time_str(rec.closing_time) + if rec.has_break: + rec.display_break = f"{self._float_to_time_str(rec.break_start_time)} - {self._float_to_time_str(rec.break_end_time)}" + else: + rec.display_break = "" + + def _float_to_time_str(self, time_float): + hours = int(time_float) + minutes = int((time_float - hours) * 60) + ampm = "am" if hours < 12 else "pm" + display_hours = hours if hours <= 12 else hours - 12 + if display_hours == 0: display_hours = 12 + return f"{display_hours}:{minutes:02d}{ampm}" + + _sql_constraints = [ + ('day_unique', 'unique(day)', 'Schedule for this day already exists!') + ] + + @api.constrains('opening_time', 'closing_time', 'break_start_time', 'break_end_time', 'has_break') + def _check_times(self): + for rec in self: + if not rec.is_closed: + if rec.opening_time >= rec.closing_time: + raise ValidationError(_("Opening time must be before closing time.")) + if rec.has_break: + if not (rec.opening_time < rec.break_start_time < rec.break_end_time < rec.closing_time): + raise ValidationError(_("Break time must be within opening and closing hours, and start before it ends.")) + + def name_get(self): + result = [] + for rec in self: + day_name = dict(self._fields['day'].selection).get(rec.day) + result.append((rec.id, day_name)) + return result diff --git a/addons/dine360_reservation/models/restaurant_reservation.py b/addons/dine360_reservation/models/restaurant_reservation.py index e09df98..1731b4a 100644 --- a/addons/dine360_reservation/models/restaurant_reservation.py +++ b/addons/dine360_reservation/models/restaurant_reservation.py @@ -14,8 +14,13 @@ class RestaurantReservation(models.Model): email = fields.Char(string='Email') num_people = fields.Integer(string='Number of People', default=1) - floor_id = fields.Many2one('restaurant.floor', string='Floor', required=True) - table_id = fields.Many2one('restaurant.table', string='Table', required=True) + floor_id = fields.Many2one('restaurant.floor', string='Floor') + table_id = fields.Many2one('restaurant.table', string='Primary Table') + table_ids = fields.Many2many('restaurant.table', string='Tables') + + is_admin_override = fields.Boolean(string='Admin Override', default=False, help="Skip all validation and availability checks") + override_reason = fields.Text(string='Override Reason') + overridden_by_id = fields.Many2one('res.users', string='Overridden By', readonly=True) start_time = fields.Datetime(string='Start Time', required=True) end_time = fields.Datetime(string='End Time', required=True) @@ -51,43 +56,104 @@ class RestaurantReservation(models.Model): def create(self, vals): if vals.get('name', _('New')) == _('New'): vals['name'] = self.env['ir.sequence'].next_by_code('restaurant.reservation') or _('New') + if vals.get('is_admin_override'): + vals['overridden_by_id'] = self.env.user.id return super(RestaurantReservation, self).create(vals) - @api.constrains('table_id', 'start_time', 'end_time', 'state') + def write(self, vals): + if vals.get('is_admin_override'): + vals['overridden_by_id'] = self.env.user.id + return super(RestaurantReservation, self).write(vals) + + @api.constrains('is_admin_override', 'override_reason') + def _check_override_reason(self): + for rec in self: + if rec.is_admin_override and not rec.override_reason: + raise ValidationError(_("Please provide a reason for the admin override.")) + + @api.constrains('table_ids', 'start_time', 'end_time', 'state', 'is_admin_override') def _check_overlap(self): for rec in self: + if rec.is_admin_override: + continue if rec.state in ['confirmed', 'completed']: + tables = rec.table_ids or rec.table_id + if not tables: + continue overlap = self.search([ ('id', '!=', rec.id), - ('table_id', '=', rec.table_id.id), + ('table_ids', 'in', tables.ids), ('state', '=', 'confirmed'), ('start_time', '<', rec.end_time), ('end_time', '>', rec.start_time), ]) if overlap: - raise ValidationError(_('This table is already reserved for the selected time slot.')) + raise ValidationError(_('One or more tables are already reserved for the selected time slot.')) - @api.constrains('start_time', 'end_time') + @api.constrains('start_time', 'end_time', 'is_admin_override') def _check_opening_hours(self): restaurant_tz = pytz.timezone('America/Toronto') for rec in self: + if rec.is_admin_override: + continue local_start = pytz.utc.localize(rec.start_time).astimezone(restaurant_tz) local_end = pytz.utc.localize(rec.end_time).astimezone(restaurant_tz) - day = local_start.weekday() # 0=Mon, 6=Sun + res_date = local_start.date() + day = str(local_start.weekday()) # 0=Mon, 6=Sun time_start = local_start.hour + local_start.minute / 60.0 time_end = local_end.hour + local_end.minute / 60.0 - if day in [6, 0, 1, 2, 3]: # Sun-Thu - if time_start < 12.0 or time_end > 21.0: - raise ValidationError(_('Reservations for Sunday - Thursday must be between 12:00 PM and 9:00 PM (Local Time).')) - else: # Fri-Sat - if time_start < 12.0 or time_end > 23.0: - raise ValidationError(_('Reservations for Friday & Saturday must be between 12:00 PM and 11:00 PM (Local Time).')) + # 1. Check for Holiday/Override + holiday = self.env['reservation.holiday'].sudo().search([('date', '=', res_date)], limit=1) + + if holiday: + if holiday.is_closed: + raise ValidationError(_("Reservations are closed on %s for %s.") % (res_date.strftime('%B %d, %Y'), holiday.name)) + + # Check holiday override hours if specified + if holiday.opening_time or holiday.closing_time: + opening = holiday.opening_time or 0.0 + closing = holiday.closing_time or 24.0 + if time_start < opening or time_end > closing: + raise ValidationError(_("Special hours for %s (%s): %s to %s.") % ( + holiday.name, + res_date.strftime('%B %d'), + self._float_to_time_str(opening), + self._float_to_time_str(closing) + )) + return # If holiday found and validated, skip regular schedule - @api.onchange('start_time') + # 2. Check Regular Schedule + schedule = self.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1) + + if not schedule: + continue + + if schedule.is_closed: + raise ValidationError(_("Reservations are closed for %s.") % dict(schedule._fields['day'].selection).get(day)) + + if time_start < schedule.opening_time or time_end > schedule.closing_time: + raise ValidationError(_("Reservations for %s must be between %s and %s.") % ( + dict(schedule._fields['day'].selection).get(day), + self._float_to_time_str(schedule.opening_time), + self._float_to_time_str(schedule.closing_time) + )) + + if schedule.has_break: + if time_start < schedule.break_end_time and time_end > schedule.break_start_time: + raise ValidationError(_("The restaurant has a break between %s and %s on %s.") % ( + self._float_to_time_str(schedule.break_start_time), + self._float_to_time_str(schedule.break_end_time), + dict(schedule._fields['day'].selection).get(day) + )) + + @api.onchange('start_time', 'table_id') def _onchange_start_time(self): - if self.start_time: + if self.start_time and self.table_id: + duration = float(self.table_id.reservation_slot_duration or 1.0) + self.end_time = self.start_time + timedelta(hours=duration) + elif self.start_time: self.end_time = self.start_time + timedelta(hours=1) def action_confirm(self): diff --git a/addons/dine360_reservation/models/restaurant_table.py b/addons/dine360_reservation/models/restaurant_table.py new file mode 100644 index 0000000..4a6539c --- /dev/null +++ b/addons/dine360_reservation/models/restaurant_table.py @@ -0,0 +1,29 @@ +from odoo import models, fields, api + +class RestaurantTable(models.Model): + _inherit = 'restaurant.table' + + is_reservation_enabled = fields.Boolean(string='Enabled for Reservation', default=True) + min_party_size = fields.Integer(string='Min Party Size', default=1) + max_party_size = fields.Integer(string='Max Party Size', default=1) + zone = fields.Char(string='Zone/Section', help="e.g. Window Side, Garden, VIP Section") + reservation_slot_duration = fields.Selection([ + ('0.5', '30 Minutes'), + ('1.0', '1 Hour'), + ('1.5', '1.5 Hours'), + ('2.0', '2 Hours'), + ('3.0', '3 Hours'), + ], string='Slot Duration', default='1.0', required=True) + + @api.onchange('seats') + def _onchange_seats_for_reservation(self): + for record in self: + if record.seats == 6: + record.min_party_size = 4 + record.max_party_size = 6 + elif record.seats == 4: + record.min_party_size = 2 + record.max_party_size = 4 + else: + record.min_party_size = 1 + record.max_party_size = record.seats or 1 diff --git a/addons/dine360_reservation/security/ir.model.access.csv b/addons/dine360_reservation/security/ir.model.access.csv index 6191133..aab549d 100644 --- a/addons/dine360_reservation/security/ir.model.access.csv +++ b/addons/dine360_reservation/security/ir.model.access.csv @@ -1,2 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_restaurant_reservation_user,restaurant.reservation,model_restaurant_reservation,base.group_user,1,1,1,1 +access_reservation_schedule_user,reservation.schedule,model_reservation_schedule,base.group_user,1,1,1,1 +access_reservation_holiday_user,reservation.holiday,model_reservation_holiday,base.group_user,1,1,1,1 +access_reservation_peak_hour_user,reservation.peak_hour,model_reservation_peak_hour,base.group_user,1,1,1,1 diff --git a/addons/dine360_reservation/views/menu_items.xml b/addons/dine360_reservation/views/menu_items.xml index 0106528..3c2a6fb 100644 --- a/addons/dine360_reservation/views/menu_items.xml +++ b/addons/dine360_reservation/views/menu_items.xml @@ -12,4 +12,22 @@ parent="menu_restaurant_reservation_root" action="action_restaurant_reservation" sequence="10"/> + + + + + + diff --git a/addons/dine360_reservation/views/reservation_holiday_views.xml b/addons/dine360_reservation/views/reservation_holiday_views.xml new file mode 100644 index 0000000..abc6612 --- /dev/null +++ b/addons/dine360_reservation/views/reservation_holiday_views.xml @@ -0,0 +1,30 @@ + + + + reservation.holiday.tree + reservation.holiday + + + + + + + + + + + + + Holiday / Special Overrides + reservation.holiday + tree + +

+ Add holiday or festival overrides. +

+

+ You can mark a specific date as closed or set special opening/closing hours that override the regular schedule. +

+
+
+
diff --git a/addons/dine360_reservation/views/reservation_schedule_views.xml b/addons/dine360_reservation/views/reservation_schedule_views.xml new file mode 100644 index 0000000..2ced474 --- /dev/null +++ b/addons/dine360_reservation/views/reservation_schedule_views.xml @@ -0,0 +1,67 @@ + + + + reservation.schedule.tree + reservation.schedule + + + + + + + + + + + + + + + reservation.schedule.form + reservation.schedule + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Reservation Schedule + reservation.schedule + tree,form + +

+ Manage your daily reservation hours and break times. +

+
+
+
diff --git a/addons/dine360_reservation/views/reservation_templates.xml b/addons/dine360_reservation/views/reservation_templates.xml index d0ce5f6..dfb94e0 100644 --- a/addons/dine360_reservation/views/reservation_templates.xml +++ b/addons/dine360_reservation/views/reservation_templates.xml @@ -12,9 +12,35 @@

Table Reservation

Book your spot for an authentic South Indian dining experience.

-
- Sun - Thu: 12pm - 9pm | Fri & Sat: 12pm - 11pm -
+
+ +
+
+
+ +
+
+ +
+
+ + Closed + + + - + +
Break: +
+
+
+
+
+
+
+
+
@@ -39,32 +65,28 @@ -
- - -
-
- - -
-
+
-
- - -
+
+ + +
+
+ +
+
+ + Select a date and guest count to see available slots +
+
+ + +
@@ -72,26 +94,83 @@ CONFIRM RESERVATION
- + + + function fetchSlots() { + const date = dateInput.value; + const guests = guestsInput.value; + + if (!date || !guests) return; + + container.innerHTML = '
Loading slots...
'; + hiddenInput.value = ""; + + fetch('/reservation/get_slots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: "2.0", + params: { date_str: date, num_people: guests } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.error) { + container.innerHTML = `
${data.error.message}
`; + return; + } + const result = data.result; + container.innerHTML = ""; + + if (result.error) { + container.innerHTML = `
${result.error}
`; + return; + } + + if (result.slots.length === 0) { + container.innerHTML = '
No available slots for this selection. Try another date or party size.
'; + return; + } + + result.slots.forEach(slot => { + const btn = document.createElement('button'); + btn.type = "button"; + btn.className = "btn btn-outline-dark slot-btn transition-all m-1"; + btn.style.cssText = "border-radius: 8px; font-weight: 600; padding: 10px 15px; border: 1px solid #fecd4f; min-width: 100px;"; + btn.innerHTML = slot.display; + btn.onclick = function() { + document.querySelectorAll('.slot-btn').forEach(b => { + b.classList.remove('active'); + b.style.backgroundColor = ""; + b.style.color = ""; + }); + this.classList.add('active'); + this.style.backgroundColor = "#fecd4f"; + this.style.color = "#171422"; + hiddenInput.value = slot.full_dt; + + const infoDiv = document.getElementById('selection_info'); + const infoText = document.getElementById('assigned_table_text'); + infoDiv.style.display = 'block'; + infoText.innerHTML = "Assignment: " + slot.tables + ""; + }; + container.appendChild(btn); + }); + }); + } + + dateInput.addEventListener('change', fetchSlots); + guestsInput.addEventListener('change', fetchSlots); + + if(dateInput.value) fetchSlots(); + }); + @@ -117,7 +196,14 @@
Floor:
-
Table:
+
Table(s): + + + + + + +
Guests:
Time:
diff --git a/addons/dine360_reservation/views/reservation_views.xml b/addons/dine360_reservation/views/reservation_views.xml index 175cad5..ed315dd 100644 --- a/addons/dine360_reservation/views/reservation_views.xml +++ b/addons/dine360_reservation/views/reservation_views.xml @@ -10,7 +10,7 @@ - + @@ -47,10 +47,13 @@ + + + - + @@ -69,12 +72,11 @@ - + - diff --git a/addons/dine360_reservation/views/restaurant_table_views.xml b/addons/dine360_reservation/views/restaurant_table_views.xml new file mode 100644 index 0000000..9505ee7 --- /dev/null +++ b/addons/dine360_reservation/views/restaurant_table_views.xml @@ -0,0 +1,51 @@ + + + + + restaurant.table.form.inherit + restaurant.table + + + + + + + + + + + + + + + + + restaurant.table.tree.reservation.settings + restaurant.table + + + + + + + + + + + + + + + + + Reservation Settings + restaurant.table + tree,form + + +

+ Configure your tables for reservation. +

+
+
+