feat(değerlendirme): ay sonu değerlendirmesi — 4 soru ve döküm (backend)

- GET /periods/{id}/review: param, hedef, harcanan, biriken; grup, kategori ve hafta bazında planlanan/gerçekleşen
- Dönemde "Nasıl daha iyi yapabilirim?" metni (reflection_md)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 11:54:57 +03:00
parent e4c8d971fc
commit 8ec1fd6f25
18 changed files with 625 additions and 4 deletions
+13
View File
@@ -300,3 +300,16 @@ DELETE {{baseUrl}}/entries/BURAYA-KAYIT-ID
### Silmeyi geri al ("↩️ Geri al")
POST {{baseUrl}}/entries/BURAYA-KAYIT-ID/restore
# ─────────────────────────── Ay sonu değerlendirmesi ───────────────────────────
### Değerlendirme: 4 soru (available, savings_goal, spent_total, realized_savings) + grup/kategori/hafta
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/review
### "Nasıl daha iyi yapabilirim?" (Markdown; boş metin → kaldır; yalnızca açık dönem)
PATCH {{baseUrl}}/periods/BURAYA-DONEM-ID
Content-Type: application/json
{
"reflection_md": "- Dışarıda yemeği haftada 1'e indir\n- Market listesiyle alışveriş"
}
+3
View File
@@ -35,5 +35,8 @@ pub struct Period {
pub status: PeriodStatus,
/// Kuruş
pub savings_goal: i64,
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
#[serde(default)]
pub reflection_md: Option<String>,
pub version: i32,
}
+2
View File
@@ -9,6 +9,7 @@ mod household;
mod note;
mod period;
mod plan;
mod review;
mod spa;
use axum::{
@@ -78,6 +79,7 @@ pub fn router(state: AppState) -> Router {
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
.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}/budget",
get(budget::get).put(budget::set_monthly),
+6
View File
@@ -54,6 +54,8 @@ pub struct UpdateRequest {
end_date: Option<String>,
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
reflection_md: Option<String>,
version: Option<i32>,
}
@@ -73,6 +75,8 @@ pub struct PeriodDto {
/// Kuruş
#[ts(type = "number")]
savings_goal: i64,
/// "Nasıl daha iyi yapabilirim?" (Markdown)
reflection_md: Option<String>,
version: i32,
weeks: Vec<WeekDto>,
}
@@ -102,6 +106,7 @@ impl From<Period> for PeriodDto {
end_date: p.end_date,
status: p.status,
savings_goal: p.savings_goal,
reflection_md: p.reflection_md,
version: p.version,
weeks: weeks(start, end)
.into_iter()
@@ -178,6 +183,7 @@ pub async fn update(
start_date: req.start_date,
end_date: req.end_date,
savings_goal: req.savings_goal,
reflection_md: req.reflection_md,
version: req.version,
},
)
+152
View File
@@ -0,0 +1,152 @@
//! Ay sonu değerlendirmesi:
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods/{id}/review` | 4 soru, grup/kategori/hafta bazında planlanan–gerçekleşen |
//!
//! "Nasıl daha iyi yapabilirim?" metni: `PATCH /api/periods/{id}` → `reflection_md`.
use axum::{
Json,
extract::{Path, State},
};
use mongodb::bson::Uuid;
use serde::Serialize;
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
models::group::Group,
services::review,
state::AppState,
};
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodReview")]
pub struct ReviewDto {
#[ts(type = "number")]
income_total: i64,
#[ts(type = "number")]
fixed_expense_total: i64,
#[ts(type = "number")]
carryover_debt: i64,
#[ts(type = "number")]
carryover_surplus: i64,
/// 1. Ne kadar param vardı? (gelir − sabit gider − borç + artı)
#[ts(type = "number")]
available: i64,
/// 2. Ne kadar biriktirmek istedim?
#[ts(type = "number")]
savings_goal: i64,
/// 3. Ne kadar harcadım?
#[ts(type = "number")]
spent_total: i64,
/// 4. Ne kadar biriktirdim? (negatifse açık var)
#[ts(type = "number")]
realized_savings: i64,
/// Gerçekleşen − hedef birikim (negatifse hedefin altında)
#[ts(type = "number")]
savings_gap: i64,
/// 4 grup, sabit sırada
groups: Vec<ReviewGroupDto>,
/// Harcama olan kategoriler, çoktan aza
categories: Vec<ReviewCategoryDto>,
/// Haftalar: 4 grubun toplamı
weeks: Vec<ReviewWeekDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReviewGroup")]
struct ReviewGroupDto {
group: Group,
/// Aylık bütçe
#[ts(type = "number")]
planned: i64,
#[ts(type = "number")]
spent: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReviewCategory")]
struct ReviewCategoryDto {
category_id: String,
group: Group,
#[ts(type = "number")]
spent: i64,
/// Kayıt sayısı
#[ts(type = "number")]
count: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ReviewWeek")]
struct ReviewWeekDto {
index: u32,
start_date: String,
last_date: String,
#[ts(type = "number")]
planned: i64,
#[ts(type = "number")]
spent: i64,
}
pub async fn get(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<ReviewDto>> {
let id = Uuid::parse_str(&id).map_err(|_| AppError::NotFound)?;
let r = review::review(&s.db, auth.household_id, id).await?;
// Haftalar: her grubun aynı sıradaki haftası toplanır (hepsi aynı takvimden gelir)
let weeks = r
.groups
.first()
.map(|g| {
g.weeks
.iter()
.enumerate()
.map(|(i, w)| ReviewWeekDto {
index: w.index,
start_date: w.start_date.clone(),
last_date: 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();
Ok(Json(ReviewDto {
income_total: r.summary.income_total,
fixed_expense_total: r.summary.fixed_expense_total,
carryover_debt: r.summary.carryover_debt,
carryover_surplus: r.summary.carryover_surplus,
available: r.available,
savings_goal: r.summary.savings_goal,
spent_total: r.summary.spent_total,
realized_savings: r.realized_savings,
savings_gap: r.realized_savings - r.summary.savings_goal,
groups: r
.groups
.iter()
.map(|g| ReviewGroupDto {
group: g.group,
planned: g.monthly,
spent: g.spent,
})
.collect(),
categories: r
.categories
.into_iter()
.map(|c| ReviewCategoryDto {
category_id: c.category_id.to_string(),
group: c.group,
spent: c.spent,
count: c.count,
})
.collect(),
weeks,
}))
}
+1
View File
@@ -10,4 +10,5 @@ pub mod money;
pub mod note;
pub mod period;
pub mod plan;
pub mod review;
pub mod seed;
+21
View File
@@ -41,9 +41,14 @@ pub struct PeriodChanges {
pub start_date: Option<String>,
pub end_date: Option<String>,
pub savings_goal: Option<i64>,
/// `Some("")` → metni kaldır
pub reflection_md: Option<String>,
pub version: Option<i32>,
}
/// "Nasıl daha iyi yapabilirim?" üst sınırı (karakter) — notlarla aynı
pub const MAX_REFLECTION: usize = 20_000;
fn coll(db: &Database) -> mongodb::Collection<Period> {
db.collection(col::PERIODS)
}
@@ -177,6 +182,9 @@ pub async fn update(
if let Some(goal) = ch.savings_goal {
set.insert("savings_goal", validate_amount(goal)?);
}
if let Some(text) = ch.reflection_md {
set.insert("reflection_md", clean_reflection(&text)?);
}
let mut f = scope(household_id);
f.insert("_id", id);
@@ -190,6 +198,19 @@ pub async fn update(
// ---------- doğrulamalar ----------
fn clean_reflection(text: &str) -> AppResult<Bson> {
let t = text.trim();
if t.is_empty() {
return Ok(Bson::Null);
}
if t.chars().count() > MAX_REFLECTION {
return Err(AppError::BadRequest(
"Metin en fazla 20.000 karakter olabilir".into(),
));
}
Ok(Bson::String(t.to_string()))
}
fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
let bad = |m: &str| AppError::BadRequest(m.into());
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
+105
View File
@@ -0,0 +1,105 @@
//! Ay sonu değerlendirmesi (KAKEIBO_PROMPT §8.5, DATA_MODEL.md §5).
//!
//! Kakeibo'nun dört sorusu — hepsi sorgu anında hesaplanır, saklanmaz:
//! 1. Ne kadar param vardı? `available` = gelir − sabit gider − devreden borç + devreden artı
//! 2. Ne kadar biriktirmek istedim? `savings_goal`
//! 3. Ne kadar harcadım? `spent_total`
//! 4. Ne kadar biriktirdim? `realized_savings` = available − spent_total
//!
//! Ek olarak: grup bazlı planlanan/gerçekleşen, kategori bazlı harcama, haftalık dağılım.
//! Kapalı dönemde de okunabilir.
use mongodb::{
Database,
bson::{Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::AppResult,
models::{common::scope, group::Group},
services::budget::{self, GroupBudget, Summary},
};
pub struct Review {
pub summary: Summary,
/// 1. soru
pub available: i64,
/// 4. soru (negatifse açık var → kapanışta borç olarak devreder)
pub realized_savings: i64,
/// Grup: aylık bütçe ve gerçekleşen (`Group::ALL` sırasıyla)
pub groups: Vec<GroupBudget>,
/// Harcama olan kategoriler, çoktan aza
pub categories: Vec<CategorySpent>,
}
pub struct CategorySpent {
pub category_id: Uuid,
pub group: Group,
pub spent: i64,
pub count: i64,
}
pub async fn review(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Review> {
// `summary` dönemin bu haneye ait olduğunu da denetler (yoksa 404)
let summary = budget::summary(db, household_id, period_id).await?;
let groups = budget::view(db, household_id, period_id).await?;
let categories = spent_by_category(db, household_id, period_id).await?;
let available = summary.income_total - summary.fixed_expense_total - summary.carryover_debt
+ summary.carryover_surplus;
Ok(Review {
realized_savings: available - summary.spent_total,
available,
summary,
groups,
categories,
})
}
async fn spent_by_category(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<CategorySpent>> {
#[derive(Deserialize)]
struct Key {
category_id: Uuid,
group: Group,
}
#[derive(Deserialize)]
struct Row {
#[serde(rename = "_id")]
key: Key,
total: i64,
count: i64,
}
let mut m = scope(household_id);
m.insert("period_id", period_id);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": {
"_id": { "category_id": "$category_id", "group": "$group" },
"total": { "$sum": "$amount" },
"count": { "$sum": 1_i64 },
} },
// Eşit tutarda sıra her seferinde aynı olsun
doc! { "$sort": { "total": -1, "_id.category_id": 1 } },
];
let mut cursor = db
.collection::<Document>(col::ENTRIES)
.aggregate(pipeline)
.with_type::<Row>()
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
let r = cursor.deserialize_current()?;
out.push(CategorySpent {
category_id: r.key.category_id,
group: r.key.group,
spent: r.total,
count: r.count,
});
}
Ok(out)
}
+241
View File
@@ -0,0 +1,241 @@
//! Ay sonu değerlendirmesi: dört soru, grup/kategori/hafta dökümü, "Nasıl daha iyi yapabilirim?".
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, Uuid, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
/// Gruptaki n'inci (0'dan) örnek kategori
async fn category(app: &TestApp, s: &Session, group: &str, n: usize) -> String {
let (_, cats) = app.get("/categories", s).await;
cats.as_array()
.unwrap()
.iter()
.filter(|c| c["group"] == group)
.nth(n)
.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn spend(app: &TestApp, s: &Session, pid: &str, date: &str, amount: i64, cat: &str) -> Value {
let (st, e) = app
.post(
&format!("/periods/{pid}/entries"),
s,
json!({ "date": date, "amount": amount, "category_id": cat }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{e}");
e
}
async fn plan(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
let (st, b) = app
.post(
&format!("/periods/{pid}/plan-items"),
s,
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
fn column(list: &Value, key: &str) -> Vec<i64> {
list.as_array()
.unwrap()
.iter()
.map(|x| x[key].as_i64().unwrap())
.collect()
}
#[tokio::test]
async fn four_questions_and_breakdowns() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 500_000).await;
plan(&app, &s, &pid, "income", 4_800_000).await;
plan(&app, &s, &pid, "expense", 1_545_000).await;
app.db
.collection::<Document>("carryovers")
.insert_one(doc! {
"household_id": Uuid::parse_str(&s.household_id).unwrap(),
"to_period_id": Uuid::parse_str(&pid).unwrap(),
"kind": "debt", "amount": 100_000_i64, "deleted_at": null,
})
.await
.unwrap();
app.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 1_000_000, "wants": 500_000, "culture": 200_000, "unexpected": 100_000 }),
)
.await;
let (market, rent) = (
category(&app, &s, "needs", 0).await,
category(&app, &s, "needs", 1).await,
);
let wants = category(&app, &s, "wants", 0).await;
let culture = category(&app, &s, "culture", 0).await;
// Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31
spend(&app, &s, &pid, "2026-12-01", 100_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 50_000, &market).await;
spend(&app, &s, &pid, "2026-12-08", 300_000, &rent).await;
spend(&app, &s, &pid, "2026-12-15", 40_000, &wants).await;
spend(&app, &s, &pid, "2026-12-31", 10_000, &culture).await;
let gone = spend(&app, &s, &pid, "2026-12-15", 99_999, &wants).await;
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
.await;
let (st, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
assert_eq!(st, StatusCode::OK, "{r}");
// 1: 4.800.000 − 1.545.000 − 100.000 = 3.155.000 · 3: 500.000 · 4: 3.155.000 − 500.000
for (k, v) in [
("available", 3_155_000),
("savings_goal", 500_000),
("spent_total", 500_000),
("realized_savings", 2_655_000),
("savings_gap", 2_155_000),
("carryover_debt", 100_000),
] {
assert_eq!(r[k], v, "{k}: {r}");
}
assert_eq!(
r["groups"],
json!([
{ "group": "needs", "planned": 1_000_000, "spent": 450_000 },
{ "group": "wants", "planned": 500_000, "spent": 40_000 },
{ "group": "culture", "planned": 200_000, "spent": 10_000 },
{ "group": "unexpected", "planned": 100_000, "spent": 0 },
])
);
// Kategoriler çoktan aza; silinen kayıt sayılmaz
let cats = r["categories"].as_array().unwrap();
assert_eq!(
column(&r["categories"], "spent"),
vec![300_000, 150_000, 40_000, 10_000]
);
assert_eq!(
(
cats[0]["category_id"].as_str(),
cats[1]["category_id"].as_str()
),
(Some(rent.as_str()), Some(market.as_str()))
);
assert_eq!(
(cats[1]["count"].as_i64(), cats[1]["group"].as_str()),
(Some(2), Some("needs"))
);
// Haftalar: 4 grubun toplamı; planlananların toplamı aylık bütçelerin toplamı
assert_eq!(
column(&r["weeks"], "spent"),
vec![100_000, 350_000, 40_000, 0, 10_000]
);
assert_eq!(
column(&r["weeks"], "planned").iter().sum::<i64>(),
1_800_000
);
assert_eq!(r["weeks"][4]["last_date"], "2026-12-31");
app.cleanup().await;
}
#[tokio::test]
async fn overspending_gives_negative_savings() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 100_000).await;
plan(&app, &s, &pid, "income", 200_000).await;
let needs = category(&app, &s, "needs", 0).await;
spend(&app, &s, &pid, "2026-12-10", 250_000, &needs).await;
let (_, r) = app.get(&format!("/periods/{pid}/review"), &s).await;
assert_eq!(
(
r["available"].as_i64(),
r["realized_savings"].as_i64(),
r["savings_gap"].as_i64()
),
(Some(200_000), Some(-50_000), Some(-150_000))
);
// Hiç bütçe yoksa: gruplar 0 planlı, haftalar yine dönem takviminden
assert_eq!(column(&r["groups"], "planned"), vec![0; 4]);
assert_eq!(r["weeks"].as_array().unwrap().len(), 5);
app.cleanup().await;
}
#[tokio::test]
async fn reflection_text_and_access_rules() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let other = app.register("Diger", None).await;
let pid = open_december(&app, &s, 0).await;
let url = format!("/periods/{pid}");
let (st, p) = app
.patch(
&url,
&s,
json!({ "reflection_md": " **Dışarıda yemeği** azalt. " }),
)
.await;
assert_eq!(st, StatusCode::OK, "{p}");
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
let (_, p) = app.get(&url, &s).await;
assert_eq!(p["reflection_md"], "**Dışarıda yemeği** azalt.");
// Türkçe karakterler tek sayılır; sınırın üstü 400
let (st, _) = app
.patch(&url, &s, json!({ "reflection_md": "ş".repeat(20_000) }))
.await;
assert_eq!(st, StatusCode::OK);
let (st, b) = app
.patch(&url, &s, json!({ "reflection_md": "a".repeat(20_001) }))
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
assert_eq!(
b["error"]["message"],
"Metin en fazla 20.000 karakter olabilir"
);
// Boş metin → kaldırılır
let (_, p) = app.patch(&url, &s, json!({ "reflection_md": "" })).await;
assert_eq!(p["reflection_md"], Value::Null);
// Başka hane göremez
let (st, _) = app.get(&format!("{url}/review"), &other).await;
assert_eq!(st, StatusCode::NOT_FOUND);
// Kapalı dönem: değerlendirme okunur, metin değiştirilemez
app.db
.collection::<Document>("periods")
.update_one(
doc! { "_id": Uuid::parse_str(&pid).unwrap() },
doc! { "$set": { "status": "closed" } },
)
.await
.unwrap();
let (st, _) = app.get(&format!("{url}/review"), &s).await;
assert_eq!(st, StatusCode::OK);
let (st, _) = app.patch(&url, &s, json!({ "reflection_md": "geç" })).await;
assert_eq!(st, StatusCode::CONFLICT);
app.cleanup().await;
}
+5 -1
View File
@@ -1,7 +1,8 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 5 – Adım 5 (kullanıcıda):** `docs/DEPLOY.md` §6.1–6.3: Atlas'a betik + systemd timer kurulumu, ilk yedek, `restore.sh latest kakeibo_restore_test` ile karşılaştırma, bu bilgisayarda pull timer. Kullanıcı çıktıları (journal satırları, "✅ kakeibo ile aynı") bildirince Faz 5 ✅ → Faz 6 (ay sonu değerlendirmesi + dönem kapatma/devir).
**Faz 6 – Adım 2:** Backend dönem kapatma (`POST /periods/{id}/close`, tek transaction): `status=closed`, `closed_at/by`; gerçekleşen birikim < 0 → `carryovers{kind: debt}`, > 0 → kullanıcının `to_savings` kadarı birikime (Faz 8'e kadar dönemde saklanır), kalanı `surplus`; devrin `to_period_id`'si sonraki dönem açılınca bağlanır. Yeniden açma (sahip): devirler soft delete. **Kullanıcıya sorulacak:** başka açık dönem varken eski dönem nasıl yeniden açılır?
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
@@ -104,3 +105,6 @@
- **Adım 3:** Defter ekranı (`features/ledger/`): `WeekPicker` (◀ ▶, "Bu hafta", haftanın harcanan/bütçe toplamı), `EntryForm` (büyük `MoneyInput`, 4 renkli grup düğmesi, kategori/ödeme türü çipleri, tarih, açıklama; kayıttan sonra tutar+açıklama temizlenir, odak tutara döner), `schema.ts` (`entrySchema(start,last)`, `fieldForError`: sunucu mesajı → alan). Hafta adreste: `/defter?hafta=N` (bugünün haftasında parametre yok); başka haftaya tarihli kayıt → o haftaya geçer; hafta değişince tarih o haftaya çekilir. `lib/ledger.ts` (`weekIndexFor`, `defaultEntryDate`, `parseWeekParam`, son seçim localStorage `kakeibo.ledger.last`: grup + **her grubun son kategorisi** + ödeme türü; pasifleşen seçim düşer). `lib/catalog.ts` (`useCategories`, `usePaymentMethods`, 5 dk staleTime), `lib/entries.ts` (`useEntries`, `useCreateEntry`), `queryKeys.entries`. Frontend 79 test; e2e 31 kontrol (ayrı 8091 sunucu, DB silindi). Mobilde "Beklenmeyen" taşması düzeltildi.
- **Adım 4:** Defter listesi ve özet: `entry-list.tsx` (gün gün `groupByDay`, gün adı `formatWeekday` = Intl tr-TR UTC; satır: kategori emojisi grup renginde, grup · ödeme · açıklama; düzenle/sil 44 px), sil → sonner `action` "↩️ Geri al" (8 sn) → `useRestoreEntry`. Düzenleme aynı `EntryForm` (`entry` + `key`, Güncelle/Vazgeç; `version` gönderilir, 409 Türkçe root hatası; `''` → ödeme/açıklama kaldır). Katalog **pasifler dahil** okunur (eski kayıtların adı; seçenekte yalnız aktifler + kaydın mevcut değeri). `week-summary.tsx` grup bazlı bütçe/harcanan/kalan + çubuk, aşımda "Aşıldı" kırmızı. Masaüstü iki sütun (form solda yapışkan); dar sütunda taşmaya karşı **container query** (`@container`, `@lg:grid-cols-4`). Frontend 81 test; e2e 29 kontrol (+ Adım 3'ün 31'i yeniden geçti). **Not:** e2e betikleri aynı DB'yi paylaşırsa birbirini bozar → her biri temiz DB ile.
- **Adım 5:** Yedekleme betikleri: `scripts/backup.sh` (Swarm etiketiyle konteyneri bulur, `mongodump --archive --gzip` → `.partial` → `gzip -t` + `mongorestore --dryRun` → ad verilir; son `KEEP=14`, `BACKUP_GROUP` ile okuma izni), `scripts/restore.sh` (dosya adından kaynak DB, `--nsFrom/--nsTo` ile **başka adla**; mevcut hedefe yalnızca `--force` + `--drop`; `COMPARE_DB` ile sayım karşılaştırma), `scripts/backup-pull.sh` (rsync SSH BatchMode, `~/Yedekler/kakeibo`, son 60, 48 saatten eski → `notify-send`). `deploy/systemd/` (sunucu 03:30, bu bilgisayar 09:00 + açılış, `Persistent=true`). Kullanıcı kararı: sunucu dışı kopya **bu bilgisayara** (S3 değil). `npm run db:backup` / `db:restore` (yerel, `backups/` gitignore). Yerel test: yedek → geri yükleme sayıları kakeibo_dev ile aynı, koruma, bozuk dosya hatası, saklama ✅.
### 2026-11-12 — Faz 6
- **Adım 1:** Değerlendirme backend: `services/review.rs` (`budget::summary` + `budget::view` + kategori aggregation), `routes/review.rs` → `GET /periods/{id}/review` (`PeriodReview`: `available` = gelir − sabit − borç + artı, `savings_goal`, `spent_total`, `realized_savings` = available − harcanan, `savings_gap`; `groups` planned/spent, `categories` çoktan aza (+count), `weeks` 4 grubun toplamı). Kapalı dönemde okunur. `periods.reflection_md` (PATCH, ≤ 20.000 karakter, `""` → null, yalnızca açık dönem). `tests/review.rs` (3). Mutasyon: 4. soruya hedef düşmek / silinenleri saymak → yakalandı. Not: `TestApp::register` adı e-postaya çevirir → Türkçe karakterli ad kullanma.
+8 -1
View File
@@ -10,7 +10,7 @@
| **Faz 3** | 🚀 İlk canlıya alma: Dockerfile, Dokploy, canlı MongoDB, domain + HTTPS, yedekleme | Giriş ekranı canlıda çalışıyor | ✅ |
| **Faz 4** | Dönem açma + aylık planlama + notlar | Plan ekranı | ✅ |
| **Faz 5** | Günlük defter | Harcama girişi | 🔄 |
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ⏳ |
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | 🔄 |
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ⏳ |
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ⏳ |
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ |
@@ -59,3 +59,10 @@
- ✅ 3. Frontend: Defter — hafta seçici + hızlı giriş formu (tutar, 4 grup düğmesi, kategori, ödeme türü, tarih, açıklama; son seçim hatırlanır)
- ✅ 4. Frontend: haftanın girişleri (gün gün), düzenle/sil + "Geri al", haftalık özet
- 🔄 5. Canlı yedekleme (betikler + test ✅, sunucuda kurulum kullanıcıda) (mongodump günlük, 14 gün) + geri yükleme denemesi
## Faz 6 Adımları (taslak)
- ✅ 1. Backend: değerlendirme (`GET /periods/{id}/review`: 4 soru, grup/kategori/hafta) + `reflection_md`
- ⏳ 2. Backend: dönem kapatma (transaction: durum, devir, birikime aktarılan tutar) + yeni dönemde devrin bağlanması + yeniden açma (sahip)
- ⏳ 3. Frontend: Değerlendirme sayfası (4 soru, planlanan/gerçekleşen, en çok harcanan kategoriler, haftalık dağılım, "Nasıl daha iyi yapabilirim?", notlar)
- ⏳ 4. Frontend: dönem kapatma sihirbazı (+ yeni dönemi kopyalayarak açma)
- ⏳ 5. Frontend: kapalı dönemler listesi + yeniden açma (sahip)
+1
View File
@@ -13,6 +13,7 @@ const period: Period = {
days: 31,
status: 'open',
savings_goal: 500_000,
reflection_md: null,
version: 3,
weeks: [
{ index: 1, start_date: '2026-12-01', last_date: '2026-12-06', days: 6 },
+5 -1
View File
@@ -18,4 +18,8 @@ last_date: string, days: number, status: PeriodStatus,
/**
* Kuruş
*/
savings_goal: number, version: number, weeks: Array<PeriodWeek>, };
savings_goal: number,
/**
* "Nasıl daha iyi yapabilirim?" (Markdown)
*/
reflection_md: string | null, version: number, weeks: Array<PeriodWeek>, };
+38
View File
@@ -0,0 +1,38 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ReviewCategory } from "./ReviewCategory";
import type { ReviewGroup } from "./ReviewGroup";
import type { ReviewWeek } from "./ReviewWeek";
export type PeriodReview = { income_total: number, fixed_expense_total: number, carryover_debt: number, carryover_surplus: number,
/**
* 1. Ne kadar param vardı? (gelir − sabit gider − borç + artı)
*/
available: number,
/**
* 2. Ne kadar biriktirmek istedim?
*/
savings_goal: number,
/**
* 3. Ne kadar harcadım?
*/
spent_total: number,
/**
* 4. Ne kadar biriktirdim? (negatifse açık var)
*/
realized_savings: number,
/**
* Gerçekleşen − hedef birikim (negatifse hedefin altında)
*/
savings_gap: number,
/**
* 4 grup, sabit sırada
*/
groups: Array<ReviewGroup>,
/**
* Harcama olan kategoriler, çoktan aza
*/
categories: Array<ReviewCategory>,
/**
* Haftalar: 4 grubun toplamı
*/
weeks: Array<ReviewWeek>, };
+5 -1
View File
@@ -1,3 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number, version?: number, };
export type PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number,
/**
* "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
*/
reflection_md?: string, version?: number, };
+8
View File
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Group } from "./Group";
export type ReviewCategory = { category_id: string, group: Group, spent: number,
/**
* Kayıt sayısı
*/
count: number, };
+8
View File
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Group } from "./Group";
export type ReviewGroup = { group: Group,
/**
* Aylık bütçe
*/
planned: number, spent: number, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ReviewWeek = { index: number, start_date: string, last_date: string, planned: number, spent: number, };