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
+8
View File
@@ -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
+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,
}
})
+1
View File
@@ -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;
+164
View File
@@ -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<Uuid>,
pub from: Option<String>,
pub to: Option<String>,
pub group: Option<Group>,
pub category_id: Option<Uuid>,
/// `Some(None)` → ödeme türü boş olanlar
pub payment_method: Option<Option<Uuid>>,
}
#[derive(Debug, Deserialize)]
pub struct Row<K> {
#[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<Row<Bson>>,
groups: Vec<Row<Group>>,
categories: Vec<Row<CategoryKey>>,
methods: Vec<Row<Option<Uuid>>>,
months: Vec<Row<String>>,
}
pub struct Breakdown {
pub total: i64,
pub count: i64,
/// `Group::ALL` sırasıyla, harcama olmayan grup 0
pub groups: Vec<Row<Group>>,
/// Çoktan aza (eşitlikte kategori kimliği)
pub categories: Vec<Row<CategoryKey>>,
/// Çoktan aza; `None` = ödeme türü girilmemiş
pub methods: Vec<Row<Option<Uuid>>>,
/// `"YYYY-MM"`, eskiden yeniye
pub months: Vec<Row<String>>,
}
pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult<Breakdown> {
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::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Facets>()
.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,
})
}
+13 -10
View File
@@ -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 {
+191
View File
@@ -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;
}
+11
View File
@@ -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