This commit is contained in:
Vladimir Voronin
2025-01-07 12:35:17 +03:00
commit afd8e9f6d7
72 changed files with 4606 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
package space.luminic.budgerapp.services
import org.springframework.cache.annotation.Cacheable
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.configs.AuthException
import space.luminic.budgerapp.models.Token
import space.luminic.budgerapp.models.TokenStatus
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.TokenRepo
import space.luminic.budgerapp.repos.UserRepo
import space.luminic.budgerapp.utils.JWTUtil
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Date
import java.util.UUID
@Service
class AuthService(
private val userRepository: UserRepo,
private val tokenRepo: TokenRepo,
private val jwtUtil: JWTUtil
) {
private val passwordEncoder = BCryptPasswordEncoder()
fun login(username: String, password: String): Mono<String> {
return userRepository.findByUsername(username)
.flatMap { user ->
if (passwordEncoder.matches(password, user.password)) {
val token = jwtUtil.generateToken(user.username)
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
tokenRepo.save(
Token(
token = token,
username = username,
issuedAt = LocalDateTime.now(),
expiresAt = LocalDateTime.ofInstant(
expireAt.toInstant(),
ZoneId.systemDefault()
)
)
)
.thenReturn(token)
} else {
Mono.error(AuthException("Invalid credentials"))
}
}
}
@Cacheable("tokens")
fun isTokenValid(token: String): Mono<User> {
// print("checking token: $token")
return tokenRepo.findByToken(token)
.flatMap {
if (it.status == TokenStatus.ACTIVE &&
it.expiresAt.isAfter(LocalDateTime.now())
) {
userRepository.findByUsername(it.username)
} else {
Mono.error(AuthException("Token expired"))
}
}.switchIfEmpty(Mono.error(AuthException("User not found")))
}
}

View File

