dc0d783e46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1348 lines
42 KiB
Rust
1348 lines
42 KiB
Rust
//! 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| 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<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ı");
|
||
|
||
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."));
|
||
}
|
||
}
|