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:
Generated
+10
@@ -1428,6 +1428,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"http-body-util",
|
||||
"jsonwebtoken",
|
||||
"minijinja",
|
||||
"mongodb",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
@@ -1589,6 +1590,15 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "509eeb2603a84eccabb4d75f6ec8ba23b46bbf441aaeb863d6b83a5887fd51e4"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
|
||||
@@ -10,6 +10,7 @@ axum-extra = { version = "0.12.6", features = ["cookie"] }
|
||||
dotenvy = "0.15.7"
|
||||
flate2 = "1"
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto"] }
|
||||
minijinja = { version = "3.1.0", features = ["serde"] }
|
||||
mongodb = "3.10.0"
|
||||
quick-xml = { version = "0.42.0", features = ["serialize"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
|
||||
@@ -420,3 +420,8 @@ Content-Type: application/json
|
||||
|
||||
### Geri al: geri dönüş noktasına dön (bu da yeni bir nokta bırakır)
|
||||
POST {{baseUrl}}/backup/restore-points/{{pointId}}/restore
|
||||
|
||||
# ─────────────────────────── Aylık değerlendirme raporu ───────────────────────────
|
||||
|
||||
### Dönemin raporu (Markdown; açık dönem → ara rapor)
|
||||
GET {{baseUrl}}/periods/{{periodId}}/report.md
|
||||
|
||||
@@ -9,6 +9,7 @@ mod catalog;
|
||||
mod entry;
|
||||
mod health;
|
||||
mod household;
|
||||
mod monthly_report;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
@@ -90,6 +91,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/periods/{id}/notes", get(note::list).post(note::create))
|
||||
.route("/periods/{id}/summary", get(budget::summary))
|
||||
.route("/periods/{id}/review", get(review::get))
|
||||
.route("/periods/{id}/report.md", get(monthly_report::get))
|
||||
.route("/periods/{id}/daily", get(stats::daily))
|
||||
.route("/stats/periods", get(stats::periods))
|
||||
.route("/reports/breakdown", get(reports::breakdown))
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Aylık değerlendirme raporu (`services::monthly_report`, şablon `templates/monthly_report.md`).
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/report.md` | dönemin raporu (Markdown); açık dönem → ara rapor |
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
services::{backup::slug, monthly_report, period},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn get(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
|
||||
let p = period::get(&s.db, auth.household_id, id).await?;
|
||||
let body = monthly_report::markdown(&s.db, auth.household_id, id).await?;
|
||||
let file = format!("kakeibo-rapor-{}.md", slug(&p.name));
|
||||
Ok((
|
||||
[
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
"text/markdown; charset=utf-8".to_string(),
|
||||
),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("inline; filename=\"{file}\""),
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
body,
|
||||
))
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod entry;
|
||||
pub mod holding;
|
||||
pub mod invite;
|
||||
pub mod money;
|
||||
pub mod monthly_report;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -358,7 +358,13 @@ async fn bad_files_and_permissions() {
|
||||
let newer = file.replacen("\"schema_version\": ", "\"schema_version\": 99", 1);
|
||||
let (st, b) = app.post_text("/backup/preview", &owner, &newer).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert!(b["error"]["message"].as_str().unwrap().contains("daha yeni"), "{b}");
|
||||
assert!(
|
||||
b["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("daha yeni"),
|
||||
"{b}"
|
||||
);
|
||||
assert_eq!(view(&app, &owner, &nov).await, before);
|
||||
assert!(points(&app, &owner).await.is_empty());
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user