move card issuence server side
This commit is contained in:
181
AGENTS.md
181
AGENTS.md
@@ -1,67 +1,36 @@
|
||||
PROJECT CONTEXT / SYSTEM BRIEF
|
||||
|
||||
This is a hotel-grade Property Management System (PMS) being rebuilt from scratch.
|
||||
This AGENTS file captures the product rules + current codebase state.
|
||||
This AGENTS file captures product rules + current codebase state.
|
||||
|
||||
Tech stack
|
||||
- Spring Boot monolith
|
||||
- Kotlin only
|
||||
- JPA / Hibernate
|
||||
- PostgreSQL
|
||||
- No Flyway for now, schema via JPA during development (Flyway deps present but disabled)
|
||||
- Flyway deps present but disabled (no migrations during dev)
|
||||
- Single API domain api.hoteltrisolaris.in
|
||||
- Android app and future website consume the same APIs
|
||||
Server specs (current)
|
||||
- CPU: i5-8400
|
||||
- RAM: 48 GB
|
||||
- GPU: RTX 3060 (used for llama.cpp)
|
||||
|
||||
Legacy note
|
||||
Old Firestore Android app exists only to understand behavior. Do not reuse old models.
|
||||
Core principles
|
||||
- Server is source of truth; clients send intent.
|
||||
- Ledger-based design: never store totals; append rows only.
|
||||
- Occupancy = RoomStay. Billing = Charge. Payments = Payment. Invoices are derived.
|
||||
- Room availability by room number; toAt=null means occupied.
|
||||
- Room change = close old RoomStay + open new one.
|
||||
- Multi-property: every domain object scoped to property_id.
|
||||
- Users belong to org; access granted per property.
|
||||
|
||||
CORE DESIGN PRINCIPLES
|
||||
|
||||
Server is the only source of truth
|
||||
Clients never calculate state
|
||||
Clients send intent, server derives facts
|
||||
|
||||
Ledger-based design
|
||||
Never store totals
|
||||
Never overwrite money
|
||||
Append rows and derive views
|
||||
|
||||
Three independent concerns
|
||||
Occupancy handled by RoomStay
|
||||
Charges handled by Charge
|
||||
Payments handled by Payment
|
||||
Invoices are derived views, not stored state
|
||||
|
||||
Room availability is by room number
|
||||
Availability is derived from RoomStay
|
||||
toAt = null means occupied
|
||||
Category counts are only summaries
|
||||
|
||||
Room changes must never break billing
|
||||
Changing rooms means closing one RoomStay and opening another
|
||||
Charges are time-bound and linked to booking, optionally to room_stay
|
||||
|
||||
Multi-property from day one
|
||||
Every domain object is scoped to property_id
|
||||
Users belong to organization
|
||||
Access is granted per property
|
||||
|
||||
Auth and access
|
||||
User exists once as AppUser
|
||||
Property access via PropertyUser
|
||||
Roles are per property
|
||||
Every API call must enforce property membership
|
||||
|
||||
IMMUTABLE RULES
|
||||
- Use Kotlin only
|
||||
- Follow existing package structure
|
||||
- No speculative features
|
||||
- No premature microservices
|
||||
- Flyway must remain disabled during development. Do not introduce or modify Flyway migrations unless explicitly instructed after schema stabilization.
|
||||
- Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE (other roles may exist but must not be depended on)
|
||||
- Booking is a lifecycle container only. Booking does not own rooms or money. Occupancy via RoomStay. Billing via Charge/Payment ledgers.
|
||||
- Realtime must emit derived events only (no raw entity subscriptions)
|
||||
- Ask before touching auth or payment logic
|
||||
Immutable rules
|
||||
- Use Kotlin only; no microservices.
|
||||
- Flyway must remain disabled until schema stabilizes.
|
||||
- Canonical staff roles: ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE.
|
||||
- Booking does not own rooms or money.
|
||||
- Realtime events must be derived, not raw DB changes.
|
||||
- Ask before touching auth or payment logic.
|
||||
|
||||
===============================================================================
|
||||
CURRENT CODEBASE UNDERSTANDING (TrisolarisServer)
|
||||
@@ -73,72 +42,94 @@ Repository
|
||||
- Scheduling enabled (@EnableScheduling)
|
||||
|
||||
Security/Auth
|
||||
- Firebase Admin auth for every request, Firebase UID required.
|
||||
- Security filter verifies token and maps to MyPrincipal(userId, firebaseUid).
|
||||
- Endpoints: /auth/verify and /auth/me.
|
||||
- Firebase Admin auth for every request; Firebase UID required.
|
||||
- /auth/verify and /auth/me.
|
||||
|
||||
Domain entities
|
||||
- Organization: id, name, emailAliases
|
||||
- Property: org, code, name, addressText, timezone, currency, active, emailAddresses, otaAliases
|
||||
- AppUser: org, firebaseUid, phoneE164, name, disabled
|
||||
- PropertyUser: roles per property
|
||||
- Role enum includes ADMIN, MANAGER, STAFF, HOUSEKEEPING, FINANCE, GUIDE, SUPERVISOR, AGENT (but only canonical roles should be used)
|
||||
- RoomType: code/name/occupancy + otaAliases
|
||||
- Room: roomNumber, floor, hasNfc, active, maintenance, notes
|
||||
- Booking: status, source/sourceBookingId, expected check-in/out, emailAuditPdfUrl
|
||||
- Guest
|
||||
- RoomStay
|
||||
- GuestDocument: files for guest/booking with AI-extracted data
|
||||
- InboundEmail: inbound mail audit (PDF + raw eml), extractedData, status
|
||||
|
||||
Repos
|
||||
- Repos are under com.android.trisolarisserver.repo (note: not db.repo).
|
||||
- Added repos for Booking, Guest, GuestDocument, InboundEmail.
|
||||
- Organization: name, emailAliases, allowedTransportModes.
|
||||
- Property: code, name, addressText, emailAddresses, otaAliases, allowedTransportModes.
|
||||
- AppUser, PropertyUser (roles per property).
|
||||
- RoomType: code/name/occupancy + otaAliases.
|
||||
- Room: roomNumber, floor, hasNfc, active, maintenance, notes.
|
||||
- Booking: status, expected check-in/out, emailAuditPdfUrl, transportMode, transportVehicleNumber.
|
||||
- Guest (org-scoped).
|
||||
- RoomStay.
|
||||
- RoomStayChange (idempotent room move).
|
||||
- IssuedCard (cardId, cardIndex, issuedAt, expiresAt, issuedBy, revokedAt).
|
||||
- PropertyCardCounter (per-property cardIndex counter).
|
||||
- GuestDocument (files + AI-extracted json).
|
||||
- GuestVehicle (org-scoped vehicle numbers).
|
||||
- InboundEmail (audit PDF + raw EML, extracted json, status).
|
||||
- RoomImage (original + thumbnail).
|
||||
|
||||
Key modules
|
||||
|
||||
Rooms / inventory
|
||||
- /properties/{propertyId}/rooms
|
||||
- /properties/{propertyId}/rooms/board
|
||||
- /properties/{propertyId}/rooms/board/stream (SSE)
|
||||
- /properties/{propertyId}/rooms/availability
|
||||
- /properties/{propertyId}/rooms/availability-range?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
Availability derived from RoomStay. Date range uses overlap [from, to).
|
||||
|
||||
Room types
|
||||
- CRUD with otaAliases in DTOs.
|
||||
- CRUD with otaAliases.
|
||||
|
||||
Properties/Orgs
|
||||
- Property create/update accept addressText, otaAliases, emailAddresses.
|
||||
- Org create/get returns emailAliases.
|
||||
Properties / Orgs
|
||||
- Property create/update accepts addressText, otaAliases, emailAddresses, allowedTransportModes.
|
||||
- Org create/get returns emailAliases + allowedTransportModes.
|
||||
|
||||
Guest documents (files)
|
||||
- POST /properties/{propertyId}/guests/{guestId}/documents (multipart + bookingId)
|
||||
- GET list
|
||||
- GET file (token or auth)
|
||||
- Files stored under /home/androidlover5842/docs/{propertyId}/{guestId}/{bookingId}/
|
||||
- AI extraction via llama.cpp with strict system prompt
|
||||
Booking flow
|
||||
- /bookings/{id}/check-in (creates RoomStay rows)
|
||||
- /bookings/{id}/check-out (closes RoomStay)
|
||||
- /bookings/{id}/cancel, /no-show
|
||||
- /bookings/{id}/room-stays (pre-assign RoomStay with date range)
|
||||
- /room-stays/{id}/change-room (idempotent via RoomStayChange)
|
||||
|
||||
Card issuing
|
||||
- /room-stays/{id}/cards/prepare -> returns cardIndex + sector0 payload
|
||||
- /room-stays/{id}/cards -> store issued card
|
||||
- /room-stays/{id}/cards (list)
|
||||
- /room-stays/cards/{id}/revoke (ADMIN only)
|
||||
|
||||
Guest APIs
|
||||
- /properties/{propertyId}/guests/search?phone=... or ?vehicleNumber=...
|
||||
- /properties/{propertyId}/guests/{guestId}/vehicles (add vehicle)
|
||||
|
||||
Guest documents
|
||||
- /properties/{propertyId}/guests/{guestId}/documents (upload/list/file)
|
||||
- AI extraction with strict system prompt.
|
||||
|
||||
Room images
|
||||
- /properties/{propertyId}/rooms/{roomId}/images (upload/list/file)
|
||||
- Thumbnails generated (320px).
|
||||
|
||||
Transport modes
|
||||
- /properties/{propertyId}/transport-modes -> returns enabled list (property > org > default all).
|
||||
|
||||
Inbound email ingestion
|
||||
- IMAP poller (1 min) with enable flag.
|
||||
- Saves audit PDF + raw .eml to /home/androidlover5842/docs/emails
|
||||
- Matches property via To/CC email first, then text aliases (name/code/address/otaAliases)
|
||||
- AI extracts booking fields and creates/cancels Booking
|
||||
- Booking gets emailAuditPdfUrl that points to /properties/{propertyId}/inbound-emails/{emailId}/file
|
||||
- Saves audit PDF + raw .eml under /home/androidlover5842/docs/emails.
|
||||
- Property match: To/CC email first; fallback to name/code/address/otaAliases.
|
||||
- AI extracts booking fields; creates/cancels Booking.
|
||||
- Booking emailAuditPdfUrl -> /properties/{propertyId}/inbound-emails/{emailId}/file
|
||||
- Manual upload: POST /properties/{propertyId}/inbound-emails/manual (PDF).
|
||||
|
||||
Realtime
|
||||
- SSE room board events with heartbeat, on room create/update, check-in/out, and room change.
|
||||
|
||||
AI integration
|
||||
- Base URL configured per profile:
|
||||
- dev: https://ai.hoteltrisolaris.in/v1/chat/completions
|
||||
- prod: http://localhost:8089/v1/chat/completions
|
||||
- LlamaClient uses strict system prompt: only visible text, no guessing.
|
||||
- Base URL per profile: dev=https://ai.hoteltrisolaris.in/v1/chat/completions, prod=http://localhost:8089/v1/chat/completions
|
||||
- LlamaClient uses strict system prompt (no guessing).
|
||||
- Read timeout 5 minutes.
|
||||
|
||||
Config
|
||||
- application.properties: flyway disabled, storage paths, IMAP config, token secrets.
|
||||
- storage.documents.publicBaseUrl + token secret/ttl.
|
||||
- storage.emails.publicBaseUrl used for booking audit URL.
|
||||
- mail.imap.enabled=false by default.
|
||||
- storage.documents.root=/home/androidlover5842/docs
|
||||
- storage.emails.root=/home/androidlover5842/docs/emails
|
||||
- storage.rooms.root=/home/androidlover5842/docs/rooms
|
||||
- publicBaseUrl entries for docs/emails/rooms
|
||||
- mail.imap.enabled=false by default
|
||||
|
||||
Notes / constraints
|
||||
- API user creation removed; users are created by app; API only manages roles.
|
||||
- Users are created by app; API only manages roles.
|
||||
- Admin can assign ADMIN/MANAGER/STAFF/AGENT; Manager can assign STAFF/AGENT.
|
||||
- Agents can only see free rooms.
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.android.trisolarisserver.controller
|
||||
|
||||
import com.android.trisolarisserver.component.PropertyAccess
|
||||
import com.android.trisolarisserver.controller.dto.CardPrepareRequest
|
||||
import com.android.trisolarisserver.controller.dto.CardPrepareResponse
|
||||
import com.android.trisolarisserver.controller.dto.IssueCardRequest
|
||||
import com.android.trisolarisserver.controller.dto.IssuedCardResponse
|
||||
import com.android.trisolarisserver.models.property.Role
|
||||
import com.android.trisolarisserver.models.room.IssuedCard
|
||||
import com.android.trisolarisserver.repo.AppUserRepo
|
||||
import com.android.trisolarisserver.repo.IssuedCardRepo
|
||||
import com.android.trisolarisserver.repo.PropertyCardCounterRepo
|
||||
import com.android.trisolarisserver.repo.PropertyRepo
|
||||
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.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.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/properties/{propertyId}/room-stays")
|
||||
class IssuedCards(
|
||||
private val propertyAccess: PropertyAccess,
|
||||
private val roomStayRepo: RoomStayRepo,
|
||||
private val issuedCardRepo: IssuedCardRepo,
|
||||
private val appUserRepo: AppUserRepo,
|
||||
private val counterRepo: PropertyCardCounterRepo,
|
||||
private val propertyRepo: PropertyRepo
|
||||
) {
|
||||
|
||||
@PostMapping("/{roomStayId}/cards/prepare")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@Transactional
|
||||
fun prepare(
|
||||
@PathVariable propertyId: UUID,
|
||||
@PathVariable roomStayId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||
@RequestBody request: CardPrepareRequest
|
||||
): CardPrepareResponse {
|
||||
requireIssueActor(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 closed")
|
||||
}
|
||||
|
||||
val issuedAt = OffsetDateTime.now()
|
||||
val expiresAt = request.expiresAt?.let { parseOffset(it) } ?: stay.toAt
|
||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
|
||||
if (!expiresAt.isAfter(issuedAt)) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
|
||||
}
|
||||
|
||||
val cardIndex = nextCardIndex(stay.property.id!!)
|
||||
val payload = buildSector0Payload(stay.room.roomNumber, cardIndex, issuedAt, expiresAt)
|
||||
return CardPrepareResponse(
|
||||
cardIndex = cardIndex,
|
||||
key = payload.key,
|
||||
timeData = payload.timeData,
|
||||
issuedAt = issuedAt.toString(),
|
||||
expiresAt = expiresAt.toString()
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/{roomStayId}/cards")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@Transactional
|
||||
fun issue(
|
||||
@PathVariable propertyId: UUID,
|
||||
@PathVariable roomStayId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?,
|
||||
@RequestBody request: IssueCardRequest
|
||||
): IssuedCardResponse {
|
||||
val actor = requireIssueActor(propertyId, principal)
|
||||
if (request.cardId.isBlank()) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardId required")
|
||||
}
|
||||
if (request.cardIndex <= 0) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "cardIndex required")
|
||||
}
|
||||
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 closed")
|
||||
}
|
||||
val issuedAt = parseOffset(request.issuedAt) ?: OffsetDateTime.now()
|
||||
val expiresAt = parseOffset(request.expiresAt)
|
||||
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt required")
|
||||
if (!expiresAt.isAfter(issuedAt)) {
|
||||
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "expiresAt must be after issuedAt")
|
||||
}
|
||||
val now = OffsetDateTime.now()
|
||||
if (issuedCardRepo.existsActiveForRoomStay(roomStayId, now)) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room stay")
|
||||
}
|
||||
if (issuedCardRepo.existsActiveForRoom(propertyId, stay.room.id!!, now)) {
|
||||
throw ResponseStatusException(HttpStatus.CONFLICT, "Active card already exists for room")
|
||||
}
|
||||
|
||||
val card = IssuedCard(
|
||||
property = stay.property,
|
||||
room = stay.room,
|
||||
roomStay = stay,
|
||||
cardId = request.cardId.trim(),
|
||||
cardIndex = request.cardIndex,
|
||||
issuedAt = issuedAt,
|
||||
expiresAt = expiresAt,
|
||||
issuedBy = actor
|
||||
)
|
||||
return issuedCardRepo.save(card).toResponse()
|
||||
}
|
||||
|
||||
@GetMapping("/{roomStayId}/cards")
|
||||
fun list(
|
||||
@PathVariable propertyId: UUID,
|
||||
@PathVariable roomStayId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?
|
||||
): List<IssuedCardResponse> {
|
||||
requireMember(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")
|
||||
}
|
||||
return issuedCardRepo.findByRoomStayIdOrderByIssuedAtDesc(roomStayId)
|
||||
.map { it.toResponse() }
|
||||
}
|
||||
|
||||
@PostMapping("/cards/{cardId}/revoke")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun revoke(
|
||||
@PathVariable propertyId: UUID,
|
||||
@PathVariable cardId: UUID,
|
||||
@AuthenticationPrincipal principal: MyPrincipal?
|
||||
) {
|
||||
requireRevokeActor(propertyId, principal)
|
||||
val card = issuedCardRepo.findByIdAndPropertyId(cardId, propertyId)
|
||||
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Card not found")
|
||||
if (card.revokedAt == null) {
|
||||
card.revokedAt = OffsetDateTime.now()
|
||||
issuedCardRepo.save(card)
|
||||
}
|
||||
}
|
||||
|
||||
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 requireMember(propertyId: UUID, principal: MyPrincipal?) {
|
||||
if (principal == null) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||
}
|
||||
propertyAccess.requireMember(propertyId, principal.userId)
|
||||
}
|
||||
|
||||
private fun requireIssueActor(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)
|
||||
return appUserRepo.findById(principal.userId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireRevokeActor(propertyId: UUID, principal: MyPrincipal?) {
|
||||
if (principal == null) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
|
||||
}
|
||||
propertyAccess.requireMember(propertyId, principal.userId)
|
||||
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN)
|
||||
}
|
||||
|
||||
private fun nextCardIndex(propertyId: UUID): Int {
|
||||
var counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
||||
if (counter == null) {
|
||||
val property = propertyRepo.findById(propertyId).orElseThrow {
|
||||
ResponseStatusException(HttpStatus.NOT_FOUND, "Property not found")
|
||||
}
|
||||
counterRepo.save(com.android.trisolarisserver.models.room.PropertyCardCounter(
|
||||
property = property,
|
||||
nextIndex = 1
|
||||
))
|
||||
counter = counterRepo.findByPropertyIdForUpdate(propertyId)
|
||||
}
|
||||
val current = counter!!.nextIndex
|
||||
counter.nextIndex = current + 1
|
||||
counter.updatedAt = OffsetDateTime.now()
|
||||
counterRepo.save(counter)
|
||||
return current
|
||||
}
|
||||
|
||||
private fun buildSector0Payload(
|
||||
roomNumber: Int,
|
||||
cardIndex: Int,
|
||||
issuedAt: OffsetDateTime,
|
||||
expiresAt: OffsetDateTime
|
||||
): Sector0Payload {
|
||||
val key = buildSector0Block2(roomNumber, cardIndex)
|
||||
val newData = "0000000000" + formatDateComponents(issuedAt) + formatDateComponents(expiresAt)
|
||||
val checkSum = calculateChecksum(key + newData)
|
||||
val finalData = newData + checkSum
|
||||
return Sector0Payload(key, finalData)
|
||||
}
|
||||
|
||||
private fun buildSector0Block2(roomNumber: Int, cardID: Int): String {
|
||||
val guestID = cardID + 1
|
||||
val key = "${cardID}2F${guestID}"
|
||||
val finalRoom = if (roomNumber < 10) "0$roomNumber" else roomNumber.toString()
|
||||
return "472F${key}00010000${finalRoom}0000"
|
||||
}
|
||||
|
||||
private fun formatDateComponents(time: OffsetDateTime): String {
|
||||
val minute = time.minute.toString().padStart(2, '0')
|
||||
val hour = time.hour.toString().padStart(2, '0')
|
||||
val day = time.dayOfMonth.toString().padStart(2, '0')
|
||||
val month = time.monthValue.toString().padStart(2, '0')
|
||||
val year = time.year.toString().takeLast(2)
|
||||
return "${minute}${hour}${day}${month}${year}"
|
||||
}
|
||||
|
||||
private fun calculateChecksum(dataHex: String): String {
|
||||
val data = hexStringToByteArray(dataHex)
|
||||
var checksum = 0
|
||||
for (byte in data) {
|
||||
checksum = calculateByteChecksum(byte, checksum)
|
||||
}
|
||||
return String.format("%02X", checksum)
|
||||
}
|
||||
|
||||
private fun calculateByteChecksum(byte: Byte, currentChecksum: Int): Int {
|
||||
var checksum = currentChecksum
|
||||
var b = byte.toInt()
|
||||
for (i in 0 until 8) {
|
||||
checksum = if ((checksum xor b) and 1 != 0) {
|
||||
(checksum xor 0x18) shr 1 or 0x80
|
||||
} else {
|
||||
checksum shr 1
|
||||
}
|
||||
b = b shr 1
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val len = hexString.length
|
||||
val data = ByteArray(len / 2)
|
||||
for (i in 0 until len step 2) {
|
||||
data[i / 2] = ((Character.digit(hexString[i], 16) shl 4)
|
||||
+ Character.digit(hexString[i + 1], 16)).toByte()
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
private data class Sector0Payload(
|
||||
val key: String,
|
||||
val timeData: String
|
||||
)
|
||||
private fun IssuedCard.toResponse(): IssuedCardResponse {
|
||||
return IssuedCardResponse(
|
||||
id = id!!,
|
||||
propertyId = property.id!!,
|
||||
roomId = room.id!!,
|
||||
roomStayId = roomStay.id!!,
|
||||
cardId = cardId,
|
||||
cardIndex = cardIndex,
|
||||
issuedAt = issuedAt.toString(),
|
||||
expiresAt = expiresAt.toString(),
|
||||
issuedByUserId = issuedBy?.id,
|
||||
revokedAt = revokedAt?.toString()
|
||||
)
|
||||
}
|
||||
@@ -45,3 +45,35 @@ data class RoomStayPreAssignRequest(
|
||||
val toAt: String,
|
||||
val notes: String? = null
|
||||
)
|
||||
|
||||
data class IssueCardRequest(
|
||||
val cardId: String,
|
||||
val cardIndex: Int,
|
||||
val issuedAt: String? = null,
|
||||
val expiresAt: String
|
||||
)
|
||||
|
||||
data class IssuedCardResponse(
|
||||
val id: UUID,
|
||||
val propertyId: UUID,
|
||||
val roomId: UUID,
|
||||
val roomStayId: UUID,
|
||||
val cardId: String,
|
||||
val cardIndex: Int,
|
||||
val issuedAt: String,
|
||||
val expiresAt: String,
|
||||
val issuedByUserId: UUID?,
|
||||
val revokedAt: String?
|
||||
)
|
||||
|
||||
data class CardPrepareRequest(
|
||||
val expiresAt: String? = null
|
||||
)
|
||||
|
||||
data class CardPrepareResponse(
|
||||
val cardIndex: Int,
|
||||
val key: String,
|
||||
val timeData: String,
|
||||
val issuedAt: String,
|
||||
val expiresAt: String
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.android.trisolarisserver.models.room
|
||||
|
||||
import com.android.trisolarisserver.models.property.AppUser
|
||||
import com.android.trisolarisserver.models.property.Property
|
||||
import jakarta.persistence.*
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "issued_card",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(columnNames = ["property_id", "card_index"])
|
||||
],
|
||||
indexes = [
|
||||
Index(name = "idx_issued_card_room_stay", columnList = "room_stay_id"),
|
||||
Index(name = "idx_issued_card_card_id", columnList = "card_id")
|
||||
]
|
||||
)
|
||||
class IssuedCard(
|
||||
@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_id", nullable = false)
|
||||
var room: Room,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "room_stay_id", nullable = false)
|
||||
var roomStay: RoomStay,
|
||||
|
||||
@Column(name = "card_id", nullable = false)
|
||||
var cardId: String,
|
||||
|
||||
@Column(name = "card_index", nullable = false)
|
||||
var cardIndex: Int,
|
||||
|
||||
@Column(name = "issued_at", nullable = false, columnDefinition = "timestamptz")
|
||||
var issuedAt: OffsetDateTime,
|
||||
|
||||
@Column(name = "expires_at", nullable = false, columnDefinition = "timestamptz")
|
||||
var expiresAt: OffsetDateTime,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "issued_by")
|
||||
var issuedBy: AppUser? = null,
|
||||
|
||||
@Column(name = "revoked_at", columnDefinition = "timestamptz")
|
||||
var revokedAt: OffsetDateTime? = null
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
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 = "property_card_counter",
|
||||
uniqueConstraints = [UniqueConstraint(columnNames = ["property_id"])]
|
||||
)
|
||||
class PropertyCardCounter(
|
||||
@Id
|
||||
@GeneratedValue
|
||||
@Column(columnDefinition = "uuid")
|
||||
val id: UUID? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "property_id", nullable = false)
|
||||
var property: Property,
|
||||
|
||||
@Column(name = "next_index", nullable = false)
|
||||
var nextIndex: Int = 1,
|
||||
|
||||
@Column(name = "updated_at", nullable = false, columnDefinition = "timestamptz")
|
||||
var updatedAt: OffsetDateTime = OffsetDateTime.now()
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.android.trisolarisserver.repo
|
||||
|
||||
import com.android.trisolarisserver.models.room.IssuedCard
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import java.util.UUID
|
||||
|
||||
interface IssuedCardRepo : JpaRepository<IssuedCard, UUID> {
|
||||
fun findByRoomStayIdOrderByIssuedAtDesc(roomStayId: UUID): List<IssuedCard>
|
||||
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): IssuedCard?
|
||||
|
||||
@org.springframework.data.jpa.repository.Query("""
|
||||
select case when count(c) > 0 then true else false end
|
||||
from IssuedCard c
|
||||
where c.property.id = :propertyId
|
||||
and c.room.id = :roomId
|
||||
and c.revokedAt is null
|
||||
and c.expiresAt > :now
|
||||
""")
|
||||
fun existsActiveForRoom(
|
||||
@org.springframework.data.repository.query.Param("propertyId") propertyId: UUID,
|
||||
@org.springframework.data.repository.query.Param("roomId") roomId: UUID,
|
||||
@org.springframework.data.repository.query.Param("now") now: java.time.OffsetDateTime
|
||||
): Boolean
|
||||
|
||||
@org.springframework.data.jpa.repository.Query("""
|
||||
select case when count(c) > 0 then true else false end
|
||||
from IssuedCard c
|
||||
where c.roomStay.id = :roomStayId
|
||||
and c.revokedAt is null
|
||||
and c.expiresAt > :now
|
||||
""")
|
||||
fun existsActiveForRoomStay(
|
||||
@org.springframework.data.repository.query.Param("roomStayId") roomStayId: UUID,
|
||||
@org.springframework.data.repository.query.Param("now") now: java.time.OffsetDateTime
|
||||
): Boolean
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.android.trisolarisserver.repo
|
||||
|
||||
import com.android.trisolarisserver.models.room.PropertyCardCounter
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.util.UUID
|
||||
import jakarta.persistence.LockModeType
|
||||
|
||||
interface PropertyCardCounterRepo : JpaRepository<PropertyCardCounter, UUID> {
|
||||
fun findByPropertyId(propertyId: UUID): PropertyCardCounter?
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select c from PropertyCardCounter c where c.property.id = :propertyId")
|
||||
fun findByPropertyIdForUpdate(@Param("propertyId") propertyId: UUID): PropertyCardCounter?
|
||||
}
|
||||
Reference in New Issue
Block a user