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"
);
}
}
+2
View File
@@ -1,6 +1,8 @@
//! İş mantığı. Route'lar ince kalır; asıl işi buradaki fonksiyonlar yapar.
pub mod account;
pub mod calendar;
pub mod catalog;
pub mod invite;
pub mod period;
pub mod seed;
+295
View File
@@ -0,0 +1,295 @@
//! Dönem iş mantığı (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.4).
//!
//! Kurallar:
//! - Hanede aynı anda en fazla **bir açık** dönem (servis kontrolü + kısmi benzersiz indeks).
//! - Dönemler çakışmaz. Bitiş tarihi hariçtir.
//! - Kapalı dönem salt okunur (yeniden açma: Faz 6, yalnızca sahip).
//! - Tarih aralığı daraltılırken dışarıda harcama kalıyorsa engellenir.
use mongodb::{
Database,
bson::{Bson, Document, Uuid, doc},
options::ReturnDocument,
};
use time::Date;
use crate::{
db::{col, is_duplicate_key},
error::{AppError, AppResult},
models::{
common::{new_meta, now, scope},
period::{Period, PeriodStatus},
},
services::calendar::{days_between, default_period_name, format_date, parse_date},
};
/// En uzun dönem (gün). Aylık defter için ~2 ay pay bırakır.
pub const MAX_DAYS: i64 = 62;
/// Birikim hedefi üst sınırı (kuruş) — yazım hatalarını yakalamak için: 100 milyar ₺
pub const MAX_AMOUNT: i64 = 10_000_000_000_000;
pub struct NewPeriod {
pub start_date: String,
pub end_date: String,
pub name: Option<String>,
pub savings_goal: Option<i64>,
}
#[derive(Default)]
pub struct PeriodChanges {
pub name: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub savings_goal: Option<i64>,
pub version: Option<i32>,
}
fn coll(db: &Database) -> mongodb::Collection<Period> {
db.collection(col::PERIODS)
}
pub async fn current(db: &Database, household_id: Uuid) -> AppResult<Option<Period>> {
let mut f = scope(household_id);
f.insert("status", PeriodStatus::Open.as_str());
Ok(coll(db).find_one(f).await?)
}
/// En yeni dönem başta.
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<Period>> {
let mut cursor = coll(db)
.find(scope(household_id))
.sort(doc! { "start_date": -1 })
.await?;
let mut out = Vec::new();
while cursor.advance().await? {
out.push(cursor.deserialize_current()?);
}
Ok(out)
}
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
let mut f = scope(household_id);
f.insert("_id", id);
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
}
pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResult<Period> {
let (start, end) = validate_range(&p.start_date, &p.end_date)?;
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
let name = clean_name(p.name.as_deref(), start, end)?;
if let Some(open) = current(db, household_id).await? {
return Err(AppError::Conflict(format!(
"Zaten açık bir dönem var ({}). Yeni dönem açmadan önce onu kapatın.",
open.name
)));
}
ensure_no_overlap(db, household_id, start, end, None).await?;
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
let mut prev_f = scope(household_id);
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
let previous = coll(db)
.find_one(prev_f)
.sort(doc! { "start_date": -1 })
.await?;
let mut d = new_meta(household_id);
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
d.insert("name", name);
d.insert("start_date", format_date(start));
d.insert("end_date", format_date(end));
d.insert("status", PeriodStatus::Open.as_str());
d.insert("savings_goal", savings_goal);
d.insert("reflection_md", "");
d.insert(
"previous_period_id",
previous.map_or(Bson::Null, |p| p.id.into()),
);
d.insert("closed_at", Bson::Null);
d.insert("closed_by", Bson::Null);
d.insert("reopened_at", Bson::Null);
match db.collection::<Document>(col::PERIODS).insert_one(d).await {
Ok(_) => {}
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
Err(e) if is_duplicate_key(&e) => {
return Err(AppError::Conflict(
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
));
}
Err(e) => return Err(e.into()),
}
coll(db)
.find_one(doc! { "_id": id })
.await?
.ok_or_else(|| AppError::Internal("açılan dönem okunamadı".into()))
}
pub async fn update(
db: &Database,
household_id: Uuid,
id: Uuid,
ch: PeriodChanges,
) -> AppResult<Period> {
let current = get(db, household_id, id).await?;
if current.status == PeriodStatus::Closed {
return Err(AppError::Conflict(
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
));
}
if ch.version.is_some_and(|v| v != current.version) {
return Err(stale());
}
let mut set = doc! { "updated_at": now() };
let start_s = ch.start_date.as_deref().unwrap_or(&current.start_date);
let end_s = ch.end_date.as_deref().unwrap_or(&current.end_date);
let (start, end) = validate_range(start_s, end_s)?;
let dates_changed =
format_date(start) != current.start_date || format_date(end) != current.end_date;
if dates_changed {
ensure_no_overlap(db, household_id, start, end, Some(id)).await?;
ensure_entries_inside(db, household_id, id, start, end).await?;
set.insert("start_date", format_date(start));
set.insert("end_date", format_date(end));
}
if let Some(name) = ch.name {
set.insert("name", clean_name(Some(&name), start, end)?);
}
if let Some(goal) = ch.savings_goal {
set.insert("savings_goal", validate_amount(goal)?);
}
let mut f = scope(household_id);
f.insert("_id", id);
f.insert("version", current.version);
coll(db)
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
.return_document(ReturnDocument::After)
.await?
.ok_or_else(stale)
}
// ---------- doğrulamalar ----------
fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
let bad = |m: &str| AppError::BadRequest(m.into());
let start = parse_date(start).ok_or_else(|| bad("Başlangıç tarihi geçersiz"))?;
let end = parse_date(end).ok_or_else(|| bad("Bitiş tarihi geçersiz"))?;
let days = days_between(start, end);
if days < 1 {
return Err(bad("Bitiş tarihi başlangıçtan sonra olmalı"));
}
if days > MAX_DAYS {
return Err(AppError::BadRequest(format!(
"Dönem en fazla {MAX_DAYS} gün olabilir"
)));
}
Ok((start, end))
}
fn validate_amount(v: i64) -> AppResult<i64> {
if !(0..=MAX_AMOUNT).contains(&v) {
return Err(AppError::BadRequest("Tutar geçersiz".into()));
}
Ok(v)
}
fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult<String> {
match name.map(str::trim) {
None | Some("") => Ok(default_period_name(start, end)),
Some(n) if n.chars().count() > 60 => Err(AppError::BadRequest(
"Dönem adı en fazla 60 karakter olabilir".into(),
)),
Some(n) => Ok(n.to_string()),
}
}
/// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç`
/// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir).
async fn ensure_no_overlap(
db: &Database,
household_id: Uuid,
start: Date,
end: Date,
exclude: Option<Uuid>,
) -> AppResult<()> {
let mut f = scope(household_id);
f.insert("start_date", doc! { "$lt": format_date(end) });
f.insert("end_date", doc! { "$gt": format_date(start) });
if let Some(id) = exclude {
f.insert("_id", doc! { "$ne": id });
}
if let Some(other) = coll(db).find_one(f).await? {
return Err(AppError::Conflict(format!(
"Bu tarihler \"{}\" dönemiyle çakışıyor",
other.name
)));
}
Ok(())
}
/// Dönem daraltılırken dışarıda harcama kalmasın.
async fn ensure_entries_inside(
db: &Database,
household_id: Uuid,
period_id: Uuid,
start: Date,
end: Date,
) -> AppResult<()> {
let mut f = scope(household_id);
f.insert("period_id", period_id);
f.insert(
"$or",
vec![
doc! { "date": { "$lt": format_date(start) } },
doc! { "date": { "$gte": format_date(end) } },
],
);
let outside = db
.collection::<Document>(col::ENTRIES)
.count_documents(f)
.await?;
if outside > 0 {
return Err(AppError::Conflict(format!(
"Yeni tarih aralığının dışında kalan {outside} harcama var. Önce onları taşıyın ya da silin."
)));
}
Ok(())
}
fn stale() -> AppError {
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn range_rules() {
assert!(validate_range("2026-12-01", "2027-01-01").is_ok());
assert!(
validate_range("2026-12-01", "2026-12-01").is_err(),
"boş aralık"
);
assert!(validate_range("2026-12-10", "2026-12-01").is_err(), "ters");
assert!(
validate_range("2026-12-01", "2027-02-02").is_err(),
"63 gün"
);
assert!(validate_range("2026-12-01", "2027-02-01").is_ok(), "62 gün");
assert!(validate_range("01.12.2026", "2027-01-01").is_err(), "biçim");
}
#[test]
fn amount_rules() {
assert!(validate_amount(0).is_ok());
assert!(validate_amount(-1).is_err());
assert!(validate_amount(MAX_AMOUNT + 1).is_err());
}
}