feat(defter): özet ve bütçeye gerçekleşen harcama eklendi (backend)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 11:12:51 +03:00
parent 2003b8fad7
commit 7143613ab2
11 changed files with 241 additions and 10 deletions
+2 -1
View File
@@ -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)
+18 -2
View File
@@ -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<WeekBudgetDto>,
@@ -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<GroupBudget> for GroupBudgetDto {
@@ -112,6 +124,7 @@ impl From<GroupBudget> 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<GroupBudget> 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,
}))
}
+73 -1
View File
@@ -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<WeekBudget>,
/// 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<Vec<(Group, String, i64)>> {
#[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::<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((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<Vec<u64>> {
let mut f = scope(household_id);
+3 -1
View File
@@ -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
})
);
+121
View File
@@ -451,3 +451,124 @@ async fn previous_suggestion_uses_real_entries() {
app.cleanup().await;
}
fn spent_by_week(budget: &Value, group: &str) -> (i64, Vec<i64>) {
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;
}