1923 lines
89 KiB
Kotlin
1923 lines
89 KiB
Kotlin
package space.luminic.budgerapp.services
|
||
|
||
import kotlinx.coroutines.*
|
||
import kotlinx.coroutines.flow.map
|
||
import kotlinx.coroutines.flow.toList
|
||
import kotlinx.coroutines.reactive.asFlow
|
||
import kotlinx.coroutines.reactive.awaitFirstOrNull
|
||
import kotlinx.coroutines.reactive.awaitSingle
|
||
import kotlinx.coroutines.reactor.awaitSingle
|
||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||
import org.bson.BsonNull
|
||
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.data.domain.Sort
|
||
import org.springframework.data.domain.Sort.Direction
|
||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
|
||
import org.springframework.data.mongodb.core.aggregation.Aggregation.*
|
||
import org.springframework.data.mongodb.core.aggregation.DateOperators.DateToString
|
||
import org.springframework.data.mongodb.core.query.Criteria
|
||
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.mappers.BudgetMapper
|
||
import space.luminic.budgerapp.mappers.TransactionsMapper
|
||
import space.luminic.budgerapp.models.*
|
||
import space.luminic.budgerapp.repos.BudgetRepo
|
||
import space.luminic.budgerapp.repos.CategoryRepo
|
||
import space.luminic.budgerapp.repos.TransactionRepo
|
||
import space.luminic.budgerapp.repos.WarnRepo
|
||
import java.time.*
|
||
import java.time.format.DateTimeFormatter
|
||
import java.time.temporal.TemporalAdjusters
|
||
import java.util.*
|
||
|
||
@Service
|
||
class FinancialService(
|
||
val budgetRepo: BudgetRepo,
|
||
val warnRepo: WarnRepo,
|
||
val transactionsRepo: TransactionRepo,
|
||
val recurrentService: RecurrentService,
|
||
val userService: UserService,
|
||
val reactiveMongoTemplate: ReactiveMongoTemplate,
|
||
private val categoryRepo: CategoryRepo,
|
||
val transactionsMapper: TransactionsMapper,
|
||
val budgetMapper: BudgetMapper,
|
||
private val subscriptionService: SubscriptionService,
|
||
private val nlpService: NLPService
|
||
) {
|
||
private val logger = LoggerFactory.getLogger(FinancialService::class.java)
|
||
|
||
|
||
suspend fun updateBudgetOnCreate(transaction: Transaction) {
|
||
val budget = findProjectedBudget(
|
||
transaction.space!!.id!!, budgetId = null, transaction.date, transaction.date
|
||
)
|
||
if (transaction.category.type.code == "EXPENSE") {
|
||
var budgetCategory = budget.categories.firstOrNull { it.category.id == transaction.category.id }
|
||
|
||
if (budgetCategory == null) {
|
||
budgetCategory = BudgetCategory(0.0, 0.0, 0.0, transaction.category)
|
||
budget.categories.add(budgetCategory)
|
||
budgetRepo.save(budget).awaitSingle()
|
||
|
||
}
|
||
if (transaction.category.type.code == "INCOME") {
|
||
budgetRepo.save(budget).awaitSingle()
|
||
}
|
||
val categorySums = getBudgetSumsByCategory(transaction.category.id!!, budget)
|
||
budgetCategory.currentPlanned = categorySums.getDouble("plannedAmount")
|
||
budgetCategory.currentSpent = categorySums.getDouble("instantAmount")
|
||
// При совпадении бюджетов разница просто корректирует лимит
|
||
if (transaction.type.code == "PLANNED") {
|
||
budgetCategory.currentLimit += transaction.amount
|
||
}
|
||
logger.info("updateBudgetOnCreate end")
|
||
budgetRepo.save(budget).awaitSingle()
|
||
}
|
||
}
|
||
|
||
|
||
suspend fun updateBudgetOnEdit(
|
||
oldTransaction: Transaction, newTransaction: Transaction, difference: Double
|
||
) = coroutineScope {
|
||
logger.info("updateBudgetOnEdit start")
|
||
|
||
val oldBudgetDeffer = async {
|
||
findProjectedBudget(
|
||
newTransaction.space?.id!!, budgetId = null, oldTransaction.date, oldTransaction.date
|
||
)
|
||
}
|
||
val newBudgetDeffer = async {
|
||
findProjectedBudget(
|
||
newTransaction.space?.id!!, budgetId = null, newTransaction.date, newTransaction.date
|
||
)
|
||
}
|
||
val (oldBudget, newBudget) = awaitAll(oldBudgetDeffer, newBudgetDeffer)
|
||
|
||
val isSameBudget = oldBudget.id == newBudget.id
|
||
|
||
if (isSameBudget) {
|
||
updateSameBudget(oldTransaction, newTransaction, difference, newBudget)
|
||
} else {
|
||
updateDifferentBudgets(oldTransaction, newTransaction, difference, oldBudget, newBudget)
|
||
}
|
||
}
|
||
|
||
|
||
private suspend fun updateSameBudget(
|
||
oldTransaction: Transaction, newTransaction: Transaction, difference: Double, budget: Budget
|
||
) = coroutineScope {
|
||
val oldCategory = findBudgetCategory(oldTransaction, budget)
|
||
val newCategory = findBudgetCategory(newTransaction, budget)
|
||
if (oldCategory.category.id == newCategory.category.id) {
|
||
updateBudgetCategory(newTransaction, budget, difference)
|
||
|
||
} else {
|
||
logger.info("hui" + oldTransaction.category.id + " " + (-oldTransaction.amount).toString())
|
||
val updateOldBudgetCategoryDeffer =
|
||
async {
|
||
updateBudgetCategory(
|
||
oldTransaction,
|
||
budget,
|
||
-difference,
|
||
isCategoryChanged = true,
|
||
isOldCategory = true
|
||
)
|
||
}
|
||
val updateNewBudgetCategoryDeffer =
|
||
async {
|
||
updateBudgetCategory(
|
||
newTransaction,
|
||
budget,
|
||
difference,
|
||
true
|
||
)
|
||
}
|
||
val (updateOldBudgetCategory, updateNewBudgetCategory) = awaitAll(
|
||
updateNewBudgetCategoryDeffer,
|
||
updateOldBudgetCategoryDeffer
|
||
)
|
||
logger.info(updateOldBudgetCategory.toString())
|
||
logger.info(updateNewBudgetCategory.toString())
|
||
}
|
||
}
|
||
|
||
|
||
private suspend fun updateDifferentBudgets(
|
||
oldTransaction: Transaction,
|
||
newTransaction: Transaction,
|
||
difference: Double,
|
||
oldBudget: Budget,
|
||
newBudget: Budget
|
||
) = coroutineScope {
|
||
// val oldCategory = findBudgetCategory(oldTransaction, oldBudget)
|
||
// val newCategory = findBudgetCategory(newTransaction, newBudget)
|
||
|
||
|
||
async {
|
||
updateBudgetCategory(
|
||
oldTransaction,
|
||
oldBudget,
|
||
-difference,
|
||
isCategoryChanged = true,
|
||
isOldCategory = true,
|
||
isNewBudget = true
|
||
)
|
||
updateBudgetCategory(newTransaction, newBudget, difference, isNewBudget = true)
|
||
}
|
||
|
||
}
|
||
|
||
private suspend fun findBudgetCategory(transaction: Transaction, budget: Budget): BudgetCategory {
|
||
return if (transaction.category.type.code == "EXPENSE") {
|
||
budget.categories.firstOrNull { it.category.id == transaction.category.id }
|
||
?: addCategoryToBudget(transaction.category, budget)
|
||
|
||
} else {
|
||
budget.incomeCategories.firstOrNull { it.category.id == transaction.category.id }
|
||
?: addCategoryToBudget(transaction.category, budget)
|
||
}
|
||
}
|
||
|
||
private suspend fun addCategoryToBudget(category: Category, budget: Budget): BudgetCategory {
|
||
val sums = getBudgetSumsByCategory(category.id!!, budget)
|
||
val categoryBudget = BudgetCategory(
|
||
currentSpent = sums.getDouble("instantAmount"),
|
||
currentPlanned = sums.getDouble("plannedAmount"),
|
||
currentLimit = sums.getDouble("plannedAmount"),
|
||
category = category
|
||
)
|
||
if (category.type.code == "EXPENSE") {
|
||
budget.categories.add(categoryBudget)
|
||
} else {
|
||
budget.incomeCategories.add(categoryBudget)
|
||
}
|
||
budgetRepo.save(budget).awaitSingle()
|
||
return categoryBudget
|
||
}
|
||
|
||
private suspend fun updateBudgetCategory(
|
||
transaction: Transaction,
|
||
budget: Budget,
|
||
difference: Double,
|
||
isCategoryChanged: Boolean = false,
|
||
isOldCategory: Boolean = false,
|
||
isNewBudget: Boolean = false
|
||
): Double {
|
||
return if (transaction.category.type.code == "EXPENSE") {
|
||
val sums = getBudgetSumsByCategory(transaction.category.id!!, budget)
|
||
val categoryBudget = budget.categories.firstOrNull { it.category.id == transaction.category.id }
|
||
?: throw NotFoundException("Not found category in budget")
|
||
categoryBudget.currentPlanned = sums.getDouble("plannedAmount")
|
||
categoryBudget.currentSpent = sums.getDouble("instantAmount")
|
||
if (transaction.type.code == "PLANNED") {
|
||
if (isCategoryChanged) {
|
||
if (isOldCategory) {
|
||
categoryBudget.currentLimit -= transaction.amount
|
||
} else categoryBudget.currentLimit += transaction.amount
|
||
} else if (isNewBudget) {
|
||
categoryBudget.currentLimit += transaction.amount
|
||
} else categoryBudget.currentLimit += difference
|
||
}
|
||
budgetRepo.save(budget).awaitSingle()
|
||
1.0
|
||
} else 0.0
|
||
}
|
||
|
||
|
||
suspend fun updateBudgetOnDelete(transaction: Transaction) {
|
||
// Найдем бюджет по дате
|
||
val budget = findProjectedBudget(transaction.space!!.id!!, budgetId = null, transaction.date, transaction.date)
|
||
|
||
|
||
// Получаем категории бюджета
|
||
val budgetCategories =
|
||
getBudgetCategories(transaction.space?.id!!, budget.dateFrom, budget.dateTo)
|
||
?: throw NotFoundException("Budget category not found in the budget")
|
||
|
||
// Обрабатываем категории в зависимости от типа транзакции
|
||
val updatedCategories = when (transaction.type.code) {
|
||
"PLANNED" -> budget.categories.map { category ->
|
||
if (category.category.id == transaction.category.id) {
|
||
budgetCategories[category.category.id]?.let { data ->
|
||
category.currentSpent = data["instantAmount"] ?: 0.0
|
||
category.currentPlanned = data["plannedAmount"] ?: 0.0
|
||
category.currentLimit -= transaction.amount // Корректируем лимит по расходу
|
||
}
|
||
}
|
||
category // Возвращаем обновленную категорию
|
||
}
|
||
|
||
"INSTANT" -> budget.categories.map { category ->
|
||
if (category.category.id == transaction.category.id) {
|
||
budgetCategories[category.category.id]?.let { data ->
|
||
category.currentSpent = data["instantAmount"] ?: 0.0
|
||
category.currentPlanned = data["plannedAmount"] ?: 0.0
|
||
}
|
||
}
|
||
category // Возвращаем обновленную категорию
|
||
}
|
||
|
||
else -> budget.categories
|
||
}
|
||
|
||
// Сохраняем обновленные категории в бюджете
|
||
budget.categories = updatedCategories.toMutableList() // Обновляем категории
|
||
budgetRepo.save(budget).awaitSingle()
|
||
}
|
||
|
||
|
||
fun getBudgets(spaceId: String, sortSetting: SortSetting? = null): Mono<List<Budget>> {
|
||
val sort = sortSetting?.let {
|
||
Sort.by(it.order, it.by)
|
||
} ?: Sort.by(Direction.DESC, "dateFrom")
|
||
|
||
return findProjectedBudgets(ObjectId(spaceId), sortRequested = sort)
|
||
}
|
||
|
||
|
||
fun findProjectedBudgets(
|
||
spaceId: ObjectId,
|
||
projectKeys: Array<String> = arrayOf("_id", "name", "dateFrom", "dateTo"),
|
||
sortRequested: Sort? = null
|
||
): Mono<List<Budget>> {
|
||
val lookupCategories = lookup("categories", "categories.category.\$id", "_id", "categoriesDetails")
|
||
|
||
|
||
val lookupIncomeCategories =
|
||
lookup("categories", "incomeCategories.category.\$id", "_id", "incomeCategoriesDetails")
|
||
|
||
val lookupSpace = lookup("spaces", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpace = unwind("spaceDetails")
|
||
val matchStage = match(Criteria.where("spaceDetails._id").`is`(spaceId))
|
||
// matchCriteria.add(Criteria.where("spaceDetails._id").`is`(ObjectId(spaceId)))
|
||
val projectStage = project(*projectKeys) // Оставляем только нужные поля
|
||
val sort = sortRequested?.let { sort(it) } ?: sort(
|
||
Sort.by(Direction.DESC, "date").and(Sort.by(Direction.DESC, "createdAt"))
|
||
)
|
||
val aggregation =
|
||
newAggregation(
|
||
lookupCategories,
|
||
lookupIncomeCategories,
|
||
lookupSpace,
|
||
unwindSpace,
|
||
matchStage,
|
||
projectStage,
|
||
sort
|
||
)
|
||
|
||
return reactiveMongoTemplate.aggregate(aggregation, "budgets", Document::class.java).collectList().map { docs ->
|
||
docs.map { doc ->
|
||
budgetMapper.fromDocument(doc)
|
||
}.toMutableList()
|
||
}
|
||
}
|
||
|
||
suspend fun findProjectedBudget(
|
||
spaceId: String, budgetId: String? = null, dateFrom: LocalDate? = null, dateTo: LocalDate? = null
|
||
): Budget {
|
||
val lookupCategories = lookup("categories", "categories.category.\$id", "_id", "categoriesDetails")
|
||
// val unwindCategories = unwind("categoriesDetails")
|
||
|
||
val lookupIncomeCategories =
|
||
lookup("categories", "incomeCategories.category.\$id", "_id", "incomeCategoriesDetails")
|
||
// val unwindIncomeCategories = unwind("incomeCategoriesDetails")
|
||
val lookupSpace = lookup("spaces", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpace = unwind("spaceDetails")
|
||
val matchCriteria = mutableListOf<Criteria>()
|
||
budgetId?.let { matchCriteria.add(Criteria.where("_id").`is`(ObjectId(it))) }
|
||
dateFrom?.let { matchCriteria.add(Criteria.where("dateFrom").lte(dateTo!!)) }
|
||
dateTo?.let { matchCriteria.add(Criteria.where("dateTo").gte(it)) }
|
||
matchCriteria.add(Criteria.where("spaceDetails._id").`is`(ObjectId(spaceId)))
|
||
val matchStage = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
||
val aggregation = newAggregation(lookupCategories, lookupIncomeCategories, lookupSpace, unwindSpace, matchStage)
|
||
|
||
return reactiveMongoTemplate.aggregate(aggregation, "budgets", Document::class.java).next().map { doc ->
|
||
budgetMapper.fromDocument(doc)
|
||
}.awaitSingleOrNull() ?: throw NotFoundException("Budget not found")
|
||
}
|
||
|
||
|
||
// @Cacheable("budgets", key = "#id")
|
||
suspend fun getBudget(spaceId: String, id: String): BudgetDTO = coroutineScope {
|
||
val budget = findProjectedBudget(spaceId, id)
|
||
|
||
// Если доступ есть, продолжаем процесс
|
||
val budgetDTO = BudgetDTO(
|
||
budget.id,
|
||
budget.space,
|
||
budget.name,
|
||
budget.dateFrom,
|
||
budget.dateTo,
|
||
budget.createdAt,
|
||
categories = budget.categories,
|
||
incomeCategories = budget.incomeCategories,
|
||
)
|
||
logger.info("Fetching categories and transactions")
|
||
|
||
val categoriesDeffer = async { getBudgetCategories(spaceId, budgetDTO.dateFrom, budgetDTO.dateTo) }
|
||
val transactionsDeffer = async { getTransactionsByTypes(spaceId, budgetDTO.dateFrom, budgetDTO.dateTo) }
|
||
|
||
val categories = categoriesDeffer.await() ?: throw NotFoundException("Categories in null")
|
||
val transactions = transactionsDeffer.await() ?: throw NotFoundException("Categories in null")
|
||
val updatedCategories = budgetDTO.categories.map { category ->
|
||
categories.get(category.category.id)?.let { data ->
|
||
category.currentSpent = data["instantAmount"] ?: 0.0
|
||
category.currentPlanned = data["plannedAmount"] ?: 0.0
|
||
}
|
||
category // Возвращаем обновленную категорию
|
||
}
|
||
budgetDTO.categories = updatedCategories.toMutableList()
|
||
budgetDTO.plannedExpenses = transactions.get("plannedExpenses") as MutableList
|
||
budgetDTO.plannedIncomes = transactions["plannedIncomes"] as MutableList
|
||
budgetDTO.transactions = transactions["instantTransactions"] as MutableList
|
||
logger.info("got budget for spaceId=$spaceId, id=$id")
|
||
|
||
budgetDTO
|
||
}
|
||
|
||
|
||
// fun regenBudgets(): Mono<Void> {
|
||
// return budgetRepo.findAll()
|
||
// .flatMap { budget ->
|
||
// spaceService.getSpace("67af3c0f652da946a7dd9931")
|
||
// .map { space ->
|
||
// budget.space = space
|
||
// budget
|
||
// }
|
||
// .flatMap { updatedBudget -> budgetRepo.save(updatedBudget) }
|
||
// }
|
||
// .then()
|
||
// }
|
||
|
||
// fun regenTransactions(): Mono<Void> {
|
||
// return transactionsRepo.findAll().flatMap { transaction ->
|
||
// spaceService.getSpace("67af3c0f652da946a7dd9931")
|
||
// .map { space ->
|
||
// transaction.space = space
|
||
// transaction
|
||
// }
|
||
// .flatMap { updatedTransaction -> transactionsRepo.save(updatedTransaction) }
|
||
// }
|
||
// .then()
|
||
// }
|
||
|
||
|
||
fun regenCats(): Mono<Void> {
|
||
return categoryRepo.findBySpaceId(ObjectId("67b352b13384483a1c2282ed")).flatMap { cat ->
|
||
// if (cat.space?.id == "67b352b13384483a1c2282ed") {
|
||
categoryRepo.deleteById(cat.id!!) // Возвращаем `Mono<Void>`
|
||
// } else {
|
||
// Mono.empty() // Если не удаляем, возвращаем пустой `Mono`
|
||
// }
|
||
}.then() // Убедимся, что все операции завершены
|
||
}
|
||
|
||
fun createCategory(space: Space, category: Category): Mono<Category> {
|
||
category.space = space
|
||
return categoryRepo.save(category)
|
||
.flatMap { savedCategory ->
|
||
findProjectedBudgets(
|
||
ObjectId(space.id), projectKeys = arrayOf(
|
||
"_id",
|
||
"name",
|
||
"dateFrom",
|
||
"dateTo",
|
||
"space",
|
||
"spaceDetails",
|
||
"categories",
|
||
"categoriesDetails",
|
||
"incomeCategories",
|
||
"incomeCategoriesDetails"
|
||
)
|
||
)
|
||
.flatMapMany { Flux.fromIterable(it) } // Преобразуем List<Budget> в Flux<Budget>
|
||
.flatMap { budget ->
|
||
when (savedCategory.type.code) {
|
||
"INCOME" -> budget.incomeCategories.add(BudgetCategory(0.0, 0.0, 0.0, savedCategory))
|
||
"EXPENSE" -> budget.categories.add(BudgetCategory(0.0, 0.0, 0.0, savedCategory))
|
||
}
|
||
budgetRepo.save(budget) // Сохраняем каждый обновленный бюджет
|
||
}
|
||
.then(Mono.just(savedCategory)) // Возвращаем сохраненную категорию после обработки всех бюджетов
|
||
}
|
||
}
|
||
|
||
|
||
suspend fun createBudget(space: Space, budget: Budget, createRecurrent: Boolean): Budget = coroutineScope {
|
||
val startBudgetDeferred = async { getBudgetByDate(budget.dateFrom, space.id!!) }
|
||
val endBudgetDeferred = async { getBudgetByDate(budget.dateTo, space.id!!) }
|
||
|
||
val (startBudget, endBudget) = awaitAll(startBudgetDeferred, endBudgetDeferred)
|
||
|
||
if (startBudget != null || endBudget != null) {
|
||
throw IllegalArgumentException("Бюджет с теми же датами найден")
|
||
}
|
||
budget.space = space
|
||
|
||
if (createRecurrent) {
|
||
recurrentService.createRecurrentsForBudget(space, budget)
|
||
}
|
||
val categoriesDeferred =
|
||
async { getCategoryTransactionPipeline(space.id!!, budget.dateFrom, budget.dateTo) }
|
||
val incomeCategoriesDeferred =
|
||
async { getCategoryTransactionPipeline(space.id!!, budget.dateFrom, budget.dateTo, "INCOME") }
|
||
budget.categories = categoriesDeferred.await().toMutableList()
|
||
val savedBudget = budgetRepo.save(budget).awaitSingle()
|
||
launch {
|
||
try {
|
||
updateBudgetWarns(savedBudget)
|
||
} catch (error: Exception) {
|
||
logger.error("Error during updateBudgetWarns: ${error.message}")
|
||
}
|
||
}
|
||
savedBudget.incomeCategories = incomeCategoriesDeferred.await().toMutableList()
|
||
|
||
return@coroutineScope budgetRepo.save(savedBudget).awaitSingle().also {
|
||
launch {
|
||
try {
|
||
updateBudgetWarns(it)
|
||
} catch (error: Exception) {
|
||
logger.error("Error during updateBudgetWarns: ${error.message}")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
suspend fun getBudgetByDate(date: LocalDate, spaceId: String): Budget? {
|
||
return budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThanEqualAndSpace(date, date, ObjectId(spaceId))
|
||
.awaitSingleOrNull()
|
||
}
|
||
|
||
|
||
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 ->
|
||
// getTransactionsByTypes(it.dateFrom, it.dateTo)
|
||
// }
|
||
// }
|
||
|
||
|
||
suspend fun deleteBudget(spaceId: String, budgetId: String) {
|
||
val budget = findProjectedBudget(spaceId, budgetId)
|
||
getTransactionsToDelete(spaceId, budget.dateFrom, budget.dateTo).map {
|
||
deleteTransaction(it)
|
||
}
|
||
budgetRepo.deleteById(budget.id!!)
|
||
}
|
||
|
||
|
||
suspend fun setCategoryLimit(spaceId: String, budgetId: String, catId: String, limit: Double): BudgetCategory =
|
||
coroutineScope {
|
||
val budget = findProjectedBudget(spaceId, budgetId = budgetId)
|
||
val categoryToEdit = budget.categories.firstOrNull { it.category.id == catId }
|
||
?: throw NotFoundException("Category not found in the budget")
|
||
val transactionSumsByCategory = calcTransactionsSum(spaceId, budget, catId, "PLANNED").awaitSingle()
|
||
if (transactionSumsByCategory > limit) {
|
||
throw IllegalArgumentException("Limit can't be less than planned expenses on category. Current planned value: $transactionSumsByCategory")
|
||
} else {
|
||
categoryToEdit.currentLimit = limit
|
||
launch {
|
||
updateBudgetWarns(budget)
|
||
}
|
||
budgetRepo.save(budget).awaitSingle()
|
||
categoryToEdit
|
||
}
|
||
}
|
||
|
||
fun getWarns(budgetId: String, isHide: Boolean? = null): Mono<List<Warn>> {
|
||
return warnRepo.findAllByBudgetIdAndIsHide(budgetId, isHide == true).collectList()
|
||
}
|
||
|
||
fun hideWarn(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 = getAverageSpendingByCategory()
|
||
val averageIncomeMono = getAverageIncome()
|
||
val currentBudgetIncomeMono = calcTransactionsSum(
|
||
budget.space!!.id!!, finalBudget, transactionType = "PLANNED", categoryType = "INCOME"
|
||
)
|
||
val plannedIncomeMono = calcTransactionsSum(
|
||
budget.space!!.id!!, finalBudget, categoryType = "INCOME", transactionType = "PLANNED"
|
||
)
|
||
val plannedSavingMono = calcTransactionsSum(
|
||
budget.space!!.id!!,
|
||
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 }
|
||
}
|
||
|
||
|
||
fun getTransactions(
|
||
spaceId: String,
|
||
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>()
|
||
|
||
// Добавляем фильтры
|
||
matchCriteria.add(Criteria.where("spaceDetails._id").`is`(ObjectId(spaceId)))
|
||
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 unwindCategory = unwind("categoryDetails")
|
||
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpace = unwind("spaceDetails")
|
||
val lookupUsers = lookup("users", "user.\$id", "_id", "userDetails")
|
||
val unwindUser = unwind("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,
|
||
unwindCategory,
|
||
lookupSpaces,
|
||
unwindSpace,
|
||
lookupUsers,
|
||
unwindUser,
|
||
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", Document::class.java
|
||
).collectList().map { docs ->
|
||
|
||
val test = docs.map { doc ->
|
||
transactionsMapper.fromDocument(doc)
|
||
}.toMutableList()
|
||
|
||
test
|
||
}
|
||
}
|
||
|
||
suspend fun getAllTransactions(): List<Transaction>{
|
||
|
||
// Сборка агрегации
|
||
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
|
||
val unwindCategory = unwind("categoryDetails")
|
||
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpace = unwind("spaceDetails")
|
||
val lookupUsers = lookup("users", "user.\$id", "_id", "userDetails")
|
||
val unwindUser = unwind("userDetails")
|
||
val sort =
|
||
sort(Sort.by(Direction.DESC, "date").and(Sort.by(Direction.DESC, "createdAt")))
|
||
|
||
val aggregationBuilder = mutableListOf(
|
||
lookup,
|
||
unwindCategory,
|
||
lookupSpaces,
|
||
unwindSpace,
|
||
lookupUsers,
|
||
unwindUser,
|
||
sort
|
||
)
|
||
|
||
val aggregation = newAggregation(aggregationBuilder)
|
||
|
||
return reactiveMongoTemplate.aggregate(
|
||
aggregation, "transactions", Document::class.java
|
||
).collectList().awaitSingle().map { doc ->
|
||
transactionsMapper.fromDocument(doc)
|
||
}
|
||
}
|
||
|
||
|
||
suspend fun getTransactionByParentId(parentId: String): Transaction? {
|
||
// Сборка агрегации
|
||
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
|
||
val unwindCategory = unwind("categoryDetails")
|
||
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpace = unwind("spaceDetails")
|
||
val lookupUsers = lookup("users", "user.\$id", "_id", "userDetails")
|
||
val unwindUser = unwind("userDetails")
|
||
|
||
val match = match(Criteria.where("parentId").`is`(parentId))
|
||
|
||
val aggregationBuilder = mutableListOf(
|
||
lookup,
|
||
unwindCategory,
|
||
lookupSpaces,
|
||
unwindSpace,
|
||
lookupUsers,
|
||
unwindUser,
|
||
match
|
||
)
|
||
val aggregation = newAggregation(aggregationBuilder)
|
||
|
||
return reactiveMongoTemplate.aggregate(
|
||
aggregation, "transactions", Document::class.java
|
||
).map { doc ->
|
||
transactionsMapper.fromDocument(doc)
|
||
|
||
}.awaitFirstOrNull()
|
||
}
|
||
|
||
|
||
suspend fun getTransactionsToDelete(
|
||
spaceId: String,
|
||
dateFrom: LocalDate,
|
||
dateTo: LocalDate
|
||
): List<Transaction> {
|
||
val matchCriteria = mutableListOf<Criteria>()
|
||
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpace = unwind("spaceDetails")
|
||
val lookupCategory = lookup("categories", "category.\$id", "_id", "categoryDetails")
|
||
val unwindCategory = unwind("categoryDetails")
|
||
matchCriteria.add(Criteria.where("spaceDetails._id").`is`(ObjectId(spaceId)))
|
||
matchCriteria.add(Criteria.where("date").gte(dateFrom))
|
||
matchCriteria.add(Criteria.where("date").lt(dateTo))
|
||
matchCriteria.add(
|
||
Criteria().orOperator(
|
||
Criteria.where("type.code").`is`("PLANNED"), Criteria.where("parentId").exists(true)
|
||
)
|
||
)
|
||
val match = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
||
val project = project("_id", "type", "comment", "date", "amount", "isDone", "categoryDetails")
|
||
val aggregationBuilder = mutableListOf(
|
||
lookupSpaces, unwindSpace, lookupCategory, unwindCategory, match, project
|
||
)
|
||
val aggregation = newAggregation(aggregationBuilder)
|
||
|
||
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Document::class.java)
|
||
.collectList() // Собирать все результаты в список
|
||
.map { docs ->
|
||
docs.map { doc ->
|
||
transactionsMapper.fromDocument(doc)
|
||
}
|
||
}.awaitSingle()
|
||
}
|
||
|
||
|
||
suspend fun getTransactionById(id: String): Transaction {
|
||
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpaces = unwind("spaceDetails")
|
||
val lookupCategory = lookup("categories", "category.\$id", "_id", "categoryDetails")
|
||
val unwindCategory = unwind("categoryDetails")
|
||
val lookupUser = lookup("users", "user.\$id", "_id", "userDetails")
|
||
val unwindUser = unwind("userDetails")
|
||
val matchCriteria = mutableListOf<Criteria>()
|
||
matchCriteria.add(Criteria.where("_id").`is`(ObjectId(id)))
|
||
val match = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
|
||
// val project = project("_iid", "type", "comment", "date", "amount", "isDone")
|
||
val aggregationBuilder = mutableListOf(
|
||
lookupSpaces, unwindSpaces, lookupCategory, unwindCategory, lookupUser, unwindUser, match
|
||
)
|
||
val aggregation = newAggregation(aggregationBuilder)
|
||
|
||
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Document::class.java).next()
|
||
.map { doc ->
|
||
transactionsMapper.fromDocument(doc)
|
||
}.awaitSingleOrNull() ?: throw NotFoundException("Transaction with id $id not found")
|
||
|
||
}
|
||
|
||
|
||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||
|
||
suspend fun createTransaction(space: Space, transaction: Transaction, user: User? = null): Transaction {
|
||
val author = user
|
||
?: userService.getByUserNameWoPass(
|
||
ReactiveSecurityContextHolder.getContext().awaitSingle().authentication.name
|
||
)
|
||
if (space.users.none { it.id.toString() == author.id }) {
|
||
throw IllegalArgumentException("User does not have access to this Space")
|
||
}
|
||
// Привязываем space и user к транзакции
|
||
transaction.user = author
|
||
transaction.space = space
|
||
|
||
val savedTransaction = transactionsRepo.save(transaction).awaitSingle()
|
||
|
||
updateBudgetOnCreate(savedTransaction)
|
||
scope.launch {
|
||
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||
val transactionType = if (transaction.type.code == "INSTANT") "текущую" else "плановую"
|
||
|
||
subscriptionService.sendToSpaceOwner(
|
||
space.owner!!.id!!, PushMessage(
|
||
title = "Новая транзакция в пространстве ${space.name}!",
|
||
body = "Пользователь ${author.username} создал $transactionType транзакцию на сумму ${transaction.amount.toInt()} с комментарием ${transaction.comment} с датой ${
|
||
dateFormatter.format(
|
||
transaction.date
|
||
)
|
||
}",
|
||
url = "https://luminic.space/"
|
||
)
|
||
)
|
||
}
|
||
scope.launch {
|
||
nlpService.reteach()
|
||
}
|
||
return savedTransaction
|
||
}
|
||
|
||
|
||
@CacheEvict(cacheNames = ["transactions", "budgets"], allEntries = true)
|
||
suspend fun editTransaction(transaction: Transaction, author: User): Transaction {
|
||
val oldStateOfTransaction = getTransactionById(transaction.id!!)
|
||
val changed = compareSumDateDoneIsChanged(oldStateOfTransaction, transaction)
|
||
if (!changed) {
|
||
return transactionsRepo.save(transaction).awaitSingle()
|
||
}
|
||
val amountDifference = transaction.amount - oldStateOfTransaction.amount
|
||
|
||
if (oldStateOfTransaction.type.code == "PLANNED") {
|
||
handleChildTransaction(
|
||
oldStateOfTransaction,
|
||
transaction
|
||
)
|
||
}
|
||
val space = transaction.space
|
||
val savedTransaction = transactionsRepo.save(transaction).awaitSingle()
|
||
updateBudgetOnEdit(oldStateOfTransaction, savedTransaction, amountDifference)
|
||
scope.launch {
|
||
var whatChanged = "nothing"
|
||
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||
val transactionType = if (transaction.type.code == "INSTANT") "текущую" else "плановую"
|
||
|
||
val sb = StringBuilder()
|
||
if (oldStateOfTransaction.amount != transaction.amount) {
|
||
sb.append("${oldStateOfTransaction.amount} → ${transaction.amount}\n")
|
||
whatChanged = "main"
|
||
}
|
||
if (oldStateOfTransaction.comment != transaction.comment) {
|
||
sb.append("${oldStateOfTransaction.comment} → ${transaction.comment}\n")
|
||
whatChanged = "main"
|
||
|
||
}
|
||
if (oldStateOfTransaction.date != transaction.date) {
|
||
sb.append("${dateFormatter.format(oldStateOfTransaction.date)} → ${dateFormatter.format(transaction.date)}\n")
|
||
whatChanged = "main"
|
||
}
|
||
if (!oldStateOfTransaction.isDone && transaction.isDone) {
|
||
whatChanged = "done_true"
|
||
}
|
||
if (oldStateOfTransaction.isDone && !transaction.isDone) {
|
||
whatChanged = "done_false"
|
||
}
|
||
|
||
val body: String = when (whatChanged) {
|
||
"main" -> {
|
||
"Пользователь ${author.username} изменил $transactionType транзакцию:\n$sb"
|
||
}
|
||
|
||
"done_true" -> {
|
||
"Пользователь ${author.username} выполнил ${transaction.comment} с суммой ${transaction.amount.toInt()}"
|
||
}
|
||
|
||
"done_false" -> {
|
||
"Пользователь ${author.username} отменил выполнение ${transaction.comment} с суммой ${transaction.amount.toInt()}"
|
||
}
|
||
|
||
else -> "Изменения не обнаружены, но что то точно изменилось"
|
||
}
|
||
|
||
subscriptionService.sendToSpaceOwner(
|
||
space?.owner!!.id!!, PushMessage(
|
||
title = "Новое действие в пространстве ${space.name}!",
|
||
body = body,
|
||
url = "https://luminic.space/"
|
||
)
|
||
)
|
||
}
|
||
|
||
return savedTransaction
|
||
}
|
||
|
||
|
||
private suspend fun handleChildTransaction(oldTransaction: Transaction, newTransaction: Transaction) {
|
||
if (!oldTransaction.isDone && newTransaction.isDone) {
|
||
val newChildTransaction = newTransaction.copy(
|
||
id = null, type = TransactionType("INSTANT", "Текущие"), parentId = newTransaction.id
|
||
)
|
||
transactionsRepo.save(newChildTransaction).awaitSingle()
|
||
updateBudgetOnCreate(newChildTransaction)
|
||
} else if (oldTransaction.isDone && !newTransaction.isDone) {
|
||
val childTransaction = getTransactionByParentId(newTransaction.id!!)
|
||
childTransaction?.let { deleteTransaction(it) }
|
||
} else {
|
||
val childTransaction = getTransactionByParentId(newTransaction.id!!)
|
||
childTransaction?.let {
|
||
logger.info("Updating child: $it")
|
||
it.amount = newTransaction.amount
|
||
it.category = newTransaction.category
|
||
it.comment = newTransaction.comment
|
||
it.user = newTransaction.user
|
||
transactionsRepo.save(it).awaitSingle()
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
|
||
suspend fun deleteTransaction(transaction: Transaction) = coroutineScope {
|
||
transactionsRepo.deleteById(transaction.id!!).awaitFirstOrNull()
|
||
launch { updateBudgetOnDelete(transaction) }
|
||
|
||
}
|
||
|
||
|
||
// @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(
|
||
spaceId: String,
|
||
budget: Budget,
|
||
categoryId: String? = null,
|
||
categoryType: String? = null,
|
||
transactionType: String? = null,
|
||
isDone: Boolean? = null
|
||
): Mono<Double> {
|
||
val matchCriteria = mutableListOf<Criteria>()
|
||
|
||
// Добавляем фильтры
|
||
matchCriteria.add(Criteria.where("spaceDetails._id").`is`(ObjectId(spaceId)))
|
||
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 lookupSpace = lookup("space", "space.\$id", "_id", "spaceDetails")
|
||
val unwindSpace = unwind("spaceDetails")
|
||
|
||
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, lookupSpace, unwindSpace, 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> {
|
||
val 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
|
||
}
|
||
|
||
|
||
suspend fun getTransactionsByTypes(
|
||
spaceId: String, dateFrom: LocalDate, dateTo: LocalDate
|
||
): Map<String, List<Transaction>>? = coroutineScope {
|
||
val pipeline = listOf(
|
||
Document(
|
||
"\$lookup",
|
||
Document("from", "categories").append("localField", "category.\$id")
|
||
.append("foreignField", "_id")
|
||
.append("as", "categoryDetailed")
|
||
), Document(
|
||
"\$lookup",
|
||
Document("from", "spaces").append("localField", "space.\$id").append("foreignField", "_id")
|
||
.append("as", "spaceDetailed")
|
||
), 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", "\$spaceDetailed").append("preserveNullAndEmptyArrays", true)
|
||
), Document(
|
||
"\$unwind", Document("path", "\$userDetailed").append("preserveNullAndEmptyArrays", true)
|
||
), Document(
|
||
"\$match", Document(
|
||
"\$and", listOf(
|
||
Document("spaceDetailed._id", ObjectId(spaceId)), 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))
|
||
)
|
||
)
|
||
)
|
||
)
|
||
|
||
val collection = reactiveMongoTemplate.getCollection("transactions").awaitSingle()
|
||
val aggregationResult = collection.aggregate(pipeline, Document::class.java).awaitSingle()
|
||
|
||
val plannedExpensesDeffer = async { extractTransactions(aggregationResult, "plannedExpenses") }
|
||
val plannedIncomesDeffer = async { extractTransactions(aggregationResult, "plannedIncomes") }
|
||
val instantTransactionsDeffer = async { extractTransactions(aggregationResult, "instantTransactions") }
|
||
|
||
val (plannedExpenses, plannedIncomes, instantTransactions) = awaitAll(
|
||
plannedExpensesDeffer,
|
||
plannedIncomesDeffer,
|
||
instantTransactionsDeffer
|
||
)
|
||
mapOf(
|
||
"plannedExpenses" to plannedExpenses,
|
||
"plannedIncomes" to plannedIncomes,
|
||
"instantTransactions" to instantTransactions
|
||
)
|
||
}
|
||
|
||
|
||
private suspend fun extractTransactions(aggregationResult: Document, key: String): List<Transaction> {
|
||
val resultTransactions = aggregationResult[key] as? List<Document> ?: emptyList()
|
||
return resultTransactions.map { documentToTransactionMapper(it) }
|
||
}
|
||
|
||
|
||
private fun documentToTransactionMapper(document: Document): Transaction {
|
||
val transactionType = document["type"] as Document
|
||
val user: User?
|
||
|
||
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).toInstant().atZone(ZoneId.systemDefault())
|
||
.toLocalDate(),
|
||
createdAt = (userDocument["createdAt"] as Date).toInstant().atZone(ZoneId.systemDefault())
|
||
.toLocalDateTime(),
|
||
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(),
|
||
null,
|
||
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),
|
||
)
|
||
|
||
}
|
||
|
||
suspend fun getBudgetSumsByCategory(categoryId: String, budget: Budget): Document {
|
||
logger.info("getting budget sums for category $categoryId")
|
||
val pipeline = listOf(
|
||
Document(
|
||
"\$match", Document("category.\$id", ObjectId(categoryId)).append(
|
||
"date", Document(
|
||
"\$gte", Date.from(
|
||
LocalDateTime.of(budget.dateFrom, LocalTime.MIN).atZone(ZoneId.systemDefault())
|
||
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
|
||
)
|
||
).append(
|
||
"\$lt", Date.from(
|
||
LocalDateTime.of(budget.dateTo, LocalTime.MIN).atZone(ZoneId.systemDefault())
|
||
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
|
||
)
|
||
)
|
||
)
|
||
), Document(
|
||
"\$group", Document("_id", BsonNull()).append(
|
||
"plannedAmount", Document(
|
||
"\$sum", Document(
|
||
"\$cond",
|
||
listOf(Document("\$eq", listOf("\$type.code", "PLANNED")), "\$amount", 0.0)
|
||
)
|
||
)
|
||
).append(
|
||
"instantAmount", Document(
|
||
"\$sum", Document(
|
||
"\$cond",
|
||
listOf(Document("\$eq", listOf("\$type.code", "INSTANT")), "\$amount", 0.0)
|
||
)
|
||
)
|
||
)
|
||
), Document(
|
||
"\$project", Document("_id", 0).append("plannedAmount", 1).append("instantAmount", 1)
|
||
)
|
||
)
|
||
|
||
val collection = reactiveMongoTemplate.getCollection("transactions").awaitSingle()
|
||
val result = collection.aggregate(pipeline).awaitFirstOrNull()
|
||
return result ?: Document("plannedAmount", 0.0).append("instantAmount", 0.0)
|
||
}
|
||
|
||
suspend fun getBudgetCategories(
|
||
spaceId: String, dateFrom: LocalDate, dateTo: LocalDate
|
||
): Map<String, Map<String, Double>>? {
|
||
val pipeline = listOf(
|
||
Document(
|
||
"\$lookup",
|
||
Document("from", "spaces").append("localField", "space.\$id").append("foreignField", "_id")
|
||
.append("as", "spaceDetailed")
|
||
), Document(
|
||
"\$unwind", Document("path", "\$spaceDetailed").append("preserveNullAndEmptyArrays", true)
|
||
), Document(
|
||
"\$match", Document(
|
||
Document("spaceDetailed._id", ObjectId(spaceId))
|
||
)
|
||
), 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(
|
||
"\$lte", 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("transactionSums", 1L)
|
||
), 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))
|
||
)
|
||
)
|
||
)
|
||
|
||
|
||
val collection = reactiveMongoTemplate.getCollection("categories").awaitSingle()
|
||
val aggregation = collection.aggregate(pipeline, Document::class.java).asFlow().map { 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 и values в виде пары, чтобы дальше с ними можно было работать
|
||
id to values
|
||
}.toList().toMap()
|
||
return aggregation
|
||
}
|
||
|
||
|
||
suspend fun getCategoryTransactionPipeline(
|
||
spaceId: String, dateFrom: LocalDate, dateTo: LocalDate, catType: String? = "EXPENSE"
|
||
): List<BudgetCategory> {
|
||
val pipeline = listOf(
|
||
Document(
|
||
"\$lookup",
|
||
Document("from", "spaces").append("localField", "space.\$id").append("foreignField", "_id")
|
||
.append("as", "spaceDetailed")
|
||
), Document(
|
||
"\$unwind", Document("path", "\$spaceDetailed").append("preserveNullAndEmptyArrays", true)
|
||
), Document(
|
||
"\$match", Document(
|
||
Document("spaceDetailed._id", ObjectId(spaceId))
|
||
)
|
||
), Document("\$match", Document("type.code", catType)), 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))
|
||
)
|
||
)
|
||
)
|
||
|
||
val collection = reactiveMongoTemplate.getCollection("categories").awaitSingle()
|
||
return collection.aggregate(pipeline, Document::class.java)
|
||
.asFlow() // Преобразуем Flux в Flow
|
||
.map { document ->
|
||
val categoryType = document["type"] as Document
|
||
BudgetCategory(
|
||
currentSpent = document["instantAmount"] as Double,
|
||
currentLimit = document["plannedAmount"] as Double,
|
||
currentPlanned = document["plannedAmount"] as Double,
|
||
category = Category(
|
||
id = document["_id"].toString(),
|
||
type = CategoryType(categoryType["code"] as String, categoryType["name"] as String),
|
||
name = document["name"] as String,
|
||
description = document["description"] as String,
|
||
icon = document["icon"] as String
|
||
)
|
||
)
|
||
}.toList()
|
||
}
|
||
|
||
|
||
// fun getCategorySumsPipeline(dateFrom: LocalDate, dateTo: LocalDate): Mono<List<Document>> {
|
||
// val pipeline = listOf(
|
||
// Document(
|
||
// "\$lookup",
|
||
// Document("from", "categories").append("localField", "category.\$id")
|
||
// .append("foreignField", "_id")
|
||
// .append("as", "categoryDetails")
|
||
// ), Document("\$unwind", "\$categoryDetails"), Document(
|
||
// "\$match", Document(
|
||
// "date", Document(
|
||
// "\$gte", Date.from(
|
||
// LocalDateTime.of(dateFrom, LocalTime.MIN).atZone(ZoneId.systemDefault())
|
||
// .withZoneSameInstant(ZoneOffset.UTC).toInstant()
|
||
// )
|
||
// ).append(
|
||
// "\$lte",
|
||
// LocalDateTime.of(dateTo, LocalTime.MIN).atZone(ZoneId.systemDefault())
|
||
// .withZoneSameInstant(ZoneOffset.UTC).toInstant()
|
||
// )
|
||
// )
|
||
// ), Document(
|
||
// "\$group", Document(
|
||
// "_id",
|
||
// Document("categoryId", "\$categoryDetails._id").append(
|
||
// "categoryName",
|
||
// "\$categoryDetails.name"
|
||
// )
|
||
// .append("year", Document("\$year", "\$date"))
|
||
// .append("month", Document("\$month", "\$date"))
|
||
// ).append("totalAmount", Document("\$sum", "\$amount"))
|
||
// ), Document(
|
||
// "\$group",
|
||
// Document("_id", "\$_id.categoryId").append(
|
||
// "categoryName",
|
||
// Document("\$first", "\$_id.categoryName")
|
||
// )
|
||
// .append(
|
||
// "monthlyData", Document(
|
||
// "\$push", Document(
|
||
// "month", Document(
|
||
// "\$concat", listOf(
|
||
// Document("\$toString", "\$_id.year"), "-", Document(
|
||
// "\$cond", listOf(
|
||
// Document("\$lt", listOf("\$_id.month", 10L)), Document(
|
||
// "\$concat", listOf(
|
||
// "0", Document("\$toString", "\$_id.month")
|
||
// )
|
||
// ), Document("\$toString", "\$_id.month")
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// ).append("totalAmount", "\$totalAmount")
|
||
// )
|
||
// )
|
||
// ), Document(
|
||
// "\$addFields", Document(
|
||
// "completeMonthlyData", Document(
|
||
// "\$map",
|
||
// Document("input", Document("\$range", listOf(0L, 6L))).append("as", "offset").append(
|
||
// "in", Document(
|
||
// "month", Document(
|
||
// "\$dateToString", Document("format", "%Y-%m").append(
|
||
// "date", Document(
|
||
// "\$dateAdd",
|
||
// Document("startDate", Date(1754006400000L)).append("unit", "month")
|
||
// .append(
|
||
// "amount", Document("\$multiply", listOf("\$\$offset", 1L))
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// ).append(
|
||
// "totalAmount", Document(
|
||
// "\$let", Document(
|
||
// "vars", Document(
|
||
// "matched", Document(
|
||
// "\$arrayElemAt", listOf(
|
||
// Document(
|
||
// "\$filter",
|
||
// Document("input", "\$monthlyData").append("as", "data")
|
||
// .append(
|
||
// "cond", Document(
|
||
// "\$eq", listOf(
|
||
// "\$\$data.month", Document(
|
||
// "\$dateToString",
|
||
// Document("format", "%Y-%m").append(
|
||
// "date", Document(
|
||
// "\$dateAdd", Document(
|
||
// "startDate", Date(
|
||
// 1733011200000L
|
||
// )
|
||
// ).append(
|
||
// "unit", "month"
|
||
// ).append(
|
||
// "amount",
|
||
// Document(
|
||
// "\$multiply",
|
||
// listOf(
|
||
// "\$\$offset",
|
||
// 1L
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// ), 0L
|
||
// )
|
||
// )
|
||
// )
|
||
// ).append(
|
||
// "in", Document("\$ifNull", listOf("\$\$matched.totalAmount", 0L))
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// )
|
||
// ), Document(
|
||
// "\$project",
|
||
// Document("_id", 0L).append("categoryId", "\$_id").append("categoryName", "\$categoryName")
|
||
// .append("monthlyData", "\$completeMonthlyData")
|
||
// )
|
||
// )
|
||
//
|
||
// return reactiveMongoTemplate.getCollection("transactions").flatMapMany { it.aggregate(pipeline) }.map {
|
||
// it["categoryId"] = it["categoryId"].toString()
|
||
// it
|
||
// }.collectList()
|
||
// }
|
||
|
||
suspend fun getCategorySummaries(spaceId: String, dateFrom: LocalDate): List<Document> {
|
||
val sixMonthsAgo = Date.from(
|
||
LocalDateTime.of(dateFrom, LocalTime.MIN).atZone(ZoneId.systemDefault())
|
||
.withZoneSameInstant(ZoneOffset.UTC)
|
||
.toInstant()
|
||
) // Пример даты, можно заменить на вычисляемую
|
||
|
||
val aggregation = listOf(
|
||
// 1. Фильтр за последние 6 месяцев
|
||
Document(
|
||
"\$lookup",
|
||
Document("from", "spaces").append("localField", "space.\$id").append("foreignField", "_id")
|
||
.append("as", "spaceInfo")
|
||
),
|
||
|
||
// 4. Распаковываем массив категорий
|
||
Document("\$unwind", "\$spaceInfo"),
|
||
Document("\$match", Document("spaceInfo._id", ObjectId(spaceId))),
|
||
Document(
|
||
"\$match",
|
||
Document("date", Document("\$gte", sixMonthsAgo).append("\$lt", Date())).append(
|
||
"type.code",
|
||
"INSTANT"
|
||
)
|
||
),
|
||
|
||
// 2. Группируем по категории + (год, месяц)
|
||
Document(
|
||
"\$group", Document(
|
||
"_id",
|
||
Document("category", "\$category.\$id").append("year", Document("\$year", "\$date"))
|
||
.append("month", Document("\$month", "\$date"))
|
||
).append("totalAmount", Document("\$sum", "\$amount"))
|
||
),
|
||
|
||
// 3. Подтягиваем информацию о категории
|
||
Document(
|
||
"\$lookup",
|
||
Document("from", "categories").append("localField", "_id.category")
|
||
.append("foreignField", "_id")
|
||
.append("as", "categoryInfo")
|
||
),
|
||
|
||
// 4. Распаковываем массив категорий
|
||
Document("\$unwind", "\$categoryInfo"),
|
||
|
||
// 5. Фильтруем по типу категории (EXPENSE)
|
||
Document("\$match", Document("categoryInfo.type.code", "EXPENSE")),
|
||
|
||
// 6. Группируем обратно по категории, собирая все (год, месяц, total)
|
||
Document(
|
||
"\$group",
|
||
Document("_id", "\$_id.category").append(
|
||
"categoryName",
|
||
Document("\$first", "\$categoryInfo.name")
|
||
)
|
||
.append("categoryType", Document("\$first", "\$categoryInfo.type.code"))
|
||
.append("categoryIcon", Document("\$first", "\$categoryInfo.icon")).append(
|
||
"monthlySums", Document(
|
||
"\$push",
|
||
Document("year", "\$_id.year").append("month", "\$_id.month")
|
||
.append("total", "\$totalAmount")
|
||
)
|
||
)
|
||
),
|
||
|
||
// 7. Формируем единый массив из 6 элементов:
|
||
// - каждый элемент = {year, month, total},
|
||
// - если нет записей за месяц, ставим total=0
|
||
Document(
|
||
"\$project",
|
||
Document("categoryName", 1).append("categoryType", 1).append("categoryIcon", 1).append(
|
||
"monthlySums", Document(
|
||
"\$map", Document("input", Document("\$range", listOf(0, 6))).append("as", "i").append(
|
||
"in", Document(
|
||
"\$let", Document(
|
||
"vars", Document(
|
||
"subDate", Document(
|
||
"\$dateSubtract",
|
||
Document("startDate", Date()).append("unit", "month")
|
||
.append("amount", "$\$i")
|
||
)
|
||
)
|
||
).append(
|
||
"in",
|
||
Document("year", Document("\$year", "$\$subDate")).append(
|
||
"month",
|
||
Document("\$month", "$\$subDate")
|
||
).append(
|
||
"total", Document(
|
||
"\$ifNull", listOf(
|
||
Document(
|
||
"\$getField", Document("field", "total").append(
|
||
"input", Document(
|
||
"\$arrayElemAt", listOf(
|
||
Document(
|
||
"\$filter", Document(
|
||
"input", "\$monthlySums"
|
||
).append("as", "ms").append(
|
||
"cond", Document(
|
||
"\$and", listOf(
|
||
Document(
|
||
"\$eq",
|
||
listOf(
|
||
"$\$ms.year",
|
||
Document(
|
||
"\$year",
|
||
"$\$subDate"
|
||
)
|
||
)
|
||
), Document(
|
||
"\$eq",
|
||
listOf(
|
||
"$\$ms.month",
|
||
Document(
|
||
"\$month",
|
||
"$\$subDate"
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
), 0.0
|
||
)
|
||
)
|
||
)
|
||
), 0.0
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
|
||
// 8. Сортируем результат по имени категории
|
||
Document("\$sort", Document("categoryName", 1))
|
||
)
|
||
|
||
|
||
// Выполняем агрегацию
|
||
return reactiveMongoTemplate.getCollection("transactions").flatMapMany { it.aggregate(aggregation) }
|
||
.map { document ->
|
||
// Преобразуем _id в строку
|
||
document["_id"] = document["_id"].toString()
|
||
|
||
// Получаем monthlySums и приводим к изменяемому списку
|
||
val monthlySums = (document["monthlySums"] as? List<*>)?.map { monthlySum ->
|
||
if (monthlySum is Document) {
|
||
// Создаем копию Document, чтобы избежать изменений в исходном списке
|
||
Document(monthlySum).apply {
|
||
// Добавляем поле date
|
||
val date = LocalDate.of(getInteger("year"), getInteger("month"), 1)
|
||
this["date"] = date
|
||
}
|
||
} else {
|
||
monthlySum
|
||
}
|
||
}?.toMutableList()
|
||
|
||
// Сортируем monthlySums по полю date
|
||
val sortedMonthlySums = monthlySums?.sortedBy { (it as? Document)?.get("date") as? LocalDate }
|
||
|
||
// Рассчитываем разницу между текущим и предыдущим месяцем
|
||
var previousMonthSum = 0.0
|
||
sortedMonthlySums?.forEach { monthlySum ->
|
||
if (monthlySum is Document) {
|
||
val currentMonthSum = monthlySum.getDouble("total") ?: 0.0
|
||
|
||
// Рассчитываем разницу в процентах
|
||
val difference = if (previousMonthSum != 0.0 && currentMonthSum != 0.0) {
|
||
(((currentMonthSum - previousMonthSum) / previousMonthSum) * 100).toInt()
|
||
} else {
|
||
0
|
||
}
|
||
|
||
// Добавляем поле difference
|
||
monthlySum["difference"] = difference
|
||
|
||
// Обновляем previousMonthSum для следующей итерации
|
||
previousMonthSum = currentMonthSum
|
||
}
|
||
}
|
||
|
||
// Обновляем документ с отсортированными и обновленными monthlySums
|
||
document["monthlySums"] = sortedMonthlySums
|
||
document
|
||
}.collectList().awaitSingle()
|
||
}
|
||
|
||
} |