Files
kakeibo/backend/src/services/monthly_report.rs
T

1367 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Uuid, i64>,
}
#[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<String>,
pub today: String,
pub goal: i64,
pub available: i64,
pub spent: i64,
pub realized: i64,
pub groups: Vec<GroupFact>,
pub weeks: Vec<WeekFact>,
/// Harcama olan kategoriler, çoktan aza
pub cats: Vec<CatFact>,
/// En yeni önce, en fazla `PREV_N`
pub prev: Vec<PrevFact>,
/// (ö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<i64>,
pub portfolio: Option<i64>,
/// Kapanışta gelecek döneme devreden: (`"surplus"` | `"debt"`, tutar)
pub carry: Option<(String, i64)>,
pub plan_notes: Vec<NoteFact>,
pub review_notes: Vec<NoteFact>,
pub reflection: Option<String>,
}
pub async fn gather(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Facts> {
let p = period::get(db, household_id, period_id).await?;
let r = review::review(db, household_id, period_id).await?;
let household = db
.collection::<mongodb::bson::Document>(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<Uuid, (String, String)> = 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<Uuid, String> =
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::<mongodb::bson::Document>(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::<Vec<_>>(),
)
};
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<f64> {
(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<i64>,
}
#[derive(Debug, Serialize)]
pub struct GroupRow {
emoji: &'static str,
label: &'static str,
planned: i64,
spent: i64,
left: i64,
usage: Option<f64>,
mark: &'static str,
}
#[derive(Debug, Serialize)]
pub struct Totals {
planned: i64,
spent: i64,
left: i64,
usage: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct CatRow {
group: &'static str,
emoji: String,
name: String,
spent: i64,
count: i64,
share: Option<f64>,
/// 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<f64>,
mark: &'static str,
}
#[derive(Debug, Serialize)]
pub struct MethodRow {
name: String,
spent: i64,
share: Option<f64>,
}
#[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<String>,
pub questions: Vec<Question>,
pub groups: Vec<GroupRow>,
pub totals: Totals,
pub top: Vec<CatRow>,
pub avg_n: usize,
pub weeks: Vec<WeekRow>,
pub activity: Vec<String>,
pub methods: Vec<MethodRow>,
pub savings: Vec<String>,
pub good: Vec<String>,
pub hard: Vec<String>,
pub could: Vec<String>,
pub plan_notes: Vec<NoteRow>,
pub review_notes: Vec<NoteRow>,
pub reflection: Option<String>,
pub suggestions: Vec<Suggestion>,
pub suggestion_total: i64,
pub suggestion_note: Option<String>,
pub steps: Vec<String>,
pub carry: Option<String>,
pub categories: Vec<CatRow>,
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<i64> {
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<GroupRow> = 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<WeekRow> = 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<Suggestion> = f
.groups
.iter()
.map(|g| {
let i = g.group.order();
let sum: i64 = g.spent + f.prev.iter().map(|p| p.groups[i]).sum::<i64>();
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<String> {
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<i64>| v.map_or("—".to_string(), tl));
env.add_filter("pct", |v: Option<f64>| 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<String> {
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");
}
}