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
+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);
}
}