ai creates booking

This commit is contained in:
androidlover5842
2026-01-24 19:22:37 +05:30
parent b8c9f8dac4
commit ac79d6d1c0
18 changed files with 559 additions and 12 deletions

View File

@@ -31,6 +31,8 @@ dependencies {
implementation("tools.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.google.firebase:firebase-admin:9.7.0")
implementation("com.sun.mail:jakarta.mail:2.0.1")
implementation("org.apache.pdfbox:pdfbox:2.0.30")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
testImplementation("org.springframework.boot:spring-boot-starter-flyway-test")

View File

@@ -2,8 +2,10 @@ package com.android.trisolarisserver
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
class TrisolarisServerApplication
fun main(args: Array<String>) {

View File

@@ -0,0 +1,85 @@
package com.android.trisolarisserver.component
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.pdmodel.PDPage
import org.apache.pdfbox.pdmodel.PDPageContentStream
import org.apache.pdfbox.pdmodel.common.PDRectangle
import org.apache.pdfbox.pdmodel.font.PDType1Font
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.OffsetDateTime
import java.util.UUID
@Component
class EmailStorage(
@Value("\${storage.emails.root:/home/androidlover5842/docs/emails}")
private val rootPath: String
) {
fun storePdf(propertyId: UUID?, messageId: String?, subject: String?, body: String): String {
val dir = if (propertyId != null) {
Paths.get(rootPath, propertyId.toString())
} else {
Paths.get(rootPath, "unassigned")
}
Files.createDirectories(dir)
val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf"
val path = dir.resolve(fileName).normalize()
val document = PDDocument()
val page = PDPage(PDRectangle.LETTER)
document.addPage(page)
val header = "Subject: ${subject ?: ""}\n\n"
writeText(document, page, header + body)
document.save(path.toFile())
document.close()
return path.toString()
}
private fun writeText(document: PDDocument, firstPage: PDPage, text: String) {
var page = firstPage
var y = 730f
val marginX = 50f
val lineHeight = 14f
val maxLines = 48
var linesOnPage = 0
var content = PDPageContentStream(document, page)
content.beginText()
content.setFont(PDType1Font.HELVETICA, 11f)
content.newLineAtOffset(marginX, y)
fun newLine() {
content.newLineAtOffset(0f, -lineHeight)
y -= lineHeight
linesOnPage++
if (linesOnPage >= maxLines) {
content.endText()
content.close()
page = PDPage(PDRectangle.LETTER)
document.addPage(page)
content = PDPageContentStream(document, page)
content.beginText()
content.setFont(PDType1Font.HELVETICA, 11f)
y = 730f
linesOnPage = 0
content.newLineAtOffset(marginX, y)
}
}
val lines = text.split("\n")
for (line in lines) {
val chunks = line.chunked(90)
for (chunk in chunks) {
content.showText(chunk)
newLine()
}
}
content.endText()
content.close()
}
}

View File

@@ -15,10 +15,20 @@ class LlamaClient(
@Value("\${ai.llama.baseUrl}")
private val baseUrl: String
) {
private val systemPrompt =
"Look only at visible text. " +
"Return the exact text you can read verbatim. " +
"If the text is unclear, partial, or inferred, return NOT CLEARLY VISIBLE. " +
"Do not guess. Do not explain."
fun ask(imageUrl: String, question: String): String {
val payload = mapOf(
"model" to "qwen",
"messages" to listOf(
mapOf(
"role" to "system",
"content" to systemPrompt
),
mapOf(
"role" to "user",
"content" to listOf(
@@ -36,4 +46,27 @@ class LlamaClient(
val node = objectMapper.readTree(body)
return node.path("choices").path(0).path("message").path("content").asText()
}
fun askText(content: String, question: String): String {
val payload = mapOf(
"model" to "qwen",
"messages" to listOf(
mapOf(
"role" to "system",
"content" to systemPrompt
),
mapOf(
"role" to "user",
"content" to "${question}\n\nEMAIL:\n${content}"
)
)
)
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
val entity = HttpEntity(payload, headers)
val response = restTemplate.postForEntity(baseUrl, entity, String::class.java)
val body = response.body ?: return ""
val node = objectMapper.readTree(body)
return node.path("choices").path(0).path("message").path("content").asText()
}
}

View File

@@ -12,7 +12,7 @@ class HttpConfig {
fun restTemplate(builder: RestTemplateBuilder): RestTemplate {
return builder
.setConnectTimeout(Duration.ofSeconds(10))
.setReadTimeout(Duration.ofSeconds(60))
.setReadTimeout(Duration.ofMinutes(5))
.build()
}
}

View File

@@ -0,0 +1,14 @@
package com.android.trisolarisserver.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JacksonConfig {
@Bean
fun objectMapper(): ObjectMapper {
return jacksonObjectMapper()
}
}

View File

@@ -239,9 +239,12 @@ data class GuestDocumentResponse(
private fun GuestDocument.toResponse(objectMapper: ObjectMapper): GuestDocumentResponse {
val id = id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Document id missing")
val extracted = extractedData?.let {
val extracted: Map<String, String>? = extractedData?.let {
try {
objectMapper.readValue(it, Map::class.java).mapValues { entry -> entry.value?.toString() ?: "" }
val raw = objectMapper.readValue(it, Map::class.java)
raw.entries.associate { entry ->
entry.key.toString() to (entry.value?.toString() ?: "")
}
} catch (_: Exception) {
null
}

View File

@@ -64,7 +64,8 @@ class Properties(
name = request.name,
timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR",
active = request.active ?: true
active = request.active ?: true,
emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf()
)
val saved = propertyRepo.save(property)
return saved.toResponse()
@@ -205,6 +206,9 @@ class Properties(
property.timezone = request.timezone ?: property.timezone
property.currency = request.currency ?: property.currency
property.active = request.active ?: property.active
if (request.emailAliases != null) {
property.emailAliases = request.emailAliases.toMutableSet()
}
return propertyRepo.save(property).toResponse()
}
@@ -241,7 +245,8 @@ private fun Property.toResponse(): PropertyResponse {
name = name,
timezone = timezone,
currency = currency,
active = active
active = active,
emailAliases = emailAliases.toSet()
)
}

View File

@@ -16,7 +16,8 @@ data class PropertyCreateRequest(
val name: String,
val timezone: String? = null,
val currency: String? = null,
val active: Boolean? = null
val active: Boolean? = null,
val emailAliases: Set<String>? = null
)
data class PropertyUpdateRequest(
@@ -24,7 +25,8 @@ data class PropertyUpdateRequest(
val name: String,
val timezone: String? = null,
val currency: String? = null,
val active: Boolean? = null
val active: Boolean? = null,
val emailAliases: Set<String>? = null
)
data class PropertyResponse(
@@ -34,7 +36,8 @@ data class PropertyResponse(
val name: String,
val timezone: String,
val currency: String,
val active: Boolean
val active: Boolean,
val emailAliases: Set<String>
)
data class UserResponse(

View File

@@ -4,4 +4,7 @@ import com.android.trisolarisserver.models.booking.Booking
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface BookingRepo : JpaRepository<Booking, UUID>
interface BookingRepo : JpaRepository<Booking, UUID> {
fun findByPropertyIdAndSourceBookingId(propertyId: UUID, sourceBookingId: String): Booking?
fun existsByPropertyIdAndSourceBookingId(propertyId: UUID, sourceBookingId: String): Boolean
}

View File

@@ -4,4 +4,6 @@ import com.android.trisolarisserver.models.booking.Guest
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface GuestRepo : JpaRepository<Guest, UUID>
interface GuestRepo : JpaRepository<Guest, UUID> {
fun findByOrgIdAndPhoneE164(orgId: UUID, phoneE164: String): Guest?
}

View File

@@ -0,0 +1,12 @@
package com.android.trisolarisserver.db.repo
import com.android.trisolarisserver.models.booking.InboundEmail
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> {
fun findByMessageId(messageId: String): InboundEmail?
fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail?
fun existsByMessageId(messageId: String): Boolean
fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean
}

View File

@@ -0,0 +1,68 @@
package com.android.trisolarisserver.models.booking
import com.android.trisolarisserver.models.property.Property
import jakarta.persistence.*
import java.time.OffsetDateTime
import java.util.UUID
@Entity
@Table(
name = "inbound_email",
uniqueConstraints = [
UniqueConstraint(columnNames = ["message_id"]),
UniqueConstraint(columnNames = ["property_id", "ota_booking_id"])
]
)
class InboundEmail(
@Id
@GeneratedValue
@Column(columnDefinition = "uuid")
val id: UUID? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "property_id")
var property: Property? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "booking_id")
var booking: Booking? = null,
@Column(name = "ota_booking_id")
var otaBookingId: String? = null,
@Column(name = "message_id")
var messageId: String? = null,
@Column(name = "subject")
var subject: String? = null,
@Column(name = "from_address")
var fromAddress: String? = null,
@Column(name = "received_at", columnDefinition = "timestamptz")
var receivedAt: OffsetDateTime? = null,
@Column(name = "status", nullable = false)
@Enumerated(EnumType.STRING)
var status: InboundEmailStatus = InboundEmailStatus.PENDING,
@Column(name = "raw_pdf_path")
var rawPdfPath: String? = null,
@Column(name = "extracted_data", columnDefinition = "jsonb")
var extractedData: String? = null,
@Column(name = "processed_at", columnDefinition = "timestamptz")
var processedAt: OffsetDateTime? = null,
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)
enum class InboundEmailStatus {
PENDING,
CREATED,
CANCELLED,
SKIPPED,
ERROR
}

View File

@@ -34,6 +34,14 @@ class Property(
@Column(name = "is_active", nullable = false)
var active: Boolean = true,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_email_alias",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "alias", nullable = false)
var emailAliases: MutableSet<String> = mutableSetOf(),
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now()
)

View File

@@ -0,0 +1,298 @@
package com.android.trisolarisserver.service
import com.android.trisolarisserver.component.EmailStorage
import com.android.trisolarisserver.component.LlamaClient
import com.android.trisolarisserver.db.repo.BookingRepo
import com.android.trisolarisserver.db.repo.GuestRepo
import com.android.trisolarisserver.db.repo.InboundEmailRepo
import com.android.trisolarisserver.models.booking.Booking
import com.android.trisolarisserver.models.booking.BookingStatus
import com.android.trisolarisserver.models.booking.Guest
import com.android.trisolarisserver.models.booking.InboundEmail
import com.android.trisolarisserver.models.booking.InboundEmailStatus
import com.android.trisolarisserver.models.property.Property
import com.android.trisolarisserver.repo.PropertyRepo
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Multipart
import jakarta.mail.Session
import jakarta.mail.Store
import jakarta.mail.search.FlagTerm
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.time.OffsetDateTime
import java.time.ZoneId
import java.util.Properties
import java.util.concurrent.atomic.AtomicBoolean
import java.security.MessageDigest
@Component
class EmailIngestionService(
private val propertyRepo: PropertyRepo,
private val inboundEmailRepo: InboundEmailRepo,
private val bookingRepo: BookingRepo,
private val guestRepo: GuestRepo,
private val llamaClient: LlamaClient,
private val emailStorage: EmailStorage,
private val objectMapper: ObjectMapper,
@Value("\${mail.imap.host}")
private val host: String,
@Value("\${mail.imap.port:993}")
private val port: Int,
@Value("\${mail.imap.username}")
private val username: String,
@Value("\${mail.imap.password}")
private val password: String,
@Value("\${mail.imap.protocol:imaps}")
private val protocol: String
) {
private val running = AtomicBoolean(false)
@Value("\${mail.imap.enabled:false}")
private val enabled: Boolean = false
@Scheduled(fixedDelayString = "\${mail.imap.pollMs:60000}")
fun pollInbox() {
if (!enabled) return
if (!running.compareAndSet(false, true)) return
try {
val session = Session.getInstance(Properties())
val store: Store = session.getStore(protocol)
store.connect(host, port, username, password)
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
val unseen = inbox.search(FlagTerm(Flags(Flags.Flag.SEEN), false))
for (message in unseen) {
processMessage(message)
message.setFlag(Flags.Flag.SEEN, true)
}
inbox.close(true)
store.close()
} catch (_: Exception) {
// swallow to keep scheduler alive
} finally {
running.set(false)
}
}
private fun processMessage(message: Message) {
val messageId = buildMessageId(message)
if (messageId != null && inboundEmailRepo.existsByMessageId(messageId)) {
return
}
val subject = message.subject
val from = message.from?.firstOrNull()?.toString()
val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset)
val body = extractText(message)
val property = matchProperty(subject, body)
val inbound = InboundEmail(
property = property,
messageId = messageId,
subject = subject,
fromAddress = from,
receivedAt = receivedAt
)
inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body)
inboundEmailRepo.save(inbound)
if (property == null) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val extracted = extractBookingDetails(body)
inbound.extractedData = objectMapper.writeValueAsString(extracted)
val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) }
if (!otaBookingId.isNullOrBlank() &&
inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId)
) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
inbound.otaBookingId = otaBookingId
inboundEmailRepo.save(inbound)
val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true
if (isCancel) {
if (otaBookingId.isNullOrBlank()) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId)
if (booking != null) {
booking.status = BookingStatus.CANCELLED
bookingRepo.save(booking)
inbound.booking = booking
}
inbound.status = InboundEmailStatus.CANCELLED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val sourceBookingId = otaBookingId ?: "email:$messageId"
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
inbound.status = InboundEmailStatus.SKIPPED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
return
}
val guest = resolveGuest(property, extracted)
val booking = createBooking(property, guest, extracted, sourceBookingId)
inbound.booking = booking
inbound.status = InboundEmailStatus.CREATED
inbound.processedAt = OffsetDateTime.now()
inboundEmailRepo.save(inbound)
}
private fun extractBookingDetails(body: String): Map<String, String> {
val results = linkedMapOf<String, String>()
results["isCancel"] = llamaClient.askText(body, "Is this a cancellation email? Answer YES or NO only.")
results["otaBookingId"] = llamaClient.askText(body, "OTA BOOKING ID? Reply only the ID or NONE.")
results["checkinDate"] = llamaClient.askText(body, "CHECKIN DATE? Reply YYYY-MM-DD or NONE.")
results["checkoutDate"] = llamaClient.askText(body, "CHECKOUT DATE? Reply YYYY-MM-DD or NONE.")
results["nights"] = llamaClient.askText(body, "NIGHTS? Reply number or NONE.")
results["amount"] = llamaClient.askText(body, "TOTAL AMOUNT? Reply number only or NONE.")
results["roomRateWithoutGst"] = llamaClient.askText(body, "ROOM RATE WITHOUT GST? Reply number only or NONE.")
results["roomRateWithGst"] = llamaClient.askText(body, "ROOM RATE WITH GST? Reply number only or NONE.")
results["commissionIncludingGst"] = llamaClient.askText(body, "COMMISSION INCLUDING GST? Reply number only or NONE.")
results["guestName"] = llamaClient.askText(body, "GUEST NAME? Reply name or NONE.")
results["guestPhone"] = llamaClient.askText(body, "GUEST PHONE? Reply number or NONE.")
results["propertyName"] = llamaClient.askText(body, "PROPERTY NAME? Reply only name or NONE.")
results["propertyAddress"] = llamaClient.askText(body, "PROPERTY ADDRESS? Reply only address or NONE.")
results["roomTypesRaw"] = llamaClient.askText(
body,
"Room wise Payment Breakup. Return ONLY JSON. Include all dates."
)
results["policy"] = llamaClient.askText(body, "CANCELLATION POLICY? Reply short text or NONE.")
results["otaSource"] = llamaClient.askText(body, "OTA SOURCE? Reply only name or NONE.")
val source = results["otaSource"] ?: ""
if (source.contains("mmt", ignoreCase = true) || source.contains("make my trip", ignoreCase = true) ||
source.contains("makemytrip", ignoreCase = true)
) {
results["propertyGrossCharges"] =
llamaClient.askText(body, "PROPERTY GROSS CHARGES? Reply number only or NONE.")
results["payableToProperty"] =
llamaClient.askText(body, "PAYABLE TO PROPERTY? Reply number only or NONE.")
results["taxDeduction"] =
llamaClient.askText(body, "TAX DEDUCTION (7+8)? Reply number only or NONE.")
}
return results
}
private fun resolveGuest(property: Property, extracted: Map<String, String>): Guest {
val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim()
if (!phone.isNullOrBlank()) {
val existing = guestRepo.findByOrgIdAndPhoneE164(property.org.id!!, phone)
if (existing != null) return existing
}
val guest = Guest(
org = property.org,
phoneE164 = phone,
name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) }
)
return guestRepo.save(guest)
}
private fun createBooking(
property: Property,
guest: Guest,
extracted: Map<String, String>,
sourceBookingId: String
): Booking {
val zone = ZoneId.of(property.timezone)
val checkin = parsedDate(extracted["checkinDate"], zone)
val checkout = parsedDate(extracted["checkoutDate"], zone)
val booking = Booking(
property = property,
primaryGuest = guest,
status = BookingStatus.OPEN,
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
sourceBookingId = sourceBookingId,
expectedCheckinAt = checkin,
expectedCheckoutAt = checkout
)
return bookingRepo.save(booking)
}
private fun buildMessageId(message: Message): String? {
val header = message.getHeader("Message-ID")?.firstOrNull()
if (!header.isNullOrBlank()) return header
val from = message.from?.firstOrNull()?.toString() ?: ""
val subject = message.subject ?: ""
val received = message.receivedDate?.time?.toString() ?: ""
val raw = "$from|$subject|$received"
if (raw.isBlank()) return null
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
return digest.joinToString("") { "%02x".format(it) }
}
private fun parsedDate(value: String?, zone: ZoneId): OffsetDateTime? {
if (value.isNullOrBlank() || value.contains("NONE", true)) return null
return try {
val localDate = java.time.LocalDate.parse(value.trim())
localDate.atTime(12, 0).atZone(zone).toOffsetDateTime()
} catch (_: Exception) {
null
}
}
private fun matchProperty(subject: String?, body: String): Property? {
val haystack = "${subject ?: ""}\n$body".lowercase()
val properties = propertyRepo.findAll()
val matches = properties.filter { property ->
val aliases = mutableSetOf<String>()
aliases.add(property.name)
aliases.add(property.code)
aliases.addAll(property.emailAliases)
aliases.any { alias -> alias.isNotBlank() && haystack.contains(alias.lowercase()) }
}
return if (matches.size == 1) matches.first() else null
}
private fun extractText(message: Message): String {
return try {
val content = message.content
when (content) {
is String -> content
is Multipart -> extractFromMultipart(content)
else -> content.toString()
}
} catch (_: Exception) {
""
}
}
private fun extractFromMultipart(multipart: Multipart): String {
var text: String? = null
var html: String? = null
for (i in 0 until multipart.count) {
val part = multipart.getBodyPart(i)
if (part.isMimeType("text/plain")) {
text = part.content.toString()
} else if (part.isMimeType("text/html")) {
html = part.content.toString()
} else if (part.content is Multipart) {
val nested = extractFromMultipart(part.content as Multipart)
if (nested.isNotBlank()) text = nested
}
}
return text ?: html?.replace(Regex("<[^>]*>"), " ") ?: ""
}
}

View File

@@ -1 +1,2 @@
spring.datasource.url=jdbc:postgresql://192.168.1.53:5432/trisolaris
ai.llama.baseUrl=https://ai.hoteltrisolaris.in/v1/chat/completions

View File

@@ -1 +1,2 @@
spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris
spring.datasource.url=jdbc:postgresql://localhost:5432/trisolaris
ai.llama.baseUrl=http://localhost:8089/v1/chat/completions

View File

@@ -9,4 +9,11 @@ storage.documents.root=/home/androidlover5842/docs
storage.documents.publicBaseUrl=https://api.hoteltrisolaris.in
storage.documents.tokenSecret=change-me
storage.documents.tokenTtlSeconds=300
ai.llama.baseUrl=https://ai.hoteltrisolaris.in/v1/chat/completions
storage.emails.root=/home/androidlover5842/docs/emails
mail.imap.host=localhost
mail.imap.port=993
mail.imap.protocol=imaps
mail.imap.username=
mail.imap.password=
mail.imap.pollMs=60000
mail.imap.enabled=false