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:
@@ -10,4 +10,5 @@ pub mod money;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user