From 30c37affb4f79808604b9c3b90f228159298d8d8 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Mon, 2 Feb 2026 09:09:40 +0530 Subject: [PATCH] Add room-type quantity reservation APIs --- AGENTS.md | 9 +- .../controller/booking/BookingFlow.kt | 19 ++- .../controller/booking/BookingRoomRequests.kt | 161 ++++++++++++++++++ .../dto/booking/BookingRoomRequestDtos.kt | 22 +++ .../models/booking/BookingRoomRequest.kt | 65 +++++++ .../repo/booking/BookingRoomRequestRepo.kt | 51 ++++++ .../trisolarisserver/repo/room/RoomRepo.kt | 15 ++ .../repo/room/RoomStayRepo.kt | 18 ++ 8 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingRoomRequests.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingRoomRequestDtos.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/models/booking/BookingRoomRequest.kt create mode 100644 src/main/kotlin/com/android/trisolarisserver/repo/booking/BookingRoomRequestRepo.kt diff --git a/AGENTS.md b/AGENTS.md index 8ed5027..e49f376 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,12 +106,14 @@ Properties Booking flow - POST /properties/{propertyId}/bookings (create booking) -- /properties/{propertyId}/bookings/{bookingId}/check-in (creates RoomStay rows) +- /properties/{propertyId}/bookings/{bookingId}/check-in/bulk (creates RoomStay rows with per-stay rates) - /properties/{propertyId}/bookings/{bookingId}/check-out (closes RoomStay) +- /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out (closes specific stay; single-stay booking auto-closes booking) - /properties/{propertyId}/bookings/{bookingId}/cancel - /properties/{propertyId}/bookings/{bookingId}/no-show -- /properties/{propertyId}/bookings/{bookingId}/room-stays (pre-assign RoomStay with date range) -- /properties/{propertyId}/room-stays/{roomStayId}/change-room (idempotent via RoomStayChange) +- /properties/{propertyId}/bookings/{bookingId}/room-requests (room-type quantity reservation) +- /properties/{propertyId}/bookings/{bookingId}/room-requests/{requestId} (cancel reservation) +- /properties/{propertyId}/room-stays/{roomStayId}/void (soft-void active stay) Card issuing - /properties/{propertyId}/room-stays/{roomStayId}/cards/prepare -> returns cardIndex + sector0 payload @@ -200,6 +202,7 @@ Notes / constraints - Checkout supports both booking-level and specific room-stay checkout; specific checkout endpoint: `POST /properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out`. - Staff can change/void stays only before first payment on booking; manager/admin can act after payments. - Checkout validation: nightly rate must be within +/-20% of room type default rate (when default rate exists), and minimum stay duration must be at least 1 hour. +- Room-type reservations: use booking room requests (`booking_room_request`) for quantity holds without room numbers; availability checks include active requests + occupied stays. Operational notes - Payment provider migrated: PayU removed; Razorpay now used for settings, QR, payment links, and webhooks. diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt index 4222ea3..8220dbb 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingFlow.kt @@ -23,6 +23,7 @@ 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 @@ -32,6 +33,7 @@ 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 @@ -70,7 +72,8 @@ class BookingFlow( private val guestDocumentRepo: GuestDocumentRepo, private val paymentRepo: PaymentRepo, private val bookingSnapshotBuilder: BookingSnapshotBuilder, - private val roomStayAuditLogRepo: RoomStayAuditLogRepo + private val roomStayAuditLogRepo: RoomStayAuditLogRepo, + private val bookingRoomRequestRepo: BookingRoomRequestRepo ) { @PostMapping @@ -357,6 +360,7 @@ class BookingFlow( createdBy = actor ) roomStayRepo.save(newStay) + fulfillRoomRequestIfAny(booking.id!!, room.roomType.id!!, checkInAt) } val bookingCheckInAt = checkInTimes.minOrNull() ?: now @@ -658,6 +662,19 @@ class BookingFlow( } } + 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 diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingRoomRequests.kt b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingRoomRequests.kt new file mode 100644 index 0000000..88afe4b --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/booking/BookingRoomRequests.kt @@ -0,0 +1,161 @@ +package com.android.trisolarisserver.controller.booking + +import com.android.trisolarisserver.component.auth.PropertyAccess +import com.android.trisolarisserver.controller.common.parseOffset +import com.android.trisolarisserver.controller.common.requireRole +import com.android.trisolarisserver.controller.dto.booking.BookingRoomRequestCreateRequest +import com.android.trisolarisserver.controller.dto.booking.BookingRoomRequestResponse +import com.android.trisolarisserver.models.booking.BookingRoomRequest +import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus +import com.android.trisolarisserver.models.booking.BookingStatus +import com.android.trisolarisserver.models.property.Role +import com.android.trisolarisserver.repo.booking.BookingRepo +import com.android.trisolarisserver.repo.booking.BookingRoomRequestRepo +import com.android.trisolarisserver.repo.property.AppUserRepo +import com.android.trisolarisserver.repo.room.RoomRepo +import com.android.trisolarisserver.repo.room.RoomStayRepo +import com.android.trisolarisserver.repo.room.RoomTypeRepo +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.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +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.util.UUID + +@RestController +@RequestMapping("/properties/{propertyId}/bookings/{bookingId}/room-requests") +class BookingRoomRequests( + private val propertyAccess: PropertyAccess, + private val bookingRepo: BookingRepo, + private val roomTypeRepo: RoomTypeRepo, + private val roomRepo: RoomRepo, + private val roomStayRepo: RoomStayRepo, + private val appUserRepo: AppUserRepo, + private val bookingRoomRequestRepo: BookingRoomRequestRepo +) { + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Transactional + fun create( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal?, + @RequestBody request: BookingRoomRequestCreateRequest + ): BookingRoomRequestResponse { + val actor = requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF) + 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") + } + if (booking.status == BookingStatus.CANCELLED || booking.status == BookingStatus.NO_SHOW || booking.status == BookingStatus.CHECKED_OUT) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Booking closed") + } + + if (request.quantity <= 0) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "quantity must be > 0") + } + 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") + } + + val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, request.roomTypeCode) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found") + val capacity = roomRepo.countActiveSellableByType(propertyId, roomType.id!!) + val occupied = roomStayRepo.countOccupiedByTypeInRange(propertyId, roomType.id!!, fromAt, toAt) + val requested = bookingRoomRequestRepo.sumRemainingByTypeAndRange(propertyId, roomType.id!!, fromAt, toAt) + val free = capacity - occupied - requested + if (request.quantity.toLong() > free) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Insufficient room type availability") + } + + val appUser = appUserRepo.findById(actor.userId).orElse(null) + val saved = bookingRoomRequestRepo.save( + BookingRoomRequest( + property = booking.property, + booking = booking, + roomType = roomType, + quantity = request.quantity, + fromAt = fromAt, + toAt = toAt, + status = BookingRoomRequestStatus.ACTIVE, + createdBy = appUser + ) + ) + return saved.toResponse() + } + + @GetMapping + fun list( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ): List { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE) + 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 bookingRoomRequestRepo.findByBookingIdOrderByCreatedAtAsc(bookingId).map { it.toResponse() } + } + + @DeleteMapping("/{requestId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + fun cancel( + @PathVariable propertyId: UUID, + @PathVariable bookingId: UUID, + @PathVariable requestId: UUID, + @AuthenticationPrincipal principal: MyPrincipal? + ) { + requireRole(propertyAccess, propertyId, principal, Role.ADMIN, Role.MANAGER, Role.STAFF) + 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 request = bookingRoomRequestRepo.findById(requestId).orElseThrow { + ResponseStatusException(HttpStatus.NOT_FOUND, "Room request not found") + } + if (request.booking.id != bookingId || request.property.id != propertyId) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room request not found for booking") + } + if (request.status == BookingRoomRequestStatus.FULFILLED) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot cancel fulfilled room request") + } + request.status = BookingRoomRequestStatus.CANCELLED + bookingRoomRequestRepo.save(request) + } + + private fun BookingRoomRequest.toResponse(): BookingRoomRequestResponse { + val remaining = (quantity - fulfilledQuantity).coerceAtLeast(0) + return BookingRoomRequestResponse( + id = id!!, + bookingId = booking.id!!, + roomTypeCode = roomType.code, + quantity = quantity, + fulfilledQuantity = fulfilledQuantity, + remainingQuantity = remaining, + fromAt = fromAt.toString(), + toAt = toAt.toString(), + status = status.name + ) + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingRoomRequestDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingRoomRequestDtos.kt new file mode 100644 index 0000000..21c7791 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/booking/BookingRoomRequestDtos.kt @@ -0,0 +1,22 @@ +package com.android.trisolarisserver.controller.dto.booking + +import java.util.UUID + +data class BookingRoomRequestCreateRequest( + val roomTypeCode: String, + val quantity: Int, + val fromAt: String, + val toAt: String +) + +data class BookingRoomRequestResponse( + val id: UUID, + val bookingId: UUID, + val roomTypeCode: String, + val quantity: Int, + val fulfilledQuantity: Int, + val remainingQuantity: Int, + val fromAt: String, + val toAt: String, + val status: String +) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/BookingRoomRequest.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/BookingRoomRequest.kt new file mode 100644 index 0000000..97f21bd --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/BookingRoomRequest.kt @@ -0,0 +1,65 @@ +package com.android.trisolarisserver.models.booking + +import com.android.trisolarisserver.models.property.AppUser +import com.android.trisolarisserver.models.property.Property +import com.android.trisolarisserver.models.room.RoomType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import java.time.OffsetDateTime +import java.util.UUID + +@Entity +@Table(name = "booking_room_request") +class BookingRoomRequest( + @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 = "booking_id", nullable = false) + var booking: Booking, + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "room_type_id", nullable = false) + var roomType: RoomType, + + @Column(nullable = false) + var quantity: Int, + + @Column(name = "fulfilled_quantity", nullable = false) + var fulfilledQuantity: Int = 0, + + @Column(name = "from_at", nullable = false, columnDefinition = "timestamptz") + var fromAt: OffsetDateTime, + + @Column(name = "to_at", nullable = false, columnDefinition = "timestamptz") + var toAt: OffsetDateTime, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var status: BookingRoomRequestStatus = BookingRoomRequestStatus.ACTIVE, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + var createdBy: AppUser? = null, + + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") + val createdAt: OffsetDateTime = OffsetDateTime.now() +) + +enum class BookingRoomRequestStatus { + ACTIVE, CANCELLED, FULFILLED +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/booking/BookingRoomRequestRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/booking/BookingRoomRequestRepo.kt new file mode 100644 index 0000000..b65e5c5 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/repo/booking/BookingRoomRequestRepo.kt @@ -0,0 +1,51 @@ +package com.android.trisolarisserver.repo.booking + +import com.android.trisolarisserver.models.booking.BookingRoomRequest +import com.android.trisolarisserver.models.booking.BookingRoomRequestStatus +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.OffsetDateTime +import java.util.UUID + +interface BookingRoomRequestRepo : JpaRepository { + fun findByBookingIdOrderByCreatedAtAsc(bookingId: UUID): List + + @Query( + """ + select coalesce(sum(br.quantity - br.fulfilledQuantity), 0) + from BookingRoomRequest br + where br.property.id = :propertyId + and br.roomType.id = :roomTypeId + and br.status = :status + and br.fromAt < :toAt + and br.toAt > :fromAt + """ + ) + fun sumRemainingByTypeAndRange( + @Param("propertyId") propertyId: UUID, + @Param("roomTypeId") roomTypeId: UUID, + @Param("fromAt") fromAt: OffsetDateTime, + @Param("toAt") toAt: OffsetDateTime, + @Param("status") status: BookingRoomRequestStatus = BookingRoomRequestStatus.ACTIVE + ): Long + + @Query( + """ + select br + from BookingRoomRequest br + where br.booking.id = :bookingId + and br.roomType.id = :roomTypeId + and br.status = :status + and br.fromAt <= :at + and br.toAt > :at + order by br.fromAt asc, br.createdAt asc + """ + ) + fun findActiveForFulfillment( + @Param("bookingId") bookingId: UUID, + @Param("roomTypeId") roomTypeId: UUID, + @Param("at") at: OffsetDateTime, + @Param("status") status: BookingRoomRequestStatus = BookingRoomRequestStatus.ACTIVE + ): List +} diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomRepo.kt index 27d5963..24ada09 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomRepo.kt @@ -46,4 +46,19 @@ interface RoomRepo : JpaRepository { order by r.roomNumber """) fun findOccupiedRooms(@Param("propertyId") propertyId: UUID): List + + @Query( + """ + select count(r) + from Room r + where r.property.id = :propertyId + and r.roomType.id = :roomTypeId + and r.active = true + and r.maintenance = false + """ + ) + fun countActiveSellableByType( + @Param("propertyId") propertyId: UUID, + @Param("roomTypeId") roomTypeId: UUID + ): Long } diff --git a/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt index cf89690..e1eab0c 100644 --- a/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/repo/room/RoomStayRepo.kt @@ -139,6 +139,24 @@ interface RoomStayRepo : JpaRepository { @Param("roomStayId") roomStayId: UUID, @Param("bookingId") bookingId: UUID ): RoomStay? + + @Query( + """ + select count(distinct rs.room.id) + from RoomStay rs + where rs.property.id = :propertyId + and rs.room.roomType.id = :roomTypeId + and rs.isVoided = false + and rs.fromAt < :toAt + and (rs.toAt is null or rs.toAt > :fromAt) + """ + ) + fun countOccupiedByTypeInRange( + @Param("propertyId") propertyId: UUID, + @Param("roomTypeId") roomTypeId: UUID, + @Param("fromAt") fromAt: java.time.OffsetDateTime, + @Param("toAt") toAt: java.time.OffsetDateTime + ): Long } interface BookingRoomNumberRow {