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.BookingBulkCheckInRequest 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.BookingListItem 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.MemberRelation 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.GetMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam 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 phone = request.guestPhoneE164?.trim()?.takeIf { it.isNotBlank() } val guest = resolveGuestForBooking(propertyId, property, actor, now, phone) val fromCity = request.fromCity?.trim()?.ifBlank { null } val toCity = request.toCity?.trim()?.ifBlank { null } val memberRelation = parseMemberRelation(request.memberRelation) 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!=null) { adultCount + (request.childCount ?: 0) } else { null } val booking = com.android.trisolarisserver.models.booking.Booking( property = property, primaryGuest = guest, status = BookingStatus.OPEN, source = request.source?.trim().takeIf { !it.isNullOrBlank() } ?: "WALKIN", checkinAt = null, expectedCheckinAt = expectedCheckInAt, expectedCheckoutAt = 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, expectedGuestCount = request.expectedGuestCount, fromCity = fromCity, toCity = toCity, memberRelation = memberRelation, 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() ) } @GetMapping fun listBookings( @PathVariable propertyId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestParam(required = false) status: String? ): List { requireRole( propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE ) val statuses = parseStatuses(status) val bookings = if (statuses.isEmpty()) { bookingRepo.findByPropertyIdOrderByCreatedAtDesc(propertyId) } else { bookingRepo.findByPropertyIdAndStatusInOrderByCreatedAtDesc(propertyId, statuses) } return bookings.map { booking -> val guest = booking.primaryGuest BookingListItem( id = booking.id!!, status = booking.status.name, guestId = guest?.id, guestName = guest?.name, guestPhone = guest?.phoneE164, source = booking.source, expectedCheckInAt = booking.expectedCheckinAt?.toString(), expectedCheckOutAt = booking.expectedCheckoutAt?.toString(), checkInAt = booking.checkinAt?.toString(), checkOutAt = booking.checkoutAt?.toString(), adultCount = booking.adultCount, childCount = booking.childCount, maleCount = booking.maleCount, femaleCount = booking.femaleCount, totalGuestCount = booking.totalGuestCount, expectedGuestCount = booking.expectedGuestCount, notes = booking.notes ) } } @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-in/bulk") @ResponseStatus(HttpStatus.CREATED) @Transactional fun bulkCheckIn( @PathVariable propertyId: UUID, @PathVariable bookingId: UUID, @AuthenticationPrincipal principal: MyPrincipal?, @RequestBody request: BookingBulkCheckInRequest ) { val actor = requireActor(propertyId, principal) if (request.stays.isEmpty()) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "stays required") } val booking = requireBooking(propertyId, bookingId) if (booking.status != BookingStatus.OPEN) { throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not open") } val roomIds = request.stays.map { it.roomId } if (roomIds.distinct().size != roomIds.size) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate roomId in stays") } val rooms = request.stays.associate { stay -> val room = roomRepo.findByIdAndPropertyId(stay.roomId, propertyId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found") if (!room.active || room.maintenance) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available") } stay.roomId to room } val occupied = roomStayRepo.findActiveRoomIds(propertyId, roomIds) if (occupied.isNotEmpty()) { throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied") } val now = OffsetDateTime.now() val checkInTimes = mutableListOf() val checkOutTimes = mutableListOf() request.stays.forEach { stay -> val checkInAt = parseOffset(stay.checkInAt) ?: now checkInTimes.add(checkInAt) val checkOutAt = parseOffset(stay.checkOutAt) if (checkOutAt != null) { if (!checkOutAt.isAfter(checkInAt)) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range for stay") } checkOutTimes.add(checkOutAt) } val room = rooms.getValue(stay.roomId) val newStay = RoomStay( property = booking.property, booking = booking, room = room, fromAt = checkInAt, toAt = null, rateSource = parseRateSource(stay.rateSource), nightlyRate = stay.nightlyRate, ratePlanCode = stay.ratePlanCode, currency = stay.currency ?: booking.property.currency, createdBy = actor ) roomStayRepo.save(newStay) } val bookingCheckInAt = checkInTimes.minOrNull() ?: now val bookingExpectedCheckout = checkOutTimes.maxOrNull() booking.status = BookingStatus.CHECKED_IN booking.checkinAt = bookingCheckInAt if (bookingExpectedCheckout != null) { booking.expectedCheckoutAt = bookingExpectedCheckout } 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) } private fun parseStatuses(raw: String?): Set { if (raw.isNullOrBlank()) return emptySet() return raw.split(",") .map { it.trim() } .filter { it.isNotEmpty() } .map { value -> try { BookingStatus.valueOf(value.uppercase()) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid status: $value") } } .toSet() } @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) 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 parseMemberRelation(value: String?): MemberRelation? { if (value.isNullOrBlank()) return null return try { MemberRelation.valueOf(value.trim().uppercase()) } catch (_: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown member relation") } } private fun parseRateSource(value: String?): RateSource? { if (value.isNullOrBlank()) return null return try { RateSource.valueOf(value.trim().uppercase()) } 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 = property.allowedTransportModes.ifEmpty { 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 } }