feat(dönem): dönem kapatma, devir ve düzeltme için yeniden açma (backend)
- POST /periods/{id}/close: tek transaction; açık → borç, artı → birikim + devir
- Devir sonraki dönem açılınca ona bağlanır
- POST /periods/{id}/reopen (sahip): düzeltme modu, yeni dönem açık kalır
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! `periods` koleksiyonu (DATA_MODEL.md §3.4).
|
||||
|
||||
use mongodb::bson::Uuid;
|
||||
use mongodb::bson::{DateTime, Uuid};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
@@ -10,8 +10,12 @@ use ts_rs::TS;
|
||||
pub enum PeriodStatus {
|
||||
/// Girişler yapılabilir. Hanede en fazla bir tane.
|
||||
Open,
|
||||
/// Salt okunur (Faz 6: kapanış sihirbazı, sahip yeniden açabilir).
|
||||
/// Salt okunur. Sahip yeniden açarsa `Correcting` olur.
|
||||
Closed,
|
||||
/// Düzeltmede: kapanmış dönem sahip tarafından düzeltme için açıldı.
|
||||
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||
Correcting,
|
||||
}
|
||||
|
||||
impl PeriodStatus {
|
||||
@@ -19,8 +23,14 @@ impl PeriodStatus {
|
||||
match self {
|
||||
PeriodStatus::Open => "open",
|
||||
PeriodStatus::Closed => "closed",
|
||||
PeriodStatus::Correcting => "correcting",
|
||||
}
|
||||
}
|
||||
|
||||
/// Kayıt eklenip değiştirilebilir mi?
|
||||
pub fn is_writable(self) -> bool {
|
||||
self != PeriodStatus::Closed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -38,5 +48,10 @@ pub struct Period {
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown, Değerlendirme sayfası)
|
||||
#[serde(default)]
|
||||
pub reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan tutar (kuruş). Faz 8'de varlık hareketine dönüşecek.
|
||||
#[serde(default)]
|
||||
pub to_savings: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub closed_at: Option<DateTime>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ 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}/close", post(period::close))
|
||||
.route("/periods/{id}/reopen", post(period::reopen))
|
||||
.route(
|
||||
"/periods/{id}/plan-items",
|
||||
get(plan::list).post(plan::create),
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! | 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 |
|
||||
//! | PATCH | `/api/periods/{id}` | ad / tarihler / birikim hedefi / "Nasıl daha iyi…" |
|
||||
//! | POST | `/api/periods/{id}/close` | dönemi kapat: devir + birikime aktarılan (transaction) |
|
||||
//! | POST | `/api/periods/{id}/reopen` | **sahip:** kapalı dönemi düzeltmeye aç (`correcting`) |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -25,6 +27,7 @@ use crate::{
|
||||
models::period::{Period, PeriodStatus},
|
||||
services::{
|
||||
calendar::{days_between, format_date, parse_date, weeks},
|
||||
closing,
|
||||
period::{self, NewPeriod, PeriodChanges},
|
||||
},
|
||||
state::AppState,
|
||||
@@ -77,6 +80,11 @@ pub struct PeriodDto {
|
||||
savings_goal: i64,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||
reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
|
||||
#[ts(type = "number | null")]
|
||||
to_savings: Option<i64>,
|
||||
/// Kapanış zamanı (ISO 8601)
|
||||
closed_at: Option<String>,
|
||||
version: i32,
|
||||
weeks: Vec<WeekDto>,
|
||||
}
|
||||
@@ -107,6 +115,8 @@ impl From<Period> for PeriodDto {
|
||||
status: p.status,
|
||||
savings_goal: p.savings_goal,
|
||||
reflection_md: p.reflection_md,
|
||||
to_savings: p.to_savings,
|
||||
closed_at: p.closed_at.and_then(|d| d.try_to_rfc3339_string().ok()),
|
||||
version: p.version,
|
||||
weeks: weeks(start, end)
|
||||
.into_iter()
|
||||
@@ -190,3 +200,76 @@ pub async fn update(
|
||||
.await?;
|
||||
Ok(Json(p.into()))
|
||||
}
|
||||
|
||||
// ---------- Kapatma / yeniden açma ----------
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "PeriodCloseRequest", optional_fields)]
|
||||
pub struct CloseRequest {
|
||||
/// Birikime aktarılacak (kuruş); verilmezse gerçekleşen birikimin tamamı. Kalanı artı olarak devreder.
|
||||
#[ts(type = "number", optional)]
|
||||
to_savings: Option<i64>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "PeriodCloseResult")]
|
||||
pub struct CloseResultDto {
|
||||
period: PeriodDto,
|
||||
/// Değerlendirmenin 4. sorusu (negatifse açık)
|
||||
#[ts(type = "number")]
|
||||
realized_savings: i64,
|
||||
#[ts(type = "number")]
|
||||
to_savings: i64,
|
||||
/// Sonraki döneme devreden; yoksa `null`
|
||||
carryover: Option<CarryoverDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Carryover")]
|
||||
struct CarryoverDto {
|
||||
/// `debt` (borç) · `surplus` (artı)
|
||||
#[ts(type = "\"debt\" | \"surplus\"")]
|
||||
kind: &'static str,
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
/// Sonraki dönem henüz açılmadıysa `null` (açılınca bağlanır)
|
||||
to_period_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn close(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<CloseRequest>,
|
||||
) -> AppResult<Json<CloseResultDto>> {
|
||||
let c = closing::close(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
parse_id(&id)?,
|
||||
req.to_savings,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(CloseResultDto {
|
||||
period: c.period.into(),
|
||||
realized_savings: c.realized_savings,
|
||||
to_savings: c.to_savings,
|
||||
carryover: c.carryover.map(|x| CarryoverDto {
|
||||
kind: x.kind.as_str(),
|
||||
amount: x.amount,
|
||||
to_period_id: x.to_period_id.map(|u| u.to_string()),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn reopen(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<PeriodDto>> {
|
||||
auth.require_owner()?;
|
||||
let p = closing::reopen(&s.db, auth.household_id, auth.user_id, parse_id(&id)?).await?;
|
||||
Ok(Json(p.into()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Dönem kapatma ve yeniden açma (KAKEIBO_PROMPT §6, DATA_MODEL.md §3.10, §6).
|
||||
//!
|
||||
//! Kapatma (tek transaction):
|
||||
//! 1. Dönem `closed` olur (`closed_at`, `closed_by`, `to_savings`).
|
||||
//! 2. Bu dönemden önceki devirler (yeniden kapatmada) soft delete edilir.
|
||||
//! 3. Gerçekleşen birikim (değerlendirmenin 4. sorusu) dağıtılır:
|
||||
//! - < 0 → açık **borç** olarak devreder (`debt`).
|
||||
//! - ≥ 0 → `to_savings` kadarı birikime (varsayılan: tamamı), kalanı **artı** olarak devreder (`surplus`).
|
||||
//! 4. Devir sonraki döneme bağlanır; sonraki dönem henüz yoksa `to_period_id: null` kalır
|
||||
//! ve o dönem açılınca bağlanır (`period::create`).
|
||||
//!
|
||||
//! Yeniden açma (yalnızca sahip): dönem `correcting` olur, yeni açık dönem varsa o da açık kalır.
|
||||
//! Mevcut devir, "yeniden kapat" denene kadar yerinde kalır (sonraki dönemin rakamları oynamaz).
|
||||
//! Ardından kapanmış dönem olan bir dönem açılamaz: sonraki devirler zincirleme bozulurdu.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::{Period, PeriodStatus},
|
||||
},
|
||||
services::{period, review},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CarryKind {
|
||||
Debt,
|
||||
Surplus,
|
||||
}
|
||||
|
||||
impl CarryKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CarryKind::Debt => "debt",
|
||||
CarryKind::Surplus => "surplus",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Carry {
|
||||
pub kind: CarryKind,
|
||||
pub amount: i64,
|
||||
pub to_period_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
pub struct Closing {
|
||||
pub period: Period,
|
||||
pub realized_savings: i64,
|
||||
pub to_savings: i64,
|
||||
pub carryover: Option<Carry>,
|
||||
}
|
||||
|
||||
/// Gerçekleşen birikimi birikim / devir olarak böler. `to_savings` verilmezse tamamı birikime.
|
||||
pub fn settle(
|
||||
realized: i64,
|
||||
to_savings: Option<i64>,
|
||||
) -> AppResult<(i64, Option<(CarryKind, i64)>)> {
|
||||
if realized < 0 {
|
||||
if to_savings.unwrap_or(0) != 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu dönemde birikim yok; açık, borç olarak sonraki döneme devreder".into(),
|
||||
));
|
||||
}
|
||||
return Ok((0, Some((CarryKind::Debt, -realized))));
|
||||
}
|
||||
let t = to_savings.unwrap_or(realized);
|
||||
if !(0..=realized).contains(&t) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Birikime aktarılacak tutar 0 ile gerçekleşen birikim arasında olmalı".into(),
|
||||
));
|
||||
}
|
||||
let surplus = realized - t;
|
||||
Ok((t, (surplus > 0).then_some((CarryKind::Surplus, surplus))))
|
||||
}
|
||||
|
||||
pub async fn close(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
to_savings: Option<i64>,
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Closing> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status == PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
|
||||
}
|
||||
if version.is_some_and(|v| v != p.version) {
|
||||
return Err(period::stale());
|
||||
}
|
||||
let realized = review::review(db, household_id, period_id)
|
||||
.await?
|
||||
.realized_savings;
|
||||
let (to_savings, carry) = settle(realized, to_savings)?;
|
||||
let next = next_period(db, household_id, &p).await?;
|
||||
let ts = now();
|
||||
|
||||
let mut session = db.client().start_session().await?;
|
||||
session.start_transaction().await?;
|
||||
let result = async {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", period_id);
|
||||
f.insert("version", p.version);
|
||||
let r = db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": PeriodStatus::Closed.as_str(),
|
||||
"closed_at": ts,
|
||||
"closed_by": user_id,
|
||||
"to_savings": to_savings,
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(period::stale());
|
||||
}
|
||||
|
||||
let mut old = scope(household_id);
|
||||
old.insert("from_period_id", period_id);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.update_many(
|
||||
old,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
|
||||
if let Some((kind, amount)) = carry {
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("from_period_id", period_id);
|
||||
d.insert(
|
||||
"to_period_id",
|
||||
next.as_ref().map_or(Bson::Null, |n| n.id.into()),
|
||||
);
|
||||
d.insert("kind", kind.as_str());
|
||||
d.insert("amount", amount);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.insert_one(d)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => session.commit_transaction().await?,
|
||||
Err(e) => {
|
||||
let _ = session.abort_transaction().await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Closing {
|
||||
period: period::get(db, household_id, period_id).await?,
|
||||
realized_savings: realized,
|
||||
to_savings,
|
||||
carryover: carry.map(|(kind, amount)| Carry {
|
||||
kind,
|
||||
amount,
|
||||
to_period_id: next.map(|n| n.id),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Kapalı dönemi düzeltme için açar (yalnızca sahip — rota denetler).
|
||||
pub async fn reopen(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period_id: Uuid,
|
||||
) -> AppResult<Period> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status != PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict(
|
||||
"Yalnızca kapalı bir dönem yeniden açılabilir".into(),
|
||||
));
|
||||
}
|
||||
let mut later = scope(household_id);
|
||||
later.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() });
|
||||
if db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.find_one(later)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"Bu dönemden sonra kapanmış bir dönem var. Yalnızca en son kapanan dönem yeniden açılabilir."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", period_id);
|
||||
f.insert("version", p.version);
|
||||
let r = db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"status": PeriodStatus::Correcting.as_str(),
|
||||
"reopened_at": ts,
|
||||
"reopened_by": user_id,
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "version": 1 },
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if r.matched_count == 0 {
|
||||
return Err(period::stale());
|
||||
}
|
||||
period::get(db, household_id, period_id).await
|
||||
}
|
||||
|
||||
/// Bu dönemden hemen sonra gelen dönem (varsa)
|
||||
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
Ok(db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find_one(f)
|
||||
.sort(doc! { "start_date": 1 })
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settle_rules() {
|
||||
// Artı: varsayılan tamamı birikime
|
||||
assert_eq!(settle(700, None).unwrap(), (700, None));
|
||||
assert_eq!(
|
||||
settle(700, Some(500)).unwrap(),
|
||||
(500, Some((CarryKind::Surplus, 200)))
|
||||
);
|
||||
assert_eq!(
|
||||
settle(700, Some(0)).unwrap(),
|
||||
(0, Some((CarryKind::Surplus, 700)))
|
||||
);
|
||||
assert!(settle(700, Some(701)).is_err());
|
||||
assert!(settle(700, Some(-1)).is_err());
|
||||
// Sıfır: devir yok
|
||||
assert_eq!(settle(0, None).unwrap(), (0, None));
|
||||
// Açık: borç; birikim istenemez
|
||||
assert_eq!(settle(-50, None).unwrap(), (0, Some((CarryKind::Debt, 50))));
|
||||
assert_eq!(
|
||||
settle(-50, Some(0)).unwrap(),
|
||||
(0, Some((CarryKind::Debt, 50)))
|
||||
);
|
||||
assert!(settle(-50, Some(10)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod account;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod closing;
|
||||
pub mod entry;
|
||||
pub mod invite;
|
||||
pub mod money;
|
||||
|
||||
@@ -82,7 +82,7 @@ pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Perio
|
||||
/// 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 {
|
||||
if !p.status.is_writable() {
|
||||
return Err(closed());
|
||||
}
|
||||
Ok(p)
|
||||
@@ -122,24 +122,51 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
|
||||
d.insert("end_date", format_date(end));
|
||||
d.insert("status", PeriodStatus::Open.as_str());
|
||||
d.insert("savings_goal", savings_goal);
|
||||
d.insert("reflection_md", "");
|
||||
d.insert("reflection_md", Bson::Null);
|
||||
d.insert("to_savings", Bson::Null);
|
||||
d.insert(
|
||||
"previous_period_id",
|
||||
previous.map_or(Bson::Null, |p| p.id.into()),
|
||||
previous.as_ref().map_or(Bson::Null, |p| p.id.into()),
|
||||
);
|
||||
d.insert("closed_at", Bson::Null);
|
||||
d.insert("closed_by", Bson::Null);
|
||||
d.insert("reopened_at", Bson::Null);
|
||||
|
||||
match db.collection::<Document>(col::PERIODS).insert_one(d).await {
|
||||
Ok(_) => {}
|
||||
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
|
||||
Err(e) if is_duplicate_key(&e) => {
|
||||
return Err(AppError::Conflict(
|
||||
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
|
||||
));
|
||||
// Tek transaction: dönem + önceki dönemden bekleyen devrin bu döneme bağlanması
|
||||
let mut session = db.client().start_session().await?;
|
||||
session.start_transaction().await?;
|
||||
let result = async {
|
||||
db.collection::<Document>(col::PERIODS)
|
||||
.insert_one(d)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
if let Some(prev) = &previous {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("from_period_id", prev.id);
|
||||
f.insert("to_period_id", Bson::Null);
|
||||
db.collection::<Document>(col::CARRYOVERS)
|
||||
.update_many(
|
||||
f,
|
||||
doc! { "$set": { "to_period_id": id.clone(), "updated_at": now() }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.session(&mut session)
|
||||
.await?;
|
||||
}
|
||||
Ok::<_, mongodb::error::Error>(())
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => session.commit_transaction().await?,
|
||||
Err(e) => {
|
||||
let _ = session.abort_transaction().await;
|
||||
// Aynı anda iki istek: kısmi benzersiz indeks ikinci açık dönemi engeller
|
||||
if is_duplicate_key(&e) {
|
||||
return Err(AppError::Conflict(
|
||||
"Zaten açık bir dönem var. Sayfayı yenileyin.".into(),
|
||||
));
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
|
||||
coll(db)
|
||||
@@ -155,7 +182,7 @@ pub async fn update(
|
||||
ch: PeriodChanges,
|
||||
) -> AppResult<Period> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
if current.status == PeriodStatus::Closed {
|
||||
if !current.status.is_writable() {
|
||||
return Err(closed());
|
||||
}
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
@@ -297,7 +324,7 @@ async fn ensure_entries_inside(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
pub fn stale() -> AppError {
|
||||
AppError::Conflict("Dönem başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user