@@ -0,0 +1,616 @@
package space.luminic.budgerapp.services
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.context.event.EventListener
import org.springframework.data.domain.Sort
import org.springframework.data.domain.Sort.Direction
import org.springframework.stereotype.Service
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.BudgetCategory
import space.luminic.budgerapp.models.Transaction
import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation.group
import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
import org.springframework.data.mongodb.core.aggregation.Aggregation.match
import org.springframework.data.mongodb.core.aggregation.Aggregation.project
import org.springframework.data.mongodb.core.aggregation.Aggregation.sort
import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
import org.springframework.data.mongodb.core.query.Criteria
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.BudgetDTO
import space.luminic.budgerapp.models.BudgetNotFoundException
import space.luminic.budgerapp.models.PushMessage
import space.luminic.budgerapp.models.SortSetting
import space.luminic.budgerapp.models.TransactionEvent
import space.luminic.budgerapp.models.TransactionEventType
import space.luminic.budgerapp.models.Warn
import space.luminic.budgerapp.models.WarnSerenity
import space.luminic.budgerapp.repos.BudgetRepo
import space.luminic.budgerapp.repos.WarnRepo
import java.time.LocalDate
import java.util.Optional
import kotlin.collections.get
@Service
class BudgetService(
val budgetRepo: BudgetRepo,
val warnRepo: WarnRepo,
val transactionService: TransactionService,
val recurrentService: RecurrentService,
val categoryService: CategoryService,
val reactiveMongoTemplate: ReactiveMongoTemplate
) {
private val logger = LoggerFactory.getLogger(BudgetService::class.java)
@EventListener
@CacheEvict(cacheNames = ["budgets"], allEntries = true)
fun handleTransactionEvent(event: TransactionEvent) {
logger.info("Got ${event.eventType} event on transaction ${event.newTransaction.id}")
if (event.newTransaction.category.type?.code == "EXPENSE") {
when (event.eventType) {
TransactionEventType.EDIT -> updateBudgetOnEdit(event)
TransactionEventType.CREATE -> updateBudgetOnCreate(event)
TransactionEventType.DELETE -> updateBudgetOnDelete(event)
}
}
// runBlocking(Dispatchers.IO) {
// updateBudgetWarns(
// budget = budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
// event.newTransaction.date.toLocalDate(), event.newTransaction.date.toLocalDate()
// )
// )
// }
}
fun updateBudgetOnCreate(event: TransactionEvent) {
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.newTransaction.date, event.newTransaction.date
).flatMap { budget ->
categoryService.getBudgetCategories(budget.dateFrom, budget.dateTo).flatMap { categories ->
val updatedCategories = when (event.newTransaction.type.code) {
"PLANNED" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
category.currentLimit += event.newTransaction.amount
}
category
}.collectList()
"INSTANT" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}.collectList()
else -> Mono.just(budget.categories) // Добавляем обработку типа по умолчанию
}
updatedCategories.flatMap { updated ->
budget.categories = updated
budgetRepo.save(budget) // Сохраняем обновленный бюджет
}
}
}.then() // Гарантируем завершение
.subscribe() // Запускаем выполнение
}
fun updateBudgetOnEdit(event: TransactionEvent) {
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.oldTransaction.date, event.oldTransaction.date
).switchIfEmpty(
Mono.error(BudgetNotFoundException("old budget cannot be null"))
).then().subscribe()
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.newTransaction.date, event.newTransaction.date
).flatMap { budget ->
categoryService.getBudgetCategories(budget.dateFrom, budget.dateTo).flatMap { categories ->
val updatedCategories = when (event.newTransaction.type.code) {
"PLANNED" -> Flux.fromIterable(budget.categories)
.map { category ->
if (category.category.id == event.newTransaction.category.id) {
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
category.currentLimit += event.difference!!
}
}
category
}.collectList()
"INSTANT" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}.collectList()
else -> Mono.just(budget.categories) // Добавляем обработку типа по умолчанию
}
updatedCategories.flatMap { updated ->
budget.categories = updated
budgetRepo.save(budget) // Сохраняем обновленный бюджет
}
}
}.then() // Гарантируем завершение
.subscribe() // Запускаем выполнение
}
fun updateBudgetOnDelete(event: TransactionEvent) {
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.newTransaction.date, event.newTransaction.date
).flatMap { budget ->
categoryService.getBudgetCategories(budget.dateFrom, budget.dateTo).flatMap { categories ->
val updatedCategories = when (event.newTransaction.type.code) {
"PLANNED" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
category.currentLimit += event.newTransaction.amount
}
category
}.collectList()
"INSTANT" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}.collectList()
else -> Mono.just(budget.categories) // Добавляем обработку типа по умолчанию
}
updatedCategories.flatMap { updated ->
budget.categories = updated
budgetRepo.save(budget) // Сохраняем обновленный бюджет
}
}
}.then() // Гарантируем завершение
.subscribe() // Запускаем выполнение
}
fun updateBudgetTransactions(budget: Budget): Budget {
// budget.plannedExpenses = getBudgetTransactions(
// budget = budget,
// transactionType = "PLANNED",
// categoryType = "EXPENSE",
// sortBy = SortSetting("date", Direction.ASC)
// )
// budget.plannedIncomes = getBudgetTransactions(
// budget = budget,
// transactionType = "PLANNED",
// categoryType = "INCOME",
// sortBy = SortSetting("date", Direction.ASC)
// )
return budget
}
@Cacheable("budgetsList")
fun getBudgets(sortSetting: SortSetting? = null): Mono<MutableList<Budget>> {
val sort = if (sortSetting != null) {
Sort.by(sortSetting.order, sortSetting.by)
} else {
Sort.by(Sort.Direction.DESC, "dateFrom")
}
return budgetRepo.findAll(sort)
.collectList() // Сбор Flux<Budget> в Mono<List<Budget>>
}
// @Cacheable("budgets", key = "#id")
fun getBudget(id: String): Mono<BudgetDTO> {
return budgetRepo.findById(id)
.flatMap { budget ->
val budgetDTO = BudgetDTO(
budget.id,
budget.name,
budget.dateFrom,
budget.dateTo,
budget.createdAt,
categories = budget.categories
)
logger.info("Fetching categories and transactions")
val categoriesMono = categoryService.getBudgetCategories(budgetDTO.dateFrom, budgetDTO.dateTo)
val transactionsMono =
transactionService.getTransactionsByTypes(budgetDTO.dateFrom, budgetDTO.dateTo)
Mono.zip(categoriesMono, transactionsMono)
.flatMap { tuple ->
val categories = tuple.t1
val transactions = tuple.t2
Flux.fromIterable(budgetDTO.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}
.collectList()
.map { updatedCategories ->
budgetDTO.categories = updatedCategories
budgetDTO.plannedExpenses = transactions["plannedExpenses"] as MutableList
budgetDTO.plannedIncomes = transactions["plannedIncomes"] as MutableList
budgetDTO.transactions = transactions["instantTransactions"] as MutableList
budgetDTO
}
}
}
.doOnError { error ->
logger.error("Error fetching budget: ${error.message}", error)
}
.switchIfEmpty(Mono.error(BudgetNotFoundException("Budget not found with id: $id")))
}
// fun transferBudgets() {
// val budgets = getBudgets()
// budgetRepo.saveAll<Budget>(budgets)
//
// }
//
// fun getBudgets(): List<Budget> {
// val budgetIds = budgetRepoSql.getBudgetsIds()
// var budgets = mutableListOf<Budget>()
// budgetIds.forEach { budgetId ->
// budgets.add(getBudgetSQL(budgetId)!!)
// }
// return budgets
// }
@CacheEvict(cacheNames = ["budgets", "budgetsList"], allEntries = true)
fun createBudget(budget: Budget, createRecurrent: Boolean): Mono<Budget> {
return Mono.zip(
getBudgetByDate(budget.dateFrom).map { Optional.ofNullable(it) }
.switchIfEmpty(Mono.just(Optional.empty())),
getBudgetByDate(budget.dateTo).map { Optional.ofNullable(it) }
.switchIfEmpty(Mono.just(Optional.empty()))
).flatMap { tuple ->
val startBudget = tuple.t1.orElse(null)
val endBudget = tuple.t2.orElse(null)
// Проверяем, пересекаются ли бюджеты по датам
if (startBudget != null || endBudget != null) {
return@flatMap Mono.error<Budget>(IllegalArgumentException("Бюджет с теми же датами найден"))
}
// Если createRecurrent=true, создаем рекуррентные транзакции
val recurrentsCreation = if (createRecurrent) {
recurrentService.createRecurrentsForBudget(budget)
} else {
Mono.empty()
}
// Создаем бюджет после возможного создания рекуррентных транзакций
recurrentsCreation.then(
categoryService.getCategoryTransactionPipeline(budget.dateFrom, budget.dateTo)
.flatMap { categories ->
budget.categories = categories
budgetRepo.save(budget)
}
.doOnNext { savedBudget ->
// Выполнение updateBudgetWarns в фоне
updateBudgetWarns(budget = savedBudget)
.doOnError { error ->
// Логируем ошибку, если произошла
println("Error during updateBudgetWarns: ${error.message}")
}
.subscribe()
}
)
}
}
fun getBudgetByDate(date: LocalDate): Mono<Budget> {
return budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(date, date).switchIfEmpty(Mono.empty())
}
// fun getBudgetCategorySQL(id: Int): List<BudgetCategory>? {
// var categories = budgetRepoSql.getBudgetCategory(id)
// for (category in categories) {
// categoryService.getCategoryByName(category.category.name)?.let { category.category = it }
// }
// return categories
// }
fun getBudgetCategories(id: String): Mono<List<BudgetCategory>> {
return budgetRepo.findById(id).flatMap { budget ->
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetailed")
val unwind = unwind("categoryDetailed")
val projectDouble = project("categoryDetailed", "amount", "date")
.andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val match = match(Criteria.where("date").gte(budget.dateFrom).lt(budget.dateTo))
val group = group("categoryDetailed").sum("amount").`as`("currentSpent")
val project = project("currentSpent").and("_id").`as`("category")
val sort = sort(Sort.by(Sort.Order.asc("_id")))
val aggregation = newAggregation(lookup, unwind, projectDouble, match, group, project, sort)
reactiveMongoTemplate.aggregate(aggregation, "transactions", BudgetCategory::class.java)
.collectList() // Преобразование результата в список
}
}
fun getBudgetTransactionsByType(budgetId: String): Mono<Map<String, List<Transaction>>> {
return budgetRepo.findById(budgetId).flatMap { it ->
transactionService.getTransactionsByTypes(it.dateFrom, it.dateTo)
}
}
fun getBudgetTransactions(
budget: Budget,
transactionType: String? = null,
isDone: Boolean? = null,
categoryType: String? = null,
sortBy: SortSetting? = null
): Mono<MutableList<Transaction>> {
val defaultSort = SortSetting("date", Sort.Direction.ASC) // Сортировка по умолчанию
return transactionService.getTransactions(
dateFrom = budget.dateFrom,
dateTo = budget.dateTo,
transactionType = transactionType,
isDone = isDone,
categoryType = categoryType,
sortSetting = sortBy ?: defaultSort
).onErrorResume { e ->
Mono.error(RuntimeException("Error fetching transactions: ${e.message}", e))
}
}
@CacheEvict(cacheNames = ["budgets", "budgetsList"], allEntries = true)
fun deleteBudget(budgetId: String): Mono<Void> {
return budgetRepo.findById(budgetId)
.switchIfEmpty(Mono.error(BudgetNotFoundException("Budget with id: $budgetId not found")))
.flatMap { budget ->
transactionService.getTransactionsToDelete(budget.dateFrom, budget.dateTo)
.flatMapMany { transactions ->
Flux.fromIterable(transactions)
.flatMap { transaction ->
transactionService.deleteTransaction(transaction.id!!)
}
}
.then(
budgetRepo.delete(budget)
)
}
}
@CacheEvict(cacheNames = ["budgets", "budgetsList"], allEntries = true)
fun setCategoryLimit(budgetId: String, catId: String, limit: Double): Mono<BudgetCategory> {
return budgetRepo.findById(budgetId).flatMap { budget ->
val catEdit = budget.categories.firstOrNull { it.category.id == catId }
?: return@flatMap Mono.error<BudgetCategory>(Exception("Category not found in the budget"))
transactionService.calcTransactionsSum(budget, catId, "PLANNED").flatMap { catPlanned ->
if (catPlanned > limit) {
Mono.error(Exception("Limit can't be less than planned expenses on category. Current planned value: $catPlanned"))
} else {
catEdit.currentLimit = limit
budgetRepo.save(budget).flatMap {
updateBudgetWarns(it)
.thenReturn(catEdit)
}
}
}
}
}
fun recalcBudgetCategory(): Mono<Void> {
return budgetRepo.findAll(Sort.by(Direction.DESC, "dateFrom")) // Получаем все бюджеты
// .flatMapIterable { budgets -> budgets } // Преобразуем Flux<Budget> в Flux<Budget> (итерация по бюджетам)
// .flatMap { budget ->
// logger.warn("HERE $budget")
// Flux.fromIterable(budget.categories) // Преобразуем категории в поток
// .flatMap { category ->
// logger.warn("HERE $category")
// Mono.zip(
// transactionService.calcTransactionsSum(budget, category.category.id!!, "PLANNED"),
// transactionService.calcTransactionsSum(budget, category.category.id!!, "INSTANT", isDone = true),
// transactionService.calcTransactionsSum(budget, category.category.id!!, "PLANNED")
// ).map { (plannedSum, spentSum, limitSum) ->
// category.currentPlanned = plannedSum
// category.currentSpent = spentSum
// category.currentLimit = limitSum
// }
// }
// .then() // Завершаем поток категорий
// .flatMap { updateBudgetWarns(budgetId = budget.id!!) } // Обновляем предупреждения
// }
// .collectList() // Собираем все бюджеты
// .flatMap { budgets -> budgetRepo.saveAll(budgets).collectList() } // Сохраняем все бюджеты
.then() // Завершаем метод
}
fun getWarns(budgetId: String, isHide: Boolean? = null): Mono<List<Warn>> {
return warnRepo.findAllByBudgetIdAndIsHide(budgetId, isHide == true).collectList()
}
fun hideWarn(budgetId: String, warnId: String): Mono<Warn> {
return warnRepo.findById(warnId) // Ищем предупреждение
.flatMap { warn ->
warn.isHide = true // Обновляем поле
warnRepo.save(warn) // Сохраняем изменённое предупреждение
}
}
fun updateBudgetWarns(budget: Budget? = null): Mono<List<Warn>> {
logger.info("STARTED WARNS UPDATE")
val finalBudgetMono = budget?.let { Mono.just(it) }
?: return Mono.just(emptyList())
return finalBudgetMono.flatMap { finalBudget ->
if (finalBudget.categories.isEmpty()) {
logger.info("No categories found for budget ${finalBudget.id}")
return@flatMap Mono.just(emptyList<Warn>())
}
val averageSumsMono = transactionService.getAverageSpendingByCategory()
val averageIncomeMono = transactionService.getAverageIncome()
val currentBudgetIncomeMono = transactionService.calcTransactionsSum(
finalBudget, transactionType = "PLANNED", categoryType = "INCOME"
)
val plannedIncomeMono = transactionService.calcTransactionsSum(
finalBudget, categoryType = "INCOME", transactionType = "PLANNED"
)
val plannedSavingMono = transactionService.calcTransactionsSum(
finalBudget, categoryId = "675850148198643f121e466a", transactionType = "PLANNED"
)
Mono.zip(
averageSumsMono,
averageIncomeMono,
currentBudgetIncomeMono,
plannedIncomeMono,
plannedSavingMono
).flatMap { tuple ->
val averageSums = tuple.t1
val averageIncome = tuple.t2
val currentBudgetIncome = tuple.t3
val plannedIncome = tuple.t4
val plannedSaving = tuple.t5
Flux.fromIterable(finalBudget.categories)
.flatMap { category ->
processCategoryWarnings(
category,
finalBudget,
averageSums,
averageIncome,
currentBudgetIncome,
plannedIncome,
plannedSaving
)
}
.collectList()
.flatMap { warns ->
warnRepo.saveAll(warns.filterNotNull()).collectList()
}
.doOnSuccess { logger.info("ENDED WARNS UPDATE") }
.map { it.sortedByDescending { warn -> warn.serenity.sort } }
}
}.doOnError { error ->
logger.error("Error updating budget warns: ${error.message}", error)
}.onErrorResume {
Mono.just(emptyList()) // Возвращаем пустой список в случае ошибки
}
}
private fun processCategoryWarnings(
category: BudgetCategory,
finalBudget: Budget,
averageSums: Map<String, Double>,
averageIncome: Double,
currentBudgetIncome: Double,
plannedIncome: Double,
plannedSaving: Double
): Flux<Warn> {
val warnsForCategory = mutableListOf<Mono<Warn?>>()
val averageSum = averageSums[category.category.id] ?: 0.0
val categorySpentRatioInAvgIncome = if (averageIncome > 0.0) averageSum / averageIncome else 0.0
val projectedAvailableSum = currentBudgetIncome * categorySpentRatioInAvgIncome
val contextAtAvg = "category${category.category.id}atbudget${finalBudget.id}lessavg"
val lowSavingContext = "savingValueLess10atBudget${finalBudget.id}"
if (averageSum > category.currentLimit) {
val warnMono = warnRepo.findWarnByContext(contextAtAvg)
.switchIfEmpty(
Mono.just(
Warn(
serenity = WarnSerenity.MAIN,
message = PushMessage(
title = "Внимание на ${category.category.name}!",
body = "Лимит меньше средних трат (Среднее: <b>${averageSum.toInt()} ₽</b> Текущий лимит: <b>${category.currentLimit.toInt()} ₽</b>)." +
"\nСредняя доля данной категории в доходах: <b>${(categorySpentRatioInAvgIncome * 100).toInt()}%</b>." +
"\nПроецируется на текущие поступления: <b>${projectedAvailableSum.toInt()} ₽</b>",
icon = category.category.icon
),
budgetId = finalBudget.id!!,
context = contextAtAvg,
isHide = false
)
)
)
warnsForCategory.add(warnMono)
} else {
warnRepo.findWarnByContext(contextAtAvg).flatMap { warnRepo.delete(it).then(Mono.empty<Warn>()) }
}
if (category.category.id == "675850148198643f121e466a") {
val savingRatio = if (plannedIncome > 0.0) category.currentLimit / plannedIncome else 0.0
if (savingRatio < 0.1) {
val warnMono = warnRepo.findWarnByContext(lowSavingContext)
.switchIfEmpty(
Mono.just(
Warn(
serenity = WarnSerenity.IMPORTANT,
message = PushMessage(
title = "Доля сбережений очень мала!",
body = "Текущие плановые сбережения равны ${plannedSaving.toInt()} (${
(savingRatio * 100).toInt()
}%)! Исправьте!",
icon = category.category.icon
),
budgetId = finalBudget.id!!,
context = lowSavingContext,
isHide = false
)
)
)
warnsForCategory.add(warnMono)
} else {
warnRepo.findWarnByContext(lowSavingContext)
.flatMap { warnRepo.delete(it).then(Mono.empty<Warn>()) }
}
}
return Flux.fromIterable(warnsForCategory).flatMap { it }
}
}

