feat(dönem): dönem açma, açık dönem, düzenleme ve haftalar (backend)

- /api/periods: liste, açık dönem, aç, detay, düzenle
- Kurallar: tek açık dönem, çakışma yok, bitiş hariç, en fazla 62 gün, kapalı dönem salt okunur
- Tarih daraltılırken dışarıda harcama kalırsa engellenir
- Haftalar Pazartesi başlangıçlı, kısmi haftalar ayrı (takvim yardımcıları)
- Otomatik dönem adı: "Aralık 2026" / "15 Ara 2026 – 14 Oca 2027"
- Testler: takvim (4), dönem kuralları (2), entegrasyon (5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 02:56:13 +03:00
parent a2b4119f3e
commit b883530f76
16 changed files with 1048 additions and 1 deletions
+4
View File
@@ -4,6 +4,7 @@ mod auth;
mod catalog;
mod health;
mod household;
mod period;
mod spa;
use axum::{
@@ -59,6 +60,9 @@ pub fn router(state: AppState) -> Router {
"/payment-methods/{id}",
patch(catalog::update_payment_method),
)
.route("/periods", get(period::list).post(period::create))
.route("/periods/current", get(period::current))
.route("/periods/{id}", get(period::get).patch(period::update))
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
.fallback(|| async { AppError::NotFound });
+186
View File
@@ -0,0 +1,186 @@
//! Dönem uç noktaları (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/periods` | tüm dönemler (en yeni başta) |
//! | GET | `/api/periods/current` | açık dönem ya da `null` |
//! | POST | `/api/periods` | dönem aç |
//! | GET | `/api/periods/{id}` | dönem + haftalar |
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi |
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::period::{Period, PeriodStatus},
services::{
calendar::{days_between, format_date, parse_date, weeks},
period::{self, NewPeriod, PeriodChanges},
},
state::AppState,
};
// ---------- İstek / yanıt ----------
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PeriodCreateRequest", optional_fields)]
pub struct CreateRequest {
/// `"YYYY-MM-DD"`, dahil
start_date: String,
/// `"YYYY-MM-DD"`, hariç (ör. Aralık için `2027-01-01`)
end_date: String,
/// Boşsa otomatik ("Aralık 2026")
name: Option<String>,
/// Kuruş
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
}
#[derive(Deserialize, Validate, TS)]
#[ts(export, rename = "PeriodUpdateRequest", optional_fields)]
pub struct UpdateRequest {
name: Option<String>,
start_date: Option<String>,
end_date: Option<String>,
#[ts(type = "number", optional)]
savings_goal: Option<i64>,
version: Option<i32>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "Period")]
pub struct PeriodDto {
id: String,
name: String,
/// Dahil
start_date: String,
/// Hariç (veritabanındaki değer)
end_date: String,
/// Son gün, dahil (gösterim için)
last_date: String,
days: u32,
status: PeriodStatus,
/// Kuruş
#[ts(type = "number")]
savings_goal: i64,
version: i32,
weeks: Vec<WeekDto>,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "PeriodWeek")]
struct WeekDto {
/// 1'den başlar
index: u32,
start_date: String,
/// Dahil
last_date: String,
days: u32,
}
impl From<Period> for PeriodDto {
fn from(p: Period) -> Self {
// Veritabanındaki tarihler her zaman doğrulanmış biçimde yazılır
let start = parse_date(&p.start_date).expect("geçerli başlangıç tarihi");
let end = parse_date(&p.end_date).expect("geçerli bitiş tarihi");
Self {
id: p.id.to_string(),
name: p.name,
last_date: format_date(end - time::Duration::days(1)),
days: days_between(start, end) as u32,
start_date: p.start_date,
end_date: p.end_date,
status: p.status,
savings_goal: p.savings_goal,
version: p.version,
weeks: weeks(start, end)
.into_iter()
.map(|w| WeekDto {
index: w.index,
start_date: format_date(w.start),
last_date: format_date(w.last),
days: w.days,
})
.collect(),
}
}
}
fn parse_id(id: &str) -> AppResult<Uuid> {
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
}
// ---------- Uç noktalar ----------
pub async fn list(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<Vec<PeriodDto>>> {
let items = period::list(&s.db, auth.household_id).await?;
Ok(Json(items.into_iter().map(PeriodDto::from).collect()))
}
pub async fn current(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<Json<Option<PeriodDto>>> {
let p = period::current(&s.db, auth.household_id).await?;
Ok(Json(p.map(PeriodDto::from)))
}
pub async fn create(
State(s): State<AppState>,
auth: AuthUser,
ValidJson(req): ValidJson<CreateRequest>,
) -> AppResult<(StatusCode, Json<PeriodDto>)> {
let p = period::create(
&s.db,
auth.household_id,
NewPeriod {
start_date: req.start_date,
end_date: req.end_date,
name: req.name,
savings_goal: req.savings_goal,
},
)
.await?;
Ok((StatusCode::CREATED, Json(p.into())))
}
pub async fn get(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
) -> AppResult<Json<PeriodDto>> {
let p = period::get(&s.db, auth.household_id, parse_id(&id)?).await?;
Ok(Json(p.into()))
}
pub async fn update(
State(s): State<AppState>,
auth: AuthUser,
Path(id): Path<String>,
ValidJson(req): ValidJson<UpdateRequest>,
) -> AppResult<Json<PeriodDto>> {
let p = period::update(
&s.db,
auth.household_id,
parse_id(&id)?,
PeriodChanges {
name: req.name,
start_date: req.start_date,
end_date: req.end_date,
savings_goal: req.savings_goal,
version: req.version,
},
)
.await?;
Ok(Json(p.into()))
}