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,
|
"savings_goal": 750000,
|
||||||
"version": 1
|
"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 group;
|
||||||
pub mod household;
|
pub mod household;
|
||||||
pub mod invite;
|
pub mod invite;
|
||||||
|
pub mod note;
|
||||||
pub mod period;
|
pub mod period;
|
||||||
|
pub mod plan_item;
|
||||||
pub mod user;
|
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 catalog;
|
||||||
mod health;
|
mod health;
|
||||||
mod household;
|
mod household;
|
||||||
|
mod note;
|
||||||
mod period;
|
mod period;
|
||||||
|
mod plan;
|
||||||
mod spa;
|
mod spa;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -63,6 +65,17 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/periods", get(period::list).post(period::create))
|
.route("/periods", get(period::list).post(period::create))
|
||||||
.route("/periods/current", get(period::current))
|
.route("/periods/current", get(period::current))
|
||||||
.route("/periods/{id}", get(period::get).patch(period::update))
|
.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)
|
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||||
.fallback(|| async { AppError::NotFound });
|
.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 calendar;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod invite;
|
pub mod invite;
|
||||||
|
pub mod note;
|
||||||
pub mod period;
|
pub mod period;
|
||||||
|
pub mod plan;
|
||||||
pub mod seed;
|
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)
|
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> {
|
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 (start, end) = validate_range(&p.start_date, &p.end_date)?;
|
||||||
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
|
let savings_goal = validate_amount(p.savings_goal.unwrap_or(0))?;
|
||||||
@@ -135,9 +151,7 @@ pub async fn update(
|
|||||||
) -> AppResult<Period> {
|
) -> AppResult<Period> {
|
||||||
let current = get(db, household_id, id).await?;
|
let current = get(db, household_id, id).await?;
|
||||||
if current.status == PeriodStatus::Closed {
|
if current.status == PeriodStatus::Closed {
|
||||||
return Err(AppError::Conflict(
|
return Err(closed());
|
||||||
"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) {
|
if ch.version.is_some_and(|v| v != current.version) {
|
||||||
return Err(stale());
|
return Err(stale());
|
||||||
@@ -192,7 +206,7 @@ fn validate_range(start: &str, end: &str) -> AppResult<(Date, Date)> {
|
|||||||
Ok((start, end))
|
Ok((start, end))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_amount(v: i64) -> AppResult<i64> {
|
pub fn validate_amount(v: i64) -> AppResult<i64> {
|
||||||
if !(0..=MAX_AMOUNT).contains(&v) {
|
if !(0..=MAX_AMOUNT).contains(&v) {
|
||||||
return Err(AppError::BadRequest("Tutar geçersiz".into()));
|
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ı
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ 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
|
## 📌 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`).
|
- **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).
|
- 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.
|
- 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).
|
- 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.
|
- 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
|
## ❓ 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ı 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.
|
- **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+).
|
- Ü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
|
### 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 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ı
|
## Faz 4 Adımları
|
||||||
- ✅ 1. Backend: dönem (aç, açık dönem, liste, detay + haftalar, düzenle), takvim hesapları, testler
|
- ✅ 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
|
- ✅ 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)
|
- 🔄 3. Backend: bütçe (aylık/haftalık, öneri), özet (harcanabilir tutar)
|
||||||
- ⏳ 4. Frontend: ilk dönem sihirbazı
|
- ⏳ 4. Frontend: ilk dönem sihirbazı
|
||||||
- ⏳ 5. Frontend: Plan ekranı (görüntüle/düzenle, özet kutucukları, bütçe dağıtımı)
|
- ⏳ 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)
|
- ⏳ 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