Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt
androidlover5842 30c37affb4
All checks were successful
build-and-deploy / build-deploy (push) Successful in 35s
Add room-type quantity reservation APIs
2026-02-02 09:09:40 +05:30

750 lines
31 KiB
Kotlin

package com.android.trisolarisserver.controller.booking
import com.android.trisolarisserver.controller.common.computeExpectedPay
import com.android.trisolarisserver.controller.common.nowForProperty
import com.android.trisolarisserver.controller.common.parseOffset
import com.android.trisolarisserver.controller.common.requireMember
import com.android.trisolarisserver.controller.common.requireRole
import com.android.trisolarisserver.component.booking.BookingEvents
import com.android.trisolarisserver.component.auth.PropertyAccess
import com.android.trisolarisserver.component.room.RoomBoardEvents
import com.android.trisolarisserver.controller.dto.booking.BookingCancelRequest
import com.android.trisolarisserver.controller.dto.booking.BookingBulkCheckInRequest
import com.android.trisolarisserver.controller.dto.booking.BookingCheckOutRequest
import com.android.trisolarisserver.controller.dto.booking.BookingCreateRequest
import com.android.trisolarisserver.controller.dto.booking.BookingCreateResponse
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
import com.android.trisolarisserver.controller.dto.booking.BookingExpectedDatesUpdateRequest
import com.android.trisolarisserver.controller.dto.booking.BookingLinkGuestRequest
import com.android.trisolarisserver.controller.dto.booking.BookingNoShowRequest
import com.android.trisolarisserver.controller.dto.booking.BookingListItem
import com.android.trisolarisserver.repo.booking.BookingRepo
import com.android.trisolarisserver.repo.guest.GuestDocumentRepo
import com.android.trisolarisserver.repo.guest.GuestRepo
import com.android.trisolarisserver.repo.guest.GuestRatingRepo
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus
import com.android.trisolarisserver.models.booking.MemberRelation
import com.android.trisolarisserver.models.booking.TransportMode
import com.android.trisolarisserver.models.room.RoomStayAuditLog
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.property.AppUserRepo
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
import com.android.trisolarisserver.repo.booking.BookingRoomRequestRepo
import com.android.trisolarisserver.repo.property.PropertyRepo
import com.android.trisolarisserver.repo.room.RoomRepo
import com.android.trisolarisserver.repo.room.RoomStayAuditLogRepo
import com.android.trisolarisserver.repo.room.RoomStayRepo
import com.android.trisolarisserver.security.MyPrincipal
import jakarta.servlet.http.HttpServletResponse
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 bookingEvents: BookingEvents,
private val guestVehicleRepo: GuestVehicleRepo,
private val guestRatingRepo: GuestRatingRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val paymentRepo: PaymentRepo,
private val bookingSnapshotBuilder: BookingSnapshotBuilder,
private val roomStayAuditLogRepo: RoomStayAuditLogRepo,
private val bookingRoomRequestRepo: BookingRoomRequestRepo
) {
@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)
bookingEvents.emit(propertyId, saved.id!!)
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<BookingListItem> {
requireRole(
propertyAccess,
propertyId,
principal,
Role.ADMIN,
Role.MANAGER,
Role.STAFF,
Role.HOUSEKEEPING,
Role.FINANCE
)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val statuses = parseStatuses(status)
val bookings = if (statuses.isEmpty()) {
bookingRepo.findByPropertyIdOrderByCreatedAtDesc(propertyId)
} else {
bookingRepo.findByPropertyIdAndStatusInOrderByCreatedAtDesc(propertyId, statuses)
}
val bookingIds = bookings.mapNotNull { it.id }
val roomNumbersByBooking = if (bookingIds.isEmpty()) {
emptyMap()
} else {
roomStayRepo.findActiveRoomNumbersByBookingIds(bookingIds)
.groupBy { it.bookingId }
.mapValues { (_, rows) -> rows.map { it.roomNumber }.distinct().sorted() }
}
val staysByBooking = if (bookingIds.isEmpty()) {
emptyMap()
} else {
roomStayRepo.findByBookingIdIn(bookingIds).groupBy { it.booking.id!! }
}
val paymentsByBooking = if (bookingIds.isEmpty()) {
emptyMap()
} else {
paymentRepo.sumAmountByBookingIds(bookingIds)
.associate { it.bookingId to it.total }
}
return bookings.map { booking ->
val guest = booking.primaryGuest
val stays = staysByBooking[booking.id].orEmpty()
val expectedPay = if (stays.isEmpty()) {
null
} else {
computeExpectedPay(stays, property.timezone)
}
val collected = paymentsByBooking[booking.id] ?: 0L
val pending = expectedPay?.let { it - collected }
BookingListItem(
id = booking.id!!,
status = booking.status.name,
guestId = guest?.id,
guestName = guest?.name,
guestPhone = guest?.phoneE164,
roomNumbers = roomNumbersByBooking[booking.id] ?: emptyList(),
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,
pending = pending
)
}
}
@GetMapping("/{bookingId}")
@Transactional
fun getBooking(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): BookingDetailResponse {
requireRole(
propertyAccess,
propertyId,
principal,
Role.ADMIN,
Role.MANAGER,
Role.STAFF,
Role.HOUSEKEEPING,
Role.FINANCE
)
val booking = requireBooking(propertyId, bookingId)
return bookingSnapshotBuilder.build(propertyId, bookingId)
}
@GetMapping("/{bookingId}/stream")
fun streamBooking(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
response: HttpServletResponse
): org.springframework.web.servlet.mvc.method.annotation.SseEmitter {
requireRole(
propertyAccess,
propertyId,
principal,
Role.ADMIN,
Role.MANAGER,
Role.STAFF,
Role.HOUSEKEEPING,
Role.FINANCE
)
requireBooking(propertyId, bookingId)
prepareSse(response)
return bookingEvents.subscribe(propertyId, bookingId)
}
@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)
bookingEvents.emit(propertyId, bookingId)
if (previous != null && previous.id != guest.id && isPlaceholderGuest(previous) && isSafeToDelete(previous)) {
guestRepo.delete(previous)
}
}
@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<OffsetDateTime>()
request.stays.forEach { stay ->
val checkInAt = parseOffset(stay.checkInAt) ?: now
checkInTimes.add(checkInAt)
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)
fulfillRoomRequestIfAny(booking.id!!, room.roomType.id!!, checkInAt)
}
val bookingCheckInAt = checkInTimes.minOrNull() ?: now
booking.status = BookingStatus.CHECKED_IN
booking.checkinAt = bookingCheckInAt
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)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/expected-dates")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun updateExpectedDates(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingExpectedDatesUpdateRequest
) {
requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
when (booking.status) {
BookingStatus.OPEN -> {
if (request.expectedCheckInAt != null) {
booking.expectedCheckinAt = parseOffset(request.expectedCheckInAt)
}
if (request.expectedCheckOutAt != null) {
booking.expectedCheckoutAt = parseOffset(request.expectedCheckOutAt)
}
}
BookingStatus.CHECKED_IN -> {
if (request.expectedCheckInAt != null) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot change expected check-in after check-in")
}
if (request.expectedCheckOutAt != null) {
booking.expectedCheckoutAt = parseOffset(request.expectedCheckOutAt)
}
}
BookingStatus.CHECKED_OUT,
BookingStatus.CANCELLED,
BookingStatus.NO_SHOW -> {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed")
}
}
val expectedIn = booking.expectedCheckinAt
val expectedOut = booking.expectedCheckoutAt
if (expectedIn != null && expectedOut != null && !expectedOut.isAfter(expectedIn)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
}
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/check-out")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun checkOut(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCheckOutRequest
) {
val actor = 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)
if (stays.any { !isCheckoutAmountValid(it) || !isMinimumStayDurationValid(it, checkOutAt) }) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range")
}
val oldStates = stays.associate { it.id!! to it.toAt }
stays.forEach { it.toAt = checkOutAt }
roomStayRepo.saveAll(stays)
stays.forEach {
logStayAudit(
stay = it,
action = "CHECK_OUT",
actor = actor,
oldToAt = oldStates[it.id],
oldIsVoided = it.isVoided,
newToAt = checkOutAt,
newIsVoided = false,
reason = request.notes
)
}
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)
bookingEvents.emit(propertyId, bookingId)
}
@PostMapping("/{bookingId}/room-stays/{roomStayId}/check-out")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional
fun checkOutRoomStay(
@PathVariable propertyId: UUID,
@PathVariable bookingId: UUID,
@PathVariable roomStayId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCheckOutRequest
) {
val actor = requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.CHECKED_IN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in")
}
val staysForBooking = roomStayRepo.findByBookingId(bookingId)
val singleStayBooking = staysForBooking.size == 1
val stay = roomStayRepo.findByIdAndBookingId(roomStayId, bookingId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for booking")
if (stay.toAt != null) {
if (singleStayBooking) {
booking.status = BookingStatus.CHECKED_OUT
booking.checkoutAt = stay.toAt
booking.updatedAt = OffsetDateTime.now()
bookingRepo.save(booking)
bookingEvents.emit(propertyId, bookingId)
}
return
}
val now = OffsetDateTime.now()
val checkOutAt = parseOffset(request.checkOutAt) ?: now
if (!isCheckoutAmountValid(stay)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay amount is outside allowed range")
}
if (!isMinimumStayDurationValid(stay, checkOutAt)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Minimum stay duration is 1 hour")
}
val oldToAt = stay.toAt
val oldIsVoided = stay.isVoided
stay.toAt = checkOutAt
roomStayRepo.save(stay)
logStayAudit(
stay = stay,
action = "CHECK_OUT",
actor = actor,
oldToAt = oldToAt,
oldIsVoided = oldIsVoided,
newToAt = checkOutAt,
newIsVoided = false,
reason = request.notes
)
val remainingActive = roomStayRepo.findActiveByBookingId(bookingId)
if (singleStayBooking || remainingActive.isEmpty()) {
booking.status = BookingStatus.CHECKED_OUT
booking.checkoutAt = checkOutAt
booking.updatedAt = now
bookingRepo.save(booking)
}
roomBoardEvents.emit(propertyId)
bookingEvents.emit(propertyId, bookingId)
}
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<BookingStatus> {
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)
bookingEvents.emit(propertyId, bookingId)
}
@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)
bookingEvents.emit(propertyId, bookingId)
}
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 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 fulfillRoomRequestIfAny(bookingId: UUID, roomTypeId: UUID, checkInAt: OffsetDateTime) {
val requests = bookingRoomRequestRepo.findActiveForFulfillment(bookingId, roomTypeId, checkInAt)
for (request in requests) {
if (request.fulfilledQuantity >= request.quantity) continue
request.fulfilledQuantity += 1
if (request.fulfilledQuantity >= request.quantity) {
request.status = BookingRoomRequestStatus.FULFILLED
}
bookingRoomRequestRepo.save(request)
return
}
}
private fun isCheckoutAmountValid(stay: RoomStay): Boolean {
val base = stay.room.roomType.defaultRate ?: return true
val nightly = stay.nightlyRate ?: return false
val low = (base * 80L) / 100L
val high = (base * 120L) / 100L
return nightly in low..high
}
private fun isMinimumStayDurationValid(stay: RoomStay, checkOutAt: OffsetDateTime): Boolean {
return java.time.Duration.between(stay.fromAt, checkOutAt).toMinutes() >= 60
}
private fun logStayAudit(
stay: RoomStay,
action: String,
actor: com.android.trisolarisserver.models.property.AppUser?,
oldToAt: OffsetDateTime?,
oldIsVoided: Boolean,
newToAt: OffsetDateTime?,
newIsVoided: Boolean,
reason: String?
) {
roomStayAuditLogRepo.save(
RoomStayAuditLog(
property = stay.property,
booking = stay.booking,
roomStay = stay,
action = action,
oldToAt = oldToAt,
newToAt = newToAt,
oldIsVoided = oldIsVoided,
newIsVoided = newIsVoided,
reason = reason,
actor = actor
)
)
}
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
}
private fun prepareSse(response: HttpServletResponse) {
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
response.setHeader("X-Accel-Buffering", "no")
}
}