View File

@@ -0,0 +1,16 @@
package space.luminic.budgerapp.services
import org.springframework.cache.CacheManager
import org.springframework.stereotype.Service
@Service
class CacheInspector(private val cacheManager: CacheManager) {
fun getCacheContent(cacheName: String): Map<Any, Any>? {
val cache = cacheManager.getCache(cacheName)
if (cache != null && cache is org.springframework.cache.concurrent.ConcurrentMapCache) {
return cache.nativeCache as Map<Any, Any>
}
return null
}
}

View File

@@ -0,0 +1,362 @@
package space.luminic.budgerapp.services
import org.bson.Document
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.BudgetCategory
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.CategoryType
import space.luminic.budgerapp.repos.CategoryRepo
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Date
import kotlin.jvm.optionals.getOrNull
@Service
class CategoryService(
private val categoryRepo: CategoryRepo,
private val transactionService: TransactionService,
private val mongoTemplate: ReactiveMongoTemplate
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun getCategoryByName(name: String): Mono<Category> {
return categoryRepo.findByName(name)
}
@Cacheable("getAllCategories")
fun getCategories(): Mono<List<Category>> {
return categoryRepo.findAll().collectList()
}
@Cacheable("categoryTypes")
fun getCategoryTypes(): List<CategoryType> {
var types = mutableListOf<CategoryType>()
types.add(CategoryType("EXPENSE", "Траты"))
types.add(CategoryType("INCOME", "Поступления"))
return types
}
@CacheEvict(cacheNames = ["getAllCategories"],allEntries = true)
fun createCategory(category: Category): Mono<Category> {
return categoryRepo.save(category)
}
@CacheEvict(cacheNames = ["getAllCategories"],allEntries = true)
fun editCategory(category: Category): Mono<Category> {
return categoryRepo.findById(category.id!!) // Возвращаем Mono<Category>
.flatMap { oldCategory ->
if (oldCategory.type.code != category.type.code) {
return@flatMap Mono.error<Category>(IllegalArgumentException("You cannot change category type"))
}
categoryRepo.save(category) // Сохраняем категорию, если тип не изменился
}
}
@CacheEvict(cacheNames = ["getAllCategories"],allEntries = true)
fun deleteCategory(categoryId: String): Mono<String> {
return categoryRepo.findById(categoryId).switchIfEmpty(
Mono.error(IllegalArgumentException("Category with id: $categoryId not found"))
).flatMap {
transactionService.getTransactions(categoryId = categoryId)
.flatMapMany { transactions ->
categoryRepo.findByName("Другое").switchIfEmpty(
categoryRepo.save(
Category(
type = CategoryType("EXPENSE", "Траты"),
name = "Другое",
description = "Категория для других трат",
icon = "🚮"
)
)
).flatMapMany { newCategory ->
Flux.fromIterable(transactions).flatMap { transaction ->
transaction.category = newCategory // Присваиваем конкретный объект категории
transactionService.editTransaction(transaction) // Сохраняем изменения
}
}
}
.then(categoryRepo.deleteById(categoryId)) // Удаляем старую категорию
.thenReturn(categoryId) // Возвращаем удалённую категорию
}
}
fun getBudgetCategories(dateFrom: LocalDate, dateTo: LocalDate): Mono<Map<String, Map<String, Double>>> {
logger.info("here cat starts")
val pipeline = listOf(
Document(
"\$lookup",
Document("from", "transactions")
.append(
"let",
Document("categoryId", "\$_id")
)
.append(
"pipeline", listOf(
Document(
"\$match",
Document(
"\$expr",
Document(
"\$and", listOf(
Document("\$eq", listOf("\$category.\$id", "\$\$categoryId")),
Document(
"\$gte", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateFrom, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
),
)
),
Document(
"\$lt", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateTo, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
)
)
)
)
)
)
),
Document(
"\$group",
Document("_id", "\$type.code")
.append(
"totalAmount",
Document("\$sum", "\$amount")
)
)
)
)
.append("as", "transactionSums")
),
Document(
"\$project",
Document("_id", 1L)
.append(
"plannedAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "PLANNED"))
)
), 0L
)
)
)
.append(
"instantAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "INSTANT"))
)
), 0L
)
)
)
),
Document(
"\$addFields",
Document(
"plannedAmount",
Document("\$ifNull", listOf("\$plannedAmount.totalAmount", 0.0))
)
.append(
"instantAmount",
Document("\$ifNull", listOf("\$instantAmount.totalAmount", 0.0))
)
)
)
// Анализ плана выполнения (вывод для отладки)
// getCategoriesExplainReactive(pipeline)
// .doOnNext { explainResult ->
// logger.info("Explain Result: ${explainResult.toJson()}")
// }
// .subscribe() // Этот вызов лучше оставить только для отладки
//
return mongoTemplate.getCollection("categories")
.flatMapMany { it.aggregate(pipeline) }
.collectList()
.flatMap { result ->
val categories = result.associate { document ->
val id = document["_id"].toString()
val values = mapOf(
"plannedAmount" to (document["plannedAmount"] as Double? ?: 0.0),
"instantAmount" to (document["instantAmount"] as Double? ?: 0.0)
)
id to values
}
logger.info("here cat ends")
Mono.just(categories)
}
}
fun getCategoryTransactionPipeline(dateFrom: LocalDate, dateTo: LocalDate): Mono<MutableList<BudgetCategory>> {
val pipeline = listOf(
Document("\$match", Document("type.code", "EXPENSE")),
Document(
"\$lookup",
Document("from", "transactions")
.append(
"let",
Document("categoryId", "\$_id")
)
.append(
"pipeline", listOf(
Document(
"\$match",
Document(
"\$expr",
Document(
"\$and", listOf(
Document("\$eq", listOf("\$category.\$id", "\$\$categoryId")),
Document(
"\$gte", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateFrom, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
)
)
),
Document(
"\$lt", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateTo, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
)
)
)
)
)
)
),
Document(
"\$group",
Document("_id", "\$type.code")
.append(
"totalAmount",
Document("\$sum", "\$amount")
)
)
)
)
.append("as", "transactionSums")
),
Document(
"\$project",
Document("_id", 1L)
.append("type", 1L)
.append("name", 1L)
.append("description", 1L)
.append("icon", 1L)
.append(
"plannedAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "PLANNED"))
)
), 0.0
)
)
)
.append(
"instantAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "INSTANT"))
)
), 0.0
)
)
)
),
Document(
"\$addFields",
Document(
"plannedAmount",
Document("\$ifNull", listOf("\$plannedAmount.totalAmount", 0.0))
)
.append(
"instantAmount",
Document("\$ifNull", listOf("\$instantAmount.totalAmount", 0.0))
)
)
)
return mongoTemplate.getCollection("categories")
.flatMapMany { it.aggregate(pipeline, Document::class.java) }
.map { document ->
val catType = document["type"] as Document
BudgetCategory(
currentSpent = document["instantAmount"] as Double,
currentLimit = document["plannedAmount"] as Double,
currentPlanned = document["plannedAmount"] as Double,
category = Category(
document["_id"].toString(),
CategoryType(catType["code"] as String, catType["name"] as String),
name = document["name"] as String,
description = document["description"] as String,
icon = document["icon"] as String
)
)
}
.collectList()
.map { it.toMutableList() }
}
}

