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,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
// }
}