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") } } }