This commit is contained in:
xds
2025-04-07 18:26:23 +03:00
parent a38d5068e0
commit af7577c65b
13 changed files with 229 additions and 62 deletions

View File

@@ -2,12 +2,14 @@ package space.luminic.budgerapp
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import space.luminic.budgerapp.configs.NLPConfig
import space.luminic.budgerapp.configs.TelegramBotProperties
import java.util.*
@@ -15,7 +17,8 @@ import java.util.*
@EnableCaching
@EnableAsync
@EnableScheduling
@EnableConfigurationProperties(TelegramBotProperties::class)
//@EnableConfigurationProperties([TelegramBotProperties::class,)
@ConfigurationPropertiesScan(basePackages = ["space.luminic.budgerapp"])
@EnableMongoRepositories(basePackages = ["space.luminic.budgerapp.repos"])
class BudgerAppApplication

View File

@@ -1,12 +1,19 @@
package space.luminic.budgerapp.configs
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
//@Configuration
//class CommonConfig {
// @Bean
// fun httpTraceRepository(): HttpTraceRepository {
// return InMemoryHttpTraceRepository()
// }
//}
//}
@ConfigurationProperties(prefix = "nlp")
data class NLPConfig(
val address: String,
)

View File

@@ -1,18 +1,25 @@
package space.luminic.budgerapp.controllers
import com.opencsv.CSVWriter
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.apache.commons.io.IOUtils.writer
import org.bson.Document
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import space.luminic.budgerapp.controllers.BudgetController.LimitValue
import space.luminic.budgerapp.controllers.dtos.BudgetCreationDTO
import space.luminic.budgerapp.models.*
import space.luminic.budgerapp.services.*
import java.io.ByteArrayOutputStream
import java.io.OutputStreamWriter
import java.time.LocalDate
@RestController
@RequestMapping("/spaces")
class SpaceController(
@@ -172,6 +179,63 @@ class SpaceController(
}
}
@GetMapping("/{spaceId}/transactions/csv")
suspend fun getTransactionsCSV(
@PathVariable spaceId: String,
@RequestParam(value = "transaction_type") transactionType: String? = null,
@RequestParam(value = "category_type") categoryType: String? = null,
@RequestParam(value = "user_id") userId: String? = null,
@RequestParam(value = "is_child") isChild: Boolean? = null,
@RequestParam(value = "limit") limit: Int = 20000,
@RequestParam(value = "offset") offset: Int = 0
): ResponseEntity<Any> {
try {
val bos = ByteArrayOutputStream()
val writer = CSVWriter(OutputStreamWriter(bos))
val CSVHeaders = arrayOf("id", "name", "category")
writer.writeNext(CSVHeaders)
financialService.getTransactions(
spaceId = spaceId,
transactionType = transactionType,
categoryType = categoryType,
userId = userId,
isChild = isChild,
limit = limit,
offset = offset
).awaitSingle().map {
val data = arrayOf(it.id, it.comment, it.category.name)
writer.writeNext(data)
}
writer.close()
val csvData = bos.toByteArray()
val headers = HttpHeaders()
headers.contentType = MediaType.parseMediaType("text/csv")
headers.setContentDispositionFormData("attachment", "pojos.csv")
return ResponseEntity(csvData, headers, HttpStatus.OK)
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@GetMapping("/{spaceId}/category-predict")
suspend fun getTransactionCategoryPredict(
@PathVariable spaceId: String,
@RequestParam comment: String
): List<Category> {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return categoryService.getCategories(
"67af3c0f652da946a7dd9931",
"EXPENSE",
sortBy = "name",
direction = "ASC",
predict = comment
)
}
@GetMapping("/{spaceId}/transactions/{id}")
suspend fun getTransaction(
@@ -181,6 +245,7 @@ class SpaceController(
return financialService.getTransactionById(id)
}
@PostMapping("/{spaceId}/transactions")
suspend fun createTransaction(@PathVariable spaceId: String, @RequestBody transaction: Transaction): Transaction {
val user = authService.getSecurityUser()
@@ -220,7 +285,7 @@ class SpaceController(
): List<Category> {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return categoryService.getCategories(spaceId, type, sortBy, direction).awaitSingleOrNull().orEmpty()
return categoryService.getCategories(spaceId, type, sortBy, direction)
}
@GetMapping("/{spaceId}/categories/types")

View File

@@ -0,0 +1,4 @@
package space.luminic.budgerapp.models
data class CategoryPrediction(val category: String, val weight: Double) {
}

View File

@@ -14,6 +14,7 @@ import org.springframework.stereotype.Service
import org.telegram.telegrambots.bots.TelegramLongPollingBot
import org.telegram.telegrambots.meta.api.methods.send.SendMessage
import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage
import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageReplyMarkup
import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageText
import org.telegram.telegrambots.meta.api.objects.Update
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup
@@ -35,9 +36,76 @@ class BotService(
private val categoriesService: CategoryService,
private val financialService: FinancialService,
private val spaceService: SpaceService,
private val nlpService: NLPService
) : TelegramLongPollingBot(telegramBotProperties.token) {
private val logger = LoggerFactory.getLogger(javaClass)
private suspend fun constructCategoriesButtons(
nlp: Boolean,
text: String? = null,
chatId: Long
): InlineKeyboardMarkup {
val categories =
categoriesService.getCategories(
"67af3c0f652da946a7dd9931",
"EXPENSE",
sortBy = "name",
direction = "ASC"
)
val keyboard = InlineKeyboardMarkup()
val buttonLines = mutableListOf<MutableList<InlineKeyboardButton>>()
val filteredCategories = mutableListOf<Category>()
if (nlp) {
if (text.isNullOrBlank()) {
throw TelegramBotException("Текст не может быть пустым", chatId)
}
val predictedCategories = nlpService.predictCategory(text, 0)
for (category in predictedCategories) {
filteredCategories.add(categories.first { it.name == category.category })
}
} else {
filteredCategories.addAll(categories)
}
filteredCategories.map { category ->
val btn = InlineKeyboardButton.builder().text("${category.icon} ${category.name}")
.callbackData("category_${category.id}").build()
if (category.name.length >= 15) {
// Если текст длинный, создаём отдельную строку для кнопки
buttonLines.add(mutableListOf(btn))
} else {
var isAdded = false
// Пытаемся добавить кнопку в существующую строку
for (line in buttonLines) {
if (line.size < 2 && (line.isEmpty() || line[0].text.length < 14)) {
line.add(btn)
isAdded = true
break
}
}
// Если не нашли подходящую строку, создаём новую
if (!isAdded) {
buttonLines.add(mutableListOf(btn))
} else {
}
}
}
val allCatsBtn = InlineKeyboardButton.builder().text("Все категории").callbackData("all_cats").build()
val backButton = InlineKeyboardButton.builder().text("Отмена").callbackData("cancel").build()
buttonLines.add(mutableListOf(allCatsBtn))
buttonLines.add(mutableListOf(backButton))
keyboard.keyboard = buttonLines
return keyboard
}
override fun getBotUsername(): String {
return telegramBotProperties.username
}
@@ -85,6 +153,12 @@ class BotService(
val deleteMsg = DeleteMessage(chatId, update.callbackQuery.message.messageId)
execute(deleteMsg)
sendMessage(chatId, "Введите сумму и комментарий когда будете готовы.")
} else if (update.callbackQuery.data == "all_cats") {
val editMessageReplyMarkup = EditMessageReplyMarkup()
editMessageReplyMarkup.chatId = chatId
editMessageReplyMarkup.messageId = update.callbackQuery.message.messageId
editMessageReplyMarkup.replyMarkup = constructCategoriesButtons(false, chatId = chatId.toLong())
execute(editMessageReplyMarkup)
}
}
}
@@ -149,53 +223,12 @@ class BotService(
comment += "$word "
}
val categories =
categoriesService.getCategories(
"67af3c0f652da946a7dd9931",
"EXPENSE",
sortBy = "name",
direction = "ASC"
)
.awaitSingle()
val keyboard = InlineKeyboardMarkup()
val buttonLines = mutableListOf<MutableList<InlineKeyboardButton>>()
categories.map { category ->
val btn = InlineKeyboardButton.builder().text("${category.icon} ${category.name}")
.callbackData("category_${category.id}").build()
if (category.name.length >= 15) {
// Если текст длинный, создаём отдельную строку для кнопки
buttonLines.add(mutableListOf(btn))
} else {
var isAdded = false
// Пытаемся добавить кнопку в существующую строку
for (line in buttonLines) {
if (line.size < 2 && (line.isEmpty() || line[0].text.length < 14)) {
line.add(btn)
isAdded = true
break
}
}
// Если не нашли подходящую строку, создаём новую
if (!isAdded) {
buttonLines.add(mutableListOf(btn))
} else {
}
}
}
val backButton = InlineKeyboardButton.builder().text("Отмена").callbackData("cancel").build()
buttonLines.add(mutableListOf(backButton))
keyboard.keyboard = buttonLines
val message = SendMessage()
message.chatId = chatId
val msg = "Выберите категорию"
message.text = msg
message.replyMarkup = keyboard
message.replyMarkup = constructCategoriesButtons(true, text, chatId.toLong())
val userState = BotUserState(user = user)
val chatData =
userState.data.find { it.chatId == chatId } ?: ChatData(chatId, state = BotStates.WAIT_CATEGORY)
@@ -229,7 +262,6 @@ class BotService(
sortBy = "name",
direction = "ASC"
)
.awaitSingle()
.first { it.id == update.callbackQuery.data.split("_")[1] }
val space = spaceService.getSpace("67af3c0f652da946a7dd9931")
val instantType = financialService.getTransactionTypes().first { it.code == "INSTANT" }

View File

@@ -3,6 +3,7 @@ package space.luminic.budgerapp.services
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.bson.Document
import org.bson.types.ObjectId
@@ -28,8 +29,8 @@ class CategoryService(
private val mongoTemplate: ReactiveMongoTemplate,
private val categoryMapper: CategoryMapper,
private val budgetRepo: BudgetRepo,
) {
private val nlpService: NLPService
) {
private val logger = LoggerFactory.getLogger(javaClass)
@@ -67,14 +68,15 @@ class CategoryService(
}.awaitFirstOrNull() ?: throw NotFoundException("Category not found")
}
fun getCategories(
@Cacheable(cacheNames = ["categories"])
suspend fun getCategories(
spaceId: String,
type: String? = null,
sortBy: String,
direction: String,
tagCode: String? = null
): Mono<List<Category>> {
tagCode: String? = null,
predict: String? = null
): MutableList<Category> {
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
val unwindSpace = unwind("spaceDetails")
val matchCriteria = mutableListOf<Criteria>()
@@ -99,7 +101,7 @@ class CategoryService(
).filterNotNull()
val aggregation = newAggregation(aggregationBuilder)
return mongoTemplate.aggregate(
val categories = mongoTemplate.aggregate(
aggregation, "categories", Document::class.java
)
.collectList() // Преобразуем Flux<Transaction> в Mono<List<Transaction>>
@@ -107,9 +109,20 @@ class CategoryService(
docs.map { doc ->
categoryMapper.fromDocument(doc)
}
}
}.awaitSingle().toMutableList()
val predictedCategories = mutableListOf<CategoryPrediction>()
if (!predict.isNullOrBlank()) {
predictedCategories.addAll(nlpService.predictCategory(predict, 1))
}
val filteredCategories = mutableListOf<Category>()
for (category in predictedCategories) {
categories.find { it.name == category.category }?.let { filteredCategories.add(it) }
}
return if (filteredCategories.isEmpty()) categories else filteredCategories
}
@Cacheable("categoryTypes")
fun getCategoryTypes(): List<CategoryType> {
val types = mutableListOf<CategoryType>()

View File

@@ -47,7 +47,7 @@ class FinancialService(
private val categoryRepo: CategoryRepo,
val transactionsMapper: TransactionsMapper,
val budgetMapper: BudgetMapper,
private val subscriptionService: SubscriptionService
private val subscriptionService: SubscriptionService,
) {
private val logger = LoggerFactory.getLogger(FinancialService::class.java)
@@ -57,8 +57,14 @@ class FinancialService(
transaction.space!!.id!!, budgetId = null, transaction.date, transaction.date
)
if (transaction.category.type.code == "EXPENSE") {
val budgetCategory = budget.categories.firstOrNull { it.category.id == transaction.category.id }
?: throw NotFoundException("Budget category not found in the budget")
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()
}
@@ -845,6 +851,7 @@ class FinancialService(
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
suspend fun createTransaction(space: Space, transaction: Transaction, user: User? = null): Transaction {
@@ -930,12 +937,15 @@ class FinancialService(
"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 -> "Изменения не обнаружены, но что то точно изменилось"
}

View File

@@ -0,0 +1,30 @@
package space.luminic.budgerapp.services
import kotlinx.coroutines.reactive.awaitSingle
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import space.luminic.budgerapp.configs.NLPConfig
import space.luminic.budgerapp.models.CategoryPrediction
@Service
class NLPService(
private val nlpConfig: NLPConfig,
private val webClient: WebClient = WebClient.builder()
.baseUrl(nlpConfig.address)
// .defaultHeader("Authorization", "Bearer YOUR_API_KEY")
.build(),
) {
suspend fun predictCategory(category: String, cloud: Int): List<CategoryPrediction> {
val response = webClient.get().uri("/predict?req=$category&cloud=$cloud")
.retrieve()
.bodyToFlux(CategoryPrediction::class.java)
.collectList()
.awaitSingle()
return response
}
}

View File

@@ -140,7 +140,7 @@ class SpaceService(
launch {
val categories =
categoryService.getCategories(objectId.toString(), null, "name", "ASC").awaitFirstOrNull().orEmpty()
categoryService.getCategories(objectId.toString(), null, "name", "ASC")
categoryRepo.deleteAll(categories).awaitFirstOrNull()
}
@@ -280,7 +280,6 @@ class SpaceService(
val existedTag = findTag(space, tagCode) ?: throw NoSuchElementException("Tag with code $tagCode not found")
val categoriesWithTag =
categoryService.getCategories(space.id!!, sortBy = "name", direction = "ASC", tagCode = existedTag.code)
.awaitSingleOrNull().orEmpty()
categoriesWithTag.map { cat ->
cat.tags.removeIf { it.code == tagCode } // Изменяем список тегов
cat