279 lines
13 KiB
Python
279 lines
13 KiB
Python
from odoo import http, _
|
|
from odoo.http import request
|
|
import datetime
|
|
import pytz
|
|
|
|
class TableReservationController(http.Controller):
|
|
|
|
@http.route(['/reservation'], type='http', auth="public", website=True)
|
|
def reservation_form(self, **post):
|
|
schedule = request.env['reservation.schedule'].sudo().search([])
|
|
return request.render("dine360_reservation.reservation_page_template", {
|
|
'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
|
|
customer_name = post.get('customer_name')
|
|
phone = post.get('phone')
|
|
email = post.get('email')
|
|
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.'})
|
|
|
|
if not email:
|
|
return request.render("dine360_reservation.reservation_page_template", {
|
|
'error': 'Email is required.',
|
|
'post': post,
|
|
})
|
|
|
|
# Convert start_time to datetime object and localize to restaurant timezone (America/Toronto)
|
|
restaurant_tz = pytz.timezone('America/Toronto')
|
|
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,
|
|
})
|
|
|
|
res_date = local_start.date() # Use local_start for date to get correct weekday
|
|
|
|
# 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)
|
|
|
|
# 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,
|
|
'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,
|
|
'state': 'confirmed' # Direct confirmation from website
|
|
})
|
|
return request.render("dine360_reservation.reservation_success_template", {
|
|
'reservation': reservation,
|
|
})
|
|
except Exception as e:
|
|
return request.render("dine360_reservation.reservation_page_template", {
|
|
'error': str(e),
|
|
'post': post,
|
|
})
|