//! Sabit gelir / gider kalemleri (KAKEIBO_PROMPT §8.3). //! //! - Tutar kuruş, > 0. Tarih isteğe bağlı; verilirse dönem içinde olmalı. //! - Kapalı dönemde yazma yok. //! - "Önceki dönemden kopyala": kalemler göreli günüyle taşınır (5 Ara → 5 Oca); //! aynı tür + ad zaten varsa atlanır (iki kez basınca kopya oluşmaz). use mongodb::{ Database, bson::{Bson, Document, Uuid, doc}, options::ReturnDocument, }; use time::Duration; use crate::{ db::col, error::{AppError, AppResult}, models::{ common::{new_meta, now, scope}, period::Period, plan_item::{PlanItem, PlanKind}, }, services::{ calendar::{format_date, parse_date}, period, }, }; pub struct NewItem { pub kind: PlanKind, pub name: String, pub amount: i64, pub date: Option, } #[derive(Default)] pub struct ItemChanges { pub name: Option, pub amount: Option, /// `Some("")` → tarihi kaldır pub date: Option, pub version: Option, } fn coll(db: &Database) -> mongodb::Collection { db.collection(col::PLAN_ITEMS) } /// Dönemin kalemleri: önce gelirler, sonra giderler; her biri sıralı. pub async fn list(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult> { period::get(db, household_id, period_id).await?; // dönem bu haneye ait mi? let mut f = scope(household_id); f.insert("period_id", period_id); let mut cursor = coll(db) .find(f) .sort(doc! { "kind": -1, "sort_order": 1 }) // "income" > "expense" → gelir önce .await?; let mut out = Vec::new(); while cursor.advance().await? { out.push(cursor.deserialize_current()?); } Ok(out) } pub async fn create( db: &Database, household_id: Uuid, period_id: Uuid, item: NewItem, ) -> AppResult { let p = period::get_open(db, household_id, period_id).await?; let name = clean_name(&item.name)?; let amount = clean_amount(item.amount)?; let date = clean_date(item.date.as_deref(), &p)?; let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?; insert( db, household_id, period_id, item.kind, name, amount, date, sort_order, None, ) .await } pub async fn update( db: &Database, household_id: Uuid, id: Uuid, ch: ItemChanges, ) -> AppResult { let current = get(db, household_id, id).await?; let p = period::get_open(db, household_id, current.period_id).await?; if ch.version.is_some_and(|v| v != current.version) { return Err(stale()); } let mut set = doc! { "updated_at": now() }; if let Some(name) = ch.name { set.insert("name", clean_name(&name)?); } if let Some(amount) = ch.amount { set.insert("amount", clean_amount(amount)?); } if let Some(date) = ch.date { set.insert( "date", clean_date(Some(&date), &p)?.map_or(Bson::Null, Bson::String), ); } 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) } /// Soft delete (geçmiş/eşitleme için kayıt kalır). pub async fn delete(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> { let current = get(db, household_id, id).await?; period::get_open(db, household_id, current.period_id).await?; let ts = now(); let mut f = scope(household_id); f.insert("_id", id); db.collection::(col::PLAN_ITEMS) .update_one( f, doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } }, ) .await?; Ok(()) } /// Önceki dönemin kalemlerini bu döneme kopyalar. Eklenen kalem sayısını döner. pub async fn copy_previous(db: &Database, household_id: Uuid, period_id: Uuid) -> AppResult { let target = period::get_open(db, household_id, period_id).await?; // Bir önceki dönem: başlangıcı bu dönemden önce olan en yeni dönem let mut pf = scope(household_id); pf.insert("start_date", doc! { "$lt": &target.start_date }); let source = db .collection::(col::PERIODS) .find_one(pf) .sort(doc! { "start_date": -1 }) .await? .ok_or_else(|| AppError::BadRequest("Kopyalanacak önceki dönem yok".into()))?; let existing = list(db, household_id, period_id).await?; let src_items = list(db, household_id, source.id).await?; let src_start = parse_date(&source.start_date).ok_or_else(bad_period)?; let dst_start = parse_date(&target.start_date).ok_or_else(bad_period)?; let dst_last = parse_date(&target.end_date).ok_or_else(bad_period)? - Duration::days(1); let mut added = 0; for item in src_items { let duplicate = existing .iter() .any(|e| e.kind == item.kind && e.name.to_lowercase() == item.name.to_lowercase()); if duplicate { continue; } // Göreli gün korunur, dönem dışına taşarsa son güne çekilir let date = item.date.as_deref().and_then(parse_date).map(|d| { let shifted = dst_start + (d - src_start); format_date(shifted.clamp(dst_start, dst_last)) }); let sort_order = next_sort_order(db, household_id, period_id, item.kind).await?; insert( db, household_id, period_id, item.kind, item.name, item.amount, date, sort_order, Some(item.id), ) .await?; added += 1; } Ok(added) } // ---------- yardımcılar ---------- async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult { let mut f = scope(household_id); f.insert("_id", id); coll(db).find_one(f).await?.ok_or(AppError::NotFound) } #[allow(clippy::too_many_arguments)] async fn insert( db: &Database, household_id: Uuid, period_id: Uuid, kind: PlanKind, name: String, amount: i64, date: Option, sort_order: i32, copied_from: Option, ) -> AppResult { let mut d = new_meta(household_id); let id = d.get("_id").cloned().unwrap_or(Bson::Null); d.insert("period_id", period_id); d.insert("kind", kind.as_str()); d.insert("name", name); d.insert("amount", amount); d.insert("date", date.map_or(Bson::Null, Bson::String)); d.insert("sort_order", sort_order); d.insert("copied_from_id", copied_from.map_or(Bson::Null, Bson::from)); db.collection::(col::PLAN_ITEMS) .insert_one(d) .await?; coll(db) .find_one(doc! { "_id": id }) .await? .ok_or_else(|| AppError::Internal("eklenen kalem okunamadı".into())) } async fn next_sort_order( db: &Database, household_id: Uuid, period_id: Uuid, kind: PlanKind, ) -> AppResult { let mut f = scope(household_id); f.insert("period_id", period_id); f.insert("kind", kind.as_str()); let last = coll(db).find_one(f).sort(doc! { "sort_order": -1 }).await?; Ok(last.map_or(10, |i| i.sort_order + 10)) } fn clean_name(name: &str) -> AppResult { let n = name.trim(); match n.chars().count() { 0 => Err(AppError::BadRequest("Kalem adı boş olamaz".into())), 1..=60 => Ok(n.to_string()), _ => Err(AppError::BadRequest( "Kalem adı en fazla 60 karakter olabilir".into(), )), } } fn clean_amount(v: i64) -> AppResult { if v <= 0 { return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into())); } period::validate_amount(v) } /// Boş → `None`; doluysa geçerli ve dönem içinde olmalı. fn clean_date(date: Option<&str>, p: &Period) -> AppResult> { match date.map(str::trim) { None | Some("") => Ok(None), Some(s) => { parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?; if s < p.start_date.as_str() || s >= p.end_date.as_str() { return Err(AppError::BadRequest("Tarih dönem içinde olmalı".into())); } Ok(Some(s.to_string())) } } } fn stale() -> AppError { AppError::Conflict("Kalem başka biri tarafından değiştirildi, sayfayı yenileyin".into()) } fn bad_period() -> AppError { AppError::Internal("dönem tarihi okunamadı".into()) }