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
+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)
}