feat(rapor): aylık değerlendirme raporu backend (şablon, yorumlar, öneriler)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 21:36:43 +03:00
parent b974525cf6
commit dc0d783e46
14 changed files with 1772 additions and 3 deletions
+156
View File
@@ -0,0 +1,156 @@
//! Aylık değerlendirme raporu: kapalı dönem (önceki dönemle karşılaştırma, notlar, devir),
//! ara rapor, hane yalıtımı.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use serde_json::json;
async fn id_by(app: &TestApp, s: &Session, name: &str) -> String {
let (_, list) = app.get("/categories", s).await;
list.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == name)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn open(app: &TestApp, s: &Session, start: &str, end: &str, goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": start, "end_date": end, "savings_goal": goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
let id = p["id"].as_str().unwrap().to_string();
app.post(
&format!("/periods/{id}/plan-items"),
s,
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
)
.await;
id
}
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) {
let (st, b) = app
.post(
&format!("/periods/{pid}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
async fn report(app: &TestApp, s: &Session, pid: &str) -> (StatusCode, String, String) {
app.get_text(&format!("/periods/{pid}/report.md"), s).await
}
#[tokio::test]
async fn closed_period_report_with_comparison_notes_and_carry() {
let app = TestApp::new().await;
let s = app.register("Sahip", None).await;
let market = id_by(&app, &s, "Market").await;
let yemek = id_by(&app, &s, "Dışarıda yemek").await;
// Ekim: 10.000 gelir, 6.000 harcama (Market 3.000, yemek 3.000) → kapalı
let oct = open(&app, &s, "2026-10-01", "2026-11-01", 200_000).await;
spend(&app, &s, &oct, "2026-10-05", 300_000, &market).await;
spend(&app, &s, &oct, "2026-10-06", 300_000, &yemek).await;
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
.await;
// Kasım: hedef 3.000; bütçe Zorunlu 4.000 / İstek 1.000; Market 5.000 (+%67), yemek 1.000
let nov = open(&app, &s, "2026-11-01", "2026-12-01", 300_000).await;
app.put(
&format!("/periods/{nov}/budget"),
&s,
json!({ "needs": 400_000, "wants": 100_000, "culture": 0, "unexpected": 0 }),
)
.await;
spend(&app, &s, &nov, "2026-11-17", 500_000, &market).await;
spend(&app, &s, &nov, "2026-11-03", 100_000, &yemek).await;
app.post(
&format!("/periods/{nov}/notes"),
&s,
json!({ "context": "planning", "title": "Bu ayın niyeti", "body_md": "Dışarıda **az** ye." }),
)
.await;
let (_, p) = app.get(&format!("/periods/{nov}"), &s).await;
app.patch(
&format!("/periods/{nov}"),
&s,
json!({ "reflection_md": "Market listesiyle çık.", "version": p["version"] }),
)
.await;
// Açıkken: ara rapor
let (st, ct, md) = report(&app, &s, &nov).await;
assert_eq!(st, StatusCode::OK, "{md}");
assert!(ct.starts_with("text/markdown"), "{ct}");
assert!(md.starts_with("# Kasım 2026 — Aylık Değerlendirme"), "{md}");
// Testin çalıştığı güne göre Kasım 2026 sürüyor ya da bitmiş olabilir
assert_eq!(md.contains("· Sürüyor ("), md.contains("> **Ara rapor:**"));
let (st, b) = app
.post(
&format!("/periods/{nov}/close"),
&s,
json!({ "to_savings": 0 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let (st, _, md) = report(&app, &s, &nov).await;
assert_eq!(st, StatusCode::OK);
println!("{md}");
// Başlık ve karşılaştırma
assert!(md.contains("· Kapandı "));
// Önceki dönemle aynı birikim (4.000) → karşılaştırma cümlesi eklenmez
assert!(md.contains(
"> Birikim hedefin 3.000,00 ₺ idi; 4.000,00 ₺ biriktirdin (%133). Hedefe ulaştın.\n"
));
assert!(md.contains("- İstek bütçenin içinde kaldı.\n"));
assert!(md.contains("- Harcama yapmadığın gün: 28 / 30; günlük ortalama 200,00 ₺."));
assert!(md.contains("haftalık bütçe kullanımı %429."));
assert!(md.contains("| Kakeibo'nun 4 sorusu | Bu dönem | Ekim 2026 |"));
assert!(md.contains("| Ne kadar harcadım? | 6.000,00 ₺ | 6.000,00 ₺ |"));
// Gruplar, kategoriler (ortalamaya göre), haftalar
assert!(md.contains("| 🏠 Zorunlu | 4.000,00 ₺ | 5.000,00 ₺ | -1.000,00 ₺ | ❌ %125 |"));
assert!(
md.contains("| 🛒 Market | 5.000,00 ₺ | 1 | %83 | +%67 (+2.000,00 ₺) |"),
"{md}"
);
assert!(md.contains("| 3. hafta | 16.11 – 22.11 |"));
// Yorumlar
assert!(md.contains("- Zorunlu grubu bütçeyi 1.000,00 ₺ aştı (%125)."));
assert!(md.contains("- Market, ortalamasının %67 üstünde (+2.000,00 ₺); 1 işlem."));
assert!(md.contains("- Dışarıda yemek harcaman ortalamanın %67 altında (-2.000,00 ₺)."));
assert!(md.contains("- Zorunlu bütçede kalsaydı birikimin 5.000,00 ₺ olurdu (+1.000,00 ₺)."));
// Notlar
assert!(md.contains("#### Bu ayın niyeti\n\nDışarıda **az** ye."));
assert!(md.contains("#### Nasıl daha iyi yapabilirim?\n\nMarket listesiyle çık."));
// Öneri: Zorunlu (5.000 + 3.000) / 2 = 4.000
assert!(md.contains("| 🏠 Zorunlu | 4.000,00 ₺ | son 2 dönemin ortalaması |"));
assert!(md.contains("1. Market için haftalık 750,00 ₺ sınır koy"));
// Devir: 4.000 birikim, 0'ı aktarıldı → artı devreder
assert!(md.contains("**Devir:** gelecek döneme 4.000,00 ₺ artı devrediyor"));
assert!(md.contains("## Ek: Tüm kategoriler"));
assert!(!md.contains("{{") && !md.contains("{%"));
// Başka hane göremez; geçersiz kimlik → 404
let other = app.register("Diger", None).await;
let (st, _, _) = report(&app, &other, &nov).await;
assert_eq!(st, StatusCode::NOT_FOUND);
let (st, _, _) = report(&app, &s, "yok").await;
assert_eq!(st, StatusCode::NOT_FOUND);
app.cleanup().await;
}