+ many
This commit is contained in:
@@ -68,6 +68,7 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.7.3")
|
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("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-api:0.11.5")
|
||||||
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
|
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
./gradlew bootJar || exit 1
|
./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 "
|
ssh root@31.59.58.220 "
|
||||||
cd /root/luminic/space/back &&
|
cd /root/luminic/app/back &&
|
||||||
docker compose up -d --build &&
|
docker compose up -d --build &&
|
||||||
docker restart back-app-1
|
docker restart back-app-1
|
||||||
"
|
"
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class TransactionController (
|
|||||||
|
|
||||||
@PostMapping("/_search")
|
@PostMapping("/_search")
|
||||||
fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List<TransactionDTO>{
|
fun getTransactions(@PathVariable spaceId: Int, @RequestBody filter: TransactionService.TransactionsFilter) : List<TransactionDTO>{
|
||||||
return transactionService.getTransactions(spaceId, filter,"date", "DESC").map { it.toDto() }
|
return transactionService.getTransactions(spaceId, filter).map { it.toDto() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{transactionId}")
|
@GetMapping("/{transactionId}")
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class SecurityConfig(
|
|||||||
@Bean
|
@Bean
|
||||||
fun corsConfigurationSource(): CorsConfigurationSource {
|
fun corsConfigurationSource(): CorsConfigurationSource {
|
||||||
val cors = CorsConfiguration().apply {
|
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")
|
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||||
allowedHeaders = listOf("*")
|
allowedHeaders = listOf("*")
|
||||||
allowCredentials = true
|
allowCredentials = true
|
||||||
|
|||||||
@@ -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<DashboardCategoryDTO>,
|
||||||
|
val upcomingTransactions: List<TransactionDTO>,
|
||||||
|
val recentTransactions: List<TransactionDTO>,
|
||||||
|
val weeks: List<DashboardWeeksDTO>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DashboardWeeksDTO(
|
||||||
|
val startDate: LocalDate,
|
||||||
|
val endDate: LocalDate,
|
||||||
|
val expenseSum: Int,
|
||||||
|
val categories: List<WeekCategoryDTO>
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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<DashboardCategory>,
|
||||||
|
val upcomingTransactions: List<Transaction>,
|
||||||
|
val recentTransactions: List<Transaction>,
|
||||||
|
val weeks: List<DashboardWeeks>
|
||||||
|
)
|
||||||
|
|
||||||
|
@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<WeekCategory>
|
||||||
|
)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
14
src/main/kotlin/space/luminic/finance/repos/DashboardRepo.kt
Normal file
14
src/main/kotlin/space/luminic/finance/repos/DashboardRepo.kt
Normal file
@@ -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<Int>)
|
||||||
|
}
|
||||||
367
src/main/kotlin/space/luminic/finance/repos/DashboardRepoImpl.kt
Normal file
367
src/main/kotlin/space/luminic/finance/repos/DashboardRepoImpl.kt
Normal file
@@ -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<String, Any>(
|
||||||
|
"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<WeekCategory> =
|
||||||
|
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<Int>) {
|
||||||
|
val sql = """INSERT INTO finance.analyze_runs VALUES (now(), :spaces);"""
|
||||||
|
val params = mapOf(
|
||||||
|
"spaces" to spaces.joinToString(",")
|
||||||
|
)
|
||||||
|
jdbcTemplate.update(sql, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ class RecurrentOperationRepoImpl(
|
|||||||
join finance.users su on s.owner_id = su.id
|
join finance.users su on s.owner_id = su.id
|
||||||
join finance.categories c on ro.category_id = c.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
|
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
|
order by ro.date, ro.id
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val params = mapOf("spaceId" to spaceId)
|
val params = mapOf("spaceId" to spaceId)
|
||||||
@@ -109,7 +109,7 @@ class RecurrentOperationRepoImpl(
|
|||||||
join finance.users su on s.owner_id = su.id
|
join finance.users su on s.owner_id = su.id
|
||||||
join finance.categories c on ro.category_id = c.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
|
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()
|
""".trimIndent()
|
||||||
val params = mapOf("spaceId" to spaceId, "id" to id)
|
val params = mapOf("spaceId" to spaceId, "id" to id)
|
||||||
return jdbcTemplate.query(sql, params, operationRowMapper()).firstOrNull()
|
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.users su on s.owner_id = su.id
|
||||||
join finance.categories c on ro.category_id = c.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
|
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()
|
""".trimIndent()
|
||||||
val params = mapOf( "date" to date)
|
val params = mapOf( "date" to date)
|
||||||
return jdbcTemplate.query(sql, params, operationRowMapper())
|
return jdbcTemplate.query(sql, params, operationRowMapper())
|
||||||
@@ -203,7 +203,8 @@ class RecurrentOperationRepoImpl(
|
|||||||
|
|
||||||
override fun delete(id: Int) {
|
override fun delete(id: Int) {
|
||||||
val sql = """
|
val sql = """
|
||||||
delete from finance.recurrent_operations
|
update finance.recurrent_operations
|
||||||
|
set is_deleted = true
|
||||||
where id = :id
|
where id = :id
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
val params = mapOf("id" to id)
|
val params = mapOf("id" to id)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package space.luminic.finance.repos
|
|||||||
|
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import space.luminic.finance.models.Space
|
import space.luminic.finance.models.Space
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface SpaceRepo {
|
interface SpaceRepo {
|
||||||
|
fun findSpacesForScheduling(lastRun: LocalDateTime): List<Space>
|
||||||
fun findSpacesAvailableForUser(userId: Int): List<Space>
|
fun findSpacesAvailableForUser(userId: Int): List<Space>
|
||||||
fun findSpaceById(id: Int, userId: Int): Space?
|
fun findSpaceById(id: Int, userId: Int): Space?
|
||||||
fun create(space: Space, createdById: Int): Int
|
fun create(space: Space, createdById: Int): Int
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.springframework.stereotype.Repository
|
|||||||
import space.luminic.finance.dtos.SpaceDTO
|
import space.luminic.finance.dtos.SpaceDTO
|
||||||
import space.luminic.finance.models.Space
|
import space.luminic.finance.models.Space
|
||||||
import space.luminic.finance.models.User
|
import space.luminic.finance.models.User
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class SpaceRepoImpl(
|
class SpaceRepoImpl(
|
||||||
@@ -84,6 +85,44 @@ class SpaceRepoImpl(
|
|||||||
return spaceMap.map { it.value }
|
return spaceMap.map { it.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun findSpacesForScheduling(lastRun: LocalDateTime): List<Space> {
|
||||||
|
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<Space> {
|
override fun findSpacesAvailableForUser(userId: Int): List<Space> {
|
||||||
val sql = """
|
val sql = """
|
||||||
|
|||||||
@@ -98,10 +98,18 @@ class TransactionRepoImpl(
|
|||||||
sql += " AND t.type = :type"
|
sql += " AND t.type = :type"
|
||||||
params.put("type", it.name)
|
params.put("type", it.name)
|
||||||
}
|
}
|
||||||
|
filters.query?.let {
|
||||||
|
sql += " AND lower(t.comment) LIKE ('%${it.lowercase()}%')"
|
||||||
|
params["query"] = it.lowercase()
|
||||||
|
}
|
||||||
filters.kind?.let {
|
filters.kind?.let {
|
||||||
sql += " AND t.kind = :kind"
|
sql += " AND t.kind = :kind"
|
||||||
params.put("kind", it.name)
|
params.put("kind", it.name)
|
||||||
}
|
}
|
||||||
|
filters.categoriesIds?.let {
|
||||||
|
sql += " AND t.category_id in (:categoriesIds)"
|
||||||
|
params.put("categoriesIds", it)
|
||||||
|
}
|
||||||
filters.isDone?.let {
|
filters.isDone?.let {
|
||||||
sql += " AND t.is_done = :isDone"
|
sql += " AND t.is_done = :isDone"
|
||||||
params.put("isDone", it)
|
params.put("isDone", it)
|
||||||
@@ -114,8 +122,15 @@ class TransactionRepoImpl(
|
|||||||
sql += " AND t.date <= :dateTo"
|
sql += " AND t.date <= :dateTo"
|
||||||
params.put("dateTo", it)
|
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 += """
|
sql += """
|
||||||
ORDER BY t.date, t.id
|
|
||||||
OFFSET :offset ROWS
|
OFFSET :offset ROWS
|
||||||
FETCH FIRST :limit ROWS ONLY"""
|
FETCH FIRST :limit ROWS ONLY"""
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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!! })
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
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.dtos.RecurrentOperationDTO
|
||||||
import space.luminic.finance.models.Category
|
import space.luminic.finance.models.Category
|
||||||
import space.luminic.finance.models.NotFoundException
|
import space.luminic.finance.models.NotFoundException
|
||||||
@@ -72,7 +74,7 @@ class RecurrentOperationServiceImpl(
|
|||||||
category = category,
|
category = category,
|
||||||
comment = creatingOperation.name,
|
comment = creatingOperation.name,
|
||||||
amount = creatingOperation.amount,
|
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
|
recurrentId = createdRecurrentId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -110,6 +112,7 @@ class RecurrentOperationServiceImpl(
|
|||||||
type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
|
type = if (it.category?.type == Category.CategoryType.EXPENSE) Transaction.TransactionType.EXPENSE else Transaction.TransactionType.INCOME,
|
||||||
category = updatedOperation.category,
|
category = updatedOperation.category,
|
||||||
comment = operation.name,
|
comment = operation.name,
|
||||||
|
amount = operation.amount,
|
||||||
date = LocalDate.of(
|
date = LocalDate.of(
|
||||||
it.date.year,
|
it.date.year,
|
||||||
it.date.monthValue,
|
it.date.monthValue,
|
||||||
@@ -125,10 +128,11 @@ class RecurrentOperationServiceImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
|
||||||
override fun delete(spaceId: Int, id: Int) {
|
override fun delete(spaceId: Int, id: Int) {
|
||||||
val userId = authService.getSecurityUserId()
|
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)
|
recurrentOperationRepo.delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.scheduling.annotation.EnableScheduling
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@Service
|
@Service
|
||||||
class Scheduler(
|
class Scheduler(
|
||||||
private val recurrentOperationService: RecurrentOperationService,
|
private val recurrentOperationService: RecurrentOperationService,
|
||||||
private val notificationService: NotificationService
|
private val notificationService: NotificationService,
|
||||||
|
private val dashboardService: DashboardService
|
||||||
) {
|
) {
|
||||||
private val log = LoggerFactory.getLogger(Scheduler::class.java)
|
private val log = LoggerFactory.getLogger(Scheduler::class.java)
|
||||||
|
|
||||||
@@ -19,9 +21,15 @@ class Scheduler(
|
|||||||
recurrentOperationService.createRecurrentTransactions()
|
recurrentOperationService.createRecurrentTransactions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(cron = "0 30 19 * * *")
|
@Scheduled(cron = "0 30 16 * * *")
|
||||||
fun sendDailyReminders() {
|
fun sendDailyReminders() {
|
||||||
log.info("Sending daily reminders")
|
log.info("Sending daily reminders")
|
||||||
notificationService.sendDailyReminder()
|
notificationService.sendDailyReminder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Scheduled(cron = "0 0 */3 * * *")
|
||||||
|
@Scheduled(fixedRate = 3, timeUnit =TimeUnit.HOURS)
|
||||||
|
fun analyzePeriodScheduled() {
|
||||||
|
dashboardService.analyzePeriodScheduled()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,10 @@ package space.luminic.finance.services
|
|||||||
|
|
||||||
import space.luminic.finance.dtos.SpaceDTO
|
import space.luminic.finance.dtos.SpaceDTO
|
||||||
import space.luminic.finance.models.Space
|
import space.luminic.finance.models.Space
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
interface SpaceService {
|
interface SpaceService {
|
||||||
|
fun getSpacesForScheduling(lastRun: LocalDateTime? = null): List<Space>
|
||||||
fun checkSpace(spaceId: Int): Space
|
fun checkSpace(spaceId: Int): Space
|
||||||
fun getSpaces(): List<Space>
|
fun getSpaces(): List<Space>
|
||||||
fun getSpace(id: Int, userId: Int?): Space
|
fun getSpace(id: Int, userId: Int?): Space
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import space.luminic.finance.dtos.SpaceDTO
|
|||||||
import space.luminic.finance.models.NotFoundException
|
import space.luminic.finance.models.NotFoundException
|
||||||
import space.luminic.finance.models.Space
|
import space.luminic.finance.models.Space
|
||||||
import space.luminic.finance.repos.SpaceRepo
|
import space.luminic.finance.repos.SpaceRepo
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class SpaceServiceImpl(
|
class SpaceServiceImpl(
|
||||||
@@ -13,6 +16,11 @@ class SpaceServiceImpl(
|
|||||||
private val spaceRepo: SpaceRepo,
|
private val spaceRepo: SpaceRepo,
|
||||||
private val categoryService: CategoryService
|
private val categoryService: CategoryService
|
||||||
) : SpaceService {
|
) : SpaceService {
|
||||||
|
override fun getSpacesForScheduling(lastRun: LocalDateTime?): List<Space> {
|
||||||
|
val lastRunDate = lastRun ?: LocalDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC)
|
||||||
|
return spaceRepo.findSpacesForScheduling(lastRunDate)
|
||||||
|
}
|
||||||
|
|
||||||
override fun checkSpace(spaceId: Int): Space {
|
override fun checkSpace(spaceId: Int): Space {
|
||||||
return getSpace(spaceId, null)
|
return getSpace(spaceId, null)
|
||||||
}
|
}
|
||||||
@@ -66,6 +74,7 @@ class SpaceServiceImpl(
|
|||||||
)
|
)
|
||||||
return spaceRepo.update(updatedSpace, userId)
|
return spaceRepo.update(updatedSpace, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
override fun deleteSpace(spaceId: Int) {
|
override fun deleteSpace(spaceId: Int) {
|
||||||
spaceRepo.delete(spaceId)
|
spaceRepo.delete(spaceId)
|
||||||
|
|||||||
@@ -7,20 +7,34 @@ import java.time.LocalDate
|
|||||||
interface TransactionService {
|
interface TransactionService {
|
||||||
|
|
||||||
data class TransactionsFilter(
|
data class TransactionsFilter(
|
||||||
|
val query : String? = null,
|
||||||
val type: Transaction.TransactionType? = null,
|
val type: Transaction.TransactionType? = null,
|
||||||
val kind: Transaction.TransactionKind? = null,
|
val kind: Transaction.TransactionKind? = null,
|
||||||
|
val categoriesIds: Set<Int>? = null,
|
||||||
val dateFrom: LocalDate? = null,
|
val dateFrom: LocalDate? = null,
|
||||||
val dateTo: LocalDate? = null,
|
val dateTo: LocalDate? = null,
|
||||||
val isDone: Boolean? = null,
|
val isDone: Boolean? = null,
|
||||||
val offset: Int = 0,
|
val offset: Int = 0,
|
||||||
val limit: Int = 10,
|
val limit: Int = 10,
|
||||||
|
val sorts: List<Map<String, String>> = 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(
|
fun getTransactions(
|
||||||
spaceId: Int,
|
spaceId: Int,
|
||||||
filter: TransactionsFilter,
|
filter: TransactionsFilter
|
||||||
sortBy: String,
|
|
||||||
sortDirection: String
|
|
||||||
): List<Transaction>
|
): List<Transaction>
|
||||||
|
|
||||||
fun getTransaction(spaceId: Int, transactionId: Int): Transaction
|
fun getTransaction(spaceId: Int, transactionId: Int): Transaction
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import space.luminic.finance.models.NotFoundException
|
|||||||
import space.luminic.finance.models.Transaction
|
import space.luminic.finance.models.Transaction
|
||||||
import space.luminic.finance.repos.TransactionRepo
|
import space.luminic.finance.repos.TransactionRepo
|
||||||
import space.luminic.finance.services.gpt.CategorizeService
|
import space.luminic.finance.services.gpt.CategorizeService
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class TransactionServiceImpl(
|
class TransactionServiceImpl(
|
||||||
@@ -27,9 +28,7 @@ class TransactionServiceImpl(
|
|||||||
|
|
||||||
override fun getTransactions(
|
override fun getTransactions(
|
||||||
spaceId: Int,
|
spaceId: Int,
|
||||||
filter: TransactionService.TransactionsFilter,
|
filter: TransactionService.TransactionsFilter
|
||||||
sortBy: String,
|
|
||||||
sortDirection: String
|
|
||||||
): List<Transaction> {
|
): List<Transaction> {
|
||||||
val transactions = transactionRepo.findAllBySpaceId(spaceId, filter)
|
val transactions = transactionRepo.findAllBySpaceId(spaceId, filter)
|
||||||
return transactions
|
return transactions
|
||||||
@@ -109,12 +108,18 @@ class TransactionServiceImpl(
|
|||||||
val space = spaceService.getSpace(spaceId, null)
|
val space = spaceService.getSpace(spaceId, null)
|
||||||
val existingTransaction = getTransaction(space.id!!, transactionId)
|
val existingTransaction = getTransaction(space.id!!, transactionId)
|
||||||
val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
|
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(
|
val updatedTransaction = Transaction(
|
||||||
id = existingTransaction.id,
|
id = existingTransaction.id,
|
||||||
space = existingTransaction.space,
|
space = existingTransaction.space,
|
||||||
parent = existingTransaction.parent,
|
parent = existingTransaction.parent,
|
||||||
type = transaction.type,
|
type = transaction.type,
|
||||||
kind = transaction.kind,
|
kind = newKind,
|
||||||
category = newCategory,
|
category = newCategory,
|
||||||
comment = transaction.comment,
|
comment = transaction.comment,
|
||||||
amount = transaction.amount,
|
amount = transaction.amount,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package space.luminic.finance.services.gpt
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.slf4j.LoggerFactory
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import space.luminic.finance.models.Category
|
import space.luminic.finance.models.Category
|
||||||
|
import space.luminic.finance.models.DashboardData
|
||||||
import space.luminic.finance.models.Transaction
|
import space.luminic.finance.models.Transaction
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
||||||
@Service("dsCategorizationService")
|
@Service("dsCategorizationService")
|
||||||
@@ -21,7 +27,8 @@ class DeepSeekCategorizationService(
|
|||||||
|
|
||||||
private val endpoint = "https://api.deepseek.com/v1"
|
private val endpoint = "https://api.deepseek.com/v1"
|
||||||
private val mapper = jacksonObjectMapper()
|
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)
|
private val logger = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
|
override fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion {
|
||||||
@@ -74,7 +81,181 @@ class DeepSeekCategorizationService(
|
|||||||
|
|
||||||
// val (idStr, name, confStr) = match.destructured
|
// val (idStr, name, confStr) = match.destructured
|
||||||
val idStr = text
|
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 (<b>, <i>, <u> 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package space.luminic.finance.services.gpt
|
package space.luminic.finance.services.gpt
|
||||||
|
|
||||||
import space.luminic.finance.models.Category
|
import space.luminic.finance.models.Category
|
||||||
|
import space.luminic.finance.models.DashboardData
|
||||||
import space.luminic.finance.models.Transaction
|
import space.luminic.finance.models.Transaction
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null)
|
data class CategorySuggestion(val categoryId: Int, val categoryName: String? = null, val confidence: Double? = null)
|
||||||
|
|
||||||
interface GptClient {
|
interface GptClient {
|
||||||
fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion
|
fun suggestCategory(tx: Transaction, categories: List<Category>): CategorySuggestion
|
||||||
|
fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,15 @@ package space.luminic.finance.services.gpt
|
|||||||
|
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Qualifier
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import space.luminic.finance.models.Category
|
import space.luminic.finance.models.Category
|
||||||
|
import space.luminic.finance.models.DashboardData
|
||||||
import space.luminic.finance.models.Transaction
|
import space.luminic.finance.models.Transaction
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
@Service("qwenCategorizationService")
|
@Service("qwenCategorizationService")
|
||||||
class QwenCategorizationService(
|
class QwenCategorizationService(
|
||||||
@@ -75,4 +75,8 @@ class QwenCategorizationService(
|
|||||||
return CategorySuggestion(idStr.toInt(), name, confStr.toDouble())
|
return CategorySuggestion(idStr.toInt(), name, confStr.toDouble())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun analyzePeriod(startDate: LocalDate, endDate: LocalDate, dashboardData: DashboardData): String {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ class BotService(
|
|||||||
@Bean
|
@Bean
|
||||||
fun bot(): Bot {
|
fun bot(): Bot {
|
||||||
val bot = com.github.kotlintelegrambot.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
|
token = botToken
|
||||||
dispatch {
|
dispatch {
|
||||||
message(Filter.Text) {
|
message(Filter.Text) {
|
||||||
@@ -239,9 +239,9 @@ class BotService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: NotFoundException) {
|
} catch (e: NotFoundException) {
|
||||||
bot.sendMessage(ChatId.Companion.fromId(message.chat.id), text = "Кажется, мы еще не знакомы.")
|
bot.sendMessage(ChatId.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.Companion.fromId(message.chat.id), text = "")
|
bot.sendMessage(ChatId.fromId(message.chat.id), text = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
spring.application.name=budger-app
|
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
|
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.web=DEBUG
|
||||||
logging.level.org.springframework.data = DEBUG
|
logging.level.org.springframework.data=DEBUG
|
||||||
logging.level.org.springframework.data.jpa = DEBUG
|
logging.level.org.springframework.data.jpa=DEBUG
|
||||||
#logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=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.data.mongodb.code = DEBUG
|
||||||
logging.level.org.springframework.web.reactive=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=INFO
|
||||||
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO
|
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=INFO
|
||||||
logging.level.org.springframework.jdbc=INFO
|
logging.level.org.springframework.jdbc=INFO
|
||||||
logging.level.org.springframework.jdbc.datasource=INFO
|
logging.level.org.springframework.jdbc.datasource=INFO
|
||||||
logging.level.org.springframework.jdbc.support=INFO
|
logging.level.org.springframework.jdbc.support=INFO
|
||||||
|
logging.level.okhttp3=INFO
|
||||||
|
logging.level.space.luminic=DEBUG
|
||||||
|
|
||||||
management.endpoints.web.exposure.include=*
|
management.endpoints.web.exposure.include=*
|
||||||
management.endpoint.health.show-details=always
|
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.username=luminicspace
|
||||||
spring.datasource.password=LS1q2w3e4r!
|
spring.datasource.password=LS1q2w3e4r!
|
||||||
14
src/main/resources/db/migration/V31__.sql
Normal file
14
src/main/resources/db/migration/V31__.sql
Normal file
@@ -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);
|
||||||
2
src/main/resources/db/migration/V32__.sql
Normal file
2
src/main/resources/db/migration/V32__.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS period_analyze_unique_idx
|
||||||
|
ON finance.period_analyze (space_id, period_start, period_end);
|
||||||
2
src/main/resources/db/migration/V33__.sql
Normal file
2
src/main/resources/db/migration/V33__.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
alter table finance.period_analyze
|
||||||
|
alter column analyze_text set data type json USING analyze_text::json;
|
||||||
4
src/main/resources/db/migration/V34__.sql
Normal file
4
src/main/resources/db/migration/V34__.sql
Normal file
@@ -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
|
||||||
|
)
|
||||||
2
src/main/resources/db/migration/V35__.sql
Normal file
2
src/main/resources/db/migration/V35__.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
alter table finance.recurrent_operations
|
||||||
|
add column is_deleted boolean default false;
|
||||||
Reference in New Issue
Block a user