feat(plan): sabit gelir/gider kalemleri ve dönem notları (backend)
- /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>
This commit is contained in:
@@ -170,3 +170,63 @@ Content-Type: application/json
|
||||
"savings_goal": 750000,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
# ─────────────────────────── Plan kalemleri (sabit gelir / gider) ───────────────────────────
|
||||
# kind: "income" | "expense" · amount kuruş (> 0) · date isteğe bağlı, dönem içinde
|
||||
|
||||
### Dönemin kalemleri (önce gelirler)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
|
||||
|
||||
### Kalem ekle
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"kind": "expense",
|
||||
"name": "Kira",
|
||||
"amount": 1500000,
|
||||
"date": "2026-12-05"
|
||||
}
|
||||
|
||||
### Önceki dönemden kopyala (aynı tür + ad varsa atlanır; tarihler göreli günle taşınır)
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/plan-items/copy-previous
|
||||
|
||||
### Kalemi düzenle ("date": "" → tarihi kaldır)
|
||||
PATCH {{baseUrl}}/plan-items/BURAYA-KALEM-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"amount": 1600000,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
### Kalemi sil
|
||||
DELETE {{baseUrl}}/plan-items/BURAYA-KALEM-ID
|
||||
|
||||
# ─────────────────────────── Notlar ───────────────────────────
|
||||
# context: "planning" (Plan sayfası) | "review" (Ay sonu değerlendirmesi) · body_md: Markdown
|
||||
|
||||
### Notlar (?context=planning ile filtre)
|
||||
GET {{baseUrl}}/periods/BURAYA-DONEM-ID/notes?context=planning
|
||||
|
||||
### Not ekle
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/notes
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"context": "planning",
|
||||
"title": "Tatil birikimi",
|
||||
"body_md": "- Ocak'ta **5.000 ₺** ayır\n- Dışarıda yemeği azalt"
|
||||
}
|
||||
|
||||
### Notu düzenle
|
||||
PATCH {{baseUrl}}/notes/BURAYA-NOT-ID
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"body_md": "Güncel metin",
|
||||
"version": 1
|
||||
}
|
||||
|
||||
### Notu sil
|
||||
DELETE {{baseUrl}}/notes/BURAYA-NOT-ID
|
||||
|
||||
@@ -5,5 +5,7 @@ pub mod common;
|
||||
pub mod group;
|
||||
pub mod household;
|
||||
pub mod invite;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan_item;
|
||||
pub mod user;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `notes` koleksiyonu (DATA_MODEL.md §3.11). Notlar döneme özeldir.
|
||||
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// Notun bağlı olduğu sayfa
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, rename = "NoteContext")]
|
||||
pub enum NoteContext {
|
||||
/// Aylık planlama
|
||||
Planning,
|
||||
/// Ay sonu değerlendirmesi
|
||||
Review,
|
||||
}
|
||||
|
||||
impl NoteContext {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
NoteContext::Planning => "planning",
|
||||
NoteContext::Review => "review",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Note {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub period_id: Uuid,
|
||||
pub context: NoteContext,
|
||||
pub title: String,
|
||||
pub body_md: String,
|
||||
pub created_by: Uuid,
|
||||
pub updated_at: DateTime,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! `plan_items` koleksiyonu — sabit gelir / gider kalemleri (DATA_MODEL.md §3.7).
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, rename = "PlanItemKind")]
|
||||
pub enum PlanKind {
|
||||
Income,
|
||||
Expense,
|
||||
}
|
||||
|
||||
impl PlanKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
PlanKind::Income => "income",
|
||||
PlanKind::Expense => "expense",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PlanItem {
|
||||
#[serde(rename = "_id")]
|
||||
pub id: Uuid,
|
||||
pub period_id: Uuid,
|
||||
pub kind: PlanKind,
|
||||
pub name: String,
|
||||
/// Kuruş
|
||||
pub amount: i64,
|
||||
/// `"YYYY-MM-DD"` (isteğe bağlı)
|
||||
#[serde(default)]
|
||||
pub date: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub version: i32,
|
||||
}
|
||||
@@ -4,7 +4,9 @@ mod auth;
|
||||
mod catalog;
|
||||
mod health;
|
||||
mod household;
|
||||
mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod spa;
|
||||
|
||||
use axum::{
|
||||
@@ -63,6 +65,17 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/periods", get(period::list).post(period::create))
|
||||
.route("/periods/current", get(period::current))
|
||||
.route("/periods/{id}", get(period::get).patch(period::update))
|
||||
.route(
|
||||
"/periods/{id}/plan-items",
|
||||
get(plan::list).post(plan::create),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/plan-items/copy-previous",
|
||||
post(plan::copy_previous),
|
||||
)
|
||||
.route("/plan-items/{id}", patch(plan::update).delete(plan::delete))
|
||||
.route("/periods/{id}/notes", get(note::list).post(note::create))
|
||||
.route("/notes/{id}", patch(note::update).delete(note::delete))
|
||||
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||
.fallback(|| async { AppError::NotFound });
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Dönem notları:
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/notes?context=planning` | notlar (sayfa filtresi isteğe bağlı) |
|
||||
//! | POST | `/api/periods/{id}/notes` | not ekle |
|
||||
//! | PATCH | `/api/notes/{id}` | düzenle |
|
||||
//! | DELETE | `/api/notes/{id}` | sil |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::note::{Note, NoteContext},
|
||||
services::note,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListQuery {
|
||||
context: Option<NoteContext>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "NoteCreateRequest")]
|
||||
pub struct CreateRequest {
|
||||
context: NoteContext,
|
||||
title: String,
|
||||
/// Markdown
|
||||
#[serde(default)]
|
||||
body_md: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "NoteUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
title: Option<String>,
|
||||
body_md: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Note")]
|
||||
pub struct NoteDto {
|
||||
id: String,
|
||||
context: NoteContext,
|
||||
title: String,
|
||||
body_md: String,
|
||||
created_by: String,
|
||||
/// ISO 8601
|
||||
updated_at: String,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
impl From<Note> for NoteDto {
|
||||
fn from(n: Note) -> Self {
|
||||
Self {
|
||||
id: n.id.to_string(),
|
||||
context: n.context,
|
||||
title: n.title,
|
||||
body_md: n.body_md,
|
||||
created_by: n.created_by.to_string(),
|
||||
updated_at: n.updated_at.try_to_rfc3339_string().unwrap_or_default(),
|
||||
version: n.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> AppResult<Json<Vec<NoteDto>>> {
|
||||
let notes = note::list(&s.db, auth.household_id, parse_id(&period_id)?, q.context).await?;
|
||||
Ok(Json(notes.into_iter().map(NoteDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<NoteDto>)> {
|
||||
let n = note::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
parse_id(&period_id)?,
|
||||
req.context,
|
||||
&req.title,
|
||||
&req.body_md,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(n.into())))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<NoteDto>> {
|
||||
let n = note::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
parse_id(&id)?,
|
||||
req.title.as_deref(),
|
||||
req.body_md.as_deref(),
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(n.into()))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
note::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Sabit gelir / gider kalemleri:
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/plan-items` | dönemin kalemleri |
|
||||
//! | POST | `/api/periods/{id}/plan-items` | kalem ekle |
|
||||
//! | POST | `/api/periods/{id}/plan-items/copy-previous` | önceki dönemden kopyala |
|
||||
//! | PATCH | `/api/plan-items/{id}` | düzenle |
|
||||
//! | DELETE | `/api/plan-items/{id}` | sil |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
models::plan_item::{PlanItem, PlanKind},
|
||||
services::plan::{self, ItemChanges, NewItem},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PlanItemCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
kind: PlanKind,
|
||||
name: String,
|
||||
/// Kuruş, > 0
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
/// `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı)
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PlanItemUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
name: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
amount: Option<i64>,
|
||||
/// Boş metin → tarihi kaldır
|
||||
date: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PlanItem")]
|
||||
pub struct PlanItemDto {
|
||||
id: String,
|
||||
kind: PlanKind,
|
||||
name: String,
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
date: Option<String>,
|
||||
sort_order: i32,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CopyPreviousResult {
|
||||
/// Eklenen kalem sayısı (zaten var olanlar atlanır)
|
||||
added: usize,
|
||||
}
|
||||
|
||||
impl From<PlanItem> for PlanItemDto {
|
||||
fn from(i: PlanItem) -> Self {
|
||||
Self {
|
||||
id: i.id.to_string(),
|
||||
kind: i.kind,
|
||||
name: i.name,
|
||||
amount: i.amount,
|
||||
date: i.date,
|
||||
sort_order: i.sort_order,
|
||||
version: i.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
) -> AppResult<Json<Vec<PlanItemDto>>> {
|
||||
let items = plan::list(&s.db, auth.household_id, parse_id(&period_id)?).await?;
|
||||
Ok(Json(items.into_iter().map(PlanItemDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<PlanItemDto>)> {
|
||||
let item = plan::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
parse_id(&period_id)?,
|
||||
NewItem {
|
||||
kind: req.kind,
|
||||
name: req.name,
|
||||
amount: req.amount,
|
||||
date: req.date,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(item.into())))
|
||||
}
|
||||
|
||||
pub async fn copy_previous(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(period_id): Path<String>,
|
||||
) -> AppResult<Json<CopyPreviousResult>> {
|
||||
let added = plan::copy_previous(&s.db, auth.household_id, parse_id(&period_id)?).await?;
|
||||
Ok(Json(CopyPreviousResult { added }))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<PlanItemDto>> {
|
||||
let item = plan::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
parse_id(&id)?,
|
||||
ItemChanges {
|
||||
name: req.name,
|
||||
amount: req.amount,
|
||||
date: req.date,
|
||||
version: req.version,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(item.into()))
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
plan::delete(&s.db, auth.household_id, parse_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -4,5 +4,7 @@ pub mod account;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod invite;
|
||||
pub mod note;
|
||||
pub mod period;
|
||||
pub mod plan;
|
||||
pub mod seed;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Dönem notları (KAKEIBO_PROMPT §8.6): başlık + Markdown metin, `planning` ya da `review`.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
note::{Note, NoteContext},
|
||||
},
|
||||
services::period,
|
||||
};
|
||||
|
||||
pub const MAX_BODY: usize = 20_000;
|
||||
|
||||
fn coll(db: &Database) -> mongodb::Collection<Note> {
|
||||
db.collection(col::NOTES)
|
||||
}
|
||||
|
||||
/// Dönemin notları (isteğe bağlı sayfa filtresiyle), en yeni değişen başta.
|
||||
pub async fn list(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
period_id: Uuid,
|
||||
context: Option<NoteContext>,
|
||||
) -> AppResult<Vec<Note>> {
|
||||
period::get(db, household_id, period_id).await?;
|
||||
let mut f = scope(household_id);
|
||||
f.insert("period_id", period_id);
|
||||
if let Some(c) = context {
|
||||
f.insert("context", c.as_str());
|
||||
}
|
||||
let mut cursor = coll(db).find(f).sort(doc! { "updated_at": -1 }).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,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
context: NoteContext,
|
||||
title: &str,
|
||||
body_md: &str,
|
||||
) -> AppResult<Note> {
|
||||
period::get_open(db, household_id, period_id).await?;
|
||||
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("context", context.as_str());
|
||||
d.insert("title", clean_title(title)?);
|
||||
d.insert("body_md", clean_body(body_md)?);
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::NOTES).insert_one(d).await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen not okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
title: Option<&str>,
|
||||
body_md: Option<&str>,
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Note> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
period::get_open(db, household_id, current.period_id).await?;
|
||||
if version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(t) = title {
|
||||
set.insert("title", clean_title(t)?);
|
||||
}
|
||||
if let Some(b) = body_md {
|
||||
set.insert("body_md", clean_body(b)?);
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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::NOTES)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Note> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
coll(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
fn clean_title(t: &str) -> AppResult<String> {
|
||||
let t = t.trim();
|
||||
match t.chars().count() {
|
||||
0 => Err(AppError::BadRequest("Not başlığı boş olamaz".into())),
|
||||
1..=120 => Ok(t.to_string()),
|
||||
_ => Err(AppError::BadRequest(
|
||||
"Not başlığı en fazla 120 karakter olabilir".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn clean_body(b: &str) -> AppResult<String> {
|
||||
if b.chars().count() > MAX_BODY {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Not metni en fazla {MAX_BODY} karakter olabilir"
|
||||
)));
|
||||
}
|
||||
Ok(b.to_string())
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
AppError::Conflict("Not başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
@@ -73,6 +73,22 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Perio
|
||||
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))?;
|
||||
@@ -135,9 +151,7 @@ pub async fn update(
|
||||
) -> 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(),
|
||||
));
|
||||
return Err(closed());
|
||||
}
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
@@ -192,7 +206,7 @@ fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
|
||||
Ok((start, end))
|
||||
}
|
||||
|
||||
fn validate_amount(v: i64) -> AppResult<i64> {
|
||||
pub fn validate_amount(v: i64) -> AppResult<i64> {
|
||||
if !(0..=MAX_AMOUNT).contains(&v) {
|
||||
return Err(AppError::BadRequest("Tutar geçersiz".into()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Plan kalemleri ve notlar.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use mongodb::bson::{Document, doc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn open_period(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn close_period(app: &TestApp, id: &str) {
|
||||
let uuid = mongodb::bson::Uuid::parse_str(id).unwrap();
|
||||
app.db
|
||||
.collection::<Document>("periods")
|
||||
.update_one(
|
||||
doc! { "_id": uuid },
|
||||
doc! { "$set": { "status": "closed" } },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn names(list: &Value) -> Vec<String> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
format!(
|
||||
"{}:{}",
|
||||
i["kind"].as_str().unwrap(),
|
||||
i["name"].as_str().unwrap()
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_crud_and_validation() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let items = format!("/periods/{pid}/plan-items");
|
||||
|
||||
// Gider önce eklense de liste gelirle başlar
|
||||
let (st, rent) = app
|
||||
.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{rent}");
|
||||
app.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "İnternet", "amount": 45_000 }),
|
||||
)
|
||||
.await;
|
||||
let (_, list) = app.get(&items, &s).await;
|
||||
assert_eq!(
|
||||
names(&list),
|
||||
vec!["income:Maaş", "expense:Kira", "expense:İnternet"]
|
||||
);
|
||||
assert_eq!(list[0]["date"], Value::Null);
|
||||
|
||||
// Doğrulamalar
|
||||
for bad in [
|
||||
json!({ "kind": "expense", "name": "X", "amount": 0 }),
|
||||
json!({ "kind": "expense", "name": " ", "amount": 100 }),
|
||||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "2027-01-01" }), // dönem dışı (bitiş hariç)
|
||||
json!({ "kind": "expense", "name": "X", "amount": 100, "date": "05.12.2026" }),
|
||||
json!({ "kind": "gift", "name": "X", "amount": 100 }),
|
||||
] {
|
||||
let (st, _) = app.post(&items, &s, bad.clone()).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{bad}");
|
||||
}
|
||||
|
||||
// Düzenle: tutar + tarihi kaldır; eski sürüm → 409
|
||||
let rent_path = format!("/plan-items/{}", rent["id"].as_str().unwrap());
|
||||
let (st, upd) = app
|
||||
.patch(
|
||||
&rent_path,
|
||||
&s,
|
||||
json!({ "amount": 1_600_000, "date": "", "version": 1 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{upd}");
|
||||
assert_eq!(
|
||||
(upd["amount"].as_i64(), &upd["date"]),
|
||||
(Some(1_600_000), &Value::Null)
|
||||
);
|
||||
let (st, _) = app
|
||||
.patch(&rent_path, &s, json!({ "name": "Y", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT);
|
||||
|
||||
// Sil → listeden çıkar
|
||||
assert_eq!(app.delete(&rent_path, &s).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, list) = app.get(&items, &s).await;
|
||||
assert_eq!(names(&list), vec!["income:Maaş", "expense:İnternet"]);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn copy_previous_shifts_dates_and_skips_duplicates() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let dec = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let dec_items = format!("/periods/{dec}/plan-items");
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000, "date": "2026-12-15" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-12-05" }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Aidat", "amount": 80_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&dec_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Fatura", "amount": 90_000, "date": "2026-12-31" }),
|
||||
)
|
||||
.await;
|
||||
close_period(&app, &dec).await;
|
||||
|
||||
let jan = open_period(&app, &s, "2027-01-01", "2027-02-01").await;
|
||||
let jan_items = format!("/periods/{jan}/plan-items");
|
||||
// Kullanıcı kirayı elle zaten eklemiş (farklı tutarla) → kopyada atlanmalı
|
||||
app.post(
|
||||
&jan_items,
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "kira", "amount": 1_700_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (st, res) = app
|
||||
.post(&format!("{jan_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{res}");
|
||||
assert_eq!(res["added"], 3);
|
||||
|
||||
let (_, list) = app.get(&jan_items, &s).await;
|
||||
let by_name = |n: &str| {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["name"] == n)
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(by_name("Maaş")["date"], "2027-01-15", "göreli gün korunur");
|
||||
assert_eq!(by_name("Aidat")["date"], Value::Null);
|
||||
assert_eq!(by_name("kira")["amount"], 1_700_000, "elle eklenen korunur");
|
||||
|
||||
assert_eq!(by_name("Fatura")["date"], "2027-01-31");
|
||||
|
||||
// Ocak → Şubat (28 gün): 31 Ocak'taki fatura dönem dışına taşar → son güne (28 Şubat) çekilir
|
||||
close_period(&app, &jan).await;
|
||||
let feb = open_period(&app, &s, "2027-02-01", "2027-03-01").await;
|
||||
let feb_items = format!("/periods/{feb}/plan-items");
|
||||
app.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
let (_, feb_list) = app.get(&feb_items, &s).await;
|
||||
let fatura = feb_list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["name"] == "Fatura")
|
||||
.unwrap();
|
||||
assert_eq!(fatura["date"], "2027-02-28");
|
||||
|
||||
// İkinci kez → hiçbir şey eklenmez (Ocak kapalı olduğu için Şubat'ta deniyoruz)
|
||||
let (_, res) = app
|
||||
.post(&format!("{feb_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(res["added"], 0);
|
||||
|
||||
// İlk dönemde önceki yok → 400
|
||||
let (st, _) = app
|
||||
.post(&format!("{dec_items}/copy-previous"), &s, json!({}))
|
||||
.await;
|
||||
assert!(
|
||||
st == StatusCode::BAD_REQUEST || st == StatusCode::CONFLICT,
|
||||
"{st}"
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_period_is_read_only() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let (_, item) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||||
)
|
||||
.await;
|
||||
let (_, note) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&s,
|
||||
json!({ "context": "planning", "title": "Hedef" }),
|
||||
)
|
||||
.await;
|
||||
close_period(&app, &pid).await;
|
||||
|
||||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||||
let checks = [
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(&item_path, &s, json!({ "amount": 5 })).await.0,
|
||||
app.delete(&item_path, &s).await.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&s,
|
||||
json!({ "context": "review", "title": "X" }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(¬e_path, &s, json!({ "title": "Y" })).await.0,
|
||||
app.delete(¬e_path, &s).await.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|st| *st == StatusCode::CONFLICT),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// Okuma serbest
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/plan-items"), &s).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
app.get(&format!("/periods/{pid}/notes"), &s).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notes_crud_and_context_filter() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let pid = open_period(&app, &s, "2026-12-01", "2027-01-01").await;
|
||||
let notes = format!("/periods/{pid}/notes");
|
||||
|
||||
let (st, n) = app
|
||||
.post(¬es, &s, json!({ "context": "planning", "title": " Tatil birikimi ", "body_md": "- Ocak'ta **5.000 ₺**" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{n}");
|
||||
assert_eq!(n["title"], "Tatil birikimi");
|
||||
app.post(¬es, &s, json!({ "context": "review", "title": "Ders" }))
|
||||
.await;
|
||||
|
||||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||||
assert_eq!(planning.as_array().unwrap().len(), 1);
|
||||
let (_, all) = app.get(¬es, &s).await;
|
||||
assert_eq!(all.as_array().unwrap().len(), 2);
|
||||
|
||||
let (st, _) = app
|
||||
.post(¬es, &s, json!({ "context": "planning", "title": "" }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "boş başlık");
|
||||
|
||||
let path = format!("/notes/{}", n["id"].as_str().unwrap());
|
||||
let (st, upd) = app
|
||||
.patch(&path, &s, json!({ "body_md": "Güncel", "version": 1 }))
|
||||
.await;
|
||||
assert_eq!(
|
||||
(st, upd["body_md"].as_str()),
|
||||
(StatusCode::OK, Some("Güncel"))
|
||||
);
|
||||
assert_eq!(app.delete(&path, &s).await.0, StatusCode::NO_CONTENT);
|
||||
let (_, planning) = app.get(&format!("{notes}?context=planning"), &s).await;
|
||||
assert_eq!(planning, json!([]));
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_and_notes_are_isolated() {
|
||||
let app = TestApp::new().await;
|
||||
let a = app.register("A", None).await;
|
||||
let b = app.register("B", None).await;
|
||||
let pid = open_period(&app, &a, "2026-12-01", "2027-01-01").await;
|
||||
let (_, item) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&a,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 100 }),
|
||||
)
|
||||
.await;
|
||||
let (_, note) = app
|
||||
.post(
|
||||
&format!("/periods/{pid}/notes"),
|
||||
&a,
|
||||
json!({ "context": "planning", "title": "Gizli" }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let item_path = format!("/plan-items/{}", item["id"].as_str().unwrap());
|
||||
let note_path = format!("/notes/{}", note["id"].as_str().unwrap());
|
||||
let checks = [
|
||||
app.get(&format!("/periods/{pid}/plan-items"), &b).await.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&b,
|
||||
json!({ "kind": "income", "name": "X", "amount": 1 }),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items/copy-previous"),
|
||||
&b,
|
||||
json!({}),
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
app.patch(&item_path, &b, json!({ "amount": 1 })).await.0,
|
||||
app.delete(&item_path, &b).await.0,
|
||||
app.get(&format!("/periods/{pid}/notes"), &b).await.0,
|
||||
app.patch(¬e_path, &b, json!({ "title": "X" })).await.0,
|
||||
app.delete(¬e_path, &b).await.0,
|
||||
];
|
||||
assert!(
|
||||
checks.iter().all(|st| *st == StatusCode::NOT_FOUND),
|
||||
"{checks:?}"
|
||||
);
|
||||
|
||||
// A'nın verisi değişmedi
|
||||
let (_, list) = app.get(&format!("/periods/{pid}/plan-items"), &a).await;
|
||||
assert_eq!(list[0]["amount"], 100);
|
||||
assert_eq!(list[0]["version"], 1);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 4 – Adım 2:** Backend plan kalemleri (`plan_items`: income/expense, ad, tutar kuruş, tarih dönem içinde, sort_order; CRUD + `POST /api/periods/{id}/plan-items/copy-previous`) ve notlar (`notes`: context planning|review, başlık, body_md; CRUD). Kapalı dönemde yazma yok. İzolasyon testleri.
|
||||
**Faz 4 – Adım 3:** Backend bütçe: `budgets` (dönem × grup aylık tutar; haftalık yalnızca elle değiştirilirse `scope: week`), haftalık otomatik = aylık × hafta günü / dönem günü (kuruş yuvarlama: toplam aylığa eşit kalsın), öneri (`default` yüzdeler; `previous`/`avg3` entries gerektirir → şimdilik boş/varsayılana düşer), özet endpoint'i: gelir − sabit gider − birikim − devreden borç = harcanabilir; dağıtılan > harcanabilir uyarısı.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
- **Yerel demo hesapları** (`kakeibo_dev`): `demo@ornek.com` / `demo12345` (owner, "Demo Hanesi") · `es@ornek.com` / `demo12345` (member, aynı hane). `npm run db:reset` silerse API ile yeniden oluştur (ileride `db:seed`).
|
||||
@@ -38,10 +38,12 @@
|
||||
- Canlı: domain `kakeibo.ta1sph.website` (DNS → 159.146.61.151, ev dış IP'si olmalı), Gitea Dokploy'a bağlı. Canlı MongoDB = ayrı Dokploy servisi `kakeibo-kakeibomongo-fibzhc` (rs0), kullanıcı `kakeibo_app` (readWrite/kakeibo).
|
||||
- Dönem: bitiş hariç, en fazla 62 gün, hane başına tek açık, çakışma yok (metin karşılaştırması), kapalı salt okunur, daraltmada dışarıda harcama kalırsa 409. Ad boşsa otomatik (`calendar::default_period_name`). Tüm üyeler açabilir/düzenleyebilir.
|
||||
- ts-rs: `i64` tutarlar `#[ts(type = "number")]`; isteklerde `Option<i64>` için `#[ts(type = "number", optional)]` (yoksa `?` kayboluyor).
|
||||
- Plan kalemleri: tarih isteğe bağlı (dönem içinde), tutar > 0, kopyalama göreli gün + dönem sonuna kırpma, aynı tür+ad (büyük/küçük harf duyarsız) atlanır. Notlar: başlık 1–120, metin ≤ 20.000. Kapalı dönemde hepsi 409 (`period::get_open`). Silme = soft delete.
|
||||
- İzolasyon iki katmanlı: kayıt okuma `scope()` + dönem `get_open(hid, period_id)` + yazma filtresi `scope()`. Tek katmanı bozmak açık oluşturmuyor (mutasyon denemesiyle görüldü).
|
||||
- Sürümler: Rust 1.99.1 (edition 2024), Axum 0.8, React 19, Vite 8, TypeScript 7, Tailwind 4.3, shadcn 4.23, Vitest 5, react-router 8, TanStack Query 5.
|
||||
|
||||
## ❓ Açık Konular
|
||||
- **Geliştirme makinesinde Node çalışmıyor (2026-11-12):** CachyOS'ta simdjson 5.1.1 (.so.36) geldi, nodejs 26.13.0-2 hâlâ .so.34 istiyor. Çözüm: `sudo pacman -U /var/cache/pacman/pkg/simdjson-2:5.0.5-1-x86_64.pkg.tar.zst`, sonraki güncellemelerde `--ignore simdjson`. Faz 4 Adım 1'in frontend kontrolleri (tsc/vitest) Node düzelince çalıştırılacak.
|
||||
- Geliştirme makinesi: CachyOS nodejs 26.13.0-2 ↔ simdjson .so.36 uyumsuzluğu → simdjson 2:5.0.5-1'e düşürüldü (2026-11-12). Düzeltilmiş nodejs gelene kadar `pacman -Syu --ignore simdjson`. `node: libsimdjson.so.34` hatası görülürse sebep budur.
|
||||
- **Canlı yedekleme yok** (Faz 3 Adım 5 ertelendi) → gerçek veri girişinden önce, en geç Faz 5 sonunda: günlük mongodump, 14 yedek, geri yükleme testi.
|
||||
- **Canlı MongoDB auth kapalı** (Dokploy replica set betiği keyFile kullanmıyor, özel Command'ı yok sayıyor). İç ağda, port kapalı. Ayrıntı: `docs/DEPLOY.md` §4.
|
||||
- Üye çıkarma / rol değiştirme / sahipliği devretme henüz yok (Ayarlar ekranıyla birlikte, Faz 2+).
|
||||
@@ -84,3 +86,4 @@
|
||||
|
||||
### 2026-11-12 — Faz 4
|
||||
- **Adım 1:** `services/calendar.rs` (parse/format, `weeks` Pazartesi başlangıçlı + kısmi haftalar, Türkçe ay adları; 4 test), `models/period.rs`, `services/period.rs` (2 test), `routes/period.rs` (list/current/create/get/patch; DTO'da `last_date`, `days`, `weeks`). `tests/periods.rs` (5) — çakışma kontrolü bilerek bozulunca test yakaladı. Backend toplam 58 test.
|
||||
- **Adım 2:** `models/plan_item.rs`, `models/note.rs`, `services/plan.rs` (list/create/update/delete/copy_previous), `services/note.rs`, `routes/plan.rs`, `routes/note.rs`, `period::get_open` + `closed()`. `tests/plan_notes.rs` (5): CRUD + doğrulama, kopyalama (göreli gün, 28 Şubat'a kırpma, tekrar yok), kapalı dönem salt okunur, not filtresi, izolasyon. Backend 72 test.
|
||||
|
||||
+2
-2
@@ -47,8 +47,8 @@
|
||||
|
||||
## Faz 4 Adımları
|
||||
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
|
||||
- ⏳ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
|
||||
- ⏳ 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
||||
- ✅ 2. Backend: plan kalemleri (sabit gelir/gider, önceki dönemden kopyala) + notlar
|
||||
- 🔄 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
||||
- ⏳ 4. Frontend: ilk dönem sihirbazı
|
||||
- ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
|
||||
- ⏳ 6. Frontend: notlar (Markdown, düzenle/önizle)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type CopyPreviousResult = {
|
||||
/**
|
||||
* Eklenen kalem sayısı (zaten var olanlar atlanır)
|
||||
*/
|
||||
added: number, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NoteContext } from "./NoteContext";
|
||||
|
||||
export type Note = { id: string, context: NoteContext, title: string, body_md: string, created_by: string,
|
||||
/**
|
||||
* ISO 8601
|
||||
*/
|
||||
updated_at: string, version: number, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Notun bağlı olduğu sayfa
|
||||
*/
|
||||
export type NoteContext = "planning" | "review";
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NoteContext } from "./NoteContext";
|
||||
|
||||
export type NoteCreateRequest = { context: NoteContext, title: string,
|
||||
/**
|
||||
* Markdown
|
||||
*/
|
||||
body_md: string, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type NoteUpdateRequest = { title?: string, body_md?: string, version?: number, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PlanItemKind } from "./PlanItemKind";
|
||||
|
||||
export type PlanItem = { id: string, kind: PlanItemKind, name: string,
|
||||
/**
|
||||
* Kuruş
|
||||
*/
|
||||
amount: number, date: string | null, sort_order: number, version: number, };
|
||||
@@ -0,0 +1,12 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { PlanItemKind } from "./PlanItemKind";
|
||||
|
||||
export type PlanItemCreateRequest = { kind: PlanItemKind, name: string,
|
||||
/**
|
||||
* Kuruş, > 0
|
||||
*/
|
||||
amount: number,
|
||||
/**
|
||||
* `"YYYY-MM-DD"`, dönem içinde (isteğe bağlı)
|
||||
*/
|
||||
date?: string, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PlanItemKind = "income" | "expense";
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PlanItemUpdateRequest = { name?: string, amount?: number,
|
||||
/**
|
||||
* Boş metin → tarihi kaldır
|
||||
*/
|
||||
date?: string, version?: number, };
|
||||
Reference in New Issue
Block a user