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:
@@ -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(¤t.start_date);
|
||||
let end_s = ch.end_date.as_deref().unwrap_or(¤t.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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user