feat(dönem): dönem açma, açık dönem, düzenleme ve haftalar (backend)

- /api/periods: liste, açık dönem, aç, detay, düzenle
- Kurallar: tek açık dönem, çakışma yok, bitiş hariç, en fazla 62 gün, kapalı dönem salt okunur
- Tarih daraltılırken dışarıda harcama kalırsa engellenir
- Haftalar Pazartesi başlangıçlı, kısmi haftalar ayrı (takvim yardımcıları)
- Otomatik dönem adı: "Aralık 2026" / "15 Ara 2026 – 14 Oca 2027"
- Testler: takvim (4), dönem kuralları (2), entegrasyon (5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 02:56:13 +03:00
parent a2b4119f3e
commit b883530f76
16 changed files with 1048 additions and 1 deletions
+174
View File
@@ -0,0 +1,174 @@
//! Takvim hesapları: tarih okuma/yazma, dönem haftaları, Türkçe ay adları.
//!
//! Tarihler veritabanında `"YYYY-MM-DD"` metni olarak tutulur (DATA_MODEL.md §1.3).
//! Dönem bitişi **hariçtir**: `2026-12-01 – 2027-01-01` = 1–31 Aralık.
use time::{Date, Duration, Month};
const MONTHS: [&str; 12] = [
"Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim",
"Kasım", "Aralık",
];
const MONTHS_SHORT: [&str; 12] = [
"Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara",
];
/// `"2026-12-09"` → `Date`. Biçim ya da tarih geçersizse `None` (ör. `2026-02-30`).
pub fn parse_date(s: &str) -> Option<Date> {
let b = s.as_bytes();
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return None;
}
let year: i32 = s[0..4].parse().ok()?;
let month: u8 = s[5..7].parse().ok()?;
let day: u8 = s[8..10].parse().ok()?;
Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
}
/// `Date` → `"2026-12-09"`
pub fn format_date(d: Date) -> String {
format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day())
}
/// Bitişi hariç aralığın gün sayısı.
pub fn days_between(start: Date, end_exclusive: Date) -> i64 {
(end_exclusive - start).whole_days()
}
/// Dönem içindeki bir hafta. Son gün dahildir (`last`), gösterim kolaylığı için.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Week {
/// 1'den başlar
pub index: u32,
pub start: Date,
pub last: Date,
pub days: u32,
}
/// Dönemi Pazartesi başlangıçlı haftalara böler.
/// Baştaki ve sondaki kısmi haftalar ayrı hafta sayılır (KAKEIBO_PROMPT §6).
pub fn weeks(start: Date, end_exclusive: Date) -> Vec<Week> {
let mut out = Vec::new();
let mut cursor = start;
while cursor < end_exclusive {
// Bir sonraki Pazartesi (ya da dönem sonu, hangisi önce gelirse)
let to_monday = 7 - i64::from(cursor.weekday().number_days_from_monday());
let next = (cursor + Duration::days(to_monday)).min(end_exclusive);
out.push(Week {
index: out.len() as u32 + 1,
start: cursor,
last: next - Duration::days(1),
days: days_between(cursor, next) as u32,
});
cursor = next;
}
out
}
/// Kullanıcı ad vermezse dönem adı:
/// - takvim ayının tamamıysa: `"Aralık 2026"`
/// - değilse: `"15 Ara 2026 – 14 Oca 2027"`
pub fn default_period_name(start: Date, end_exclusive: Date) -> String {
let last = end_exclusive - Duration::days(1);
let full_month = start.day() == 1
&& last.month() == start.month()
&& last.year() == start.year()
&& (last + Duration::days(1)).day() == 1;
if full_month {
return format!("{} {}", MONTHS[month_idx(start)], start.year());
}
format!(
"{} {} {} – {} {} {}",
start.day(),
MONTHS_SHORT[month_idx(start)],
start.year(),
last.day(),
MONTHS_SHORT[month_idx(last)],
last.year()
)
}
fn month_idx(d: Date) -> usize {
u8::from(d.month()) as usize - 1
}
#[cfg(test)]
mod tests {
use super::*;
fn d(s: &str) -> Date {
parse_date(s).unwrap()
}
#[test]
fn parse_and_format() {
assert_eq!(format_date(d("2026-12-09")), "2026-12-09");
for bad in [
"2026-02-30",
"2026-13-01",
"26-12-09",
"2026/12/09",
"",
"2026-12-9",
] {
assert!(parse_date(bad).is_none(), "{bad} geçersiz olmalı");
}
assert!(parse_date("2028-02-29").is_some(), "artık yıl");
}
#[test]
fn december_2026_weeks() {
// 1 Aralık 2026 Salı → ilk hafta 6 gün (Sal–Paz), son hafta 4 gün (Pzt–Per)
let w = weeks(d("2026-12-01"), d("2027-01-01"));
let summary: Vec<(String, String, u32)> = w
.iter()
.map(|w| (format_date(w.start), format_date(w.last), w.days))
.collect();
assert_eq!(
summary,
vec![
("2026-12-01".into(), "2026-12-06".into(), 6),
("2026-12-07".into(), "2026-12-13".into(), 7),
("2026-12-14".into(), "2026-12-20".into(), 7),
("2026-12-21".into(), "2026-12-27".into(), 7),
("2026-12-28".into(), "2026-12-31".into(), 4),
]
);
assert_eq!(w.iter().map(|w| w.days).sum::<u32>(), 31);
assert_eq!(w.last().unwrap().index, 5);
}
#[test]
fn weeks_edge_cases() {
// Pazartesi başlayan, tam 2 hafta
let w = weeks(d("2026-11-30"), d("2026-12-14"));
assert_eq!(w.len(), 2);
assert!(w.iter().all(|w| w.days == 7));
// Tek gün (Pazar)
let w = weeks(d("2026-12-06"), d("2026-12-07"));
assert_eq!((w.len(), w[0].days), (1, 1));
// Boş aralık
assert!(weeks(d("2026-12-07"), d("2026-12-07")).is_empty());
}
#[test]
fn period_names() {
assert_eq!(
default_period_name(d("2026-12-01"), d("2027-01-01")),
"Aralık 2026"
);
assert_eq!(
default_period_name(d("2027-02-01"), d("2027-03-01")),
"Şubat 2027"
);
assert_eq!(
default_period_name(d("2026-12-15"), d("2027-01-15")),
"15 Ara 2026 – 14 Oca 2027"
);
// Ayın 1'inde başlayıp ay ortasında biten
assert_eq!(
default_period_name(d("2026-12-01"), d("2026-12-16")),
"1 Ara 2026 – 15 Ara 2026"
);
}
}