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:
@@ -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(),
|
||||
}))
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user