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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user