From 87409590a68f005a4bd136ad2c475933ceccf8b2 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 13 Nov 2026 01:48:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(raporlar):=20harcama=20k=C4=B1r=C4=B1l?= =?UTF-8?q?=C4=B1m=C4=B1=20u=C3=A7=20noktas=C4=B1=20ve=20d=C3=B6nem=20ista?= =?UTF-8?q?tistiklerinde=20birikim=20oran=C4=B1=20verileri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/api.http | 8 + backend/src/routes/mod.rs | 2 + backend/src/routes/reports.rs | 173 +++++++++++++++++++++ backend/src/routes/stats.rs | 27 +++- backend/src/services/mod.rs | 1 + backend/src/services/reports.rs | 164 ++++++++++++++++++++ backend/src/services/stats.rs | 23 +-- backend/tests/reports.rs | 191 ++++++++++++++++++++++++ backend/tests/stats.rs | 11 ++ docs/MEMORY.md | 6 +- docs/PHASES.md | 15 +- frontend/src/types/PeriodStat.ts | 6 +- frontend/src/types/ReportBreakdown.ts | 27 ++++ frontend/src/types/ReportCategoryRow.ts | 4 + frontend/src/types/ReportGroupRow.ts | 4 + frontend/src/types/ReportMethodRow.ts | 3 + frontend/src/types/ReportMonthRow.ts | 7 + 17 files changed, 649 insertions(+), 23 deletions(-) create mode 100644 backend/src/routes/reports.rs create mode 100644 backend/src/services/reports.rs create mode 100644 backend/tests/reports.rs create mode 100644 frontend/src/types/ReportBreakdown.ts create mode 100644 frontend/src/types/ReportCategoryRow.ts create mode 100644 frontend/src/types/ReportGroupRow.ts create mode 100644 frontend/src/types/ReportMethodRow.ts create mode 100644 frontend/src/types/ReportMonthRow.ts diff --git a/backend/api.http b/backend/api.http index b921f98..1dc0bc0 100644 --- a/backend/api.http +++ b/backend/api.http @@ -377,3 +377,11 @@ GET {{baseUrl}}/savings/pending ### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez) POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings + +# ─────────────────────────── Raporlar ─────────────────────────── + +### Harcama kırılımı: grup, tüm kategoriler, ödeme türü, ay (filtreler isteğe bağlı) +GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31 + +### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar +GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 3ad9d0a..f543329 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -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), diff --git a/backend/src/routes/reports.rs b/backend/src/routes/reports.rs new file mode 100644 index 0000000..a23c2a4 --- /dev/null +++ b/backend/src/routes/reports.rs @@ -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, + from: Option, + to: Option, + group: Option, + category_id: Option, + payment_method_id: Option, +} + +#[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, + /// Harcama olan **tüm** kategoriler, çoktan aza + categories: Vec, + /// Ödeme türleri; `null` = girilmemiş + payment_methods: Vec, + /// Aylar (`"YYYY-MM"`), eskiden yeniye + months: Vec, +} + +#[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, + #[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> { + 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, + auth: AuthUser, + Query(q): Query, +) -> AppResult> { + 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(), + })) +} diff --git a/backend/src/routes/stats.rs b/backend/src/routes/stats.rs index eca6a93..97d3551 100644 --- a/backend/src/routes/stats.rs +++ b/backend/src/routes/stats.rs @@ -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, @@ -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, } }) diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 17f3e55..4bdc4c2 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -15,6 +15,7 @@ pub mod period; pub mod plan; pub mod rate_sources; pub mod rates; +pub mod reports; pub mod review; pub mod seed; pub mod stats; diff --git a/backend/src/services/reports.rs b/backend/src/services/reports.rs new file mode 100644 index 0000000..fec52ce --- /dev/null +++ b/backend/src/services/reports.rs @@ -0,0 +1,164 @@ +//! Raporlar (KAKEIBO_PROMPT §8.8). Harcama kırılımı tek aggregation (`$facet`) ile: +//! grup, **tüm** kategoriler, ödeme türü, ay. Saklanmaz; her istekte hesaplanır. +//! +//! Filtre: dönem **ya da** tarih aralığı (ikisi de dahil), grup, kategori, ödeme türü +//! (`Some(None)` → ödeme türü girilmemiş kayıtlar). Silinen kayıtlar sayılmaz. + +use mongodb::{ + Database, + bson::{Bson, Document, Uuid, doc}, +}; +use serde::Deserialize; + +use crate::{ + db::col, + error::{AppError, AppResult}, + models::{common::scope, group::Group}, + services::{calendar::parse_date, period}, +}; + +#[derive(Default)] +pub struct Filter { + pub period_id: Option, + pub from: Option, + pub to: Option, + pub group: Option, + pub category_id: Option, + /// `Some(None)` → ödeme türü boş olanlar + pub payment_method: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct Row { + #[serde(rename = "_id")] + pub key: K, + pub total: i64, + pub count: i64, +} + +#[derive(Debug, Deserialize)] +pub struct CategoryKey { + pub category_id: Uuid, + pub group: Group, +} + +#[derive(Debug, Deserialize)] +struct Facets { + totals: Vec>, + groups: Vec>, + categories: Vec>, + methods: Vec>>, + months: Vec>, +} + +pub struct Breakdown { + pub total: i64, + pub count: i64, + /// `Group::ALL` sırasıyla, harcama olmayan grup 0 + pub groups: Vec>, + /// Çoktan aza (eşitlikte kategori kimliği) + pub categories: Vec>, + /// Çoktan aza; `None` = ödeme türü girilmemiş + pub methods: Vec>>, + /// `"YYYY-MM"`, eskiden yeniye + pub months: Vec>, +} + +pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult { + let mut m = scope(household_id); + if let Some(pid) = f.period_id { + period::get(db, household_id, pid).await?; // bu haneye ait mi? + m.insert("period_id", pid); + } + let mut range = Document::new(); + for (op, v) in [("$gte", &f.from), ("$lte", &f.to)] { + if let Some(d) = v { + let d = d.trim(); + parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?; + range.insert(op, d); + } + } + if let (Some(a), Some(b)) = (&f.from, &f.to) + && a.trim() > b.trim() + { + return Err(AppError::BadRequest( + "Başlangıç tarihi bitişten sonra olamaz".into(), + )); + } + if !range.is_empty() { + m.insert("date", range); + } + if let Some(g) = f.group { + m.insert("group", g.as_str()); + } + if let Some(c) = f.category_id { + m.insert("category_id", c); + } + if let Some(pm) = f.payment_method { + m.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from)); + } + + let sum = || doc! { "total": { "$sum": "$amount" }, "count": { "$sum": Bson::Int64(1) } }; + let group_by = |key: Bson| { + let mut g = doc! { "_id": key }; + g.extend(sum()); + doc! { "$group": g } + }; + let pipeline = vec![ + doc! { "$match": m }, + doc! { "$facet": { + "totals": [ group_by(Bson::Null) ], + "groups": [ group_by(Bson::String("$group".into())) ], + "categories": [ + group_by(Bson::Document(doc! { "category_id": "$category_id", "group": "$group" })), + { "$sort": { "total": -1, "_id.category_id": 1 } }, + ], + "methods": [ + // Alanı hiç olmayan eski kayıtlar da "girilmemiş" sayılsın + group_by(Bson::Document(doc! { "$ifNull": ["$payment_method_id", null] })), + { "$sort": { "total": -1, "_id": 1 } }, + ], + "months": [ + group_by(Bson::Document(doc! { "$substrBytes": ["$date", 0, 7] })), + { "$sort": { "_id": 1 } }, + ], + } }, + ]; + let mut cursor = db + .collection::(col::ENTRIES) + .aggregate(pipeline) + .with_type::() + .await?; + let f = if cursor.advance().await? { + cursor.deserialize_current()? + } else { + return Err(AppError::Internal("rapor sorgusu boş döndü".into())); + }; + + let (total, count) = f.totals.first().map_or((0, 0), |r| (r.total, r.count)); + let groups = Group::ALL + .into_iter() + .map(|g| { + f.groups.iter().find(|r| r.key == g).map_or( + Row { + key: g, + total: 0, + count: 0, + }, + |r| Row { + key: g, + total: r.total, + count: r.count, + }, + ) + }) + .collect(); + Ok(Breakdown { + total, + count, + groups, + categories: f.categories, + methods: f.methods, + months: f.months, + }) +} diff --git a/backend/src/services/stats.rs b/backend/src/services/stats.rs index 0ac9e58..3eff093 100644 --- a/backend/src/services/stats.rs +++ b/backend/src/services/stats.rs @@ -57,9 +57,17 @@ pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppRes pub struct PeriodStat { pub period: Period, - pub spendable: i64, - pub spent: i64, - pub spent_by_group: GroupAmounts, + /// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil) + pub summary: budget::Summary, +} + +impl PeriodStat { + /// Değerlendirmenin 4. sorusu: gelir − sabit − borç + artı − harcanan (`review` ile aynı) + pub fn realized_savings(&self) -> i64 { + let s = &self.summary; + s.income_total - s.fixed_expense_total - s.carryover_debt + s.carryover_surplus + - s.spent_total + } } pub struct Stats { @@ -74,13 +82,8 @@ pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResu let total_saved = list.iter().filter_map(|p| p.to_savings).sum(); let mut out = Vec::new(); for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) { - let s = budget::summary(db, household_id, p.id).await?; - out.push(PeriodStat { - period: p, - spendable: s.spendable, - spent: s.spent_total, - spent_by_group: s.spent_by_group, - }); + let summary = budget::summary(db, household_id, p.id).await?; + out.push(PeriodStat { period: p, summary }); } out.reverse(); Ok(Stats { diff --git a/backend/tests/reports.rs b/backend/tests/reports.rs new file mode 100644 index 0000000..e0de997 --- /dev/null +++ b/backend/tests/reports.rs @@ -0,0 +1,191 @@ +//! Raporlar: harcama kırılımı (grup, tüm kategoriler, ödeme türü, ay) ve filtreler. + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use serde_json::{Value, json}; + +async fn id_by_name(app: &TestApp, s: &Session, path: &str, name: &str) -> String { + let (_, list) = app.get(path, s).await; + list.as_array() + .unwrap() + .iter() + .find(|c| c["name"] == name) + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +async fn spend( + app: &TestApp, + s: &Session, + pid: &str, + date: &str, + amount: i64, + cat: &str, + pm: Option<&str>, +) -> Value { + let mut body = json!({ "date": date, "amount": amount, "category_id": cat }); + if let Some(pm) = pm { + body["payment_method_id"] = json!(pm); + } + let (st, e) = app.post(&format!("/periods/{pid}/entries"), s, body).await; + assert_eq!(st, StatusCode::CREATED, "{e}"); + e +} + +async fn report(app: &TestApp, s: &Session, query: &str) -> Value { + let (st, b) = app.get(&format!("/reports/breakdown{query}"), s).await; + assert_eq!(st, StatusCode::OK, "{query} → {b}"); + b +} + +fn totals(list: &Value, key: &str) -> Vec<(String, i64)> { + list.as_array() + .unwrap() + .iter() + .map(|r| { + ( + r[key].as_str().unwrap_or("-").to_string(), + r["total"].as_i64().unwrap(), + ) + }) + .collect() +} + +#[tokio::test] +async fn breakdown_with_filters() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + let market = id_by_name(&app, &s, "/categories", "Market").await; + let giyim = id_by_name(&app, &s, "/categories", "Giyim").await; + let kitap = id_by_name(&app, &s, "/categories", "Kitap").await; + let nakit = id_by_name(&app, &s, "/payment-methods", "Nakit").await; + let kart = id_by_name(&app, &s, "/payment-methods", "Kredi Kartı").await; + + let (_, oct) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }), + ) + .await; + let oct = oct["id"].as_str().unwrap().to_string(); + spend(&app, &s, &oct, "2026-10-05", 100_000, &market, Some(&nakit)).await; + spend(&app, &s, &oct, "2026-10-20", 50_000, &giyim, None).await; + app.post(&format!("/periods/{oct}/close"), &s, json!({})) + .await; + let (_, nov) = app + .post( + "/periods", + &s, + json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }), + ) + .await; + let nov = nov["id"].as_str().unwrap().to_string(); + spend(&app, &s, &nov, "2026-11-02", 30_000, &market, Some(&kart)).await; + spend(&app, &s, &nov, "2026-11-03", 20_000, &kitap, Some(&nakit)).await; + spend(&app, &s, &nov, "2026-11-04", 10_000, &market, Some(&nakit)).await; + let gone = spend(&app, &s, &nov, "2026-11-05", 99_000, &kitap, None).await; + app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s) + .await; + + // Filtresiz: tüm zamanlar + let r = report(&app, &s, "").await; + assert_eq!(r["total"], 210_000); + assert_eq!(r["count"], 5, "silinen sayılmaz"); + assert_eq!( + totals(&r["groups"], "group"), + [ + ("needs".into(), 140_000), + ("wants".into(), 50_000), + ("culture".into(), 20_000), + ("unexpected".into(), 0) + ] + ); + assert_eq!( + totals(&r["categories"], "category_id"), + [ + (market.clone(), 140_000), + (giyim.clone(), 50_000), + (kitap.clone(), 20_000) + ] + ); + assert_eq!(r["categories"][0]["count"], 3); + assert_eq!(r["categories"][0]["group"], "needs"); + assert_eq!( + totals(&r["payment_methods"], "payment_method_id"), + [ + (nakit.clone(), 130_000), + ("-".into(), 50_000), + (kart.clone(), 30_000) + ], + "ödeme türü girilmemiş → null" + ); + assert_eq!( + totals(&r["months"], "month"), + [("2026-10".into(), 150_000), ("2026-11".into(), 60_000)] + ); + + // Dönem + assert_eq!( + report(&app, &s, &format!("?period_id={nov}")).await["total"], + 60_000 + ); + // Tarih aralığı iki dönemi keser (ikisi de dahil) + let r = report(&app, &s, "?from=2026-10-15&to=2026-11-03").await; + assert_eq!( + (r["total"].as_i64(), r["count"].as_i64()), + (Some(100_000), Some(3)) + ); + // Grup, kategori, ödeme türü (+ girilmemiş) + assert_eq!(report(&app, &s, "?group=needs").await["total"], 140_000); + assert_eq!( + report(&app, &s, &format!("?category_id={kitap}")).await["total"], + 20_000 + ); + assert_eq!( + report(&app, &s, &format!("?payment_method_id={nakit}")).await["total"], + 130_000 + ); + assert_eq!( + report(&app, &s, "?payment_method_id=none").await["total"], + 50_000 + ); + // Birleşik + let r = report( + &app, + &s, + &format!("?period_id={nov}&group=needs&payment_method_id={nakit}"), + ) + .await; + assert_eq!( + (r["total"].as_i64(), r["count"].as_i64()), + (Some(10_000), Some(1)) + ); + + // Hatalı filtreler + for q in [ + "?from=2026-11-10&to=2026-11-01", + "?from=2026-02-30", + "?group=xyz", + "?category_id=abc", + ] { + let (st, b) = app.get(&format!("/reports/breakdown{q}"), &s).await; + assert_eq!(st, StatusCode::BAD_REQUEST, "{q} → {b}"); + } + + // Başka hane: kendi verisi (boş); bu hanenin dönemi → 404 + let other = app.register("Diger", None).await; + let r = report(&app, &other, "").await; + assert_eq!(r["total"], 0); + assert_eq!(r["groups"].as_array().unwrap().len(), 4); + assert_eq!(r["categories"], json!([])); + let (st, _) = app + .get(&format!("/reports/breakdown?period_id={nov}"), &other) + .await; + assert_eq!(st, StatusCode::NOT_FOUND); + app.cleanup().await; +} diff --git a/backend/tests/stats.rs b/backend/tests/stats.rs index 1b1f130..181ec31 100644 --- a/backend/tests/stats.rs +++ b/backend/tests/stats.rs @@ -151,6 +151,17 @@ async fn period_stats_and_total_savings() { assert_eq!(list[1]["spent"], 42_500); assert_eq!(list[1]["spent_by_group"]["needs"], 40_000); assert_eq!(list[1]["spent_by_group"]["culture"], 2_500); + assert_eq!(list[1]["income_total"], 2_000_000); + assert_eq!(list[1]["savings_goal"], 100_000); + assert_eq!(list[1]["carryover_surplus"], 200_000); + assert_eq!( + list[0]["realized_savings"], 700_000, + "Kasım: 10.000 − 3.000" + ); + assert_eq!( + list[1]["realized_savings"], 2_157_500, + "20.000 + 2.000 artı − 425 harcanan (hedef düşülmez)" + ); assert_eq!(list[1]["to_savings"], Value::Null); // limit: en yeni N dönem diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 58f0fab..acba6ec 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,10 +1,11 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 9 – Adım 1** (plan `docs/PHASES.md` Faz 9, onay bekleniyor): rapor uç noktaları. Kullanıcıya sorulanlar: aylık rapor biçimi (`templates/monthly_report.md` **yok**, kullanıcı verecek ya da taslak), JSON geri yükleme anlamı (önerilen: tüm hane verisini değiştir + önce otomatik yedek), banka ekstresi içe aktarma isteniyor mu (kapsam dışı, Adım 3'e eklenebilir). +**Faz 9 – Adım 2:** Raporlar sayfası (`/raporlar`, `features/reports/`): filtreler adreste (`?donem=`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme`), raporlar: gelir–gider (dönemler, `useStats` → çubuk), birikim oranı trendi (`realized_savings ÷ income_total`, çizgi), kategori kırılımı (**tüm** kategoriler tablo + yatay çubuk, `useReportBreakdown`), ödeme türü dağılımı, bütçe sapması (seçili dönem `useBudget`: grup aylık + hafta), varlık performansı (`usePortfolio` kâr/zarar). Her bölümde CSV (UTF-8 BOM, `;`, ondalık virgül — `lib/csv.ts` + test) ve Yazdır (`@media print`: menü/düğmeler gizli). Recharts lazy, ham CSS renkleri. **Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅. ## 📌 Alınan Kararlar +- **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir. - **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir). - **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler". - **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce. @@ -130,3 +131,6 @@ - **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir. - **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `
` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine. - **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not " kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.** + +### 2026-11-13 — Faz 9 +- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index efd4fef..8aa4e46 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -13,7 +13,7 @@ | **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ | | **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ | | **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ | -| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ | +| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 | | **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ | | **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ | @@ -78,9 +78,10 @@ - ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı - ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri -## Faz 9 Adımları (taslak) -- ⏳ 1. Backend: rapor uç noktaları — dönemler arası özet (gelir, sabit gider, harcanan, gerçekleşen birikim, **birikim oranı**), kırılım (grup / **tüm kategoriler** / ödeme türü; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü), bütçe sapması (grup × hafta, planlanan vs gerçekleşen); testler -- ⏳ 2. Frontend: Raporlar sayfası — filtre çubuğu (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor **CSV** (UTF-8, `;`, Excel uyumlu) ve **yazdır** (yazdırma stili) -- ⏳ 3. CSV içe/dışa aktarma: harcamalar, plan kalemleri, varlık işlemleri — dışa aktar; içe aktarmada önizleme, satır satır hata raporu, geçerli satırların aktarılması (kategori/ödeme türü adla eşleşir) -- ⏳ 4. JSON tam hane yedeği: sürüm bilgili dışa aktarma; geri yüklemede önizleme + onay (yalnızca sahip) -- ⏳ 5. Aylık değerlendirme çıktısı: `templates/monthly_report.md` şablonundan Markdown + yazdırılabilir/PDF (biçim kullanıcıdan; yoksa taslak şablon önerilir) +## Faz 9 Adımları +- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden +- ⏳ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır +- ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu +- ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri) +- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma +- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF diff --git a/frontend/src/types/PeriodStat.ts b/frontend/src/types/PeriodStat.ts index 5743b8f..d8b205c 100644 --- a/frontend/src/types/PeriodStat.ts +++ b/frontend/src/types/PeriodStat.ts @@ -6,11 +6,15 @@ export type PeriodStat = { id: string, name: string, start_date: string, /** * Son gün, dahil */ -last_date: string, status: PeriodStatus, spendable: number, spent: number, +last_date: string, status: PeriodStatus, income_total: number, fixed_expense_total: number, savings_goal: number, carryover_debt: number, carryover_surplus: number, spendable: number, spent: number, /** * Harcanan, gruplara göre */ spent_by_group: GroupAmounts, +/** + * Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir + */ +realized_savings: number, /** * Kapanmamışsa `null` */ diff --git a/frontend/src/types/ReportBreakdown.ts b/frontend/src/types/ReportBreakdown.ts new file mode 100644 index 0000000..1567c96 --- /dev/null +++ b/frontend/src/types/ReportBreakdown.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReportCategoryRow } from "./ReportCategoryRow"; +import type { ReportGroupRow } from "./ReportGroupRow"; +import type { ReportMethodRow } from "./ReportMethodRow"; +import type { ReportMonthRow } from "./ReportMonthRow"; + +export type ReportBreakdown = { +/** + * Kuruş + */ +total: number, count: number, +/** + * 4 grup, sabit sırada + */ +groups: Array, +/** + * Harcama olan **tüm** kategoriler, çoktan aza + */ +categories: Array, +/** + * Ödeme türleri; `null` = girilmemiş + */ +payment_methods: Array, +/** + * Aylar (`"YYYY-MM"`), eskiden yeniye + */ +months: Array, }; diff --git a/frontend/src/types/ReportCategoryRow.ts b/frontend/src/types/ReportCategoryRow.ts new file mode 100644 index 0000000..f56fa75 --- /dev/null +++ b/frontend/src/types/ReportCategoryRow.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type ReportCategoryRow = { category_id: string, group: Group, total: number, count: number, }; diff --git a/frontend/src/types/ReportGroupRow.ts b/frontend/src/types/ReportGroupRow.ts new file mode 100644 index 0000000..7c0e594 --- /dev/null +++ b/frontend/src/types/ReportGroupRow.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Group } from "./Group"; + +export type ReportGroupRow = { group: Group, total: number, count: number, }; diff --git a/frontend/src/types/ReportMethodRow.ts b/frontend/src/types/ReportMethodRow.ts new file mode 100644 index 0000000..fae1645 --- /dev/null +++ b/frontend/src/types/ReportMethodRow.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReportMethodRow = { payment_method_id: string | null, total: number, count: number, }; diff --git a/frontend/src/types/ReportMonthRow.ts b/frontend/src/types/ReportMonthRow.ts new file mode 100644 index 0000000..21e73dc --- /dev/null +++ b/frontend/src/types/ReportMonthRow.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReportMonthRow = { +/** + * `"YYYY-MM"` + */ +month: string, total: number, count: number, };