ai creates booking
This commit is contained in:
@@ -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("<[^>]*>"), " ") ?: ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user