Add room-stay void/checkout controls and audit logs
All checks were successful
build-and-deploy / build-deploy (push) Successful in 39s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 39s
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user