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,294 @@
|
||||
//! Birikim / varlık uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/assets` | varlıklar + eldeki miktar, maliyet, ortalama maliyet, gerçekleşen kâr/zarar |
|
||||
//! | POST | `/api/assets` | döviz ekle (ör. GBP) |
|
||||
//! | PATCH | `/api/assets/{id}` | ad / aktiflik |
|
||||
//! | GET | `/api/assets/{id}/transactions` | alım/satım geçmişi (en yeni başta) |
|
||||
//! | POST | `/api/assets/{id}/transactions` | alım / satım ekle |
|
||||
//! | PATCH | `/api/asset-transactions/{id}` | işlemi düzenle |
|
||||
//! | DELETE | `/api/asset-transactions/{id}` | işlemi sil (soft) |
|
||||
|
||||
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::asset::{Asset, AssetKind, AssetTx, TxType},
|
||||
services::{
|
||||
asset::{self, NewTx, TxChanges},
|
||||
holding::Holding,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "Asset")]
|
||||
pub struct AssetDto {
|
||||
id: String,
|
||||
kind: AssetKind,
|
||||
code: String,
|
||||
name: String,
|
||||
unit_label: String,
|
||||
sort_order: i32,
|
||||
is_active: bool,
|
||||
version: i32,
|
||||
/// Eldeki miktar (× 1e6)
|
||||
#[ts(type = "number")]
|
||||
quantity_e6: i64,
|
||||
/// Eldeki miktarın maliyeti (kuruş)
|
||||
#[ts(type = "number")]
|
||||
cost: i64,
|
||||
/// Birim başına ortalama maliyet (TL × 1e6); elde yoksa `null`
|
||||
#[ts(type = "number | null")]
|
||||
avg_cost_e6: Option<i64>,
|
||||
/// Satışlardan gerçekleşen kâr/zarar (kuruş)
|
||||
#[ts(type = "number")]
|
||||
realized_pl: i64,
|
||||
tx_count: u32,
|
||||
}
|
||||
|
||||
impl AssetDto {
|
||||
fn new(a: Asset, h: Holding, tx_count: usize) -> Self {
|
||||
Self {
|
||||
id: a.id.to_string(),
|
||||
kind: a.kind,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
unit_label: a.unit_label,
|
||||
sort_order: a.sort_order,
|
||||
is_active: a.is_active,
|
||||
version: a.version,
|
||||
quantity_e6: h.quantity_e6,
|
||||
cost: h.cost,
|
||||
avg_cost_e6: h.avg_cost_e6,
|
||||
realized_pl: h.realized_pl,
|
||||
tx_count: tx_count as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "AssetTransaction")]
|
||||
pub struct TxDto {
|
||||
id: String,
|
||||
asset_id: String,
|
||||
#[serde(rename = "type")]
|
||||
#[ts(rename = "type")]
|
||||
tx_type: TxType,
|
||||
date: String,
|
||||
#[ts(type = "number")]
|
||||
quantity_e6: i64,
|
||||
/// TL × 1e6
|
||||
#[ts(type = "number")]
|
||||
unit_price_e6: i64,
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
note: Option<String>,
|
||||
/// Dönem kapanışından geldiyse
|
||||
source_period_id: Option<String>,
|
||||
version: i32,
|
||||
}
|
||||
|
||||
impl From<AssetTx> for TxDto {
|
||||
fn from(t: AssetTx) -> Self {
|
||||
Self {
|
||||
id: t.id.to_string(),
|
||||
asset_id: t.asset_id.to_string(),
|
||||
tx_type: t.tx_type,
|
||||
date: t.date,
|
||||
quantity_e6: t.quantity_e6,
|
||||
unit_price_e6: t.unit_price_e6,
|
||||
total: t.total,
|
||||
note: t.note,
|
||||
source_period_id: t.source_period_id.map(|u| u.to_string()),
|
||||
version: t.version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetCreateRequest", optional_fields)]
|
||||
pub struct CreateRequest {
|
||||
/// 3 harfli döviz kodu (ör. `GBP`)
|
||||
code: String,
|
||||
name: String,
|
||||
/// Boşsa kod
|
||||
unit_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetUpdateRequest", optional_fields)]
|
||||
pub struct UpdateRequest {
|
||||
name: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetTxCreateRequest", optional_fields)]
|
||||
pub struct TxCreateRequest {
|
||||
#[serde(rename = "type")]
|
||||
#[ts(rename = "type")]
|
||||
tx_type: TxType,
|
||||
date: String,
|
||||
/// × 1e6; TL varlığında yok sayılır
|
||||
#[ts(type = "number", optional)]
|
||||
quantity_e6: Option<i64>,
|
||||
/// TL × 1e6; verilmezse tutar ÷ miktar
|
||||
#[ts(type = "number", optional)]
|
||||
unit_price_e6: Option<i64>,
|
||||
/// Kuruş — ödenen / alınan gerçek tutar
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "AssetTxUpdateRequest", optional_fields)]
|
||||
pub struct TxUpdateRequest {
|
||||
date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
quantity_e6: Option<i64>,
|
||||
#[ts(type = "number", optional)]
|
||||
unit_price_e6: Option<i64>,
|
||||
#[ts(type = "number", optional)]
|
||||
total: Option<i64>,
|
||||
/// Boş metin → notu kaldır
|
||||
note: Option<String>,
|
||||
version: Option<i32>,
|
||||
}
|
||||
|
||||
fn path_id(id: &str) -> AppResult<Uuid> {
|
||||
Uuid::parse_str(id).map_err(|_| AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn list(State(s): State<AppState>, auth: AuthUser) -> AppResult<Json<Vec<AssetDto>>> {
|
||||
let items = asset::list(&s.db, auth.household_id).await?;
|
||||
Ok(Json(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|(a, h, n)| AssetDto::new(a, h, n))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<AssetDto>)> {
|
||||
let a = asset::create(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
&req.code,
|
||||
&req.name,
|
||||
req.unit_label.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(AssetDto::new(a, Holding::default(), 0)),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<UpdateRequest>,
|
||||
) -> AppResult<Json<AssetDto>> {
|
||||
let id = path_id(&id)?;
|
||||
asset::update(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
id,
|
||||
req.name,
|
||||
req.is_active,
|
||||
req.version,
|
||||
)
|
||||
.await?;
|
||||
// Güncel durumla birlikte dön
|
||||
let (a, h, n) = asset::list(&s.db, auth.household_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|(a, _, _)| a.id == id)
|
||||
.ok_or(AppError::NotFound)?;
|
||||
Ok(Json(AssetDto::new(a, h, n)))
|
||||
}
|
||||
|
||||
pub async fn list_txs(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Vec<TxDto>>> {
|
||||
let items = asset::list_txs(&s.db, auth.household_id, path_id(&id)?).await?;
|
||||
Ok(Json(items.into_iter().map(TxDto::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn create_tx(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<TxCreateRequest>,
|
||||
) -> AppResult<(StatusCode, Json<TxDto>)> {
|
||||
let t = asset::create_tx(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
auth.user_id,
|
||||
path_id(&id)?,
|
||||
NewTx {
|
||||
tx_type: req.tx_type,
|
||||
date: req.date,
|
||||
quantity_e6: req.quantity_e6.unwrap_or(0),
|
||||
unit_price_e6: req.unit_price_e6,
|
||||
total: req.total,
|
||||
note: req.note,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(t.into())))
|
||||
}
|
||||
|
||||
pub async fn update_tx(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
ValidJson(req): ValidJson<TxUpdateRequest>,
|
||||
) -> AppResult<Json<TxDto>> {
|
||||
let t = asset::update_tx(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
path_id(&id)?,
|
||||
TxChanges {
|
||||
date: req.date,
|
||||
quantity_e6: req.quantity_e6,
|
||||
unit_price_e6: req.unit_price_e6,
|
||||
total: req.total,
|
||||
note: req.note,
|
||||
version: req.version,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(t.into()))
|
||||
}
|
||||
|
||||
pub async fn delete_tx(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<StatusCode> {
|
||||
asset::delete_tx(&s.db, auth.household_id, path_id(&id)?).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! API route'larının tek giriş noktası. Tüm uç noktalar `/api` altında.
|
||||
|
||||
mod asset;
|
||||
mod auth;
|
||||
mod budget;
|
||||
mod catalog;
|
||||
@@ -98,6 +99,16 @@ pub fn router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/entries/{id}", patch(entry::update).delete(entry::delete))
|
||||
.route("/entries/{id}/restore", post(entry::restore))
|
||||
.route("/assets", get(asset::list).post(asset::create))
|
||||
.route("/assets/{id}", patch(asset::update))
|
||||
.route(
|
||||
"/assets/{id}/transactions",
|
||||
get(asset::list_txs).post(asset::create_tx),
|
||||
)
|
||||
.route(
|
||||
"/asset-transactions/{id}",
|
||||
patch(asset::update_tx).delete(asset::delete_tx),
|
||||
)
|
||||
// Bilinmeyen /api/... → JSON 404 (arayüzün index.html'i dönmesin)
|
||||
.fallback(|| async { AppError::NotFound });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user