update
This commit is contained in:
0
Backend/src/bookings/__init__.py
Normal file
0
Backend/src/bookings/__init__.py
Normal file
0
Backend/src/bookings/models/__init__.py
Normal file
0
Backend/src/bookings/models/__init__.py
Normal file
48
Backend/src/bookings/models/booking.py
Normal file
48
Backend/src/bookings/models/booking.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class BookingStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
confirmed = 'confirmed'
|
||||
checked_in = 'checked_in'
|
||||
checked_out = 'checked_out'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class Booking(Base):
|
||||
__tablename__ = 'bookings'
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_number = Column(String(50), unique=True, nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
|
||||
check_in_date = Column(DateTime, nullable=False, index=True)
|
||||
check_out_date = Column(DateTime, nullable=False, index=True)
|
||||
num_guests = Column(Integer, nullable=False, default=1)
|
||||
total_price = Column(Numeric(10, 2), nullable=False)
|
||||
original_price = Column(Numeric(10, 2), nullable=True)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
promotion_code = Column(String(50), nullable=True)
|
||||
status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending)
|
||||
deposit_paid = Column(Boolean, nullable=False, default=False)
|
||||
requires_deposit = Column(Boolean, nullable=False, default=False)
|
||||
special_requests = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
user = relationship('User', back_populates='bookings')
|
||||
room = relationship('Room', back_populates='bookings')
|
||||
payments = relationship('Payment', back_populates='booking', cascade='all, delete-orphan')
|
||||
invoices = relationship('Invoice', back_populates='booking', cascade='all, delete-orphan')
|
||||
service_usages = relationship('ServiceUsage', back_populates='booking', cascade='all, delete-orphan')
|
||||
checkin_checkout = relationship('CheckInCheckOut', back_populates='booking', uselist=False)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=True)
|
||||
group_booking = relationship('GroupBooking', back_populates='individual_bookings')
|
||||
rate_plan_id = Column(Integer, ForeignKey('rate_plans.id'), nullable=True)
|
||||
rate_plan = relationship('RatePlan', back_populates='bookings')
|
||||
|
||||
# Composite index for date range queries (availability checks)
|
||||
__table_args__ = (
|
||||
Index('idx_booking_dates', 'check_in_date', 'check_out_date'),
|
||||
Index('idx_booking_user_dates', 'user_id', 'check_in_date', 'check_out_date'),
|
||||
)
|
||||
22
Backend/src/bookings/models/checkin_checkout.py
Normal file
22
Backend/src/bookings/models/checkin_checkout.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, Integer, DateTime, Numeric, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class CheckInCheckOut(Base):
|
||||
__tablename__ = 'checkin_checkout'
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, unique=True)
|
||||
checkin_time = Column(DateTime, nullable=True)
|
||||
checkout_time = Column(DateTime, nullable=True)
|
||||
checkin_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
checkout_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
room_condition_checkin = Column(Text, nullable=True)
|
||||
room_condition_checkout = Column(Text, nullable=True)
|
||||
additional_charges = Column(Numeric(10, 2), nullable=False, default=0.0)
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
booking = relationship('Booking', back_populates='checkin_checkout')
|
||||
checked_in_by = relationship('User', foreign_keys=[checkin_by], back_populates='checkins_processed')
|
||||
checked_out_by = relationship('User', foreign_keys=[checkout_by], back_populates='checkouts_processed')
|
||||
183
Backend/src/bookings/models/group_booking.py
Normal file
183
Backend/src/bookings/models/group_booking.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
class GroupBookingStatus(str, enum.Enum):
|
||||
draft = 'draft'
|
||||
pending = 'pending'
|
||||
confirmed = 'confirmed'
|
||||
partially_confirmed = 'partially_confirmed'
|
||||
checked_in = 'checked_in'
|
||||
checked_out = 'checked_out'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class PaymentOption(str, enum.Enum):
|
||||
coordinator_pays_all = 'coordinator_pays_all'
|
||||
individual_payments = 'individual_payments'
|
||||
split_payment = 'split_payment'
|
||||
|
||||
class GroupBooking(Base):
|
||||
__tablename__ = 'group_bookings'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_number = Column(String(50), unique=True, nullable=False, index=True)
|
||||
|
||||
# Coordinator information
|
||||
coordinator_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
coordinator_name = Column(String(100), nullable=False)
|
||||
coordinator_email = Column(String(100), nullable=False)
|
||||
coordinator_phone = Column(String(20), nullable=True)
|
||||
|
||||
# Group details
|
||||
group_name = Column(String(200), nullable=True)
|
||||
group_type = Column(String(50), nullable=True) # corporate, wedding, conference, etc.
|
||||
total_rooms = Column(Integer, nullable=False, default=0)
|
||||
total_guests = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Dates
|
||||
check_in_date = Column(DateTime, nullable=False)
|
||||
check_out_date = Column(DateTime, nullable=False)
|
||||
|
||||
# Pricing
|
||||
base_rate_per_room = Column(Numeric(10, 2), nullable=False)
|
||||
group_discount_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
group_discount_amount = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
original_total_price = Column(Numeric(10, 2), nullable=False)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
total_price = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Payment
|
||||
payment_option = Column(Enum(PaymentOption), nullable=False, default=PaymentOption.coordinator_pays_all)
|
||||
deposit_required = Column(Boolean, nullable=False, default=False)
|
||||
deposit_percentage = Column(Integer, nullable=True)
|
||||
deposit_amount = Column(Numeric(10, 2), nullable=True)
|
||||
amount_paid = Column(Numeric(10, 2), nullable=False, default=0)
|
||||
balance_due = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Status and policies
|
||||
status = Column(Enum(GroupBookingStatus), nullable=False, default=GroupBookingStatus.draft)
|
||||
cancellation_policy = Column(Text, nullable=True)
|
||||
cancellation_deadline = Column(DateTime, nullable=True)
|
||||
cancellation_penalty_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
|
||||
# Additional information
|
||||
special_requests = Column(Text, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
contract_terms = Column(Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
confirmed_at = Column(DateTime, nullable=True)
|
||||
cancelled_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
coordinator = relationship('User', foreign_keys=[coordinator_id])
|
||||
room_blocks = relationship('GroupRoomBlock', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
members = relationship('GroupBookingMember', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
individual_bookings = relationship('Booking', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
payments = relationship('GroupPayment', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
|
||||
class GroupRoomBlock(Base):
|
||||
__tablename__ = 'group_room_blocks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=False)
|
||||
room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=False)
|
||||
|
||||
# Blocking details
|
||||
rooms_blocked = Column(Integer, nullable=False, default=0)
|
||||
rooms_confirmed = Column(Integer, nullable=False, default=0)
|
||||
rooms_available = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Pricing
|
||||
rate_per_room = Column(Numeric(10, 2), nullable=False)
|
||||
total_block_price = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
block_released_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
group_booking = relationship('GroupBooking', back_populates='room_blocks')
|
||||
room_type = relationship('RoomType')
|
||||
|
||||
class GroupBookingMember(Base):
|
||||
__tablename__ = 'group_booking_members'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=False)
|
||||
|
||||
# Guest information
|
||||
full_name = Column(String(100), nullable=False)
|
||||
email = Column(String(100), nullable=True)
|
||||
phone = Column(String(20), nullable=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # If member is a registered user
|
||||
|
||||
# Room assignment
|
||||
room_block_id = Column(Integer, ForeignKey('group_room_blocks.id'), nullable=True)
|
||||
assigned_room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
|
||||
individual_booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True)
|
||||
|
||||
# Guest preferences
|
||||
special_requests = Column(Text, nullable=True)
|
||||
preferences = Column(JSON, nullable=True) # Store preferences as JSON
|
||||
|
||||
# Payment (if individual payment option)
|
||||
individual_amount = Column(Numeric(10, 2), nullable=True)
|
||||
individual_paid = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
individual_balance = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
|
||||
# Status
|
||||
is_checked_in = Column(Boolean, nullable=False, default=False)
|
||||
checked_in_at = Column(DateTime, nullable=True)
|
||||
is_checked_out = Column(Boolean, nullable=False, default=False)
|
||||
checked_out_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
group_booking = relationship('GroupBooking', back_populates='members')
|
||||
user = relationship('User', foreign_keys=[user_id])
|
||||
room_block = relationship('GroupRoomBlock')
|
||||
assigned_room = relationship('Room', foreign_keys=[assigned_room_id])
|
||||
individual_booking = relationship('Booking', foreign_keys=[individual_booking_id])
|
||||
|
||||
class GroupPayment(Base):
|
||||
__tablename__ = 'group_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=False)
|
||||
|
||||
# Payment details
|
||||
amount = Column(Numeric(10, 2), nullable=False)
|
||||
payment_method = Column(String(50), nullable=False) # Using same PaymentMethod enum values
|
||||
payment_type = Column(String(50), nullable=False, default='deposit') # deposit, full, remaining
|
||||
payment_status = Column(String(50), nullable=False, default='pending') # pending, completed, failed, refunded
|
||||
|
||||
# Transaction details
|
||||
transaction_id = Column(String(100), nullable=True)
|
||||
payment_date = Column(DateTime, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Payer information (if individual payment)
|
||||
paid_by_member_id = Column(Integer, ForeignKey('group_booking_members.id'), nullable=True)
|
||||
paid_by_user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
group_booking = relationship('GroupBooking', back_populates='payments')
|
||||
paid_by_member = relationship('GroupBookingMember', foreign_keys=[paid_by_member_id])
|
||||
paid_by_user = relationship('User', foreign_keys=[paid_by_user_id])
|
||||
|
||||
0
Backend/src/bookings/routes/__init__.py
Normal file
0
Backend/src/bookings/routes/__init__.py
Normal file
1316
Backend/src/bookings/routes/booking_routes.py
Normal file
1316
Backend/src/bookings/routes/booking_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
575
Backend/src/bookings/routes/group_booking_routes.py
Normal file
575
Backend/src/bookings/routes/group_booking_routes.py
Normal file
@@ -0,0 +1,575 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from ...shared.config.database import get_db
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ...auth.models.role import Role
|
||||
from ..models.group_booking import (
|
||||
GroupBooking, GroupBookingMember, GroupRoomBlock, GroupPayment,
|
||||
GroupBookingStatus, PaymentOption
|
||||
)
|
||||
from ...rooms.models.room import Room
|
||||
from ...rooms.models.room_type import RoomType
|
||||
from ..services.group_booking_service import GroupBookingService
|
||||
from ...rooms.services.room_service import get_base_url
|
||||
from fastapi import Request
|
||||
|
||||
router = APIRouter(prefix='/group-bookings', tags=['group-bookings'])
|
||||
|
||||
|
||||
@router.post('/')
|
||||
async def create_group_booking(
|
||||
booking_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new group booking"""
|
||||
try:
|
||||
# Extract data
|
||||
coordinator_name = booking_data.get('coordinator_name') or current_user.full_name
|
||||
coordinator_email = booking_data.get('coordinator_email') or current_user.email
|
||||
coordinator_phone = booking_data.get('coordinator_phone') or current_user.phone
|
||||
|
||||
check_in_date = datetime.fromisoformat(booking_data['check_in_date'].replace('Z', '+00:00'))
|
||||
check_out_date = datetime.fromisoformat(booking_data['check_out_date'].replace('Z', '+00:00'))
|
||||
|
||||
room_blocks = booking_data.get('room_blocks', [])
|
||||
if not room_blocks:
|
||||
raise HTTPException(status_code=400, detail="At least one room block is required")
|
||||
|
||||
payment_option = PaymentOption(booking_data.get('payment_option', 'coordinator_pays_all'))
|
||||
deposit_required = booking_data.get('deposit_required', False)
|
||||
deposit_percentage = booking_data.get('deposit_percentage')
|
||||
|
||||
group_booking = GroupBookingService.create_group_booking(
|
||||
db=db,
|
||||
coordinator_id=current_user.id,
|
||||
coordinator_name=coordinator_name,
|
||||
coordinator_email=coordinator_email,
|
||||
coordinator_phone=coordinator_phone,
|
||||
check_in_date=check_in_date,
|
||||
check_out_date=check_out_date,
|
||||
room_blocks=room_blocks,
|
||||
group_name=booking_data.get('group_name'),
|
||||
group_type=booking_data.get('group_type'),
|
||||
payment_option=payment_option,
|
||||
deposit_required=deposit_required,
|
||||
deposit_percentage=deposit_percentage,
|
||||
special_requests=booking_data.get('special_requests'),
|
||||
notes=booking_data.get('notes'),
|
||||
cancellation_policy=booking_data.get('cancellation_policy'),
|
||||
cancellation_deadline=datetime.fromisoformat(booking_data['cancellation_deadline'].replace('Z', '+00:00')) if booking_data.get('cancellation_deadline') else None,
|
||||
cancellation_penalty_percentage=booking_data.get('cancellation_penalty_percentage'),
|
||||
group_discount_percentage=booking_data.get('group_discount_percentage')
|
||||
)
|
||||
|
||||
# Load relationships
|
||||
db.refresh(group_booking)
|
||||
group_booking = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.coordinator)
|
||||
).filter(GroupBooking.id == group_booking.id).first()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Group booking created successfully',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking)
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
import traceback
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error creating group booking: {str(e)}')
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def get_group_bookings(
|
||||
search: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias='status'),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all group bookings (admin/staff only)"""
|
||||
try:
|
||||
query = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.coordinator),
|
||||
selectinload(GroupBooking.payments)
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
GroupBooking.group_booking_number.like(f'%{search}%') |
|
||||
GroupBooking.group_name.like(f'%{search}%') |
|
||||
GroupBooking.coordinator_name.like(f'%{search}%')
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(GroupBooking.status == GroupBookingStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
group_bookings = query.order_by(GroupBooking.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'group_bookings': [_serialize_group_booking(gb) for gb in group_bookings],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error getting group bookings: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/me')
|
||||
async def get_my_group_bookings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get group bookings for current user (as coordinator)"""
|
||||
try:
|
||||
group_bookings = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.payments)
|
||||
).filter(GroupBooking.coordinator_id == current_user.id).order_by(GroupBooking.created_at.desc()).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'group_bookings': [_serialize_group_booking(gb) for gb in group_bookings]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error getting my group bookings: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/{group_booking_id}')
|
||||
async def get_group_booking(
|
||||
group_booking_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific group booking"""
|
||||
try:
|
||||
group_booking = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.coordinator),
|
||||
selectinload(GroupBooking.payments),
|
||||
selectinload(GroupBooking.individual_bookings)
|
||||
).filter(GroupBooking.id == group_booking_id).first()
|
||||
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
# Check authorization
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name not in ['admin', 'staff']:
|
||||
if group_booking.coordinator_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view this group booking")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking, detailed=True)
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error getting group booking: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/members')
|
||||
async def add_member(
|
||||
group_booking_id: int,
|
||||
member_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a member to a group booking"""
|
||||
try:
|
||||
# Check authorization
|
||||
group_booking = db.query(GroupBooking).filter(GroupBooking.id == group_booking_id).first()
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name not in ['admin', 'staff']:
|
||||
if group_booking.coordinator_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add members")
|
||||
|
||||
member = GroupBookingService.add_member_to_group(
|
||||
db=db,
|
||||
group_booking_id=group_booking_id,
|
||||
full_name=member_data.get('full_name'),
|
||||
email=member_data.get('email'),
|
||||
phone=member_data.get('phone'),
|
||||
user_id=member_data.get('user_id'),
|
||||
room_block_id=member_data.get('room_block_id'),
|
||||
special_requests=member_data.get('special_requests'),
|
||||
preferences=member_data.get('preferences')
|
||||
)
|
||||
|
||||
db.refresh(member)
|
||||
member = db.query(GroupBookingMember).options(
|
||||
joinedload(GroupBookingMember.user),
|
||||
joinedload(GroupBookingMember.room_block),
|
||||
joinedload(GroupBookingMember.assigned_room)
|
||||
).filter(GroupBookingMember.id == member.id).first()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Member added successfully',
|
||||
'data': {
|
||||
'member': _serialize_member(member)
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error adding member: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/confirm')
|
||||
async def confirm_group_booking(
|
||||
group_booking_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Confirm a group booking"""
|
||||
try:
|
||||
group_booking = GroupBookingService.confirm_group_booking(db, group_booking_id)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Group booking confirmed successfully',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking)
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error confirming group booking: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/members/{member_id}/assign-room')
|
||||
async def assign_room_to_member(
|
||||
group_booking_id: int,
|
||||
member_id: int,
|
||||
assignment_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Assign a room to a group member and create individual booking"""
|
||||
try:
|
||||
room_id = assignment_data.get('room_id')
|
||||
if not room_id:
|
||||
raise HTTPException(status_code=400, detail="room_id is required")
|
||||
|
||||
booking = GroupBookingService.create_individual_booking_from_member(
|
||||
db=db,
|
||||
member_id=member_id,
|
||||
room_id=room_id
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Room assigned and booking created successfully',
|
||||
'data': {
|
||||
'booking': {
|
||||
'id': booking.id,
|
||||
'booking_number': booking.booking_number,
|
||||
'room_id': booking.room_id,
|
||||
'status': booking.status.value if hasattr(booking.status, 'value') else str(booking.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error assigning room: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/payments')
|
||||
async def add_payment(
|
||||
group_booking_id: int,
|
||||
payment_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a payment to a group booking"""
|
||||
try:
|
||||
# Check authorization
|
||||
group_booking = db.query(GroupBooking).filter(GroupBooking.id == group_booking_id).first()
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name not in ['admin', 'staff']:
|
||||
if group_booking.coordinator_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add payments")
|
||||
|
||||
payment = GroupBookingService.add_group_payment(
|
||||
db=db,
|
||||
group_booking_id=group_booking_id,
|
||||
amount=Decimal(str(payment_data.get('amount'))),
|
||||
payment_method=payment_data.get('payment_method'),
|
||||
payment_type=payment_data.get('payment_type', 'deposit'),
|
||||
transaction_id=payment_data.get('transaction_id'),
|
||||
paid_by_member_id=payment_data.get('paid_by_member_id'),
|
||||
paid_by_user_id=payment_data.get('paid_by_user_id', current_user.id),
|
||||
notes=payment_data.get('notes')
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Payment added successfully',
|
||||
'data': {
|
||||
'payment': _serialize_payment(payment)
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error adding payment: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/cancel')
|
||||
async def cancel_group_booking(
|
||||
group_booking_id: int,
|
||||
cancellation_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Cancel a group booking"""
|
||||
try:
|
||||
group_booking = GroupBookingService.cancel_group_booking(
|
||||
db=db,
|
||||
group_booking_id=group_booking_id,
|
||||
cancellation_reason=cancellation_data.get('reason')
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Group booking cancelled successfully',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking)
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error cancelling group booking: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/{group_booking_id}/availability')
|
||||
async def check_availability(
|
||||
group_booking_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check room availability for a group booking"""
|
||||
try:
|
||||
group_booking = db.query(GroupBooking).filter(GroupBooking.id == group_booking_id).first()
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
availability_results = []
|
||||
room_blocks = db.query(GroupRoomBlock).filter(
|
||||
GroupRoomBlock.group_booking_id == group_booking_id
|
||||
).all()
|
||||
|
||||
for room_block in room_blocks:
|
||||
availability = GroupBookingService.check_room_availability(
|
||||
db=db,
|
||||
room_type_id=room_block.room_type_id,
|
||||
check_in=group_booking.check_in_date,
|
||||
check_out=group_booking.check_out_date,
|
||||
num_rooms=room_block.rooms_blocked
|
||||
)
|
||||
|
||||
availability_results.append({
|
||||
'room_block_id': room_block.id,
|
||||
'room_type_id': room_block.room_type_id,
|
||||
'rooms_blocked': room_block.rooms_blocked,
|
||||
'availability': availability
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'availability': availability_results
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error checking availability: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Helper functions for serialization
|
||||
def _serialize_group_booking(group_booking: GroupBooking, detailed: bool = False) -> dict:
|
||||
"""Serialize group booking to dict"""
|
||||
data = {
|
||||
'id': group_booking.id,
|
||||
'group_booking_number': group_booking.group_booking_number,
|
||||
'coordinator': {
|
||||
'id': group_booking.coordinator_id,
|
||||
'name': group_booking.coordinator_name,
|
||||
'email': group_booking.coordinator_email,
|
||||
'phone': group_booking.coordinator_phone
|
||||
},
|
||||
'group_name': group_booking.group_name,
|
||||
'group_type': group_booking.group_type,
|
||||
'total_rooms': group_booking.total_rooms,
|
||||
'total_guests': group_booking.total_guests,
|
||||
'check_in_date': group_booking.check_in_date.isoformat() if group_booking.check_in_date else None,
|
||||
'check_out_date': group_booking.check_out_date.isoformat() if group_booking.check_out_date else None,
|
||||
'base_rate_per_room': float(group_booking.base_rate_per_room) if group_booking.base_rate_per_room else 0.0,
|
||||
'group_discount_percentage': float(group_booking.group_discount_percentage) if group_booking.group_discount_percentage else 0.0,
|
||||
'group_discount_amount': float(group_booking.group_discount_amount) if group_booking.group_discount_amount else 0.0,
|
||||
'original_total_price': float(group_booking.original_total_price) if group_booking.original_total_price else 0.0,
|
||||
'discount_amount': float(group_booking.discount_amount) if group_booking.discount_amount else 0.0,
|
||||
'total_price': float(group_booking.total_price) if group_booking.total_price else 0.0,
|
||||
'payment_option': group_booking.payment_option.value if hasattr(group_booking.payment_option, 'value') else str(group_booking.payment_option),
|
||||
'deposit_required': group_booking.deposit_required,
|
||||
'deposit_percentage': group_booking.deposit_percentage,
|
||||
'deposit_amount': float(group_booking.deposit_amount) if group_booking.deposit_amount else None,
|
||||
'amount_paid': float(group_booking.amount_paid) if group_booking.amount_paid else 0.0,
|
||||
'balance_due': float(group_booking.balance_due) if group_booking.balance_due else 0.0,
|
||||
'status': group_booking.status.value if hasattr(group_booking.status, 'value') else str(group_booking.status),
|
||||
'special_requests': group_booking.special_requests,
|
||||
'notes': group_booking.notes,
|
||||
'created_at': group_booking.created_at.isoformat() if group_booking.created_at else None,
|
||||
'updated_at': group_booking.updated_at.isoformat() if group_booking.updated_at else None
|
||||
}
|
||||
|
||||
if detailed:
|
||||
data['room_blocks'] = [_serialize_room_block(rb) for rb in group_booking.room_blocks] if group_booking.room_blocks else []
|
||||
data['members'] = [_serialize_member(m) for m in group_booking.members] if group_booking.members else []
|
||||
data['payments'] = [_serialize_payment(p) for p in group_booking.payments] if group_booking.payments else []
|
||||
data['cancellation_policy'] = group_booking.cancellation_policy
|
||||
data['cancellation_deadline'] = group_booking.cancellation_deadline.isoformat() if group_booking.cancellation_deadline else None
|
||||
data['cancellation_penalty_percentage'] = float(group_booking.cancellation_penalty_percentage) if group_booking.cancellation_penalty_percentage else None
|
||||
data['confirmed_at'] = group_booking.confirmed_at.isoformat() if group_booking.confirmed_at else None
|
||||
data['cancelled_at'] = group_booking.cancelled_at.isoformat() if group_booking.cancelled_at else None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _serialize_room_block(room_block: GroupRoomBlock) -> dict:
|
||||
"""Serialize room block to dict"""
|
||||
return {
|
||||
'id': room_block.id,
|
||||
'room_type_id': room_block.room_type_id,
|
||||
'room_type': {
|
||||
'id': room_block.room_type.id,
|
||||
'name': room_block.room_type.name,
|
||||
'base_price': float(room_block.room_type.base_price) if room_block.room_type.base_price else 0.0
|
||||
} if room_block.room_type else None,
|
||||
'rooms_blocked': room_block.rooms_blocked,
|
||||
'rooms_confirmed': room_block.rooms_confirmed,
|
||||
'rooms_available': room_block.rooms_available,
|
||||
'rate_per_room': float(room_block.rate_per_room) if room_block.rate_per_room else 0.0,
|
||||
'total_block_price': float(room_block.total_block_price) if room_block.total_block_price else 0.0,
|
||||
'is_active': room_block.is_active,
|
||||
'block_released_at': room_block.block_released_at.isoformat() if room_block.block_released_at else None
|
||||
}
|
||||
|
||||
|
||||
def _serialize_member(member: GroupBookingMember) -> dict:
|
||||
"""Serialize group member to dict"""
|
||||
return {
|
||||
'id': member.id,
|
||||
'full_name': member.full_name,
|
||||
'email': member.email,
|
||||
'phone': member.phone,
|
||||
'user_id': member.user_id,
|
||||
'room_block_id': member.room_block_id,
|
||||
'assigned_room_id': member.assigned_room_id,
|
||||
'individual_booking_id': member.individual_booking_id,
|
||||
'special_requests': member.special_requests,
|
||||
'preferences': member.preferences,
|
||||
'individual_amount': float(member.individual_amount) if member.individual_amount else None,
|
||||
'individual_paid': float(member.individual_paid) if member.individual_paid else 0.0,
|
||||
'individual_balance': float(member.individual_balance) if member.individual_balance else 0.0,
|
||||
'is_checked_in': member.is_checked_in,
|
||||
'checked_in_at': member.checked_in_at.isoformat() if member.checked_in_at else None,
|
||||
'is_checked_out': member.is_checked_out,
|
||||
'checked_out_at': member.checked_out_at.isoformat() if member.checked_out_at else None
|
||||
}
|
||||
|
||||
|
||||
def _serialize_payment(payment: GroupPayment) -> dict:
|
||||
"""Serialize group payment to dict"""
|
||||
return {
|
||||
'id': payment.id,
|
||||
'amount': float(payment.amount) if payment.amount else 0.0,
|
||||
'payment_method': payment.payment_method,
|
||||
'payment_type': payment.payment_type,
|
||||
'payment_status': payment.payment_status,
|
||||
'transaction_id': payment.transaction_id,
|
||||
'payment_date': payment.payment_date.isoformat() if payment.payment_date else None,
|
||||
'notes': payment.notes,
|
||||
'paid_by_member_id': payment.paid_by_member_id,
|
||||
'paid_by_user_id': payment.paid_by_user_id,
|
||||
'created_at': payment.created_at.isoformat() if payment.created_at else None
|
||||
}
|
||||
|
||||
0
Backend/src/bookings/schemas/__init__.py
Normal file
0
Backend/src/bookings/schemas/__init__.py
Normal file
167
Backend/src/bookings/schemas/booking.py
Normal file
167
Backend/src/bookings/schemas/booking.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Pydantic schemas for booking-related requests and responses.
|
||||
"""
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ServiceItemSchema(BaseModel):
|
||||
"""Schema for service items in a booking."""
|
||||
service_id: int = Field(..., gt=0, description="Service ID")
|
||||
quantity: int = Field(1, gt=0, le=100, description="Quantity of service")
|
||||
|
||||
|
||||
class InvoiceInfoSchema(BaseModel):
|
||||
"""Schema for invoice information."""
|
||||
company_name: Optional[str] = Field(None, max_length=200)
|
||||
company_address: Optional[str] = Field(None, max_length=500)
|
||||
company_tax_id: Optional[str] = Field(None, max_length=50)
|
||||
customer_tax_id: Optional[str] = Field(None, max_length=50)
|
||||
notes: Optional[str] = Field(None, max_length=1000)
|
||||
terms_and_conditions: Optional[str] = None
|
||||
payment_instructions: Optional[str] = None
|
||||
|
||||
|
||||
class CreateBookingRequest(BaseModel):
|
||||
"""Schema for creating a booking."""
|
||||
room_id: int = Field(..., gt=0, description="Room ID")
|
||||
check_in_date: str = Field(..., description="Check-in date (YYYY-MM-DD or ISO format)")
|
||||
check_out_date: str = Field(..., description="Check-out date (YYYY-MM-DD or ISO format)")
|
||||
total_price: float = Field(..., gt=0, description="Total booking price")
|
||||
guest_count: int = Field(1, gt=0, le=20, description="Number of guests")
|
||||
notes: Optional[str] = Field(None, max_length=1000, description="Special requests/notes")
|
||||
payment_method: str = Field("cash", description="Payment method (cash, stripe, paypal)")
|
||||
promotion_code: Optional[str] = Field(None, max_length=50)
|
||||
referral_code: Optional[str] = Field(None, max_length=50)
|
||||
services: Optional[List[ServiceItemSchema]] = Field(default_factory=list)
|
||||
invoice_info: Optional[InvoiceInfoSchema] = None
|
||||
|
||||
@field_validator('check_in_date', 'check_out_date')
|
||||
@classmethod
|
||||
def validate_date_format(cls, v: str) -> str:
|
||||
"""Validate date format."""
|
||||
try:
|
||||
# Try ISO format first
|
||||
if 'T' in v or 'Z' in v or '+' in v:
|
||||
datetime.fromisoformat(v.replace('Z', '+00:00'))
|
||||
else:
|
||||
# Try simple date format
|
||||
datetime.strptime(v, '%Y-%m-%d')
|
||||
return v
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.')
|
||||
|
||||
@field_validator('payment_method')
|
||||
@classmethod
|
||||
def validate_payment_method(cls, v: str) -> str:
|
||||
"""Validate payment method."""
|
||||
allowed_methods = ['cash', 'stripe', 'paypal', 'borica']
|
||||
if v not in allowed_methods:
|
||||
raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_dates(self):
|
||||
"""Validate that check-out is after check-in."""
|
||||
check_in = self.check_in_date
|
||||
check_out = self.check_out_date
|
||||
|
||||
if check_in and check_out:
|
||||
try:
|
||||
# Parse dates
|
||||
if 'T' in check_in or 'Z' in check_in or '+' in check_in:
|
||||
check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00'))
|
||||
else:
|
||||
check_in_dt = datetime.strptime(check_in, '%Y-%m-%d')
|
||||
|
||||
if 'T' in check_out or 'Z' in check_out or '+' in check_out:
|
||||
check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00'))
|
||||
else:
|
||||
check_out_dt = datetime.strptime(check_out, '%Y-%m-%d')
|
||||
|
||||
if check_in_dt >= check_out_dt:
|
||||
raise ValueError('Check-out date must be after check-in date')
|
||||
except (ValueError, TypeError) as e:
|
||||
if 'Check-out date' in str(e):
|
||||
raise
|
||||
raise ValueError('Invalid date format')
|
||||
|
||||
return self
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"example": {
|
||||
"room_id": 1,
|
||||
"check_in_date": "2024-12-25",
|
||||
"check_out_date": "2024-12-30",
|
||||
"total_price": 500.00,
|
||||
"guest_count": 2,
|
||||
"payment_method": "cash",
|
||||
"notes": "Late check-in requested"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class UpdateBookingRequest(BaseModel):
|
||||
"""Schema for updating a booking."""
|
||||
status: Optional[str] = Field(None, description="Booking status")
|
||||
check_in_date: Optional[str] = Field(None, description="Check-in date")
|
||||
check_out_date: Optional[str] = Field(None, description="Check-out date")
|
||||
guest_count: Optional[int] = Field(None, gt=0, le=20)
|
||||
notes: Optional[str] = Field(None, max_length=1000)
|
||||
total_price: Optional[float] = Field(None, gt=0)
|
||||
|
||||
@field_validator('check_in_date', 'check_out_date')
|
||||
@classmethod
|
||||
def validate_date_format(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate date format if provided."""
|
||||
if v:
|
||||
try:
|
||||
if 'T' in v or 'Z' in v or '+' in v:
|
||||
datetime.fromisoformat(v.replace('Z', '+00:00'))
|
||||
else:
|
||||
datetime.strptime(v, '%Y-%m-%d')
|
||||
return v
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.')
|
||||
return v
|
||||
|
||||
@field_validator('status')
|
||||
@classmethod
|
||||
def validate_status(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate booking status."""
|
||||
if v:
|
||||
allowed_statuses = ['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled']
|
||||
if v not in allowed_statuses:
|
||||
raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}')
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_dates(self):
|
||||
"""Validate dates if both are provided."""
|
||||
check_in = self.check_in_date
|
||||
check_out = self.check_out_date
|
||||
|
||||
if check_in and check_out:
|
||||
try:
|
||||
if 'T' in check_in or 'Z' in check_in or '+' in check_in:
|
||||
check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00'))
|
||||
else:
|
||||
check_in_dt = datetime.strptime(check_in, '%Y-%m-%d')
|
||||
|
||||
if 'T' in check_out or 'Z' in check_out or '+' in check_out:
|
||||
check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00'))
|
||||
else:
|
||||
check_out_dt = datetime.strptime(check_out, '%Y-%m-%d')
|
||||
|
||||
if check_in_dt >= check_out_dt:
|
||||
raise ValueError('Check-out date must be after check-in date')
|
||||
except (ValueError, TypeError) as e:
|
||||
if 'Check-out date' in str(e):
|
||||
raise
|
||||
raise ValueError('Invalid date format')
|
||||
|
||||
return self
|
||||
|
||||
0
Backend/src/bookings/services/__init__.py
Normal file
0
Backend/src/bookings/services/__init__.py
Normal file
574
Backend/src/bookings/services/group_booking_service.py
Normal file
574
Backend/src/bookings/services/group_booking_service.py
Normal file
@@ -0,0 +1,574 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
import random
|
||||
import string
|
||||
from decimal import Decimal
|
||||
from ..models.group_booking import (
|
||||
GroupBooking, GroupBookingMember, GroupRoomBlock, GroupPayment,
|
||||
GroupBookingStatus, PaymentOption
|
||||
)
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ...rooms.models.room import Room, RoomStatus
|
||||
from ...rooms.models.room_type import RoomType
|
||||
from ...auth.models.user import User
|
||||
from ...payments.models.payment import Payment, PaymentStatus, PaymentMethod
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GroupBookingService:
|
||||
|
||||
@staticmethod
|
||||
def generate_group_booking_number(db: Session) -> str:
|
||||
"""Generate unique group booking number"""
|
||||
max_attempts = 10
|
||||
for _ in range(max_attempts):
|
||||
timestamp = datetime.utcnow().strftime('%Y%m%d')
|
||||
random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
booking_number = f"GRP-{timestamp}-{random_suffix}"
|
||||
|
||||
existing = db.query(GroupBooking).filter(
|
||||
GroupBooking.group_booking_number == booking_number
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
return booking_number
|
||||
|
||||
# Fallback
|
||||
return f"GRP-{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
@staticmethod
|
||||
def calculate_group_discount(
|
||||
total_rooms: int,
|
||||
base_rate: Decimal,
|
||||
discount_percentage: Optional[float] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Calculate group discount based on number of rooms"""
|
||||
original_total = base_rate * total_rooms
|
||||
|
||||
# Default discount tiers
|
||||
if discount_percentage is None:
|
||||
if total_rooms >= 20:
|
||||
discount_percentage = 15.0
|
||||
elif total_rooms >= 10:
|
||||
discount_percentage = 10.0
|
||||
elif total_rooms >= 5:
|
||||
discount_percentage = 5.0
|
||||
else:
|
||||
discount_percentage = 0.0
|
||||
|
||||
discount_amount = original_total * Decimal(str(discount_percentage)) / Decimal('100')
|
||||
total_price = original_total - discount_amount
|
||||
|
||||
return {
|
||||
'original_total': float(original_total),
|
||||
'discount_percentage': discount_percentage,
|
||||
'discount_amount': float(discount_amount),
|
||||
'total_price': float(total_price)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def check_room_availability(
|
||||
db: Session,
|
||||
room_type_id: int,
|
||||
check_in: datetime,
|
||||
check_out: datetime,
|
||||
num_rooms: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Check if enough rooms are available for blocking"""
|
||||
# Get all rooms of this type
|
||||
rooms = db.query(Room).filter(Room.room_type_id == room_type_id).all()
|
||||
|
||||
if len(rooms) < num_rooms:
|
||||
return {
|
||||
'available': False,
|
||||
'available_count': len(rooms),
|
||||
'required_count': num_rooms,
|
||||
'message': f'Only {len(rooms)} rooms available, {num_rooms} required'
|
||||
}
|
||||
|
||||
# Check for conflicting bookings
|
||||
available_rooms = []
|
||||
for room in rooms:
|
||||
# Check if room has any bookings during the period
|
||||
conflicting_bookings = db.query(Booking).filter(
|
||||
Booking.room_id == room.id,
|
||||
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]),
|
||||
Booking.check_in_date < check_out,
|
||||
Booking.check_out_date > check_in
|
||||
).count()
|
||||
|
||||
# Check for other group blocks
|
||||
conflicting_blocks = db.query(GroupRoomBlock).join(GroupBooking).filter(
|
||||
GroupRoomBlock.room_type_id == room_type_id,
|
||||
GroupBooking.status.in_([
|
||||
GroupBookingStatus.confirmed,
|
||||
GroupBookingStatus.partially_confirmed,
|
||||
GroupBookingStatus.checked_in
|
||||
]),
|
||||
GroupBooking.check_in_date < check_out,
|
||||
GroupBooking.check_out_date > check_in,
|
||||
GroupRoomBlock.is_active == True
|
||||
).count()
|
||||
|
||||
if conflicting_bookings == 0 and conflicting_blocks == 0:
|
||||
available_rooms.append(room)
|
||||
|
||||
if len(available_rooms) < num_rooms:
|
||||
return {
|
||||
'available': False,
|
||||
'available_count': len(available_rooms),
|
||||
'required_count': num_rooms,
|
||||
'message': f'Only {len(available_rooms)} rooms available for the selected dates'
|
||||
}
|
||||
|
||||
return {
|
||||
'available': True,
|
||||
'available_count': len(available_rooms),
|
||||
'required_count': num_rooms
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_group_booking(
|
||||
db: Session,
|
||||
coordinator_id: int,
|
||||
coordinator_name: str,
|
||||
coordinator_email: str,
|
||||
coordinator_phone: Optional[str],
|
||||
check_in_date: datetime,
|
||||
check_out_date: datetime,
|
||||
room_blocks: List[Dict[str, Any]],
|
||||
group_name: Optional[str] = None,
|
||||
group_type: Optional[str] = None,
|
||||
payment_option: PaymentOption = PaymentOption.coordinator_pays_all,
|
||||
deposit_required: bool = False,
|
||||
deposit_percentage: Optional[int] = None,
|
||||
special_requests: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
cancellation_policy: Optional[str] = None,
|
||||
cancellation_deadline: Optional[datetime] = None,
|
||||
cancellation_penalty_percentage: Optional[float] = None,
|
||||
group_discount_percentage: Optional[float] = None
|
||||
) -> GroupBooking:
|
||||
"""Create a new group booking with room blocks"""
|
||||
|
||||
# Validate dates
|
||||
if check_out_date <= check_in_date:
|
||||
raise ValueError("Check-out date must be after check-in date")
|
||||
|
||||
# Calculate total rooms and base pricing
|
||||
total_rooms = sum(block.get('num_rooms', 0) for block in room_blocks)
|
||||
if total_rooms == 0:
|
||||
raise ValueError("At least one room must be blocked")
|
||||
|
||||
# Calculate pricing for each room block
|
||||
total_original_price = Decimal('0')
|
||||
room_block_objects = []
|
||||
|
||||
for block_data in room_blocks:
|
||||
room_type_id = block_data.get('room_type_id')
|
||||
num_rooms = block_data.get('num_rooms', 0)
|
||||
rate_per_room = Decimal(str(block_data.get('rate_per_room', 0)))
|
||||
|
||||
if not room_type_id or num_rooms <= 0:
|
||||
continue
|
||||
|
||||
# Check availability
|
||||
availability = GroupBookingService.check_room_availability(
|
||||
db, room_type_id, check_in_date, check_out_date, num_rooms
|
||||
)
|
||||
|
||||
if not availability['available']:
|
||||
raise ValueError(availability.get('message', 'Rooms not available'))
|
||||
|
||||
# Get room type
|
||||
room_type = db.query(RoomType).filter(RoomType.id == room_type_id).first()
|
||||
if not room_type:
|
||||
raise ValueError(f"Room type {room_type_id} not found")
|
||||
|
||||
block_total = rate_per_room * num_rooms
|
||||
total_original_price += block_total
|
||||
|
||||
# Create room block object (will be saved later)
|
||||
room_block = GroupRoomBlock(
|
||||
room_type_id=room_type_id,
|
||||
rooms_blocked=num_rooms,
|
||||
rooms_confirmed=0,
|
||||
rooms_available=num_rooms,
|
||||
rate_per_room=rate_per_room,
|
||||
total_block_price=block_total,
|
||||
is_active=True
|
||||
)
|
||||
room_block_objects.append(room_block)
|
||||
|
||||
# Calculate group discount
|
||||
base_rate = total_original_price / total_rooms if total_rooms > 0 else Decimal('0')
|
||||
pricing = GroupBookingService.calculate_group_discount(
|
||||
total_rooms, base_rate, group_discount_percentage
|
||||
)
|
||||
|
||||
# Calculate deposit
|
||||
deposit_amount = None
|
||||
if deposit_required:
|
||||
if deposit_percentage:
|
||||
deposit_amount = Decimal(str(pricing['total_price'])) * Decimal(str(deposit_percentage)) / Decimal('100')
|
||||
else:
|
||||
deposit_amount = Decimal(str(pricing['total_price'])) * Decimal('0.2') # Default 20%
|
||||
|
||||
# Create group booking
|
||||
group_booking_number = GroupBookingService.generate_group_booking_number(db)
|
||||
|
||||
group_booking = GroupBooking(
|
||||
group_booking_number=group_booking_number,
|
||||
coordinator_id=coordinator_id,
|
||||
coordinator_name=coordinator_name,
|
||||
coordinator_email=coordinator_email,
|
||||
coordinator_phone=coordinator_phone,
|
||||
group_name=group_name,
|
||||
group_type=group_type,
|
||||
total_rooms=total_rooms,
|
||||
total_guests=0, # Will be updated when members are added
|
||||
check_in_date=check_in_date,
|
||||
check_out_date=check_out_date,
|
||||
base_rate_per_room=base_rate,
|
||||
group_discount_percentage=Decimal(str(pricing['discount_percentage'])),
|
||||
group_discount_amount=Decimal(str(pricing['discount_amount'])),
|
||||
original_total_price=Decimal(str(pricing['original_total'])),
|
||||
discount_amount=Decimal(str(pricing['discount_amount'])),
|
||||
total_price=Decimal(str(pricing['total_price'])),
|
||||
payment_option=payment_option,
|
||||
deposit_required=deposit_required,
|
||||
deposit_percentage=deposit_percentage,
|
||||
deposit_amount=deposit_amount,
|
||||
amount_paid=Decimal('0'),
|
||||
balance_due=Decimal(str(pricing['total_price'])),
|
||||
status=GroupBookingStatus.draft,
|
||||
cancellation_policy=cancellation_policy,
|
||||
cancellation_deadline=cancellation_deadline,
|
||||
cancellation_penalty_percentage=Decimal(str(cancellation_penalty_percentage)) if cancellation_penalty_percentage else None,
|
||||
special_requests=special_requests,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
db.add(group_booking)
|
||||
db.flush()
|
||||
|
||||
# Add room blocks
|
||||
for room_block in room_block_objects:
|
||||
room_block.group_booking_id = group_booking.id
|
||||
db.add(room_block)
|
||||
|
||||
db.commit()
|
||||
db.refresh(group_booking)
|
||||
|
||||
return group_booking
|
||||
|
||||
@staticmethod
|
||||
def add_member_to_group(
|
||||
db: Session,
|
||||
group_booking_id: int,
|
||||
full_name: str,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
room_block_id: Optional[int] = None,
|
||||
special_requests: Optional[str] = None,
|
||||
preferences: Optional[Dict[str, Any]] = None
|
||||
) -> GroupBookingMember:
|
||||
"""Add a member to a group booking"""
|
||||
|
||||
group_booking = db.query(GroupBooking).filter(
|
||||
GroupBooking.id == group_booking_id
|
||||
).first()
|
||||
|
||||
if not group_booking:
|
||||
raise ValueError("Group booking not found")
|
||||
|
||||
# Calculate individual amount if individual payment
|
||||
individual_amount = None
|
||||
if group_booking.payment_option == PaymentOption.individual_payments:
|
||||
# Distribute cost evenly among members
|
||||
current_member_count = db.query(GroupBookingMember).filter(
|
||||
GroupBookingMember.group_booking_id == group_booking_id
|
||||
).count()
|
||||
individual_amount = group_booking.total_price / (current_member_count + 1)
|
||||
|
||||
member = GroupBookingMember(
|
||||
group_booking_id=group_booking_id,
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
user_id=user_id,
|
||||
room_block_id=room_block_id,
|
||||
special_requests=special_requests,
|
||||
preferences=preferences,
|
||||
individual_amount=individual_amount,
|
||||
individual_paid=Decimal('0'),
|
||||
individual_balance=individual_amount if individual_amount else Decimal('0')
|
||||
)
|
||||
|
||||
db.add(member)
|
||||
|
||||
# Update total guests
|
||||
group_booking.total_guests += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(member)
|
||||
|
||||
return member
|
||||
|
||||
@staticmethod
|
||||
def confirm_group_booking(
|
||||
db: Session,
|
||||
group_booking_id: int
|
||||
) -> GroupBooking:
|
||||
"""Confirm a group booking and activate room blocks"""
|
||||
|
||||
group_booking = db.query(GroupBooking).filter(
|
||||
GroupBooking.id == group_booking_id
|
||||
).first()
|
||||
|
||||
if not group_booking:
|
||||
raise ValueError("Group booking not found")
|
||||
|
||||
if group_booking.status not in [GroupBookingStatus.draft, GroupBookingStatus.pending]:
|
||||
raise ValueError(f"Cannot confirm booking with status {group_booking.status}")
|
||||
|
||||
# Re-check availability
|
||||
room_blocks = db.query(GroupRoomBlock).filter(
|
||||
GroupRoomBlock.group_booking_id == group_booking_id
|
||||
).all()
|
||||
|
||||
for room_block in room_blocks:
|
||||
availability = GroupBookingService.check_room_availability(
|
||||
db,
|
||||
room_block.room_type_id,
|
||||
group_booking.check_in_date,
|
||||
group_booking.check_out_date,
|
||||
room_block.rooms_blocked
|
||||
)
|
||||
|
||||
if not availability['available']:
|
||||
raise ValueError(f"Rooms no longer available: {availability.get('message')}")
|
||||
|
||||
# Update status
|
||||
group_booking.status = GroupBookingStatus.confirmed
|
||||
group_booking.confirmed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(group_booking)
|
||||
|
||||
return group_booking
|
||||
|
||||
@staticmethod
|
||||
def create_individual_booking_from_member(
|
||||
db: Session,
|
||||
member_id: int,
|
||||
room_id: int
|
||||
) -> Booking:
|
||||
"""Create an individual booking for a group member"""
|
||||
|
||||
member = db.query(GroupBookingMember).filter(
|
||||
GroupBookingMember.id == member_id
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
raise ValueError("Group member not found")
|
||||
|
||||
group_booking = member.group_booking
|
||||
|
||||
if not group_booking:
|
||||
raise ValueError("Group booking not found")
|
||||
|
||||
# Check if room is available
|
||||
room = db.query(Room).filter(Room.id == room_id).first()
|
||||
if not room:
|
||||
raise ValueError("Room not found")
|
||||
|
||||
# Verify room type matches
|
||||
if member.room_block_id:
|
||||
room_block = db.query(GroupRoomBlock).filter(
|
||||
GroupRoomBlock.id == member.room_block_id
|
||||
).first()
|
||||
if room_block and room.room_type_id != room_block.room_type_id:
|
||||
raise ValueError("Room type does not match the assigned room block")
|
||||
|
||||
# Calculate price for this booking
|
||||
nights = (group_booking.check_out_date - group_booking.check_in_date).days
|
||||
if nights <= 0:
|
||||
nights = 1
|
||||
|
||||
if member.individual_amount:
|
||||
booking_price = member.individual_amount
|
||||
else:
|
||||
# Use proportional share
|
||||
booking_price = group_booking.total_price / group_booking.total_rooms
|
||||
|
||||
# Generate booking number
|
||||
import random
|
||||
prefix = 'BK'
|
||||
ts = int(datetime.utcnow().timestamp() * 1000)
|
||||
rand = random.randint(1000, 9999)
|
||||
booking_number = f'{prefix}-{ts}-{rand}'
|
||||
|
||||
# Ensure uniqueness
|
||||
existing = db.query(Booking).filter(Booking.booking_number == booking_number).first()
|
||||
if existing:
|
||||
booking_number = f'{prefix}-{ts}-{rand + 1}'
|
||||
|
||||
# Create booking
|
||||
booking = Booking(
|
||||
booking_number=booking_number,
|
||||
user_id=member.user_id if member.user_id else group_booking.coordinator_id,
|
||||
room_id=room_id,
|
||||
check_in_date=group_booking.check_in_date,
|
||||
check_out_date=group_booking.check_out_date,
|
||||
num_guests=1,
|
||||
total_price=booking_price,
|
||||
original_price=booking_price,
|
||||
discount_amount=Decimal('0'),
|
||||
status=BookingStatus.confirmed,
|
||||
deposit_paid=False,
|
||||
requires_deposit=False,
|
||||
special_requests=member.special_requests,
|
||||
group_booking_id=group_booking_id
|
||||
)
|
||||
|
||||
db.add(booking)
|
||||
|
||||
# Update member
|
||||
member.assigned_room_id = room_id
|
||||
member.individual_booking_id = booking.id
|
||||
|
||||
# Update room block
|
||||
if member.room_block_id:
|
||||
room_block = db.query(GroupRoomBlock).filter(
|
||||
GroupRoomBlock.id == member.room_block_id
|
||||
).first()
|
||||
if room_block:
|
||||
room_block.rooms_confirmed += 1
|
||||
room_block.rooms_available -= 1
|
||||
|
||||
# Update group booking status
|
||||
confirmed_count = db.query(GroupBookingMember).filter(
|
||||
GroupBookingMember.group_booking_id == group_booking_id,
|
||||
GroupBookingMember.individual_booking_id.isnot(None)
|
||||
).count()
|
||||
|
||||
if confirmed_count == group_booking.total_rooms:
|
||||
group_booking.status = GroupBookingStatus.confirmed
|
||||
elif confirmed_count > 0:
|
||||
group_booking.status = GroupBookingStatus.partially_confirmed
|
||||
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
return booking
|
||||
|
||||
@staticmethod
|
||||
def add_group_payment(
|
||||
db: Session,
|
||||
group_booking_id: int,
|
||||
amount: Decimal,
|
||||
payment_method: str,
|
||||
payment_type: str = 'deposit',
|
||||
transaction_id: Optional[str] = None,
|
||||
paid_by_member_id: Optional[int] = None,
|
||||
paid_by_user_id: Optional[int] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> GroupPayment:
|
||||
"""Add a payment to a group booking"""
|
||||
|
||||
group_booking = db.query(GroupBooking).filter(
|
||||
GroupBooking.id == group_booking_id
|
||||
).first()
|
||||
|
||||
if not group_booking:
|
||||
raise ValueError("Group booking not found")
|
||||
|
||||
payment = GroupPayment(
|
||||
group_booking_id=group_booking_id,
|
||||
amount=amount,
|
||||
payment_method=payment_method,
|
||||
payment_type=payment_type,
|
||||
payment_status='completed',
|
||||
transaction_id=transaction_id,
|
||||
payment_date=datetime.utcnow(),
|
||||
paid_by_member_id=paid_by_member_id,
|
||||
paid_by_user_id=paid_by_user_id,
|
||||
notes=notes
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
|
||||
# Update group booking payment totals
|
||||
group_booking.amount_paid += amount
|
||||
group_booking.balance_due = group_booking.total_price - group_booking.amount_paid
|
||||
|
||||
# Update member payment if individual payment
|
||||
if paid_by_member_id:
|
||||
member = db.query(GroupBookingMember).filter(
|
||||
GroupBookingMember.id == paid_by_member_id
|
||||
).first()
|
||||
if member:
|
||||
member.individual_paid += amount
|
||||
member.individual_balance = (member.individual_amount or Decimal('0')) - member.individual_paid
|
||||
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
return payment
|
||||
|
||||
@staticmethod
|
||||
def cancel_group_booking(
|
||||
db: Session,
|
||||
group_booking_id: int,
|
||||
cancellation_reason: Optional[str] = None
|
||||
) -> GroupBooking:
|
||||
"""Cancel a group booking"""
|
||||
|
||||
group_booking = db.query(GroupBooking).filter(
|
||||
GroupBooking.id == group_booking_id
|
||||
).first()
|
||||
|
||||
if not group_booking:
|
||||
raise ValueError("Group booking not found")
|
||||
|
||||
if group_booking.status in [GroupBookingStatus.checked_out, GroupBookingStatus.cancelled]:
|
||||
raise ValueError(f"Cannot cancel booking with status {group_booking.status}")
|
||||
|
||||
# Calculate cancellation penalty
|
||||
penalty_amount = Decimal('0')
|
||||
if group_booking.cancellation_penalty_percentage:
|
||||
penalty_amount = group_booking.total_price * (
|
||||
Decimal(str(group_booking.cancellation_penalty_percentage)) / Decimal('100')
|
||||
)
|
||||
|
||||
# Update status
|
||||
group_booking.status = GroupBookingStatus.cancelled
|
||||
group_booking.cancelled_at = datetime.utcnow()
|
||||
|
||||
# Release room blocks
|
||||
room_blocks = db.query(GroupRoomBlock).filter(
|
||||
GroupRoomBlock.group_booking_id == group_booking_id
|
||||
).all()
|
||||
|
||||
for room_block in room_blocks:
|
||||
room_block.is_active = False
|
||||
room_block.block_released_at = datetime.utcnow()
|
||||
|
||||
# Cancel individual bookings if any
|
||||
individual_bookings = db.query(Booking).filter(
|
||||
Booking.group_booking_id == group_booking_id
|
||||
).all()
|
||||
|
||||
for booking in individual_bookings:
|
||||
if booking.status not in [BookingStatus.checked_out, BookingStatus.cancelled]:
|
||||
booking.status = BookingStatus.cancelled
|
||||
|
||||
db.commit()
|
||||
db.refresh(group_booking)
|
||||
|
||||
return group_booking
|
||||
|
||||
Reference in New Issue
Block a user