View File

@@ -0,0 +1,16 @@
package space.luminic.budgerapp.services
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
@Service
class CustomReactiveUserDetailsService(
private val userDetailsService: UserDetailsService // Ваш синхронный сервис
) : ReactiveUserDetailsService {
override fun findByUsername(username: String): Mono<UserDetails> {
return Mono.fromCallable { userDetailsService.loadUserByUsername(username) }
}
}

View File

@@ -0,0 +1,128 @@
package space.luminic.budgerapp.services
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.models.Recurrent
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionType
import space.luminic.budgerapp.repos.RecurrentRepo
import space.luminic.budgerapp.repos.TransactionRepo
import space.luminic.budgerapp.repos.sqlrepo.RecurrentRepoSQL
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.YearMonth
import kotlin.jvm.optionals.getOrNull
@Service
class RecurrentService(
private val recurrentRepo: RecurrentRepo,
private val recurrentRepoSQL: RecurrentRepoSQL,
private val transactionRepo: TransactionRepo,
private val userService: UserService,
) {
private val logger = LoggerFactory.getLogger(javaClass)
@Cacheable("recurrentsList")
fun getRecurrents(): Mono<List<Recurrent>> {
return recurrentRepo.findAll().collectList()
}
@Cacheable("recurrents", key = "#id")
fun getRecurrentById(id: String): Mono<Recurrent> {
return recurrentRepo.findById(id)
.switchIfEmpty(Mono.error(NotFoundException("Recurrent with id: $id not found")))
}
@CacheEvict(cacheNames = ["recurrentsList", "recurrents"])
fun createRecurrent(recurrent: Recurrent): Mono<Recurrent> {
return if (recurrent.id == null && recurrent.atDay <= 31) recurrentRepo.save(recurrent) else Mono.error(
RuntimeException("Cannot create recurrent with id or date cannot be higher than 31")
)
}
@CacheEvict(cacheNames = ["recurrentsList", "recurrents"])
fun createRecurrentsForBudget(budget: Budget): Mono<Void> {
val currentYearMonth = YearMonth.of(budget.dateFrom.year, budget.dateFrom.monthValue)
val daysInCurrentMonth = currentYearMonth.lengthOfMonth()
val context = ReactiveSecurityContextHolder.getContext()
.doOnNext { println("Security context: $it") }
.switchIfEmpty(Mono.error(IllegalStateException("SecurityContext is empty!")))
return context
.map {
logger.debug(it.authentication.name)
it.authentication
}
.flatMap { authentication ->
val username = authentication.name
userService.getByUsername(username)
.switchIfEmpty(Mono.error(IllegalArgumentException("User not found for username: $username")))
}
.flatMapMany { user ->
recurrentRepo.findAll()
.map { recurrent ->
// Определяем дату транзакции
val transactionDate = when {
recurrent.atDay <= daysInCurrentMonth && recurrent.atDay >= budget.dateFrom.dayOfMonth -> {
currentYearMonth.atDay(recurrent.atDay)
}
recurrent.atDay < budget.dateFrom.dayOfMonth -> {
currentYearMonth.atDay(recurrent.atDay).plusMonths(1)
}
else -> {
val extraDays = recurrent.atDay - daysInCurrentMonth
currentYearMonth.plusMonths(1).atDay(extraDays)
}
}
// Создаем транзакцию
Transaction(
date = transactionDate,
amount = recurrent.amount.toDouble(),
category = recurrent.category,
isDone = false,
comment = recurrent.name,
user = user,
type = TransactionType("PLANNED", "Запланированные")
)
}
}
.collectList() // Собираем все транзакции в список
.flatMap { transactions ->
transactionRepo.saveAll(transactions) // Сохраняем все транзакции разом
.then() // Возвращаем Mono<Void>
}
}
fun editRecurrent(recurrent: Recurrent): Mono<Recurrent> {
return if (recurrent.id != null && recurrent.atDay <= 31) recurrentRepo.save(recurrent) else Mono.error(
RuntimeException("Cannot edit recurrent without id or date cannot be higher than 31")
)
}
fun deleteRecurrent(id: String): Mono<Void> {
return recurrentRepo.deleteById(id)
}
fun transferRecurrents() {
recurrentRepo.saveAll(recurrentRepoSQL.getRecurrents()).then().subscribe()
}
}

