feat(raporlar): harcama kırılımı uç noktası ve dönem istatistiklerinde birikim oranı verileri
- GET /reports/breakdown: grup, tüm kategoriler, ödeme türü ve ay kırılımı; dönem/tarih/grup/kategori/ödeme türü filtreleri - /stats/periods: gelir, sabit gider, devirler, gerçekleşen birikim - Faz 9 planı kullanıcı kararlarıyla güncellendi (ekstre içe aktarma, JSON geri dönüş noktası, aylık rapor) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ pub mod period;
|
||||
pub mod plan;
|
||||
pub mod rate_sources;
|
||||
pub mod rates;
|
||||
pub mod reports;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Raporlar (KAKEIBO_PROMPT §8.8). Harcama kırılımı tek aggregation (`$facet`) ile:
|
||||
//! grup, **tüm** kategoriler, ödeme türü, ay. Saklanmaz; her istekte hesaplanır.
|
||||
//!
|
||||
//! Filtre: dönem **ya da** tarih aralığı (ikisi de dahil), grup, kategori, ödeme türü
|
||||
//! (`Some(None)` → ödeme türü girilmemiş kayıtlar). Silinen kayıtlar sayılmaz.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{common::scope, group::Group},
|
||||
services::{calendar::parse_date, period},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Filter {
|
||||
pub period_id: Option<Uuid>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub group: Option<Group>,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// `Some(None)` → ödeme türü boş olanlar
|
||||
pub payment_method: Option<Option<Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Row<K> {
|
||||
#[serde(rename = "_id")]
|
||||
pub key: K,
|
||||
pub total: i64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CategoryKey {
|
||||
pub category_id: Uuid,
|
||||
pub group: Group,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Facets {
|
||||
totals: Vec<Row<Bson>>,
|
||||
groups: Vec<Row<Group>>,
|
||||
categories: Vec<Row<CategoryKey>>,
|
||||
methods: Vec<Row<Option<Uuid>>>,
|
||||
months: Vec<Row<String>>,
|
||||
}
|
||||
|
||||
pub struct Breakdown {
|
||||
pub total: i64,
|
||||
pub count: i64,
|
||||
/// `Group::ALL` sırasıyla, harcama olmayan grup 0
|
||||
pub groups: Vec<Row<Group>>,
|
||||
/// Çoktan aza (eşitlikte kategori kimliği)
|
||||
pub categories: Vec<Row<CategoryKey>>,
|
||||
/// Çoktan aza; `None` = ödeme türü girilmemiş
|
||||
pub methods: Vec<Row<Option<Uuid>>>,
|
||||
/// `"YYYY-MM"`, eskiden yeniye
|
||||
pub months: Vec<Row<String>>,
|
||||
}
|
||||
|
||||
pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult<Breakdown> {
|
||||
let mut m = scope(household_id);
|
||||
if let Some(pid) = f.period_id {
|
||||
period::get(db, household_id, pid).await?; // bu haneye ait mi?
|
||||
m.insert("period_id", pid);
|
||||
}
|
||||
let mut range = Document::new();
|
||||
for (op, v) in [("$gte", &f.from), ("$lte", &f.to)] {
|
||||
if let Some(d) = v {
|
||||
let d = d.trim();
|
||||
parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
range.insert(op, d);
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (&f.from, &f.to)
|
||||
&& a.trim() > b.trim()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Başlangıç tarihi bitişten sonra olamaz".into(),
|
||||
));
|
||||
}
|
||||
if !range.is_empty() {
|
||||
m.insert("date", range);
|
||||
}
|
||||
if let Some(g) = f.group {
|
||||
m.insert("group", g.as_str());
|
||||
}
|
||||
if let Some(c) = f.category_id {
|
||||
m.insert("category_id", c);
|
||||
}
|
||||
if let Some(pm) = f.payment_method {
|
||||
m.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
}
|
||||
|
||||
let sum = || doc! { "total": { "$sum": "$amount" }, "count": { "$sum": Bson::Int64(1) } };
|
||||
let group_by = |key: Bson| {
|
||||
let mut g = doc! { "_id": key };
|
||||
g.extend(sum());
|
||||
doc! { "$group": g }
|
||||
};
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$facet": {
|
||||
"totals": [ group_by(Bson::Null) ],
|
||||
"groups": [ group_by(Bson::String("$group".into())) ],
|
||||
"categories": [
|
||||
group_by(Bson::Document(doc! { "category_id": "$category_id", "group": "$group" })),
|
||||
{ "$sort": { "total": -1, "_id.category_id": 1 } },
|
||||
],
|
||||
"methods": [
|
||||
// Alanı hiç olmayan eski kayıtlar da "girilmemiş" sayılsın
|
||||
group_by(Bson::Document(doc! { "$ifNull": ["$payment_method_id", null] })),
|
||||
{ "$sort": { "total": -1, "_id": 1 } },
|
||||
],
|
||||
"months": [
|
||||
group_by(Bson::Document(doc! { "$substrBytes": ["$date", 0, 7] })),
|
||||
{ "$sort": { "_id": 1 } },
|
||||
],
|
||||
} },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Facets>()
|
||||
.await?;
|
||||
let f = if cursor.advance().await? {
|
||||
cursor.deserialize_current()?
|
||||
} else {
|
||||
return Err(AppError::Internal("rapor sorgusu boş döndü".into()));
|
||||
};
|
||||
|
||||
let (total, count) = f.totals.first().map_or((0, 0), |r| (r.total, r.count));
|
||||
let groups = Group::ALL
|
||||
.into_iter()
|
||||
.map(|g| {
|
||||
f.groups.iter().find(|r| r.key == g).map_or(
|
||||
Row {
|
||||
key: g,
|
||||
total: 0,
|
||||
count: 0,
|
||||
},
|
||||
|r| Row {
|
||||
key: g,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(Breakdown {
|
||||
total,
|
||||
count,
|
||||
groups,
|
||||
categories: f.categories,
|
||||
methods: f.methods,
|
||||
months: f.months,
|
||||
})
|
||||
}
|
||||
@@ -57,9 +57,17 @@ pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppRes
|
||||
|
||||
pub struct PeriodStat {
|
||||
pub period: Period,
|
||||
pub spendable: i64,
|
||||
pub spent: i64,
|
||||
pub spent_by_group: GroupAmounts,
|
||||
/// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil)
|
||||
pub summary: budget::Summary,
|
||||
}
|
||||
|
||||
impl PeriodStat {
|
||||
/// Değerlendirmenin 4. sorusu: gelir − sabit − borç + artı − harcanan (`review` ile aynı)
|
||||
pub fn realized_savings(&self) -> i64 {
|
||||
let s = &self.summary;
|
||||
s.income_total - s.fixed_expense_total - s.carryover_debt + s.carryover_surplus
|
||||
- s.spent_total
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Stats {
|
||||
@@ -74,13 +82,8 @@ pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResu
|
||||
let total_saved = list.iter().filter_map(|p| p.to_savings).sum();
|
||||
let mut out = Vec::new();
|
||||
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
|
||||
let s = budget::summary(db, household_id, p.id).await?;
|
||||
out.push(PeriodStat {
|
||||
period: p,
|
||||
spendable: s.spendable,
|
||||
spent: s.spent_total,
|
||||
spent_by_group: s.spent_by_group,
|
||||
});
|
||||
let summary = budget::summary(db, household_id, p.id).await?;
|
||||
out.push(PeriodStat { period: p, summary });
|
||||
}
|
||||
out.reverse();
|
||||
Ok(Stats {
|
||||
|
||||
Reference in New Issue
Block a user