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)