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 phone = request.guestPhoneE164?.trim()?.takeIf { it.isNotBlank() } val guest = resolveGuestForBooking(propertyId, property, actor, now, phone) val hasGuestCounts = request.maleCount != null || request.femaleCount != null || request.childCount != null val adultCount = if (hasGuestCounts) { (request.maleCount ?: 0) + (request.femaleCount ?: 0) } else { null } val totalGuestCount = if (hasGuestCounts) { adultCount + (request.childCount ?: 0) } else { null } val booking = com.android.trisolarisserver.models.booking.Booking( property = property, primaryGuest = guest, 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 = adultCount, childCount = request.childCount, maleCount = request.maleCount, femaleCount = request.femaleCount, totalGuestCount = totalGuestCount, notes = request.notes, createdBy = actor, updatedAt = now ) val saved = bookingRepo.save(booking) return BookingCreateResponse( id = saved.id!!, status = saved.status.name, guestId = guest.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) } private fun resolveGuestForBooking( propertyId: UUID, property: com.android.trisolarisserver.models.property.Property, actor: com.android.trisolarisserver.models.property.AppUser?, now: OffsetDateTime, phone: String? ): com.android.trisolarisserver.models.booking.Guest { if (phone != null) { val existing = guestRepo.findByPropertyIdAndPhoneE164(propertyId, phone) if (existing != null) { return existing } } val guest = com.android.trisolarisserver.models.booking.Guest( property = property, phoneE164 = phone, createdBy = actor, updatedAt = now ) return guestRepo.save(guest) } @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 } }