7f3fde074c
- /api/periods/{id}/plan-items: listele, ekle; /api/plan-items/{id}: düzenle, sil
- Önceki dönemden kopyala: göreli gün korunur, dönem sonuna kırpılır, aynı kalem tekrar eklenmez
- /api/periods/{id}/notes (planning | review), /api/notes/{id}: Markdown notlar
- Kapalı dönemde değişiklik yok; silme geri alınabilir şekilde işaretlenir
- Entegrasyon testleri (5): kurallar, kopyalama, salt okunur dönem, hane izolasyonu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
310 lines
9.8 KiB
Rust
310 lines
9.8 KiB
Rust
//! 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)
|
||
}
|
||
|
||
/// Yazma işlemleri için: dönem bu haneye ait ve **açık** olmalı.
|
||
/// Plan kalemleri, notlar ve (Faz 5) harcamalar bunu kullanır.
|
||
pub async fn get_open(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Period> {
|
||
let p = get(db, household_id, id).await?;
|
||
if p.status == PeriodStatus::Closed {
|
||
return Err(closed());
|
||
}
|
||
Ok(p)
|
||
}
|
||
|
||
pub fn closed() -> AppError {
|
||
AppError::Conflict(
|
||
"Kapalı dönem değiştirilemez. Düzeltme için hane sahibi dönemi yeniden açmalı.".into(),
|
||
)
|
||
}
|
||
|
||
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(closed());
|
||
}
|
||
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))
|
||
}
|
||
|
||
pub 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());
|
||
}
|
||
}
|