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:
@@ -377,3 +377,11 @@ GET {{baseUrl}}/savings/pending
|
|||||||
|
|
||||||
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
|
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
|
||||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
|
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod note;
|
|||||||
mod period;
|
mod period;
|
||||||
mod plan;
|
mod plan;
|
||||||
mod rates;
|
mod rates;
|
||||||
|
mod reports;
|
||||||
mod review;
|
mod review;
|
||||||
mod spa;
|
mod spa;
|
||||||
mod stats;
|
mod stats;
|
||||||
@@ -87,6 +88,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/periods/{id}/review", get(review::get))
|
.route("/periods/{id}/review", get(review::get))
|
||||||
.route("/periods/{id}/daily", get(stats::daily))
|
.route("/periods/{id}/daily", get(stats::daily))
|
||||||
.route("/stats/periods", get(stats::periods))
|
.route("/stats/periods", get(stats::periods))
|
||||||
|
.route("/reports/breakdown", get(reports::breakdown))
|
||||||
.route(
|
.route(
|
||||||
"/periods/{id}/budget",
|
"/periods/{id}/budget",
|
||||||
get(budget::get).put(budget::set_monthly),
|
get(budget::get).put(budget::set_monthly),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//! | Yöntem | Yol | İş |
|
//! | Yöntem | Yol | İş |
|
||||||
//! |---|---|---|
|
//! |---|---|---|
|
||||||
//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması |
|
//! | 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::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -76,11 +76,24 @@ struct PeriodStatDto {
|
|||||||
last_date: String,
|
last_date: String,
|
||||||
status: PeriodStatus,
|
status: PeriodStatus,
|
||||||
#[ts(type = "number")]
|
#[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,
|
spendable: i64,
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
spent: i64,
|
spent: i64,
|
||||||
/// Harcanan, gruplara göre
|
/// Harcanan, gruplara göre
|
||||||
spent_by_group: GroupAmounts,
|
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`
|
/// Kapanmamışsa `null`
|
||||||
#[ts(type = "number | null")]
|
#[ts(type = "number | null")]
|
||||||
to_savings: Option<i64>,
|
to_savings: Option<i64>,
|
||||||
@@ -100,14 +113,20 @@ pub async fn periods(
|
|||||||
.map(|x| {
|
.map(|x| {
|
||||||
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
|
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
|
||||||
PeriodStatDto {
|
PeriodStatDto {
|
||||||
|
realized_savings: x.realized_savings(),
|
||||||
id: x.period.id.to_string(),
|
id: x.period.id.to_string(),
|
||||||
name: x.period.name,
|
name: x.period.name,
|
||||||
start_date: x.period.start_date,
|
start_date: x.period.start_date,
|
||||||
last_date: format_date(end - time::Duration::days(1)),
|
last_date: format_date(end - time::Duration::days(1)),
|
||||||
status: x.period.status,
|
status: x.period.status,
|
||||||
spendable: x.spendable,
|
income_total: x.summary.income_total,
|
||||||
spent: x.spent,
|
fixed_expense_total: x.summary.fixed_expense_total,
|
||||||
spent_by_group: x.spent_by_group.into(),
|
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,
|
to_savings: x.period.to_savings,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod period;
|
|||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod rate_sources;
|
pub mod rate_sources;
|
||||||
pub mod rates;
|
pub mod rates;
|
||||||
|
pub mod reports;
|
||||||
pub mod review;
|
pub mod review;
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -57,9 +57,17 @@ pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppRes
|
|||||||
|
|
||||||
pub struct PeriodStat {
|
pub struct PeriodStat {
|
||||||
pub period: Period,
|
pub period: Period,
|
||||||
pub spendable: i64,
|
/// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil)
|
||||||
pub spent: i64,
|
pub summary: budget::Summary,
|
||||||
pub spent_by_group: GroupAmounts,
|
}
|
||||||
|
|
||||||
|
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 {
|
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 total_saved = list.iter().filter_map(|p| p.to_savings).sum();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
|
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
|
||||||
let s = budget::summary(db, household_id, p.id).await?;
|
let summary = budget::summary(db, household_id, p.id).await?;
|
||||||
out.push(PeriodStat {
|
out.push(PeriodStat { period: p, summary });
|
||||||
period: p,
|
|
||||||
spendable: s.spendable,
|
|
||||||
spent: s.spent_total,
|
|
||||||
spent_by_group: s.spent_by_group,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
out.reverse();
|
out.reverse();
|
||||||
Ok(Stats {
|
Ok(Stats {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -151,6 +151,17 @@ async fn period_stats_and_total_savings() {
|
|||||||
assert_eq!(list[1]["spent"], 42_500);
|
assert_eq!(list[1]["spent"], 42_500);
|
||||||
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
|
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
|
||||||
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
|
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);
|
assert_eq!(list[1]["to_savings"], Value::Null);
|
||||||
|
|
||||||
// limit: en yeni N dönem
|
// limit: en yeni N dönem
|
||||||
|
|||||||
+5
-1
@@ -1,10 +1,11 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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 ✅.
|
**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
|
## 📌 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).
|
- **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".
|
- **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.
|
- **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 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 + `<details>` "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 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 + `<details>` "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 "<ad> 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 ✅.**
|
- **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 "<ad> 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.
|
||||||
|
|||||||
+8
-7
@@ -13,7 +13,7 @@
|
|||||||
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
|
| **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 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
||||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
| **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 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 | ⏳ |
|
| **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ı
|
- ✅ 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
|
- ✅ 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)
|
## Faz 9 Adımları
|
||||||
- ⏳ 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
|
- ✅ 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ı — 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)
|
- ⏳ 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 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)
|
- ⏳ 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. JSON tam hane yedeği: sürüm bilgili dışa aktarma; geri yüklemede önizleme + onay (yalnızca sahip)
|
- ⏳ 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. 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)
|
- ⏳ 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
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ export type PeriodStat = { id: string, name: string, start_date: string,
|
|||||||
/**
|
/**
|
||||||
* Son gün, dahil
|
* 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
|
* Harcanan, gruplara göre
|
||||||
*/
|
*/
|
||||||
spent_by_group: GroupAmounts,
|
spent_by_group: GroupAmounts,
|
||||||
|
/**
|
||||||
|
* Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
|
||||||
|
*/
|
||||||
|
realized_savings: number,
|
||||||
/**
|
/**
|
||||||
* Kapanmamışsa `null`
|
* Kapanmamışsa `null`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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<ReportGroupRow>,
|
||||||
|
/**
|
||||||
|
* Harcama olan **tüm** kategoriler, çoktan aza
|
||||||
|
*/
|
||||||
|
categories: Array<ReportCategoryRow>,
|
||||||
|
/**
|
||||||
|
* Ödeme türleri; `null` = girilmemiş
|
||||||
|
*/
|
||||||
|
payment_methods: Array<ReportMethodRow>,
|
||||||
|
/**
|
||||||
|
* Aylar (`"YYYY-MM"`), eskiden yeniye
|
||||||
|
*/
|
||||||
|
months: Array<ReportMonthRow>, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
@@ -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, };
|
||||||
Reference in New Issue
Block a user