add booking flow
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
package com.android.trisolarisserver.controller
|
||||
|
||||
import com.android.trisolarisserver.component.PropertyAccess
|
||||
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.BookingNoShowRequest
|
||||
import com.android.trisolarisserver.db.repo.BookingRepo
|
||||
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.property.Role
|
||||
import com.android.trisolarisserver.repo.AppUserRepo
|
||||
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 roomRepo: RoomRepo,
|
||||
private val roomStayRepo: RoomStayRepo,
|
||||
private val appUserRepo: AppUserRepo
|
||||
) {
|
||||
|
||||
@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,
|
||||
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
|
||||
}
|
||||
booking.transportVehicleNumber = request.transportVehicleNumber
|
||||
if (request.notes != null) booking.notes = request.notes
|
||||
booking.updatedAt = now
|
||||
bookingRepo.save(booking)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
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 {
|
||||
requirePrincipal(principal)
|
||||
propertyAccess.requireMember(propertyId, principal!!.userId)
|
||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||
return appUserRepo.findById(principal.userId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseOffset(value: String?): OffsetDateTime? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return try {
|
||||
OffsetDateTime.parse(value.trim())
|
||||
} catch (_: Exception) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTransportMode(value: String): TransportMode {
|
||||
return try {
|
||||
TransportMode.valueOf(value)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown transport mode")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTransportModeAllowed(
|
||||
property: com.android.trisolarisserver.models.property.Property,
|
||||
mode: TransportMode
|
||||
): Boolean {
|
||||
val allowed = when {
|
||||
property.allowedTransportModes.isNotEmpty() -> property.allowedTransportModes
|
||||
property.org.allowedTransportModes.isNotEmpty() -> property.org.allowedTransportModes
|
||||
else -> TransportMode.entries.toSet()
|
||||
}
|
||||
return allowed.contains(mode)
|
||||
}
|
||||
|
||||
private fun requirePrincipal(principal: MyPrincipal?) {
|
||||
if (principal == null) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.android.trisolarisserver.controller
|
||||
|
||||
import com.android.trisolarisserver.component.PropertyAccess
|
||||
import com.android.trisolarisserver.controller.dto.RoomChangeRequest
|
||||
import com.android.trisolarisserver.controller.dto.RoomChangeResponse
|
||||
import com.android.trisolarisserver.models.room.RoomStay
|
||||
import com.android.trisolarisserver.models.room.RoomStayChange
|
||||
import com.android.trisolarisserver.models.property.Role
|
||||
import com.android.trisolarisserver.repo.AppUserRepo
|
||||
import com.android.trisolarisserver.repo.RoomRepo
|
||||
import com.android.trisolarisserver.repo.RoomStayChangeRepo
|
||||
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}/room-stays")
|
||||
class RoomStayFlow(
|
||||
private val propertyAccess: PropertyAccess,
|
||||
private val roomStayRepo: RoomStayRepo,
|
||||
private val roomStayChangeRepo: RoomStayChangeRepo,
|
||||
private val roomRepo: RoomRepo,
|
||||
private val appUserRepo: AppUserRepo
|
||||
) {
|
||||
|
||||
@PostMapping("/{roomStayId}/change-room")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@Transactional
|
||||
fun changeRoom(
|
||||
@PathVariable propertyId: UUID,
|
||||
@PathVariable roomStayId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||
@RequestBody request: RoomChangeRequest
|
||||
): RoomChangeResponse {
|
||||
val actor = requireActor(propertyId, principal)
|
||||
|
||||
val stay = roomStayRepo.findById(roomStayId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found")
|
||||
}
|
||||
if (stay.property.id != propertyId) {
|
||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room stay not found for property")
|
||||
}
|
||||
if (stay.toAt != null) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room stay already closed")
|
||||
}
|
||||
if (request.idempotencyKey.isBlank()) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "idempotencyKey required")
|
||||
}
|
||||
if (request.newRoomId == stay.room.id) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "New room is same as current")
|
||||
}
|
||||
|
||||
val existing = roomStayChangeRepo.findByRoomStayIdAndIdempotencyKey(roomStayId, request.idempotencyKey)
|
||||
if (existing != null) {
|
||||
return toResponse(existing)
|
||||
}
|
||||
|
||||
val newRoom = roomRepo.findByIdAndPropertyId(request.newRoomId, propertyId)
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found")
|
||||
if (!newRoom.active || newRoom.maintenance) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room not available")
|
||||
}
|
||||
val occupied = roomStayRepo.findActiveRoomIds(propertyId, listOf(request.newRoomId))
|
||||
if (occupied.isNotEmpty()) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Room already occupied")
|
||||
}
|
||||
|
||||
val movedAt = parseOffset(request.movedAt) ?: OffsetDateTime.now()
|
||||
|
||||
stay.toAt = movedAt
|
||||
roomStayRepo.save(stay)
|
||||
|
||||
val newStay = RoomStay(
|
||||
property = stay.property,
|
||||
booking = stay.booking,
|
||||
room = newRoom,
|
||||
fromAt = movedAt,
|
||||
toAt = null,
|
||||
createdBy = actor
|
||||
)
|
||||
val savedNewStay = roomStayRepo.save(newStay)
|
||||
|
||||
val change = RoomStayChange(
|
||||
property = stay.property,
|
||||
roomStay = stay,
|
||||
newRoomStay = savedNewStay,
|
||||
idempotencyKey = request.idempotencyKey
|
||||
)
|
||||
val savedChange = roomStayChangeRepo.save(change)
|
||||
return toResponse(savedChange)
|
||||
}
|
||||
|
||||
private fun toResponse(change: RoomStayChange): RoomChangeResponse {
|
||||
return RoomChangeResponse(
|
||||
oldRoomStayId = change.roomStay.id!!,
|
||||
newRoomStayId = change.newRoomStay.id!!,
|
||||
oldRoomId = change.roomStay.room.id!!,
|
||||
newRoomId = change.newRoomStay.room.id!!,
|
||||
movedAt = change.newRoomStay.fromAt.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOffset(value: String?): OffsetDateTime? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return try {
|
||||
OffsetDateTime.parse(value.trim())
|
||||
} catch (_: Exception) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireActor(propertyId: UUID, principal: MyPrincipal?): com.android.trisolarisserver.models.property.AppUser {
|
||||
if (principal == null) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||
}
|
||||
propertyAccess.requireMember(propertyId, principal.userId)
|
||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER, Role.STAFF)
|
||||
return appUserRepo.findById(principal.userId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.android.trisolarisserver.controller.dto
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class BookingCheckInRequest(
|
||||
val roomIds: List<UUID>,
|
||||
val checkInAt: String? = null,
|
||||
val transportMode: String? = null,
|
||||
val transportVehicleNumber: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class BookingCheckOutRequest(
|
||||
val checkOutAt: String? = null,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class BookingCancelRequest(
|
||||
val cancelledAt: String? = null,
|
||||
val reason: String? = null
|
||||
)
|
||||
|
||||
data class BookingNoShowRequest(
|
||||
val noShowAt: String? = null,
|
||||
val reason: String? = null
|
||||
)
|
||||
|
||||
data class RoomChangeRequest(
|
||||
val newRoomId: UUID,
|
||||
val movedAt: String? = null,
|
||||
val idempotencyKey: String
|
||||
)
|
||||
|
||||
data class RoomChangeResponse(
|
||||
val oldRoomStayId: UUID,
|
||||
val newRoomStayId: UUID,
|
||||
val oldRoomId: UUID,
|
||||
val newRoomId: UUID,
|
||||
val movedAt: String
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.android.trisolarisserver.models.room
|
||||
|
||||
import com.android.trisolarisserver.models.property.Property
|
||||
import jakarta.persistence.*
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "room_stay_change",
|
||||
uniqueConstraints = [UniqueConstraint(columnNames = ["room_stay_id", "idempotency_key"])]
|
||||
)
|
||||
class RoomStayChange(
|
||||
@Id
|
||||
@GeneratedValue
|
||||
@Column(columnDefinition = "uuid")
|
||||
val id: UUID? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "property_id", nullable = false)
|
||||
var property: Property,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "room_stay_id", nullable = false)
|
||||
var roomStay: RoomStay,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "new_room_stay_id", nullable = false)
|
||||
var newRoomStay: RoomStay,
|
||||
|
||||
@Column(name = "idempotency_key", nullable = false)
|
||||
var idempotencyKey: String,
|
||||
|
||||
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
|
||||
val createdAt: OffsetDateTime = OffsetDateTime.now()
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.android.trisolarisserver.repo
|
||||
|
||||
import com.android.trisolarisserver.models.room.RoomStayChange
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.util.UUID
|
||||
|
||||
interface RoomStayChangeRepo : JpaRepository<RoomStayChange, UUID> {
|
||||
fun findByRoomStayIdAndIdempotencyKey(roomStayId: UUID, idempotencyKey: String): RoomStayChange?
|
||||
}
|
||||
@@ -27,4 +27,24 @@ interface RoomStayRepo : JpaRepository<RoomStay, UUID> {
|
||||
@Param("fromAt") fromAt: java.time.OffsetDateTime,
|
||||
@Param("toAt") toAt: java.time.OffsetDateTime
|
||||
): List<UUID>
|
||||
|
||||
@Query("""
|
||||
select rs
|
||||
from RoomStay rs
|
||||
where rs.booking.id = :bookingId
|
||||
and rs.toAt is null
|
||||
""")
|
||||
fun findActiveByBookingId(@Param("bookingId") bookingId: UUID): List<RoomStay>
|
||||
|
||||
@Query("""
|
||||
select rs.room.id
|
||||
from RoomStay rs
|
||||
where rs.property.id = :propertyId
|
||||
and rs.room.id in :roomIds
|
||||
and rs.toAt is null
|
||||
""")
|
||||
fun findActiveRoomIds(
|
||||
@Param("propertyId") propertyId: UUID,
|
||||
@Param("roomIds") roomIds: List<UUID>
|
||||
): List<UUID>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user