feat(birikim): varlıklar ve alım/satım kayıtları (ortalama maliyet)
- Varsayılan varlıklar (TL, altınlar, USD, EUR) + migration v2 ile eski hanelere ekleme - Döviz ekleme, varlık adı/aktifliği - Alım/satım ekle, düzenle, sil; eldekinden fazla satış (geriye tarihli dahil) engellenir - Ağırlıklı ortalama maliyet ve gerçekleşen kâr/zarar (hesaplanır, saklanmaz) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
//! Birikimler / varlıklar ve alım-satım kayıtları (KAKEIBO_PROMPT §8.7, DATA_MODEL.md §3.12–3.13).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Varsayılan varlıklar hane açılınca gelir (TL, altınlar, USD, EUR); kullanıcı **döviz** ekleyebilir.
|
||||
//! - Miktar/fiyat ölçekli tam sayı (`_e6`), tutar kuruş. TL varlığında miktar = tutar, birim fiyat 1.
|
||||
//! - Eldeki miktar, maliyet ve kâr/zarar saklanmaz; işlemlerden hesaplanır (`holding`).
|
||||
//! - Ekleme, düzenleme ve silmede tüm işlemler **tarih sırasıyla** yeniden denetlenir:
|
||||
//! hiçbir tarihte eldekinden fazla satılmış olamaz (geriye tarihli kayıt / alım silme dahil).
|
||||
//! - Silme soft delete. Hanenin tüm üyeleri yazabilir.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
options::ReturnDocument,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
asset::{Asset, AssetKind, AssetTx, TxType},
|
||||
common::{new_meta, now, scope},
|
||||
},
|
||||
services::{
|
||||
calendar::parse_date,
|
||||
holding::{self, Holding, Move, Oversold},
|
||||
period::MAX_AMOUNT,
|
||||
},
|
||||
};
|
||||
|
||||
/// Not üst sınırı (karakter)
|
||||
pub const MAX_NOTE: usize = 200;
|
||||
/// Miktar ve birim fiyat üst sınırı (e6): 1 milyar birim / 1 milyar TL
|
||||
const MAX_E6: i64 = 1_000_000_000_000_000;
|
||||
/// TL varlığında birim fiyat (1 TL)
|
||||
const ONE_E6: i64 = 1_000_000;
|
||||
|
||||
pub struct NewTx {
|
||||
pub tx_type: TxType,
|
||||
pub date: String,
|
||||
/// TL varlığında yok sayılır (tutardan türetilir)
|
||||
pub quantity_e6: i64,
|
||||
/// Verilmezse tutar ÷ miktar
|
||||
pub unit_price_e6: Option<i64>,
|
||||
pub total: i64,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TxChanges {
|
||||
pub date: Option<String>,
|
||||
pub quantity_e6: Option<i64>,
|
||||
pub unit_price_e6: Option<i64>,
|
||||
pub total: Option<i64>,
|
||||
/// `Some("")` → notu kaldır
|
||||
pub note: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
}
|
||||
|
||||
fn assets(db: &Database) -> mongodb::Collection<Asset> {
|
||||
db.collection(col::ASSETS)
|
||||
}
|
||||
|
||||
fn txs(db: &Database) -> mongodb::Collection<AssetTx> {
|
||||
db.collection(col::ASSET_TRANSACTIONS)
|
||||
}
|
||||
|
||||
// ---------- Varlıklar ----------
|
||||
|
||||
/// Hanenin varlıkları (sıralı) ve her birinin hesaplanmış durumu + işlem sayısı
|
||||
pub async fn list(db: &Database, household_id: Uuid) -> AppResult<Vec<(Asset, Holding, usize)>> {
|
||||
let mut cursor = assets(db)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "sort_order": 1, "_id": 1 })
|
||||
.await?;
|
||||
let mut list = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
list.push(cursor.deserialize_current()?);
|
||||
}
|
||||
|
||||
let mut by_asset: HashMap<Uuid, Vec<AssetTx>> = HashMap::new();
|
||||
for t in all_txs(db, household_id, None).await? {
|
||||
by_asset.entry(t.asset_id).or_default().push(t);
|
||||
}
|
||||
list.into_iter()
|
||||
.map(|a: Asset| {
|
||||
let ts = by_asset.remove(&a.id).unwrap_or_default();
|
||||
let h = holding::holding(&moves(&ts))
|
||||
.map_err(|_| AppError::Internal(format!("varlık {} eksi bakiyede", a.id)))?;
|
||||
Ok((a, h, ts.len()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<Asset> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
assets(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Kullanıcının eklediği döviz (ör. GBP). Kod hanede tekil.
|
||||
pub async fn create(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
code: &str,
|
||||
name: &str,
|
||||
unit_label: Option<&str>,
|
||||
) -> AppResult<Asset> {
|
||||
let code = code.trim().to_uppercase();
|
||||
if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Döviz kodu 3 harf olmalı (ör. GBP)".into(),
|
||||
));
|
||||
}
|
||||
let name = clean_name(name)?;
|
||||
let mut f = scope(household_id);
|
||||
f.insert("code", &code);
|
||||
if assets(db).find_one(f).await?.is_some() {
|
||||
return Err(AppError::Conflict("Bu varlık zaten var".into()));
|
||||
}
|
||||
let last = assets(db)
|
||||
.find_one(scope(household_id))
|
||||
.sort(doc! { "sort_order": -1 })
|
||||
.await?
|
||||
.map_or(0, |a| a.sort_order);
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("kind", "fx");
|
||||
d.insert("code", &code);
|
||||
d.insert("name", name);
|
||||
d.insert(
|
||||
"unit_label",
|
||||
unit_label
|
||||
.map(str::trim)
|
||||
.filter(|u| !u.is_empty())
|
||||
.unwrap_or(&code),
|
||||
);
|
||||
d.insert("sort_order", last + 10);
|
||||
d.insert("is_active", true);
|
||||
db.collection::<Document>(col::ASSETS).insert_one(d).await?;
|
||||
assets(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen varlık okunamadı".into()))
|
||||
}
|
||||
|
||||
/// Ad ve aktiflik. Pasif varlık listede kalır (geçmiş işlemler) ama yeni işlem alınmaz.
|
||||
pub async fn update(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Asset> {
|
||||
let current = get(db, household_id, id).await?;
|
||||
if version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
let mut set = doc! { "updated_at": now() };
|
||||
if let Some(n) = name {
|
||||
set.insert("name", clean_name(&n)?);
|
||||
}
|
||||
if let Some(a) = is_active {
|
||||
set.insert("is_active", a);
|
||||
}
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
assets(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
// ---------- İşlemler ----------
|
||||
|
||||
/// Varlığın işlemleri, en yeni başta
|
||||
pub async fn list_txs(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
asset_id: Uuid,
|
||||
) -> AppResult<Vec<AssetTx>> {
|
||||
get(db, household_id, asset_id).await?;
|
||||
let mut list = all_txs(db, household_id, Some(asset_id)).await?;
|
||||
list.reverse();
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
pub async fn create_tx(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
asset_id: Uuid,
|
||||
t: NewTx,
|
||||
) -> AppResult<AssetTx> {
|
||||
let asset = get(db, household_id, asset_id).await?;
|
||||
if !asset.is_active {
|
||||
return Err(AppError::BadRequest(
|
||||
"Bu varlık pasif; önce etkinleştirin".into(),
|
||||
));
|
||||
}
|
||||
let date = clean_date(&t.date)?;
|
||||
let (quantity_e6, unit_price_e6, total) =
|
||||
clean_amounts(&asset, t.quantity_e6, t.unit_price_e6, t.total)?;
|
||||
let note = clean_note(t.note.as_deref())?;
|
||||
|
||||
// Yeni kayıt dahil sıralı denetim (yeni kayıt aynı günün sonuna eklenir)
|
||||
let list = all_txs(db, household_id, Some(asset_id)).await?;
|
||||
let pos = list.partition_point(|x| x.date <= date);
|
||||
let mut ms = moves(&list);
|
||||
ms.insert(
|
||||
pos,
|
||||
Move {
|
||||
tx_type: t.tx_type,
|
||||
quantity_e6,
|
||||
total,
|
||||
},
|
||||
);
|
||||
check(&ms)?;
|
||||
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("asset_id", asset_id);
|
||||
d.insert("type", t.tx_type.as_str());
|
||||
d.insert("date", date);
|
||||
d.insert("quantity_e6", quantity_e6);
|
||||
d.insert("unit_price_e6", unit_price_e6);
|
||||
d.insert("total", total);
|
||||
d.insert("note", note.map_or(Bson::Null, Bson::String));
|
||||
d.insert("source_period_id", Bson::Null);
|
||||
d.insert("created_by", user_id);
|
||||
db.collection::<Document>(col::ASSET_TRANSACTIONS)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
txs(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("eklenen işlem okunamadı".into()))
|
||||
}
|
||||
|
||||
pub async fn update_tx(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
id: Uuid,
|
||||
ch: TxChanges,
|
||||
) -> AppResult<AssetTx> {
|
||||
let current = get_tx(db, household_id, id).await?;
|
||||
if ch.version.is_some_and(|v| v != current.version) {
|
||||
return Err(stale());
|
||||
}
|
||||
let asset = get(db, household_id, current.asset_id).await?;
|
||||
let date = match ch.date {
|
||||
Some(d) => clean_date(&d)?,
|
||||
None => current.date.clone(),
|
||||
};
|
||||
// Miktar ya da tutar değiştiyse birim fiyat (verilmediyse) yeniden hesaplanır
|
||||
let amounts_changed = ch.quantity_e6.is_some() || ch.total.is_some();
|
||||
let (quantity_e6, unit_price_e6, total) = clean_amounts(
|
||||
&asset,
|
||||
ch.quantity_e6.unwrap_or(current.quantity_e6),
|
||||
ch.unit_price_e6
|
||||
.or((!amounts_changed).then_some(current.unit_price_e6)),
|
||||
ch.total.unwrap_or(current.total),
|
||||
)?;
|
||||
|
||||
// Değişmiş haliyle sıralı denetim
|
||||
let mut list: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| x.id != id)
|
||||
.collect();
|
||||
let mut changed = current.clone();
|
||||
changed.date = date.clone();
|
||||
changed.quantity_e6 = quantity_e6;
|
||||
changed.total = total;
|
||||
// MongoDB `_id` (UUID) sırası bayt sırasıdır
|
||||
let pos =
|
||||
list.partition_point(|x| (x.date.as_str(), x.id.bytes()) <= (date.as_str(), id.bytes()));
|
||||
list.insert(pos, changed);
|
||||
check(&moves(&list))?;
|
||||
|
||||
let mut set = doc! {
|
||||
"updated_at": now(),
|
||||
"date": date,
|
||||
"quantity_e6": quantity_e6,
|
||||
"unit_price_e6": unit_price_e6,
|
||||
"total": total,
|
||||
};
|
||||
if let Some(n) = ch.note {
|
||||
set.insert(
|
||||
"note",
|
||||
clean_note(Some(&n))?.map_or(Bson::Null, Bson::String),
|
||||
);
|
||||
}
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
f.insert("version", current.version);
|
||||
txs(db)
|
||||
.find_one_and_update(f, doc! { "$set": set, "$inc": { "version": 1 } })
|
||||
.return_document(ReturnDocument::After)
|
||||
.await?
|
||||
.ok_or_else(stale)
|
||||
}
|
||||
|
||||
/// Soft delete. Bir alımı silmek sonraki satışları eksiye düşürecekse reddedilir.
|
||||
pub async fn delete_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<()> {
|
||||
let current = get_tx(db, household_id, id).await?;
|
||||
let rest: Vec<AssetTx> = all_txs(db, household_id, Some(current.asset_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|x| x.id != id)
|
||||
.collect();
|
||||
if holding::holding(&moves(&rest)).is_err() {
|
||||
return Err(AppError::Conflict(
|
||||
"Bu alım silinirse sonraki satışlar eldeki miktarı aşar; önce satışı düzeltin".into(),
|
||||
));
|
||||
}
|
||||
let ts = now();
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
txs(db)
|
||||
.update_one(
|
||||
f,
|
||||
doc! { "$set": { "deleted_at": ts, "updated_at": ts }, "$inc": { "version": 1 } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- yardımcılar ----------
|
||||
|
||||
async fn get_tx(db: &Database, household_id: Uuid, id: Uuid) -> AppResult<AssetTx> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("_id", id);
|
||||
txs(db).find_one(f).await?.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Hanenin (ya da bir varlığın) işlemleri, **tarih sırasıyla** (aynı gün: ekleniş sırası;
|
||||
/// UUID v7 zamana göre sıralı)
|
||||
async fn all_txs(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
asset_id: Option<Uuid>,
|
||||
) -> AppResult<Vec<AssetTx>> {
|
||||
let mut f = scope(household_id);
|
||||
if let Some(a) = asset_id {
|
||||
f.insert("asset_id", a);
|
||||
}
|
||||
let mut cursor = txs(db).find(f).sort(doc! { "date": 1, "_id": 1 }).await?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await? {
|
||||
out.push(cursor.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn moves(list: &[AssetTx]) -> Vec<Move> {
|
||||
list.iter()
|
||||
.map(|t| Move {
|
||||
tx_type: t.tx_type,
|
||||
quantity_e6: t.quantity_e6,
|
||||
total: t.total,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn check(ms: &[Move]) -> AppResult<()> {
|
||||
holding::holding(ms).map(|_| ()).map_err(|Oversold(_)| {
|
||||
AppError::BadRequest("Satış miktarı o tarihte eldeki miktarı aşıyor".into())
|
||||
})
|
||||
}
|
||||
|
||||
/// (miktar, birim fiyat, tutar). TL varlığında miktar = tutar, fiyat 1.
|
||||
fn clean_amounts(
|
||||
asset: &Asset,
|
||||
quantity_e6: i64,
|
||||
unit_price_e6: Option<i64>,
|
||||
total: i64,
|
||||
) -> AppResult<(i64, i64, i64)> {
|
||||
if total <= 0 || total > MAX_AMOUNT {
|
||||
return Err(AppError::BadRequest("Tutar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
if asset.kind == AssetKind::Cash {
|
||||
// kuruş → TL × 1e6
|
||||
return Ok((total * 10_000, ONE_E6, total));
|
||||
}
|
||||
if quantity_e6 <= 0 || quantity_e6 > MAX_E6 {
|
||||
return Err(AppError::BadRequest("Miktar sıfırdan büyük olmalı".into()));
|
||||
}
|
||||
let price = match unit_price_e6 {
|
||||
Some(p) if p <= 0 || p > MAX_E6 => {
|
||||
return Err(AppError::BadRequest("Birim fiyat geçersiz".into()));
|
||||
}
|
||||
Some(p) => p,
|
||||
None => holding::unit_price_e6(total, quantity_e6),
|
||||
};
|
||||
Ok((quantity_e6, price, total))
|
||||
}
|
||||
|
||||
fn clean_date(s: &str) -> AppResult<String> {
|
||||
let s = s.trim();
|
||||
parse_date(s).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn clean_name(s: &str) -> AppResult<String> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || s.chars().count() > 60 {
|
||||
return Err(AppError::BadRequest("Ad 1–60 karakter olmalı".into()));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
}
|
||||
|
||||
fn clean_note(n: Option<&str>) -> AppResult<Option<String>> {
|
||||
match n.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some(s) if s.chars().count() > MAX_NOTE => Err(AppError::BadRequest(format!(
|
||||
"Not en fazla {MAX_NOTE} karakter olabilir"
|
||||
))),
|
||||
Some(s) => Ok(Some(s.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn stale() -> AppError {
|
||||
AppError::Conflict("Kayıt başka biri tarafından değiştirildi, sayfayı yenileyin".into())
|
||||
}
|
||||
Reference in New Issue
Block a user