93 lines
4.0 KiB
Python
93 lines
4.0 KiB
Python
from django.db import transaction
|
|
from django.utils import timezone
|
|
|
|
from apps.core.errors import ApiError
|
|
from apps.core.jalali import jalali_date, jalali_time
|
|
from apps.reservations.models import Reservation, Slot
|
|
|
|
|
|
class ReservationService:
|
|
"""Booking with backend-enforced availability, double-booking prevention,
|
|
and admin approval (which triggers a customer SMS)."""
|
|
|
|
@transaction.atomic
|
|
def book(self, customer, slot, title: str = "", notes: str = "") -> Reservation:
|
|
slot = Slot.objects.select_for_update().get(pk=slot.pk)
|
|
if slot.blocked:
|
|
raise ApiError("SLOT_BLOCKED", "This slot is blocked.")
|
|
if slot.date < timezone.localdate():
|
|
raise ApiError("SLOT_PAST", "Cannot book a slot in the past.")
|
|
if slot.booked_count >= slot.capacity:
|
|
raise ApiError("SLOT_FULL", "This slot is fully booked.")
|
|
slot.booked_count += 1
|
|
slot.save(update_fields=["booked_count"])
|
|
return Reservation.objects.create(
|
|
customer=customer,
|
|
slot=slot,
|
|
title=title,
|
|
status=Reservation.Status.PENDING,
|
|
notes=notes,
|
|
)
|
|
|
|
@transaction.atomic
|
|
def approve(self, reservation, user) -> Reservation:
|
|
reservation = Reservation.objects.select_for_update().select_related("slot", "customer").get(pk=reservation.pk)
|
|
if reservation.status != Reservation.Status.PENDING:
|
|
raise ApiError("INVALID_STATUS", "Only pending reservations can be approved.")
|
|
reservation.status = Reservation.Status.CONFIRMED
|
|
reservation.approved_by = user
|
|
reservation.approved_at = timezone.now()
|
|
reservation.save(update_fields=["status", "approved_by", "approved_at"])
|
|
|
|
from apps.notifications.services import NotificationService
|
|
|
|
customer = reservation.customer
|
|
NotificationService().send_event(
|
|
"reservation.approved",
|
|
{
|
|
"customer_name": customer.company_name or customer.user.get_full_name() or customer.user.username,
|
|
"reservation_title": reservation.title,
|
|
"slot_date": jalali_date(reservation.slot.date),
|
|
"slot_time": jalali_time(reservation.slot.start_time),
|
|
},
|
|
recipient=customer.user.phone or customer.user.username,
|
|
)
|
|
return reservation
|
|
|
|
@transaction.atomic
|
|
def reject(self, reservation, user) -> Reservation:
|
|
reservation = Reservation.objects.select_for_update().select_related("slot").get(pk=reservation.pk)
|
|
if reservation.status not in (Reservation.Status.PENDING, Reservation.Status.CONFIRMED):
|
|
raise ApiError("INVALID_STATUS", "This reservation cannot be rejected.")
|
|
reservation.status = Reservation.Status.REJECTED
|
|
reservation.approved_by = user
|
|
reservation.approved_at = timezone.now()
|
|
reservation.save(update_fields=["status", "approved_by", "approved_at"])
|
|
slot = reservation.slot
|
|
slot.booked_count = max(0, slot.booked_count - 1)
|
|
slot.save(update_fields=["booked_count"])
|
|
return reservation
|
|
|
|
@transaction.atomic
|
|
def cancel(self, reservation) -> Reservation:
|
|
reservation = (
|
|
Reservation.objects.select_for_update()
|
|
.select_related("slot")
|
|
.get(pk=reservation.pk)
|
|
)
|
|
if reservation.status == Reservation.Status.CANCELLED:
|
|
return reservation
|
|
if reservation.status == Reservation.Status.WEIGHED:
|
|
raise ApiError("RESERVATION_LOCKED", "Cannot cancel a reservation that has been weighed.")
|
|
reservation.status = Reservation.Status.CANCELLED
|
|
reservation.save(update_fields=["status"])
|
|
slot = reservation.slot
|
|
slot.booked_count = max(0, slot.booked_count - 1)
|
|
slot.save(update_fields=["booked_count"])
|
|
return reservation
|
|
|
|
@transaction.atomic
|
|
def reschedule(self, reservation, new_slot) -> Reservation:
|
|
self.cancel(reservation)
|
|
return self.book(reservation.customer, new_slot, title=reservation.title)
|