//! 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| { // Ara raporda henüz başlamamış hafta: oran ve işaret yok ("✅ %0" yanıltır) let future = interim && w.start.as_str() > f.today.as_str(); WeekRow { index: w.index, range: format!("{} – {}", short(&w.start), short(&w.last)), planned: w.planned, spent: w.spent, usage: if future { None } else { ratio(w.spent, w.planned) }, mark: if future { "" } else { 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ı"); // 13 Kasım: 3. ve 4. hafta henüz başlamadı → oran / işaret yok assert_eq!( r.weeks[1].mark, "⚠️", "2. hafta (6.000 / 6.650 = %90,2) sürüyor, oranı var" ); assert!(r.weeks[2].usage.is_none() && r.weeks[3].mark.is_empty()); 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._")); assert!( md.contains("\n_Nasıl hesaplandı:"), "dipnot düz Markdown (ham HTML gösterilmez)" ); assert!(!md.contains('<'), "şablonda ham HTML yok"); } }