Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/BookingFlow.kt
androidlover5842 ca1597592f
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
Add booking guest link endpoint
2026-01-29 08:37:12 +05:30

399 lines
17 KiB
Kotlin

package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RoomBoardEvents
import com.android.trisolarisserver.controller.dto.BookingCancelRequest
import com.android.trisolarisserver.controller.dto.BookingCheckInRequest
import com.android.trisolarisserver.controller.dto.BookingCheckOutRequest
import com.android.trisolarisserver.controller.dto.BookingCreateRequest
import com.android.trisolarisserver.controller.dto.BookingCreateResponse
import com.android.trisolarisserver.controller.dto.BookingLinkGuestRequest
import com.android.trisolarisserver.controller.dto.BookingNoShowRequest
import com.android.trisolarisserver.controller.dto.RoomStayPreAssignRequest
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.db.repo.GuestDocumentRepo
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.db.repo.GuestRatingRepo
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.booking.TransportMode
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.models.room.RateSource
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.repo.AppUserRepo
import com.android.trisolarisserver.repo.GuestVehicleRepo
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/bookings")
class BookingFlow(
private val propertyAccess: PropertyAccess,
private val bookingRepo: BookingRepo,
private val guestRepo: GuestRepo,
private val roomRepo: RoomRepo,
private val roomStayRepo: RoomStayRepo,
private val appUserRepo: AppUserRepo,
private val propertyRepo: PropertyRepo,
private val roomBoardEvents: RoomBoardEvents,
private val guestVehicleRepo: GuestVehicleRepo,
private val guestRatingRepo: GuestRatingRepo,
private val guestDocumentRepo: GuestDocumentRepo
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun createBooking(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCreateRequest
): BookingCreateResponse {
val actor = requireActor(propertyId, principal)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val expectedCheckInAt = parseOffset(request.expectedCheckInAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckInAt required")
val expectedCheckOutAt = parseOffset(request.expectedCheckOutAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expectedCheckOutAt required")
if (!expectedCheckOutAt.isAfter(expectedCheckInAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
}
val now = nowForProperty(property.timezone)
val shouldCheckIn = !expectedCheckInAt.isBefore(now)
val booking = com.android.trisolarisserver.models.booking.Booking(
property = property,
status = if (shouldCheckIn) BookingStatus.CHECKED_IN else BookingStatus.OPEN,
source = request.source?.trim().takeIf { !it.isNullOrBlank() } ?: "WALKIN",
checkinAt = if (shouldCheckIn) expectedCheckInAt else null,
expectedCheckinAt = if (shouldCheckIn) null else expectedCheckInAt,
expectedCheckoutAt = if (shouldCheckIn) null else expectedCheckOutAt,
transportMode = request.transportMode?.let {
val mode = parseTransportMode(it)
if (!isTransportModeAllowed(property, mode)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled")
}
mode
},
adultCount = request.adultCount,
totalGuestCount = request.totalGuestCount,
notes = request.notes,
createdBy = actor,
updatedAt = now
)
val saved = bookingRepo.save(booking)
val guest = com.android.trisolarisserver.models.booking.Guest(
property = property,
createdBy = actor,
updatedAt = now
)
val savedGuest = guestRepo.save(guest)
saved.primaryGuest = savedGuest
saved.updatedAt = now
bookingRepo.save(saved)
return BookingCreateResponse(
id = saved.id!!,
status = saved.status.name,
guestId = savedGuest.id,
checkInAt = saved.checkinAt?.toString(),
expectedCheckInAt = saved.expectedCheckinAt?.toString(),
expectedCheckOutAt = saved.expectedCheckoutAt?.toString()
)
}
@PostMapping("/{bookingId}/link-guest")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun linkGuest(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingLinkGuestRequest
) {
requireMember(propertyAccess, propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
val guest = guestRepo.findById(request.guestId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Guest not found")
}
if (guest.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Guest not in property")
}
val previous = booking.primaryGuest
booking.primaryGuest = guest
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
if (previous != null && previous.id != guest.id && isPlaceholderGuest(previous) && isSafeToDelete(previous)) {
guestRepo.delete(previous)
}
}
@PostMapping("/{bookingId}/check-in")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun checkIn(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCheckInRequest
) {
val actor = requireActor(propertyId, principal)
val roomIds = request.roomIds.distinct()
if (roomIds.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomIds required")
}
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.OPEN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
}
val now = OffsetDateTime.now()
val checkInAt = parseOffset(request.checkInAt) ?: now
val rooms = roomIds.map { roomId ->
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
if (!room.active || room.maintenance) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
}
room
}
val occupied = roomStayRepo.findActiveRoomIds(propertyId, rooms.mapNotNull { it.id })
if (occupied.isNotEmpty()) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
}
rooms.forEach { room ->
val stay = RoomStay(
property = booking.property,
booking = booking,
room = room,
fromAt = checkInAt,
toAt = null,
rateSource = parseRateSource(request.rateSource),
nightlyRate = request.nightlyRate,
ratePlanCode = request.ratePlanCode,
currency = request.currency ?: booking.property.currency,
createdBy = actor
)
roomStayRepo.save(stay)
}
booking.status = BookingStatus.CHECKED_IN
booking.checkinAt = checkInAt
booking.transportMode = request.transportMode?.let {
val mode = parseTransportMode(it)
if (!isTransportModeAllowed(booking.property, mode)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Transport mode disabled")
}
mode
}
if (request.notes != null) booking.notes = request.notes
booking.updatedAt = now
bookingRepo.save(booking)
roomBoardEvents.emit(propertyId)
}
@PostMapping("/{bookingId}/check-out")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun checkOut(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCheckOutRequest
) {
requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.CHECKED_IN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in")
}
val now = OffsetDateTime.now()
val checkOutAt = parseOffset(request.checkOutAt) ?: now
val stays = roomStayRepo.findActiveByBookingId(bookingId)
stays.forEach { it.toAt = checkOutAt }
roomStayRepo.saveAll(stays)
booking.status = BookingStatus.CHECKED_OUT
booking.checkoutAt = checkOutAt
if (request.notes != null) booking.notes = request.notes
booking.updatedAt = now
bookingRepo.save(booking)
roomBoardEvents.emit(propertyId)
}
@PostMapping("/{bookingId}/cancel")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun cancel(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCancelRequest
) {
requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status == BookingStatus.CHECKED_IN) {
val active = roomStayRepo.findActiveByBookingId(bookingId)
if (active.isNotEmpty()) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel checked-in booking")
}
}
booking.status = BookingStatus.CANCELLED
if (request.reason != null) booking.notes = request.reason
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
}
@PostMapping("/{bookingId}/no-show")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun noShow(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingNoShowRequest
) {
requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.OPEN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open")
}
booking.status = BookingStatus.NO_SHOW
if (request.reason != null) booking.notes = request.reason
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
}
@PostMapping("/{bookingId}/room-stays")
@ResponseStatus(HttpStatus.CREATED)
@Transactional
fun preAssignRoom(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomStayPreAssignRequest
) {
val actor = requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
}
val room = roomRepo.findByIdAndPropertyId(request.roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
if (!room.active || room.maintenance) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
}
val fromAt = parseOffset(request.fromAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "fromAt required")
val toAt = parseOffset(request.toAt)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "toAt required")
if (!toAt.isAfter(fromAt)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
}
if (roomStayRepo.existsOverlap(propertyId, request.roomId, fromAt, toAt)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already reserved/occupied for range")
}
val stay = RoomStay(
property = booking.property,
booking = booking,
room = room,
fromAt = fromAt,
toAt = toAt,
rateSource = parseRateSource(request.rateSource),
nightlyRate = request.nightlyRate,
ratePlanCode = request.ratePlanCode,
currency = request.currency ?: booking.property.currency,
createdBy = actor
)
roomStayRepo.save(stay)
}
private fun requireBooking(propertyId: UUID, bookingId: UUID): com.android.trisolarisserver.models.booking.Booking {
val booking = bookingRepo.findById(bookingId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
}
if (booking.property.id != propertyId) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
}
return booking
}
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
val resolved = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF)
return appUserRepo.findById(resolved.userId).orElseThrow {
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
}
}
private fun parseTransportMode(value: String): TransportMode {
return try {
TransportMode.valueOf(value)
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
}
}
private fun parseRateSource(value: String?): RateSource? {
if (value.isNullOrBlank()) return null
return try {
RateSource.valueOf(value.trim())
} catch (_: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown rate source")
}
}
private fun isTransportModeAllowed(
property: com.android.trisolarisserver.models.property.Property,
mode: TransportMode
): Boolean {
val allowed = if (property.allowedTransportModes.isNotEmpty()) {
property.allowedTransportModes
} else {
TransportMode.entries.toSet()
}
return allowed.contains(mode)
}
private fun isPlaceholderGuest(guest: com.android.trisolarisserver.models.booking.Guest): Boolean {
return guest.phoneE164.isNullOrBlank() &&
guest.name.isNullOrBlank() &&
guest.nationality.isNullOrBlank() &&
guest.addressText.isNullOrBlank() &&
guest.signaturePath.isNullOrBlank()
}
private fun isSafeToDelete(guest: com.android.trisolarisserver.models.booking.Guest): Boolean {
val id = guest.id ?: return false
if (bookingRepo.countByPrimaryGuestId(id) > 0) return false
if (guestVehicleRepo.existsByGuestId(id)) return false
if (guestDocumentRepo.existsByGuestId(id)) return false
if (guestRatingRepo.existsByGuestId(id)) return false
return true
}
}