View File

@@ -0,0 +1,100 @@
package space.luminic.budgerapp.services
import com.interaso.webpush.VapidKeys
import com.interaso.webpush.WebPushService
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import org.slf4j.LoggerFactory
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.PushMessage
import space.luminic.budgerapp.models.Subscription
import space.luminic.budgerapp.models.SubscriptionDTO
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.SubscriptionRepo
import space.luminic.budgerapp.services.VapidConstants.VAPID_PRIVATE_KEY
import space.luminic.budgerapp.services.VapidConstants.VAPID_PUBLIC_KEY
import space.luminic.budgerapp.services.VapidConstants.VAPID_SUBJECT
import java.security.GeneralSecurityException
object VapidConstants {
const val VAPID_PUBLIC_KEY =
"BKmMyBUhpkcmzYWcYsjH_spqcy0zf_8eVtZo60f7949TgLztCmv3YD0E_vtV2dTfECQ4sdLdPK3ICDcyOkCqr84"
const val VAPID_PRIVATE_KEY = "YeJH_0LhnVYN6RdxMidgR6WMYlpGXTJS3HjT9V3NSGI"
const val VAPID_SUBJECT = "mailto:voroninvyu@gmail.com"
}
@Service
class SubscriptionService(private val subscriptionRepo: SubscriptionRepo) {
private val logger = LoggerFactory.getLogger(javaClass)
private val pushService =
WebPushService(
subject = VAPID_SUBJECT,
vapidKeys = VapidKeys.fromUncompressedBytes(VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
)
fun sendNotification(endpoint: String, p256dh: String, auth: String, payload: PushMessage): Mono<Void> {
return Mono.fromRunnable<Void> {
pushService.send(
payload = Json.encodeToString(payload),
endpoint = endpoint,
p256dh = p256dh,
auth = auth
)
}
.doOnSuccess {
logger.info("Уведомление успешно отправлено на endpoint: $endpoint")
}
.doOnError { e ->
logger.error("Ошибка при отправке уведомления на endpoint $endpoint: ${e.message}")
}
.onErrorResume { e ->
Mono.error(e) // Пробрасываем ошибку дальше, если нужна обработка выше
}
}
fun sendToAll(payload: PushMessage): Mono<List<String>> {
return subscriptionRepo.findAll()
.flatMap { sub ->
sendNotification(sub.endpoint, sub.p256dh, sub.auth, payload)
.then(Mono.just("${sub.user?.username} at endpoint ${sub.endpoint}"))
.onErrorResume { e ->
sub.isActive = false
subscriptionRepo.save(sub).then(Mono.empty())
}
}
.collectList() // Собираем результаты в список
}
fun subscribe(subscriptionDTO: SubscriptionDTO, user: User): Mono<String> {
val subscription = Subscription(
id = null,
user = user,
endpoint = subscriptionDTO.endpoint,
auth = subscriptionDTO.keys["auth"].orEmpty(),
p256dh = subscriptionDTO.keys["p256dh"].orEmpty(),
isActive = true
)
return subscriptionRepo.save(subscription)
.flatMap { savedSubscription ->
Mono.just("Subscription created with ID: ${savedSubscription.id}")
}
.onErrorResume(DuplicateKeyException::class.java) {
logger.info("Subscription already exists. Skipping.")
Mono.just("Subscription already exists. Skipping.")
}
.onErrorResume { e ->
logger.error("Error while saving subscription: ${e.message}")
Mono.error(RuntimeException("Error while saving subscription"))
}
}
}

View File

@@ -0,0 +1,42 @@
package space.luminic.budgerapp.services
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Token
import space.luminic.budgerapp.models.TokenStatus
import space.luminic.budgerapp.repos.TokenRepo
import java.time.LocalDateTime
@Service
class TokenService(private val tokenRepository: TokenRepo) {
@CacheEvict("tokens", allEntries = true)
fun saveToken(token: String, username: String, expiresAt: LocalDateTime) {
val newToken = Token(
token = token,
username = username,
issuedAt = LocalDateTime.now(),
expiresAt = expiresAt
)
tokenRepository.save(newToken)
}
@CacheEvict("tokens", allEntries = true)
fun revokeToken(token: String): Mono<Void> {
return tokenRepository.findByToken(token)
.flatMap { existingToken ->
val updatedToken = existingToken.copy(status = TokenStatus.REVOKED)
tokenRepository.save(updatedToken).then()
}
.switchIfEmpty(Mono.error(Exception("Token not found")))
}
@CacheEvict("tokens", allEntries = true)
fun deleteExpiredTokens() {
tokenRepository.deleteByExpiresAtBefore(LocalDateTime.now())
}
}

View File

@@ -0,0 +1,784 @@
package space.luminic.budgerapp.services
import com.mongodb.client.model.Aggregates.addFields
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.bson.Document
import org.bson.types.ObjectId
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Sort
import org.springframework.data.domain.Sort.Direction
import org.springframework.data.mongodb.MongoExpression
import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.AddFieldsOperation
import org.springframework.data.mongodb.core.aggregation.Aggregation
import org.springframework.data.mongodb.core.aggregation.Aggregation.ROOT
import org.springframework.data.mongodb.core.aggregation.Aggregation.group
import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
import org.springframework.data.mongodb.core.aggregation.Aggregation.match
import org.springframework.data.mongodb.core.aggregation.Aggregation.project
import org.springframework.data.mongodb.core.aggregation.Aggregation.sort
import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
import org.springframework.data.mongodb.core.aggregation.Aggregation.limit
import org.springframework.data.mongodb.core.aggregation.Aggregation.skip
import org.springframework.data.mongodb.core.aggregation.AggregationExpression
import org.springframework.data.mongodb.core.aggregation.AggregationResults
import org.springframework.data.mongodb.core.aggregation.ArrayOperators
import org.springframework.data.mongodb.core.aggregation.ArrayOperators.Filter.filter
import org.springframework.data.mongodb.core.aggregation.ConditionalOperators
import org.springframework.data.mongodb.core.aggregation.ConditionalOperators.IfNull.ifNull
import org.springframework.data.mongodb.core.aggregation.DateOperators
import org.springframework.data.mongodb.core.aggregation.DateOperators.DateToString
import org.springframework.data.mongodb.core.aggregation.LookupOperation
import org.springframework.data.mongodb.core.aggregation.MatchOperation
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.update
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.CategoryType
import space.luminic.budgerapp.models.SortSetting
import space.luminic.budgerapp.models.SortTypes
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionEvent
import space.luminic.budgerapp.models.TransactionEventType
import space.luminic.budgerapp.models.TransactionType
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.TransactionRepo
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
import java.util.ArrayList
import java.util.Arrays
import java.util.Date
import kotlin.jvm.optionals.getOrNull
@Service
class TransactionService(
private val mongoTemplate: MongoTemplate,
private val reactiveMongoTemplate: ReactiveMongoTemplate,
val transactionsRepo: TransactionRepo,
val userService: UserService,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(TransactionService::class.java)
@Cacheable("transactions")
fun getTransactions(
dateFrom: LocalDate? = null,
dateTo: LocalDate? = null,
transactionType: String? = null,
isDone: Boolean? = null,
categoryId: String? = null,
categoryType: String? = null,
userId: String? = null,
parentId: String? = null,
isChild: Boolean? = null,
sortSetting: SortSetting? = null,
limit: Int? = null,
offset: Int? = null,
): Mono<MutableList<Transaction>> {
val matchCriteria = mutableListOf<Criteria>()
// Добавляем фильтры
dateFrom?.let { matchCriteria.add(Criteria.where("date").gte(it)) }
dateTo?.let { matchCriteria.add(Criteria.where("date").lt(it)) }
transactionType?.let { matchCriteria.add(Criteria.where("type.code").`is`(it)) }
isDone?.let { matchCriteria.add(Criteria.where("isDone").`is`(it)) }
categoryId?.let { matchCriteria.add(Criteria.where("categoryDetails._id").`is`(it)) }
categoryType?.let { matchCriteria.add(Criteria.where("categoryDetails.type.code").`is`(it)) }
userId?.let { matchCriteria.add(Criteria.where("userDetails._id").`is`(ObjectId(it))) }
parentId?.let { matchCriteria.add(Criteria.where("parentId").`is`(it)) }
isChild?.let { matchCriteria.add(Criteria.where("parentId").exists(it)) }
// Сборка агрегации
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val lookupUsers = lookup("users", "user.\$id", "_id", "userDetails")
val match = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
var sort = sort(Sort.by(Direction.DESC, "date").and(Sort.by(Direction.DESC, "createdAt")))
sortSetting?.let {
sort = sort(Sort.by(it.order, it.by).and(Sort.by(Direction.ASC, "createdAt")))
}
val aggregationBuilder = mutableListOf(
lookup,
lookupUsers,
match.takeIf { matchCriteria.isNotEmpty() },
sort,
offset?.let { skip(it.toLong()) },
limit?.let { limit(it.toLong()) }
).filterNotNull()
val aggregation = newAggregation(aggregationBuilder)
return reactiveMongoTemplate.aggregate(
aggregation, "transactions", Transaction::class.java
)
.collectList() // Преобразуем Flux<Transaction> в Mono<List<Transaction>>
.map { it.toMutableList() }
}
fun getTransactionsToDelete(dateFrom: LocalDate, dateTo: LocalDate): Mono<List<Transaction>> {
val criteria = Criteria().andOperator(
Criteria.where("date").gte(dateFrom),
Criteria.where("date").lte(dateTo),
Criteria().orOperator(
Criteria.where("type.code").`is`("PLANNED"),
Criteria.where("parentId").exists(true)
)
)
// Пример использования в MongoTemplate:
val query = Query(criteria)
// Если вы хотите использовать ReactiveMongoTemplate:
return reactiveMongoTemplate.find(query, Transaction::class.java)
.collectList()
.doOnNext { transactions -> println("Found transactions: $transactions") }
}
@Cacheable("transactions")
fun getTransactionById(id: String): Mono<Transaction> {
return transactionsRepo.findById(id)
.map {
it
}
.switchIfEmpty(
Mono.error(IllegalArgumentException("Transaction with id: $id not found"))
)
}
@CacheEvict(cacheNames = ["transactions"], allEntries = true)
fun createTransaction(transaction: Transaction): Mono<String> {
return ReactiveSecurityContextHolder.getContext()
.map { it.authentication } // Получаем Authentication из SecurityContext
.flatMap { authentication ->
val username = authentication.name // Имя пользователя из токена
// Получаем пользователя и сохраняем транзакцию
userService.getByUsername(username)
.switchIfEmpty(Mono.error(IllegalArgumentException("User not found for username: $username")))
.flatMap { user ->
transaction.user = user
transactionsRepo.save(transaction)
.doOnNext { savedTransaction ->
// Публикуем событие после сохранения
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.CREATE,
newTransaction = savedTransaction,
oldTransaction = savedTransaction
)
)
}
.map { it.id!! } // Возвращаем ID сохраненной транзакции
}
}
}
@CacheEvict(cacheNames = ["transactions"], allEntries = true)
fun editTransaction(transaction: Transaction): Mono<Transaction> {
return transactionsRepo.findById(transaction.id!!)
.flatMap { oldStateOfTransaction ->
val changed = compareSumDateDoneIsChanged(oldStateOfTransaction, transaction)
if (!changed) {
return@flatMap transactionsRepo.save(transaction) // Сохраняем, если изменений нет
}
val amountDifference = transaction.amount - oldStateOfTransaction.amount
// Обработка дочерней транзакции
handleChildTransaction(oldStateOfTransaction, transaction, amountDifference)
.then(transactionsRepo.save(transaction)) // Сохраняем основную транзакцию
.doOnSuccess { savedTransaction ->
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.EDIT,
newTransaction = savedTransaction,
oldTransaction = oldStateOfTransaction,
difference = amountDifference
)
)
}
}
.switchIfEmpty(
Mono.error(IllegalArgumentException("Transaction not found with id: ${transaction.id}"))
)
}
private fun handleChildTransaction(
oldTransaction: Transaction,
newTransaction: Transaction,
amountDifference: Double
): Mono<Void> {
return transactionsRepo.findByParentId(newTransaction.id!!)
.flatMap { childTransaction ->
logger.info(childTransaction.toString())
// Если родительская транзакция обновлена, обновляем дочернюю
childTransaction.amount = newTransaction.amount
childTransaction.category = newTransaction.category
childTransaction.comment = newTransaction.comment
childTransaction.user = newTransaction.user
transactionsRepo.save(childTransaction)
}
.switchIfEmpty(
Mono.defer {
// Создание новой дочерней транзакции, если требуется
if (!oldTransaction.isDone && newTransaction.isDone) {
val newChildTransaction = newTransaction.copy(
id = null,
type = TransactionType("INSTANT", "Текущие"),
parentId = newTransaction.id
)
transactionsRepo.save(newChildTransaction).doOnSuccess { savedChildTransaction ->
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.CREATE,
newTransaction = savedChildTransaction,
oldTransaction = oldTransaction,
difference = amountDifference
)
)
}
} else Mono.empty()
}
)
.flatMap {
// Удаление дочерней транзакции, если родительская помечена как не выполненная
if (oldTransaction.isDone && !newTransaction.isDone) {
transactionsRepo.findByParentId(newTransaction.id!!)
.flatMap { child ->
deleteTransaction(child.id!!)
}.then()
} else {
Mono.empty()
}
}
}
fun compareSumDateDoneIsChanged(t1: Transaction, t2: Transaction): Boolean {
return if (t1.amount != t2.amount) {
true
} else if (t1.date != t2.date) {
true
} else if (t1.isDone != t2.isDone) {
true
} else if (t1.category.id != t2.category.id) {
return true
} else {
return false
}
}
@CacheEvict(cacheNames = ["transactions"], allEntries = true)
fun deleteTransaction(transactionId: String): Mono<Void> {
return transactionsRepo.findById(transactionId)
.flatMap { transactionToDelete ->
transactionsRepo.deleteById(transactionId) // Удаляем транзакцию
.then(
Mono.fromRunnable<Void> {
// Публикуем событие после успешного удаления
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.DELETE,
newTransaction = transactionToDelete,
oldTransaction = transactionToDelete
)
)
}
)
}
}
// @CacheEvict(cacheNames = ["transactions", "childTransactions"], allEntries = true)
// fun setTransactionDone(transaction: Transaction): Transaction {
// val oldStateTransaction = transactionsRepo.findById(transaction.id!!)
// .orElseThrow { RuntimeException("Transaction ${transaction.id} not found") }
//
// if (transaction.isDone) {
// if (oldStateTransaction.isDone) {
// throw RuntimeException("Transaction ${transaction.id} is already done")
// }
//
// // Создание дочерней транзакции
// val childTransaction = transaction.copy(
// id = null,
// type = TransactionType("INSTANT", "Текущие"),
// parentId = transaction.id
// )
// createTransaction(childTransaction)
// } else {
// // Удаление дочерней транзакции, если она существует
// transactionsRepo.findByParentId(transaction.id!!).getOrNull()?.let {
// deleteTransaction(it.id!!)
// } ?: logger.warn("Child transaction of parent ${transaction.id} not found")
// }
//
// return editTransaction(transaction)
// }
@Cacheable("childTransactions", key = "#parentId")
fun getChildTransaction(parentId: String): Mono<Transaction> {
return transactionsRepo.findByParentId(parentId)
}
// fun getTransactionByOldId(id: Int): Transaction? {
// return transactionsRepo.findByOldId(id).getOrNull()
// }
// fun transferTransactions(): Mono<Void> {
// var transactions = transactionsRepoSQl.getTransactions()
// return transactionsRepo.saveAll(transactions).then()
// }
//
fun calcTransactionsSum(
budget: Budget,
categoryId: String? = null,
categoryType: String? = null,
transactionType: String? = null,
isDone: Boolean? = null
): Mono<Double> {
val matchCriteria = mutableListOf<Criteria>()
// Добавляем фильтры
matchCriteria.add(Criteria.where("date").gte(budget.dateFrom))
matchCriteria.add(Criteria.where("date").lt(budget.dateTo))
categoryId?.let { matchCriteria.add(Criteria.where("category.\$id").`is`(ObjectId(it))) }
categoryType?.let { matchCriteria.add(Criteria.where("categoryDetails.type.code").`is`(it)) }
transactionType?.let { matchCriteria.add(Criteria.where("type.code").`is`(it)) }
isDone?.let { matchCriteria.add(Criteria.where("isDone").`is`(it)) }
// Сборка агрегации
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val unwind = unwind("categoryDetails")
val match = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
val project = project("category").andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val group = group(categoryId ?: "all").sum("amount").`as`("totalSum")
val projectSum = project("totalSum")
val aggregation = newAggregation(lookup, unwind, match, project, group, projectSum)
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
.map { result ->
val totalSum = result["totalSum"]
if (totalSum is Double) {
totalSum
} else {
0.0
}
}
.reduce(0.0) { acc, sum -> acc + sum } // Суммируем значения, если несколько результатов
}
// @Cacheable("transactions")
fun getAverageSpendingByCategory(): Mono<Map<String, Double>> {
val firstDateOfMonth = LocalDate.now().with(TemporalAdjusters.firstDayOfMonth())
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val unwind = unwind("categoryDetails")
val match = match(
Criteria.where("categoryDetails.type.code").`is`("EXPENSE")
.and("type.code").`is`("INSTANT")
.and("date").lt(firstDateOfMonth)
)
val projectDate = project("_id", "category", "amount", "categoryDetails")
.and(DateToString.dateOf("date").toString("%Y-%m")).`as`("month")
.andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val groupByMonthAndCategory = group("month", "category.\$id").sum("amount").`as`("sum")
val groupByCategory = group("_id.id").avg("sum").`as`("averageAmount")
val project = project()
.and("_id").`as`("category")
.and("averageAmount").`as`("avgAmount")
val sort = sort(Sort.by(Sort.Order.asc("_id")))
val aggregation = newAggregation(
lookup, unwind, match, projectDate, groupByMonthAndCategory, groupByCategory, project, sort
)
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
.collectList()
.map { results ->
results.associate { result ->
val category = result["category"]?.toString() ?: "Unknown"
val avgAmount = (result["avgAmount"] as? Double) ?: 0.0
category to avgAmount
}
}
.defaultIfEmpty(emptyMap()) // Возвращаем пустую карту, если результатов нет
}
@Cacheable("transactionTypes")
fun getTransactionTypes(): List<TransactionType> {
var types = mutableListOf<TransactionType>()
types.add(TransactionType("PLANNED", "Плановые"))
types.add(TransactionType("INSTANT", "Текущие"))
return types
}
fun getAverageIncome(): Mono<Double> {
val lookup = lookup("categories", "category.\$id", "_id", "detailedCategory")
val unwind = unwind("detailedCategory")
val match = match(
Criteria.where("detailedCategory.type.code").`is`("INCOME")
.and("type.code").`is`("INSTANT")
.and("isDone").`is`(true)
)
val project = project("_id", "category", "detailedCategory")
.and(DateToString.dateOf("date").toString("%Y-%m")).`as`("month")
.andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val groupByMonth = group("month").sum("amount").`as`("sum")
val groupForAverage = group("avgIncomeByMonth").avg("sum").`as`("averageAmount")
val aggregation = newAggregation(lookup, unwind, match, project, groupByMonth, groupForAverage)
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
.singleOrEmpty() // Ожидаем только один результат
.map { result ->
result["averageAmount"] as? Double ?: 0.0
}
.defaultIfEmpty(0.0) // Если результат пустой, возвращаем 0.0
}
fun getTransactionsByTypes(dateFrom: LocalDate, dateTo: LocalDate): Mono<Map<String, List<Transaction>>> {
logger.info("here tran starts")
val pipeline = listOf(
Document(
"\$lookup",
Document("from", "categories")
.append("localField", "category.\$id")
.append("foreignField", "_id")
.append("as", "categoryDetailed")
),
Document(
"\$lookup",
Document("from", "users")
.append("localField", "user.\$id")
.append("foreignField", "_id")
.append("as", "userDetailed")
),
Document(
"\$unwind",
Document("path", "\$categoryDetailed").append("preserveNullAndEmptyArrays", true)
),
Document(
"\$unwind",
Document("path", "\$userDetailed").append("preserveNullAndEmptyArrays", true)
),
Document(
"\$match",
Document(
"\$and", listOf(
Document(
"date",
Document(
"\$gte",
Date.from(
LocalDateTime.of(dateFrom, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC)
.toInstant()
)
)
),
Document(
"date",
Document(
"\$lt",
Date.from(
LocalDateTime.of(dateTo, LocalTime.MAX)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC)
.toInstant()
)
)
)
)
)
),
Document(
"\$facet",
Document(
"plannedExpenses",
listOf(
Document(
"\$match",
Document("type.code", "PLANNED")
.append("categoryDetailed.type.code", "EXPENSE")
),
Document("\$sort", Document("date", 1).append("_id", 1))
)
)
.append(
"plannedIncomes",
listOf(
Document(
"\$match",
Document("type.code", "PLANNED")
.append("categoryDetailed.type.code", "INCOME")
),
Document("\$sort", Document("date", 1).append("_id", 1))
)
)
.append(
"instantTransactions",
listOf(
Document("\$match", Document("type.code", "INSTANT")),
Document("\$sort", Document("date", 1).append("_id", 1))
)
)
)
)
// getCategoriesExplainReactive(pipeline)
// .doOnNext { explainResult ->
// logger.info("Explain Result: ${explainResult.toJson()}")
// }
// .subscribe() // Этот вызов лучше оставить только для отладки
return reactiveMongoTemplate.getCollection("transactions")
.flatMapMany { it.aggregate(pipeline, Document::class.java) }
.single() // Получаем только первый результат агрегации
.flatMap { aggregationResult ->
Mono.zip(
extractTransactions(aggregationResult, "plannedExpenses"),
extractTransactions(aggregationResult, "plannedIncomes"),
extractTransactions(aggregationResult, "instantTransactions")
).map { tuple ->
val plannedExpenses = tuple.t1
val plannedIncomes = tuple.t2
val instantTransactions = tuple.t3
logger.info("here tran ends")
mapOf(
"plannedExpenses" to plannedExpenses,
"plannedIncomes" to plannedIncomes,
"instantTransactions" to instantTransactions
)
}
}
}
fun getCategoriesExplainReactive(pipeline: List<Document>): Mono<Document> {
val command = Document("aggregate", "transactions")
.append("pipeline", pipeline)
.append("explain", true)
return reactiveMongoTemplate.executeCommand(command)
}
private fun extractTransactions(aggregationResult: Document, key: String): Mono<List<Transaction>> {
val resultTransactions = aggregationResult[key] as? List<Document> ?: emptyList()
return Flux.fromIterable(resultTransactions)
.map { documentToTransactionMapper(it) }
.collectList()
}
private fun documentToTransactionMapper(document: Document): Transaction {
val transactionType = document["type"] as Document
var user: User? = null
val userDocument = document["userDetailed"] as Document
user = User(
id = (userDocument["_id"] as ObjectId).toString(),
username = userDocument["username"] as String,
firstName = userDocument["firstName"] as String,
tgId = userDocument["tgId"] as String,
tgUserName = userDocument["tgUserName"]?.let { it as String },
password = null,
isActive = userDocument["isActive"] as Boolean,
regDate = userDocument["regDate"] as Date,
createdAt = userDocument["createdAt"] as Date,
roles = userDocument["roles"] as ArrayList<String>,
)
val categoryDocument = document["categoryDetailed"] as Document
val categoryTypeDocument = categoryDocument["type"] as Document
val category = Category(
id = (categoryDocument["_id"] as ObjectId).toString(),
type = CategoryType(categoryTypeDocument["code"] as String, categoryTypeDocument["name"] as String),
name = categoryDocument["name"] as String,
description = categoryDocument["description"] as String,
icon = categoryDocument["icon"] as String
)
return Transaction(
(document["_id"] as ObjectId).toString(),
TransactionType(
transactionType["code"] as String,
transactionType["name"] as String
),
user = user!!,
category = category,
comment = document["comment"] as String,
date = (document["date"] as Date).toInstant().atZone(ZoneId.systemDefault()).toLocalDate(),
amount = document["amount"] as Double,
isDone = document["isDone"] as Boolean,
parentId = if (document["parentId"] != null) document["parentId"] as String else null,
createdAt = LocalDateTime.ofInstant((document["createdAt"] as Date).toInstant(), ZoneOffset.UTC),
)
}
// fun getPlannedForBudget(budget: Budget, transactionType: String? = null): List<Map<String, Any>> {
// // 1) $lookup: "categories"
// val lookupCategories = Aggregation.lookup(
// "categories",
// "category.\$id",
// "_id",
// "categoryDetailed"
// )
//
// // 2) $lookup: "budgets" (pipeline + let)
// val matchBudgetsDoc = Document(
// "\$expr", Document(
// "\$and", listOf(
// Document("\$gte", listOf("\$\$transactionDate", "\$dateFrom")),
// Document("\$lt", listOf("\$\$transactionDate", "\$dateTo"))
// )
// )
// )
// val matchBudgetsOp = MatchOperation(matchBudgetsDoc)
//
// val lookupBudgets = LookupOperation.newLookup()
// .from("budgets")
// .letValueOf("transactionDate").bindTo("date")
// .pipeline(matchBudgetsOp)
// .`as`("budgetDetails")
//
// // 3) $unwind
// val unwindCategory = Aggregation.unwind("categoryDetailed")
// val unwindBudget = Aggregation.unwind("budgetDetails")
//
// // 4) $match: диапазон дат
// val matchDates = Aggregation.match(
// Criteria("date")
// .gte(budget.dateFrom)
// .lt(budget.dateTo)
// )
//
// // 5) $facet (разные ветки: plannedExpenses, plannedExpensesSum, ...)
// // plannedExpenses
// val plannedExpensesMatch = Aggregation.match(
// Criteria().andOperator(
// Criteria("type.code").`is`("PLANNED"),
// Criteria("categoryDetailed.type.code").`is`("EXPENSE")
// )
// )
// val plannedExpensesPipeline = listOf(plannedExpensesMatch)
//
// // plannedExpensesSum
// val plannedExpensesSumPipeline = listOf(
// plannedExpensesMatch,
// group(null).`as`("_id").sum("amount").`as`("sum"),
// project("sum").andExclude("_id")
// )
//
// // plannedIncome
// val plannedIncomeMatch = Aggregation.match(
// Criteria().andOperator(
// Criteria("type.code").`is`("PLANNED"),
// Criteria("categoryDetailed.type.code").`is`("INCOME")
// )
// )
// val plannedIncomePipeline = listOf(plannedIncomeMatch)
//
// // plannedIncomeSum
// val plannedIncomeSumPipeline = listOf(
// plannedIncomeMatch,
// group().`as`("_id").sum("amount").`as`("sum"),
// project("sum").andExclude("_id")
// )
//
// // instantTransactions
// val instantTransactionsMatch = Aggregation.match(
// Criteria("type.code").`is`("INSTANT")
// )
// val instantTransactionsProject = Aggregation.project(
// "_id", "type", "comment", "user", "amount", "date",
// "category", "isDone", "createdAt", "parentId"
// )
// val instantTransactionsPipeline = listOf(instantTransactionsMatch, instantTransactionsProject)
//
// val facetStage = Aggregation.facet(*plannedExpensesPipeline.toTypedArray()).`as`("plannedExpenses")
// .and(*plannedExpensesSumPipeline.toTypedArray()).`as`("plannedExpensesSum")
// .and(*plannedIncomePipeline.toTypedArray()).`as`("plannedIncome")
// .and(*plannedIncomeSumPipeline.toTypedArray()).`as`("plannedIncomeSum")
// .and(*instantTransactionsPipeline.toTypedArray()).`as`("instantTransactions")
//
// // 6) $set: вытаскиваем суммы из массивов
// val setStage = AddFieldsOperation.builder()
// .addField("plannedExpensesSum").withValue(
// ArrayOperators.ArrayElemAt.arrayOf("\$plannedExpensesSum.sum").elementAt(0)
// )
// .addField("plannedIncomeSum").withValue(
// ArrayOperators.ArrayElemAt.arrayOf("\$plannedIncomeSum.sum").elementAt(0)
// )
// .build()
//
// // Собираем все стадии
// val aggregation = Aggregation.newAggregation(
// lookupCategories,
// lookupBudgets,
// unwindCategory,
// unwindBudget,
// matchDates,
// facetStage,
// setStage
// )
//
// val results = mongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
// return results.mappedResults
// }
}

