Files
TrisolarisServer/src/main/kotlin/com/android/trisolarisserver/controller/Rooms.kt
androidlover5842 f7c0cf5c18
All checks were successful
build-and-deploy / build-deploy (push) Successful in 31s
ai removed boilerplate
2026-01-31 06:35:06 +05:30

462 lines
20 KiB
Kotlin

package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.component.RoomBoardEvents
import com.android.trisolarisserver.controller.dto.RoomAvailabilityRangeResponse
import com.android.trisolarisserver.controller.dto.RoomAvailabilityResponse
import com.android.trisolarisserver.controller.dto.RoomAvailabilityWithRateResponse
import com.android.trisolarisserver.controller.dto.RoomBoardResponse
import com.android.trisolarisserver.controller.dto.RoomBoardStatus
import com.android.trisolarisserver.controller.dto.RoomResponse
import com.android.trisolarisserver.controller.dto.RoomUpsertRequest
import com.android.trisolarisserver.repo.PropertyRepo
import com.android.trisolarisserver.repo.PropertyUserRepo
import com.android.trisolarisserver.repo.IssuedCardRepo
import com.android.trisolarisserver.repo.RoomImageRepo
import com.android.trisolarisserver.repo.RoomRepo
import com.android.trisolarisserver.repo.RoomStayRepo
import com.android.trisolarisserver.repo.RoomTypeRepo
import com.android.trisolarisserver.repo.RatePlanRepo
import com.android.trisolarisserver.repo.RateCalendarRepo
import com.android.trisolarisserver.models.room.Room
import com.android.trisolarisserver.models.room.RatePlan
import com.android.trisolarisserver.models.property.Role
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.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.DeleteMapping
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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/rooms")
class Rooms(
private val propertyAccess: PropertyAccess,
private val roomRepo: RoomRepo,
private val roomImageRepo: RoomImageRepo,
private val roomStayRepo: RoomStayRepo,
private val propertyRepo: PropertyRepo,
private val roomTypeRepo: RoomTypeRepo,
private val issuedCardRepo: IssuedCardRepo,
private val propertyUserRepo: PropertyUserRepo,
private val ratePlanRepo: RatePlanRepo,
private val rateCalendarRepo: RateCalendarRepo,
private val roomBoardEvents: RoomBoardEvents
) {
@GetMapping
fun listRooms(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val tempCardsByRoom = loadActiveTempCardsByRoom(propertyId)
if (isAgentOnly(roles)) {
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
return rooms
.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
.map { it.toRoomResponse(tempCardsByRoom[it.id]) }
}
return rooms
.map { it.toRoomResponse(tempCardsByRoom[it.id]) }
}
@GetMapping("/board")
fun roomBoard(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomBoardResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
val roles = propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)
val mapped = rooms.map { room ->
val status = when {
room.maintenance -> RoomBoardStatus.MAINTENANCE
!room.active -> RoomBoardStatus.INACTIVE
occupiedRoomIds.contains(room.id) -> RoomBoardStatus.OCCUPIED
else -> RoomBoardStatus.FREE
}
RoomBoardResponse(
roomNumber = room.roomNumber,
roomTypeName = room.roomType.name,
status = status
)
}
return if (isAgentOnly(roles)) mapped.filter { it.status == RoomBoardStatus.FREE } else mapped
}
@GetMapping("/board/stream")
fun roomBoardStream(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): SseEmitter {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
return roomBoardEvents.subscribe(propertyId)
}
@GetMapping("/availability")
fun roomAvailability(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomAvailabilityResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
val grouped = freeRooms.groupBy { it.roomType.name }
return grouped.entries.map { (typeName, roomList) ->
RoomAvailabilityResponse(
roomTypeName = typeName,
freeRoomNumbers = roomList.map { it.roomNumber }
)
}.sortedBy { it.roomTypeName }
}
@GetMapping("/available")
fun availableRooms(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): List<RoomResponse> {
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
val tempCardsByRoom = loadActiveTempCardsByRoom(propertyId)
return rooms
.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
.map { it.toRoomResponse(tempCardsByRoom[it.id]) }
}
@GetMapping("/by-type/{roomTypeCode}")
fun roomsByType(
@PathVariable propertyId: UUID,
@PathVariable roomTypeCode: String,
@AuthenticationPrincipal principal: MyPrincipal?,
@org.springframework.web.bind.annotation.RequestParam("availableOnly", required = false, defaultValue = "false")
availableOnly: Boolean
): List<RoomResponse> {
val roomType = roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, roomTypeCode)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
.filter { it.roomType.id == roomType.id }
val tempCardsByRoom = loadActiveTempCardsByRoom(propertyId)
if (availableOnly || (principal != null && isAgentOnly(propertyUserRepo.findRolesByPropertyAndUser(propertyId, principal.userId)))) {
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIds(propertyId).toHashSet()
return rooms
.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
.map { it.toRoomResponse(tempCardsByRoom[it.id]) }
}
return rooms.map { it.toRoomResponse(tempCardsByRoom[it.id]) }
}
@GetMapping("/availability-range")
fun roomAvailabilityRange(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@org.springframework.web.bind.annotation.RequestParam("from") from: String,
@org.springframework.web.bind.annotation.RequestParam("to") to: String
): List<RoomAvailabilityRangeResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val fromDate = parseDate(from, "Invalid date format")
val toDate = parseDate(to, "Invalid date format")
if (!toDate.isAfter(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
}
val zone = ZoneId.of(property.timezone)
val fromAt = fromDate.atStartOfDay(zone).toOffsetDateTime()
val toAt = toDate.atStartOfDay(zone).toOffsetDateTime()
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIdsBetween(propertyId, fromAt, toAt).toHashSet()
val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
val grouped = freeRooms.groupBy { it.roomType.name }
return grouped.entries.map { (typeName, roomList) ->
RoomAvailabilityRangeResponse(
roomTypeName = typeName,
freeRoomNumbers = roomList.map { it.roomNumber },
freeCount = roomList.size
)
}.sortedBy { it.roomTypeName }
}
@GetMapping("/available-range-with-rate")
fun availableRoomsWithRate(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@org.springframework.web.bind.annotation.RequestParam("from") from: String,
@org.springframework.web.bind.annotation.RequestParam("to") to: String,
@org.springframework.web.bind.annotation.RequestParam("ratePlanCode", required = false) ratePlanCode: String?
): List<RoomAvailabilityWithRateResponse> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val fromDate = parseDate(from, "Invalid date format")
val toDate = parseDate(to, "Invalid date format")
if (!toDate.isAfter(fromDate)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid date range")
}
val zone = ZoneId.of(property.timezone)
val fromAt = fromDate.atStartOfDay(zone).toOffsetDateTime()
val toAt = toDate.atStartOfDay(zone).toOffsetDateTime()
val rooms = roomRepo.findByPropertyIdOrderByRoomNumber(propertyId)
val occupiedRoomIds = roomStayRepo.findOccupiedRoomIdsBetween(propertyId, fromAt, toAt).toHashSet()
val freeRooms = rooms.filter { it.active && !it.maintenance && !occupiedRoomIds.contains(it.id) }
if (freeRooms.isEmpty()) return emptyList()
val plans = ratePlanRepo.findByPropertyIdOrderByCode(propertyId)
val plansByRoomType = plans.groupBy { it.roomType.id!! }
val averageCache = mutableMapOf<UUID, RateAverage>()
return freeRooms.map { room ->
val roomType = room.roomType
val chosenPlan = selectRatePlan(plansByRoomType[roomType.id!!], ratePlanCode)
val average = when {
chosenPlan != null -> averageCache.getOrPut(chosenPlan.id!!) {
val avg = averageRateForPlan(chosenPlan, fromDate, toDate)
RateAverage(avg, chosenPlan.currency, chosenPlan.code)
}
roomType.defaultRate != null -> RateAverage(roomType.defaultRate!!.toDouble(), property.currency, null)
else -> RateAverage(null, property.currency, null)
}
RoomAvailabilityWithRateResponse(
roomId = room.id!!,
roomNumber = room.roomNumber,
roomTypeCode = roomType.code,
roomTypeName = roomType.name,
averageRate = average.averageRate,
currency = average.currency,
ratePlanCode = average.ratePlanCode
)
}
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createRoom(
@PathVariable propertyId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomUpsertRequest
): RoomResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
if (roomRepo.existsByPropertyIdAndRoomNumber(propertyId, request.roomNumber)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property")
}
val property = propertyRepo.findById(propertyId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
}
val roomType = resolveRoomType(propertyId, request)
val room = Room(
property = property,
roomType = roomType,
roomNumber = request.roomNumber,
floor = request.floor,
hasNfc = request.hasNfc,
active = request.active,
maintenance = request.maintenance,
notes = request.notes
)
val saved = roomRepo.save(room)
val response = RoomResponse(
id = saved.id ?: throw IllegalStateException("Room id is null"),
roomNumber = saved.roomNumber,
floor = saved.floor,
roomTypeName = roomType.name,
hasNfc = saved.hasNfc,
active = saved.active,
maintenance = saved.maintenance,
notes = saved.notes,
tempCardActive = false,
tempCardExpiresAt = null
)
roomBoardEvents.emit(propertyId)
return response
}
@PutMapping("/{roomId}")
fun updateRoom(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?,
@RequestBody request: RoomUpsertRequest
): RoomResponse {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found for property")
if (roomRepo.existsByPropertyIdAndRoomNumberAndIdNot(propertyId, request.roomNumber, roomId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Room number already exists for property")
}
val roomType = resolveRoomType(propertyId, request)
room.roomNumber = request.roomNumber
room.floor = request.floor
room.roomType = roomType
room.hasNfc = request.hasNfc
room.active = request.active
room.maintenance = request.maintenance
room.notes = request.notes
val saved = roomRepo.save(room)
val response = RoomResponse(
id = saved.id ?: throw IllegalStateException("Room id is null"),
roomNumber = saved.roomNumber,
floor = saved.floor,
roomTypeName = roomType.name,
hasNfc = saved.hasNfc,
active = saved.active,
maintenance = saved.maintenance,
notes = saved.notes,
tempCardActive = false,
tempCardExpiresAt = null
)
roomBoardEvents.emit(propertyId)
return response
}
@DeleteMapping("/{roomId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteRoom(
@PathVariable propertyId: UUID,
@PathVariable roomId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
) {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val room = roomRepo.findByIdAndPropertyId(roomId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found for property")
if (roomStayRepo.existsByRoomId(roomId)) {
throw ResponseStatusException(HttpStatus.CONFLICT, "Cannot delete room with stays")
}
val images = roomImageRepo.findByRoomIdOrdered(roomId)
for (img in images) {
try {
Files.deleteIfExists(Paths.get(img.originalPath))
Files.deleteIfExists(Paths.get(img.thumbnailPath))
} catch (ex: Exception) {
throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete room image files")
}
}
if (images.isNotEmpty()) {
roomImageRepo.deleteAll(images)
}
roomRepo.delete(room)
roomBoardEvents.emit(propertyId)
}
private fun isAgentOnly(roles: Set<Role>): Boolean {
if (!roles.contains(Role.AGENT)) return false
val privileged = setOf(Role.ADMIN, Role.MANAGER, Role.STAFF, Role.HOUSEKEEPING, Role.FINANCE)
return roles.none { it in privileged }
}
private fun selectRatePlan(plans: List<RatePlan>?, ratePlanCode: String?): RatePlan? {
if (plans.isNullOrEmpty()) return null
if (!ratePlanCode.isNullOrBlank()) {
return plans.firstOrNull { it.code.equals(ratePlanCode, ignoreCase = true) }
}
return plans.firstOrNull()
}
private fun averageRateForPlan(plan: RatePlan, fromDate: LocalDate, toDate: LocalDate): Double {
val overrides = rateCalendarRepo
.findByRatePlanIdAndRateDateBetweenOrderByRateDateAsc(plan.id!!, fromDate, toDate)
.associateBy { it.rateDate }
var total = 0L
var days = 0
var date = fromDate
while (!date.isAfter(toDate)) {
days += 1
total += overrides[date]?.rate ?: plan.baseRate
date = date.plusDays(1)
}
return if (days == 0) 0.0 else total.toDouble() / days
}
private data class RateAverage(
val averageRate: Double?,
val currency: String,
val ratePlanCode: String?
)
private fun resolveRoomType(propertyId: UUID, request: RoomUpsertRequest): com.android.trisolarisserver.models.room.RoomType {
val code = request.roomTypeCode.trim()
if (code.isBlank()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "roomTypeCode required")
}
return roomTypeRepo.findByPropertyIdAndCodeIgnoreCase(propertyId, code)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Room type not found")
}
private fun loadActiveTempCardsByRoom(propertyId: UUID): Map<UUID, OffsetDateTime> {
val now = OffsetDateTime.now()
return issuedCardRepo.findActiveTempCardsForProperty(propertyId, now)
.groupBy { it.room.id ?: throw IllegalStateException("Room id is null") }
.mapValues { (_, cards) -> cards.maxBy { it.expiresAt }.expiresAt }
}
}
private fun Room.toRoomResponse(tempCardExpiresAt: OffsetDateTime? = null): RoomResponse {
val roomId = id ?: throw IllegalStateException("Room id is null")
return RoomResponse(
id = roomId,
roomNumber = roomNumber,
floor = floor,
roomTypeName = roomType.name,
hasNfc = hasNfc,
active = active,
maintenance = maintenance,
notes = notes,
tempCardActive = tempCardExpiresAt != null,
tempCardExpiresAt = tempCardExpiresAt?.toString()
)
}