Reorganize packages by domain
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
All checks were successful
build-and-deploy / build-deploy (push) Successful in 34s
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package com.android.trisolarisserver.controller.booking
|
||||
import com.android.trisolarisserver.controller.common.computeExpectedPay
|
||||
import com.android.trisolarisserver.controller.common.requireMember
|
||||
|
||||
import com.android.trisolarisserver.component.auth.PropertyAccess
|
||||
import com.android.trisolarisserver.controller.dto.payment.BookingBalanceResponse
|
||||
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||
import com.android.trisolarisserver.security.MyPrincipal
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/balance")
|
||||
class BookingBalances(
|
||||
private val propertyAccess: PropertyAccess,
|
||||
private val bookingRepo: BookingRepo,
|
||||
private val roomStayRepo: RoomStayRepo,
|
||||
private val paymentRepo: PaymentRepo
|
||||
) {
|
||||
|
||||
@GetMapping
|
||||
fun getBalance(
|
||||
@PathVariable propertyId: UUID,
|
||||
@PathVariable bookingId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?
|
||||
): BookingBalanceResponse {
|
||||
requireMember(propertyAccess, propertyId, principal)
|
||||
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")
|
||||
}
|
||||
val expected = computeExpectedPay(
|
||||
roomStayRepo.findByBookingId(bookingId),
|
||||
booking.property.timezone
|
||||
)
|
||||
val collected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||
val pending = expected - collected
|
||||
return BookingBalanceResponse(
|
||||
expectedPay = expected,
|
||||
amountCollected = collected,
|
||||
pending = pending
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
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.BookingCheckInRequest
|
||||
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.controller.dto.booking.RoomStayPreAssignRequest
|
||||
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.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.property.AppUserRepo
|
||||
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.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
|
||||
) {
|
||||
|
||||
@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")
|
||||
@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)
|
||||
bookingEvents.emit(propertyId, bookingId)
|
||||
}
|
||||
|
||||
@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>()
|
||||
val checkOutTimes = mutableListOf<OffsetDateTime>()
|
||||
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)
|
||||
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
|
||||
) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
@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)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
private fun prepareSse(response: HttpServletResponse) {
|
||||
response.setHeader("Cache-Control", "no-cache")
|
||||
response.setHeader("Connection", "keep-alive")
|
||||
response.setHeader("X-Accel-Buffering", "no")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.android.trisolarisserver.controller.booking
|
||||
import com.android.trisolarisserver.controller.common.computeExpectedPay
|
||||
import com.android.trisolarisserver.controller.common.computeExpectedPayTotal
|
||||
|
||||
import com.android.trisolarisserver.controller.dto.booking.BookingDetailResponse
|
||||
import com.android.trisolarisserver.repo.booking.BookingRepo
|
||||
import com.android.trisolarisserver.repo.guest.GuestVehicleRepo
|
||||
import com.android.trisolarisserver.repo.booking.PaymentRepo
|
||||
import com.android.trisolarisserver.repo.room.RoomStayRepo
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class BookingSnapshotBuilder(
|
||||
private val bookingRepo: BookingRepo,
|
||||
private val roomStayRepo: RoomStayRepo,
|
||||
private val paymentRepo: PaymentRepo,
|
||||
private val guestVehicleRepo: GuestVehicleRepo
|
||||
) {
|
||||
fun build(propertyId: UUID, bookingId: UUID): BookingDetailResponse {
|
||||
val booking = bookingRepo.findDetailedById(bookingId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found")
|
||||
}
|
||||
if (booking.property.id != propertyId) {
|
||||
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Booking not found for property")
|
||||
}
|
||||
|
||||
val stays = roomStayRepo.findByBookingIdWithRoom(bookingId)
|
||||
val activeRooms = stays.filter { it.toAt == null }
|
||||
val roomsToShow = activeRooms.ifEmpty { stays }
|
||||
val roomNumbers = roomsToShow.map { it.room.roomNumber }
|
||||
.distinct()
|
||||
.sorted()
|
||||
|
||||
val guest = booking.primaryGuest
|
||||
val vehicleNumbers = if (guest?.id != null) {
|
||||
guestVehicleRepo.findByGuestIdIn(listOf(guest.id!!))
|
||||
.map { it.vehicleNumber }
|
||||
.distinct()
|
||||
.sorted()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val signatureUrl = guest?.signaturePath?.let {
|
||||
"/properties/$propertyId/guests/${guest.id}/signature/file"
|
||||
}
|
||||
|
||||
val totalNightlyRate = roomsToShow.sumOf { it.nightlyRate ?: 0L }
|
||||
val expectedPay = computeExpectedPayTotal(stays, booking.expectedCheckoutAt, booking.property.timezone)
|
||||
val accruedPay = computeExpectedPay(stays, booking.property.timezone)
|
||||
val amountCollected = paymentRepo.sumAmountByBookingId(bookingId)
|
||||
val pending = accruedPay - amountCollected
|
||||
|
||||
return BookingDetailResponse(
|
||||
id = booking.id!!,
|
||||
status = booking.status.name,
|
||||
guestId = guest?.id,
|
||||
guestName = guest?.name,
|
||||
guestPhone = guest?.phoneE164,
|
||||
guestNationality = guest?.nationality,
|
||||
guestAddressText = guest?.addressText,
|
||||
guestAge = guest?.age,
|
||||
guestSignatureUrl = signatureUrl,
|
||||
vehicleNumbers = vehicleNumbers,
|
||||
roomNumbers = roomNumbers,
|
||||
source = booking.source,
|
||||
fromCity = booking.fromCity,
|
||||
toCity = booking.toCity,
|
||||
memberRelation = booking.memberRelation?.name,
|
||||
transportMode = booking.transportMode?.name,
|
||||
checkInAt = booking.checkinAt?.toString(),
|
||||
checkOutAt = booking.checkoutAt?.toString(),
|
||||
expectedCheckInAt = booking.expectedCheckinAt?.toString(),
|
||||
expectedCheckOutAt = booking.expectedCheckoutAt?.toString(),
|
||||
adultCount = booking.adultCount,
|
||||
childCount = booking.childCount,
|
||||
maleCount = booking.maleCount,
|
||||
femaleCount = booking.femaleCount,
|
||||
totalGuestCount = booking.totalGuestCount,
|
||||
expectedGuestCount = booking.expectedGuestCount,
|
||||
notes = booking.notes,
|
||||
registeredByName = booking.createdBy?.name,
|
||||
registeredByPhone = booking.createdBy?.phoneE164,
|
||||
totalNightlyRate = totalNightlyRate,
|
||||
expectedPay = expectedPay,
|
||||
amountCollected = amountCollected,
|
||||
pending = pending
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user