Add room-stay void/checkout controls and audit logs
All checks were successful
build-and-deploy / build-deploy (push) Successful in 39s

This commit is contained in:
androidlover5842
2026-02-02 08:19:40 +05:30
parent 240e8fca25
commit e77ae6396e
9 changed files with 284 additions and 3 deletions

View File

@@ -27,6 +27,7 @@ import com.android.trisolarisserver.repo.guest.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.RoomStayAuditLog
import com.android.trisolarisserver.models.room.RoomStay
import com.android.trisolarisserver.models.room.RateSource
import com.android.trisolarisserver.models.property.Role
@@ -35,6 +36,7 @@ import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
import com.android.trisolarisserver.repo.booking.PaymentRepo
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
@@ -69,7 +71,8 @@ class BookingFlow(
private val guestRatingRepo: GuestRatingRepo,
private val guestDocumentRepo: GuestDocumentRepo,
private val paymentRepo: PaymentRepo,
private val bookingSnapshotBuilder: BookingSnapshotBuilder
private val bookingSnapshotBuilder: BookingSnapshotBuilder,
private val roomStayAuditLogRepo: RoomStayAuditLogRepo
) {
@PostMapping
@@ -511,7 +514,7 @@ class BookingFlow(
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: BookingCheckOutRequest
) {
requireActor(propertyId, principal)
val actor = requireActor(propertyId, principal)
val booking = requireBooking(propertyId, bookingId)
if (booking.status != BookingStatus.CHECKED_IN) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Booking not checked in")
@@ -520,8 +523,24 @@ class BookingFlow(
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
@@ -532,6 +551,62 @@ class BookingFlow(
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 stay = roomStayRepo.findByIdAndBookingId(roomStayId, bookingId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for booking")
if (stay.toAt != null) {
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 (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,
@@ -706,6 +781,44 @@ class BookingFlow(
}
}
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