feat(raporlar): harcama kırılımı uç noktası ve dönem istatistiklerinde birikim oranı verileri

- GET /reports/breakdown: grup, tüm kategoriler, ödeme türü ve ay kırılımı; dönem/tarih/grup/kategori/ödeme türü filtreleri
- /stats/periods: gelir, sabit gider, devirler, gerçekleşen birikim
- Faz 9 planı kullanıcı kararlarıyla güncellendi (ekstre içe aktarma, JSON geri dönüş noktası, aylık rapor)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 01:48:46 +03:00
parent 8c9c3c2506
commit 87409590a6
17 changed files with 649 additions and 23 deletions
+2
View File
@@ -11,6 +11,7 @@ mod note;
mod period;
mod plan;
mod rates;
mod reports;
mod review;
mod spa;
mod stats;
@@ -87,6 +88,7 @@ pub fn router(state: AppState) -> Router {
.route("/periods/{id}/review", get(review::get))
.route("/periods/{id}/daily", get(stats::daily))
.route("/stats/periods", get(stats::periods))
.route("/reports/breakdown", get(reports::breakdown))
.route(
"/periods/{id}/budget",
get(budget::get).put(budget::set_monthly),
+173
View File
@@ -0,0 +1,173 @@
//! Rapor uç noktaları (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/reports/breakdown?period_id=&from=&to=&group=&category_id=&payment_method_id=` | harcama: toplam, grup, tüm kategoriler, ödeme türü, ay |
//!
//! `payment_method_id=none` → ödeme türü girilmemiş kayıtlar. Dönemler arası özet: `/api/stats/periods`.
use axum::{
Json,
extract::{Query, State},
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
models::group::Group,
services::reports::{self, Filter},
state::AppState,
};
#[derive(Deserialize)]
pub struct BreakdownQuery {
period_id: Option<String>,
from: Option<String>,
to: Option<String>,
group: Option<String>,
category_id: Option<String>,
payment_method_id: Option<String>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportBreakdown")]
pub struct BreakdownDto {
/// Kuruş
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
/// 4 grup, sabit sırada
groups: Vec<GroupRow>,
/// Harcama olan **tüm** kategoriler, çoktan aza
categories: Vec<CategoryRow>,
/// Ödeme türleri; `null` = girilmemiş
payment_methods: Vec<MethodRow>,
/// Aylar (`"YYYY-MM"`), eskiden yeniye
months: Vec<MonthRow>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportGroupRow")]
struct GroupRow {
group: Group,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportCategoryRow")]
struct CategoryRow {
category_id: String,
group: Group,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportMethodRow")]
struct MethodRow {
payment_method_id: Option<String>,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReportMonthRow")]
struct MonthRow {
/// `"YYYY-MM"`
month: String,
#[ts(type = "number")]
total: i64,
#[ts(type = "number")]
count: i64,
}
fn opt_id(v: Option<&str>, what: &str) -> AppResult<Option<Uuid>> {
match v.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(s) => Uuid::parse_str(s)
.map(Some)
.map_err(|_| AppError::BadRequest(format!("{what} geçersiz"))),
}
}
pub async fn breakdown(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<BreakdownQuery>,
) -> AppResult<Json<BreakdownDto>> {
let group = match q.group.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
None => None,
Some(g) => Some(
Group::ALL
.into_iter()
.find(|x| x.as_str() == g)
.ok_or_else(|| AppError::BadRequest("Grup geçersiz".into()))?,
),
};
let payment_method = match q.payment_method_id.as_deref().map(str::trim) {
None | Some("") => None,
Some("none") => Some(None),
Some(id) => Some(opt_id(Some(id), "Ödeme türü")?),
};
let filter = Filter {
period_id: opt_id(q.period_id.as_deref(), "Dönem")?,
from: q.from.filter(|s| !s.trim().is_empty()),
to: q.to.filter(|s| !s.trim().is_empty()),
group,
category_id: opt_id(q.category_id.as_deref(), "Kategori")?,
payment_method,
};
let b = reports::breakdown(&s.db, auth.household_id, filter).await?;
Ok(Json(BreakdownDto {
total: b.total,
count: b.count,
groups: b
.groups
.into_iter()
.map(|r| GroupRow {
group: r.key,
total: r.total,
count: r.count,
})
.collect(),
categories: b
.categories
.into_iter()
.map(|r| CategoryRow {
category_id: r.key.category_id.to_string(),
group: r.key.group,
total: r.total,
count: r.count,
})
.collect(),
payment_methods: b
.methods
.into_iter()
.map(|r| MethodRow {
payment_method_id: r.key.map(|u| u.to_string()),
total: r.total,
count: r.count,
})
.collect(),
months: b
.months
.into_iter()
.map(|r| MonthRow {
month: r.key,
total: r.total,
count: r.count,
})
.collect(),
}))
}
+23 -4
View File
@@ -3,7 +3,7 @@
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması |
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim |
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): gelir, sabit gider, devirler, harcanabilir, harcanan, gerçekleşen birikim, birikime aktarılan + toplam birikim |
use axum::{
Json,
@@ -76,11 +76,24 @@ struct PeriodStatDto {
last_date: String,
status: PeriodStatus,
#[ts(type = "number")]
income_total: i64,
#[ts(type = "number")]
fixed_expense_total: i64,
#[ts(type = "number")]
savings_goal: i64,
#[ts(type = "number")]
carryover_debt: i64,
#[ts(type = "number")]
carryover_surplus: i64,
#[ts(type = "number")]
spendable: i64,
#[ts(type = "number")]
spent: i64,
/// Harcanan, gruplara göre
spent_by_group: GroupAmounts,
/// Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
#[ts(type = "number")]
realized_savings: i64,
/// Kapanmamışsa `null`
#[ts(type = "number | null")]
to_savings: Option<i64>,
@@ -100,14 +113,20 @@ pub async fn periods(
.map(|x| {
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
PeriodStatDto {
realized_savings: x.realized_savings(),
id: x.period.id.to_string(),
name: x.period.name,
start_date: x.period.start_date,
last_date: format_date(end - time::Duration::days(1)),
status: x.period.status,
spendable: x.spendable,
spent: x.spent,
spent_by_group: x.spent_by_group.into(),
income_total: x.summary.income_total,
fixed_expense_total: x.summary.fixed_expense_total,
savings_goal: x.summary.savings_goal,
carryover_debt: x.summary.carryover_debt,
carryover_surplus: x.summary.carryover_surplus,
spendable: x.summary.spendable,
spent: x.summary.spent_total,
spent_by_group: x.summary.spent_by_group.into(),
to_savings: x.period.to_savings,
}
})