From dc0d783e46544baf7042ca0e0bf954b3a45f08b8 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 13 Nov 2026 21:36:43 +0300 Subject: [PATCH] =?UTF-8?q?feat(rapor):=20ayl=C4=B1k=20de=C4=9Ferlendirme?= =?UTF-8?q?=20raporu=20backend=20(=C5=9Fablon,=20yorumlar,=20=C3=B6neriler?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + Dockerfile | 2 + backend/Cargo.lock | 10 + backend/Cargo.toml | 1 + backend/api.http | 5 + backend/src/routes/mod.rs | 2 + backend/src/routes/monthly_report.rs | 44 + backend/src/services/mod.rs | 1 + backend/src/services/monthly_report.rs | 1347 ++++++++++++++++++++++++ backend/tests/backup.rs | 8 +- backend/tests/monthly_report.rs | 156 +++ docs/MEMORY.md | 4 +- docs/PHASES.md | 2 +- templates/monthly_report.md | 192 ++++ 14 files changed, 1772 insertions(+), 3 deletions(-) create mode 100644 backend/src/routes/monthly_report.rs create mode 100644 backend/src/services/monthly_report.rs create mode 100644 backend/tests/monthly_report.rs create mode 100644 templates/monthly_report.md diff --git a/CLAUDE.md b/CLAUDE.md index b8b4df6..b96f35d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ npm run db:restore -- [--force] - Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir. - Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç. - JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir). +- Aylık rapor: `services/monthly_report.rs` (`gather` → `Facts` → saf `analyze` → `render`), şablon `templates/monthly_report.md` (`include_str!`; Dockerfile `templates/`'u kopyalar). Filtreler `tl`/`pct`/`cell`. Cümlelerde sayıdan sonra ek kullanma ("%429'u" gibi ünlü uyumu gerekir) → "X / Y", "kullanımı %N". - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam diff --git a/Dockerfile b/Dockerfile index e0ec5f7..09cb702 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,8 @@ COPY backend/Cargo.toml backend/Cargo.lock ./ RUN mkdir src && echo 'fn main() {}' > src/main.rs && touch src/lib.rs \ && cargo build --release --locked \ && rm -rf src target/release/deps/*kakeibo* target/release/.fingerprint/kakeibo-* target/release/kakeibo* +# Aylık rapor şablonu derleme anında ikiliye gömülür (include_str!) +COPY templates/ /app/templates/ COPY backend/src ./src # touch: sahte lib.rs/main.rs'ten daha yeni olsun → Cargo gerçek kodu kesinlikle yeniden derler RUN touch src/lib.rs src/main.rs && cargo build --release --locked diff --git a/backend/Cargo.lock b/backend/Cargo.lock index dc4302e..35356aa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1428,6 +1428,7 @@ dependencies = [ "flate2", "http-body-util", "jsonwebtoken", + "minijinja", "mongodb", "quick-xml", "reqwest", @@ -1589,6 +1590,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "minijinja" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509eeb2603a84eccabb4d75f6ec8ba23b46bbf441aaeb863d6b83a5887fd51e4" +dependencies = [ + "serde", +] + [[package]] name = "miniz_oxide" version = "0.9.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 79f0c72..b4f59c4 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -10,6 +10,7 @@ axum-extra = { version = "0.12.6", features = ["cookie"] } dotenvy = "0.15.7" flate2 = "1" jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] } +minijinja = { version = "3.1.0", features = ["serde"] } mongodb = "3.10.0" quick-xml = { version = "0.42.0", features = ["serialize"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/backend/api.http b/backend/api.http index 7c6b7df..3cec949 100644 --- a/backend/api.http +++ b/backend/api.http @@ -420,3 +420,8 @@ Content-Type: application/json ### Geri al: geri dönüş noktasına dön (bu da yeni bir nokta bırakır) POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore + +# ─────────────────────────── Aylık değerlendirme raporu ─────────────────────────── + +### Dönemin raporu (Markdown; açık dönem → ara rapor) +GET {{baseUrl}}/periods/{{periodId}}/report.md diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 6f319d4..013af1a 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -9,6 +9,7 @@ mod catalog; mod entry; mod health; mod household; +mod monthly_report; mod note; mod period; mod plan; @@ -90,6 +91,7 @@ pub fn router(state: AppState) -> Router { .route("/periods/{id}/notes", get(note::list).post(note::create)) .route("/periods/{id}/summary", get(budget::summary)) .route("/periods/{id}/review", get(review::get)) + .route("/periods/{id}/report.md", get(monthly_report::get)) .route("/periods/{id}/daily", get(stats::daily)) .route("/stats/periods", get(stats::periods)) .route("/reports/breakdown", get(reports::breakdown)) diff --git a/backend/src/routes/monthly_report.rs b/backend/src/routes/monthly_report.rs new file mode 100644 index 0000000..be7579a --- /dev/null +++ b/backend/src/routes/monthly_report.rs @@ -0,0 +1,44 @@ +//! Aylık değerlendirme raporu (`services::monthly_report`, şablon `templates/monthly_report.md`). +//! +//! | Yöntem | Yol | İş | +//! |---|---|---| +//! | GET | `/api/periods/{id}/report.md` | dönemin raporu (Markdown); açık dönem → ara rapor | + +use axum::{ + extract::{Path, State}, + http::header, + response::IntoResponse, +}; +use mongodb::bson::Uuid; + +use crate::{ + auth::AuthUser, + error::{AppError, AppResult}, + services::{backup::slug, monthly_report, period}, + state::AppState, +}; + +pub async fn get( + State(s): State, + auth: AuthUser, + Path(id): Path, +) -> AppResult { + let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?; + let p = period::get(&s.db, auth.household_id, id).await?; + let body = monthly_report::markdown(&s.db, auth.household_id, id).await?; + let file = format!("kakeibo-rapor-{}.md", slug(&p.name)); + Ok(( + [ + ( + header::CONTENT_TYPE, + "text/markdown; charset=utf-8".to_string(), + ), + ( + header::CONTENT_DISPOSITION, + format!("inline; filename=\"{file}\""), + ), + (header::CACHE_CONTROL, "no-store".to_string()), + ], + body, + )) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 928ea2d..6f88448 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -13,6 +13,7 @@ pub mod entry; pub mod holding; pub mod invite; pub mod money; +pub mod monthly_report; pub mod note; pub mod period; pub mod plan; diff --git a/backend/src/services/monthly_report.rs b/backend/src/services/monthly_report.rs new file mode 100644 index 0000000..05233cb --- /dev/null +++ b/backend/src/services/monthly_report.rs @@ -0,0 +1,1347 @@ +//! Aylık değerlendirme raporu (Faz 9 Adım 6): `templates/monthly_report.md` → Markdown. +//! +//! Üç katman: +//! 1. [`gather`] (async): dönemin ve önceki en fazla 3 kapalı/arşiv dönemin verisini toplar → [`Facts`] +//! 2. [`analyze`] (saf): rakamlardan tablolar, "iyi gitti / zorladı / olsaydı" cümleleri, öneriler +//! 3. [`render`]: şablona yerleştirir (filtreler: `tl`, `pct`, `cell`) +//! +//! Kurallar sabit ve raporun sonunda açıklanır: grup bütçesinin %90 / %100'ü; kategoride +//! ortalamaya göre en az %20 **ve** 250 ₺ fark; öneriler 50 ₺'ye yuvarlanır. + +use std::collections::HashMap; + +use minijinja::{Environment, syntax::SyntaxConfig, value::Serde}; +use mongodb::{ + Database, + bson::{Uuid, doc}, +}; +use serde::Serialize; + +use crate::{ + db::col, + error::{AppError, AppResult}, + models::{ + common::scope, + group::Group, + note::NoteContext, + period::{Period, PeriodStatus}, + }, + services::{ + calendar::{format_date, parse_date, today_tr}, + catalog::{self, Catalog}, + note, period, rates, + reports::{self, Filter}, + review, stats, + }, +}; + +const TEMPLATE: &str = include_str!("../../../templates/monthly_report.md"); + +/// Kategori farkı eşikleri: oran **ve** tutar (küçük kalemler gürültü yapmasın) +const REL_DIFF: f64 = 0.20; +const MIN_DIFF: i64 = 25_000; +/// Önerilerde yuvarlama: 50 ₺ +const ROUND: i64 = 5_000; +/// Karşılaştırmada kullanılan önceki dönem sayısı +pub const PREV_N: usize = 3; + +// ---------- 1) Toplanan veri ---------- + +#[derive(Debug, Clone)] +pub struct GroupFact { + pub group: Group, + pub planned: i64, + pub spent: i64, +} + +#[derive(Debug, Clone)] +pub struct WeekFact { + pub index: u32, + pub start: String, + pub last: String, + pub planned: i64, + pub spent: i64, +} + +#[derive(Debug, Clone)] +pub struct CatFact { + pub id: Uuid, + pub name: String, + pub emoji: String, + pub group: Group, + pub spent: i64, + pub count: i64, +} + +/// Önceki dönem (karşılaştırma) +#[derive(Debug, Clone)] +pub struct PrevFact { + pub name: String, + pub available: i64, + pub goal: i64, + pub spent: i64, + pub realized: i64, + /// `Group::ALL` sırasıyla + pub groups: [i64; 4], + pub cats: HashMap, +} + +#[derive(Debug, Clone)] +pub struct NoteFact { + pub title: String, + pub body: String, +} + +#[derive(Debug, Clone)] +pub struct Facts { + pub household: String, + pub name: String, + /// `"YYYY-MM-DD"`, ikisi de dahil + pub start: String, + pub last: String, + pub status: PeriodStatus, + pub closed_on: Option, + pub today: String, + pub goal: i64, + pub available: i64, + pub spent: i64, + pub realized: i64, + pub groups: Vec, + pub weeks: Vec, + /// Harcama olan kategoriler, çoktan aza + pub cats: Vec, + /// En yeni önce, en fazla `PREV_N` + pub prev: Vec, + /// (ödeme türü adı, tutar), çoktan aza + pub methods: Vec<(String, i64)>, + /// (tarih, o gün harcanan) + pub daily: Vec<(String, i64)>, + pub to_savings: Option, + pub portfolio: Option, + /// Kapanışta gelecek döneme devreden: (`"surplus"` | `"debt"`, tutar) + pub carry: Option<(String, i64)>, + pub plan_notes: Vec, + pub review_notes: Vec, + pub reflection: Option, +} + +pub async fn gather(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult { + let p = period::get(db, household_id, period_id).await?; + let r = review::review(db, household_id, period_id).await?; + let household = db + .collection::(col::HOUSEHOLDS) + .find_one(doc! { "_id": household_id }) + .await? + .and_then(|h| h.get_str("name").ok().map(str::to_string)) + .unwrap_or_default(); + + let cat_items = catalog::list(db, Catalog::Categories, household_id, true).await?; + let cat_info: HashMap = cat_items + .iter() + .map(|c| (c.id, (c.name.clone(), c.emoji.clone().unwrap_or_default()))) + .collect(); + let cats = r + .categories + .iter() + .map(|c| { + let (name, emoji) = cat_info + .get(&c.category_id) + .cloned() + .unwrap_or_else(|| ("(silinmiş kategori)".into(), String::new())); + CatFact { + id: c.category_id, + name, + emoji, + group: c.group, + spent: c.spent, + count: c.count, + } + }) + .collect(); + + // Haftalar: 4 grubun aynı sıradaki haftası (hepsi aynı takvimden) + let weeks = r + .groups + .first() + .map(|g0| { + g0.weeks + .iter() + .enumerate() + .map(|(i, w)| WeekFact { + index: w.index, + start: w.start_date.clone(), + last: w.last_date.clone(), + planned: r.groups.iter().map(|g| g.weeks[i].amount).sum(), + spent: r.groups.iter().map(|g| g.weeks[i].spent).sum(), + }) + .collect() + }) + .unwrap_or_default(); + + // Önceki kapalı / arşiv dönemler (en yeni önce) + let mut prev = Vec::new(); + for q in period::list(db, household_id).await? { + if prev.len() >= PREV_N { + break; + } + if q.start_date >= p.start_date + || !matches!(q.status, PeriodStatus::Closed | PeriodStatus::Archived) + { + continue; + } + let qr = review::review(db, household_id, q.id).await?; + prev.push(PrevFact { + name: q.name.clone(), + available: qr.available, + goal: q.savings_goal, + spent: qr.summary.spent_total, + realized: qr.realized_savings, + groups: qr.summary.spent_by_group, + cats: qr + .categories + .iter() + .map(|c| (c.category_id, c.spent)) + .collect(), + }); + } + + let method_names: HashMap = + catalog::list(db, Catalog::PaymentMethods, household_id, true) + .await? + .into_iter() + .map(|m| (m.id, m.name)) + .collect(); + let breakdown = reports::breakdown( + db, + household_id, + Filter { + period_id: Some(period_id), + ..Default::default() + }, + ) + .await?; + let methods = breakdown + .methods + .iter() + .map(|m| { + let name = m + .key + .and_then(|id| method_names.get(&id).cloned()) + .unwrap_or_else(|| "Belirtilmemiş".into()); + (name, m.total) + }) + .collect(); + + let daily = stats::daily(db, household_id, period_id) + .await? + .into_iter() + .map(|d| (d.date, d.spent.iter().sum())) + .collect(); + + let portfolio = { + let pf = rates::portfolio(db, household_id).await?; + let total: i64 = pf.items.iter().filter_map(|v| v.value).sum(); + (total > 0).then_some(total) + }; + + let carry = { + let mut f = scope(household_id); + f.insert("from_period_id", period_id); + db.collection::(col::CARRYOVERS) + .find_one(f) + .await? + .and_then(|d| { + let amount = match d.get("amount") { + Some(mongodb::bson::Bson::Int64(v)) => *v, + Some(mongodb::bson::Bson::Int32(v)) => i64::from(*v), + _ => return None, + }; + Some((d.get_str("kind").ok()?.to_string(), amount)) + }) + }; + + let notes = |c| async move { + Ok::<_, AppError>( + note::list(db, household_id, period_id, Some(c)) + .await? + .into_iter() + .rev() // yazılış sırası (en eski önce) + .map(|n| NoteFact { + title: n.title, + body: n.body_md, + }) + .collect::>(), + ) + }; + + Ok(Facts { + household, + name: p.name.clone(), + start: p.start_date.clone(), + last: last_day(&p), + status: p.status, + closed_on: p.closed_at.map(|d| { + let tr = time::UtcOffset::from_hms(3, 0, 0).expect("geçerli ofset"); + let utc = time::OffsetDateTime::from_unix_timestamp(d.timestamp_millis() / 1000) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH); + format_date(utc.to_offset(tr).date()) + }), + today: format_date(today_tr()), + goal: p.savings_goal, + available: r.available, + spent: r.summary.spent_total, + realized: r.realized_savings, + groups: r + .groups + .iter() + .map(|g| GroupFact { + group: g.group, + planned: g.monthly, + spent: g.spent, + }) + .collect(), + weeks, + cats, + prev, + methods, + daily, + to_savings: p.to_savings, + portfolio, + carry, + plan_notes: notes(NoteContext::Planning).await?, + review_notes: notes(NoteContext::Review).await?, + reflection: p.reflection_md.clone().filter(|s| !s.trim().is_empty()), + }) +} + +fn last_day(p: &Period) -> String { + parse_date(&p.end_date) + .map(|d| format_date(d - time::Duration::days(1))) + .unwrap_or_else(|| p.end_date.clone()) +} + +// ---------- Biçim ---------- + +/// Kuruş → `1.250,00 ₺` (arayüzdeki `formatMoney` ile aynı) +pub fn tl(kurus: i64) -> String { + let abs = kurus.unsigned_abs(); + let int = (abs / 100).to_string(); + let mut grouped = String::new(); + for (i, ch) in int.chars().enumerate() { + if i > 0 && (int.len() - i).is_multiple_of(3) { + grouped.push('.'); + } + grouped.push(ch); + } + let sign = if kurus < 0 { "-" } else { "" }; + format!("{sign}{grouped},{:02} ₺", abs % 100) +} + +/// Oran → `%84` (Türkçe yazımda işaret önde) +pub fn pct(ratio: f64) -> String { + format!("%{}", (ratio * 100.0).round() as i64) +} + +/// `2026-11-16` → `16.11.2026` +fn date(iso: &str) -> String { + match (iso.get(0..4), iso.get(5..7), iso.get(8..10)) { + (Some(y), Some(m), Some(d)) => format!("{d}.{m}.{y}"), + _ => iso.to_string(), + } +} + +fn short(iso: &str) -> String { + date(iso).get(0..5).unwrap_or_default().to_string() +} + +fn ratio(a: i64, b: i64) -> Option { + (b > 0).then(|| a as f64 / b as f64) +} + +/// 50 ₺'ye yukarı yuvarla +fn round_up(kurus: i64) -> i64 { + if kurus <= 0 { + 0 + } else { + (kurus + ROUND - 1) / ROUND * ROUND + } +} + +fn mark(spent: i64, planned: i64) -> &'static str { + match ratio(spent, planned) { + None => "", + Some(r) if r <= 0.9 => "✅", + Some(r) if r <= 1.0 => "⚠️", + Some(_) => "❌", + } +} + +pub fn emoji(g: Group) -> &'static str { + match g { + Group::Needs => "🏠", + Group::Wants => "🛍️", + Group::Culture => "📚", + Group::Unexpected => "⚡", + } +} + +fn join_names(names: &[&str]) -> String { + match names { + [] => String::new(), + [one] => (*one).to_string(), + [rest @ .., last] => format!("{} ve {last}", rest.join(", ")), + } +} + +// ---------- 2) Analiz ---------- + +#[derive(Debug, Serialize)] +pub struct Question { + label: &'static str, + now: i64, + prev: Option, +} + +#[derive(Debug, Serialize)] +pub struct GroupRow { + emoji: &'static str, + label: &'static str, + planned: i64, + spent: i64, + left: i64, + usage: Option, + mark: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct Totals { + planned: i64, + spent: i64, + left: i64, + usage: Option, +} + +#[derive(Debug, Serialize)] +pub struct CatRow { + group: &'static str, + emoji: String, + name: String, + spent: i64, + count: i64, + share: Option, + /// Ortalamaya göre (`+%27 (+1.150,00 ₺)`), ortalama yoksa `yeni` + change: String, +} + +#[derive(Debug, Serialize)] +pub struct WeekRow { + index: u32, + range: String, + planned: i64, + spent: i64, + usage: Option, + mark: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct MethodRow { + name: String, + spent: i64, + share: Option, +} + +#[derive(Debug, Serialize)] +pub struct NoteRow { + title: String, + body: String, +} + +#[derive(Debug, Serialize)] +pub struct Suggestion { + emoji: &'static str, + label: &'static str, + amount: i64, + basis: String, +} + +/// Şablonun bağlamı +#[derive(Debug, Serialize)] +pub struct Report { + pub title: String, + pub household: String, + pub range: String, + pub status_line: String, + pub interim: bool, + pub headline: String, + pub prev_name: Option, + pub questions: Vec, + pub groups: Vec, + pub totals: Totals, + pub top: Vec, + pub avg_n: usize, + pub weeks: Vec, + pub activity: Vec, + pub methods: Vec, + pub savings: Vec, + pub good: Vec, + pub hard: Vec, + pub could: Vec, + pub plan_notes: Vec, + pub review_notes: Vec, + pub reflection: Option, + pub suggestions: Vec, + pub suggestion_total: i64, + pub suggestion_note: Option, + pub steps: Vec, + pub carry: Option, + pub categories: Vec, + pub generated_on: String, +} + +/// Kategori: bu dönem − önceki dönemlerin ortalaması (o dönemlerde hiç yoksa 0 sayılır) +fn cat_avg(f: &Facts, id: Uuid) -> Option { + if f.prev.is_empty() { + return None; + } + let sum: i64 = f.prev.iter().map(|p| *p.cats.get(&id).unwrap_or(&0)).sum(); + Some(sum / f.prev.len() as i64) +} + +/// Ortalamadan anlamlı sapma: oran ≥ %20 **ve** tutar ≥ 250 ₺ +fn significant(spent: i64, avg: i64) -> bool { + let diff = (spent - avg).abs(); + diff >= MIN_DIFF && (avg == 0 || diff as f64 / avg as f64 >= REL_DIFF) +} + +pub fn analyze(f: &Facts) -> Report { + let interim = + matches!(f.status, PeriodStatus::Open | PeriodStatus::Correcting) && f.today <= f.last; + let prev = f.prev.first(); + let days_total = f.daily.len(); + let cutoff = if interim { + f.today.as_str() + } else { + f.last.as_str() + }; + let days_elapsed = f.daily.iter().filter(|(d, _)| d.as_str() <= cutoff).count(); + + // --- Durum satırı ve başlık cümlesi + let status_line = match f.status { + PeriodStatus::Closed => match &f.closed_on { + Some(d) => format!("Kapandı {}", date(d)), + None => "Kapandı".into(), + }, + PeriodStatus::Archived => "Arşiv dönemi (ekstreden)".into(), + PeriodStatus::Correcting => "Düzeltmede".into(), + PeriodStatus::Open if interim => format!("Sürüyor ({days_elapsed} / {days_total} gün)"), + PeriodStatus::Open => "Açık".into(), + }; + + let mut headline = if interim { + format!( + "Dönem sürüyor ({days_elapsed} / {days_total} gün): şu ana kadar {} harcadın, elinde {} kalıyor.", + tl(f.spent), + tl(f.realized) + ) + } else if f.realized < 0 { + format!( + "Bu dönem {} açık verdin; harcamalar elindeki parayı aştı.", + tl(-f.realized) + ) + } else if f.goal > 0 { + let p = ratio(f.realized, f.goal).unwrap_or(0.0); + if f.realized >= f.goal { + format!( + "Birikim hedefin {} idi; {} biriktirdin ({}). Hedefe ulaştın.", + tl(f.goal), + tl(f.realized), + pct(p) + ) + } else { + format!( + "Birikim hedefin {} idi; {} biriktirdin ({}).", + tl(f.goal), + tl(f.realized), + pct(p) + ) + } + } else { + format!("Bu dönem {} biriktirdin.", tl(f.realized)) + }; + if f.goal > 0 && interim { + headline.push_str(&format!(" Birikim hedefin {}.", tl(f.goal))); + } + if let Some(p) = prev.filter(|_| !interim) { + let diff = f.realized - p.realized; + if diff != 0 { + headline.push_str(&format!( + " Önceki döneme ({}) göre {} {}.", + p.name, + tl(diff.abs()), + if diff > 0 { "daha fazla" } else { "daha az" } + )); + } + } + + let questions = vec![ + Question { + label: "Ne kadar param vardı?", + now: f.available, + prev: prev.map(|p| p.available), + }, + Question { + label: "Ne kadar biriktirmek istedim?", + now: f.goal, + prev: prev.map(|p| p.goal), + }, + Question { + label: "Ne kadar harcadım?", + now: f.spent, + prev: prev.map(|p| p.spent), + }, + Question { + label: if interim { + "Şu an ne kadar kalıyor?" + } else { + "Ne kadar biriktirdim?" + }, + now: f.realized, + prev: prev.map(|p| p.realized), + }, + ]; + + // --- Gruplar + let groups: Vec = f + .groups + .iter() + .map(|g| GroupRow { + emoji: emoji(g.group), + label: g.group.label(), + planned: g.planned, + spent: g.spent, + left: g.planned - g.spent, + usage: ratio(g.spent, g.planned), + mark: mark(g.spent, g.planned), + }) + .collect(); + let planned_total: i64 = f.groups.iter().map(|g| g.planned).sum(); + let totals = Totals { + planned: planned_total, + spent: f.spent, + left: planned_total - f.spent, + usage: ratio(f.spent, planned_total), + }; + + // --- Kategoriler + let cat_row = |c: &CatFact| { + let change = match cat_avg(f, c.id) { + None => String::new(), + Some(0) => "yeni".into(), + Some(avg) => { + let d = c.spent - avg; + format!( + "{}{} ({}{})", + if d >= 0 { "+" } else { "-" }, + pct((d as f64 / avg as f64).abs()), + if d >= 0 { "+" } else { "" }, + tl(d) + ) + } + }; + CatRow { + group: c.group.label(), + emoji: c.emoji.clone(), + name: c.name.clone(), + spent: c.spent, + count: c.count, + share: ratio(c.spent, f.spent), + change, + } + }; + let top = f.cats.iter().take(5).map(cat_row).collect(); + let mut all: Vec<&CatFact> = f.cats.iter().collect(); + all.sort_by_key(|c| (c.group.order(), -c.spent)); + let categories = all.into_iter().map(cat_row).collect(); + + // Ortalamadan sapan kategoriler (artan / azalan), etkisi büyükten + let mut ups: Vec<(&CatFact, i64)> = Vec::new(); + let mut downs: Vec<(&CatFact, i64)> = Vec::new(); + if !interim { + for c in &f.cats { + if let Some(avg) = cat_avg(f, c.id).filter(|a| *a > 0) + && significant(c.spent, avg) + { + if c.spent > avg { + ups.push((c, avg)); + } else { + downs.push((c, avg)); + } + } + } + } + ups.sort_by_key(|(c, avg)| -(c.spent - avg)); + downs.sort_by_key(|(c, avg)| -(avg - c.spent)); + + // --- Haftalar + let weeks: Vec = f + .weeks + .iter() + .map(|w| WeekRow { + index: w.index, + range: format!("{} – {}", short(&w.start), short(&w.last)), + planned: w.planned, + spent: w.spent, + usage: ratio(w.spent, w.planned), + mark: mark(w.spent, w.planned), + }) + .collect(); + let finished_weeks: Vec<&WeekFact> = f + .weeks + .iter() + .filter(|w| !interim || w.last.as_str() < f.today.as_str()) + .collect(); + let busiest = finished_weeks + .iter() + .filter_map(|w| ratio(w.spent, w.planned).map(|r| (*w, r))) + .max_by(|a, b| a.1.total_cmp(&b.1)); + let weeks_ok = finished_weeks + .iter() + .filter(|w| w.planned > 0 && w.spent <= w.planned) + .count(); + let weeks_budgeted = finished_weeks.iter().filter(|w| w.planned > 0).count(); + + let no_spend = f + .daily + .iter() + .filter(|(d, s)| d.as_str() <= cutoff && *s == 0) + .count(); + let mut activity = Vec::new(); + if let Some(top_day) = f + .daily + .iter() + .filter(|(_, s)| *s > 0) + .max_by_key(|(_, s)| *s) + { + activity.push(format!( + "En çok harcadığın gün {} ({}).", + date(&top_day.0), + tl(top_day.1) + )); + } + if days_elapsed > 0 { + activity.push(format!( + "Harcama yapmadığın gün: {no_spend} / {days_elapsed}; günlük ortalama {}.", + tl(f.spent / days_elapsed as i64) + )); + } + + let methods = f + .methods + .iter() + .map(|(n, s)| MethodRow { + name: n.clone(), + spent: *s, + share: ratio(*s, f.spent), + }) + .collect(); + + let mut savings = Vec::new(); + if let Some(s) = f.to_savings.filter(|s| *s > 0) { + savings.push(format!("Kapanışta birikime aktarılan: {}.", tl(s))); + } + if let Some(v) = f.portfolio { + savings.push(format!("Toplam birikimlerinin bugünkü değeri: {}.", tl(v))); + } + + // --- 3. Neler iyi gitti? + let mut good = Vec::new(); + if !interim && f.goal > 0 && f.realized >= f.goal { + good.push(format!( + "Birikim hedefini tutturdun: {} hedef, {} birikim.", + tl(f.goal), + tl(f.realized) + )); + } + let under: Vec<&GroupFact> = f + .groups + .iter() + .filter(|g| g.planned > 0 && g.spent <= g.planned) + .collect(); + if !under.is_empty() { + let names: Vec<&str> = under.iter().map(|g| g.group.label()).collect(); + let saved: i64 = under.iter().map(|g| g.planned - g.spent).sum(); + good.push(if saved > 0 { + format!( + "{} bütçenin içinde kaldı; toplam {} artırdın.", + join_names(&names), + tl(saved) + ) + } else { + format!("{} bütçenin içinde kaldı.", join_names(&names)) + }); + } + if let Some(p) = prev.filter(|p| !interim && f.realized > p.realized && p.realized != 0) { + good.push(format!( + "Önceki döneme göre {} daha fazla biriktirdin.", + tl(f.realized - p.realized) + )); + } + for (c, avg) in downs.iter().take(2) { + good.push(format!( + "{} harcaman ortalamanın {} altında ({}).", + c.name, + pct((avg - c.spent) as f64 / *avg as f64), + tl(c.spent - avg) + )); + } + if weeks_budgeted >= 2 && weeks_ok * 2 >= weeks_budgeted { + good.push(format!( + "Haftalık bütçeyi aşmadığın hafta: {weeks_ok} / {weeks_budgeted}." + )); + } + if no_spend >= 3 { + good.push(format!("{no_spend} gün hiç harcama yapmadın.")); + } + good.truncate(4); + + // --- 4. Neler zorladı? + let mut hard = Vec::new(); + if !interim { + if f.realized < 0 { + hard.push(format!( + "Bu dönem {} açık verdin; gelecek döneme borç olarak devreder.", + tl(-f.realized) + )); + } else if f.goal > 0 && f.realized < f.goal { + hard.push(format!( + "Birikim hedefinin {} altında kaldın.", + tl(f.goal - f.realized) + )); + } + } + let mut over: Vec<&GroupFact> = f + .groups + .iter() + .filter(|g| g.planned > 0 && g.spent > g.planned) + .collect(); + over.sort_by_key(|g| -(g.spent - g.planned)); + for g in &over { + hard.push(format!( + "{} grubu bütçeyi {} aştı ({}).", + g.group.label(), + tl(g.spent - g.planned), + pct(g.spent as f64 / g.planned as f64) + )); + } + for g in f.groups.iter().filter(|g| g.planned == 0 && g.spent > 0) { + hard.push(format!( + "{} grubuna bütçe ayrılmamıştı, {} harcandı.", + g.group.label(), + tl(g.spent) + )); + } + for (c, avg) in ups.iter().take(2) { + hard.push(format!( + "{}, ortalamasının {} üstünde ({}{}); {} işlem.", + c.name, + pct((c.spent - avg) as f64 / *avg as f64), + "+", + tl(c.spent - avg), + c.count + )); + } + if let Some((w, r)) = busiest.filter(|(_, r)| *r > 1.0) { + hard.push(format!( + "En yoğun hafta {}. hafta ({} – {}): haftalık bütçe kullanımı {}.", + w.index, + short(&w.start), + short(&w.last), + pct(r) + )); + } + hard.truncate(4); + + // --- 5. Ne yapılabilirdi? + let mut could = Vec::new(); + for g in over.iter().take(2) { + let excess = g.spent - g.planned; + let would = f.realized + excess; + let tail = if f.goal > 0 && would >= f.goal && f.realized < f.goal { + " → hedefin tamamı" + } else { + "" + }; + could.push(format!( + "{} bütçede kalsaydı birikimin {} olurdu (+{}){tail}.", + g.group.label(), + tl(would), + tl(excess) + )); + } + if let Some((c, avg)) = ups.first() { + could.push(format!( + "{} ortalamasında kalsaydı +{} daha birikirdi.", + c.name, + tl(c.spent - avg) + )); + } + if over.len() >= 2 { + let total: i64 = over.iter().map(|g| g.spent - g.planned).sum(); + let gap = f.goal - f.realized; + if gap > 0 && !interim { + could.push(format!( + "Aşan grupların toplam fazlası {}; hedefe kalan farkı karşılama oranı {}.", + tl(total), + pct((total as f64 / gap as f64).min(1.0)) + )); + } + } + could.truncate(3); + + // --- 7. Önerilen bütçe: bu + önceki dönemlerin ortalama harcaması + let n = 1 + f.prev.len(); + let suggestions: Vec = f + .groups + .iter() + .map(|g| { + let i = g.group.order(); + let sum: i64 = g.spent + f.prev.iter().map(|p| p.groups[i]).sum::(); + let (amount, basis) = if sum == 0 { + ( + round_up(g.planned), + "harcama yok; bu dönemin bütçesi".to_string(), + ) + } else if n == 1 { + (round_up(g.spent), "bu dönemin harcaması".to_string()) + } else { + ( + round_up(sum / n as i64), + format!("son {n} dönemin ortalaması"), + ) + }; + Suggestion { + emoji: emoji(g.group), + label: g.group.label(), + amount, + basis, + } + }) + .collect(); + let suggestion_total: i64 = suggestions.iter().map(|s| s.amount).sum(); + let room = f.available - f.goal; + let suggestion_note = (f.available > 0).then(|| { + if suggestion_total > room { + format!( + "Bu öneriler, bu dönemin rakamlarıyla birikim hedefine ayrılan payı {} aşıyor; gelirin aynı kalırsa önce İstek ve Kültürel'den kısmayı düşün.", + tl(suggestion_total - room) + ) + } else { + format!( + "Bu dönemin rakamlarıyla, bu bütçeden sonra birikim hedefinin üstünde {} pay kalıyor.", + tl(room - suggestion_total) + ) + } + }); + + // Somut adımlar + let weeks_n = f.weeks.len().max(1) as i64; + let mut steps = Vec::new(); + if let Some((c, avg)) = ups.first() { + steps.push(format!( + "{} için haftalık {} sınır koy (ortalamanın haftalık payı).", + c.name, + tl(round_up(avg / weeks_n)) + )); + } else if let Some(g) = over.first() + && let Some(c) = f.cats.iter().find(|c| c.group == g.group) + { + steps.push(format!( + "{} grubunda en büyük kalem {} ({}); haftalık {} sınır yaklaşık %10 tasarruf sağlar.", + g.group.label(), + c.name, + tl(c.spent), + tl(round_up(c.spent * 9 / 10 / weeks_n)) + )); + } + if let Some((w, r)) = busiest.filter(|(_, r)| *r > 1.0) { + steps.push(format!( + "{}. haftaya dikkat: bu dönem o haftanın bütçe kullanımı {} oldu; o haftaya düşen ödemeleri önceden planla.", + w.index, + pct(r) + )); + } + if !interim && f.goal > 0 && f.realized < f.goal { + steps.push(format!( + "Birikimi ay sonuna bırakma: gelir yattığı gün hedefin olan {} tutarını ayrı bir hesaba aktar.", + tl(f.goal) + )); + } else if !interim && f.goal > 0 && over.is_empty() { + steps.push(format!( + "Hedefi tutturdun; gelecek dönem %10 artırmayı düşünebilirsin: {}.", + tl(round_up(f.goal * 11 / 10)) + )); + } + if steps.is_empty() { + steps.push("Bu dönemin düzenini koru; aynı bütçeyle devam edebilirsin.".into()); + } + steps.truncate(3); + + let carry = f.carry.as_ref().map(|(kind, amount)| { + if kind == "debt" { + format!( + "gelecek döneme {} borç devrediyor (harcanabilir tutardan düşülür).", + tl(*amount) + ) + } else { + format!( + "gelecek döneme {} artı devrediyor (harcanabilir tutara eklenir).", + tl(*amount) + ) + } + }); + + let note_rows = |v: &[NoteFact]| { + v.iter() + .filter(|n| !n.title.trim().is_empty() || !n.body.trim().is_empty()) + .map(|n| NoteRow { + title: if n.title.trim().is_empty() { + "Not".into() + } else { + n.title.clone() + }, + body: n.body.clone(), + }) + .collect() + }; + + Report { + title: f.name.clone(), + household: f.household.clone(), + range: format!("{} – {}", date(&f.start), date(&f.last)), + status_line, + interim, + headline, + prev_name: prev.map(|p| p.name.clone()), + questions, + groups, + totals, + top, + avg_n: if interim { 0 } else { f.prev.len() }, + weeks, + activity, + methods, + savings, + good, + hard, + could, + plan_notes: note_rows(&f.plan_notes), + review_notes: note_rows(&f.review_notes), + reflection: f.reflection.clone(), + suggestions, + suggestion_total, + suggestion_note, + steps, + carry, + categories, + generated_on: date(&f.today), + } +} + +// ---------- 3) Şablon ---------- + +pub fn render(r: &Report) -> AppResult { + let mut env = Environment::new(); + let syntax = SyntaxConfig::builder() + .trim_blocks(true) + .lstrip_blocks(true) + .keep_trailing_newline(true) + .build() + .map_err(|e| AppError::Internal(format!("şablon sözdizimi: {e}")))?; + env.set_syntax(syntax); + env.add_filter("tl", |v: Option| v.map_or("—".to_string(), tl)); + env.add_filter("pct", |v: Option| v.map_or("—".to_string(), pct)); + // Tablo hücresi: `|` ve satır sonu tabloyu bozmasın + env.add_filter("cell", |v: String| { + v.replace('|', "\\|").replace(['\n', '\r'], " ") + }); + env.add_template("monthly_report.md", TEMPLATE) + .map_err(|e| AppError::Internal(format!("şablon: {e}")))?; + let out = env + .get_template("monthly_report.md") + .and_then(|t| t.render(Serde(r))) + .map_err(|e| AppError::Internal(format!("rapor üretilemedi: {e}")))?; + // Boş bölümlerden kalan art arda boş satırları sadeleştir + let mut clean = String::with_capacity(out.len()); + let mut blank = 0; + for line in out.lines() { + if line.trim().is_empty() { + blank += 1; + if blank > 1 { + continue; + } + } else { + blank = 0; + } + clean.push_str(line); + clean.push('\n'); + } + Ok(clean.trim_start().to_string()) +} + +pub async fn markdown(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult { + let f = gather(db, household_id, period_id).await?; + render(&analyze(&f)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cat(name: &str, group: Group, spent: i64, count: i64) -> CatFact { + CatFact { + id: crate::models::common::new_id(), + name: name.into(), + emoji: String::new(), + group, + spent, + count, + } + } + + /// Kasım: gelir 32.000, hedef 5.000, harcama 27.800 → birikim 4.200. + /// İstek 1.400 ₺ aştı; Market ortalamanın %27 üstünde; Restoran %30 altında. + fn facts() -> Facts { + let market = cat("Market", Group::Needs, 550_000, 23); + let restoran = cat("Restoran", Group::Wants, 200_000, 4); + let giyim = cat("Giyim", Group::Wants, 760_000, 3); + let (market_id, restoran_id) = (market.id, restoran.id); + let prev_cats = |m: i64, r: i64| HashMap::from([(market_id, m), (restoran_id, r)]); + let w = |i: u32, s: &str, l: &str, planned: i64, spent: i64| WeekFact { + index: i, + start: s.into(), + last: l.into(), + planned, + spent, + }; + Facts { + household: "Çakır Hanesi".into(), + name: "Kasım 2026".into(), + start: "2026-11-01".into(), + last: "2026-11-30".into(), + status: PeriodStatus::Closed, + closed_on: Some("2026-12-01".into()), + today: "2026-12-02".into(), + goal: 500_000, + available: 3_200_000, + spent: 2_780_000, + realized: 420_000, + groups: vec![ + GroupFact { + group: Group::Needs, + planned: 1_600_000, + spent: 1_500_000, + }, + GroupFact { + group: Group::Wants, + planned: 860_000, + spent: 1_000_000, + }, + GroupFact { + group: Group::Culture, + planned: 200_000, + spent: 150_000, + }, + GroupFact { + group: Group::Unexpected, + planned: 200_000, + spent: 130_000, + }, + ], + weeks: vec![ + w(1, "2026-11-01", "2026-11-08", 760_000, 600_000), + w(2, "2026-11-09", "2026-11-15", 665_000, 600_000), + w(3, "2026-11-16", "2026-11-22", 665_000, 931_000), + w(4, "2026-11-23", "2026-11-30", 770_000, 649_000), + ], + cats: vec![giyim, market, restoran], + prev: vec![ + PrevFact { + name: "Ekim 2026".into(), + available: 3_150_000, + goal: 500_000, + spent: 2_840_000, + realized: 310_000, + groups: [1_500_000, 900_000, 200_000, 240_000], + cats: prev_cats(430_000, 290_000), + }, + PrevFact { + name: "Eylül 2026".into(), + available: 0, + goal: 0, + spent: 2_500_000, + realized: -2_500_000, + groups: [1_400_000, 800_000, 100_000, 200_000], + cats: prev_cats(440_000, 280_000), + }, + ], + methods: vec![("Kredi Kartı".into(), 2_000_000), ("Nakit".into(), 780_000)], + daily: (1..=30) + .map(|d| { + ( + format!("2026-11-{d:02}"), + if d % 4 == 0 { 0 } else { 100_000 }, + ) + }) + .collect(), + to_savings: Some(420_000), + portfolio: Some(1_250_000), + carry: None, + plan_notes: vec![NoteFact { + title: "Hedef".into(), + body: "Dışarıda **az** yemek.".into(), + }], + review_notes: vec![], + reflection: Some("Market alışverişini haftada bire indir.".into()), + } + } + + #[test] + fn money_and_percent_format() { + assert_eq!(tl(125_050), "1.250,50 ₺"); + assert_eq!(tl(-100_000_000), "-1.000.000,00 ₺"); + assert_eq!(tl(5), "0,05 ₺"); + assert_eq!(pct(0.844), "%84"); + assert_eq!(round_up(1), 5_000); + assert_eq!(round_up(120_001), 125_000); + assert_eq!(round_up(0), 0); + } + + #[test] + fn thresholds() { + // Kategori: %20 **ve** 250 ₺ birlikte + assert!(significant(550_000, 435_000), "+1.150 ₺, %26"); + assert!(!significant(1_000_000, 900_000), "1.000 ₺ ama yalnız %11"); + assert!(!significant(30_000, 10_000), "%200 ama yalnız 200 ₺"); + assert!(significant(25_000, 0), "yeni kategori, 250 ₺"); + // Grup / hafta işareti: %90'a kadar ✅, %100'e kadar ⚠️, üstü ❌ + assert_eq!(mark(90, 100), "✅"); + assert_eq!(mark(95, 100), "⚠️"); + assert_eq!(mark(100, 100), "⚠️"); + assert_eq!(mark(101, 100), "❌"); + assert_eq!(mark(5, 0), "", "bütçe yoksa işaret yok"); + } + + #[test] + fn closed_period_insights() { + let r = analyze(&facts()); + assert!(!r.interim); + assert_eq!( + r.headline, + "Birikim hedefin 5.000,00 ₺ idi; 4.200,00 ₺ biriktirdin (%84). Önceki döneme (Ekim 2026) göre 1.100,00 ₺ daha fazla." + ); + assert_eq!(r.avg_n, 2); + // Market: ortalama (4.300 + 4.400) / 2 = 4.350 → +1.150 (%26) + assert!( + r.hard + .iter() + .any(|l| l == "Market, ortalamasının %26 üstünde (+1.150,00 ₺); 23 işlem."), + "{:?}", + r.hard + ); + assert_eq!(r.hard[0], "Birikim hedefinin 800,00 ₺ altında kaldın."); + assert_eq!(r.hard[1], "İstek grubu bütçeyi 1.400,00 ₺ aştı (%116)."); + // Restoran: ortalama 2.850 → −850 (%30) + assert!( + r.good + .iter() + .any(|l| l == "Restoran harcaman ortalamanın %30 altında (-850,00 ₺)."), + "{:?}", + r.good + ); + assert!( + r.good + .iter() + .any(|l| l.starts_with("Zorunlu, Kültürel ve Beklenmeyen bütçenin içinde kaldı")) + ); + assert_eq!( + r.could[0], + "İstek bütçede kalsaydı birikimin 5.600,00 ₺ olurdu (+1.400,00 ₺) → hedefin tamamı." + ); + assert!( + r.steps[0].starts_with("Market için haftalık 1.100,00 ₺ sınır"), + "{:?}", + r.steps + ); + assert!(r.steps.iter().any(|s| s.starts_with("3. haftaya dikkat"))); + assert!(r.steps.iter().any(|s| s.contains("gelir yattığı gün"))); + // Önerilen bütçe: (bu + 2 önceki) / 3, 50 ₺'ye yukarı + // Zorunlu: (15.000 + 15.000 + 14.000) / 3 = 14.666,66 → 14.700 + assert_eq!(r.suggestions[0].amount, 1_470_000); + assert_eq!(r.suggestions[0].basis, "son 3 dönemin ortalaması"); + // Ek: grup sırası, grup içinde çoktan aza + let order: Vec<&str> = r.categories.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(order, ["Market", "Giyim", "Restoran"]); + } + + #[test] + fn interim_first_period_and_render() { + let mut f = facts(); + f.status = PeriodStatus::Open; + f.closed_on = None; + f.today = "2026-11-13".into(); + f.prev.clear(); + f.plan_notes.clear(); + f.reflection = None; + let r = analyze(&f); + assert!(r.interim); + assert!( + r.headline.starts_with("Dönem sürüyor (13 / 30 gün)"), + "{}", + r.headline + ); + assert_eq!(r.status_line, "Sürüyor (13 / 30 gün)"); + assert!(r.prev_name.is_none() && r.avg_n == 0); + assert!( + !r.hard.iter().any(|l| l.contains("Birikim hedefinin")), + "ara raporda hedef yargısı yok" + ); + assert!( + !r.hard.iter().any(|l| l.contains("En yoğun hafta")), + "3. hafta henüz bitmedi" + ); + assert_eq!(r.suggestions[0].basis, "bu dönemin harcaması"); + + let md = render(&r).unwrap(); + assert!( + md.starts_with("# Kasım 2026 — Aylık Değerlendirme\n"), + "{md}" + ); + assert!(md.contains("> **Ara rapor:**")); + assert!( + md.contains("| Kakeibo'nun 4 sorusu | Bu dönem |\n|---|---:|\n"), + "tek sütun" + ); + assert!(md.contains("| 🛍️ İstek | 8.600,00 ₺ | 10.000,00 ₺ | -1.400,00 ₺ | ❌ %116 |")); + assert!(md.contains("_Bu dönem için not yazılmamış.")); + assert!(!md.contains("\n\n\n"), "art arda boş satır yok"); + assert!(!md.contains("{{") && !md.contains("{%")); + } + + #[test] + fn render_full_report_and_escape_cells() { + let mut f = facts(); + f.cats[0].name = "Giyim | Ayakkabı".into(); + let md = render(&analyze(&f)).unwrap(); + assert!(md.contains("| Kakeibo'nun 4 sorusu | Bu dönem | Ekim 2026 |")); + assert!(md.contains("| Ne kadar biriktirdim? | 4.200,00 ₺ | 3.100,00 ₺ |")); + assert!(md.contains("Giyim \\| Ayakkabı"), "tablo bozulmasın"); + assert!(md.contains("#### Hedef\n\nDışarıda **az** yemek.")); + assert!(md.contains( + "#### Nasıl daha iyi yapabilirim?\n\nMarket alışverişini haftada bire indir." + )); + assert!(md.contains("1. Market için haftalık")); + assert!(md.contains("- [ ] ____")); + assert!(md.contains("| Kredi Kartı | 20.000,00 ₺ | %72 |")); + assert!(md.contains("Rapor tarihi: 02.12.2026.")); + } +} diff --git a/backend/tests/backup.rs b/backend/tests/backup.rs index dc25f81..027ef8d 100644 --- a/backend/tests/backup.rs +++ b/backend/tests/backup.rs @@ -358,7 +358,13 @@ async fn bad_files_and_permissions() { let newer = file.replacen("\"schema_version\": ", "\"schema_version\": 99", 1); let (st, b) = app.post_text("/backup/preview", &owner, &newer).await; assert_eq!(st, StatusCode::BAD_REQUEST); - assert!(b["error"]["message"].as_str().unwrap().contains("daha yeni"), "{b}"); + assert!( + b["error"]["message"] + .as_str() + .unwrap() + .contains("daha yeni"), + "{b}" + ); assert_eq!(view(&app, &owner, &nov).await, before); assert!(points(&app, &owner).await.is_empty()); diff --git a/backend/tests/monthly_report.rs b/backend/tests/monthly_report.rs new file mode 100644 index 0000000..ed053a3 --- /dev/null +++ b/backend/tests/monthly_report.rs @@ -0,0 +1,156 @@ +//! Aylık değerlendirme raporu: kapalı dönem (önceki dönemle karşılaştırma, notlar, devir), +//! ara rapor, hane yalıtımı. + +mod common; + +use axum::http::StatusCode; +use common::{Session, TestApp}; +use serde_json::json; + +async fn id_by(app: &TestApp, s: &Session, name: &str) -> String { + let (_, list) = app.get("/categories", s).await; + list.as_array() + .unwrap() + .iter() + .find(|c| c["name"] == name) + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +async fn open(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String { + let (st, p) = app + .post( + "/periods", + s, + json!({ "start_date": start, "end_date": end, "savings_goal": goal }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{p}"); + let id = p["id"].as_str().unwrap().to_string(); + app.post( + &format!("/periods/{id}/plan-items"), + s, + json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }), + ) + .await; + id +} + +async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) { + let (st, b) = app + .post( + &format!("/periods/{pid}/entries"), + s, + json!({ "date": date, "amount": amount, "category_id": cat }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{b}"); +} + +async fn report(app: &TestApp, s: &Session, pid: &str) -> (StatusCode, String, String) { + app.get_text(&format!("/periods/{pid}/report.md"), s).await +} + +#[tokio::test] +async fn closed_period_report_with_comparison_notes_and_carry() { + let app = TestApp::new().await; + let s = app.register("Sahip", None).await; + let market = id_by(&app, &s, "Market").await; + let yemek = id_by(&app, &s, "Dışarıda yemek").await; + + // Ekim: 10.000 gelir, 6.000 harcama (Market 3.000, yemek 3.000) → kapalı + let oct = open(&app, &s, "2026-10-01", "2026-11-01", 200_000).await; + spend(&app, &s, &oct, "2026-10-05", 300_000, &market).await; + spend(&app, &s, &oct, "2026-10-06", 300_000, &yemek).await; + app.post(&format!("/periods/{oct}/close"), &s, json!({})) + .await; + + // Kasım: hedef 3.000; bütçe Zorunlu 4.000 / İstek 1.000; Market 5.000 (+%67), yemek 1.000 + let nov = open(&app, &s, "2026-11-01", "2026-12-01", 300_000).await; + app.put( + &format!("/periods/{nov}/budget"), + &s, + json!({ "needs": 400_000, "wants": 100_000, "culture": 0, "unexpected": 0 }), + ) + .await; + spend(&app, &s, &nov, "2026-11-17", 500_000, &market).await; + spend(&app, &s, &nov, "2026-11-03", 100_000, &yemek).await; + app.post( + &format!("/periods/{nov}/notes"), + &s, + json!({ "context": "planning", "title": "Bu ayın niyeti", "body_md": "Dışarıda **az** ye." }), + ) + .await; + let (_, p) = app.get(&format!("/periods/{nov}"), &s).await; + app.patch( + &format!("/periods/{nov}"), + &s, + json!({ "reflection_md": "Market listesiyle çık.", "version": p["version"] }), + ) + .await; + + // Açıkken: ara rapor + let (st, ct, md) = report(&app, &s, &nov).await; + assert_eq!(st, StatusCode::OK, "{md}"); + assert!(ct.starts_with("text/markdown"), "{ct}"); + assert!(md.starts_with("# Kasım 2026 — Aylık Değerlendirme"), "{md}"); + // Testin çalıştığı güne göre Kasım 2026 sürüyor ya da bitmiş olabilir + assert_eq!(md.contains("· Sürüyor ("), md.contains("> **Ara rapor:**")); + + let (st, b) = app + .post( + &format!("/periods/{nov}/close"), + &s, + json!({ "to_savings": 0 }), + ) + .await; + assert_eq!(st, StatusCode::OK, "{b}"); + + let (st, _, md) = report(&app, &s, &nov).await; + assert_eq!(st, StatusCode::OK); + println!("{md}"); + // Başlık ve karşılaştırma + assert!(md.contains("· Kapandı ")); + // Önceki dönemle aynı birikim (4.000) → karşılaştırma cümlesi eklenmez + assert!(md.contains( + "> Birikim hedefin 3.000,00 ₺ idi; 4.000,00 ₺ biriktirdin (%133). Hedefe ulaştın.\n" + )); + assert!(md.contains("- İstek bütçenin içinde kaldı.\n")); + assert!(md.contains("- Harcama yapmadığın gün: 28 / 30; günlük ortalama 200,00 ₺.")); + assert!(md.contains("haftalık bütçe kullanımı %429.")); + assert!(md.contains("| Kakeibo'nun 4 sorusu | Bu dönem | Ekim 2026 |")); + assert!(md.contains("| Ne kadar harcadım? | 6.000,00 ₺ | 6.000,00 ₺ |")); + // Gruplar, kategoriler (ortalamaya göre), haftalar + assert!(md.contains("| 🏠 Zorunlu | 4.000,00 ₺ | 5.000,00 ₺ | -1.000,00 ₺ | ❌ %125 |")); + assert!( + md.contains("| 🛒 Market | 5.000,00 ₺ | 1 | %83 | +%67 (+2.000,00 ₺) |"), + "{md}" + ); + assert!(md.contains("| 3. hafta | 16.11 – 22.11 |")); + // Yorumlar + assert!(md.contains("- Zorunlu grubu bütçeyi 1.000,00 ₺ aştı (%125).")); + assert!(md.contains("- Market, ortalamasının %67 üstünde (+2.000,00 ₺); 1 işlem.")); + assert!(md.contains("- Dışarıda yemek harcaman ortalamanın %67 altında (-2.000,00 ₺).")); + assert!(md.contains("- Zorunlu bütçede kalsaydı birikimin 5.000,00 ₺ olurdu (+1.000,00 ₺).")); + // Notlar + assert!(md.contains("#### Bu ayın niyeti\n\nDışarıda **az** ye.")); + assert!(md.contains("#### Nasıl daha iyi yapabilirim?\n\nMarket listesiyle çık.")); + // Öneri: Zorunlu (5.000 + 3.000) / 2 = 4.000 + assert!(md.contains("| 🏠 Zorunlu | 4.000,00 ₺ | son 2 dönemin ortalaması |")); + assert!(md.contains("1. Market için haftalık 750,00 ₺ sınır koy")); + // Devir: 4.000 birikim, 0'ı aktarıldı → artı devreder + assert!(md.contains("**Devir:** gelecek döneme 4.000,00 ₺ artı devrediyor")); + assert!(md.contains("## Ek: Tüm kategoriler")); + assert!(!md.contains("{{") && !md.contains("{%")); + + // Başka hane göremez; geçersiz kimlik → 404 + let other = app.register("Diger", None).await; + let (st, _, _) = report(&app, &other, &nov).await; + assert_eq!(st, StatusCode::NOT_FOUND); + let (st, _, _) = report(&app, &s, "yok").await; + assert_eq!(st, StatusCode::NOT_FOUND); + + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 3048559..8c9f68c 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,10 +1,11 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 9 – Adım 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 yapılabilirdi / ne yapmalıydım / gelecek ay önerileri). Önce taslak yapıyı kullanıcıya sun (bölümler, hangi veriden), sonra Markdown üretimi + yazdır/PDF. +**Faz 9 – Adım 6b:** Rapor arayüzü — Değerlendirme → "Aylık rapor" (`/degerlendirme/rapor?donem=`): `GET /periods/{id}/report.md` metnini `react-markdown` + `remark-gfm` ile göster (tablolar, görev kutuları), Yazdır/PDF (A4, açık tema, `print:` sınıfları, tablolar sayfa arasında bölünmesin, `` dipnot), "Markdown indir" (Blob, dosya adı `kakeibo-rapor-.md`), dönem seçici; kapatma sihirbazı sonucu + dönem geçmişinden "Raporu aç". Sonra Faz 9 ✅ → Faz 10. **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 +- **Aylık rapor tasarımı (kullanıcı onayı, 2026-11-13):** 7 bölüm — Bir bakışta (4 soru + önceki dönem) · Ne yaptım (gruplar ✅≤%90/⚠️≤%100/❌, ilk 5 kategori + ortalamaya göre, haftalar, ödeme türü, birikim) · İyi gitti · Zorladı · Ne yapılabilirdi ("olsaydı" hesapları) · Kendi notların (plan notları + yansıtma, yorumsuz) · Gelecek dönem (önerilen bütçe = son ≤4 dönem ortalaması 50 ₺ yukarı, ≤3 somut adım, devir, boş hedef kutuları) + Ek: tüm kategoriler. Eşik: kategori %20 **ve** 250 ₺. Karşılaştırma: önceki ≤3 kapalı/arşiv dönem. "Sen" dili. Açık dönem → ara rapor (hedef yargısı yok, bitmemiş hafta yok). - **Hafta kuralı değişti (kullanıcı, 2026-11-13):** Pazartesi başlangıçlı haftalar yerine dönemin 1–8, 9–15, 16–22, 23–son günleri (ay = 4 hafta). Kasım 2026'da 6 hafta (01.11 tek gün) çıkması üzerine. Genel: `max(1, gün/7)` hafta, ilk 8 gün, ara 7, son kalan (6–12). Defter her açılışta bugünün haftası; hafta artık adrese yazılmaz (sekme geri yüklemede eski hafta açılıyordu). - **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt). - **Dikkat (2026-11-13):** Claude'un hızlı ardışık `backend/src` değişiklikleri (bilerek bozup geri alma, toplu düzenleme) kullanıcının `npm run dev` içindeki `cargo watch`'ını backend kapalı halde bırakabiliyor (giriş: "Beklenmeyen hata", 5173 → 502). Böyle denemelerden sonra `curl localhost:8080/api/health`; kapalıysa `touch backend/src/main.rs` (cargo watch yeniden başlatır). Arayüz artık 502/503/504'te "Sunucu şu an yanıt vermiyor" der. @@ -144,3 +145,4 @@ - **Hafta düzeltmesi (2026-11-13):** `calendar::weeks` yeni kural (testler: Kasım/Aralık/Şubat/artık yıl, ay ortası dönem, 13/14/62 gün). Bütçe/defter/değerlendirme entegrasyon testleri yeni sınırlara göre güncellendi (backend 178). Frontend: `LedgerView` haftayı `useState`'te tutar, `parseWeekParam` kaldırıldı, dönem kısalırsa son haftaya düşer; `budget-preview`/`ledger` testleri yeni Aralık (8,7,7,9) ile (133). E2E 9 kontrol (8091, DB silindi). Dev verisinde elle haftalık bütçe yoktu → taşıma gerekmedi; canlıda varsa eşleşmeyen eski hafta yok sayılır (aylık tutar korunur). - **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test. - **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (``, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi). +- **Faz 9 – Adım 6a (2026-11-13):** `services/monthly_report.rs` + `routes/monthly_report.rs` (`GET /periods/{id}/report.md`, `text/markdown`), `templates/monthly_report.md` (minijinja 3.1, `serde` özelliği; trim/lstrip blocks; art arda boş satırlar sadeleştirilir). `minijinja` yeni bağımlılık (şablonda koşul/döngü). Dockerfile: `COPY templates/ /app/templates/` (`include_str!("../../../templates/…")`), `docker build --target api` geçti. Testler: 5 birim (biçim, eşikler, kapalı dönem yorumları, ara rapor + ilk dönem, tam şablon + hücre kaçışı) + 1 entegrasyon (Ekim kapalı → Kasım raporu: karşılaştırma, notlar, devir, öneri, 404). 5 bilinçli bozmadan 2'si önce kaçtı → eşik testleri eklendi, hepsi yakalandı. bson `DateTime::to_time_0_3` crate-içi → `from_unix_timestamp`. Backend 196 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index 2e8de15..5f325af 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -84,4 +84,4 @@ - ✅ 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** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme) - ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları) -- ⏳ 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. Aylık değerlendirme çıktısı — ✅ 6a backend (`templates/monthly_report.md` + minijinja, `GET /periods/{id}/report.md`, kural tabanlı yorum ve öneriler) · ⏳ 6b arayüz (rapor sayfası, yazdır/PDF, Markdown indir). Plan: `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/templates/monthly_report.md b/templates/monthly_report.md new file mode 100644 index 0000000..dee4158 --- /dev/null +++ b/templates/monthly_report.md @@ -0,0 +1,192 @@ +{# + Aylık değerlendirme raporu (Kakeibo). Değerler `backend/src/services/monthly_report.rs`'ten gelir; + hesap ve cümleler orada, burada yalnızca düzen var. Filtreler: `tl` (kuruş → 1.250,00 ₺), + `pct` (oran → %84), `cell` (tablo hücresi için kaçış). +#} +# {{ title }} — Aylık Değerlendirme + +{{ range }} · {{ status_line }} · {{ household }} + +{% if interim %} +> **Ara rapor:** dönem sürüyor; rakamlar bugüne kadarki kayıtlardan. + +{% endif %} +## 1. Bir bakışta + +> {{ headline }} + +{% if prev_name %} +| Kakeibo'nun 4 sorusu | Bu dönem | {{ prev_name | cell }} | +|---|---:|---:| +{% for q in questions %} +| {{ q.label }} | {{ q.now | tl }} | {{ q.prev | tl }} | +{% endfor %} +{% else %} +| Kakeibo'nun 4 sorusu | Bu dönem | +|---|---:| +{% for q in questions %} +| {{ q.label }} | {{ q.now | tl }} | +{% endfor %} +{% endif %} + +## 2. Ne yaptım? + +### Gruplar + +| Grup | Bütçe | Harcanan | Kalan | Kullanım | +|---|---:|---:|---:|---:| +{% for g in groups %} +| {{ g.emoji }} {{ g.label }} | {{ g.planned | tl }} | {{ g.spent | tl }} | {{ g.left | tl }} | {{ g.mark }} {{ g.usage | pct }} | +{% endfor %} +| **Toplam** | **{{ totals.planned | tl }}** | **{{ totals.spent | tl }}** | **{{ totals.left | tl }}** | {{ totals.usage | pct }} | + +✅ bütçenin %90'ına kadar · ⚠️ %90–100 · ❌ bütçe aşıldı + +{% if top %} +### En çok harcadığın kategoriler + +{% if avg_n %} +| Kategori | Tutar | İşlem | Pay | Ortalamaya göre ({{ avg_n }} dönem) | +|---|---:|---:|---:|---:| +{% for c in top %} +| {{ c.emoji }} {{ c.name | cell }} | {{ c.spent | tl }} | {{ c.count }} | {{ c.share | pct }} | {{ c.change }} | +{% endfor %} +{% else %} +| Kategori | Tutar | İşlem | Pay | +|---|---:|---:|---:| +{% for c in top %} +| {{ c.emoji }} {{ c.name | cell }} | {{ c.spent | tl }} | {{ c.count }} | {{ c.share | pct }} | +{% endfor %} +{% endif %} + +{% endif %} +### Haftalar + +| Hafta | Günler | Bütçe | Harcanan | Kullanım | +|---|---|---:|---:|---:| +{% for w in weeks %} +| {{ w.index }}. hafta | {{ w.range }} | {{ w.planned | tl }} | {{ w.spent | tl }} | {{ w.mark }} {{ w.usage | pct }} | +{% endfor %} + +{% for line in activity %} +- {{ line }} +{% endfor %} + +{% if methods %} +### Ödeme türleri + +| Ödeme türü | Tutar | Pay | +|---|---:|---:| +{% for m in methods %} +| {{ m.name | cell }} | {{ m.spent | tl }} | {{ m.share | pct }} | +{% endfor %} + +{% endif %} +{% if savings %} +### Birikim + +{% for line in savings %} +- {{ line }} +{% endfor %} + +{% endif %} +## 3. Neler iyi gitti? + +{% for line in good %} +- {{ line }} +{% else %} +- Bu dönem öne çıkan bir olumlu fark yok; 7. bölümdeki küçük adımlarla başlayabilirsin. +{% endfor %} + +## 4. Neler zorladı? + +{% for line in hard %} +- {{ line }} +{% else %} +- Belirgin bir zorlanma yok: bütçe ve birikim planın içinde. +{% endfor %} + +## 5. Ne yapılabilirdi? + +{% for line in could %} +- {{ line }} +{% else %} +- Rakamlar zaten planın içinde; aynı düzeni korumak yeterli. +{% endfor %} + +## 6. Ne yapmalıydım? — kendi notların + +{% if plan_notes %} +### Ay başında yazdıkların + +{% for n in plan_notes %} +#### {{ n.title }} + +{{ n.body }} + +{% endfor %} +{% endif %} +{% if reflection or review_notes %} +### Ay sonunda yazdıkların + +{% if reflection %} +#### Nasıl daha iyi yapabilirim? + +{{ reflection }} + +{% endif %} +{% for n in review_notes %} +#### {{ n.title }} + +{{ n.body }} + +{% endfor %} +{% endif %} +{% if not plan_notes and not reflection and not review_notes %} +_Bu dönem için not yazılmamış. Kakeibo'nun özü bu kısım: Değerlendirme sayfasındaki “Nasıl daha iyi yapabilirim?” alanına birkaç cümle yazmak bir sonraki ayı en çok değiştiren adımdır._ + +{% endif %} +## 7. Gelecek dönem için + +### Önerilen bütçe + +| Grup | Öneri | Dayanak | +|---|---:|---| +{% for s in suggestions %} +| {{ s.emoji }} {{ s.label }} | {{ s.amount | tl }} | {{ s.basis }} | +{% endfor %} +| **Toplam** | **{{ suggestion_total | tl }}** | | + +{% if suggestion_note %} +{{ suggestion_note }} + +{% endif %} +### Somut adımlar + +{% for line in steps %} +{{ loop.index }}. {{ line }} +{% endfor %} + +{% if carry %} +**Devir:** {{ carry }} + +{% endif %} +### Kendi hedeflerin + +- [ ] ______________________________________ +- [ ] ______________________________________ +- [ ] ______________________________________ + +{% if categories %} +## Ek: Tüm kategoriler + +| Grup | Kategori | Tutar | İşlem | Pay | +|---|---|---:|---:|---:| +{% for c in categories %} +| {{ c.group }} | {{ c.emoji }} {{ c.name | cell }} | {{ c.spent | tl }} | {{ c.count }} | {{ c.share | pct }} | +{% endfor %} + +{% endif %} +--- + +Nasıl hesaplandı: Kalan = bütçe − harcanan. Ortalamalar, bu dönemden önceki en fazla 3 kapalı ya da arşiv dönemden. “İyi gitti / zorladı” için grup bütçesinin %90 ve %100'ü, kategorilerde ortalamaya göre en az %20 ve 250 ₺ fark aranır. Önerilen bütçe: son en fazla 4 dönemin ortalama harcaması, 50 ₺'ye yuvarlanmış. Rapor tarihi: {{ generated_on }}.