View File

@@ -0,0 +1,50 @@
package space.luminic.budgerapp.services
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.repos.BudgetRepo
import space.luminic.budgerapp.repos.CategoryRepo
import space.luminic.budgerapp.repos.TransactionRepo
import space.luminic.budgerapp.repos.sqlrepo.BudgetRepoSQL
import space.luminic.budgerapp.repos.sqlrepo.CategoriesRepoSQL
import space.luminic.budgerapp.repos.sqlrepo.TransactionsRepoSQl
@Service
class TransferService(
private val transactionsRepoSQl: TransactionsRepoSQl,
private val categoriesRepoSQL: CategoriesRepoSQL,
private val budgetRepoSQL: BudgetRepoSQL,
private val categoryRepo: CategoryRepo,
private val transactionRepo: TransactionRepo,
private val budgetService: BudgetService
) {
fun getTransactions(): Mono<List<Transaction>> {
val transactions = transactionsRepoSQl.getTransactions()
return transactionRepo.saveAll(transactions).collectList()
}
fun getCategories(): Mono<List<Category>> {
val categories = categoriesRepoSQL.getCategories()
return Flux.fromIterable(categories)
.flatMap { category -> categoryRepo.save(category) }
.collectList() // Преобразуем Flux<Category> в Mono<List<Category>>
}
fun transferBudgets(): Mono<List<Budget>> {
val budgets = budgetRepoSQL.getBudgets()
return Flux.fromIterable(budgets)
.flatMap { budget ->
budgetService.createBudget(budget.budget, budget.createRecurrent)
}.collectList()
}
}

