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
+33
View File
@@ -230,3 +230,36 @@ Content-Type: application/json
### Notu sil
DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
# ─────────────────────────── Bütçe ve özet ───────────────────────────
# Gruplar: needs · wants · culture · unexpected · tutarlar kuruş
### Dönem özeti: gelir − sabit gider − birikim − devreden borç + devreden artı = harcanabilir
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/summary
### Bütçe: 4 grup, aylık + haftalık döküm (haftalar gün sayısına göre)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
### Aylık bütçeleri kaydet (4 grup birlikte)
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget
Content-Type: application/json
{
"needs": 1500000,
"wants": 600000,
"culture": 200000,
"unexpected": 200000
}
### Bir haftayı elle ayarla (week_start = haftanın ilk günü); "amount": null → otomatiğe döner
PUT {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/weeks
Content-Type: application/json
{
"group": "needs",
"week_start": "2026-12-07",
"amount": 400000
}
### Bütçe önerisi — method: default (varsayılan yüzdeler) | previous (önceki dönem) | avg3 (son 3 dönem)
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/budget/suggestion?method=default
+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 });
+502
View File
@@ -0,0 +1,502 @@
//! Bütçe ve dönem özeti (KAKEIBO_PROMPT §8.3, DATA_MODEL.md §3.8, §5).
//!
//! - Her grup için **aylık** tutar (`budgets`, `scope: "month"`).
//! - Haftalık tutar saklanmaz; aylık tutar hafta gün sayısına göre bölünür.
//! Kullanıcı bir haftayı elle değiştirirse yalnızca o kayıt saklanır (`scope: "week"`);
//! kalan tutar diğer haftalara gün oranında dağıtılır → haftaların toplamı = aylık.
//! - Özet: gelir − sabit gider − birikim − devreden borç + devreden artı = **harcanabilir**.
use std::collections::HashMap;
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
};
use serde::Deserialize;
use crate::{
db::col,
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
group::Group,
period::{Period, PeriodStatus},
},
services::{
calendar::{format_date, parse_date, weeks},
money::split_proportional,
period,
},
};
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
pub type GroupAmounts = [i64; 4];
pub struct Summary {
pub income_total: i64,
pub fixed_expense_total: i64,
pub savings_goal: i64,
pub carryover_debt: i64,
pub carryover_surplus: i64,
pub spendable: i64,
pub budget_total: i64,
}
pub struct WeekBudget {
pub index: u32,
pub start_date: String,
pub last_date: String,
pub days: u32,
pub amount: i64,
pub is_manual: bool,
}
pub struct GroupBudget {
pub group: Group,
pub monthly: i64,
pub weeks: Vec<WeekBudget>,
/// Elle girilen haftaların toplamı aylık tutarı aşıyor mu?
pub weekly_over: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SuggestMethod {
/// Hanenin varsayılan yüzdeleri × harcanabilir tutar
Default,
/// Önceki dönemde gerçekleşen harcamalar
Previous,
/// Son 3 dönemin gerçekleşen harcama ortalaması
Avg3,
}
pub struct Suggestion {
pub available: bool,
pub message: Option<String>,
pub amounts: GroupAmounts,
}
#[derive(Deserialize)]
struct BudgetDoc {
group: Group,
scope: String,
#[serde(default)]
week_start: Option<String>,
amount: i64,
}
// ---------- Özet ----------
pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Summary> {
let p = period::get(db, household_id, period_id).await?;
let mut income_total = 0;
let mut fixed_expense_total = 0;
for (kind, total) in sum_by(
db,
col::PLAN_ITEMS,
household_id,
doc! { "period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"income" => income_total = total,
"expense" => fixed_expense_total = total,
_ => {}
}
}
let mut carryover_debt = 0;
let mut carryover_surplus = 0;
for (kind, total) in sum_by(
db,
col::CARRYOVERS,
household_id,
doc! { "to_period_id": period_id },
"$kind",
)
.await?
{
match kind.as_str() {
"debt" => carryover_debt = total,
"surplus" => carryover_surplus = total,
_ => {}
}
}
let budget_total = monthly_amounts(db, household_id, period_id)
.await?
.iter()
.sum();
let spendable =
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
Ok(Summary {
income_total,
fixed_expense_total,
savings_goal: p.savings_goal,
carryover_debt,
carryover_surplus,
spendable,
budget_total,
})
}
// ---------- Bütçe görünümü ----------
pub async fn view(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<Vec<GroupBudget>> {
let p = period::get(db, household_id, period_id).await?;
let docs = load(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let period_weeks = weeks(start, end);
let mut out = Vec::with_capacity(4);
for group in Group::ALL {
let monthly = docs
.iter()
.find(|d| d.group == group && d.scope == "month")
.map_or(0, |d| d.amount);
// Elle girilmiş haftalar (dönem tarihleri değiştiyse eşleşmeyen eski kayıtlar yok sayılır)
let manual: HashMap<String, i64> = docs
.iter()
.filter(|d| d.group == group && d.scope == "week")
.filter_map(|d| d.week_start.clone().map(|w| (w, d.amount)))
.collect();
let manual_total: i64 = period_weeks
.iter()
.filter_map(|w| manual.get(&format_date(w.start)))
.sum();
let auto_days: Vec<u64> = period_weeks
.iter()
.filter(|w| !manual.contains_key(&format_date(w.start)))
.map(|w| u64::from(w.days))
.collect();
let mut auto = split_proportional(monthly - manual_total, &auto_days).into_iter();
let weeks = period_weeks
.iter()
.map(|w| {
let key = format_date(w.start);
let (amount, is_manual) = match manual.get(&key) {
Some(&a) => (a, true),
None => (auto.next().unwrap_or(0), false),
};
WeekBudget {
index: w.index,
start_date: key,
last_date: format_date(w.last),
days: w.days,
amount,
is_manual,
}
})
.collect();
out.push(GroupBudget {
group,
monthly,
weeks,
weekly_over: manual_total > monthly,
});
}
Ok(out)
}
// ---------- Yazma ----------
/// 4 grubun aylık tutarını birlikte kaydeder (elle girilmiş haftalar korunur).
pub async fn set_monthly(
db: &Database,
household_id: Uuid,
period_id: Uuid,
amounts: GroupAmounts,
) -> AppResult<()> {
period::get_open(db, household_id, period_id).await?;
for a in amounts {
period::validate_amount(a)?;
}
for (group, amount) in Group::ALL.into_iter().zip(amounts) {
upsert(db, household_id, period_id, group, "month", None, amount).await?;
}
Ok(())
}
/// Bir haftayı elle ayarlar; `amount = None` → otomatiğe döner.
pub async fn set_week(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
week_start: &str,
amount: Option<i64>,
) -> AppResult<()> {
let p = period::get_open(db, household_id, period_id).await?;
let (start, end) = dates(&p)?;
let valid =
parse_date(week_start).is_some_and(|d| weeks(start, end).iter().any(|w| w.start == d));
if !valid {
return Err(AppError::BadRequest("Hafta bu döneme ait değil".into()));
}
match amount {
Some(a) => {
period::validate_amount(a)?;
upsert(
db,
household_id,
period_id,
group,
"week",
Some(week_start),
a,
)
.await
}
None => {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", "week");
f.insert("week_start", week_start);
let ts = now();
db.collection::<Document>(col::BUDGETS)
.update_many(
f,
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
)
.await?;
Ok(())
}
}
}
// ---------- Öneri ----------
pub async fn suggest(
db: &Database,
household_id: Uuid,
period_id: Uuid,
method: SuggestMethod,
) -> AppResult<Suggestion> {
let p = period::get(db, household_id, period_id).await?;
match method {
SuggestMethod::Default => {
let s = summary(db, household_id, period_id).await?;
if s.spendable <= 0 {
return Ok(unavailable(
"Harcanabilir tutar sıfır ya da negatif. Önce gelir, gider ve birikim hedefini girin.",
));
}
let pct = split_percentages(db, household_id).await?;
let parts = split_proportional(s.spendable, &pct);
Ok(Suggestion {
available: true,
message: None,
amounts: [parts[0], parts[1], parts[2], parts[3]],
})
}
SuggestMethod::Previous | SuggestMethod::Avg3 => {
let n = if method == SuggestMethod::Previous {
1
} else {
3
};
let previous = previous_closed(db, household_id, &p, n).await?;
if previous.is_empty() {
return Ok(unavailable("Henüz kapanmış bir önceki dönem yok."));
}
let ids: Vec<Bson> = previous.iter().map(|id| Bson::from(*id)).collect();
let sums = sum_by(
db,
col::ENTRIES,
household_id,
doc! { "period_id": { "$in": ids } },
"$group",
)
.await?;
let total: i64 = sums.values().sum();
if total == 0 {
return Ok(unavailable("Önceki dönemlerde henüz harcama kaydı yok."));
}
let count = previous.len() as i64;
let avg = |g: Group| {
let s = sums.get(g.as_str()).copied().unwrap_or(0);
(s + count / 2) / count // yuvarlayarak bölme
};
Ok(Suggestion {
available: true,
message: None,
amounts: Group::ALL.map(avg),
})
}
}
}
// ---------- yardımcılar ----------
fn dates(p: &Period) -> AppResult<(time::Date, time::Date)> {
let bad = || AppError::Internal("dönem tarihi okunamadı".into());
Ok((
parse_date(&p.start_date).ok_or_else(bad)?,
parse_date(&p.end_date).ok_or_else(bad)?,
))
}
fn unavailable(msg: &str) -> Suggestion {
Suggestion {
available: false,
message: Some(msg.into()),
amounts: [0; 4],
}
}
async fn load(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult<Vec<BudgetDoc>> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
let mut cursor = db.collection::<BudgetDoc>(col::BUDGETS).find(f).await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
async fn monthly_amounts(
db: &Database,
household_id: Uuid,
period_id: Uuid,
) -> AppResult<GroupAmounts> {
let docs = load(db, household_id, period_id).await?;
Ok(Group::ALL.map(|g| {
docs.iter()
.find(|d| d.group == g && d.scope == "month")
.map_or(0, |d| d.amount)
}))
}
async fn upsert(
db: &Database,
household_id: Uuid,
period_id: Uuid,
group: Group,
level: &str,
week_start: Option<&str>,
amount: i64,
) -> AppResult<()> {
let coll = db.collection::<Document>(col::BUDGETS);
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert("group", group.as_str());
f.insert("scope", level);
f.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
let res = coll
.update_one(
f,
doc! { "$set": { "amount": amount, "updated_at": now() }, "$inc": { "version": 1 } },
)
.await?;
if res.matched_count == 0 {
let mut d = new_meta(household_id);
d.insert("period_id", period_id);
d.insert("group", group.as_str());
d.insert("scope", level);
d.insert(
"week_start",
week_start.map_or(Bson::Null, |w| Bson::String(w.into())),
);
d.insert("amount", amount);
coll.insert_one(d).await?;
}
Ok(())
}
/// Bir koleksiyonda `amount` alanını bir alana göre toplar: `{ "income": 450000, ... }`
async fn sum_by(
db: &Database,
collection: &str,
household_id: Uuid,
filter: Document,
key: &str,
) -> AppResult<HashMap<String, i64>> {
let mut m = scope(household_id);
m.extend(filter);
let pipeline = vec![
doc! { "$match": m },
doc! { "$group": { "_id": key, "total": { "$sum": "$amount" } } },
];
let mut cursor = db
.collection::<Document>(collection)
.aggregate(pipeline)
.await?;
let mut out = HashMap::new();
while cursor.advance().await? {
let d = cursor.deserialize_current()?;
let k = d.get_str("_id").unwrap_or_default().to_string();
let v = match d.get("total") {
Some(Bson::Int64(v)) => *v,
Some(Bson::Int32(v)) => i64::from(*v),
_ => 0,
};
out.insert(k, v);
}
Ok(out)
}
/// Hane ayarındaki varsayılan yüzdeler (yoksa 50/30/10/10).
async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u64>> {
let mut f = scope(household_id);
f.insert("_id", household_id);
let hh = db
.collection::<Document>(col::HOUSEHOLDS)
.find_one(f)
.await?;
let pct = hh
.as_ref()
.and_then(|h| h.get_document("settings").ok())
.and_then(|s| s.get_document("budget_split_pct").ok());
let default = [50u64, 30, 10, 10];
Ok(Group::ALL
.iter()
.zip(default)
.map(|(g, d)| {
pct.and_then(|p| match p.get(g.as_str()) {
Some(Bson::Int32(v)) => Some(*v as u64),
Some(Bson::Int64(v)) => Some(*v as u64),
_ => None,
})
.unwrap_or(d)
})
.collect())
}
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri.
async fn previous_closed(
db: &Database,
household_id: Uuid,
p: &Period,
n: i64,
) -> AppResult<Vec<Uuid>> {
let mut f = scope(household_id);
f.insert("start_date", doc! { "$lt": &p.start_date });
f.insert("status", PeriodStatus::Closed.as_str());
let mut cursor = db
.collection::<Period>(col::PERIODS)
.find(f)
.sort(doc! { "start_date": -1 })
.limit(n)
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?.id);
}
Ok(out)
}
+2
View File
@@ -1,9 +1,11 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account;
pub mod budget;
pub mod calendar;
pub mod catalog;
pub mod invite;
pub mod money;
pub mod note;
pub mod period;
pub mod plan;
+68
View File
@@ -0,0 +1,68 @@
//! Para hesapları (kuruş, `i64`). Float yok.
/// `total` kuruşu ağırlıklara orantılı böler; **toplam her zaman `total`'a eşittir**.
///
/// "En büyük kalan" yöntemi: herkes tam payını alır, artan kuruşlar
/// küsuratı en büyük olana (eşitlikte öndekine) birer birer verilir.
/// Ağırlıkların hepsi 0 ise hepsine 0 döner (ve `total` dağıtılamaz → çağıran kontrol eder).
pub fn split_proportional(total: i64, weights: &[u64]) -> Vec<i64> {
let sum: u128 = weights.iter().map(|&w| u128::from(w)).sum();
if sum == 0 || total <= 0 {
return vec![0; weights.len()];
}
let total_u = total as u128;
let mut parts: Vec<i64> = Vec::with_capacity(weights.len());
let mut remainders: Vec<(u128, usize)> = Vec::with_capacity(weights.len());
for (i, &w) in weights.iter().enumerate() {
let exact = total_u * u128::from(w);
parts.push((exact / sum) as i64);
remainders.push((exact % sum, i));
}
let mut left = total - parts.iter().sum::<i64>();
// Büyük kalan önce; eşitlikte küçük indeks önce
remainders.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
for (_, i) in remainders {
if left == 0 {
break;
}
parts[i] += 1;
left -= 1;
}
parts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sums_exactly_and_is_proportional() {
// 1.000,00 ₺ Aralık haftalarına (6,7,7,7,4 gün)
let p = split_proportional(100_000, &[6, 7, 7, 7, 4]);
assert_eq!(p.iter().sum::<i64>(), 100_000);
assert_eq!(p, vec![19_355, 22_581, 22_581, 22_580, 12_903]);
}
#[test]
fn default_percentages() {
// Tam paylar 617.283,5 / 370.370,1 / 123.456,7 / 123.456,7 → artan 2 kuruş
// küsuratı en büyük olan ikisine (0,7'ler) gider
assert_eq!(
split_proportional(1_234_567, &[50, 30, 10, 10]),
vec![617_283, 370_370, 123_457, 123_457]
);
assert_eq!(split_proportional(1, &[50, 30, 10, 10]), vec![1, 0, 0, 0]);
}
#[test]
fn edge_cases() {
assert_eq!(split_proportional(0, &[1, 2]), vec![0, 0]);
assert_eq!(split_proportional(-500, &[1, 2]), vec![0, 0]);
assert_eq!(split_proportional(100, &[0, 0]), vec![0, 0]);
assert_eq!(split_proportional(100, &[]), Vec::<i64>::new());
assert_eq!(split_proportional(7, &[0, 3, 0]), vec![0, 7, 0]);
// Çok büyük tutarlarda taşma yok
let big = 10_000_000_000_000;
assert_eq!(split_proportional(big, &[31, 31]).iter().sum::<i64>(), big);
}
}
+403
View File
@@ -0,0 +1,403 @@
//! Bütçe ve dönem özeti.
mod common;
use axum::http::StatusCode;
use common::{Session, TestApp};
use mongodb::bson::{Document, Uuid, doc};
use serde_json::{Value, json};
async fn open_december(app: &TestApp, s: &Session, savings_goal: i64) -> String {
let (st, p) = app
.post(
"/periods",
s,
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": savings_goal }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{p}");
p["id"].as_str().unwrap().to_string()
}
async fn add_item(app: &TestApp, s: &Session, pid: &str, kind: &str, amount: i64) {
let (st, b) = app
.post(
&format!("/periods/{pid}/plan-items"),
s,
json!({ "kind": kind, "name": format!("{kind}-{amount}"), "amount": amount }),
)
.await;
assert_eq!(st, StatusCode::CREATED, "{b}");
}
fn group<'a>(budget: &'a Value, code: &str) -> &'a Value {
budget
.as_array()
.unwrap()
.iter()
.find(|g| g["group"] == code)
.unwrap()
}
fn week_amounts(g: &Value) -> Vec<i64> {
g["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["amount"].as_i64().unwrap())
.collect()
}
#[tokio::test]
async fn summary_computes_spendable_with_carryovers() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 500_000).await;
// Boş dönem: gelir yok → harcanabilir = -birikim
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["spendable"], -500_000);
add_item(&app, &s, &pid, "income", 4_500_000).await;
add_item(&app, &s, &pid, "income", 300_000).await;
add_item(&app, &s, &pid, "expense", 1_500_000).await;
add_item(&app, &s, &pid, "expense", 45_000).await;
// Önceki dönemden devir (Faz 6'da kapanış sihirbazı yazacak)
let hid = Uuid::parse_str(&s.household_id).unwrap();
let to = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("carryovers")
.insert_many([
doc! { "household_id": hid, "to_period_id": to, "kind": "debt", "amount": 100_000_i64, "deleted_at": null },
doc! { "household_id": hid, "to_period_id": to, "kind": "surplus", "amount": 20_000_i64, "deleted_at": null },
])
.await
.unwrap();
let (st, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(st, StatusCode::OK);
assert_eq!(
sum,
json!({
"income_total": 4_800_000,
"fixed_expense_total": 1_545_000,
"savings_goal": 500_000,
"carryover_debt": 100_000,
"carryover_surplus": 20_000,
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
"spendable": 2_675_000,
"budget_total": 0,
"unallocated": 2_675_000
})
);
app.cleanup().await;
}
#[tokio::test]
async fn default_suggestion_splits_spendable() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
let url = format!("/periods/{pid}/budget/suggestion?method=default");
// Gelir yokken öneri yok
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], false);
assert!(sug["message"].as_str().unwrap().contains("Harcanabilir"));
add_item(&app, &s, &pid, "income", 1_234_567).await;
let (_, sug) = app.get(&url, &s).await;
assert_eq!(sug["available"], true);
assert_eq!(
sug["amounts"],
json!({ "needs": 617_283, "wants": 370_370, "culture": 123_457, "unexpected": 123_457 })
);
// Önceki dönem yokken previous/avg3 → veri yok
for m in ["previous", "avg3"] {
let (_, sug) = app
.get(&format!("/periods/{pid}/budget/suggestion?method={m}"), &s)
.await;
assert_eq!(sug["available"], false, "{m}");
}
app.cleanup().await;
}
#[tokio::test]
async fn monthly_budget_is_split_into_weeks_by_days() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
add_item(&app, &s, &pid, "income", 300_000).await;
let url = format!("/periods/{pid}/budget");
// Başta hepsi 0
let (_, b) = app.get(&url, &s).await;
assert_eq!(b.as_array().unwrap().len(), 4);
assert_eq!(week_amounts(group(&b, "needs")), vec![0; 5]);
let (st, b) = app
.put(
&url,
&s,
json!({ "needs": 100_000, "wants": 50_000, "culture": 31, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(needs["monthly"], 100_000);
// Aralık: 6,7,7,7,4 gün → toplam tam 100.000
assert_eq!(
week_amounts(needs),
vec![19_355, 22_581, 22_581, 22_580, 12_903]
);
assert_eq!(week_amounts(group(&b, "culture")), vec![6, 7, 7, 7, 4]);
for g in b.as_array().unwrap() {
assert_eq!(
week_amounts(g).iter().sum::<i64>(),
g["monthly"].as_i64().unwrap()
);
}
// Özet: 150.031 dağıtıldı, harcanabilir 300.000 → kalan 149.969
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(
(sum["budget_total"].as_i64(), sum["unallocated"].as_i64()),
(Some(150_031), Some(149_969))
);
// Fazla dağıtım → kalan negatif (arayüz uyarı gösterir)
app.put(
&url,
&s,
json!({ "needs": 400_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let (_, sum) = app.get(&format!("/periods/{pid}/summary"), &s).await;
assert_eq!(sum["unallocated"], -100_000);
// Negatif tutar → 400
let (st, _) = app
.put(
&url,
&s,
json!({ "needs": -1, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn manual_week_override_redistributes_the_rest() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let pid = open_december(&app, &s, 0).await;
app.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 100_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
let weeks_url = format!("/periods/{pid}/budget/weeks");
// 2. hafta (7–13 Ara) elle 50.000 → kalan 50.000 diğer haftalara (6+7+7+4 = 24 gün)
let (st, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 50_000 }),
)
.await;
assert_eq!(st, StatusCode::OK, "{b}");
let needs = group(&b, "needs");
assert_eq!(
week_amounts(needs),
vec![12_500, 50_000, 14_584, 14_583, 8_333]
);
assert_eq!(week_amounts(needs).iter().sum::<i64>(), 100_000);
let manual: Vec<bool> = needs["weeks"]
.as_array()
.unwrap()
.iter()
.map(|w| w["is_manual"].as_bool().unwrap())
.collect();
assert_eq!(manual, vec![false, true, false, false, false]);
assert_eq!(needs["weekly_over"], false);
// Aylık tutar değişince elle girilen hafta korunur
let (_, b) = app
.put(
&format!("/periods/{pid}/budget"),
&s,
json!({ "needs": 74_000, "wants": 0, "culture": 0, "unexpected": 0 }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs"))[1], 50_000);
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
// Elle girilenler aylığı aşarsa uyarı; diğer haftalar 0
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": 30_000 }),
)
.await;
let needs = group(&b, "needs");
assert_eq!(needs["weekly_over"], true);
assert_eq!(week_amounts(needs), vec![0, 50_000, 30_000, 0, 0]);
// null → otomatiğe dön
app.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-14", "amount": null }),
)
.await;
let (_, b) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": null }),
)
.await;
assert_eq!(week_amounts(group(&b, "needs")).iter().sum::<i64>(), 74_000);
assert!(
group(&b, "needs")["weeks"]
.as_array()
.unwrap()
.iter()
.all(|w| w["is_manual"] == false)
);
// Haftanın ilk günü olmayan tarih → 400
let (st, _) = app
.put(
&weeks_url,
&s,
json!({ "group": "needs", "week_start": "2026-12-08", "amount": 1 }),
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
app.cleanup().await;
}
#[tokio::test]
async fn previous_and_avg3_suggestions_use_actual_spending() {
let app = TestApp::new().await;
let s = app.register("Test", None).await;
let hid = Uuid::parse_str(&s.household_id).unwrap();
// Ekim ve Kasım kapalı, harcamaları var (Faz 5'te defterden gelecek)
let mut closed = Vec::new();
for (start, end) in [("2026-10-01", "2026-11-01"), ("2026-11-01", "2026-12-01")] {
let (_, p) = app
.post(
"/periods",
&s,
json!({ "start_date": start, "end_date": end }),
)
.await;
let id = Uuid::parse_str(p["id"].as_str().unwrap()).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
closed.push(id);
}
let entry = |pid: Uuid, group: &str, amount: i64| {
doc! { "household_id": hid, "period_id": pid, "group": group, "amount": amount, "date": "2026-10-10", "deleted_at": null }
};
app.db
.collection::<Document>("entries")
.insert_many([
entry(closed[0], "needs", 100_000),
entry(closed[0], "wants", 41_000),
entry(closed[1], "needs", 200_000),
entry(closed[1], "needs", 50_000),
entry(closed[1], "culture", 10_000),
])
.await
.unwrap();
let pid = open_december(&app, &s, 0).await;
let (_, prev) = app
.get(
&format!("/periods/{pid}/budget/suggestion?method=previous"),
&s,
)
.await;
assert_eq!(prev["available"], true, "{prev}");
assert_eq!(
prev["amounts"],
json!({ "needs": 250_000, "wants": 0, "culture": 10_000, "unexpected": 0 })
);
// avg3: 2 kapalı dönem var → ikiye bölünür (yuvarlayarak)
let (_, avg) = app
.get(&format!("/periods/{pid}/budget/suggestion?method=avg3"), &s)
.await;
assert_eq!(
avg["amounts"],
json!({ "needs": 175_000, "wants": 20_500, "culture": 5_000, "unexpected": 0 })
);
app.cleanup().await;
}
#[tokio::test]
async fn budget_closed_period_and_isolation() {
let app = TestApp::new().await;
let a = app.register("A", None).await;
let b = app.register("B", None).await;
let pid = open_december(&app, &a, 0).await;
let body = json!({ "needs": 1, "wants": 0, "culture": 0, "unexpected": 0 });
// B erişemez
let checks = [
app.get(&format!("/periods/{pid}/summary"), &b).await.0,
app.get(&format!("/periods/{pid}/budget"), &b).await.0,
app.put(&format!("/periods/{pid}/budget"), &b, body.clone())
.await
.0,
app.put(
&format!("/periods/{pid}/budget/weeks"),
&b,
json!({ "group": "needs", "week_start": "2026-12-07", "amount": 1 }),
)
.await
.0,
app.get(&format!("/periods/{pid}/budget/suggestion"), &b)
.await
.0,
];
assert!(
checks.iter().all(|s| *s == StatusCode::NOT_FOUND),
"{checks:?}"
);
// Kapalı dönem: okuma serbest, yazma 409
let id = Uuid::parse_str(&pid).unwrap();
app.db
.collection::<Document>("periods")
.update_one(doc! { "_id": id }, doc! { "$set": { "status": "closed" } })
.await
.unwrap();
assert_eq!(
app.get(&format!("/periods/{pid}/budget"), &a).await.0,
StatusCode::OK
);
assert_eq!(
app.put(&format!("/periods/{pid}/budget"), &a, body).await.0,
StatusCode::CONFLICT
);
app.cleanup().await;
}