feat(bütçe): aylık/haftalık bütçe, bütçe önerisi ve dönem özeti (backend)

- /api/periods/{id}/summary: gelir − sabit gider − birikim − devreden borç + artı = harcanabilir
- /api/periods/{id}/budget: 4 grup aylık; haftalar gün sayısına göre, toplam kuruşu kuruşuna eşit
- Elle hafta ayarı (kalan diğer haftalara dağılır), otomatiğe dönüş, aşım uyarısı
- Öneri: varsayılan yüzdeler / önceki dönem / son 3 dönem ortalaması
- Testler: orantılı bölme (3), bütçe entegrasyon (6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 03:42:49 +03:00
parent 7f3fde074c
commit 95231cd4d6
16 changed files with 1337 additions and 3 deletions
+246
View File
@@ -0,0 +1,246 @@
//! Bütçe ve özet uç noktaları:
//!
//! | 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 |
//! | 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` |
use axum::{
Json,
extract::{Path, Query, State},
};
use mongodb::bson::Uuid;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use validator::Validate;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
extract::ValidJson,
models::group::Group,
services::budget::{self, GroupBudget, SuggestMethod},
state::AppState,
};
/// Gruplara göre tutarlar (kuruş)
#[derive(Serialize, Deserialize, Validate, TS, Clone, Copy)]
#[ts(export)]
pub struct GroupAmounts {
#[ts(type = "number")]
needs: i64,
#[ts(type = "number")]
wants: i64,
#[ts(type = "number")]
culture: i64,
#[ts(type = "number")]
unexpected: i64,
}
impl From<budget::GroupAmounts> for GroupAmounts {
fn from(a: budget::GroupAmounts) -> Self {
Self {
needs: a[0],
wants: a[1],
culture: a[2],
unexpected: a[3],
}
}
}
impl From<GroupAmounts> for budget::GroupAmounts {
fn from(a: GroupAmounts) -> Self {
[a.needs, a.wants, a.culture, a.unexpected]
}
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodSummary")]
pub struct SummaryDto {
#[ts(type = "number")]
income_total: i64,
#[ts(type = "number")]
fixed_expense_total: i64,
#[ts(type = "number")]
savings_goal: i64,
/// Önceki dönemden devreden borç
#[ts(type = "number")]
carryover_debt: i64,
/// Önceki dönemden devreden artı bakiye
#[ts(type = "number")]
carryover_surplus: i64,
/// gelir − sabit gider − birikim − borç + artı
#[ts(type = "number")]
spendable: i64,
/// 4 grubun aylık bütçe toplamı
#[ts(type = "number")]
budget_total: i64,
/// harcanabilir − dağıtılan (negatifse fazla dağıtılmış)
#[ts(type = "number")]
unallocated: i64,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "GroupBudget")]
pub struct GroupBudgetDto {
group: Group,
#[ts(type = "number")]
monthly: i64,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor
weekly_over: bool,
weeks: Vec<WeekBudgetDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "WeekBudget")]
struct WeekBudgetDto {
index: u32,
start_date: String,
last_date: String,
days: u32,
#[ts(type = "number")]
amount: i64,
/// Elle girildi (otomatik değil)
is_manual: bool,
}
impl From<GroupBudget> for GroupBudgetDto {
fn from(g: GroupBudget) -> Self {
Self {
group: g.group,
monthly: g.monthly,
weekly_over: g.weekly_over,
weeks: g
.weeks
.into_iter()
.map(|w| WeekBudgetDto {
index: w.index,
start_date: w.start_date,
last_date: w.last_date,
days: w.days,
amount: w.amount,
is_manual: w.is_manual,
})
.collect(),
}
}
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "BudgetWeekRequest")]
pub struct WeekRequest {
group: Group,
/// Haftanın ilk günü (`PeriodWeek.start_date`)
week_start: String,
/// Kuruş; `null` → otomatiğe döndür
#[ts(type = "number | null")]
amount: Option<i64>,
}
#[derive(Deserialize, Clone, Copy, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, rename = "SuggestMethod")]
pub enum Method {
Default,
Previous,
Avg3,
}
#[derive(Deserialize)]
pub struct SuggestQuery {
method: Option<Method>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "BudgetSuggestion")]
pub struct SuggestionDto {
/// `false` → bu yöntem için veri yok (`message` nedenini söyler)
available: bool,
message: Option<String>,
amounts: GroupAmounts,
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
pub async fn summary(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<SummaryDto>> {
let x = budget::summary(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(SummaryDto {
income_total: x.income_total,
fixed_expense_total: x.fixed_expense_total,
savings_goal: x.savings_goal,
carryover_debt: x.carryover_debt,
carryover_surplus: x.carryover_surplus,
spendable: x.spendable,
budget_total: x.budget_total,
unallocated: x.spendable - x.budget_total,
}))
}
pub async fn get(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let groups = budget::view(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn set_monthly(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<GroupAmounts>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let pid = parse_id(&id)?;
budget::set_monthly(&s.db, auth.household_id, pid, req.into()).await?;
let groups = budget::view(&s.db, auth.household_id, pid).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn set_week(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<WeekRequest>,
) -> AppResult<Json<Vec<GroupBudgetDto>>> {
let pid = parse_id(&id)?;
budget::set_week(
&s.db,
auth.household_id,
pid,
req.group,
&req.week_start,
req.amount,
)
.await?;
let groups = budget::view(&s.db, auth.household_id, pid).await?;
Ok(Json(groups.into_iter().map(GroupBudgetDto::from).collect()))
}
pub async fn suggestion(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
Query(q): Query<SuggestQuery>,
) -> AppResult<Json<SuggestionDto>> {
let method = match q.method.unwrap_or(Method::Default) {
Method::Default => SuggestMethod::Default,
Method::Previous => SuggestMethod::Previous,
Method::Avg3 => SuggestMethod::Avg3,
};
let x = budget::suggest(&s.db, auth.household_id, parse_id(&id)?, method).await?;
Ok(Json(SuggestionDto {
available: x.available,
message: x.message,
amounts: x.amounts.into(),
}))
}
+8
View File
@@ -1,6 +1,7 @@
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
mod auth;
mod budget;
mod catalog;
mod health;
mod household;
@@ -75,6 +76,13 @@ pub fn router(state: AppState) -> Router {
)
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
.route("/periods/{id}/notes", get(note::list).post(note::create))
.route("/periods/{id}/summary", get(budget::summary))
.route(
"/periods/{id}/budget",
get(budget::get).put(budget::set_monthly),
)
.route("/periods/{id}/budget/weeks", put(budget::set_week))
.route("/periods/{id}/budget/suggestion", get(budget::suggestion))
.route("/notes/{id}", patch(note::update).delete(note::delete))
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound });