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:
@@ -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