This commit is contained in:
Iliyan Angelov
2025-11-30 22:43:09 +02:00
parent 24b40450dd
commit 39fcfff811
1610 changed files with 5442 additions and 1383 deletions

View File

View File

View 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'),
)

View 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')

View 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])

View File

File diff suppressed because it is too large Load Diff

View 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
}

View File

View 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

View 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