View File

@@ -0,0 +1,57 @@
package space.luminic.budgerapp.services
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.Cacheable
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.UserRepo
import kotlin.jvm.optionals.getOrNull
@Service
class UserService(val userRepo: UserRepo, val passwordEncoder: PasswordEncoder) {
val logger = LoggerFactory.getLogger(javaClass)
// fun regenPass(): List<User>? {
// var users = getUsers()!!.toMutableList()
// for (user in users) {
// user.password = passwordEncoder.encode(user.password)
// }
// userRepo.saveAll<User>(users)
// return users
// }
@Cacheable("users", key = "#username")
fun getByUsername(username: String): Mono<User> {
return userRepo.findByUsernameWOPassword(username).switchIfEmpty(
Mono.error(NotFoundException("User with username: $username not found"))
)
}
fun getById(id: String): Mono<User> {
return userRepo.findById(id)
.map { user ->
user.apply { password = null } // Убираем пароль
}
.switchIfEmpty(Mono.error(Exception("User not found"))) // Обрабатываем случай, когда пользователь не найден
}
@Cacheable("users", key = "#username")
fun getByUserNameWoPass(username: String): Mono<User> {
return userRepo.findByUsernameWOPassword(username)
}
fun getUsers(): Mono<List<User>> {
return userRepo.findAll()
.map { user -> user.apply { password = null } } // Убираем пароль
.collectList() // Преобразуем Flux<User> в Mono<List<User>>
.doOnNext { logger.debug("Users fetched successfully: ${it.size} users found") }
}
}