From 7143613ab297de3b5c28496ea2c9b1423627f5ee Mon Sep 17 00:00:00 2001 From: suphicakir Date: Thu, 12 Nov 2026 11:12:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(defter):=20=C3=B6zet=20ve=20b=C3=BCt=C3=A7?= =?UTF-8?q?eye=20ger=C3=A7ekle=C5=9Fen=20harcama=20eklendi=20(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dönem özeti: spent_total ve remaining (harcanabilir − harcanan) - Bütçe görünümü: grup ve hafta bazında spent - Değerler saklanmaz, kayıtlardan anlık toplanır; silinenler sayılmaz Co-Authored-By: Claude Opus 5.5 --- backend/api.http | 3 +- backend/src/routes/budget.rs | 20 ++++- backend/src/services/budget.rs | 74 ++++++++++++++++- backend/tests/budget.rs | 4 +- backend/tests/entries.rs | 121 ++++++++++++++++++++++++++++ docs/MEMORY.md | 3 +- docs/PHASES.md | 4 +- frontend/src/lib/plan-draft.test.ts | 2 + frontend/src/types/GroupBudget.ts | 4 + frontend/src/types/PeriodSummary.ts | 10 ++- frontend/src/types/WeekBudget.ts | 6 +- 11 files changed, 241 insertions(+), 10 deletions(-) diff --git a/backend/api.http b/backend/api.http index ab37ae0..251172f 100644 --- a/backend/api.http +++ b/backend/api.http @@ -235,9 +235,10 @@ DELETE {{baseUrl}}/notes/BURAYA-NOT-ID # Gruplar: needs · wants · culture · unexpected · tutarlar kuruş ### Dönem özeti: gelir − sabit gider − birikim − devreden borç + devreden artı = harcanabilir +### + spent_total (gerçekleşen harcama), remaining (harcanabilir − harcanan) GET {{baseUrl}}/periods/BURAYA-DONEM-ID/summary -### Bütçe: 4 grup, aylık + haftalık döküm (haftalar gün sayısına göre) +### Bütçe: 4 grup, aylık + haftalık döküm (haftalar gün sayısına göre); her grup ve haftada `spent` GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget ### Aylık bütçeleri kaydet (4 grup birlikte) diff --git a/backend/src/routes/budget.rs b/backend/src/routes/budget.rs index 0d10c02..d396501 100644 --- a/backend/src/routes/budget.rs +++ b/backend/src/routes/budget.rs @@ -2,8 +2,8 @@ //! //! | Yöntem | Yol | İş | //! |---|---|---| -//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir | -//! | GET | `/api/periods/{id}/budget` | 4 grup: aylık + haftalık döküm | +//! | GET | `/api/periods/{id}/summary` | gelir/gider/birikim/devir → harcanabilir, harcanan, kalan | +//! | GET | `/api/periods/{id}/budget` | 4 grup: aylık + haftalık döküm, her birinde harcanan | //! | PUT | `/api/periods/{id}/budget` | 4 grubun aylık tutarları | //! | PUT | `/api/periods/{id}/budget/weeks` | bir haftayı elle ayarla / otomatiğe döndür | //! | GET | `/api/periods/{id}/budget/suggestion?method=…` | `default` · `previous` · `avg3` | @@ -81,6 +81,12 @@ pub struct SummaryDto { /// harcanabilir − dağıtılan (negatifse fazla dağıtılmış) #[ts(type = "number")] unallocated: i64, + /// Dönemde gerçekleşen harcama + #[ts(type = "number")] + spent_total: i64, + /// Kalan harcanabilir: harcanabilir − gerçekleşen (negatifse aşılmış) + #[ts(type = "number")] + remaining: i64, } #[derive(Serialize, TS)] @@ -89,6 +95,9 @@ pub struct GroupBudgetDto { group: Group, #[ts(type = "number")] monthly: i64, + /// Dönem boyunca gerçekleşen harcama + #[ts(type = "number")] + spent: i64, /// Elle girilen haftaların toplamı aylık tutarı aşıyor weekly_over: bool, weeks: Vec, @@ -105,6 +114,9 @@ struct WeekBudgetDto { amount: i64, /// Elle girildi (otomatik değil) is_manual: bool, + /// Bu haftada gerçekleşen harcama + #[ts(type = "number")] + spent: i64, } impl From for GroupBudgetDto { @@ -112,6 +124,7 @@ impl From for GroupBudgetDto { Self { group: g.group, monthly: g.monthly, + spent: g.spent, weekly_over: g.weekly_over, weeks: g .weeks @@ -123,6 +136,7 @@ impl From for GroupBudgetDto { days: w.days, amount: w.amount, is_manual: w.is_manual, + spent: w.spent, }) .collect(), } @@ -182,6 +196,8 @@ pub async fn summary( spendable: x.spendable, budget_total: x.budget_total, unallocated: x.spendable - x.budget_total, + spent_total: x.spent_total, + remaining: x.spendable - x.spent_total, })) } diff --git a/backend/src/services/budget.rs b/backend/src/services/budget.rs index cd71388..3393652 100644 --- a/backend/src/services/budget.rs +++ b/backend/src/services/budget.rs @@ -5,6 +5,8 @@ //! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`); //! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık. //! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**. +//! - Gerçekleşen harcama (`entries`) saklanmaz; her istekte toplanır: +//! özette toplam + kalan, bütçe görünümünde grup ve hafta bazında. use std::collections::HashMap; @@ -40,6 +42,8 @@ pub struct Summary { pub carryover_surplus: i64, pub spendable: i64, pub budget_total: i64, + /// Dönemde gerçekleşen harcama (silinenler hariç) + pub spent_total: i64, } pub struct WeekBudget { @@ -49,11 +53,15 @@ pub struct WeekBudget { pub days: u32, pub amount: i64, pub is_manual: bool, + /// Bu haftada bu grupta gerçekleşen harcama + pub spent: i64, } pub struct GroupBudget { pub group: Group, pub monthly: i64, + /// Dönem boyunca bu grupta gerçekleşen harcama + pub spent: i64, pub weeks: Vec, /// Elle girilen haftaların toplamı aylık tutarı aşıyor mu? pub weekly_over: bool, @@ -131,6 +139,16 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR .sum(); let spendable = income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus; + let spent_total = sum_by( + db, + col::ENTRIES, + household_id, + doc! { "period_id": period_id }, + "$group", + ) + .await? + .values() + .sum(); Ok(Summary { income_total, @@ -140,6 +158,7 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR carryover_surplus, spendable, budget_total, + spent_total, }) } @@ -154,6 +173,7 @@ pub async fn view( let docs = load(db, household_id, period_id).await?; let (start, end) = dates(&p)?; let period_weeks = weeks(start, end); + let spent = spent_by_group_and_date(db, household_id, period_id).await?; let mut out = Vec::with_capacity(4); for group in Group::ALL { @@ -183,6 +203,13 @@ pub async fn view( .iter() .map(|w| { let key = format_date(w.start); + let last = format_date(w.last); + // Tarihler "YYYY-MM-DD" → metin karşılaştırması tarih sırasıyla aynı + let week_spent = spent + .iter() + .filter(|(g, d, _)| *g == group && *d >= key && *d <= last) + .map(|(_, _, a)| a) + .sum(); let (amount, is_manual) = match manual.get(&key) { Some(&a) => (a, true), None => (auto.next().unwrap_or(0), false), @@ -190,10 +217,11 @@ pub async fn view( WeekBudget { index: w.index, start_date: key, - last_date: format_date(w.last), + last_date: last, days: w.days, amount, is_manual, + spent: week_spent, } }) .collect(); @@ -201,6 +229,11 @@ pub async fn view( out.push(GroupBudget { group, monthly, + spent: spent + .iter() + .filter(|(g, _, _)| *g == group) + .map(|(_, _, a)| a) + .sum(), weeks, weekly_over: manual_total > monthly, }); @@ -451,6 +484,45 @@ async fn sum_by( Ok(out) } +/// Dönemin harcamaları (grup, gün) bazında toplanmış: en fazla 4 × 62 satır. +async fn spent_by_group_and_date( + db: &Database, + household_id: Uuid, + period_id: Uuid, +) -> AppResult> { + #[derive(Deserialize)] + struct Key { + group: Group, + date: String, + } + #[derive(Deserialize)] + struct Row { + #[serde(rename = "_id")] + key: Key, + total: i64, + } + let mut m = scope(household_id); + m.insert("period_id", period_id); + let pipeline = vec![ + doc! { "$match": m }, + doc! { "$group": { + "_id": { "group": "$group", "date": "$date" }, + "total": { "$sum": "$amount" }, + } }, + ]; + let mut cursor = db + .collection::(col::ENTRIES) + .aggregate(pipeline) + .with_type::() + .await?; + let mut out = Vec::new(); + while cursor.advance().await? { + let r = cursor.deserialize_current()?; + out.push((r.key.group, r.key.date, r.total)); + } + Ok(out) +} + /// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10). async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult> { let mut f = scope(household_id); diff --git a/backend/tests/budget.rs b/backend/tests/budget.rs index f1254f4..c9afded 100644 --- a/backend/tests/budget.rs +++ b/backend/tests/budget.rs @@ -88,7 +88,9 @@ async fn summary_computes_spendable_with_carryovers() { // 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000 "spendable": 2_675_000, "budget_total": 0, - "unallocated": 2_675_000 + "unallocated": 2_675_000, + "spent_total": 0, + "remaining": 2_675_000 }) ); diff --git a/backend/tests/entries.rs b/backend/tests/entries.rs index 2ef0b4d..c90fb1b 100644 --- a/backend/tests/entries.rs +++ b/backend/tests/entries.rs @@ -451,3 +451,124 @@ async fn previous_suggestion_uses_real_entries() { app.cleanup().await; } + +fn spent_by_week(budget: &Value, group: &str) -> (i64, Vec) { + let g = budget + .as_array() + .unwrap() + .iter() + .find(|g| g["group"] == group) + .unwrap(); + let weeks = g["weeks"] + .as_array() + .unwrap() + .iter() + .map(|w| w["spent"].as_i64().unwrap()) + .collect(); + (g["spent"].as_i64().unwrap(), weeks) +} + +#[tokio::test] +async fn actual_spending_in_summary_and_budget() { + let app = TestApp::new().await; + let s = app.register("Test", None).await; + // Kasım'da bir harcama: Aralık toplamlarına girmemeli + let nov = open_period(&app, &s, "2026-11-01", "2026-12-01").await; + let needs = category(&app, &s, "needs").await; + let wants = category(&app, &s, "wants").await; + let culture = category(&app, &s, "culture").await; + let (st, _) = add( + &app, + &s, + &nov, + json!({ "date": "2026-11-30", "amount": 1_000_000, "category_id": needs }), + ) + .await; + assert_eq!(st, StatusCode::CREATED); + close(&app, &nov).await; + let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await; + + app.post( + &format!("/periods/{pid}/plan-items"), + &s, + json!({ "kind": "income", "name": "Maaş", "amount": 300_000 }), + ) + .await; + + // Aralık 2026 haftaları: 1–6 · 7–13 · 14–20 · 21–27 · 28–31 + for (date, amount, cat) in [ + ("2026-12-01", 1_000, &needs), + ("2026-12-06", 2_000, &needs), // 1. haftanın son günü + ("2026-12-07", 500, &needs), // 2. haftanın ilk günü + ("2026-12-31", 300, &wants), // dönemin son günü + ] { + let (st, b) = add( + &app, + &s, + &pid, + json!({ "date": date, "amount": amount, "category_id": cat }), + ) + .await; + assert_eq!(st, StatusCode::CREATED, "{b}"); + } + // Kategori değişince harcama diğer gruba geçer + let (_, moved) = add( + &app, + &s, + &pid, + json!({ "date": "2026-12-08", "amount": 400, "category_id": needs }), + ) + .await; + app.patch( + &format!("/entries/{}", moved["id"].as_str().unwrap()), + &s, + json!({ "category_id": wants }), + ) + .await; + // Silinen harcama sayılmaz + let (_, gone) = add( + &app, + &s, + &pid, + json!({ "date": "2026-12-15", "amount": 700, "category_id": culture }), + ) + .await; + let gone_id = gone["id"].as_str().unwrap(); + app.delete(&format!("/entries/{gone_id}"), &s).await; + + let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; + // 1.000 + 2.000 + 500 + 300 + 400 = 4.200 + assert_eq!( + (sum["spent_total"].as_i64(), sum["remaining"].as_i64()), + (Some(4_200), Some(295_800)), + "{sum}" + ); + + let (st, b) = app.get(&format!("/periods/{pid}/budget"), &s).await; + assert_eq!(st, StatusCode::OK); + assert_eq!( + spent_by_week(&b, "needs"), + (3_500, vec![3_000, 500, 0, 0, 0]) + ); + assert_eq!(spent_by_week(&b, "wants"), (700, vec![0, 400, 0, 0, 300])); + assert_eq!(spent_by_week(&b, "culture"), (0, vec![0; 5])); + + // Geri al → yeniden sayılır + app.post(&format!("/entries/{gone_id}/restore"), &s, json!({})) + .await; + let (_, b) = app.get(&format!("/periods/{pid}/budget"), &s).await; + assert_eq!(spent_by_week(&b, "culture"), (700, vec![0, 0, 700, 0, 0])); + + // Harcanabilir aşılırsa kalan negatif + add( + &app, + &s, + &pid, + json!({ "date": "2026-12-20", "amount": 300_000, "category_id": needs }), + ) + .await; + let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await; + assert_eq!(sum["remaining"], 300_000 - 304_900); + + app.cleanup().await; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 653e5d9..885fe9a 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 5 – Adım 2:** Backend gerçekleşen harcamalar: dönem özetine `spent_total` + `remaining` (harcanabilir − harcanan), bütçe görünümüne grup×hafta `spent` (aggregation, saklanmaz), haftalık özet uç noktası ya da `budget` yanıtına ekleme (karar: mevcut `GET /budget` DTO'suna `spent` alanı). Testler: haftalara doğru dağılım, silinen kayıt sayılmaz, izolasyon. +**Faz 5 – Adım 3:** Frontend Defter (`/defter`): hafta seçici (◀ önceki | Bu hafta | sonraki ▶, dönem haftaları `GET /periods/{id}` → `weeks`), hızlı giriş formu (büyük tutar, 4 renkli grup düğmesi → o grubun aktif kategorileri, ödeme türü, tarih varsayılan bugün/dönem içi, açıklama), son kategori/ödeme türü localStorage'da hatırlanır. `lib/entries.ts` kancaları; kayıttan sonra `period(id)` önekiyle geçersiz kıl (özet + bütçe `spent` yenilenir). ## 📌 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`). @@ -100,3 +100,4 @@ ### 2026-11-12 — Faz 5 - **Adım 1:** Harcamalar backend: `models/entry.rs`, `services/entry.rs` (list + `from`/`to` filtresi, sıra `date` ↓ `_id` ↓; create/update/delete soft/`restore`), `routes/entry.rs` (5 uç nokta), `catalog::find`. Grup **kategoriden türetilir** (istemci göndermez). Pasif kategori/ödeme türü yeni seçimde reddedilir, kaydın mevcut değeri olarak kalabilir. Ödeme türü isteğe bağlı; düzenlemede `""` → kaldır (projedeki "boş metin = kaldır" kuralı; ts-rs `deserialize_with` desteklemediği için çift-Option yerine). `tests/entries.rs` (6) — `previous` önerisi artık gerçek kayıtla test edildi. Mutasyon: grup türetme bozulunca yakalandı; restore'da hane filtresi tek başına kaldırılınca `get_open` ikinci katman olarak korudu, ikisi birden kaldırılınca 2 test yakaladı. Backend 97 test. +- **Adım 2:** Gerçekleşen harcama (saklanmaz, her istekte aggregation): `GET /summary` → `spent_total`, `remaining` (= harcanabilir − harcanan, negatifse aşılmış). `GET /budget` → grupta `spent` (dönem), haftada `spent`. `spent_by_group_and_date` (grup×gün toplanır, Rust'ta haftalara dağıtılır; tarih metin karşılaştırması). Silinen kayıt sayılmaz, kategori değişince harcama yeni gruba geçer. Test `actual_spending_in_summary_and_budget` (hafta sınırları 6/7 Aralık, dönem son günü, önceki dönem hariç, geri al). Mutasyon: hafta sınırı `<=`→`<` ve silinenleri sayma → ikisi de yakalandı. Backend 98 test. diff --git a/docs/PHASES.md b/docs/PHASES.md index 746a4dc..64941a5 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -55,7 +55,7 @@ ## Faz 5 Adımları (taslak) - ✅ 1. Backend: harcama kayıtları (ekle/düzenle/sil + geri al, hafta/dönem filtresi, kategori–grup tutarlılığı, yalnızca açık dönem), testler -- 🔄 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi -- ⏳ 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) +- ✅ 2. Backend: haftalık/aylık gerçekleşen (grup bazlı bütçe–harcanan–kalan), özet ve önerilere gerçekleşen verisi +- 🔄 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 (mongodump günlük, 14 gün) + geri yükleme denemesi diff --git a/frontend/src/lib/plan-draft.test.ts b/frontend/src/lib/plan-draft.test.ts index e6e5fc2..1768b76 100644 --- a/frontend/src/lib/plan-draft.test.ts +++ b/frontend/src/lib/plan-draft.test.ts @@ -57,10 +57,12 @@ const week = (start: string, amount: number, is_manual = false) => ({ days: 7, amount, is_manual, + spent: 0, }) const budget: GroupBudget[] = (['needs', 'wants', 'culture', 'unexpected'] as const).map((g) => ({ group: g, monthly: g === 'needs' ? 1_000_000 : 0, + spent: 0, weekly_over: false, weeks: g === 'needs' diff --git a/frontend/src/types/GroupBudget.ts b/frontend/src/types/GroupBudget.ts index 0214404..e18ce23 100644 --- a/frontend/src/types/GroupBudget.ts +++ b/frontend/src/types/GroupBudget.ts @@ -3,6 +3,10 @@ import type { Group } from "./Group"; import type { WeekBudget } from "./WeekBudget"; export type GroupBudget = { group: Group, monthly: number, +/** + * Dönem boyunca gerçekleşen harcama + */ +spent: number, /** * Elle girilen haftaların toplamı aylık tutarı aşıyor */ diff --git a/frontend/src/types/PeriodSummary.ts b/frontend/src/types/PeriodSummary.ts index 301054e..f10ac10 100644 --- a/frontend/src/types/PeriodSummary.ts +++ b/frontend/src/types/PeriodSummary.ts @@ -20,4 +20,12 @@ budget_total: number, /** * harcanabilir − dağıtılan (negatifse fazla dağıtılmış) */ -unallocated: number, }; +unallocated: number, +/** + * Dönemde gerçekleşen harcama + */ +spent_total: number, +/** + * Kalan harcanabilir: harcanabilir − gerçekleşen (negatifse aşılmış) + */ +remaining: number, }; diff --git a/frontend/src/types/WeekBudget.ts b/frontend/src/types/WeekBudget.ts index 9fe5e0a..89d21ef 100644 --- a/frontend/src/types/WeekBudget.ts +++ b/frontend/src/types/WeekBudget.ts @@ -4,4 +4,8 @@ export type WeekBudget = { index: number, start_date: string, last_date: string, /** * Elle girildi (otomatik değil) */ -is_manual: boolean, }; +is_manual: boolean, +/** + * Bu haftada gerçekleşen harcama + */ +spent: number, };