278 lines
11 KiB
Kotlin
278 lines
11 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.BookingNoShowRequest
|
|
import com.android.trisolarisserver.controller.dto.RoomStayPreAssignRequest
|
|
import com.android.trisolarisserver.db.repo.BookingRepo
|
|
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.property.Role
|
|
import com.android.trisolarisserver.repo.AppUserRepo
|
|
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 roomRepo: RoomRepo,
|
|
private val roomStayRepo: RoomStayRepo,
|
|
private val appUserRepo: AppUserRepo,
|
|
private val roomBoardEvents: RoomBoardEvents
|
|
) {
|
|
|
|
@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,
|
|
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
|
|
}
|
|
booking.transportVehicleNumber = request.transportVehicleNumber
|
|
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,
|
|
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 {
|
|
requirePrincipal(principal)
|
|
propertyAccess.requireMember(propertyId, principal!!.userId)
|
|
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
|
return appUserRepo.findById(principal.userId).orElseThrow {
|
|
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
|
}
|
|
}
|
|
|
|
private fun parseOffset(value: String?): OffsetDateTime? {
|
|
if (value.isNullOrBlank()) return null
|
|
return try {
|
|
OffsetDateTime.parse(value.trim())
|
|
} catch (_: Exception) {
|
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
|
}
|
|
}
|
|
|
|
private fun parseTransportMode(value: String): TransportMode {
|
|
return try {
|
|
TransportMode.valueOf(value)
|
|
} catch (_: IllegalArgumentException) {
|
|
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
|
|
}
|
|
}
|
|
|
|
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 requirePrincipal(principal: MyPrincipal?) {
|
|
if (principal == null) {
|
|
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
|
}
|
|
}
|
|
}
|