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>
284 lines
8.6 KiB
Rust
284 lines
8.6 KiB
Rust
//! 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<String>,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct ItemChanges {
|
||
pub name: Option<String>,
|
||
pub amount: Option<i64>,
|
||
/// `Some("")` → tarihi kaldır
|
||
pub date: Option<String>,
|
||
pub version: Option<i32>,
|
||
}
|
||
|
||
fn coll(db: &Database) -> mongodb::Collection<PlanItem> {
|
||
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<Vec<PlanItem>> {
|
||
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<PlanItem> {
|
||
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<PlanItem> {
|
||
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::<Document>(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<usize> {
|
||
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::<Period>(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<PlanItem> {
|
||
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<String>,
|
||
sort_order: i32,
|
||
copied_from: Option<Uuid>,
|
||
) -> AppResult<PlanItem> {
|
||
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::<Document>(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<i32> {
|
||
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<String> {
|
||
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<i64> {
|
||
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<Option<String>> {
|
||
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())
|
||
}
|