From cbcd21946cf5f1e6433d4dd2b84d9b5bd7c4d99f Mon Sep 17 00:00:00 2001 From: xds Date: Fri, 2 Jan 2026 14:28:42 +0300 Subject: [PATCH] + many --- build.gradle.kts | 1 + deploy.sh | 6 +- .../finance/api/DashboardController.kt | 35 ++ .../finance/api/TransactionController.kt | 2 +- .../luminic/finance/configs/SecurityConfig.kt | 2 +- .../luminic/finance/dtos/DashboardDataDTO.kt | 36 ++ .../finance/mappers/DashboardDataMapper.kt | 46 +++ .../luminic/finance/models/DashboardData.kt | 56 +++ .../luminic/finance/repos/DashboardRepo.kt | 14 + .../finance/repos/DashboardRepoImpl.kt | 367 ++++++++++++++++++ .../repos/RecurrentOperationRepoImpl.kt | 9 +- .../space/luminic/finance/repos/SpaceRepo.kt | 2 + .../luminic/finance/repos/SpaceRepoImpl.kt | 39 ++ .../finance/repos/TransactionRepoImpl.kt | 17 +- .../finance/services/DashboardService.kt | 9 + .../finance/services/DashboardServiceImpl.kt | 59 +++ .../services/RecurrentOperationServiceImpl.kt | 10 +- .../luminic/finance/services/Scheduler.kt | 12 +- .../luminic/finance/services/SpaceService.kt | 3 +- .../finance/services/SpaceServiceImpl.kt | 9 + .../finance/services/TransactionService.kt | 20 +- .../services/TransactionServiceImpl.kt | 15 +- .../gpt/DeepSeekCategorizationService.kt | 185 ++++++++- .../luminic/finance/services/gpt/GptClient.kt | 3 + .../services/gpt/QwenCategorizationService.kt | 8 +- .../finance/services/telegram/BotService.kt | 8 +- src/main/resources/application-dev.properties | 13 +- src/main/resources/db/migration/V31__.sql | 14 + src/main/resources/db/migration/V32__.sql | 2 + src/main/resources/db/migration/V33__.sql | 2 + src/main/resources/db/migration/V34__.sql | 4 + src/main/resources/db/migration/V35__.sql | 2 + 32 files changed, 972 insertions(+), 38 deletions(-) create mode 100644 src/main/kotlin/space/luminic/finance/api/DashboardController.kt create mode 100644 src/main/kotlin/space/luminic/finance/dtos/DashboardDataDTO.kt create mode 100644 src/main/kotlin/space/luminic/finance/mappers/DashboardDataMapper.kt create mode 100644 src/main/kotlin/space/luminic/finance/models/DashboardData.kt create mode 100644 src/main/kotlin/space/luminic/finance/repos/DashboardRepo.kt create mode 100644 src/main/kotlin/space/luminic/finance/repos/DashboardRepoImpl.kt create mode 100644 src/main/kotlin/space/luminic/finance/services/DashboardService.kt create mode 100644 src/main/kotlin/space/luminic/finance/services/DashboardServiceImpl.kt create mode 100644 src/main/resources/db/migration/V31__.sql create mode 100644 src/main/resources/db/migration/V32__.sql create mode 100644 src/main/resources/db/migration/V33__.sql create mode 100644 src/main/resources/db/migration/V34__.sql create mode 100644 src/main/resources/db/migration/V35__.sql diff --git a/build.gradle.kts b/build.gradle.kts index b295573..4018b17 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.7.3") implementation("org.jetbrains.kotlin.plugin.jpa:org.jetbrains.kotlin.plugin.jpa.gradle.plugin:1.9.25") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.0") implementation("io.jsonwebtoken:jjwt-api:0.11.5") implementation("io.jsonwebtoken:jjwt-impl:0.11.5") diff --git a/deploy.sh b/deploy.sh index 9725f50..20a57e2 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,10 +1,10 @@ #!/bin/bash ./gradlew bootJar || exit 1 -scp build/libs/luminic-space-v2.jar root@213.226.71.138:/root/luminic/space/back +scp build/libs/luminic-space-v2.jar root@31.59.58.220:/root/luminic/app/back -ssh root@213.226.71.138 " - cd /root/luminic/space/back && +ssh root@31.59.58.220 " + cd /root/luminic/app/back && docker compose up -d --build && docker restart back-app-1 " \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/api/DashboardController.kt b/src/main/kotlin/space/luminic/finance/api/DashboardController.kt new file mode 100644 index 0000000..1f5919d --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/api/DashboardController.kt @@ -0,0 +1,35 @@ +package space.luminic.finance.api + + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType +import io.swagger.v3.oas.annotations.security.SecurityScheme +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import space.luminic.finance.dtos.DashboardDataDTO +import space.luminic.finance.mappers.DashboardDataMapper.toDto +import space.luminic.finance.models.DashboardData +import space.luminic.finance.services.DashboardService +import java.time.LocalDate + +@RestController +@RequestMapping("/spaces/{spaceId}/dashboard") +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) +class DashboardController(private val dashboardService: DashboardService) { + @GetMapping + fun getDashboardData( + @PathVariable spaceId: Int, + @RequestParam startDate: LocalDate, + @RequestParam endDate: LocalDate + ): DashboardDataDTO { + return dashboardService.getDashboardData(spaceId, startDate, endDate).toDto() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/api/TransactionController.kt b/src/main/kotlin/space/luminic/finance/api/TransactionController.kt index d6adb11..3a20233 100644 --- a/src/main/kotlin/space/luminic/finance/api/TransactionController.kt +++ b/src/main/kotlin/space/luminic/finance/api/TransactionController.kt @@ -23,7 +23,7 @@ class TransactionController ( @PostMapping("/_search") fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List{ - return transactionService.getTransactions(spaceId, filter,"date", "DESC").map { it.toDto() } + return transactionService.getTransactions(spaceId, filter).map { it.toDto() } } @GetMapping("/{transactionId}") diff --git a/src/main/kotlin/space/luminic/finance/configs/SecurityConfig.kt b/src/main/kotlin/space/luminic/finance/configs/SecurityConfig.kt index a51ab77..7812ca7 100644 --- a/src/main/kotlin/space/luminic/finance/configs/SecurityConfig.kt +++ b/src/main/kotlin/space/luminic/finance/configs/SecurityConfig.kt @@ -50,7 +50,7 @@ class SecurityConfig( @Bean fun corsConfigurationSource(): CorsConfigurationSource { val cors = CorsConfiguration().apply { - allowedOrigins = listOf("https://app.luminic.space", "http://localhost:5173") + allowedOrigins = listOf("https://app.luminic.space", "http://localhost:5173", "http://localhost:5174") allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") allowedHeaders = listOf("*") allowCredentials = true diff --git a/src/main/kotlin/space/luminic/finance/dtos/DashboardDataDTO.kt b/src/main/kotlin/space/luminic/finance/dtos/DashboardDataDTO.kt new file mode 100644 index 0000000..de8f2f3 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/dtos/DashboardDataDTO.kt @@ -0,0 +1,36 @@ +package space.luminic.finance.dtos + +import space.luminic.finance.models.AISummaryData +import java.time.LocalDate + +data class DashboardDataDTO( + val analyzedText: AISummaryData? = null, + val totalExpense: Int, + val totalIncome: Int, + val balance: Int, + val categories: List, + val upcomingTransactions: List, + val recentTransactions: List, + val weeks: List +) + +data class DashboardWeeksDTO( + val startDate: LocalDate, + val endDate: LocalDate, + val expenseSum: Int, + val categories: List +) + +data class WeekCategoryDTO( + val categoryId: Int?, + val categoryName: String?, + val categoryIcon: String?, + val sum: Int? = 0 +) +data class DashboardCategoryDTO ( + val category: CategoryDTO, + val currentPeriodAmount: Int, + val previousPeriodAmount: Int, + val changeDiff: Double, + val changeDiffPercentage: Double, +) \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/mappers/DashboardDataMapper.kt b/src/main/kotlin/space/luminic/finance/mappers/DashboardDataMapper.kt new file mode 100644 index 0000000..2defaa4 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/mappers/DashboardDataMapper.kt @@ -0,0 +1,46 @@ +package space.luminic.finance.mappers + +import space.luminic.finance.dtos.DashboardCategoryDTO +import space.luminic.finance.dtos.DashboardDataDTO +import space.luminic.finance.dtos.DashboardWeeksDTO +import space.luminic.finance.dtos.WeekCategoryDTO +import space.luminic.finance.mappers.CategoryMapper.toDto +import space.luminic.finance.mappers.TransactionMapper.toDto +import space.luminic.finance.models.DashboardCategory +import space.luminic.finance.models.DashboardData +import space.luminic.finance.models.DashboardWeeks +import space.luminic.finance.models.WeekCategory + +object DashboardDataMapper { + fun DashboardData.toDto() = DashboardDataDTO( + analyzedText = this.analyzedText, + totalExpense = this.totalExpense, + totalIncome = this.totalIncome, + balance = this.balance, + categories = this.categories.map { it.toDto() }, + upcomingTransactions = this.upcomingTransactions.map { it.toDto() }, + recentTransactions = this.recentTransactions.map { it.toDto() }, + weeks = this.weeks.map { it.toDto() }, + ) + fun DashboardWeeks.toDto() = DashboardWeeksDTO( + startDate = this.startDate, + endDate = this.endDate, + expenseSum = this.expenseSum, + categories = this.categories.map { it.toDto() }, + ) + fun WeekCategory.toDto() = WeekCategoryDTO( + categoryId = this.categoryId, + categoryName = this.categoryName, + categoryIcon = this.categoryIcon, + sum = this.sum + ) + fun DashboardCategory.toDto() = DashboardCategoryDTO( + category = this.category.toDto(), + currentPeriodAmount = this.currentPeriodAmount, + previousPeriodAmount = this.previousPeriodAmount, + changeDiff = this.changeDiff, + changeDiffPercentage = this.changeDiffPercentage, + ) +} + + diff --git a/src/main/kotlin/space/luminic/finance/models/DashboardData.kt b/src/main/kotlin/space/luminic/finance/models/DashboardData.kt new file mode 100644 index 0000000..fed52f9 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/models/DashboardData.kt @@ -0,0 +1,56 @@ +package space.luminic.finance.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import lombok.AllArgsConstructor +import java.time.LocalDate + +data class DashboardData( + var analyzedText: AISummaryData? = null, + val totalExpense: Int, + val totalIncome: Int, + val balance: Int, + val prevTotalExpense: Int, + val prevTotalIncome: Int, + val prevBalance: Int, + val prevCurIncomeChange: Double, + val prevCurExpenseChange: Double, + val categories: List, + val upcomingTransactions: List, + val recentTransactions: List, + val weeks: List +) + +@AllArgsConstructor +data class AISummaryData( +//) "Общая оценка периода" +//2) "Анализ по категориям" +//3) "Ключевые инсайты" +//4) "Рекомендации" + val common: String, + val categoryAnalysis: String, + val keyInsights: String, + val recommendations: String, +) + +data class DashboardWeeks( + val startDate: LocalDate, + val endDate: LocalDate, + val expenseSum: Int, + val categories: List +) + +@Serializable +data class WeekCategory( + @SerialName("category_id") val categoryId: Int?, + @SerialName("category_name") val categoryName: String?, + @SerialName("category_icon") val categoryIcon: String?, + val sum: Int? = 0 +) +data class DashboardCategory( + val category: Category, + val currentPeriodAmount: Int, + val previousPeriodAmount: Int, + val changeDiff: Double, + val changeDiffPercentage: Double, +) \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/DashboardRepo.kt b/src/main/kotlin/space/luminic/finance/repos/DashboardRepo.kt new file mode 100644 index 0000000..2880692 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/repos/DashboardRepo.kt @@ -0,0 +1,14 @@ +package space.luminic.finance.repos + +import space.luminic.finance.models.AISummaryData +import space.luminic.finance.models.DashboardData +import java.time.LocalDate +import java.time.LocalDateTime + +interface DashboardRepo { + fun getData(spaceId: Int, startDate: LocalDate, endDate: LocalDate): DashboardData + fun savePeriodAnalyze(spaceId: Int, startDate: LocalDate, endDate: LocalDate, analyzedText: String) + fun getPeriodAnalyzedText(spaceId: Int, startDate: LocalDate, endDate: LocalDate): AISummaryData? + fun getAnalyzeLastRun(): LocalDateTime? + fun createRun(spaces: List) +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/DashboardRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/DashboardRepoImpl.kt new file mode 100644 index 0000000..1f21c83 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/repos/DashboardRepoImpl.kt @@ -0,0 +1,367 @@ +package space.luminic.finance.repos + +import kotlinx.serialization.json.Json +import org.springframework.dao.EmptyResultDataAccessException +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Repository +import space.luminic.finance.models.* +import space.luminic.finance.services.TransactionService +import java.time.LocalDate +import java.time.LocalDateTime + +@Repository +class DashboardRepoImpl( + private val transactionRepo: TransactionRepo, + private val jdbcTemplate: NamedParameterJdbcTemplate +) : DashboardRepo { + override fun getData( + spaceId: Int, + startDate: LocalDate, + endDate: LocalDate + ): DashboardData { + val getSumsSql = """ + WITH bounds AS (SELECT :startDate::date AS cur_start, + :endDate::date AS cur_end, + (:startDate::date - INTERVAL '1 month') AS prev_start, + (:endDate::date - INTERVAL '1 month') AS prev_end), + sums_cur_period AS (SELECT COALESCE(SUM(amount) FILTER (WHERE type = 'EXPENSE'), 0) AS sum_expense, + COALESCE(SUM(amount) FILTER (WHERE type = 'INCOME'), 0) AS sum_income + FROM finance.transactions + CROSS JOIN bounds b + WHERE space_id = :spaceId + AND kind = 'INSTANT' + AND is_deleted = false + AND date BETWEEN b.cur_start AND b.cur_end), + sums_prev_period AS (SELECT COALESCE(SUM(amount) FILTER (WHERE type = 'EXPENSE'), 0) AS sum_expense, + COALESCE(SUM(amount) FILTER (WHERE type = 'INCOME'), 0) AS sum_income + FROM finance.transactions + CROSS JOIN bounds b + WHERE space_id = :spaceId + AND kind = 'INSTANT' + AND is_deleted = false + AND date BETWEEN b.prev_start AND b.prev_end) +SELECT cur.sum_expense AS cur_sum_expense, + cur.sum_income AS cur_sum_income, + cur.sum_income - cur.sum_expense AS cur_balance, + prev.sum_expense AS prev_sum_expense, + prev.sum_income AS prev_sum_income, + prev.sum_income - prev.sum_expense AS prev_balance, + CASE + WHEN prev.sum_expense != 0 THEN (cur.sum_expense / prev.sum_expense) * 100 + ELSE 0 END as prev_cur_expense_change, + CASE + WHEN prev.sum_income != 0 THEN (cur.sum_income / prev.sum_income) * 100 + ELSE 0 END as prev_cur_income_change +FROM sums_cur_period cur + CROSS JOIN sums_prev_period prev; + """.trimIndent() + val params = mutableMapOf( + "spaceId" to spaceId, + "startDate" to startDate, + "endDate" to endDate, + ) + val expenseIncomeSumResult = jdbcTemplate.queryForObject(getSumsSql, params) { rs, _ -> + mapOf( + "cur_sum_expense" to rs.getDouble("cur_sum_expense"), + "cur_sum_income" to rs.getDouble("cur_sum_income"), + "cur_balance" to rs.getDouble("cur_balance"), + "prev_sum_expense" to rs.getDouble("prev_sum_expense"), + "prev_sum_income" to rs.getDouble("prev_sum_income"), + "prev_balance" to rs.getDouble("prev_balance"), + "prev_cur_expense_change" to rs.getDouble("prev_cur_expense_change"), + "prev_cur_income_change" to rs.getDouble("prev_cur_income_change"), + ) + } + val getCatsSql = """ + WITH bounds AS ( + SELECT + :startDate::date AS cur_start, + :endDate::date AS cur_end, + (:startDate::date - INTERVAL '1 month') AS prev_start, + (:endDate::date - INTERVAL '1 month') AS prev_end + ), + agg AS ( + SELECT + c.*, + COALESCE( + SUM( + CASE + WHEN t.date BETWEEN b.cur_start AND b.cur_end + THEN t.amount + END + ), + 0 + ) AS sum_this_period, + + COALESCE( + SUM( + CASE + WHEN t.date BETWEEN b.prev_start AND b.prev_end + THEN t.amount + END + ), + 0 + ) AS sum_previous_period + FROM finance.categories c + CROSS JOIN bounds b + LEFT JOIN finance.transactions t + ON t.category_id = c.id + where t.space_id = :spaceId and t.kind = 'INSTANT' + AND t.is_deleted = false + + GROUP BY + c.id, c.name + ) + SELECT + agg.*, + + agg.sum_this_period - agg.sum_previous_period AS diff_period, + CASE + WHEN agg.sum_previous_period = 0 THEN NULL -- или 0, если так удобнее + ELSE (agg.sum_this_period::numeric / agg.sum_previous_period::numeric - 1) * 100 + END AS diff_percent + FROM agg + ORDER BY agg.name; + """.trimIndent() + val resultCatsSql = jdbcTemplate.query(getCatsSql, params) { rs, _ -> + DashboardCategory( + Category( + id = rs.getInt("id"), + type = Category.CategoryType.valueOf(rs.getString("type")), + name = rs.getString("name"), + description = rs.getString("description"), + icon = rs.getString("icon"), + isDeleted = rs.getBoolean("is_deleted"), + createdAt = rs.getTimestamp("created_at").toInstant(), + updatedAt = if (rs.getTimestamp("updated_at") != null) rs.getTimestamp("updated_at") + .toInstant() else null + ), + rs.getInt("sum_this_period"), + rs.getInt("sum_previous_period"), + rs.getDouble("diff_period"), + rs.getDouble("diff_percent"), + ) + } + val weeksSql = """WITH bounds AS ( + SELECT + date_trunc('week', current_date)::date AS cur_week_start +), +weeks AS ( + -- 4 недели: текущая + 3 предыдущие + SELECT + (cur_week_start - (n * INTERVAL '1 week'))::date AS week_start + FROM bounds, + generate_series(0, 3) AS g(n) +), +tx AS ( + SELECT + date_trunc('week', t.date)::date AS week_start, + t.amount, + c.id AS category_id, + c.name AS category_name, + c.icon AS category_icon + FROM finance.transactions t + JOIN finance.categories c ON c.id = t.category_id + JOIN bounds b ON TRUE + WHERE t.type = 'EXPENSE' + AND t.kind = 'INSTANT' + AND t.is_deleted = false + AND t.date >= b.cur_week_start - INTERVAL '3 weeks' + AND t.date < b.cur_week_start + INTERVAL '1 week' +), +weekly_by_cat AS ( + SELECT + w.week_start, + tx.category_id, + tx.category_name, + tx.category_icon, + SUM(tx.amount) AS category_sum + FROM weeks w + LEFT JOIN tx + ON tx.week_start = w.week_start + GROUP BY + w.week_start, tx.category_id, tx.category_name, tx.category_icon +), +weekly_totals AS ( + SELECT + week_start, + COALESCE(SUM(category_sum), 0) AS week_expense_sum + FROM weekly_by_cat + GROUP BY week_start +), +ranked AS ( + SELECT + wbc.*, + ROW_NUMBER() OVER ( + PARTITION BY week_start + ORDER BY category_sum DESC + ) AS rn + FROM weekly_by_cat wbc +) +SELECT + w.week_start, + (w.week_start + INTERVAL '6 days')::date AS week_end, + wt.week_expense_sum, + CASE + WHEN COUNT(r.*) FILTER (WHERE r.rn <= 5) = 0 THEN '[]'::jsonb + ELSE jsonb_agg( + jsonb_build_object( + 'category_id', r.category_id, + 'category_name', r.category_name, + 'category_icon', r.category_icon, + 'sum', r.category_sum + ) + ORDER BY r.category_sum DESC + ) + END AS top_5_categories +FROM weeks w +LEFT JOIN weekly_totals wt ON wt.week_start = w.week_start +LEFT JOIN ranked r + ON r.week_start = w.week_start + AND r.rn <= 5 +GROUP BY + w.week_start, + wt.week_expense_sum +ORDER BY + w.week_start""" + val weeks = jdbcTemplate.query(weeksSql, params) { rs, _ -> + val weekStart = rs.getDate("week_start").toLocalDate() + val weekEnd = rs.getDate("week_end").toLocalDate() + + val expenseSum = rs.getBigDecimal("week_expense_sum")?.toInt() ?: 0 + // или getInt, если в БД тип точно int/numeric(…) + + val categoriesJson = rs.getString("top_5_categories") ?: "[]" + + val categories: List = + Json.decodeFromString(categoriesJson) // тип T выведется из переменной + + DashboardWeeks( + startDate = weekStart, + endDate = weekEnd, + expenseSum = expenseSum, + categories = categories + ) + } + return DashboardData( + totalExpense = expenseIncomeSumResult?.get("cur_sum_expense")?.toInt() ?: 0, + totalIncome = expenseIncomeSumResult?.get("cur_sum_income")?.toInt() ?: 0, + balance = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0, + prevTotalExpense = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0, + prevTotalIncome = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0, + prevBalance = expenseIncomeSumResult?.get("cur_balance")?.toInt() ?: 0, + prevCurIncomeChange = expenseIncomeSumResult?.get("cur_balance") ?: 0.0, + prevCurExpenseChange = expenseIncomeSumResult?.get("cur_balance") ?: 0.0, + categories = resultCatsSql, + upcomingTransactions = transactionRepo.findAllBySpaceId( + spaceId, + TransactionService.TransactionsFilter( + kind = Transaction.TransactionKind.PLANNING, + dateFrom = startDate, + dateTo = endDate, + isDone = false, + limit = 5 + ) + ), + recentTransactions = transactionRepo.findAllBySpaceId( + spaceId, + TransactionService.TransactionsFilter( + kind = Transaction.TransactionKind.INSTANT, + dateFrom = startDate, + dateTo = endDate, + limit = 5, + sorts = listOf(mapOf("sortBy" to "date", "sortDirection" to "DESC")) + ) + ), + weeks = weeks, + + ) + } + + override fun savePeriodAnalyze( + spaceId: Int, + startDate: LocalDate, + endDate: LocalDate, + analyzedText: String + ) { + val sql = """ + INSERT INTO finance.period_analyze ( + space_id, + period_start, + period_end, + analyze_text +) +VALUES ( + :spaceId, + :periodStart, + :periodEnd, + :analyzeText::json +) +ON CONFLICT (space_id, period_start, period_end) +DO UPDATE SET + analyze_text = EXCLUDED.analyze_text::json, + last_analyze_at = now(); + """.trimIndent() + val params = mapOf( + "spaceId" to spaceId, + "periodStart" to startDate, + "periodEnd" to endDate, + "analyzeText" to analyzedText + ) + jdbcTemplate.update(sql, params) + } + + override fun getPeriodAnalyzedText( + spaceId: Int, + startDate: LocalDate, + endDate: LocalDate + ): AISummaryData? { + val sql = + """SELECT analyze_text->>'common' AS common, + analyze_text->>'categoryAnalysis' AS category_analysis, + analyze_text->>'keyInsights' AS key_insights, + analyze_text->>'recommendations' AS recommendations + from finance.period_analyze WHERE space_id = :spaceId AND period_start = :periodStart AND period_end = :periodEnd""" + val params = mapOf( + "spaceId" to spaceId, + "periodStart" to startDate, + "periodEnd" to endDate + + ) + return try { + return jdbcTemplate + .query(sql, params) { rs, _ -> + AISummaryData( + common = rs.getString("common"), + categoryAnalysis = rs.getString("category_analysis"), + keyInsights = rs.getString("key_insights"), + recommendations = rs.getString("recommendations") + ) + } + .firstOrNull() + } catch (e: EmptyResultDataAccessException) { + null + } + } + + override fun getAnalyzeLastRun(): LocalDateTime? { + val sql = """ + SELECT MAX(run_at) AS last_run + FROM finance.analyze_runs + """ + + return jdbcTemplate + .query(sql) { rs, _ -> + rs.getTimestamp("last_run")?.toLocalDateTime() + } + .firstOrNull() + } + + override fun createRun(spaces: List) { + val sql = """INSERT INTO finance.analyze_runs VALUES (now(), :spaces);""" + val params = mapOf( + "spaces" to spaces.joinToString(",") + ) + jdbcTemplate.update(sql, params) + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt index 7508e4a..2b2b241 100644 --- a/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/RecurrentOperationRepoImpl.kt @@ -73,7 +73,7 @@ class RecurrentOperationRepoImpl( join finance.users su on s.owner_id = su.id join finance.categories c on ro.category_id = c.id join finance.users r_created_by on ro.created_by_id = r_created_by.id - where ro.space_id = :spaceId + where ro.space_id = :spaceId and ro.is_deleted = false order by ro.date, ro.id """.trimIndent() val params = mapOf("spaceId" to spaceId) @@ -109,7 +109,7 @@ class RecurrentOperationRepoImpl( join finance.users su on s.owner_id = su.id join finance.categories c on ro.category_id = c.id join finance.users r_created_by on ro.created_by_id = r_created_by.id - where ro.space_id = :spaceId and ro.id = :id + where ro.space_id = :spaceId and ro.id = :id and ro.is_deleted = false; """.trimIndent() val params = mapOf("spaceId" to spaceId, "id" to id) return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull() @@ -143,7 +143,7 @@ class RecurrentOperationRepoImpl( join finance.users su on s.owner_id = su.id join finance.categories c on ro.category_id = c.id join finance.users r_created_by on ro.created_by_id = r_created_by.id - where ro.date = :date + where ro.date = :date and ro.is_deleted = false """.trimIndent() val params = mapOf( "date" to date) return jdbcTemplate.query(sql, params, operationRowMapper()) @@ -203,7 +203,8 @@ class RecurrentOperationRepoImpl( override fun delete(id: Int) { val sql = """ - delete from finance.recurrent_operations + update finance.recurrent_operations + set is_deleted = true where id = :id """.trimIndent() val params = mapOf("id" to id) diff --git a/src/main/kotlin/space/luminic/finance/repos/SpaceRepo.kt b/src/main/kotlin/space/luminic/finance/repos/SpaceRepo.kt index 6c84acd..1e9d65e 100644 --- a/src/main/kotlin/space/luminic/finance/repos/SpaceRepo.kt +++ b/src/main/kotlin/space/luminic/finance/repos/SpaceRepo.kt @@ -2,9 +2,11 @@ package space.luminic.finance.repos import org.springframework.stereotype.Repository import space.luminic.finance.models.Space +import java.time.LocalDateTime @Repository interface SpaceRepo { + fun findSpacesForScheduling(lastRun: LocalDateTime): List fun findSpacesAvailableForUser(userId: Int): List fun findSpaceById(id: Int, userId: Int): Space? fun create(space: Space, createdById: Int): Int diff --git a/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt index b7ae6aa..9bcf431 100644 --- a/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt @@ -6,6 +6,7 @@ import org.springframework.stereotype.Repository import space.luminic.finance.dtos.SpaceDTO import space.luminic.finance.models.Space import space.luminic.finance.models.User +import java.time.LocalDateTime @Repository class SpaceRepoImpl( @@ -84,6 +85,44 @@ class SpaceRepoImpl( return spaceMap.map { it.value } } + override fun findSpacesForScheduling(lastRun: LocalDateTime): List { + val sql = """ + select s.id as s_id, + s.name as s_name, + s.created_at as s_created_at, + true as s_is_owner, + s.owner_id as s_owner_id, + ou.username as s_owner_username, + ou.first_name as s_owner_firstname, + ou.tg_id as s_owner_tg_id, + sp.participants_id as sp_uid, + u.username as sp_username, + u.first_name as sp_first_name, + s.created_at as s_created_at, + s.created_by_id as s_created_by, + cau.username as s_created_by_username, + cau.first_name as s_created_by_firstname, + s.updated_at as s_updated_at, + s.updated_by_id as s_updated_by, + uau.username as s_updated_by_username, + uau.first_name as s_updated_by_firstname + + from finance.spaces s + join finance.users ou on s.owner_id = ou.id + join finance.spaces_participants sp on sp.space_id = s.id + join finance.users u on sp.participants_id = u.id + left join finance.users cau on s.created_by_id = cau.id + left join finance.users uau on s.updated_by_id = uau.id + left join finance.transactions t on t.space_id = s.id + where s.is_deleted = false and t.created_at >= :lastRun + group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, + uau.username, uau.first_name; + """.trimMargin() + val params = mapOf("lastRun" to lastRun) + val spaces = jdbcTemplate.query(sql, params, shortRowMapper()) + return collectParticipants(spaces) + } + override fun findSpacesAvailableForUser(userId: Int): List { val sql = """ diff --git a/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt index 796399b..5754065 100644 --- a/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/TransactionRepoImpl.kt @@ -98,10 +98,18 @@ class TransactionRepoImpl( sql += " AND t.type = :type" params.put("type", it.name) } + filters.query?.let { + sql += " AND lower(t.comment) LIKE ('%${it.lowercase()}%')" + params["query"] = it.lowercase() + } filters.kind?.let { sql += " AND t.kind = :kind" params.put("kind", it.name) } + filters.categoriesIds?.let { + sql += " AND t.category_id in (:categoriesIds)" + params.put("categoriesIds", it) + } filters.isDone?.let { sql += " AND t.is_done = :isDone" params.put("isDone", it) @@ -114,8 +122,15 @@ class TransactionRepoImpl( sql += " AND t.date <= :dateTo" params.put("dateTo", it) } + sql += if (filters.sorts.isNotEmpty()) { + var orderStatement = " ORDER BY " + orderStatement += filters.sorts.joinToString(",") { map -> + map.entries.joinToString(" ") { (_, v) -> v } + } + orderStatement + } else " ORDER BY t.date DESC, t.id" + sql += """ - ORDER BY t.date, t.id OFFSET :offset ROWS FETCH FIRST :limit ROWS ONLY""" diff --git a/src/main/kotlin/space/luminic/finance/services/DashboardService.kt b/src/main/kotlin/space/luminic/finance/services/DashboardService.kt new file mode 100644 index 0000000..ff89de8 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/DashboardService.kt @@ -0,0 +1,9 @@ +package space.luminic.finance.services + +import space.luminic.finance.models.DashboardData +import java.time.LocalDate + +interface DashboardService { + fun getDashboardData(spaceId: Int, startDate: LocalDate, endDate: LocalDate): DashboardData + fun analyzePeriodScheduled() +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/DashboardServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/DashboardServiceImpl.kt new file mode 100644 index 0000000..07f1275 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/DashboardServiceImpl.kt @@ -0,0 +1,59 @@ +package space.luminic.finance.services + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Service +import space.luminic.finance.models.AISummaryData +import space.luminic.finance.models.DashboardData +import space.luminic.finance.repos.DashboardRepo +import space.luminic.finance.services.gpt.GptClient +import java.time.LocalDate + +@Service +class DashboardServiceImpl( + private val authService: AuthService, + private val spaceService: SpaceService, + private val dashboardRepo: DashboardRepo, + @Qualifier("dsCategorizationService") private val gptClient: GptClient +) : DashboardService { + + private val om = ObjectMapper() + override fun getDashboardData(spaceId: Int, startDate: LocalDate, endDate: LocalDate): DashboardData { + val userId = authService.getSecurityUserId() + spaceService.getSpace(spaceId, userId) + val data = dashboardRepo.getData(spaceId, startDate, endDate) + data.analyzedText = dashboardRepo.getPeriodAnalyzedText(spaceId, startDate, endDate) + return data + } + + override fun analyzePeriodScheduled() { + val today = LocalDate.now() + + + val startDate = if (today.dayOfMonth < 10) { + today.minusMonths(1).withDayOfMonth(10) + } else { + today.withDayOfMonth(10) + } + + + val endDate = if (today.dayOfMonth >= 10) { + today.plusMonths(1).withDayOfMonth(9) + } else { + today.withDayOfMonth(9) + } + val lastRun = dashboardRepo.getAnalyzeLastRun() + val spaces = spaceService.getSpacesForScheduling(lastRun) + spaces.forEach { space -> + val data = dashboardRepo.getData(space.id!!, startDate, endDate) + dashboardRepo.savePeriodAnalyze( + space.id!!, + startDate, + endDate, + gptClient.analyzePeriod(startDate, endDate, data) + ) + dashboardRepo.createRun(spaces.map { it.id!! }) + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt index 75f3463..1a6688f 100644 --- a/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/RecurrentOperationServiceImpl.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Isolation +import org.springframework.transaction.annotation.Transactional import space.luminic.finance.dtos.RecurrentOperationDTO import space.luminic.finance.models.Category import space.luminic.finance.models.NotFoundException @@ -72,7 +74,7 @@ class RecurrentOperationServiceImpl( category = category, comment = creatingOperation.name, amount = creatingOperation.amount, - date = date.plusMonths(i.toLong()), + date = if (now.dayOfMonth < 10 && operation.date > 10) date.plusMonths((i-1).toLong()) else date.plusMonths(i.toLong()), recurrentId = createdRecurrentId ) ) @@ -110,6 +112,7 @@ class RecurrentOperationServiceImpl( type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME, category = updatedOperation.category, comment = operation.name, + amount = operation.amount, date = LocalDate.of( it.date.year, it.date.monthValue, @@ -125,10 +128,11 @@ class RecurrentOperationServiceImpl( } } } - + @Transactional(isolation = Isolation.READ_UNCOMMITTED) override fun delete(spaceId: Int, id: Int) { val userId = authService.getSecurityUserId() - spaceRepo.findSpaceById(spaceId, userId) + spaceRepo.findSpaceById(spaceId, userId)?: throw NotFoundException("Cannot find space with id $id") + transactionRepo.deleteByRecurrentId(spaceId, id) recurrentOperationRepo.delete(id) } diff --git a/src/main/kotlin/space/luminic/finance/services/Scheduler.kt b/src/main/kotlin/space/luminic/finance/services/Scheduler.kt index 36350ee..2987c7e 100644 --- a/src/main/kotlin/space/luminic/finance/services/Scheduler.kt +++ b/src/main/kotlin/space/luminic/finance/services/Scheduler.kt @@ -4,12 +4,14 @@ import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit @EnableScheduling @Service class Scheduler( private val recurrentOperationService: RecurrentOperationService, - private val notificationService: NotificationService + private val notificationService: NotificationService, + private val dashboardService: DashboardService ) { private val log = LoggerFactory.getLogger(Scheduler::class.java) @@ -19,9 +21,15 @@ class Scheduler( recurrentOperationService.createRecurrentTransactions() } - @Scheduled(cron = "0 30 19 * * *") + @Scheduled(cron = "0 30 16 * * *") fun sendDailyReminders() { log.info("Sending daily reminders") notificationService.sendDailyReminder() } + +// @Scheduled(cron = "0 0 */3 * * *") + @Scheduled(fixedRate = 3, timeUnit =TimeUnit.HOURS) + fun analyzePeriodScheduled() { + dashboardService.analyzePeriodScheduled() + } } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/SpaceService.kt b/src/main/kotlin/space/luminic/finance/services/SpaceService.kt index 823beac..31eb7b8 100644 --- a/src/main/kotlin/space/luminic/finance/services/SpaceService.kt +++ b/src/main/kotlin/space/luminic/finance/services/SpaceService.kt @@ -2,9 +2,10 @@ package space.luminic.finance.services import space.luminic.finance.dtos.SpaceDTO import space.luminic.finance.models.Space +import java.time.LocalDateTime interface SpaceService { - + fun getSpacesForScheduling(lastRun: LocalDateTime? = null): List fun checkSpace(spaceId: Int): Space fun getSpaces(): List fun getSpace(id: Int, userId: Int?): Space diff --git a/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt index dcf8fa2..7efcaa9 100644 --- a/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/SpaceServiceImpl.kt @@ -6,6 +6,9 @@ import space.luminic.finance.dtos.SpaceDTO import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.Space import space.luminic.finance.repos.SpaceRepo +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset @Service class SpaceServiceImpl( @@ -13,6 +16,11 @@ class SpaceServiceImpl( private val spaceRepo: SpaceRepo, private val categoryService: CategoryService ) : SpaceService { + override fun getSpacesForScheduling(lastRun: LocalDateTime?): List { + val lastRunDate = lastRun ?: LocalDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC) + return spaceRepo.findSpacesForScheduling(lastRunDate) + } + override fun checkSpace(spaceId: Int): Space { return getSpace(spaceId, null) } @@ -66,6 +74,7 @@ class SpaceServiceImpl( ) return spaceRepo.update(updatedSpace, userId) } + @Transactional override fun deleteSpace(spaceId: Int) { spaceRepo.delete(spaceId) diff --git a/src/main/kotlin/space/luminic/finance/services/TransactionService.kt b/src/main/kotlin/space/luminic/finance/services/TransactionService.kt index bb2522d..7a92489 100644 --- a/src/main/kotlin/space/luminic/finance/services/TransactionService.kt +++ b/src/main/kotlin/space/luminic/finance/services/TransactionService.kt @@ -7,20 +7,34 @@ import java.time.LocalDate interface TransactionService { data class TransactionsFilter( + val query : String? = null, val type: Transaction.TransactionType? = null, val kind: Transaction.TransactionKind? = null, + val categoriesIds: Set? = null, val dateFrom: LocalDate? = null, val dateTo: LocalDate? = null, val isDone: Boolean? = null, val offset: Int = 0, val limit: Int = 10, + val sorts: List> = listOf( + mapOf( + "sortBy" to "t.created_at", + "sortDirection" to SortDirection.DESC.name + ), + mapOf( + "sortBy" to "t.id", + "sortDirection" to SortDirection.ASC.name + ) + ), ) + enum class SortDirection { + ASC, DESC + } + fun getTransactions( spaceId: Int, - filter: TransactionsFilter, - sortBy: String, - sortDirection: String + filter: TransactionsFilter ): List fun getTransaction(spaceId: Int, transactionId: Int): Transaction diff --git a/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt index dbca062..986e2a1 100644 --- a/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt @@ -11,7 +11,8 @@ import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.Transaction import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.services.gpt.CategorizeService -import java.time.format.DateTimeFormatter +import java.time.LocalDate +import java.time.LocalDateTime @Service class TransactionServiceImpl( @@ -27,9 +28,7 @@ class TransactionServiceImpl( override fun getTransactions( spaceId: Int, - filter: TransactionService.TransactionsFilter, - sortBy: String, - sortDirection: String + filter: TransactionService.TransactionsFilter ): List { val transactions = transactionRepo.findAllBySpaceId(spaceId, filter) return transactions @@ -109,12 +108,18 @@ class TransactionServiceImpl( val space = spaceService.getSpace(spaceId, null) val existingTransaction = getTransaction(space.id!!, transactionId) val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) } + val today = LocalDate.now() + val newKind = if ( + !existingTransaction.isDone && + transaction.isDone && + (today.isAfter(transaction.date) || today.isEqual(transaction.date)) + ) Transaction.TransactionKind.INSTANT else transaction.kind val updatedTransaction = Transaction( id = existingTransaction.id, space = existingTransaction.space, parent = existingTransaction.parent, type = transaction.type, - kind = transaction.kind, + kind = newKind, category = newCategory, comment = transaction.comment, amount = transaction.amount, diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/DeepSeekCategorizationService.kt b/src/main/kotlin/space/luminic/finance/services/gpt/DeepSeekCategorizationService.kt index 13dc4f6..7eb7cb0 100644 --- a/src/main/kotlin/space/luminic/finance/services/gpt/DeepSeekCategorizationService.kt +++ b/src/main/kotlin/space/luminic/finance/services/gpt/DeepSeekCategorizationService.kt @@ -1,9 +1,12 @@ package space.luminic.finance.services.gpt +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import okhttp3.* import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody import org.slf4j.LoggerFactory @@ -11,7 +14,10 @@ import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import space.luminic.finance.models.Category +import space.luminic.finance.models.DashboardData import space.luminic.finance.models.Transaction +import java.time.LocalDate +import java.util.concurrent.TimeUnit @Service("dsCategorizationService") @@ -21,7 +27,8 @@ class DeepSeekCategorizationService( private val endpoint = "https://api.deepseek.com/v1" private val mapper = jacksonObjectMapper() - private val client = OkHttpClient() + + private val client = OkHttpClient().newBuilder().callTimeout(5, TimeUnit.MINUTES).readTimeout(1, TimeUnit.MINUTES).build() private val logger = LoggerFactory.getLogger(javaClass) override fun suggestCategory(tx: Transaction, categories: List): CategorySuggestion { @@ -74,7 +81,181 @@ class DeepSeekCategorizationService( // val (idStr, name, confStr) = match.destructured val idStr = text - return CategorySuggestion(idStr.toInt(), ) + return CategorySuggestion(idStr.toInt()) + } + } + + override fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String { + mapper.registerModule(JavaTimeModule()); + if (dashboardData.totalIncome == 0 && dashboardData.totalExpense == 0){ + return "Начните записывать траты и поступления и мы начнем их анализировать!" + } else { + var prompt = """ +You are a personal finance analyst. +Your task is to analyze the user’s budget period and provide: +1) A summary, +2) Key insights, +3) Explanations of deviations, +4) Actionable recommendations. + +The answer MUST be written in Russian. + +IMPORTANT: The user’s budget is calculated in custom periods, not strict calendar months. A period is usually about a month, for example: starts on 10 November and ends on 9 December. The period may be ongoing or already finished. + +Here is the data for the current budget period: + +Period label (for display, may be month-like): {{period}} +Current income: {{income}} +Current expense: {{expense}} +Net result: {{net}} + +Comparison with the previous period: + - Income: {{income_change_percent}}% + - Expense: {{expense_change_percent}}% + - Net result change: {{net_change_value}} ({{net_change_percent}}%) + +Expense categories: +{{categories_list}} + +(format for categories_list example: +"Еда — план {{plan}}, факт {{actual}}, отклонение {{diff}} ({{diff_percent}}%)" +one category per line) + +Response formatting requirements: + +1. The final response MUST be valid json.: +2. Use a HTML text formatting like (, , only) in sections. + +"common" - Общая оценка периода +"categoryAnalysis" - Анализ по категориям +"keyInsights" - Ключевые инсайты +"recommendations" - Рекомендации + +{ "common": "string", + "categoryAnalysis": "string", + "keyInsights": "string", + "recommendations": "string", +} + +Content requirements for each section (in Russian): + +Section 1: "Общая оценка периода" +- Give a short 1–2 sentence overview of how the budget period is going financially (баланс, общая тенденция, спокойный/напряжённый период и т.п.). +- Mention overall direction compared to the previous period (лучше/хуже/на том же уровне), but do NOT restate all numeric values exactly. +- If the period is still ongoing, clearly indicate that the evaluation is preliminary (e.g. "на данный момент", "пока", "в текущем периоде"). + +Section 2: "Анализ по категориям" +- Explain which 2–4 categories had the biggest impact on the budget (самые крупные, самые проблемные или самые улучшившиеся). +- Опиши, как изменились привычки в этих категориях (больше/меньше, чем обычно или чем в прошлом периоде, почему это может быть так). +- Use a list-style text with "-" at the start of lines for key category observations. + +Section 3: "Ключевые инсайты" +- Highlight 2–4 important insights about the user’s behavior and spending patterns in this period. +- For each insight, briefly explain the possible cause or context (например, рост доставки еды, больше поездок, крупные единовременные покупки). +- Use "-" bullets (text-only, not HTML list tags). + +Section 4: "Рекомендации" +- Provide 3–5 concrete, actionable recommendations the user can apply in the next period or in the remaining part of the current period. +- Recommendations must be practical actions (e.g., "ограничить спонтанные покупки по одной категории", "установить лимит", "вынести обязательные платежи в начало периода"). +- VERY IMPORTANT: Do NOT repeat exact numeric values from the input in the recommendations. Focus on actions and относительные формулировки (уменьшить, зафиксировать, перенести, разделить и т.п.). +- Use "-" bullets (text-only, not HTML list tags). + +General style requirements: +- The tone should be friendly, professional, and supportive, without blaming the user. +- Keep the writing structured, concise, and avoid filler. +- Do NOT invent completely unrealistic events; base your reasoning on the provided data patterns. + +CONTEXT: +today: {{TODAY}} +period starts: {{START_DATE}} +period ends: {{END_DATE}} + +Dates are provided in ISO format (YYYY-MM-DD). + +PERIOD STATUS LOGIC (VERY IMPORTANT): +- If {{TODAY}} < {{START_DATE}}: + - Treat the period as future/planned. + - Do NOT write as if the period has already started or finished. + - Use formulations like "запланированный период", "ожидаемые траты". +- If {{START_DATE}} <= {{TODAY}} <= {{END_DATE}}: + - Treat the period as ongoing. + - Do NOT say or imply that the period has already finished or "завершился". + - Avoid phrases like "по итогам периода" or "период завершился". + - Use formulations like "в текущем периоде", "на данный момент", "пока". +- If {{TODAY}} > {{END_DATE}}: + - Treat the period as completed. + - You MAY use phrases like "по итогам периода", "период завершился", "за весь период". + +REMINDER: +- Always check the relationship between today, period start and period end when choosing wording (ongoing vs completed vs future). +- The final response MUST be in Russian. +- The final response MUST be valid json with fields: +"common" - Общая оценка периода +"categoryAnalysis" - Анализ по категориям +"keyInsights" - Ключевые инсайты +"recommendations" - Рекомендации + +{ "common": "string", + "categoryAnalysis": "string", + "keyInsights": "string", + "recommendations": "string", +} +""".trimIndent() + prompt = prompt.replace("{{TODAY}}", LocalDate.now().toString()) + prompt = prompt.replace("{{period}}", "$startDate - $endDate") + prompt = prompt.replace("{{income}}", dashboardData.totalIncome.toString()) + prompt = prompt.replace("{{expense}}", dashboardData.totalExpense.toString()) + prompt = prompt.replace("{{net}}", dashboardData.balance.toString()) + prompt = prompt.replace("{{income_change_percent}}", dashboardData.prevCurIncomeChange.toString()) + prompt = prompt.replace("{{expense_change_percent}}", dashboardData.prevCurExpenseChange.toString()) + prompt = + prompt.replace("{{net_change_value}}", (dashboardData.balance - dashboardData.prevBalance).toString()) + prompt = prompt.replace( + "{{net_change_percent}}", if (dashboardData.prevBalance != 0) + ((dashboardData.balance / dashboardData.prevBalance) * 100).toString() else "0" + ) + prompt = prompt.replace( + "{{categories_list}}", + dashboardData.categories.joinToString(",") { mapper.writeValueAsString(it) }) + + prompt = prompt.replace("{{START_DATE}}", startDate.toString()) + prompt = prompt.replace("{{END_DATE}}", endDate.toString()) + val body = mapOf( + "model" to "deepseek-chat", + "messages" to listOf( +// mapOf("role" to "assistant", "content" to prompt), + mapOf("role" to "user", "content" to prompt) + ) + ) + val jsonBody = mapper.writeValueAsString(body) + val requestBody = jsonBody.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + val request = Request.Builder() + .url("${endpoint}/chat/completions") + .addHeader("Authorization", "Bearer $apiKey") + .post(requestBody) + .build() + + logger.info("start analyze period $startDate - $endDate ") + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("Qwen error: ${response.code} ${response.body?.string()}") + + val bodyStr = response.body?.string().orEmpty() + + // Берём content из choices[0].message.content + val root = mapper.readTree(bodyStr) + val text = root["choices"]?.get(0)?.get("message")?.get("content")?.asText() + ?: error("No choices[0].message.content in response") + + // Парсим "ID – Название (вероятность)" +// val regex = Regex("""^\s*(\d+)\s*[–-]\s*(.+?)\s*\((0(?:\.\d+)?|1(?:\.0)?)\)\s*$""") +// val match = regex.find(text.trim()) ?: error("Bad format: '$text'") + +// val (idStr, name, confStr) = match.destructured + logger.debug("got anazyled text: $text") + val idStr = text.replace("```json", "").replace("```", "") + logger.info("stopped analyze period $startDate - $endDate ") + return idStr + } } } } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/GptClient.kt b/src/main/kotlin/space/luminic/finance/services/gpt/GptClient.kt index 65df916..aadc197 100644 --- a/src/main/kotlin/space/luminic/finance/services/gpt/GptClient.kt +++ b/src/main/kotlin/space/luminic/finance/services/gpt/GptClient.kt @@ -1,10 +1,13 @@ package space.luminic.finance.services.gpt import space.luminic.finance.models.Category +import space.luminic.finance.models.DashboardData import space.luminic.finance.models.Transaction +import java.time.LocalDate data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null) interface GptClient { fun suggestCategory(tx: Transaction, categories: List): CategorySuggestion + fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/gpt/QwenCategorizationService.kt b/src/main/kotlin/space/luminic/finance/services/gpt/QwenCategorizationService.kt index a781a39..26f69ae 100644 --- a/src/main/kotlin/space/luminic/finance/services/gpt/QwenCategorizationService.kt +++ b/src/main/kotlin/space/luminic/finance/services/gpt/QwenCategorizationService.kt @@ -3,15 +3,15 @@ package space.luminic.finance.services.gpt import okhttp3.* import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import space.luminic.finance.models.Category +import space.luminic.finance.models.DashboardData import space.luminic.finance.models.Transaction +import java.time.LocalDate @Service("qwenCategorizationService") class QwenCategorizationService( @@ -75,4 +75,8 @@ class QwenCategorizationService( return CategorySuggestion(idStr.toInt(), name, confStr.toDouble()) } } + + override fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String { + TODO("Not yet implemented") + } } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt b/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt index d46306c..6361a28 100644 --- a/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt +++ b/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt @@ -121,7 +121,7 @@ class BotService( @Bean fun bot(): Bot { val bot = com.github.kotlintelegrambot.bot { - logLevel = if (profile == "proc") LogLevel.None else LogLevel.All() + logLevel = if (profile == "prod") LogLevel.None else LogLevel.All() token = botToken dispatch { message(Filter.Text) { @@ -239,9 +239,9 @@ class BotService( ) } catch (e: NotFoundException) { - bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.") - bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Давайте зарегистрируемся? ") - bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "") + bot.sendMessage(ChatId.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.") + bot.sendMessage(ChatId.fromId(message.chat.id), text = "Давайте зарегистрируемся? ") + bot.sendMessage(ChatId.fromId(message.chat.id), text = "") } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 01185ba..db2db06 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -1,19 +1,20 @@ spring.application.name=budger-app spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app-v2?authSource=admin&minPoolSize=10&maxPoolSize=100 - logging.level.org.springframework.web=DEBUG -logging.level.org.springframework.data = DEBUG -logging.level.org.springframework.data.jpa = DEBUG +logging.level.org.springframework.data=DEBUG +logging.level.org.springframework.data.jpa=DEBUG #logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=DEBUG -logging.level.org.springframework.security = DEBUG +logging.level.org.springframework.security=DEBUG #logging.level.org.springframework.data.mongodb.code = DEBUG logging.level.org.springframework.web.reactive=DEBUG -logging.level.org.mongodb.driver.protocol.command = DEBUG +logging.level.org.mongodb.driver.protocol.command=DEBUG logging.level.org.springframework.jdbc.core=INFO logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO logging.level.org.springframework.jdbc=INFO logging.level.org.springframework.jdbc.datasource=INFO logging.level.org.springframework.jdbc.support=INFO +logging.level.okhttp3=INFO +logging.level.space.luminic=DEBUG management.endpoints.web.exposure.include=* management.endpoint.health.show-details=always @@ -23,6 +24,6 @@ nlp.address=http://127.0.0.1:8000 -spring.datasource.url=jdbc:postgresql://213.226.71.138:5432/luminic-space-db +spring.datasource.url=jdbc:postgresql://31.59.58.220:5432/luminic-space-db spring.datasource.username=luminicspace spring.datasource.password=LS1q2w3e4r! \ No newline at end of file diff --git a/src/main/resources/db/migration/V31__.sql b/src/main/resources/db/migration/V31__.sql new file mode 100644 index 0000000..1ab9cbf --- /dev/null +++ b/src/main/resources/db/migration/V31__.sql @@ -0,0 +1,14 @@ +create table if not exists finance.period_analyze +( + id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL, + space_id integer not null, + period_start date not null, + period_end date not null, + analyze_text text not null, + first_analyze_at timestamp without time zone not null default now(), + last_analyze_at timestamp without time zone not null default now(), + CONSTRAINT pk_period_analyze PRIMARY KEY (id) +); + +ALTER TABLE finance.period_analyze + ADD CONSTRAINT FK_PERIOD_ANALYZE_ON_SPACE FOREIGN KEY (space_id) REFERENCES finance.spaces (id); diff --git a/src/main/resources/db/migration/V32__.sql b/src/main/resources/db/migration/V32__.sql new file mode 100644 index 0000000..bb52209 --- /dev/null +++ b/src/main/resources/db/migration/V32__.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX IF NOT EXISTS period_analyze_unique_idx + ON finance.period_analyze (space_id, period_start, period_end); \ No newline at end of file diff --git a/src/main/resources/db/migration/V33__.sql b/src/main/resources/db/migration/V33__.sql new file mode 100644 index 0000000..81f472e --- /dev/null +++ b/src/main/resources/db/migration/V33__.sql @@ -0,0 +1,2 @@ +alter table finance.period_analyze +alter column analyze_text set data type json USING analyze_text::json; \ No newline at end of file diff --git a/src/main/resources/db/migration/V34__.sql b/src/main/resources/db/migration/V34__.sql new file mode 100644 index 0000000..6f9c60d --- /dev/null +++ b/src/main/resources/db/migration/V34__.sql @@ -0,0 +1,4 @@ +create table if not exists finance.analyze_runs ( + run_at timestamp without time zone primary key, + involved_spaces varchar not null +) \ No newline at end of file diff --git a/src/main/resources/db/migration/V35__.sql b/src/main/resources/db/migration/V35__.sql new file mode 100644 index 0000000..cf4fa64 --- /dev/null +++ b/src/main/resources/db/migration/V35__.sql @@ -0,0 +1,2 @@ +alter table finance.recurrent_operations +add column is_deleted boolean default false; \ No newline at end of file