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

@@ -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("<[^>]*>"), " ") ?: ""
}
}