inital commit
This commit is contained in:
159
booking/services.py
Normal file
159
booking/services.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from decimal import Decimal
|
||||
from datetime import timezone as dt_timezone
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import AvailabilitySlot, Booking, BookingEventLog
|
||||
|
||||
ACTIVE_BOOKING_STATUSES = [Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED]
|
||||
|
||||
|
||||
def _normalize_utc(dt):
|
||||
if timezone.is_naive(dt):
|
||||
raise ValidationError("Datetime values must include timezone information (UTC ISO-8601).")
|
||||
return dt.astimezone(dt_timezone.utc)
|
||||
|
||||
|
||||
def is_bookable(*, equipment_item=None, adventure_offering=None, starts_at, ends_at, exclude_booking_id=None):
|
||||
starts_at = _normalize_utc(starts_at)
|
||||
ends_at = _normalize_utc(ends_at)
|
||||
if starts_at >= ends_at:
|
||||
return False
|
||||
|
||||
filters = Q(starts_at__lt=ends_at, ends_at__gt=starts_at, status__in=ACTIVE_BOOKING_STATUSES)
|
||||
if equipment_item is not None:
|
||||
filters &= Q(equipment_item=equipment_item)
|
||||
elif adventure_offering is not None:
|
||||
filters &= Q(adventure_offering=adventure_offering)
|
||||
else:
|
||||
return False
|
||||
|
||||
queryset = Booking.objects.filter(filters)
|
||||
if exclude_booking_id is not None:
|
||||
queryset = queryset.exclude(id=exclude_booking_id)
|
||||
has_overlap = queryset.exists()
|
||||
if has_overlap:
|
||||
return False
|
||||
|
||||
slot_filters = Q(starts_at__lt=ends_at, ends_at__gt=starts_at)
|
||||
if equipment_item is not None:
|
||||
slot_filters &= Q(equipment_item=equipment_item)
|
||||
else:
|
||||
slot_filters &= Q(adventure_offering=adventure_offering)
|
||||
|
||||
target_slots = AvailabilitySlot.objects.filter(slot_filters)
|
||||
if not target_slots.exists():
|
||||
# If no slot schedule exists yet, keep backward-compatible "bookable by default" behavior.
|
||||
return True
|
||||
|
||||
has_blocking_unavailable = target_slots.filter(is_available=False).exists()
|
||||
if has_blocking_unavailable:
|
||||
return False
|
||||
|
||||
has_covering_available_slot = target_slots.filter(is_available=True, starts_at__lte=starts_at, ends_at__gte=ends_at).exists()
|
||||
return has_covering_available_slot
|
||||
|
||||
|
||||
def quote_booking(*, equipment_item=None, adventure_offering=None, starts_at, ends_at, participants_count=1):
|
||||
starts_at = _normalize_utc(starts_at)
|
||||
ends_at = _normalize_utc(ends_at)
|
||||
if ends_at <= starts_at:
|
||||
raise ValidationError("ends_at must be greater than starts_at.")
|
||||
if starts_at < timezone.now():
|
||||
raise ValidationError("starts_at must be in the future.")
|
||||
|
||||
if equipment_item is not None and adventure_offering is not None:
|
||||
raise ValidationError("Only one target can be quoted at a time.")
|
||||
if equipment_item is None and adventure_offering is None:
|
||||
raise ValidationError("A booking target is required.")
|
||||
|
||||
if equipment_item is not None:
|
||||
duration_days = (ends_at - starts_at).total_seconds() / 86400
|
||||
billable_days = max(1, int(duration_days) if duration_days.is_integer() else int(duration_days) + 1)
|
||||
return Decimal(billable_days) * equipment_item.price_per_day
|
||||
|
||||
if participants_count < 1:
|
||||
raise ValidationError("participants_count must be at least 1.")
|
||||
if participants_count > adventure_offering.capacity:
|
||||
raise ValidationError("participants_count exceeds adventure capacity.")
|
||||
return Decimal(participants_count) * adventure_offering.price_per_person
|
||||
|
||||
|
||||
def create_booking_request(
|
||||
*,
|
||||
customer,
|
||||
starts_at,
|
||||
ends_at,
|
||||
equipment_item=None,
|
||||
adventure_offering=None,
|
||||
customer_notes="",
|
||||
participants_count=1,
|
||||
listing_click=None,
|
||||
):
|
||||
starts_at = _normalize_utc(starts_at)
|
||||
ends_at = _normalize_utc(ends_at)
|
||||
if not is_bookable(
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=adventure_offering,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
):
|
||||
raise ValidationError("This item/offering is not available for this range.")
|
||||
|
||||
total_price = quote_booking(
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=adventure_offering,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
participants_count=participants_count,
|
||||
)
|
||||
|
||||
vendor = equipment_item.vendor if equipment_item else adventure_offering.vendor
|
||||
booking = Booking.objects.create(
|
||||
customer=customer,
|
||||
vendor=vendor,
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=adventure_offering,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
status=Booking.Status.REQUESTED,
|
||||
total_price=total_price,
|
||||
customer_notes=customer_notes,
|
||||
listing_click=listing_click,
|
||||
)
|
||||
BookingEventLog.objects.create(
|
||||
booking=booking,
|
||||
from_status="",
|
||||
to_status=Booking.Status.REQUESTED,
|
||||
note="Booking requested.",
|
||||
actor=customer,
|
||||
)
|
||||
return booking
|
||||
|
||||
|
||||
def transition_booking_status(*, booking, to_status, actor, note="", vendor_notes=None):
|
||||
allowed_transitions = {
|
||||
Booking.Status.REQUESTED: {Booking.Status.APPROVED, Booking.Status.DECLINED, Booking.Status.CANCELLED},
|
||||
Booking.Status.APPROVED: {Booking.Status.CONFIRMED, Booking.Status.CANCELLED},
|
||||
Booking.Status.CONFIRMED: {Booking.Status.CANCELLED},
|
||||
Booking.Status.DECLINED: set(),
|
||||
Booking.Status.CANCELLED: set(),
|
||||
}
|
||||
current = booking.status
|
||||
if to_status not in allowed_transitions[current]:
|
||||
raise ValidationError(f"Invalid booking status transition: {current} -> {to_status}")
|
||||
|
||||
booking.status = to_status
|
||||
if vendor_notes is not None:
|
||||
booking.vendor_notes = vendor_notes
|
||||
booking.save(update_fields=["status", "vendor_notes", "updated_at"])
|
||||
BookingEventLog.objects.create(
|
||||
booking=booking,
|
||||
from_status=current,
|
||||
to_status=to_status,
|
||||
note=note,
|
||||
actor=actor,
|
||||
)
|
||||
return booking
|
||||
Reference in New Issue
Block a user