Compare commits
6 Commits
48554e0ff6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d1a24afc3c | |||
| 4d764689e2 | |||
| 33f64a0459 | |||
| feb2e1d16c | |||
| 87409590a6 | |||
| 8c9c3c2506 |
@@ -377,3 +377,23 @@ GET {{baseUrl}}/savings/pending
|
||||
|
||||
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
|
||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
|
||||
|
||||
# ─────────────────────────── Raporlar ───────────────────────────
|
||||
|
||||
### Harcama kırılımı: grup, tüm kategoriler, ödeme türü, ay (filtreler isteğe bağlı)
|
||||
GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
|
||||
|
||||
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
|
||||
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
|
||||
|
||||
# ─────────────────────────── CSV dışa / içe aktarma ───────────────────────────
|
||||
|
||||
### Harcamaları CSV olarak indir (tarih aralığı isteğe bağlı)
|
||||
GET {{baseUrl}}/export/entries.csv?from=2026-01-01&to=2026-12-31
|
||||
|
||||
### İçe aktarma denemesi (hiçbir şey yazılmaz); commit=true → geçerli satırlar yazılır
|
||||
POST {{baseUrl}}/import/entries?commit=false
|
||||
Content-Type: text/csv; charset=utf-8
|
||||
|
||||
Tarih;Tutar;Kategori;Açıklama
|
||||
13.11.2026;45,90;Market;Ekmek
|
||||
|
||||
@@ -69,6 +69,8 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
vec![
|
||||
idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }),
|
||||
idx(doc! { "household_id": 1, "date": 1 }),
|
||||
// Ekstre içe aktarmada tekrar tespiti
|
||||
idx(doc! { "household_id": 1, "import_key": 1 }),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -83,6 +85,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
col::ASSET_TRANSACTIONS,
|
||||
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
|
||||
),
|
||||
(
|
||||
col::IMPORT_RULES,
|
||||
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
|
||||
),
|
||||
(
|
||||
col::EXCHANGE_RATES,
|
||||
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
|
||||
|
||||
@@ -70,6 +70,8 @@ pub mod col {
|
||||
pub const ASSETS: &str = "assets";
|
||||
pub const ASSET_TRANSACTIONS: &str = "asset_transactions";
|
||||
pub const EXCHANGE_RATES: &str = "exchange_rates";
|
||||
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
|
||||
pub const IMPORT_RULES: &str = "import_rules";
|
||||
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
|
||||
|
||||
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
|
||||
@@ -87,5 +89,6 @@ pub mod col {
|
||||
NOTES,
|
||||
ASSETS,
|
||||
ASSET_TRANSACTIONS,
|
||||
IMPORT_RULES,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -34,6 +34,16 @@ impl Group {
|
||||
}
|
||||
}
|
||||
|
||||
/// Türkçe ad (CSV dışa/içe aktarma; arayüz metinleri `frontend/src/lib/groups.ts`)
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Group::Needs => "Zorunlu",
|
||||
Group::Wants => "İstek",
|
||||
Group::Culture => "Kültürel",
|
||||
Group::Unexpected => "Beklenmeyen",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ekranda gösterim sırası
|
||||
pub fn order(self) -> usize {
|
||||
Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
|
||||
|
||||
@@ -16,6 +16,10 @@ pub enum PeriodStatus {
|
||||
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||
Correcting,
|
||||
/// Geçmiş (arşiv): ekstre içe aktarımıyla yıl başından beri olan aylar için açılır.
|
||||
/// Yalnızca kayıt tutar: plan/bütçe zorunlu değil, kapatılmaz, devir üretmez ve
|
||||
/// devir zincirinde atlanır; bütçe önerilerini ("önceki dönem", "son 3 ay") besler.
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl PeriodStatus {
|
||||
@@ -24,6 +28,7 @@ impl PeriodStatus {
|
||||
PeriodStatus::Open => "open",
|
||||
PeriodStatus::Closed => "closed",
|
||||
PeriodStatus::Correcting => "correcting",
|
||||
PeriodStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Banka / kart ekstresi içe aktarma (hanenin tüm üyeleri). Dosya tarayıcıda okunur;
|
||||
//! buraya eşleştirilmiş satırlar gelir.
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | POST | `/api/bank-import/preview` | satır başına öneri (tür, kategori), tekrar, dönem; açılacak arşiv dönemleri |
|
||||
//! | POST | `/api/bank-import/commit` | kullanıcının seçimiyle aktar; seçimleri kural olarak öğren |
|
||||
|
||||
use axum::{Json, extract::State};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
services::{
|
||||
bank_import::{self, Direction, Kind, Row, Source, Target},
|
||||
calendar::{default_period_name, format_date, parse_date},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, rename = "BankRowIn")]
|
||||
pub struct RowIn {
|
||||
/// `"YYYY-MM-DD"`
|
||||
date: String,
|
||||
description: String,
|
||||
/// Kuruş, > 0
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
#[ts(type = "\"in\" | \"out\"")]
|
||||
direction: Direction,
|
||||
/// Yalnızca aktarmada (kullanıcının seçimi)
|
||||
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"", optional)]
|
||||
kind: Option<Kind>,
|
||||
#[ts(optional)]
|
||||
category_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "BankPreviewRequest")]
|
||||
pub struct PreviewRequest {
|
||||
#[ts(type = "\"card\" | \"account\"")]
|
||||
source: Source,
|
||||
rows: Vec<RowIn>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "BankCommitRequest", optional_fields)]
|
||||
pub struct CommitRequest {
|
||||
/// Kayıtlara yazılacak ödeme türü (ör. Kredi Kartı)
|
||||
payment_method_id: Option<String>,
|
||||
rows: Vec<RowIn>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankPreviewRow")]
|
||||
struct PreviewRowDto {
|
||||
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"")]
|
||||
kind: &'static str,
|
||||
category_id: Option<String>,
|
||||
/// Öneri hanenin öğrendiği kuraldan
|
||||
from_rule: bool,
|
||||
/// Daha önce aktarılmış (atlanacak)
|
||||
duplicate: bool,
|
||||
/// `existing` · `archive` · `blocked`
|
||||
#[ts(type = "\"existing\" | \"archive\" | \"blocked\"")]
|
||||
period: &'static str,
|
||||
/// Dönem adı (var olan ya da açılacak arşiv)
|
||||
period_name: Option<String>,
|
||||
/// Engel nedeni
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankPreview")]
|
||||
pub struct PreviewDto {
|
||||
rows: Vec<PreviewRowDto>,
|
||||
/// Açılacak arşiv dönemleri
|
||||
archives: Vec<ArchiveDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankArchive")]
|
||||
struct ArchiveDto {
|
||||
name: String,
|
||||
start_date: String,
|
||||
/// Son gün, dahil
|
||||
last_date: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankImportReport")]
|
||||
pub struct ReportDto {
|
||||
entries: u32,
|
||||
refunds: u32,
|
||||
incomes: u32,
|
||||
ignored: u32,
|
||||
duplicates: u32,
|
||||
/// Açılan arşiv dönemleri
|
||||
archives: Vec<String>,
|
||||
errors: Vec<RowError>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankRowError")]
|
||||
struct RowError {
|
||||
/// Satır sırası (0'dan)
|
||||
index: u32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn rows_of(rows: Vec<RowIn>) -> AppResult<Vec<Row>> {
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
let category_id = match r.category_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some(id) => Some(
|
||||
Uuid::parse_str(id)
|
||||
.map_err(|_| AppError::BadRequest("Kategori geçersiz".into()))?,
|
||||
),
|
||||
};
|
||||
Ok(Row {
|
||||
date: r.date.trim().to_string(),
|
||||
description: r.description,
|
||||
amount: r.amount,
|
||||
direction: r.direction,
|
||||
kind: r.kind,
|
||||
category_id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn archive_name(start: &str, end: &str) -> String {
|
||||
match (parse_date(start), parse_date(end)) {
|
||||
(Some(s), Some(e)) => default_period_name(s, e),
|
||||
_ => start.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<PreviewRequest>,
|
||||
) -> AppResult<Json<PreviewDto>> {
|
||||
let rows = rows_of(req.rows)?;
|
||||
let p = bank_import::preview(&s.db, auth.household_id, req.source, &rows).await?;
|
||||
Ok(Json(PreviewDto {
|
||||
rows: p
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (period, period_name, message) = match r.target {
|
||||
Target::Existing { name, .. } => ("existing", Some(name), None),
|
||||
Target::Archive { start, end } => {
|
||||
("archive", Some(archive_name(&start, &end)), None)
|
||||
}
|
||||
Target::Blocked(m) => ("blocked", None, Some(m)),
|
||||
};
|
||||
PreviewRowDto {
|
||||
kind: r.kind.as_str(),
|
||||
category_id: r.category_id.map(|c| c.to_string()),
|
||||
from_rule: r.from_rule,
|
||||
duplicate: r.duplicate,
|
||||
period,
|
||||
period_name,
|
||||
message,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
archives: p
|
||||
.archives
|
||||
.into_iter()
|
||||
.map(|(start, end)| {
|
||||
let last = parse_date(&end)
|
||||
.map_or(end.clone(), |e| format_date(e - time::Duration::days(1)));
|
||||
ArchiveDto {
|
||||
name: archive_name(&start, &end),
|
||||
start_date: start,
|
||||
last_date: last,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn commit(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CommitRequest>,
|
||||
) -> AppResult<Json<ReportDto>> {
|
||||
let pm = match req.payment_method_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some(id) => Some(
|
||||
Uuid::parse_str(id).map_err(|_| AppError::BadRequest("Ödeme türü geçersiz".into()))?,
|
||||
),
|
||||
};
|
||||
let rows = rows_of(req.rows)?;
|
||||
let r = bank_import::commit(&s.db, auth.household_id, auth.user_id, pm, &rows).await?;
|
||||
Ok(Json(ReportDto {
|
||||
entries: r.entries as u32,
|
||||
refunds: r.refunds as u32,
|
||||
incomes: r.incomes as u32,
|
||||
ignored: r.ignored as u32,
|
||||
duplicates: r.duplicates as u32,
|
||||
archives: r.archives,
|
||||
errors: r
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|(i, message)| RowError {
|
||||
index: i as u32,
|
||||
message,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod asset;
|
||||
mod auth;
|
||||
mod bank_import;
|
||||
mod budget;
|
||||
mod catalog;
|
||||
mod entry;
|
||||
@@ -11,12 +12,15 @@ mod note;
|
||||
mod period;
|
||||
mod plan;
|
||||
mod rates;
|
||||
mod reports;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
mod transfer;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
@@ -87,6 +91,26 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/periods/{id}/review", get(review::get))
|
||||
.route("/periods/{id}/daily", get(stats::daily))
|
||||
.route("/stats/periods", get(stats::periods))
|
||||
.route("/reports/breakdown", get(reports::breakdown))
|
||||
.route("/export/entries.csv", get(transfer::export_entries))
|
||||
.route("/export/plan-items.csv", get(transfer::export_plan_items))
|
||||
.route(
|
||||
"/export/asset-transactions.csv",
|
||||
get(transfer::export_asset_txs),
|
||||
)
|
||||
.route(
|
||||
"/bank-import/preview",
|
||||
post(bank_import::preview).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/bank-import/commit",
|
||||
post(bank_import::commit).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/import/{kind}",
|
||||
// Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB
|
||||
post(transfer::import).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/budget",
|
||||
get(budget::get).put(budget::set_monthly),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Rapor uç noktaları (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/reports/breakdown?period_id=&from=&to=&group=&category_id=&payment_method_id=` | harcama: toplam, grup, tüm kategoriler, ödeme türü, ay |
|
||||
//!
|
||||
//! `payment_method_id=none` → ödeme türü girilmemiş kayıtlar. Dönemler arası özet: `/api/stats/periods`.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
models::group::Group,
|
||||
services::reports::{self, Filter},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BreakdownQuery {
|
||||
period_id: Option<String>,
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
group: Option<String>,
|
||||
category_id: Option<String>,
|
||||
payment_method_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportBreakdown")]
|
||||
pub struct BreakdownDto {
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
/// 4 grup, sabit sırada
|
||||
groups: Vec<GroupRow>,
|
||||
/// Harcama olan **tüm** kategoriler, çoktan aza
|
||||
categories: Vec<CategoryRow>,
|
||||
/// Ödeme türleri; `null` = girilmemiş
|
||||
payment_methods: Vec<MethodRow>,
|
||||
/// Aylar (`"YYYY-MM"`), eskiden yeniye
|
||||
months: Vec<MonthRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportGroupRow")]
|
||||
struct GroupRow {
|
||||
group: Group,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportCategoryRow")]
|
||||
struct CategoryRow {
|
||||
category_id: String,
|
||||
group: Group,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportMethodRow")]
|
||||
struct MethodRow {
|
||||
payment_method_id: Option<String>,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ReportMonthRow")]
|
||||
struct MonthRow {
|
||||
/// `"YYYY-MM"`
|
||||
month: String,
|
||||
#[ts(type = "number")]
|
||||
total: i64,
|
||||
#[ts(type = "number")]
|
||||
count: i64,
|
||||
}
|
||||
|
||||
fn opt_id(v: Option<&str>, what: &str) -> AppResult<Option<Uuid>> {
|
||||
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
None => Ok(None),
|
||||
Some(s) => Uuid::parse_str(s)
|
||||
.map(Some)
|
||||
.map_err(|_| AppError::BadRequest(format!("{what} geçersiz"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn breakdown(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<BreakdownQuery>,
|
||||
) -> AppResult<Json<BreakdownDto>> {
|
||||
let group = match q.group.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
|
||||
None => None,
|
||||
Some(g) => Some(
|
||||
Group::ALL
|
||||
.into_iter()
|
||||
.find(|x| x.as_str() == g)
|
||||
.ok_or_else(|| AppError::BadRequest("Grup geçersiz".into()))?,
|
||||
),
|
||||
};
|
||||
let payment_method = match q.payment_method_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some("none") => Some(None),
|
||||
Some(id) => Some(opt_id(Some(id), "Ödeme türü")?),
|
||||
};
|
||||
let filter = Filter {
|
||||
period_id: opt_id(q.period_id.as_deref(), "Dönem")?,
|
||||
from: q.from.filter(|s| !s.trim().is_empty()),
|
||||
to: q.to.filter(|s| !s.trim().is_empty()),
|
||||
group,
|
||||
category_id: opt_id(q.category_id.as_deref(), "Kategori")?,
|
||||
payment_method,
|
||||
};
|
||||
let b = reports::breakdown(&s.db, auth.household_id, filter).await?;
|
||||
Ok(Json(BreakdownDto {
|
||||
total: b.total,
|
||||
count: b.count,
|
||||
groups: b
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|r| GroupRow {
|
||||
group: r.key,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
categories: b
|
||||
.categories
|
||||
.into_iter()
|
||||
.map(|r| CategoryRow {
|
||||
category_id: r.key.category_id.to_string(),
|
||||
group: r.key.group,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
payment_methods: b
|
||||
.methods
|
||||
.into_iter()
|
||||
.map(|r| MethodRow {
|
||||
payment_method_id: r.key.map(|u| u.to_string()),
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
months: b
|
||||
.months
|
||||
.into_iter()
|
||||
.map(|r| MonthRow {
|
||||
month: r.key,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması |
|
||||
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim |
|
||||
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): gelir, sabit gider, devirler, harcanabilir, harcanan, gerçekleşen birikim, birikime aktarılan + toplam birikim |
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -76,11 +76,24 @@ struct PeriodStatDto {
|
||||
last_date: String,
|
||||
status: PeriodStatus,
|
||||
#[ts(type = "number")]
|
||||
income_total: i64,
|
||||
#[ts(type = "number")]
|
||||
fixed_expense_total: i64,
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_debt: i64,
|
||||
#[ts(type = "number")]
|
||||
carryover_surplus: i64,
|
||||
#[ts(type = "number")]
|
||||
spendable: i64,
|
||||
#[ts(type = "number")]
|
||||
spent: i64,
|
||||
/// Harcanan, gruplara göre
|
||||
spent_by_group: GroupAmounts,
|
||||
/// Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
|
||||
#[ts(type = "number")]
|
||||
realized_savings: i64,
|
||||
/// Kapanmamışsa `null`
|
||||
#[ts(type = "number | null")]
|
||||
to_savings: Option<i64>,
|
||||
@@ -100,14 +113,20 @@ pub async fn periods(
|
||||
.map(|x| {
|
||||
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
|
||||
PeriodStatDto {
|
||||
realized_savings: x.realized_savings(),
|
||||
id: x.period.id.to_string(),
|
||||
name: x.period.name,
|
||||
start_date: x.period.start_date,
|
||||
last_date: format_date(end - time::Duration::days(1)),
|
||||
status: x.period.status,
|
||||
spendable: x.spendable,
|
||||
spent: x.spent,
|
||||
spent_by_group: x.spent_by_group.into(),
|
||||
income_total: x.summary.income_total,
|
||||
fixed_expense_total: x.summary.fixed_expense_total,
|
||||
savings_goal: x.summary.savings_goal,
|
||||
carryover_debt: x.summary.carryover_debt,
|
||||
carryover_surplus: x.summary.carryover_surplus,
|
||||
spendable: x.summary.spendable,
|
||||
spent: x.summary.spent_total,
|
||||
spent_by_group: x.summary.spent_by_group.into(),
|
||||
to_savings: x.period.to_savings,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! CSV dışa / içe aktarma (hanenin tüm üyeleri):
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | GET | `/api/export/entries.csv?from=&to=` | harcamalar |
|
||||
//! | GET | `/api/export/plan-items.csv` | plan kalemleri (tüm dönemler) |
|
||||
//! | GET | `/api/export/asset-transactions.csv` | varlık işlemleri |
|
||||
//! | POST | `/api/import/{tür}?commit=false\|true` | gövde: CSV metni → rapor; `commit=true` geçerli satırları yazar |
|
||||
//!
|
||||
//! `{tür}`: `entries` · `plan-items` · `asset-transactions`.
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
services::{
|
||||
calendar::{format_date, today_tr},
|
||||
transfer,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
fn csv_response(name: &str, body: String) -> impl IntoResponse {
|
||||
let file = format!("kakeibo-{name}-{}.csv", format_date(today_tr()));
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{file}\""),
|
||||
),
|
||||
(header::CACHE_CONTROL, "no-store".to_string()),
|
||||
],
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RangeQuery {
|
||||
from: Option<String>,
|
||||
to: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn export_entries(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(q): Query<RangeQuery>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let body = transfer::export_entries(
|
||||
&s.db,
|
||||
auth.household_id,
|
||||
q.from.as_deref().filter(|x| !x.is_empty()),
|
||||
q.to.as_deref().filter(|x| !x.is_empty()),
|
||||
)
|
||||
.await?;
|
||||
Ok(csv_response("harcamalar", body))
|
||||
}
|
||||
|
||||
pub async fn export_plan_items(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let body = transfer::export_plan_items(&s.db, auth.household_id).await?;
|
||||
Ok(csv_response("plan-kalemleri", body))
|
||||
}
|
||||
|
||||
pub async fn export_asset_txs(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let body = transfer::export_asset_txs(&s.db, auth.household_id).await?;
|
||||
Ok(csv_response("varlik-islemleri", body))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportQuery {
|
||||
#[serde(default)]
|
||||
commit: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "ImportReport")]
|
||||
pub struct ReportDto {
|
||||
/// Başlık hariç dolu satır
|
||||
total: u32,
|
||||
/// Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
|
||||
valid: u32,
|
||||
/// Veritabanında zaten olduğu için atlanan
|
||||
duplicates: u32,
|
||||
/// Yazılan (denemede 0)
|
||||
imported: u32,
|
||||
errors: Vec<ImportError>,
|
||||
/// İlk geçerli satırlar (gösterime hazır)
|
||||
preview: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct ImportError {
|
||||
/// Dosyadaki satır numarası (başlık 1)
|
||||
line: u32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn import(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(kind): Path<String>,
|
||||
Query(q): Query<ImportQuery>,
|
||||
body: String,
|
||||
) -> AppResult<Json<ReportDto>> {
|
||||
let r = match kind.as_str() {
|
||||
"entries" => {
|
||||
transfer::import_entries(&s.db, auth.household_id, auth.user_id, &body, q.commit)
|
||||
.await?
|
||||
}
|
||||
"plan-items" => {
|
||||
transfer::import_plan_items(&s.db, auth.household_id, &body, q.commit).await?
|
||||
}
|
||||
"asset-transactions" => {
|
||||
transfer::import_asset_txs(&s.db, auth.household_id, auth.user_id, &body, q.commit)
|
||||
.await?
|
||||
}
|
||||
_ => return Err(AppError::NotFound),
|
||||
};
|
||||
Ok(Json(ReportDto {
|
||||
total: r.total as u32,
|
||||
valid: r.valid as u32,
|
||||
duplicates: r.duplicates as u32,
|
||||
imported: r.imported as u32,
|
||||
errors: r
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|(line, message)| ImportError {
|
||||
line: line as u32,
|
||||
message,
|
||||
})
|
||||
.collect(),
|
||||
preview: r.preview,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
//! Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; Faz 9 Adım 4).
|
||||
//!
|
||||
//! Dosya tarayıcıda okunur (Excel/CSV) ve sütunları eşleştirilir; buraya yalnızca
|
||||
//! **tarih, açıklama, tutar, yön** gelir. Her satır bir türe ayrılır:
|
||||
//! - `expense` → harcama (kategori gerekir)
|
||||
//! - `refund` → **iade**: aynı kategoride eksi tutarlı kayıt (harcamadan düşer)
|
||||
//! - `income` → dönemin planına **gelir** kalemi
|
||||
//! - `ignore` → aktarılmaz (kart borcu ödemesi, kendi hesaplar arası virman…)
|
||||
//!
|
||||
//! Dönem: satırın tarihini içeren yazılabilir dönem; yoksa takvim ayı kadar **arşiv** dönemi
|
||||
//! açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Kapalı dönem ve gelecek tarih → hata.
|
||||
//! Tekrar: `import_key` (tarih|yön|tutar|sadeleştirilmiş açıklama) — aynı ekstre ya da örtüşen
|
||||
//! iki ekstre yüklense de çift kayıt olmaz. Öneri: hanenin öğrenilmiş kuralları (açıklamanın ilk
|
||||
//! iki anlamlı kelimesi), yoksa hazır ipuçları ("kredi kartı ödemesi" → yoksay), yoksa yön.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use time::Date;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::Period,
|
||||
plan_item::PlanKind,
|
||||
},
|
||||
services::{
|
||||
calendar::{format_date, parse_date, today_tr},
|
||||
catalog::{self, Catalog},
|
||||
entry::MAX_DESCRIPTION,
|
||||
period,
|
||||
plan::{self, NewItem},
|
||||
},
|
||||
};
|
||||
|
||||
pub const MAX_ROWS: usize = 5_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Source {
|
||||
/// Kredi kartı: çıkış = harcama, giriş = iade ya da ödeme
|
||||
Card,
|
||||
/// Banka hesabı: çıkış = harcama, giriş = gelir
|
||||
Account,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Direction {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Kind {
|
||||
Expense,
|
||||
Refund,
|
||||
Income,
|
||||
Ignore,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Expense => "expense",
|
||||
Kind::Refund => "refund",
|
||||
Kind::Income => "income",
|
||||
Kind::Ignore => "ignore",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Row {
|
||||
pub date: String,
|
||||
pub description: String,
|
||||
/// Kuruş, > 0
|
||||
pub amount: i64,
|
||||
pub direction: Direction,
|
||||
/// Commit'te kullanıcının seçimi
|
||||
pub kind: Option<Kind>,
|
||||
pub category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// ---------- Metin sadeleştirme ----------
|
||||
|
||||
/// Türkçe harfleri ASCII'ye indir, küçült (bankalar çoğu zaman "ODEME", "SUT" yazar)
|
||||
pub fn fold(s: &str) -> String {
|
||||
s.chars()
|
||||
// "İ".to_lowercase() = "i" + birleşik nokta (U+0307) → önce doğrudan "i"
|
||||
.map(|c| if c == 'İ' { 'i' } else { c })
|
||||
.flat_map(|c| c.to_lowercase())
|
||||
.filter(|c| *c != '\u{307}')
|
||||
.map(|c| match c {
|
||||
'ı' | 'i' | 'î' => 'i',
|
||||
'ş' => 's',
|
||||
'ğ' => 'g',
|
||||
'ü' | 'û' => 'u',
|
||||
'ö' => 'o',
|
||||
'ç' => 'c',
|
||||
'â' => 'a',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Anlamlı kelimeler: harf/rakam dışı ayırıcı; yalnızca rakam ve tek harf atılır
|
||||
fn words(desc: &str) -> Vec<String> {
|
||||
fold(desc)
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 2 && !w.chars().all(|c| c.is_ascii_digit()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kural anahtarı: ilk iki anlamlı kelime (`MIGROS 1234 ISTANBUL TR` → `migros istanbul`)
|
||||
pub fn rule_key(desc: &str) -> String {
|
||||
words(desc)
|
||||
.into_iter()
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş)
|
||||
pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String {
|
||||
let d = match dir {
|
||||
Direction::In => "in",
|
||||
Direction::Out => "out",
|
||||
};
|
||||
format!("{date}|{d}|{amount}|{}", words(desc).join(" "))
|
||||
}
|
||||
|
||||
/// Hazır öneri (kural yoksa): ödeme/virman → yoksay; kartta giriş → iade; hesapta giriş → gelir
|
||||
pub fn default_kind(source: Source, dir: Direction, desc: &str) -> Kind {
|
||||
let f = words(desc).join(" ");
|
||||
let transfer = [
|
||||
"kredi karti",
|
||||
"kart odeme",
|
||||
"kk odeme",
|
||||
"virman",
|
||||
"hesaplar arasi",
|
||||
"kendi hesab",
|
||||
"hesabima",
|
||||
"borc odeme",
|
||||
"odemeniz icin",
|
||||
"tahsilat",
|
||||
]
|
||||
.iter()
|
||||
.any(|h| f.contains(h));
|
||||
match (source, dir) {
|
||||
_ if transfer => Kind::Ignore,
|
||||
(_, Direction::Out) => Kind::Expense,
|
||||
(Source::Card, Direction::In) => {
|
||||
if f.contains("odeme") {
|
||||
Kind::Ignore
|
||||
} else {
|
||||
Kind::Refund
|
||||
}
|
||||
}
|
||||
(Source::Account, Direction::In) => Kind::Income,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Dönem ----------
|
||||
|
||||
fn first_of_month(d: Date) -> Date {
|
||||
d.replace_day(1).expect("1. gün")
|
||||
}
|
||||
|
||||
fn next_month(d: Date) -> Date {
|
||||
let (y, m) = if d.month() == time::Month::December {
|
||||
(d.year() + 1, time::Month::January)
|
||||
} else {
|
||||
(d.year(), d.month().next())
|
||||
};
|
||||
Date::from_calendar_date(y, m, 1).expect("geçerli ay")
|
||||
}
|
||||
|
||||
/// Satırın dönemi
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Target {
|
||||
/// Var olan yazılabilir dönem (açık, düzeltmede ya da arşiv)
|
||||
Existing {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
},
|
||||
/// Açılacak arşiv dönemi [start, end)
|
||||
Archive {
|
||||
start: String,
|
||||
end: String,
|
||||
},
|
||||
Blocked(String),
|
||||
}
|
||||
|
||||
/// Tarih → dönem: içeren dönem; yoksa takvim ayı, var olan dönemlerin dışında kalan kısmı
|
||||
pub fn target(periods: &[Period], date: &str, today: &str) -> Target {
|
||||
if date > today {
|
||||
return Target::Blocked("Gelecek tarihli satır".into());
|
||||
}
|
||||
if let Some(p) = periods
|
||||
.iter()
|
||||
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
|
||||
{
|
||||
return if p.status.is_writable() {
|
||||
Target::Existing {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
}
|
||||
} else {
|
||||
Target::Blocked(format!(
|
||||
"Dönem kapalı ({}); düzeltme için yeniden açın",
|
||||
p.name
|
||||
))
|
||||
};
|
||||
}
|
||||
let d = parse_date(date).expect("doğrulanmış tarih");
|
||||
let (ms, me) = (format_date(first_of_month(d)), format_date(next_month(d)));
|
||||
// Ayın içinde, tarihten önce biten en geç dönem / tarihten sonra başlayan en erken dönem
|
||||
let start = periods
|
||||
.iter()
|
||||
.map(|p| p.end_date.as_str())
|
||||
.filter(|e| *e <= date && *e > ms.as_str())
|
||||
.max()
|
||||
.map_or(ms.clone(), str::to_string);
|
||||
let end = periods
|
||||
.iter()
|
||||
.map(|p| p.start_date.as_str())
|
||||
.filter(|s| *s > date && *s < me.as_str())
|
||||
.min()
|
||||
.map_or(me.clone(), str::to_string);
|
||||
Target::Archive { start, end }
|
||||
}
|
||||
|
||||
// ---------- Kurallar ----------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Rule {
|
||||
pattern: String,
|
||||
kind: Kind,
|
||||
#[serde(default)]
|
||||
category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
async fn rules(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rule>> {
|
||||
let mut c = db
|
||||
.collection::<Rule>(col::IMPORT_RULES)
|
||||
.find(scope(household_id))
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
while c.advance().await? {
|
||||
let r = c.deserialize_current()?;
|
||||
out.insert(r.pattern.clone(), r);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Var olan içe aktarma anahtarları (harcama + plan kalemi) → adet
|
||||
async fn existing_keys(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, usize>> {
|
||||
let mut out = HashMap::new();
|
||||
for coll in [col::ENTRIES, col::PLAN_ITEMS] {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("import_key", doc! { "$type": "string" });
|
||||
let mut c = db
|
||||
.collection::<Document>(coll)
|
||||
.find(f)
|
||||
.projection(doc! { "import_key": 1 })
|
||||
.await?;
|
||||
while c.advance().await? {
|
||||
if let Ok(k) = c.current().get_str("import_key") {
|
||||
*out.entry(k.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn take(m: &mut HashMap<String, usize>, k: &str) -> bool {
|
||||
match m.get_mut(k) {
|
||||
Some(n) if *n > 0 => {
|
||||
*n -= 1;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(r: &Row) -> Result<(), String> {
|
||||
parse_date(&r.date).ok_or_else(|| format!("Tarih geçersiz: {}", r.date))?;
|
||||
if r.amount <= 0 {
|
||||
return Err("Tutar sıfırdan büyük olmalı".into());
|
||||
}
|
||||
period::validate_amount(r.amount).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Önizleme ----------
|
||||
|
||||
pub struct PreviewRow {
|
||||
pub kind: Kind,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// Öneri hanenin öğrenilmiş kuralından mı?
|
||||
pub from_rule: bool,
|
||||
pub duplicate: bool,
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
pub struct Preview {
|
||||
pub rows: Vec<PreviewRow>,
|
||||
/// Açılacak arşiv dönemleri [start, end)
|
||||
pub archives: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn check_rows(rows: &[Row]) -> AppResult<()> {
|
||||
if rows.is_empty() {
|
||||
return Err(AppError::BadRequest("Aktarılacak satır yok".into()));
|
||||
}
|
||||
if rows.len() > MAX_ROWS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Bir seferde en fazla {MAX_ROWS} satır aktarılabilir"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
source: Source,
|
||||
rows: &[Row],
|
||||
) -> AppResult<Preview> {
|
||||
check_rows(rows)?;
|
||||
let periods = period::list(db, household_id).await?;
|
||||
let rules = rules(db, household_id).await?;
|
||||
let mut keys = existing_keys(db, household_id).await?;
|
||||
let today = format_date(today_tr());
|
||||
let mut archives: Vec<(String, String)> = Vec::new();
|
||||
let out = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let target = match validate(r) {
|
||||
Err(e) => Target::Blocked(e),
|
||||
Ok(()) => target(&periods, &r.date, &today),
|
||||
};
|
||||
if let Target::Archive { start, end } = &target
|
||||
&& !archives.iter().any(|(s, _)| s == start)
|
||||
{
|
||||
archives.push((start.clone(), end.clone()));
|
||||
}
|
||||
let duplicate = matches!(target, Target::Existing { .. } | Target::Archive { .. })
|
||||
&& take(
|
||||
&mut keys,
|
||||
&import_key(&r.date, r.direction, r.amount, &r.description),
|
||||
);
|
||||
let rule = rules.get(&rule_key(&r.description));
|
||||
PreviewRow {
|
||||
kind: rule.map_or_else(
|
||||
|| default_kind(source, r.direction, &r.description),
|
||||
|x| x.kind,
|
||||
),
|
||||
category_id: rule.and_then(|x| x.category_id),
|
||||
from_rule: rule.is_some(),
|
||||
duplicate,
|
||||
target,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
archives.sort();
|
||||
Ok(Preview {
|
||||
rows: out,
|
||||
archives,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Aktarma ----------
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
pub entries: usize,
|
||||
pub refunds: usize,
|
||||
pub incomes: usize,
|
||||
pub ignored: usize,
|
||||
pub duplicates: usize,
|
||||
/// Açılan arşiv dönemlerinin adları
|
||||
pub archives: Vec<String>,
|
||||
/// (satır sırası 0'dan, neden)
|
||||
pub errors: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
pub async fn commit(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
payment_method_id: Option<Uuid>,
|
||||
rows: &[Row],
|
||||
) -> AppResult<Report> {
|
||||
check_rows(rows)?;
|
||||
if let Some(pm) = payment_method_id {
|
||||
catalog::find(db, household_id, Catalog::PaymentMethods, pm)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||
}
|
||||
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||
let mut periods = period::list(db, household_id).await?;
|
||||
let mut keys = existing_keys(db, household_id).await?;
|
||||
let today = format_date(today_tr());
|
||||
let mut report = Report::default();
|
||||
let mut entries: Vec<Document> = Vec::new();
|
||||
let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new();
|
||||
let mut learned: HashMap<String, (Kind, Option<Uuid>)> = HashMap::new();
|
||||
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
let kind = r.kind.unwrap_or(Kind::Ignore);
|
||||
let pattern = rule_key(&r.description);
|
||||
if kind == Kind::Ignore {
|
||||
report.ignored += 1;
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, None));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = validate(r) {
|
||||
report.errors.push((i, e));
|
||||
continue;
|
||||
}
|
||||
let key = import_key(&r.date, r.direction, r.amount, &r.description);
|
||||
let pid = match target(&periods, &r.date, &today) {
|
||||
Target::Blocked(e) => {
|
||||
report.errors.push((i, e));
|
||||
continue;
|
||||
}
|
||||
Target::Existing { id, .. } => id,
|
||||
Target::Archive { start, end } => {
|
||||
let p = period::create_archive(
|
||||
db,
|
||||
household_id,
|
||||
parse_date(&start).expect("tarih"),
|
||||
parse_date(&end).expect("tarih"),
|
||||
)
|
||||
.await?;
|
||||
report.archives.push(p.name.clone());
|
||||
let id = p.id;
|
||||
periods.push(p);
|
||||
id
|
||||
}
|
||||
};
|
||||
if take(&mut keys, &key) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
let desc: String = r.description.trim().chars().take(MAX_DESCRIPTION).collect();
|
||||
match kind {
|
||||
Kind::Expense | Kind::Refund => {
|
||||
let Some(cat) = r.category_id.and_then(|c| cats.iter().find(|x| x.id == c)) else {
|
||||
report.errors.push((i, "Kategori seçin".into()));
|
||||
continue;
|
||||
};
|
||||
let Some(group) = cat.group else {
|
||||
report.errors.push((i, "Kategorinin grubu yok".into()));
|
||||
continue;
|
||||
};
|
||||
let amount = if kind == Kind::Refund {
|
||||
-r.amount
|
||||
} else {
|
||||
r.amount
|
||||
};
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("period_id", pid);
|
||||
d.insert("date", &r.date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("category_id", cat.id);
|
||||
d.insert(
|
||||
"payment_method_id",
|
||||
payment_method_id.map_or(Bson::Null, Bson::from),
|
||||
);
|
||||
d.insert(
|
||||
"description",
|
||||
if desc.is_empty() {
|
||||
Bson::Null
|
||||
} else {
|
||||
Bson::String(desc)
|
||||
},
|
||||
);
|
||||
d.insert("created_by", user_id);
|
||||
d.insert("import_key", &key);
|
||||
entries.push(d);
|
||||
if kind == Kind::Refund {
|
||||
report.refunds += 1;
|
||||
} else {
|
||||
report.entries += 1;
|
||||
}
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, Some(cat.id)));
|
||||
}
|
||||
}
|
||||
Kind::Income => {
|
||||
// Plan kalemi adı en fazla 60 karakter
|
||||
let name: String = if desc.is_empty() {
|
||||
"Gelir".into()
|
||||
} else {
|
||||
desc.chars().take(60).collect()
|
||||
};
|
||||
incomes.push((
|
||||
i,
|
||||
pid,
|
||||
NewItem {
|
||||
kind: PlanKind::Income,
|
||||
name,
|
||||
amount: r.amount,
|
||||
date: Some(r.date.clone()),
|
||||
},
|
||||
key,
|
||||
));
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, None));
|
||||
}
|
||||
}
|
||||
Kind::Ignore => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
if !entries.is_empty() {
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_many(entries)
|
||||
.await?;
|
||||
}
|
||||
for (i, pid, item, key) in incomes {
|
||||
match plan::create(db, household_id, pid, item).await {
|
||||
Ok(p) => {
|
||||
db.collection::<Document>(col::PLAN_ITEMS)
|
||||
.update_one(doc! { "_id": p.id }, doc! { "$set": { "import_key": key } })
|
||||
.await?;
|
||||
report.incomes += 1;
|
||||
}
|
||||
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => report.errors.push((i, m)),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Kurallar: bu aktarımdaki seçimler sonraki ekstreler için öneri olur
|
||||
let ts = now();
|
||||
for (pattern, (kind, category_id)) in learned {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("pattern", &pattern);
|
||||
db.collection::<Document>(col::IMPORT_RULES)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"kind": kind.as_str(),
|
||||
"category_id": category_id.map_or(Bson::Null, Bson::from),
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "uses": 1, "version": 1 },
|
||||
"$setOnInsert": { "_id": crate::models::common::new_id(), "created_at": ts },
|
||||
},
|
||||
)
|
||||
.upsert(true)
|
||||
.await?;
|
||||
}
|
||||
report.errors.sort_by_key(|(i, _)| *i);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folding_and_keys() {
|
||||
assert_eq!(fold("ÖDEME Şükrü İĞNE"), "odeme sukru igne");
|
||||
assert_eq!(rule_key("MIGROS 1234 ISTANBUL TR"), "migros istanbul");
|
||||
assert_eq!(rule_key("A101 MAGAZACILIK*456"), "a101 magazacilik");
|
||||
assert_eq!(rule_key("12/11 *"), "");
|
||||
assert_eq!(
|
||||
import_key("2026-11-13", Direction::Out, 4590, " Migros ŞUBE 12 "),
|
||||
"2026-11-13|out|4590|migros sube"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_kinds() {
|
||||
use Direction::*;
|
||||
use Source::*;
|
||||
assert_eq!(default_kind(Card, Out, "MIGROS ISTANBUL"), Kind::Expense);
|
||||
assert_eq!(default_kind(Card, In, "IADE MIGROS"), Kind::Refund);
|
||||
assert_eq!(
|
||||
default_kind(Card, In, "ÖDEMENİZ İÇİN TEŞEKKÜRLER"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(default_kind(Account, In, "MAAŞ ÖDEMESİ ACME"), Kind::Income);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "KREDİ KARTI ÖDEMESİ"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "Hesaplar arası virman"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "ELEKTRIK FATURASI"),
|
||||
Kind::Expense
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -553,7 +553,8 @@ async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri.
|
||||
/// Bu dönemden önceki en yeni `n` kapalı **ya da arşiv** dönemin kimlikleri
|
||||
/// (arşiv: ekstreden aktarılan geçmiş aylar → öneriler gerçek harcamaya dayanır).
|
||||
async fn previous_closed(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
@@ -562,7 +563,10 @@ async fn previous_closed(
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$lt": &p.start_date });
|
||||
f.insert("status", PeriodStatus::Closed.as_str());
|
||||
f.insert(
|
||||
"status",
|
||||
doc! { "$in": [PeriodStatus::Closed.as_str(), PeriodStatus::Archived.as_str()] },
|
||||
);
|
||||
let mut cursor = db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find(f)
|
||||
|
||||
@@ -90,6 +90,11 @@ pub async fn close(
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Closing> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status == PeriodStatus::Archived {
|
||||
return Err(AppError::Conflict(
|
||||
"Arşiv dönemi kapatılmaz; yalnızca kayıt tutar".into(),
|
||||
));
|
||||
}
|
||||
if p.status == PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
|
||||
}
|
||||
@@ -200,7 +205,11 @@ pub async fn reopen(
|
||||
}
|
||||
let mut later = scope(household_id);
|
||||
later.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() });
|
||||
// Arşiv dönemleri devir üretmediği için engel değildir
|
||||
later.insert(
|
||||
"status",
|
||||
doc! { "$nin": [PeriodStatus::Open.as_str(), PeriodStatus::Archived.as_str()] },
|
||||
);
|
||||
if db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.find_one(later)
|
||||
@@ -242,6 +251,8 @@ pub async fn reopen(
|
||||
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 });
|
||||
// Devir arşiv dönemine bağlanmaz (arşiv devir zincirinde yok)
|
||||
f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
|
||||
Ok(db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find_one(f)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! CSV okuma/yazma yardımcıları (saf; veritabanı yok). Türkçe Excel uyumlu:
|
||||
//! yazarken UTF-8 BOM, `;` ayırıcı, CRLF, tutar `1250,50`, tarih `GG.AA.YYYY`.
|
||||
//! Okurken esnek: BOM, `;` / `,` / sekme (başlık satırından anlaşılır), tırnaklı alanlar,
|
||||
//! tutar `1.250,50` · `1250,50` · `1250.50`, tarih `13.11.2026` · `13/11/2026` · `2026-11-13`.
|
||||
|
||||
use crate::services::calendar::{format_date, parse_date};
|
||||
|
||||
pub const BOM: &str = "\u{FEFF}";
|
||||
|
||||
// ---------- Yazma ----------
|
||||
|
||||
fn quote(s: &str) -> String {
|
||||
if s.contains([';', '"', '\n', '\r']) {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Başlık + satırlar → CSV metni (BOM'lu)
|
||||
pub fn write(headers: &[&str], rows: &[Vec<String>]) -> String {
|
||||
let mut out = String::from(BOM);
|
||||
let line = |cells: Vec<String>| cells.iter().map(|c| quote(c)).collect::<Vec<_>>().join(";");
|
||||
out.push_str(&line(headers.iter().map(|h| h.to_string()).collect()));
|
||||
out.push_str("\r\n");
|
||||
for r in rows {
|
||||
out.push_str(&line(r.clone()));
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Kuruş → `1250,50`
|
||||
pub fn money(kurus: i64) -> String {
|
||||
let sign = if kurus < 0 { "-" } else { "" };
|
||||
let a = kurus.unsigned_abs();
|
||||
format!("{sign}{},{:02}", a / 100, a % 100)
|
||||
}
|
||||
|
||||
/// × 1e6 → `5,5` (gereksiz sıfırsız)
|
||||
pub fn e6(v: i64) -> String {
|
||||
let sign = if v < 0 { "-" } else { "" };
|
||||
let a = v.unsigned_abs();
|
||||
let frac = format!("{:06}", a % 1_000_000);
|
||||
let frac = frac.trim_end_matches('0');
|
||||
if frac.is_empty() {
|
||||
format!("{sign}{}", a / 1_000_000)
|
||||
} else {
|
||||
format!("{sign}{},{frac}", a / 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
/// `"2026-11-13"` → `13.11.2026`
|
||||
pub fn date(iso: &str) -> String {
|
||||
match iso.split('-').collect::<Vec<_>>().as_slice() {
|
||||
[y, m, d] => format!("{d}.{m}.{y}"),
|
||||
_ => iso.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Okuma ----------
|
||||
|
||||
/// CSV metni → satırlar (başlık dahil). Boş satırlar atlanır; her satırın dosyadaki numarası.
|
||||
pub fn parse(text: &str) -> Vec<(usize, Vec<String>)> {
|
||||
let text = text.strip_prefix(BOM).unwrap_or(text);
|
||||
let first = text.lines().next().unwrap_or("");
|
||||
let delim = [';', '\t', ',']
|
||||
.into_iter()
|
||||
.max_by_key(|d| first.matches(*d).count())
|
||||
.unwrap_or(';');
|
||||
|
||||
let mut rows = Vec::new();
|
||||
let mut row = Vec::new();
|
||||
let mut cell = String::new();
|
||||
let mut in_quotes = false;
|
||||
let mut line = 1;
|
||||
let mut row_line = 1;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'"' if in_quotes && chars.peek() == Some(&'"') => {
|
||||
cell.push('"');
|
||||
chars.next();
|
||||
}
|
||||
'"' if in_quotes => in_quotes = false,
|
||||
'"' if cell.is_empty() => in_quotes = true,
|
||||
c if c == delim && !in_quotes => row.push(std::mem::take(&mut cell)),
|
||||
'\r' if !in_quotes => {}
|
||||
'\n' if !in_quotes => {
|
||||
row.push(std::mem::take(&mut cell));
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push((row_line, std::mem::take(&mut row)));
|
||||
}
|
||||
row.clear();
|
||||
line += 1;
|
||||
row_line = line;
|
||||
}
|
||||
'\n' => {
|
||||
cell.push(c);
|
||||
line += 1;
|
||||
}
|
||||
c => cell.push(c),
|
||||
}
|
||||
}
|
||||
row.push(cell);
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push((row_line, row));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Başlık adı karşılaştırması: küçük harf (Türkçe İ/I), boşluksuz
|
||||
pub fn norm(s: &str) -> String {
|
||||
s.trim()
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'İ' => 'i',
|
||||
'I' => 'ı',
|
||||
c => c.to_lowercase().next().unwrap_or(c),
|
||||
})
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Başlık satırında adı verilen sütunların sırası (ilk eşleşen ad)
|
||||
pub fn column(headers: &[String], names: &[&str]) -> Option<usize> {
|
||||
let hs: Vec<String> = headers.iter().map(|h| norm(h)).collect();
|
||||
names
|
||||
.iter()
|
||||
.find_map(|n| hs.iter().position(|h| *h == norm(n)))
|
||||
}
|
||||
|
||||
/// Ondalık metin → (tam, kesir) basamakları; Türkçe ve İngilizce yazımı tanır
|
||||
fn split_decimal(s: &str, max_frac: usize) -> Option<(bool, String, String)> {
|
||||
let s: String = s
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '₺' && *c != '\u{a0}')
|
||||
.collect();
|
||||
let (neg, s) = match s.strip_prefix('-') {
|
||||
Some(r) => (true, r.to_string()),
|
||||
None => (false, s),
|
||||
};
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (int, frac) = if let Some((i, f)) = s.rsplit_once(',') {
|
||||
// Virgül ondalık; noktalar binlik
|
||||
(i.replace('.', ""), f.to_string())
|
||||
} else if s.matches('.').count() == 1 {
|
||||
let (i, f) = s.split_once('.').unwrap();
|
||||
// Tek nokta + tam 3 rakam → binlik (1.000), değilse ondalık (12.50)
|
||||
if f.len() == 3 && i != "0" && !i.is_empty() {
|
||||
(format!("{i}{f}"), String::new())
|
||||
} else {
|
||||
(i.to_string(), f.to_string())
|
||||
}
|
||||
} else {
|
||||
(s.replace('.', ""), String::new())
|
||||
};
|
||||
let int = if int.is_empty() { "0".to_string() } else { int };
|
||||
if !int.bytes().all(|b| b.is_ascii_digit())
|
||||
|| !frac.bytes().all(|b| b.is_ascii_digit())
|
||||
|| frac.len() > max_frac
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((neg, int, frac))
|
||||
}
|
||||
|
||||
/// `1.250,50` → 125050 kuruş (en fazla 2 ondalık)
|
||||
pub fn parse_money(s: &str) -> Option<i64> {
|
||||
let (neg, int, frac) = split_decimal(s, 2)?;
|
||||
let v =
|
||||
int.parse::<i64>().ok()?.checked_mul(100)? + format!("{frac:0<2}").parse::<i64>().ok()?;
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
/// `5,5` → 5_500_000 (en fazla 6 ondalık; eksi yok)
|
||||
pub fn parse_e6(s: &str) -> Option<i64> {
|
||||
let (neg, int, frac) = split_decimal(s, 6)?;
|
||||
if neg {
|
||||
return None;
|
||||
}
|
||||
int.parse::<i64>()
|
||||
.ok()?
|
||||
.checked_mul(1_000_000)?
|
||||
.checked_add(format!("{frac:0<6}").parse::<i64>().ok()?)
|
||||
}
|
||||
|
||||
/// `13.11.2026` · `13/11/2026` · `2026-11-13` → `"2026-11-13"`
|
||||
pub fn parse_date_any(s: &str) -> Option<String> {
|
||||
let s = s.trim();
|
||||
let iso = if s.contains('-') && s.len() == 10 && s.as_bytes()[4] == b'-' {
|
||||
s.to_string()
|
||||
} else {
|
||||
let p: Vec<&str> = s.split(['.', '/']).collect();
|
||||
match p.as_slice() {
|
||||
[d, m, y] if y.len() == 4 => format!("{y}-{m:0>2}-{d:0>2}"),
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
parse_date(&iso).map(format_date)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn writes_excel_friendly() {
|
||||
let csv = write(
|
||||
&["Ad", "Not"],
|
||||
&[
|
||||
vec!["Market".into(), "a;b".into()],
|
||||
vec!["\"x\"".into(), String::new()],
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Ad;Not\r\nMarket;\"a;b\"\r\n\"\"\"x\"\"\";\r\n"
|
||||
);
|
||||
assert_eq!(money(125_050), "1250,50");
|
||||
assert_eq!(money(-5), "-0,05");
|
||||
assert_eq!(e6(5_500_000), "5,5");
|
||||
assert_eq!(e6(10_000_000), "10");
|
||||
assert_eq!(e6(125_000), "0,125");
|
||||
assert_eq!(date("2026-11-13"), "13.11.2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_quotes_delimiters_and_line_numbers() {
|
||||
let rows = parse(
|
||||
"\u{FEFF}Tarih;Tutar;Açıklama\r\n13.11.2026;1.250,50;\"Kafe; tatlı\"\r\n\r\n14.11.2026;10;\"iki\nsatır\"\n",
|
||||
);
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(
|
||||
rows[1],
|
||||
(
|
||||
2,
|
||||
vec!["13.11.2026".into(), "1.250,50".into(), "Kafe; tatlı".into()]
|
||||
)
|
||||
);
|
||||
assert_eq!(rows[2].0, 4, "boş satır sayılır ama atlanır");
|
||||
assert_eq!(rows[2].1[2], "iki\nsatır");
|
||||
// Virgül ayraçlı dosya
|
||||
let rows = parse("date,amount\n2026-11-13,\"1,5\"\n");
|
||||
assert_eq!(rows[1].1, vec!["2026-11-13", "1,5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_by_name() {
|
||||
let h: Vec<String> = ["TARİH", "Tutar ", "Ödeme Türü"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
assert_eq!(column(&h, &["Tarih"]), Some(0));
|
||||
assert_eq!(column(&h, &["ödeme türü"]), Some(2));
|
||||
assert_eq!(column(&h, &["Kategori"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_and_dates() {
|
||||
assert_eq!(parse_money("1.250,50"), Some(125_050));
|
||||
assert_eq!(parse_money("1250,5"), Some(125_050));
|
||||
assert_eq!(parse_money("1250.50"), Some(125_050));
|
||||
assert_eq!(parse_money("1.000"), Some(100_000));
|
||||
assert_eq!(parse_money("-45,90 ₺"), Some(-4_590));
|
||||
assert_eq!(parse_money("1,234"), None, "3 ondalık para olmaz");
|
||||
assert_eq!(parse_money("abc"), None);
|
||||
assert_eq!(parse_e6("5,5"), Some(5_500_000));
|
||||
assert_eq!(parse_e6("0.125"), Some(125_000));
|
||||
assert_eq!(parse_e6("-1"), None);
|
||||
assert_eq!(parse_date_any("13.11.2026").as_deref(), Some("2026-11-13"));
|
||||
assert_eq!(parse_date_any("3/1/2026").as_deref(), Some("2026-01-03"));
|
||||
assert_eq!(parse_date_any("2026-11-13").as_deref(), Some("2026-11-13"));
|
||||
assert_eq!(parse_date_any("30.02.2026"), None);
|
||||
assert_eq!(parse_date_any("13.11.26"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı.
|
||||
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı. İstisna: ekstreden gelen **iade**
|
||||
//! kayıtları eksi tutarlıdır (kategorinin harcamasından düşer); düzenlemede eksi kalır.
|
||||
//! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez.
|
||||
//! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı
|
||||
//! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir).
|
||||
@@ -146,7 +147,16 @@ pub async fn update(
|
||||
set.insert("date", clean_date(&date, &p)?);
|
||||
}
|
||||
if let Some(amount) = ch.amount {
|
||||
set.insert("amount", clean_amount(amount)?);
|
||||
// İade kaydı (ekstreden, eksi tutar) iade olarak kalır: tutar eksi girilir
|
||||
let amount = if current.amount < 0 {
|
||||
if amount >= 0 {
|
||||
return Err(AppError::BadRequest("İade tutarı eksi olmalı".into()));
|
||||
}
|
||||
-clean_amount(-amount)?
|
||||
} else {
|
||||
clean_amount(amount)?
|
||||
};
|
||||
set.insert("amount", amount);
|
||||
}
|
||||
if let Some(cid) = ch.category_id {
|
||||
let group = category_group(db, household_id, cid, Some(current.category_id)).await?;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
pub mod account;
|
||||
pub mod asset;
|
||||
pub mod bank_import;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod closing;
|
||||
pub mod csv_io;
|
||||
pub mod entry;
|
||||
pub mod holding;
|
||||
pub mod invite;
|
||||
@@ -15,6 +17,8 @@ pub mod period;
|
||||
pub mod plan;
|
||||
pub mod rate_sources;
|
||||
pub mod rates;
|
||||
pub mod reports;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
pub mod transfer;
|
||||
|
||||
@@ -110,6 +110,8 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
|
||||
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
|
||||
let mut prev_f = scope(household_id);
|
||||
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
|
||||
// Arşiv dönemleri devir zincirinde yok
|
||||
prev_f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
|
||||
let previous = coll(db)
|
||||
.find_one(prev_f)
|
||||
.sort(doc! { "start_date": -1 })
|
||||
@@ -273,6 +275,42 @@ fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult<String> {
|
||||
|
||||
/// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç`
|
||||
/// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir).
|
||||
/// Geçmiş (arşiv) dönem açar: ekstre içe aktarımında dönemi olmayan aylar için.
|
||||
/// Açık dönem kuralına takılmaz (arşiv açık sayılmaz); çakışma yine yasak. Plan, bütçe, devir yok.
|
||||
pub async fn create_archive(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
start: Date,
|
||||
end: Date,
|
||||
) -> AppResult<Period> {
|
||||
if end <= start {
|
||||
return Err(AppError::BadRequest(
|
||||
"Arşiv dönemi tarihleri geçersiz".into(),
|
||||
));
|
||||
}
|
||||
ensure_no_overlap(db, household_id, start, end, None).await?;
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("name", clean_name(None, start, end)?);
|
||||
d.insert("start_date", format_date(start));
|
||||
d.insert("end_date", format_date(end));
|
||||
d.insert("status", PeriodStatus::Archived.as_str());
|
||||
d.insert("savings_goal", 0_i64);
|
||||
d.insert("reflection_md", Bson::Null);
|
||||
d.insert("to_savings", Bson::Null);
|
||||
d.insert("previous_period_id", Bson::Null);
|
||||
d.insert("closed_at", Bson::Null);
|
||||
d.insert("closed_by", Bson::Null);
|
||||
d.insert("reopened_at", Bson::Null);
|
||||
db.collection::<Document>(col::PERIODS)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("arşiv dönemi okunamadı".into()))
|
||||
}
|
||||
|
||||
async fn ensure_no_overlap(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Raporlar (KAKEIBO_PROMPT §8.8). Harcama kırılımı tek aggregation (`$facet`) ile:
|
||||
//! grup, **tüm** kategoriler, ödeme türü, ay. Saklanmaz; her istekte hesaplanır.
|
||||
//!
|
||||
//! Filtre: dönem **ya da** tarih aralığı (ikisi de dahil), grup, kategori, ödeme türü
|
||||
//! (`Some(None)` → ödeme türü girilmemiş kayıtlar). Silinen kayıtlar sayılmaz.
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{common::scope, group::Group},
|
||||
services::{calendar::parse_date, period},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Filter {
|
||||
pub period_id: Option<Uuid>,
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub group: Option<Group>,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// `Some(None)` → ödeme türü boş olanlar
|
||||
pub payment_method: Option<Option<Uuid>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Row<K> {
|
||||
#[serde(rename = "_id")]
|
||||
pub key: K,
|
||||
pub total: i64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CategoryKey {
|
||||
pub category_id: Uuid,
|
||||
pub group: Group,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Facets {
|
||||
totals: Vec<Row<Bson>>,
|
||||
groups: Vec<Row<Group>>,
|
||||
categories: Vec<Row<CategoryKey>>,
|
||||
methods: Vec<Row<Option<Uuid>>>,
|
||||
months: Vec<Row<String>>,
|
||||
}
|
||||
|
||||
pub struct Breakdown {
|
||||
pub total: i64,
|
||||
pub count: i64,
|
||||
/// `Group::ALL` sırasıyla, harcama olmayan grup 0
|
||||
pub groups: Vec<Row<Group>>,
|
||||
/// Çoktan aza (eşitlikte kategori kimliği)
|
||||
pub categories: Vec<Row<CategoryKey>>,
|
||||
/// Çoktan aza; `None` = ödeme türü girilmemiş
|
||||
pub methods: Vec<Row<Option<Uuid>>>,
|
||||
/// `"YYYY-MM"`, eskiden yeniye
|
||||
pub months: Vec<Row<String>>,
|
||||
}
|
||||
|
||||
pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult<Breakdown> {
|
||||
let mut m = scope(household_id);
|
||||
if let Some(pid) = f.period_id {
|
||||
period::get(db, household_id, pid).await?; // bu haneye ait mi?
|
||||
m.insert("period_id", pid);
|
||||
}
|
||||
let mut range = Document::new();
|
||||
for (op, v) in [("$gte", &f.from), ("$lte", &f.to)] {
|
||||
if let Some(d) = v {
|
||||
let d = d.trim();
|
||||
parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||
range.insert(op, d);
|
||||
}
|
||||
}
|
||||
if let (Some(a), Some(b)) = (&f.from, &f.to)
|
||||
&& a.trim() > b.trim()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Başlangıç tarihi bitişten sonra olamaz".into(),
|
||||
));
|
||||
}
|
||||
if !range.is_empty() {
|
||||
m.insert("date", range);
|
||||
}
|
||||
if let Some(g) = f.group {
|
||||
m.insert("group", g.as_str());
|
||||
}
|
||||
if let Some(c) = f.category_id {
|
||||
m.insert("category_id", c);
|
||||
}
|
||||
if let Some(pm) = f.payment_method {
|
||||
m.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
}
|
||||
|
||||
let sum = || doc! { "total": { "$sum": "$amount" }, "count": { "$sum": Bson::Int64(1) } };
|
||||
let group_by = |key: Bson| {
|
||||
let mut g = doc! { "_id": key };
|
||||
g.extend(sum());
|
||||
doc! { "$group": g }
|
||||
};
|
||||
let pipeline = vec![
|
||||
doc! { "$match": m },
|
||||
doc! { "$facet": {
|
||||
"totals": [ group_by(Bson::Null) ],
|
||||
"groups": [ group_by(Bson::String("$group".into())) ],
|
||||
"categories": [
|
||||
group_by(Bson::Document(doc! { "category_id": "$category_id", "group": "$group" })),
|
||||
{ "$sort": { "total": -1, "_id.category_id": 1 } },
|
||||
],
|
||||
"methods": [
|
||||
// Alanı hiç olmayan eski kayıtlar da "girilmemiş" sayılsın
|
||||
group_by(Bson::Document(doc! { "$ifNull": ["$payment_method_id", null] })),
|
||||
{ "$sort": { "total": -1, "_id": 1 } },
|
||||
],
|
||||
"months": [
|
||||
group_by(Bson::Document(doc! { "$substrBytes": ["$date", 0, 7] })),
|
||||
{ "$sort": { "_id": 1 } },
|
||||
],
|
||||
} },
|
||||
];
|
||||
let mut cursor = db
|
||||
.collection::<Document>(col::ENTRIES)
|
||||
.aggregate(pipeline)
|
||||
.with_type::<Facets>()
|
||||
.await?;
|
||||
let f = if cursor.advance().await? {
|
||||
cursor.deserialize_current()?
|
||||
} else {
|
||||
return Err(AppError::Internal("rapor sorgusu boş döndü".into()));
|
||||
};
|
||||
|
||||
let (total, count) = f.totals.first().map_or((0, 0), |r| (r.total, r.count));
|
||||
let groups = Group::ALL
|
||||
.into_iter()
|
||||
.map(|g| {
|
||||
f.groups.iter().find(|r| r.key == g).map_or(
|
||||
Row {
|
||||
key: g,
|
||||
total: 0,
|
||||
count: 0,
|
||||
},
|
||||
|r| Row {
|
||||
key: g,
|
||||
total: r.total,
|
||||
count: r.count,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(Breakdown {
|
||||
total,
|
||||
count,
|
||||
groups,
|
||||
categories: f.categories,
|
||||
methods: f.methods,
|
||||
months: f.months,
|
||||
})
|
||||
}
|
||||
@@ -57,9 +57,17 @@ pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppRes
|
||||
|
||||
pub struct PeriodStat {
|
||||
pub period: Period,
|
||||
pub spendable: i64,
|
||||
pub spent: i64,
|
||||
pub spent_by_group: GroupAmounts,
|
||||
/// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil)
|
||||
pub summary: budget::Summary,
|
||||
}
|
||||
|
||||
impl PeriodStat {
|
||||
/// Değerlendirmenin 4. sorusu: gelir − sabit − borç + artı − harcanan (`review` ile aynı)
|
||||
pub fn realized_savings(&self) -> i64 {
|
||||
let s = &self.summary;
|
||||
s.income_total - s.fixed_expense_total - s.carryover_debt + s.carryover_surplus
|
||||
- s.spent_total
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Stats {
|
||||
@@ -74,13 +82,8 @@ pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResu
|
||||
let total_saved = list.iter().filter_map(|p| p.to_savings).sum();
|
||||
let mut out = Vec::new();
|
||||
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
|
||||
let s = budget::summary(db, household_id, p.id).await?;
|
||||
out.push(PeriodStat {
|
||||
period: p,
|
||||
spendable: s.spendable,
|
||||
spent: s.spent_total,
|
||||
spent_by_group: s.spent_by_group,
|
||||
});
|
||||
let summary = budget::summary(db, household_id, p.id).await?;
|
||||
out.push(PeriodStat { period: p, summary });
|
||||
}
|
||||
out.reverse();
|
||||
Ok(Stats {
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
//! Uygulamanın kendi CSV biçimiyle dışa / içe aktarma (KAKEIBO_PROMPT §8.9):
|
||||
//! harcamalar, plan kalemleri, varlık işlemleri.
|
||||
//!
|
||||
//! İçe aktarma iki aşamalı: `commit = false` → yalnızca rapor (deneme); `true` → geçerli satırlar
|
||||
//! yazılır. Hatalı satırlar satır numarası ve nedeniyle raporlanır, diğerleri etkilenmez.
|
||||
//! **Aynı dosya iki kez yüklense de çift kayıt olmaz:** veritabanında aynısı olan satır atlanır
|
||||
//! (dosyada aynı satırdan iki tane, veritabanında bir tane varsa biri eklenir).
|
||||
//! Sütunlar başlık adıyla bulunur (sıra serbest); kategori / ödeme türü / varlık adla eşleşir.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
asset::{Asset, AssetKind, AssetTx, TxType},
|
||||
common::{new_meta, scope},
|
||||
entry::Entry,
|
||||
group::Group,
|
||||
period::Period,
|
||||
plan_item::{PlanItem, PlanKind},
|
||||
},
|
||||
services::{
|
||||
asset,
|
||||
catalog::{self, Catalog},
|
||||
csv_io::{self, column, norm},
|
||||
entry::MAX_DESCRIPTION,
|
||||
holding::{self, Move},
|
||||
period,
|
||||
plan::{self, NewItem},
|
||||
},
|
||||
};
|
||||
|
||||
/// Bir dosyada en fazla satır
|
||||
pub const MAX_ROWS: usize = 20_000;
|
||||
/// Önizlemede gösterilen geçerli satır sayısı
|
||||
const PREVIEW: usize = 10;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
/// Başlık hariç dolu satır sayısı
|
||||
pub total: usize,
|
||||
/// Eklenebilir satırlar (tekrarlar hariç)
|
||||
pub valid: usize,
|
||||
/// Veritabanında zaten olduğu için atlanan
|
||||
pub duplicates: usize,
|
||||
/// Yazılan (deneme: 0)
|
||||
pub imported: usize,
|
||||
/// (satır numarası, neden)
|
||||
pub errors: Vec<(usize, String)>,
|
||||
/// İlk geçerli satırlar, gösterime hazır hücreler
|
||||
pub preview: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// (satır numarası, hücreler)
|
||||
type Rows = Vec<(usize, Vec<String>)>;
|
||||
|
||||
fn rows_of(text: &str) -> AppResult<(Vec<String>, Rows)> {
|
||||
let mut rows = csv_io::parse(text);
|
||||
if rows.is_empty() {
|
||||
return Err(AppError::BadRequest("Dosya boş".into()));
|
||||
}
|
||||
if rows.len() - 1 > MAX_ROWS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Dosya en fazla {MAX_ROWS} satır olabilir; parçalara bölün"
|
||||
)));
|
||||
}
|
||||
let header = rows.remove(0).1;
|
||||
Ok((header, rows))
|
||||
}
|
||||
|
||||
/// Zorunlu sütunlar: (anahtar adlar) → sıra; eksikse anlaşılır hata
|
||||
fn require(header: &[String], cols: &[(&str, &[&str])]) -> AppResult<Vec<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut missing = Vec::new();
|
||||
for (label, names) in cols {
|
||||
match column(header, names) {
|
||||
Some(i) => out.push(i),
|
||||
None => missing.push(*label),
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Gerekli sütun eksik: {}",
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn cell(row: &[String], i: Option<usize>) -> &str {
|
||||
i.and_then(|i| row.get(i)).map_or("", |s| s.trim())
|
||||
}
|
||||
|
||||
fn period_for<'a>(periods: &'a [Period], date: &str) -> Result<&'a Period, String> {
|
||||
let p = periods
|
||||
.iter()
|
||||
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
|
||||
.ok_or_else(|| "Bu tarih için dönem yok".to_string())?;
|
||||
if !p.status.is_writable() {
|
||||
return Err(format!("Dönem kapalı ({})", p.name));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn group_from(s: &str) -> Option<Group> {
|
||||
let n = norm(s);
|
||||
Group::ALL
|
||||
.into_iter()
|
||||
.find(|g| norm(g.label()) == n || g.as_str() == n)
|
||||
}
|
||||
|
||||
/// Ada göre katalog kaydı (grup verilmişse o grupta). Aynı ad birden çok grupta → hata.
|
||||
fn find_by_name<'a>(
|
||||
items: &'a [catalog::Item],
|
||||
name: &str,
|
||||
group: Option<Group>,
|
||||
what: &str,
|
||||
) -> Result<&'a catalog::Item, String> {
|
||||
let n = norm(name);
|
||||
let matches: Vec<&catalog::Item> = items
|
||||
.iter()
|
||||
.filter(|c| norm(&c.name) == n && (group.is_none() || c.group == group))
|
||||
.collect();
|
||||
match matches.as_slice() {
|
||||
[one] => Ok(one),
|
||||
[] => Err(format!("{what} bulunamadı: {name}")),
|
||||
_ => Err(format!(
|
||||
"\"{name}\" birden fazla grupta var; Grup sütununu doldurun"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tekrar sayacı: anahtar → veritabanında kaç tane var (eşleştikçe azalır)
|
||||
struct Existing(HashMap<String, usize>);
|
||||
|
||||
impl Existing {
|
||||
fn new(keys: impl Iterator<Item = String>) -> Self {
|
||||
let mut m = HashMap::new();
|
||||
for k in keys {
|
||||
*m.entry(k).or_insert(0) += 1;
|
||||
}
|
||||
Self(m)
|
||||
}
|
||||
/// Varsa bir tanesini "tüket" ve `true` dön
|
||||
fn take(&mut self, key: &str) -> bool {
|
||||
match self.0.get_mut(key) {
|
||||
Some(n) if *n > 0 => {
|
||||
*n -= 1;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================= Harcamalar =================
|
||||
|
||||
const E_HEADERS: [&str; 6] = [
|
||||
"Tarih",
|
||||
"Tutar",
|
||||
"Grup",
|
||||
"Kategori",
|
||||
"Ödeme türü",
|
||||
"Açıklama",
|
||||
];
|
||||
|
||||
fn entry_key(date: &str, amount: i64, category: Uuid, desc: Option<&str>) -> String {
|
||||
format!("{date}|{amount}|{category}|{}", desc.unwrap_or("").trim())
|
||||
}
|
||||
|
||||
pub async fn export_entries(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
from: Option<&str>,
|
||||
to: Option<&str>,
|
||||
) -> AppResult<String> {
|
||||
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
|
||||
let mut f = scope(household_id);
|
||||
let mut range = Document::new();
|
||||
if let Some(d) = from {
|
||||
range.insert("$gte", d);
|
||||
}
|
||||
if let Some(d) = to {
|
||||
range.insert("$lte", d);
|
||||
}
|
||||
if !range.is_empty() {
|
||||
f.insert("date", range);
|
||||
}
|
||||
let mut c = db
|
||||
.collection::<Entry>(col::ENTRIES)
|
||||
.find(f)
|
||||
.sort(doc! { "date": 1, "_id": 1 })
|
||||
.await?;
|
||||
let mut rows = Vec::new();
|
||||
while c.advance().await? {
|
||||
let e = c.deserialize_current()?;
|
||||
let cat = cats.iter().find(|x| x.id == e.category_id);
|
||||
let pm = e
|
||||
.payment_method_id
|
||||
.and_then(|id| pms.iter().find(|x| x.id == id));
|
||||
rows.push(vec![
|
||||
csv_io::date(&e.date),
|
||||
csv_io::money(e.amount),
|
||||
e.group.label().to_string(),
|
||||
cat.map_or(String::new(), |x| x.name.clone()),
|
||||
pm.map_or(String::new(), |x| x.name.clone()),
|
||||
e.description.unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
Ok(csv_io::write(&E_HEADERS, &rows))
|
||||
}
|
||||
|
||||
pub async fn import_entries(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
text: &str,
|
||||
commit: bool,
|
||||
) -> AppResult<Report> {
|
||||
let (header, rows) = rows_of(text)?;
|
||||
let req = require(
|
||||
&header,
|
||||
&[
|
||||
("Tarih", &["Tarih", "date"]),
|
||||
("Tutar", &["Tutar", "amount"]),
|
||||
("Kategori", &["Kategori", "category"]),
|
||||
],
|
||||
)?;
|
||||
let (c_date, c_amount, c_cat) = (Some(req[0]), Some(req[1]), Some(req[2]));
|
||||
let c_group = column(&header, &["Grup", "group"]);
|
||||
let c_pm = column(&header, &["Ödeme türü", "Ödeme", "payment_method"]);
|
||||
let c_desc = column(&header, &["Açıklama", "description"]);
|
||||
|
||||
let periods = period::list(db, household_id).await?;
|
||||
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||
let pms = catalog::list(db, Catalog::PaymentMethods, household_id, true).await?;
|
||||
let mut existing = {
|
||||
let mut c = db
|
||||
.collection::<Entry>(col::ENTRIES)
|
||||
.find(scope(household_id))
|
||||
.await?;
|
||||
let mut keys = Vec::new();
|
||||
while c.advance().await? {
|
||||
let e = c.deserialize_current()?;
|
||||
keys.push(entry_key(
|
||||
&e.date,
|
||||
e.amount,
|
||||
e.category_id,
|
||||
e.description.as_deref(),
|
||||
));
|
||||
}
|
||||
Existing::new(keys.into_iter())
|
||||
};
|
||||
|
||||
let mut report = Report {
|
||||
total: rows.len(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut docs = Vec::new();
|
||||
for (line, row) in &rows {
|
||||
let parsed = (|| -> Result<Document, String> {
|
||||
let date = csv_io::parse_date_any(cell(row, c_date))
|
||||
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
|
||||
let p = period_for(&periods, &date)?;
|
||||
let amount = csv_io::parse_money(cell(row, c_amount))
|
||||
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
|
||||
if amount <= 0 {
|
||||
return Err("Tutar sıfırdan büyük olmalı".into());
|
||||
}
|
||||
period::validate_amount(amount).map_err(|e| e.to_string())?;
|
||||
let group = match cell(row, c_group) {
|
||||
"" => None,
|
||||
g => Some(group_from(g).ok_or_else(|| format!("Grup tanınmadı: {g}"))?),
|
||||
};
|
||||
let cat = find_by_name(&cats, cell(row, c_cat), group, "Kategori")?;
|
||||
let cat_group = cat.group.ok_or("Kategorinin grubu yok")?;
|
||||
let pm = match cell(row, c_pm) {
|
||||
"" => None,
|
||||
n => Some(find_by_name(&pms, n, None, "Ödeme türü")?.id),
|
||||
};
|
||||
let desc = match cell(row, c_desc) {
|
||||
"" => None,
|
||||
d if d.chars().count() > MAX_DESCRIPTION => {
|
||||
return Err(format!(
|
||||
"Açıklama en fazla {MAX_DESCRIPTION} karakter olabilir"
|
||||
));
|
||||
}
|
||||
d => Some(d.to_string()),
|
||||
};
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("period_id", p.id);
|
||||
d.insert("date", &date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", cat_group.as_str());
|
||||
d.insert("category_id", cat.id);
|
||||
d.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||
d.insert("description", desc.clone().map_or(Bson::Null, Bson::String));
|
||||
d.insert("created_by", user_id);
|
||||
d.insert("__key", entry_key(&date, amount, cat.id, desc.as_deref()));
|
||||
d.insert(
|
||||
"__preview",
|
||||
vec![
|
||||
csv_io::date(&date),
|
||||
csv_io::money(amount),
|
||||
cat.name.clone(),
|
||||
desc.unwrap_or_default(),
|
||||
],
|
||||
);
|
||||
Ok(d)
|
||||
})();
|
||||
match parsed {
|
||||
Err(e) => report.errors.push((*line, e)),
|
||||
Ok(mut d) => {
|
||||
let key = d.get_str("__key").unwrap_or_default().to_string();
|
||||
if existing.take(&key) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if report.preview.len() < PREVIEW
|
||||
&& let Ok(p) = d.get_array("__preview")
|
||||
{
|
||||
report.preview.push(
|
||||
p.iter()
|
||||
.map(|x| x.as_str().unwrap_or_default().to_string())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
d.remove("__key");
|
||||
d.remove("__preview");
|
||||
docs.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
report.valid = docs.len();
|
||||
if commit && !docs.is_empty() {
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_many(docs)
|
||||
.await?;
|
||||
report.imported = report.valid;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// ================= Plan kalemleri =================
|
||||
|
||||
const P_HEADERS: [&str; 6] = ["Dönem başlangıcı", "Dönem", "Tür", "Ad", "Tutar", "Tarih"];
|
||||
|
||||
fn kind_label(k: PlanKind) -> &'static str {
|
||||
match k {
|
||||
PlanKind::Income => "Gelir",
|
||||
PlanKind::Expense => "Sabit gider",
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_from(s: &str) -> Option<PlanKind> {
|
||||
match norm(s).as_str() {
|
||||
"gelir" | "income" => Some(PlanKind::Income),
|
||||
"gider" | "sabitgider" | "expense" => Some(PlanKind::Expense),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_key(period: Uuid, kind: PlanKind, name: &str, amount: i64) -> String {
|
||||
format!("{period}|{}|{}|{amount}", kind.as_str(), norm(name))
|
||||
}
|
||||
|
||||
async fn all_plan_items(db: &Database, household_id: Uuid) -> AppResult<Vec<PlanItem>> {
|
||||
let mut c = db
|
||||
.collection::<PlanItem>(col::PLAN_ITEMS)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "period_id": 1, "kind": 1, "sort_order": 1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while c.advance().await? {
|
||||
out.push(c.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn export_plan_items(db: &Database, household_id: Uuid) -> AppResult<String> {
|
||||
let mut periods = period::list(db, household_id).await?;
|
||||
periods.reverse(); // eskiden yeniye
|
||||
let items = all_plan_items(db, household_id).await?;
|
||||
let mut rows = Vec::new();
|
||||
for p in &periods {
|
||||
// Uygulamadaki sıra: önce gelirler, sonra sabit giderler
|
||||
let mut list: Vec<&PlanItem> = items.iter().filter(|i| i.period_id == p.id).collect();
|
||||
list.sort_by_key(|i| (i.kind != PlanKind::Income, i.sort_order));
|
||||
for i in list {
|
||||
rows.push(vec![
|
||||
csv_io::date(&p.start_date),
|
||||
p.name.clone(),
|
||||
kind_label(i.kind).to_string(),
|
||||
i.name.clone(),
|
||||
csv_io::money(i.amount),
|
||||
i.date.as_deref().map(csv_io::date).unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Ok(csv_io::write(&P_HEADERS, &rows))
|
||||
}
|
||||
|
||||
pub async fn import_plan_items(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
text: &str,
|
||||
commit: bool,
|
||||
) -> AppResult<Report> {
|
||||
let (header, rows) = rows_of(text)?;
|
||||
let req = require(
|
||||
&header,
|
||||
&[
|
||||
("Dönem başlangıcı", &["Dönem başlangıcı", "period_start"]),
|
||||
("Tür", &["Tür", "kind"]),
|
||||
("Ad", &["Ad", "name"]),
|
||||
("Tutar", &["Tutar", "amount"]),
|
||||
],
|
||||
)?;
|
||||
let (c_period, c_kind, c_name, c_amount) =
|
||||
(Some(req[0]), Some(req[1]), Some(req[2]), Some(req[3]));
|
||||
let c_date = column(&header, &["Tarih", "date"]);
|
||||
|
||||
let periods = period::list(db, household_id).await?;
|
||||
let mut existing = Existing::new(
|
||||
all_plan_items(db, household_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|i| plan_key(i.period_id, i.kind, &i.name, i.amount)),
|
||||
);
|
||||
let mut report = Report {
|
||||
total: rows.len(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut todo: Vec<(usize, Uuid, NewItem)> = Vec::new();
|
||||
for (line, row) in &rows {
|
||||
let parsed = (|| -> Result<(Uuid, NewItem, Vec<String>), String> {
|
||||
let start = csv_io::parse_date_any(cell(row, c_period))
|
||||
.ok_or_else(|| format!("Dönem başlangıcı geçersiz: {}", cell(row, c_period)))?;
|
||||
let p = period_for(&periods, &start)?;
|
||||
let kind = kind_from(cell(row, c_kind)).ok_or_else(|| {
|
||||
format!(
|
||||
"Tür \"Gelir\" ya da \"Sabit gider\" olmalı: {}",
|
||||
cell(row, c_kind)
|
||||
)
|
||||
})?;
|
||||
let name = cell(row, c_name);
|
||||
if name.is_empty() {
|
||||
return Err("Ad boş".into());
|
||||
}
|
||||
let amount = csv_io::parse_money(cell(row, c_amount))
|
||||
.filter(|a| *a > 0)
|
||||
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_amount)))?;
|
||||
let date = match cell(row, c_date) {
|
||||
"" => None,
|
||||
d => Some(csv_io::parse_date_any(d).ok_or_else(|| format!("Tarih geçersiz: {d}"))?),
|
||||
};
|
||||
let preview = vec![
|
||||
p.name.clone(),
|
||||
kind_label(kind).into(),
|
||||
name.into(),
|
||||
csv_io::money(amount),
|
||||
];
|
||||
Ok((
|
||||
p.id,
|
||||
NewItem {
|
||||
kind,
|
||||
name: name.to_string(),
|
||||
amount,
|
||||
date,
|
||||
},
|
||||
preview,
|
||||
))
|
||||
})();
|
||||
match parsed {
|
||||
Err(e) => report.errors.push((*line, e)),
|
||||
Ok((pid, item, preview)) => {
|
||||
if existing.take(&plan_key(pid, item.kind, &item.name, item.amount)) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if report.preview.len() < PREVIEW {
|
||||
report.preview.push(preview);
|
||||
}
|
||||
todo.push((*line, pid, item));
|
||||
}
|
||||
}
|
||||
}
|
||||
report.valid = todo.len();
|
||||
if commit {
|
||||
// Ad uzunluğu, tarihin dönem içinde olması vb. son denetim `plan::create`'te
|
||||
for (line, pid, item) in todo {
|
||||
match plan::create(db, household_id, pid, item).await {
|
||||
Ok(_) => report.imported += 1,
|
||||
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
|
||||
report.errors.push((line, m));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
report.valid = report.imported;
|
||||
report.errors.sort_by_key(|(l, _)| *l);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
// ================= Varlık işlemleri =================
|
||||
|
||||
const A_HEADERS: [&str; 8] = [
|
||||
"Tarih",
|
||||
"Varlık kodu",
|
||||
"Varlık",
|
||||
"İşlem",
|
||||
"Miktar",
|
||||
"Birim fiyat",
|
||||
"Tutar",
|
||||
"Not",
|
||||
];
|
||||
|
||||
fn tx_key(asset: Uuid, date: &str, t: TxType, q: i64, total: i64) -> String {
|
||||
format!("{asset}|{date}|{}|{q}|{total}", t.as_str())
|
||||
}
|
||||
|
||||
async fn household_assets(db: &Database, household_id: Uuid) -> AppResult<Vec<Asset>> {
|
||||
let mut c = db
|
||||
.collection::<Asset>(col::ASSETS)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "sort_order": 1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while c.advance().await? {
|
||||
out.push(c.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn household_txs(db: &Database, household_id: Uuid) -> AppResult<Vec<AssetTx>> {
|
||||
let mut c = db
|
||||
.collection::<AssetTx>(col::ASSET_TRANSACTIONS)
|
||||
.find(scope(household_id))
|
||||
.sort(doc! { "date": 1, "_id": 1 })
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
while c.advance().await? {
|
||||
out.push(c.deserialize_current()?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn export_asset_txs(db: &Database, household_id: Uuid) -> AppResult<String> {
|
||||
let assets = household_assets(db, household_id).await?;
|
||||
let rows = household_txs(db, household_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let a = assets.iter().find(|a| a.id == t.asset_id);
|
||||
vec![
|
||||
csv_io::date(&t.date),
|
||||
a.map_or(String::new(), |a| a.code.clone()),
|
||||
a.map_or(String::new(), |a| a.name.clone()),
|
||||
match t.tx_type {
|
||||
TxType::Buy => "Alım",
|
||||
TxType::Sell => "Satım",
|
||||
}
|
||||
.to_string(),
|
||||
csv_io::e6(t.quantity_e6),
|
||||
csv_io::e6(t.unit_price_e6),
|
||||
csv_io::money(t.total),
|
||||
t.note.unwrap_or_default(),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(csv_io::write(&A_HEADERS, &rows))
|
||||
}
|
||||
|
||||
struct NewTxRow {
|
||||
line: usize,
|
||||
asset: Uuid,
|
||||
tx: asset::NewTx,
|
||||
quantity_e6: i64,
|
||||
}
|
||||
|
||||
pub async fn import_asset_txs(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
text: &str,
|
||||
commit: bool,
|
||||
) -> AppResult<Report> {
|
||||
let (header, rows) = rows_of(text)?;
|
||||
let req = require(
|
||||
&header,
|
||||
&[
|
||||
("Tarih", &["Tarih", "date"]),
|
||||
("İşlem", &["İşlem", "type"]),
|
||||
("Tutar", &["Tutar", "total"]),
|
||||
],
|
||||
)?;
|
||||
let (c_date, c_type, c_total) = (Some(req[0]), Some(req[1]), Some(req[2]));
|
||||
let c_code = column(&header, &["Varlık kodu", "Kod", "code"]);
|
||||
let c_name = column(&header, &["Varlık", "asset"]);
|
||||
if c_code.is_none() && c_name.is_none() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Gerekli sütun eksik: Varlık kodu (ya da Varlık)".into(),
|
||||
));
|
||||
}
|
||||
let c_qty = column(&header, &["Miktar", "quantity"]);
|
||||
let c_price = column(&header, &["Birim fiyat", "unit_price"]);
|
||||
let c_note = column(&header, &["Not", "note"]);
|
||||
|
||||
let assets = household_assets(db, household_id).await?;
|
||||
let existing_txs = household_txs(db, household_id).await?;
|
||||
let mut existing = Existing::new(
|
||||
existing_txs
|
||||
.iter()
|
||||
.map(|t| tx_key(t.asset_id, &t.date, t.tx_type, t.quantity_e6, t.total)),
|
||||
);
|
||||
|
||||
let mut report = Report {
|
||||
total: rows.len(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut todo: Vec<NewTxRow> = Vec::new();
|
||||
for (line, row) in &rows {
|
||||
let parsed = (|| -> Result<(NewTxRow, Vec<String>), String> {
|
||||
let a = {
|
||||
let code = norm(cell(row, c_code));
|
||||
let name = norm(cell(row, c_name));
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| {
|
||||
(!code.is_empty() && norm(&a.code) == code)
|
||||
|| (code.is_empty() && norm(&a.name) == name)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Varlık bulunamadı: {}",
|
||||
if code.is_empty() {
|
||||
cell(row, c_name)
|
||||
} else {
|
||||
cell(row, c_code)
|
||||
}
|
||||
)
|
||||
})?
|
||||
};
|
||||
if !a.is_active {
|
||||
return Err(format!("Varlık pasif: {}", a.name));
|
||||
}
|
||||
let date = csv_io::parse_date_any(cell(row, c_date))
|
||||
.ok_or_else(|| format!("Tarih geçersiz: {}", cell(row, c_date)))?;
|
||||
let tx_type = match norm(cell(row, c_type)).as_str() {
|
||||
"alım" | "alis" | "alış" | "buy" => TxType::Buy,
|
||||
"satım" | "satış" | "satis" | "sell" => TxType::Sell,
|
||||
t => return Err(format!("İşlem \"Alım\" ya da \"Satım\" olmalı: {t}")),
|
||||
};
|
||||
let total = csv_io::parse_money(cell(row, c_total))
|
||||
.filter(|t| *t > 0)
|
||||
.ok_or_else(|| format!("Tutar geçersiz: {}", cell(row, c_total)))?;
|
||||
let quantity_e6 = if a.kind == AssetKind::Cash {
|
||||
total * 10_000
|
||||
} else {
|
||||
csv_io::parse_e6(cell(row, c_qty))
|
||||
.filter(|q| *q > 0)
|
||||
.ok_or_else(|| format!("Miktar geçersiz: {}", cell(row, c_qty)))?
|
||||
};
|
||||
let unit_price_e6 = match cell(row, c_price) {
|
||||
"" => None,
|
||||
p => Some(
|
||||
csv_io::parse_e6(p)
|
||||
.filter(|p| *p > 0)
|
||||
.ok_or_else(|| format!("Birim fiyat geçersiz: {p}"))?,
|
||||
),
|
||||
};
|
||||
let note = match cell(row, c_note) {
|
||||
"" => None,
|
||||
n => Some(n.to_string()),
|
||||
};
|
||||
let preview = vec![
|
||||
csv_io::date(&date),
|
||||
a.name.clone(),
|
||||
if tx_type == TxType::Buy {
|
||||
"Alım"
|
||||
} else {
|
||||
"Satım"
|
||||
}
|
||||
.into(),
|
||||
if a.kind == AssetKind::Cash {
|
||||
csv_io::money(total)
|
||||
} else {
|
||||
format!("{} {}", csv_io::e6(quantity_e6), a.unit_label)
|
||||
},
|
||||
];
|
||||
Ok((
|
||||
NewTxRow {
|
||||
line: *line,
|
||||
asset: a.id,
|
||||
quantity_e6,
|
||||
tx: asset::NewTx {
|
||||
tx_type,
|
||||
date,
|
||||
quantity_e6,
|
||||
unit_price_e6,
|
||||
total,
|
||||
note,
|
||||
},
|
||||
},
|
||||
preview,
|
||||
))
|
||||
})();
|
||||
match parsed {
|
||||
Err(e) => report.errors.push((*line, e)),
|
||||
Ok((r, preview)) => {
|
||||
if existing.take(&tx_key(
|
||||
r.asset,
|
||||
&r.tx.date,
|
||||
r.tx.tx_type,
|
||||
r.quantity_e6,
|
||||
r.tx.total,
|
||||
)) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
if report.preview.len() < PREVIEW {
|
||||
report.preview.push(preview);
|
||||
}
|
||||
todo.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tarih sırasıyla (aynı gün dosya sırası) eldekinden fazla satış denetimi:
|
||||
// mevcut işlemler + kabul edilen yeniler; aşan satır reddedilir, diğerleri sürer.
|
||||
todo.sort_by(|a, b| (a.tx.date.as_str(), a.line).cmp(&(b.tx.date.as_str(), b.line)));
|
||||
let mut accepted: Vec<NewTxRow> = Vec::new();
|
||||
for r in todo {
|
||||
let mut list: Vec<(String, u8, usize, Move)> = existing_txs
|
||||
.iter()
|
||||
.filter(|t| t.asset_id == r.asset)
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
(
|
||||
t.date.clone(),
|
||||
0,
|
||||
i,
|
||||
Move {
|
||||
tx_type: t.tx_type,
|
||||
quantity_e6: t.quantity_e6,
|
||||
total: t.total,
|
||||
},
|
||||
)
|
||||
})
|
||||
.chain(
|
||||
accepted
|
||||
.iter()
|
||||
.chain(std::iter::once(&r))
|
||||
.filter(|x| x.asset == r.asset)
|
||||
.map(|x| {
|
||||
(
|
||||
x.tx.date.clone(),
|
||||
1,
|
||||
x.line,
|
||||
Move {
|
||||
tx_type: x.tx.tx_type,
|
||||
quantity_e6: x.quantity_e6,
|
||||
total: x.tx.total,
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.collect();
|
||||
list.sort_by(|a, b| (a.0.as_str(), a.1, a.2).cmp(&(b.0.as_str(), b.1, b.2)));
|
||||
let moves: Vec<Move> = list.into_iter().map(|x| x.3).collect();
|
||||
if holding::holding(&moves).is_err() {
|
||||
report.errors.push((
|
||||
r.line,
|
||||
"Satış miktarı o tarihte eldeki miktarı aşıyor".into(),
|
||||
));
|
||||
} else {
|
||||
accepted.push(r);
|
||||
}
|
||||
}
|
||||
report.errors.sort_by_key(|(l, _)| *l);
|
||||
report.valid = accepted.len();
|
||||
if commit {
|
||||
report.valid = 0;
|
||||
for r in accepted {
|
||||
match asset::create_tx(db, household_id, user_id, r.asset, r.tx).await {
|
||||
Ok(_) => report.imported += 1,
|
||||
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => {
|
||||
report.errors.push((r.line, m))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
report.valid = report.imported;
|
||||
report.errors.sort_by_key(|(l, _)| *l);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! Ekstre içe aktarma: öneri (tür/kategori/kural), arşiv dönemi, iade, gelir, yoksay,
|
||||
//! tekrar tespiti, kapalı/gelecek dönem engeli, arşiv kuralları.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn id_by(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||
let (_, list) = app.get(path, s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["name"] == name)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn row(date: &str, desc: &str, amount: i64, dir: &str) -> Value {
|
||||
json!({ "date": date, "description": desc, "amount": amount, "direction": dir })
|
||||
}
|
||||
|
||||
async fn preview(app: &TestApp, s: &Session, source: &str, rows: &[Value]) -> Value {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
"/bank-import/preview",
|
||||
s,
|
||||
json!({ "source": source, "rows": rows }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
b
|
||||
}
|
||||
|
||||
async fn commit(app: &TestApp, s: &Session, pm: Option<&str>, rows: Vec<Value>) -> Value {
|
||||
let (st, b) = app
|
||||
.post(
|
||||
"/bank-import/commit",
|
||||
s,
|
||||
json!({ "payment_method_id": pm, "rows": rows }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
b
|
||||
}
|
||||
|
||||
fn with(mut r: Value, kind: &str, cat: Option<&str>) -> Value {
|
||||
r["kind"] = json!(kind);
|
||||
if let Some(c) = cat {
|
||||
r["category_id"] = json!(c);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn card_statement_with_archive_refund_and_rules() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by(&app, &s, "/categories", "Market").await;
|
||||
let kart = id_by(&app, &s, "/payment-methods", "Kredi Kartı").await;
|
||||
// Ekim kapalı, Kasım açık
|
||||
let (_, oct) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let oct = oct["id"].as_str().unwrap().to_string();
|
||||
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||
.await;
|
||||
let (_, nov) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||
)
|
||||
.await;
|
||||
let nov = nov["id"].as_str().unwrap().to_string();
|
||||
|
||||
let rows = vec![
|
||||
row("2026-09-10", "MİGROS 1234 İSTANBUL TR", 45_000, "out"),
|
||||
row("2026-09-12", "IADE MIGROS ISTANBUL", 10_000, "in"),
|
||||
row("2026-09-30", "ÖDEMENİZ İÇİN TEŞEKKÜRLER", 500_000, "in"),
|
||||
row("2026-11-05", "A101 MAGAZACILIK", 20_000, "out"),
|
||||
row("2099-12-20", "GELECEK", 1_000, "out"),
|
||||
row("2026-10-05", "EKIM", 1_000, "out"),
|
||||
];
|
||||
let p = preview(&app, &s, "card", &rows).await;
|
||||
let kinds: Vec<&str> = p["rows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["kind"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
"expense", "refund", "ignore", "expense", "expense", "expense"
|
||||
]
|
||||
);
|
||||
assert_eq!(p["rows"][0]["period"], "archive");
|
||||
assert_eq!(p["rows"][0]["period_name"], "Eylül 2026");
|
||||
assert_eq!(p["rows"][3]["period"], "existing");
|
||||
assert_eq!(p["rows"][3]["period_name"], "Kasım 2026");
|
||||
assert_eq!(p["rows"][4]["message"], "Gelecek tarihli satır");
|
||||
assert_eq!(
|
||||
p["rows"][5]["message"],
|
||||
"Dönem kapalı (Ekim 2026); düzeltme için yeniden açın"
|
||||
);
|
||||
assert_eq!(
|
||||
p["archives"],
|
||||
json!([{ "name": "Eylül 2026", "start_date": "2026-09-01", "last_date": "2026-09-30" }])
|
||||
);
|
||||
assert_eq!(p["rows"][0]["from_rule"], false);
|
||||
|
||||
// Aktar: kullanıcı seçimleri
|
||||
let chosen = vec![
|
||||
with(rows[0].clone(), "expense", Some(&market)),
|
||||
with(rows[1].clone(), "refund", Some(&market)),
|
||||
with(rows[2].clone(), "ignore", None),
|
||||
with(rows[3].clone(), "expense", Some(&market)),
|
||||
with(rows[4].clone(), "expense", Some(&market)),
|
||||
with(rows[5].clone(), "expense", Some(&market)),
|
||||
];
|
||||
let r = commit(&app, &s, Some(&kart), chosen.clone()).await;
|
||||
assert_eq!(
|
||||
(
|
||||
r["entries"].as_u64(),
|
||||
r["refunds"].as_u64(),
|
||||
r["ignored"].as_u64()
|
||||
),
|
||||
(Some(2), Some(1), Some(1))
|
||||
);
|
||||
assert_eq!(r["archives"], json!(["Eylül 2026"]));
|
||||
assert_eq!(
|
||||
r["errors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["index"].as_u64().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
[4, 5]
|
||||
);
|
||||
|
||||
// Arşiv dönemi: Eylül, net harcama 450 − 100 = 350
|
||||
let (_, periods) = app.get("/periods", &s).await;
|
||||
let sep = periods
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == "Eylül 2026")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(sep["status"], "archived");
|
||||
let sep_id = sep["id"].as_str().unwrap();
|
||||
let (_, sum) = app.get(&format!("/periods/{sep_id}/summary"), &s).await;
|
||||
assert_eq!(sum["spent_total"], 35_000, "iade harcamadan düşer");
|
||||
let (_, entries) = app.get(&format!("/periods/{sep_id}/entries"), &s).await;
|
||||
let refund = entries
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|e| e["amount"] == -10_000)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(refund["payment_method_id"], kart.as_str());
|
||||
assert_eq!(refund["description"], "IADE MIGROS ISTANBUL");
|
||||
let (_, nov_entries) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||
assert_eq!(nov_entries.as_array().unwrap().len(), 1);
|
||||
|
||||
// Aynı ekstre tekrar: öğrenilen kural + tekrar işareti; aktarma çift kayıt yapmaz
|
||||
let p = preview(&app, &s, "card", &rows).await;
|
||||
assert_eq!(p["rows"][0]["from_rule"], true);
|
||||
assert_eq!(p["rows"][0]["category_id"], market.as_str());
|
||||
assert_eq!(p["rows"][1]["kind"], "refund");
|
||||
assert_eq!(p["rows"][2]["kind"], "ignore", "yoksay kuralı da öğrenildi");
|
||||
assert_eq!(
|
||||
p["rows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["duplicate"].as_bool().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
[true, true, false, true, false, false]
|
||||
);
|
||||
assert_eq!(p["archives"], json!([]), "Eylül artık var");
|
||||
let r = commit(&app, &s, Some(&kart), chosen).await;
|
||||
assert_eq!(
|
||||
(r["entries"].as_u64(), r["duplicates"].as_u64()),
|
||||
(Some(0), Some(3))
|
||||
);
|
||||
|
||||
// İade düzenlemede eksi kalır
|
||||
let rid = refund["id"].as_str().unwrap();
|
||||
let (st, _) = app
|
||||
.patch(&format!("/entries/{rid}"), &s, json!({ "amount": 5_000 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let (st, e) = app
|
||||
.patch(&format!("/entries/{rid}"), &s, json!({ "amount": -5_000 }))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{e}");
|
||||
|
||||
// Arşiv kapatılmaz; öneriler arşivi kullanır (Ekim 0 + Eylül 400 net) / 2 dönem
|
||||
let (st, b) = app
|
||||
.post(&format!("/periods/{sep_id}/close"), &s, json!({}))
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CONFLICT, "{b}");
|
||||
let (_, sug) = app
|
||||
.get(&format!("/periods/{nov}/budget/suggestion?method=avg3"), &s)
|
||||
.await;
|
||||
assert_eq!(sug["available"], true, "{sug}");
|
||||
assert_eq!(sug["amounts"]["needs"], 20_000, "(450 − 50) / 2");
|
||||
|
||||
// Başka hane kuralları görmez
|
||||
let other = app.register("Diger", None).await;
|
||||
let p = preview(&app, &other, "card", &rows[..1]).await;
|
||||
assert_eq!(p["rows"][0]["from_rule"], false);
|
||||
assert_eq!(p["rows"][0]["duplicate"], false);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_statement_income_and_transfers() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let fatura = id_by(&app, &s, "/categories", "Faturalar").await;
|
||||
let rows = vec![
|
||||
row("2026-08-01", "MAAŞ ÖDEMESİ ACME A.Ş.", 4_500_000, "in"),
|
||||
row("2026-08-05", "KREDİ KARTI ÖDEMESİ", 1_200_000, "out"),
|
||||
row("2026-08-07", "ELEKTRIK FATURASI", 85_000, "out"),
|
||||
row("2026-08-31", "HESAPLAR ARASI VİRMAN", 100_000, "out"),
|
||||
];
|
||||
let p = preview(&app, &s, "account", &rows).await;
|
||||
let kinds: Vec<&str> = p["rows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["kind"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(kinds, ["income", "ignore", "expense", "ignore"]);
|
||||
|
||||
let r = commit(
|
||||
&app,
|
||||
&s,
|
||||
None,
|
||||
vec![
|
||||
with(rows[0].clone(), "income", None),
|
||||
with(rows[1].clone(), "ignore", None),
|
||||
with(rows[2].clone(), "expense", Some(&fatura)),
|
||||
with(rows[3].clone(), "ignore", None),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
(
|
||||
r["incomes"].as_u64(),
|
||||
r["entries"].as_u64(),
|
||||
r["ignored"].as_u64()
|
||||
),
|
||||
(Some(1), Some(1), Some(2))
|
||||
);
|
||||
let (_, periods) = app.get("/periods", &s).await;
|
||||
let aug = periods
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == "Ağustos 2026")
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (_, sum) = app.get(&format!("/periods/{aug}/summary"), &s).await;
|
||||
assert_eq!(sum["income_total"], 4_500_000);
|
||||
assert_eq!(sum["spent_total"], 85_000);
|
||||
let (_, items) = app.get(&format!("/periods/{aug}/plan-items"), &s).await;
|
||||
assert_eq!(items[0]["name"], "MAAŞ ÖDEMESİ ACME A.Ş.");
|
||||
assert_eq!(items[0]["date"], "2026-08-01");
|
||||
// Gelir de tekrar etmez
|
||||
let p = preview(&app, &s, "account", &rows[..1]).await;
|
||||
assert_eq!(p["rows"][0]["duplicate"], true);
|
||||
|
||||
// Kategori seçilmemiş harcama → satır hatası; boş istek → 400
|
||||
let r = commit(
|
||||
&app,
|
||||
&s,
|
||||
None,
|
||||
vec![with(row("2026-08-09", "SU", 1_000, "out"), "expense", None)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r["errors"][0]["message"], "Kategori seçin");
|
||||
let (st, _) = app
|
||||
.post(
|
||||
"/bank-import/preview",
|
||||
&s,
|
||||
json!({ "source": "card", "rows": [] }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archives_are_skipped_in_the_carryover_chain() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by(&app, &s, "/categories", "Market").await;
|
||||
// Ekim: gelir 10.000, 5.000 birikime → 5.000 artı devreder (bağlanacak dönem yok)
|
||||
let (_, oct) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let oct = oct["id"].as_str().unwrap().to_string();
|
||||
app.post(
|
||||
&format!("/periods/{oct}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&format!("/periods/{oct}/close"),
|
||||
&s,
|
||||
json!({ "to_savings": 500_000 }),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Kasım ekstresi → Kasım arşiv dönemi
|
||||
let r = commit(
|
||||
&app,
|
||||
&s,
|
||||
None,
|
||||
vec![with(
|
||||
row("2026-11-03", "MIGROS", 10_000, "out"),
|
||||
"expense",
|
||||
Some(&market),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(r["archives"], json!(["Kasım 2026"]));
|
||||
|
||||
// Aralık açılır: önceki (devir) dönemi arşiv değil Ekim → artı Aralık'a bağlanır
|
||||
let (st, dec) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{dec}");
|
||||
let dec = dec["id"].as_str().unwrap().to_string();
|
||||
let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await;
|
||||
assert_eq!(sum["carryover_surplus"], 500_000);
|
||||
|
||||
// Ekim yeniden açılıp kapatılırsa yeni devir yine Aralık'a (arşiv Kasım değil)
|
||||
app.post(&format!("/periods/{oct}/reopen"), &s, json!({}))
|
||||
.await;
|
||||
let (st, b) = app
|
||||
.post(
|
||||
&format!("/periods/{oct}/close"),
|
||||
&s,
|
||||
json!({ "to_savings": 400_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
assert_eq!(b["carryover"]["to_period_id"], dec.as_str());
|
||||
let (_, sum) = app.get(&format!("/periods/{dec}/summary"), &s).await;
|
||||
assert_eq!(sum["carryover_surplus"], 600_000);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -101,6 +101,43 @@ impl TestApp {
|
||||
(status, json, set_cookie)
|
||||
}
|
||||
|
||||
/// Düz metin gövde gönderir (CSV içe aktarma)
|
||||
pub async fn post_text(&self, path: &str, s: &Session, text: &str) -> (StatusCode, Value) {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.body(Body::from(text.to_string()))
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
/// Metin yanıt (CSV dışa aktarma): (durum, içerik türü, gövde)
|
||||
pub async fn get_text(&self, path: &str, s: &Session) -> (StatusCode, String, String) {
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(status, ct, String::from_utf8(bytes.to_vec()).unwrap())
|
||||
}
|
||||
|
||||
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
|
||||
(st, v)
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Raporlar: harcama kırılımı (grup, tüm kategoriler, ödeme türü, ay) ve filtreler.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn id_by_name(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||
let (_, list) = app.get(path, s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["name"] == name)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn spend(
|
||||
app: &TestApp,
|
||||
s: &Session,
|
||||
pid: &str,
|
||||
date: &str,
|
||||
amount: i64,
|
||||
cat: &str,
|
||||
pm: Option<&str>,
|
||||
) -> Value {
|
||||
let mut body = json!({ "date": date, "amount": amount, "category_id": cat });
|
||||
if let Some(pm) = pm {
|
||||
body["payment_method_id"] = json!(pm);
|
||||
}
|
||||
let (st, e) = app.post(&format!("/periods/{pid}/entries"), s, body).await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||
e
|
||||
}
|
||||
|
||||
async fn report(app: &TestApp, s: &Session, query: &str) -> Value {
|
||||
let (st, b) = app.get(&format!("/reports/breakdown{query}"), s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{query} → {b}");
|
||||
b
|
||||
}
|
||||
|
||||
fn totals(list: &Value, key: &str) -> Vec<(String, i64)> {
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r[key].as_str().unwrap_or("-").to_string(),
|
||||
r["total"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn breakdown_with_filters() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by_name(&app, &s, "/categories", "Market").await;
|
||||
let giyim = id_by_name(&app, &s, "/categories", "Giyim").await;
|
||||
let kitap = id_by_name(&app, &s, "/categories", "Kitap").await;
|
||||
let nakit = id_by_name(&app, &s, "/payment-methods", "Nakit").await;
|
||||
let kart = id_by_name(&app, &s, "/payment-methods", "Kredi Kartı").await;
|
||||
|
||||
let (_, oct) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||
)
|
||||
.await;
|
||||
let oct = oct["id"].as_str().unwrap().to_string();
|
||||
spend(&app, &s, &oct, "2026-10-05", 100_000, &market, Some(&nakit)).await;
|
||||
spend(&app, &s, &oct, "2026-10-20", 50_000, &giyim, None).await;
|
||||
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||
.await;
|
||||
let (_, nov) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||
)
|
||||
.await;
|
||||
let nov = nov["id"].as_str().unwrap().to_string();
|
||||
spend(&app, &s, &nov, "2026-11-02", 30_000, &market, Some(&kart)).await;
|
||||
spend(&app, &s, &nov, "2026-11-03", 20_000, &kitap, Some(&nakit)).await;
|
||||
spend(&app, &s, &nov, "2026-11-04", 10_000, &market, Some(&nakit)).await;
|
||||
let gone = spend(&app, &s, &nov, "2026-11-05", 99_000, &kitap, None).await;
|
||||
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||
.await;
|
||||
|
||||
// Filtresiz: tüm zamanlar
|
||||
let r = report(&app, &s, "").await;
|
||||
assert_eq!(r["total"], 210_000);
|
||||
assert_eq!(r["count"], 5, "silinen sayılmaz");
|
||||
assert_eq!(
|
||||
totals(&r["groups"], "group"),
|
||||
[
|
||||
("needs".into(), 140_000),
|
||||
("wants".into(), 50_000),
|
||||
("culture".into(), 20_000),
|
||||
("unexpected".into(), 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
totals(&r["categories"], "category_id"),
|
||||
[
|
||||
(market.clone(), 140_000),
|
||||
(giyim.clone(), 50_000),
|
||||
(kitap.clone(), 20_000)
|
||||
]
|
||||
);
|
||||
assert_eq!(r["categories"][0]["count"], 3);
|
||||
assert_eq!(r["categories"][0]["group"], "needs");
|
||||
assert_eq!(
|
||||
totals(&r["payment_methods"], "payment_method_id"),
|
||||
[
|
||||
(nakit.clone(), 130_000),
|
||||
("-".into(), 50_000),
|
||||
(kart.clone(), 30_000)
|
||||
],
|
||||
"ödeme türü girilmemiş → null"
|
||||
);
|
||||
assert_eq!(
|
||||
totals(&r["months"], "month"),
|
||||
[("2026-10".into(), 150_000), ("2026-11".into(), 60_000)]
|
||||
);
|
||||
|
||||
// Dönem
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?period_id={nov}")).await["total"],
|
||||
60_000
|
||||
);
|
||||
// Tarih aralığı iki dönemi keser (ikisi de dahil)
|
||||
let r = report(&app, &s, "?from=2026-10-15&to=2026-11-03").await;
|
||||
assert_eq!(
|
||||
(r["total"].as_i64(), r["count"].as_i64()),
|
||||
(Some(100_000), Some(3))
|
||||
);
|
||||
// Grup, kategori, ödeme türü (+ girilmemiş)
|
||||
assert_eq!(report(&app, &s, "?group=needs").await["total"], 140_000);
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?category_id={kitap}")).await["total"],
|
||||
20_000
|
||||
);
|
||||
assert_eq!(
|
||||
report(&app, &s, &format!("?payment_method_id={nakit}")).await["total"],
|
||||
130_000
|
||||
);
|
||||
assert_eq!(
|
||||
report(&app, &s, "?payment_method_id=none").await["total"],
|
||||
50_000
|
||||
);
|
||||
// Birleşik
|
||||
let r = report(
|
||||
&app,
|
||||
&s,
|
||||
&format!("?period_id={nov}&group=needs&payment_method_id={nakit}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
(r["total"].as_i64(), r["count"].as_i64()),
|
||||
(Some(10_000), Some(1))
|
||||
);
|
||||
|
||||
// Hatalı filtreler
|
||||
for q in [
|
||||
"?from=2026-11-10&to=2026-11-01",
|
||||
"?from=2026-02-30",
|
||||
"?group=xyz",
|
||||
"?category_id=abc",
|
||||
] {
|
||||
let (st, b) = app.get(&format!("/reports/breakdown{q}"), &s).await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST, "{q} → {b}");
|
||||
}
|
||||
|
||||
// Başka hane: kendi verisi (boş); bu hanenin dönemi → 404
|
||||
let other = app.register("Diger", None).await;
|
||||
let r = report(&app, &other, "").await;
|
||||
assert_eq!(r["total"], 0);
|
||||
assert_eq!(r["groups"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(r["categories"], json!([]));
|
||||
let (st, _) = app
|
||||
.get(&format!("/reports/breakdown?period_id={nov}"), &other)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -151,6 +151,17 @@ async fn period_stats_and_total_savings() {
|
||||
assert_eq!(list[1]["spent"], 42_500);
|
||||
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
|
||||
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
|
||||
assert_eq!(list[1]["income_total"], 2_000_000);
|
||||
assert_eq!(list[1]["savings_goal"], 100_000);
|
||||
assert_eq!(list[1]["carryover_surplus"], 200_000);
|
||||
assert_eq!(
|
||||
list[0]["realized_savings"], 700_000,
|
||||
"Kasım: 10.000 − 3.000"
|
||||
);
|
||||
assert_eq!(
|
||||
list[1]["realized_savings"], 2_157_500,
|
||||
"20.000 + 2.000 artı − 425 harcanan (hedef düşülmez)"
|
||||
);
|
||||
assert_eq!(list[1]["to_savings"], Value::Null);
|
||||
|
||||
// limit: en yeni N dönem
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! CSV dışa / içe aktarma: biçim, deneme → aktarma, satır hataları, tekrar atlama,
|
||||
//! varlıkta tarih sırasıyla eldekinden fazla satış denetimi.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn id_by(app: &TestApp, s: &Session, path: &str, key: &str, value: &str) -> String {
|
||||
let (_, list) = app.get(path, s).await;
|
||||
list.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c[key] == value)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn open(app: &TestApp, s: &Session, start: &str, end: &str) -> String {
|
||||
let (st, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": start, "end_date": end }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::CREATED, "{p}");
|
||||
p["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
async fn import(app: &TestApp, s: &Session, kind: &str, csv: &str, commit: bool) -> Value {
|
||||
let (st, r) = app
|
||||
.post_text(&format!("/import/{kind}?commit={commit}"), s, csv)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{r}");
|
||||
r
|
||||
}
|
||||
|
||||
fn errors(r: &Value) -> Vec<(u64, String)> {
|
||||
r["errors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e["line"].as_u64().unwrap(),
|
||||
e["message"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn entries_export_and_import() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let market = id_by(&app, &s, "/categories", "name", "Market").await;
|
||||
let nakit = id_by(&app, &s, "/payment-methods", "name", "Nakit").await;
|
||||
// Ekim kapalı, Kasım açık
|
||||
let oct = open(&app, &s, "2026-10-01", "2026-11-01").await;
|
||||
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||
.await;
|
||||
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
app.post(&format!("/periods/{nov}/entries"), &s, json!({ "date": "2026-11-05", "amount": 125_050, "category_id": market, "payment_method_id": nakit, "description": "Kafe; tatlı" })).await;
|
||||
|
||||
let (st, ct, csv) = app.get_text("/export/entries.csv", &s).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert!(ct.starts_with("text/csv"), "{ct}");
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Tarih;Tutar;Grup;Kategori;Ödeme türü;Açıklama\r\n05.11.2026;1250,50;Zorunlu;Market;Nakit;\"Kafe; tatlı\"\r\n"
|
||||
);
|
||||
|
||||
// Aynı dosya geri yüklenirse çift kayıt olmaz
|
||||
let r = import(&app, &s, "entries", &csv, true).await;
|
||||
assert_eq!(
|
||||
(
|
||||
r["total"].as_u64(),
|
||||
r["valid"].as_u64(),
|
||||
r["duplicates"].as_u64(),
|
||||
r["imported"].as_u64()
|
||||
),
|
||||
(Some(1), Some(0), Some(1), Some(0))
|
||||
);
|
||||
|
||||
// Sütun sırası serbest, ayrı yazımlar; hatalı satırlar raporlanır
|
||||
let file = "Açıklama;Kategori;Tutar;Tarih;Grup;Ödeme türü\r\n\
|
||||
Ekmek;market;45,90;06.11.2026;;\r\n\
|
||||
x;Market;10;31.02.2026;;\r\n\
|
||||
x;Market;10;15.09.2026;;\r\n\
|
||||
x;Market;10;15.10.2026;;\r\n\
|
||||
x;Uzay;10;06.11.2026;;\r\n\
|
||||
x;Market;0;06.11.2026;;\r\n\
|
||||
x;Market;10;06.11.2026;;Kripto\r\n\
|
||||
x;Market;10;06.11.2026;İstek;\r\n\
|
||||
Kitaplar;Kitap;1.200;2026-11-07;Kültürel;nakit\r\n\
|
||||
Kafe; tatlı;Market;1.250,50;05.11.2026;Zorunlu;Nakit\r\n";
|
||||
// Son satırda tırnaksız ';' → sütunlar kayar: açıkça hata olmalı
|
||||
let dry = import(&app, &s, "entries", file, false).await;
|
||||
assert_eq!(dry["total"], 10);
|
||||
assert_eq!(dry["valid"], 2);
|
||||
assert_eq!(dry["imported"], 0, "deneme yazmaz");
|
||||
assert_eq!(
|
||||
errors(&dry),
|
||||
vec![
|
||||
(3, "Tarih geçersiz: 31.02.2026".into()),
|
||||
(4, "Bu tarih için dönem yok".into()),
|
||||
(5, "Dönem kapalı (Ekim 2026)".into()),
|
||||
(6, "Kategori bulunamadı: Uzay".into()),
|
||||
(7, "Tutar sıfırdan büyük olmalı".into()),
|
||||
(8, "Ödeme türü bulunamadı: Kripto".into()),
|
||||
(9, "Kategori bulunamadı: Market".into()),
|
||||
(11, "Tarih geçersiz: 1.250,50".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
dry["preview"][0],
|
||||
json!(["06.11.2026", "45,90", "Market", "Ekmek"])
|
||||
);
|
||||
let (_, before) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||
assert_eq!(before.as_array().unwrap().len(), 1);
|
||||
|
||||
let done = import(&app, &s, "entries", file, true).await;
|
||||
assert_eq!(done["imported"], 2);
|
||||
let (_, list) = app.get(&format!("/periods/{nov}/entries"), &s).await;
|
||||
let list = list.as_array().unwrap();
|
||||
assert_eq!(list.len(), 3);
|
||||
let book = list
|
||||
.iter()
|
||||
.find(|e| e["description"] == "Kitaplar")
|
||||
.unwrap();
|
||||
assert_eq!(book["amount"], 120_000, "1.200 → bin iki yüz");
|
||||
assert_eq!(book["group"], "culture");
|
||||
assert_eq!(
|
||||
book["payment_method_id"],
|
||||
nakit.as_str(),
|
||||
"ad büyük/küçük harf duyarsız"
|
||||
);
|
||||
|
||||
// Dosyada 2 aynı satır, veritabanında 1 → biri eklenir
|
||||
let twice = "Tarih;Tutar;Kategori;Açıklama\r\n06.11.2026;45,90;Market;Ekmek\r\n06.11.2026;45,90;Market;Ekmek\r\n";
|
||||
let r = import(&app, &s, "entries", twice, true).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["imported"].as_u64()),
|
||||
(Some(1), Some(1))
|
||||
);
|
||||
|
||||
// Biçim hataları
|
||||
let (st, b) = app
|
||||
.post_text(
|
||||
"/import/entries?commit=false",
|
||||
&s,
|
||||
"Tarih;Tutar\r\n01.11.2026;5\r\n",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(b["error"]["message"], "Gerekli sütun eksik: Kategori");
|
||||
let (st, _) = app.post_text("/import/entries?commit=false", &s, "").await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
let (st, _) = app.post_text("/import/bilinmeyen", &s, "a\r\n").await;
|
||||
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||
|
||||
// Başka hane yalnızca kendi verisini dışa aktarır
|
||||
let other = app.register("Diger", None).await;
|
||||
let (_, _, csv) = app.get_text("/export/entries.csv", &other).await;
|
||||
assert_eq!(csv.lines().count(), 1, "yalnızca başlık");
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_items_roundtrip() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
let nov = open(&app, &s, "2026-11-01", "2026-12-01").await;
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 4_500_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Kira", "amount": 1_500_000, "date": "2026-11-05" }),
|
||||
)
|
||||
.await;
|
||||
let (_, _, csv) = app.get_text("/export/plan-items.csv", &s).await;
|
||||
assert_eq!(
|
||||
csv,
|
||||
"\u{FEFF}Dönem başlangıcı;Dönem;Tür;Ad;Tutar;Tarih\r\n01.11.2026;Kasım 2026;Gelir;Maaş;45000,00;\r\n01.11.2026;Kasım 2026;Sabit gider;Kira;15000,00;05.11.2026\r\n"
|
||||
);
|
||||
|
||||
// Başka hane: aynı dönemi açıp içe aktarır
|
||||
let other = app.register("Diger", None).await;
|
||||
let onov = open(&app, &other, "2026-11-01", "2026-12-01").await;
|
||||
let with_errors = format!(
|
||||
"{csv}01.11.2026;Kasım 2026;Prim;Bonus;100,00;\r\n01.01.2026;;Gelir;Eski;1,00;\r\n"
|
||||
);
|
||||
let r = import(&app, &other, "plan-items", &with_errors, true).await;
|
||||
assert_eq!(r["imported"], 2);
|
||||
assert_eq!(
|
||||
errors(&r),
|
||||
vec![
|
||||
(4, "Tür \"Gelir\" ya da \"Sabit gider\" olmalı: Prim".into()),
|
||||
(5, "Bu tarih için dönem yok".into()),
|
||||
]
|
||||
);
|
||||
let (_, items) = app
|
||||
.get(&format!("/periods/{onov}/plan-items"), &other)
|
||||
.await;
|
||||
assert_eq!(items.as_array().unwrap().len(), 2);
|
||||
// Tekrar → atlanır
|
||||
let r = import(&app, &other, "plan-items", &csv, true).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["imported"].as_u64()),
|
||||
(Some(2), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn asset_transactions_in_date_order() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Test", None).await;
|
||||
// Satış dosyada alımdan önce ama tarihi sonra → geçerli; fazla satış → hata
|
||||
let file = "Tarih;Varlık kodu;İşlem;Miktar;Birim fiyat;Tutar;Not\r\n\
|
||||
10.11.2026;XAU_GRAM;Satım;4;;26.000;\r\n\
|
||||
01.11.2026;XAU_GRAM;Alım;10;6.300;63.000;Kuyumcu\r\n\
|
||||
12.11.2026;XAU_GRAM;Satış;7;;45.000;\r\n\
|
||||
02.11.2026;TRY;Alım;;;5.000;\r\n\
|
||||
02.11.2026;BTC;Alım;1;;1;\r\n\
|
||||
03.11.2026;USD;Alım;abc;;100;\r\n";
|
||||
let dry = import(&app, &s, "asset-transactions", file, false).await;
|
||||
assert_eq!(dry["valid"], 3);
|
||||
assert_eq!(
|
||||
errors(&dry),
|
||||
vec![
|
||||
(4, "Satış miktarı o tarihte eldeki miktarı aşıyor".into()),
|
||||
(6, "Varlık bulunamadı: BTC".into()),
|
||||
(7, "Miktar geçersiz: abc".into()),
|
||||
]
|
||||
);
|
||||
let r = import(&app, &s, "asset-transactions", file, true).await;
|
||||
assert_eq!(r["imported"], 3);
|
||||
let (_, assets) = app.get("/assets", &s).await;
|
||||
let gram = assets
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "XAU_GRAM")
|
||||
.unwrap();
|
||||
assert_eq!(gram["quantity_e6"], 6_000_000);
|
||||
assert_eq!(gram["realized_pl"], 80_000);
|
||||
let tl = assets
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|a| a["code"] == "TRY")
|
||||
.unwrap();
|
||||
assert_eq!(tl["cost"], 500_000);
|
||||
|
||||
// Dışa aktar → geri yükle: hepsi tekrar
|
||||
let (_, _, csv) = app.get_text("/export/asset-transactions.csv", &s).await;
|
||||
assert!(
|
||||
csv.contains("01.11.2026;XAU_GRAM;Gram Altın;Alım;10;6300;63000,00;Kuyumcu"),
|
||||
"{csv}"
|
||||
);
|
||||
let r = import(&app, &s, "asset-transactions", &csv, false).await;
|
||||
assert_eq!(
|
||||
(r["duplicates"].as_u64(), r["valid"].as_u64()),
|
||||
(Some(3), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
+11
-1
@@ -81,7 +81,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
```js
|
||||
{ _id, household_id, name /* "Aralık 2026" */,
|
||||
start_date: "2026-12-01", end_date: "2027-01-01", // bitiş HARİÇ
|
||||
status: "open" | "closed" | "correcting",
|
||||
status: "open" | "closed" | "correcting" | "archived",
|
||||
savings_goal: 500000, // kuruş — birikim hedefi
|
||||
reflection_md: "...", // "Nasıl daha iyi yapabilirim?"
|
||||
to_savings: 450000, // kapanışta birikime aktarılan (Faz 8'de varlık hareketi)
|
||||
@@ -93,6 +93,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
- Hanede en fazla **bir** `open` dönem (servis kontrolü + kısmi benzersiz indeks).
|
||||
- **Yeniden açma (karar 2026-11-12, "düzeltme modu"):** sahip kapalı dönemi açınca durum `correcting` olur; yazılabilir ama "açık dönem" sayılmaz → yeni dönem açık kalabilir (tek `open` kuralı korunur). Mevcut devir "yeniden kapat"a kadar yerinde kalır; yeniden kapatınca eskisi soft delete, yenisi hesaplanır. Ardından kapanmış dönem varsa açılamaz (zincirleme devirler bozulmasın).
|
||||
- **Haftalar saklanmaz.** `start_date`/`end_date`'ten hesaplanır (Pazartesi başlangıçlı; ilk/son kısmi haftalar ayrı).
|
||||
- **Arşiv (`archived`, karar 2026-11-13):** ekstre içe aktarımında dönemi olmayan aylar için takvim ayı kadar açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Yazılabilir (kayıt düzeltme), ama kapatılmaz, devir üretmez; devir zincirinde (yeni dönemin önceki dönemi, kapatmada sonraki dönem, yeniden açma engeli) **atlanır**. Bütçe önerileri ("önceki dönem", "son 3 ay") kapalı **ve** arşiv dönemleri kullanır.
|
||||
|
||||
### 3.5 `categories` — Alt kategoriler
|
||||
```js
|
||||
@@ -137,6 +138,15 @@ Renkler ve ikonlar frontend tasarım token'larında.
|
||||
description: "Haftalık market",
|
||||
created_by /* user_id */, created_at, ... }
|
||||
```
|
||||
- **İade** (ekstreden, karar 2026-11-13): aynı kategoride **eksi** `amount`; toplamlar net olur. Elle girişte tutar > 0; iade düzenlemede eksi kalır.
|
||||
- `import_key` (isteğe bağlı): ekstreden gelen kayıtta `tarih|yön|tutar|sadeleştirilmiş açıklama` → aynı satır ikinci kez aktarılmaz (plan kalemlerinde de, gelir için).
|
||||
|
||||
### 3.9a `import_rules` — Ekstre öğrenilmiş kuralları
|
||||
```js
|
||||
{ _id, household_id, pattern /* açıklamanın ilk 2 anlamlı kelimesi, ASCII küçük: "migros istanbul" */,
|
||||
kind: "expense" | "refund" | "income" | "ignore", category_id, uses, created_at, updated_at, version }
|
||||
```
|
||||
- Aktarmada her seçim kural olarak yazılır (upsert); sonraki ekstrede aynı `pattern` öneri olur.
|
||||
|
||||
### 3.10 `carryovers` — Devirler
|
||||
```js
|
||||
|
||||
+10
-1
@@ -1,10 +1,13 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 9 planı:** Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı. Önce `KAKEIBO_PROMPT.md` §8.8–8.9 (+ `templates/monthly_report.md`) oku; rapor biçimini kullanıcı verecek (açık konu). Kullanıcı şartı: raporlarda **tüm** kategorilerin dağılımı. Adımları PHASES'e yaz, onaylat. Faz 9'da Dokploy adımları da var (§12.2) — canlı kurulum zaten yapıldı.
|
||||
**Faz 9 – Adım 4b:** Ekstre arayüzü (Ayarlar → Veri ya da ayrı sayfa `/ice-aktar`). Excel okuma: SheetJS (`xlsx` npm sürümü eski/açıklı → `https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz` tarball; lazy; gerekçe ver) — .xlsx/.xls (+ HTML-xls), CSV `lib/decode.ts`. Akış: dosya → sayfa/başlık satırı seç → sütun eşleştir (tarih, açıklama, tutar ya da Borç/Alacak iki sütun, işaret: kart/hesap) + **profil** kaydet (localStorage, banka adıyla) → `POST /bank-import/preview` → tablo: satır başına tür (harcama/iade/gelir/yoksay) + kategori seçimi (öneri, `from_rule` rozeti), tekrar/engelli satırlar soluk, açılacak arşivler listesi, toplu "aynı açıklamaya uygula" → ödeme türü seç → `commit` → rapor. Tarih biçimleri (GG.AA.YYYY, GG/AA/YYYY, Excel seri sayı), tutar (1.250,50 / -45,90 / Borç-Alacak). Defter: iade satırı "İade" rozeti; iade düzenlemede tutar alanı eksi/mutlak (şu an istemci şeması eksiyi reddeder → düzelt).
|
||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
- **Ekstre kararları (kullanıcı, 2026-11-13):** biçim Excel (.xlsx/.xls) + CSV (kullanıcı tam bilmiyor; ilk gerçek ekstreyle denenecek), hesaba gelenler → **gelir** (plan kalemi), arşiv dönemi **takvim ayı**, iadeler **aynı kategoriden düşülür** (eksi tutarlı kayıt).
|
||||
- **Dikkat (2026-11-13):** Claude'un hızlı ardışık `backend/src` değişiklikleri (bilerek bozup geri alma, toplu düzenleme) kullanıcının `npm run dev` içindeki `cargo watch`'ını backend kapalı halde bırakabiliyor (giriş: "Beklenmeyen hata", 5173 → 502). Böyle denemelerden sonra `curl localhost:8080/api/health`; kapalıysa `touch backend/src/main.rs` (cargo watch yeniden başlatır). Arayüz artık 502/503/504'te "Sunucu şu an yanıt vermiyor" der.
|
||||
- **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir.
|
||||
- **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir).
|
||||
- **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler".
|
||||
- **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce.
|
||||
@@ -130,3 +133,9 @@
|
||||
- **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir.
|
||||
- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `<details>` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine.
|
||||
- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not "<ad> kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.**
|
||||
|
||||
### 2026-11-13 — Faz 9
|
||||
- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test.
|
||||
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
|
||||
- **Adım 3:** CSV dışa/içe aktarma. Backend `services/csv_io.rs` (saf: yazma BOM/`;`/CRLF/`1250,50`/GG.AA.YYYY; okuma: BOM, `;`/`,`/sekme otomatik, tırnak, satır numarası, `norm` başlık eşleme (Türkçe İ/ı), `parse_money` (1.250,50 · 1250.50 · 1.000=bin), `parse_e6`, `parse_date_any`), `services/transfer.rs` (harcama/plan/varlık: dışa aktar; içe aktar `commit=false` deneme → rapor {total, valid, duplicates, imported, errors[(satır, neden)], preview}; tekrar sayacı `Existing` (dosyada 2, DB'de 1 → 1 eklenir); harcamada dönem tarihten, kapalı/yok → satır hatası, kategori adla (+grup), aynı ad 2 grupta → hata; plan `plan::create` ile; varlıkta tarih+dosya sırasına göre eldekinden fazla satış simülasyonu, yazma `asset::create_tx`). `Group::label()`. Rotalar `routes/transfer.rs` (`/export/*.csv`, `POST /import/{tür}` 5 MB). `tests/transfer.rs` (3) + csv_io birim (4); mutasyon 3/3. Frontend: Ayarlar → Veri (`features/settings/data-section.tsx`: indirme bağlantıları, içe aktarma: tür seç → dosya → deneme raporu → onay), `api.postCsv`, `lib/decode.ts` (UTF-8 fatal → Windows-1254), `lib/transfer.ts`. Backend 164, frontend 125 test; e2e 16 kontrol.
|
||||
- **Adım 4a:** Ekstre backend. `PeriodStatus::Archived` (yazılabilir; kapatılmaz 409; devir zincirinde atlanır: `period::create` önceki, `closing::next_period`, reopen engeli; `budget::previous_closed` kapalı+arşiv), `period::create_archive`. `services/bank_import.rs`: `fold` (Türkçe → ASCII; **"İ".to_lowercase() = i + U+0307** düzeltildi), `rule_key` (ilk 2 anlamlı kelime), `import_key`, `default_kind` (ödeme/virman → yoksay; kartta giriş → iade, hesapta → gelir), `target` (içeren yazılabilir dönem / takvim ayı arşivi (kırpılmış) / gelecek-kapalı engel), `preview`, `commit` (arşivleri açar, harcama/iade insert_many + `import_key`, gelir `plan::create` + `import_key`, kurallar upsert `import_rules`). Entry update: iade eksi kalır. Rotalar `routes/bank_import.rs` (`/bank-import/preview|commit`, 5 MB). `tests/bank_import.rs` (3, devir zinciri dahil); mutasyon 5/5. Frontend: `archived` durum metni, Değerlendirme'de arşiv bilgisi, kapatma sihirbazı arşivi reddeder. Backend 177 test.
|
||||
|
||||
+9
-1
@@ -13,7 +13,7 @@
|
||||
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
|
||||
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ |
|
||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 |
|
||||
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
|
||||
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
|
||||
|
||||
@@ -77,3 +77,11 @@
|
||||
- ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
|
||||
- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
|
||||
- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
|
||||
|
||||
## Faz 9 Adımları
|
||||
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
|
||||
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
||||
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||
- 🔄 4. **Banka / kredi kartı ekstresi içe aktarma** — ✅ 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) · ⏳ 4b arayüz (Excel/CSV okuma, sütun eşleştirme + banka profili, satır gözden geçirme)
|
||||
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
|
||||
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
||||
|
||||
@@ -19,14 +19,14 @@ export function AppLayout() {
|
||||
<Sidebar />
|
||||
|
||||
{/* Mobil üst çubuk */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden">
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden print:hidden">
|
||||
<Logo size={28} withText />
|
||||
</header>
|
||||
|
||||
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
|
||||
pb-28 → mobilde alt menünün altında içerik kalmasın. */}
|
||||
<div className="md:pl-64">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10">
|
||||
<div className="md:pl-64 print:pl-0">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10 print:max-w-none print:p-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@ export function AppLayout() {
|
||||
|
||||
function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex">
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex print:hidden">
|
||||
<div className="flex h-16 items-center px-5">
|
||||
<Logo size={32} withText />
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@ function BottomNav() {
|
||||
return (
|
||||
<nav
|
||||
aria-label={t.nav.main}
|
||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden print:hidden"
|
||||
>
|
||||
<ul className="grid grid-cols-5">
|
||||
{bottomNav.map(({ to, label, icon: Icon }) => (
|
||||
|
||||
@@ -24,7 +24,7 @@ export function PeriodHeading({ period, actions }: { period: Period; actions?: R
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
period.status === 'closed' ? 'bg-muted text-muted-foreground' : 'bg-warning/15',
|
||||
period.status === 'correcting' ? 'bg-warning/15' : 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t.periods.status[period.status]}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { formatCompact, formatMoney, formatPercent } from '@/lib/format'
|
||||
import { savingsRate } from '@/lib/reports'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { PeriodStat } from '@/types/PeriodStat'
|
||||
|
||||
const s = t.reports
|
||||
|
||||
/** Ham CSS değişkenleri (`--color-*` `@theme inline` yüzünden derlenmiş CSS'te olmayabilir) */
|
||||
const C = {
|
||||
income: 'var(--income)',
|
||||
expense: 'var(--expense)',
|
||||
primary: 'var(--primary)',
|
||||
grid: 'var(--border)',
|
||||
axis: 'var(--muted-foreground)',
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
background: 'var(--popover)',
|
||||
color: 'var(--popover-foreground)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
fontSize: 13,
|
||||
}
|
||||
|
||||
/** Dönem adını eksende kısalt: "Kasım 2026" → "Kas 26" */
|
||||
const short = (name: string) => {
|
||||
const m = /^(\S+)\s+(\d{4})$/.exec(name)
|
||||
return m ? `${m[1].slice(0, 3)} ${m[2].slice(2)}` : name
|
||||
}
|
||||
|
||||
/** Raporlar sayfasının grafikleri (Recharts → ayrı parça) */
|
||||
export default function ReportChart({
|
||||
kind,
|
||||
periods,
|
||||
}: {
|
||||
kind: 'periods' | 'rate'
|
||||
periods: PeriodStat[]
|
||||
}) {
|
||||
const data = periods.map((p) => ({
|
||||
label: short(p.name),
|
||||
name: p.name,
|
||||
income: p.income_total,
|
||||
expense: p.fixed_expense_total + p.spent,
|
||||
rate: savingsRate(p),
|
||||
}))
|
||||
return (
|
||||
<div className="h-64 sm:h-72" aria-hidden>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{kind === 'periods' ? (
|
||||
<BarChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
|
||||
<YAxis
|
||||
stroke={C.axis}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => formatCompact(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => (typeof v === 'number' ? formatMoney(v) : '—')}
|
||||
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
|
||||
contentStyle={tooltipStyle}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} itemSorter={null} />
|
||||
<Bar
|
||||
dataKey="income"
|
||||
name={s.income}
|
||||
fill={C.income}
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="expense"
|
||||
name={s.expense}
|
||||
fill={C.expense}
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
|
||||
<YAxis
|
||||
stroke={C.axis}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={48}
|
||||
tickFormatter={(v: number) => formatPercent(v, 0)}
|
||||
/>
|
||||
<ReferenceLine y={0} stroke={C.axis} />
|
||||
<Tooltip
|
||||
formatter={(v) => (typeof v === 'number' ? formatPercent(v) : '—')}
|
||||
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
|
||||
contentStyle={tooltipStyle}
|
||||
/>
|
||||
<Line
|
||||
dataKey="rate"
|
||||
name={s.rate}
|
||||
stroke={C.primary}
|
||||
strokeWidth={3}
|
||||
dot={{ r: 3, strokeWidth: 2, fill: 'var(--card)' }}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ChevronRight, X } from 'lucide-react'
|
||||
import { Segmented } from '@/components/segmented'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toIsoDate } from '@/lib/format'
|
||||
import { GROUPS, type GroupCode } from '@/lib/groups'
|
||||
import { activeFilterCount, type ReportFilter } from '@/lib/reports'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import type { Period } from '@/types/Period'
|
||||
|
||||
const s = t.reports
|
||||
const select = 'h-11 w-full rounded-lg border bg-background px-3 text-sm'
|
||||
|
||||
type Scope = 'all' | 'period' | 'range'
|
||||
|
||||
/** Rapor filtreleri. Mobilde katlanır; seçim adrese yazılır (sayfa yenilense de kalır). */
|
||||
export function Filters({
|
||||
filter: f,
|
||||
onChange,
|
||||
periods,
|
||||
categories,
|
||||
methods,
|
||||
}: {
|
||||
filter: ReportFilter
|
||||
onChange: (f: ReportFilter) => void
|
||||
periods: Period[]
|
||||
categories: CatalogItem[]
|
||||
methods: CatalogItem[]
|
||||
}) {
|
||||
const [open] = useState(
|
||||
() => activeFilterCount(f) > 0 || window.matchMedia('(min-width: 768px)').matches,
|
||||
)
|
||||
const scope: Scope = f.period ? 'period' : f.from || f.to ? 'range' : 'all'
|
||||
const count = activeFilterCount(f)
|
||||
const cats = categories.filter((c) => !f.group || c.group === f.group)
|
||||
|
||||
function setScope(next: Scope) {
|
||||
const rest = { ...f, period: undefined, from: undefined, to: undefined }
|
||||
if (next === 'period') onChange({ ...rest, period: periods[0]?.id })
|
||||
else if (next === 'range') {
|
||||
const today = toIsoDate(new Date())
|
||||
onChange({ ...rest, from: `${today.slice(0, 4)}-01-01`, to: today })
|
||||
} else onChange(rest)
|
||||
}
|
||||
|
||||
return (
|
||||
<details open={open} className="group rounded-2xl border bg-card p-4 sm:p-5 print:hidden">
|
||||
<summary className="flex min-h-11 cursor-pointer items-center justify-between font-semibold">
|
||||
{s.filterCount(count)}
|
||||
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{s.scope}</p>
|
||||
<Segmented
|
||||
label={s.scope}
|
||||
value={scope}
|
||||
onChange={setScope}
|
||||
options={[
|
||||
{ value: 'all', label: s.allTime },
|
||||
{ value: 'period', label: s.byPeriod },
|
||||
{ value: 'range', label: s.byRange },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{scope === 'period' && (
|
||||
<Field label={s.byPeriod} id="f-period">
|
||||
<select
|
||||
id="f-period"
|
||||
name="period"
|
||||
className={select}
|
||||
value={f.period ?? ''}
|
||||
onChange={(e) => onChange({ ...f, period: e.target.value || undefined })}
|
||||
>
|
||||
{periods.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{scope === 'range' && (
|
||||
<>
|
||||
<Field label={s.from} id="f-from">
|
||||
<Input
|
||||
id="f-from"
|
||||
name="from"
|
||||
type="date"
|
||||
value={f.from ?? ''}
|
||||
max={f.to}
|
||||
onChange={(e) => onChange({ ...f, from: e.target.value || undefined })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={s.to} id="f-to">
|
||||
<Input
|
||||
id="f-to"
|
||||
name="to"
|
||||
type="date"
|
||||
value={f.to ?? ''}
|
||||
min={f.from}
|
||||
onChange={(e) => onChange({ ...f, to: e.target.value || undefined })}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label={s.group} id="f-group">
|
||||
<select
|
||||
id="f-group"
|
||||
name="group"
|
||||
className={select}
|
||||
value={f.group ?? ''}
|
||||
onChange={(e) => {
|
||||
const group = (e.target.value || undefined) as GroupCode | undefined
|
||||
// Seçili kategori başka gruptaysa bırak
|
||||
const keep = categories.find((c) => c.id === f.category)?.group === group
|
||||
onChange({ ...f, group, category: keep ? f.category : undefined })
|
||||
}}
|
||||
>
|
||||
<option value="">{s.any}</option>
|
||||
{GROUPS.map((g) => (
|
||||
<option key={g.code} value={g.code}>
|
||||
{g.emoji} {g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={s.category} id="f-category">
|
||||
<select
|
||||
id="f-category"
|
||||
name="category"
|
||||
className={select}
|
||||
value={f.category ?? ''}
|
||||
onChange={(e) => onChange({ ...f, category: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">{s.any}</option>
|
||||
{cats.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.emoji ? `${c.emoji} ` : ''}
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={s.method} id="f-method">
|
||||
<select
|
||||
id="f-method"
|
||||
name="method"
|
||||
className={select}
|
||||
value={f.method ?? ''}
|
||||
onChange={(e) => onChange({ ...f, method: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">{s.any}</option>
|
||||
<option value="none">{s.noMethod}</option>
|
||||
{methods.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<Button variant="ghost" onClick={() => onChange({})}>
|
||||
<X aria-hidden />
|
||||
{s.clear}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,114 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { Printer } from 'lucide-react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||
import { Skeleton } from '@/components/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { usePortfolio } from '@/lib/assets'
|
||||
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
||||
import { useCurrentPeriod, usePeriods, useStats } from '@/lib/periods'
|
||||
import { printSection } from '@/lib/print'
|
||||
import {
|
||||
describeFilter,
|
||||
filterToParams,
|
||||
parseFilter,
|
||||
useReportBreakdown,
|
||||
type ReportFilter,
|
||||
} from '@/lib/reports'
|
||||
import { t } from '@/lib/strings'
|
||||
import { Filters } from './filters'
|
||||
import {
|
||||
AssetsReport,
|
||||
CategoriesReport,
|
||||
DeviationReport,
|
||||
GroupsReport,
|
||||
MethodsReport,
|
||||
MonthsReport,
|
||||
PeriodsReport,
|
||||
RateReport,
|
||||
} from './sections'
|
||||
|
||||
const s = t.reports
|
||||
|
||||
/**
|
||||
* Raporlar: filtreler adreste (`?donem=`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`).
|
||||
* Kırılımlar filtreye göre; dönemler arası özet ve varlıklar filtrelerden bağımsız;
|
||||
* bütçe sapması seçili dönem (yoksa açık / son dönem). Her bölüm CSV + yazdır.
|
||||
*/
|
||||
export function ReportsPage() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const filter = parseFilter(params)
|
||||
const periods = usePeriods()
|
||||
const current = useCurrentPeriod()
|
||||
const categories = useCategories(true)
|
||||
const methods = usePaymentMethods(true)
|
||||
const stats = useStats(12)
|
||||
const breakdown = useReportBreakdown(filter)
|
||||
const portfolio = usePortfolio()
|
||||
|
||||
const setFilter = (f: ReportFilter) => setParams(filterToParams(f), { replace: true })
|
||||
const failed = [periods, categories, methods].find((q) => q.isError)
|
||||
const devPeriod =
|
||||
periods.data?.find((p) => p.id === filter.period) ?? current.data ?? periods.data?.[0] ?? null
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.reports.title} description={t.pages.reports.description} />
|
||||
<ComingSoon when="Faz 9'da" />
|
||||
<PageHeader
|
||||
title={t.pages.reports.title}
|
||||
description={t.pages.reports.description}
|
||||
actions={
|
||||
<Button variant="outline" className="print:hidden" onClick={() => printSection()}>
|
||||
<Printer aria-hidden />
|
||||
{s.printAll}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{failed?.error ? (
|
||||
<PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||
) : !periods.data || !categories.data || !methods.data ? (
|
||||
<PageLoading />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
|
||||
<p className="hidden text-sm print:block">
|
||||
{describeFilter(filter, {
|
||||
period: (id) => periods.data.find((p) => p.id === id)?.name,
|
||||
category: (id) => categories.data.find((c) => c.id === id)?.name,
|
||||
method: (id) => methods.data.find((m) => m.id === id)?.name,
|
||||
})}
|
||||
</p>
|
||||
<Filters
|
||||
filter={filter}
|
||||
onChange={setFilter}
|
||||
periods={periods.data}
|
||||
categories={categories.data}
|
||||
methods={methods.data}
|
||||
/>
|
||||
{breakdown.isError ? (
|
||||
<PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
|
||||
) : !breakdown.data ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : (
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
|
||||
<GroupsReport b={breakdown.data} />
|
||||
<MethodsReport b={breakdown.data} methods={methods.data} />
|
||||
<div className="lg:col-span-2">
|
||||
<CategoriesReport b={breakdown.data} categories={categories.data} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<MonthsReport b={breakdown.data} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DeviationReport period={devPeriod} />
|
||||
{stats.data && (
|
||||
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
|
||||
<PeriodsReport periods={stats.data.periods} />
|
||||
<RateReport periods={stats.data.periods} />
|
||||
</div>
|
||||
)}
|
||||
{portfolio.data && <AssetsReport p={portfolio.data} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { Download, Printer } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { downloadCsv, toCsv, type Cell } from '@/lib/csv'
|
||||
import { formatMoney, formatPercent, toIsoDate } from '@/lib/format'
|
||||
import { printSection } from '@/lib/print'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const s = t.reports
|
||||
|
||||
export type CsvData = { file: string; headers: string[]; rows: Cell[][] }
|
||||
|
||||
/**
|
||||
* Rapor bölümü: başlık + açıklama + "CSV indir" / "Yazdır" (yalnızca bu bölüm).
|
||||
* `data-report` → yazdırma stili (lib/print.ts, index.css).
|
||||
*/
|
||||
export function ReportSection({
|
||||
id,
|
||||
title,
|
||||
hint,
|
||||
note,
|
||||
csv,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
hint?: string
|
||||
/** "Filtrelere göre" / "Filtrelerden bağımsız" */
|
||||
note?: string
|
||||
csv?: CsvData
|
||||
children: ReactNode
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null)
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
data-report
|
||||
aria-labelledby={`${id}-title`}
|
||||
className="rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6"
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 id={`${id}-title`} className="font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{(hint || note) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hint}
|
||||
{hint && note && ' · '}
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 print:hidden">
|
||||
{csv && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
`kakeibo-${csv.file}-${toIsoDate(new Date())}`,
|
||||
toCsv(csv.headers, csv.rows),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download aria-hidden />
|
||||
{s.csv}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => printSection(ref.current)}>
|
||||
<Printer aria-hidden />
|
||||
{s.print}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tablo + satır içi pay çubuğu (yazdırmada da görünür; grafik kütüphanesi gerekmez) */
|
||||
export function ShareTable({
|
||||
rows,
|
||||
total,
|
||||
firstHeader,
|
||||
showCount = true,
|
||||
}: {
|
||||
rows: { key: string; label: ReactNode; amount: number; count?: number; color?: string }[]
|
||||
total: number
|
||||
firstHeader: string
|
||||
showCount?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-2 font-medium">{firstHeader}</th>
|
||||
{showCount && <th className="px-2 py-2 text-right font-medium">{s.count}</th>}
|
||||
<th className="px-2 py-2 text-right font-medium">{s.share}</th>
|
||||
<th className="py-2 pl-2 text-right font-medium">{s.amount}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const ratio = total > 0 ? r.amount / total : 0
|
||||
return (
|
||||
<tr key={r.key} className="border-b last:border-0">
|
||||
<td className="py-2 pr-2">
|
||||
<div className="min-w-32">{r.label}</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div
|
||||
className={cn('h-full rounded-full', r.color ?? 'bg-primary')}
|
||||
style={{ width: `${ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
{showCount && <td className="money px-2 py-2 text-right">{r.count ?? ''}</td>}
|
||||
<td className="money px-2 py-2 text-right text-muted-foreground">
|
||||
{formatPercent(ratio)}
|
||||
</td>
|
||||
<td className="money py-2 pl-2 text-right font-medium whitespace-nowrap">
|
||||
{formatMoney(r.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-semibold">
|
||||
<td className="py-2 pr-2">{s.total}</td>
|
||||
{showCount && (
|
||||
<td className="money px-2 py-2 text-right">
|
||||
{rows.reduce((a, r) => a + (r.count ?? 0), 0)}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-2 py-2" />
|
||||
<td className="money py-2 pl-2 text-right whitespace-nowrap">{formatMoney(total)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import { lazy, Suspense, type ReactNode } from 'react'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { Skeleton } from '@/components/skeleton'
|
||||
import { csvMoney, csvPercent } from '@/lib/csv'
|
||||
import { formatE6 } from '@/lib/decimal'
|
||||
import { formatDate, formatDayMonth, formatMoney, formatPercent } from '@/lib/format'
|
||||
import { group } from '@/lib/groups'
|
||||
import { useBudget } from '@/lib/periods'
|
||||
import { deviation, formatMonth, savingsRate } from '@/lib/reports'
|
||||
import { formatPlPercent, plRatio, qty } from '@/lib/savings'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CatalogItem } from '@/types/CatalogItem'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PeriodStat } from '@/types/PeriodStat'
|
||||
import type { Portfolio } from '@/types/Portfolio'
|
||||
import type { ReportBreakdown } from '@/types/ReportBreakdown'
|
||||
import { ReportSection, ShareTable } from './section'
|
||||
|
||||
const s = t.reports
|
||||
const ReportChart = lazy(() => import('./charts'))
|
||||
const chartFallback = <Skeleton className="h-64 sm:h-72" />
|
||||
|
||||
const signed = (kurus: number) => formatMoney(kurus, 'TRY', { signed: true })
|
||||
const pct = (r: number | null) => (r === null ? '—' : formatPercent(r))
|
||||
|
||||
// ---------- Dönemler (filtrelerden bağımsız) ----------
|
||||
|
||||
export function PeriodsReport({ periods }: { periods: PeriodStat[] }) {
|
||||
return (
|
||||
<ReportSection
|
||||
id="r-periods"
|
||||
title={s.periodsTitle}
|
||||
hint={s.periodsHint}
|
||||
note={s.unfilteredNote}
|
||||
csv={{
|
||||
file: s.file.periods,
|
||||
headers: [s.period, s.from, s.to, s.income, s.fixed, s.spent, s.realized, `${s.rate} (%)`],
|
||||
rows: periods.map((p) => [
|
||||
p.name,
|
||||
formatDate(p.start_date),
|
||||
formatDate(p.last_date),
|
||||
csvMoney(p.income_total),
|
||||
csvMoney(p.fixed_expense_total),
|
||||
csvMoney(p.spent),
|
||||
csvMoney(p.realized_savings),
|
||||
csvPercent(savingsRate(p)),
|
||||
]),
|
||||
}}
|
||||
>
|
||||
{periods.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
|
||||
) : (
|
||||
<>
|
||||
<Suspense fallback={chartFallback}>
|
||||
<ReportChart kind="periods" periods={periods} />
|
||||
</Suspense>
|
||||
<p className="sr-only">{s.periodsSummary(periods.length)}</p>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-2 font-medium">{s.period}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.income}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.expense}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.realized}</th>
|
||||
<th className="py-2 pl-2 text-right font-medium">{s.rate}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...periods].reverse().map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2 pr-2 whitespace-nowrap">{p.name}</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(p.income_total)}
|
||||
</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(p.fixed_expense_total + p.spent)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
'money px-2 py-2 text-right whitespace-nowrap',
|
||||
p.realized_savings < 0 && 'text-expense',
|
||||
)}
|
||||
>
|
||||
{formatMoney(p.realized_savings)}
|
||||
</td>
|
||||
<td className="money py-2 pl-2 text-right">{pct(savingsRate(p))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function RateReport({ periods }: { periods: PeriodStat[] }) {
|
||||
const last = periods.at(-1)
|
||||
return (
|
||||
<ReportSection id="r-rate" title={s.rateTitle} hint={s.rateHint} note={s.unfilteredNote}>
|
||||
{periods.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
|
||||
) : (
|
||||
<>
|
||||
<Suspense fallback={chartFallback}>
|
||||
<ReportChart kind="rate" periods={periods} />
|
||||
</Suspense>
|
||||
{last && (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{s.rateSummary(last.name, pct(savingsRate(last)))}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Kırılımlar (filtrelere göre) ----------
|
||||
|
||||
export function GroupsReport({ b }: { b: ReportBreakdown }) {
|
||||
return (
|
||||
<ReportSection
|
||||
id="r-groups"
|
||||
title={s.groupsTitle}
|
||||
note={s.filteredNote}
|
||||
csv={{
|
||||
file: s.file.groups,
|
||||
headers: [s.group, s.count, `${s.share} (%)`, s.amount],
|
||||
rows: b.groups.map((r) => [
|
||||
group(r.group).label,
|
||||
r.count,
|
||||
csvPercent(b.total ? r.total / b.total : 0),
|
||||
csvMoney(r.total),
|
||||
]),
|
||||
}}
|
||||
>
|
||||
<ShareTable
|
||||
firstHeader={s.group}
|
||||
total={b.total}
|
||||
rows={b.groups.map((r) => {
|
||||
const g = group(r.group)
|
||||
return {
|
||||
key: r.group,
|
||||
label: (
|
||||
<span className={g.className.text}>
|
||||
{g.emoji} {g.label}
|
||||
</span>
|
||||
),
|
||||
amount: r.total,
|
||||
count: r.count,
|
||||
color: g.className.fill,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoriesReport({
|
||||
b,
|
||||
categories,
|
||||
}: {
|
||||
b: ReportBreakdown
|
||||
categories: CatalogItem[]
|
||||
}) {
|
||||
const name = (id: string) => categories.find((c) => c.id === id)
|
||||
return (
|
||||
<ReportSection
|
||||
id="r-categories"
|
||||
title={s.categoriesTitle}
|
||||
hint={s.categoriesHint}
|
||||
note={s.filteredNote}
|
||||
csv={{
|
||||
file: s.file.categories,
|
||||
headers: [s.category, s.group, s.count, `${s.share} (%)`, s.amount],
|
||||
rows: b.categories.map((r) => [
|
||||
name(r.category_id)?.name ?? '—',
|
||||
group(r.group).label,
|
||||
r.count,
|
||||
csvPercent(b.total ? r.total / b.total : 0),
|
||||
csvMoney(r.total),
|
||||
]),
|
||||
}}
|
||||
>
|
||||
{b.categories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||
) : (
|
||||
<ShareTable
|
||||
firstHeader={s.category}
|
||||
total={b.total}
|
||||
rows={b.categories.map((r) => {
|
||||
const c = name(r.category_id)
|
||||
const g = group(r.group)
|
||||
return {
|
||||
key: r.category_id,
|
||||
label: (
|
||||
<>
|
||||
{c?.emoji ? `${c.emoji} ` : ''}
|
||||
{c?.name ?? '—'}{' '}
|
||||
<span className={cn('text-xs', g.className.text)}>{g.label}</span>
|
||||
</>
|
||||
),
|
||||
amount: r.total,
|
||||
count: r.count,
|
||||
color: g.className.fill,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function MethodsReport({ b, methods }: { b: ReportBreakdown; methods: CatalogItem[] }) {
|
||||
const name = (id: string | null) =>
|
||||
id ? (methods.find((m) => m.id === id)?.name ?? '—') : s.noMethod
|
||||
return (
|
||||
<ReportSection
|
||||
id="r-methods"
|
||||
title={s.methodsTitle}
|
||||
note={s.filteredNote}
|
||||
csv={{
|
||||
file: s.file.methods,
|
||||
headers: [s.method, s.count, `${s.share} (%)`, s.amount],
|
||||
rows: b.payment_methods.map((r) => [
|
||||
name(r.payment_method_id),
|
||||
r.count,
|
||||
csvPercent(b.total ? r.total / b.total : 0),
|
||||
csvMoney(r.total),
|
||||
]),
|
||||
}}
|
||||
>
|
||||
{b.payment_methods.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||
) : (
|
||||
<ShareTable
|
||||
firstHeader={s.method}
|
||||
total={b.total}
|
||||
rows={b.payment_methods.map((r) => ({
|
||||
key: r.payment_method_id ?? 'none',
|
||||
label: name(r.payment_method_id),
|
||||
amount: r.total,
|
||||
count: r.count,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function MonthsReport({ b }: { b: ReportBreakdown }) {
|
||||
return (
|
||||
<ReportSection
|
||||
id="r-months"
|
||||
title={s.monthsTitle}
|
||||
note={s.filteredNote}
|
||||
csv={{
|
||||
file: s.file.months,
|
||||
headers: [s.period, s.count, s.amount],
|
||||
rows: b.months.map((r) => [formatMonth(r.month), r.count, csvMoney(r.total)]),
|
||||
}}
|
||||
>
|
||||
{b.months.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||
) : (
|
||||
<ShareTable
|
||||
firstHeader={s.period}
|
||||
total={b.total}
|
||||
rows={b.months.map((r) => ({
|
||||
key: r.month,
|
||||
label: formatMonth(r.month),
|
||||
amount: r.total,
|
||||
count: r.count,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Bütçe sapması (tek dönem) ----------
|
||||
|
||||
export function DeviationReport({ period }: { period: Period | null }) {
|
||||
if (!period) {
|
||||
return (
|
||||
<ReportSection id="r-deviation" title={s.deviationTitle}>
|
||||
<p className="text-sm text-muted-foreground">{s.noDeviationPeriod}</p>
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
return <DeviationView period={period} />
|
||||
}
|
||||
|
||||
function DeviationView({ period }: { period: Period }) {
|
||||
const budget = useBudget(period.id)
|
||||
const rows = budget.data ? deviation(budget.data) : []
|
||||
const weeks = period.weeks.map((w, i) => {
|
||||
const planned = budget.data?.reduce((a, b) => a + (b.weeks[i]?.amount ?? 0), 0) ?? 0
|
||||
const spent = budget.data?.reduce((a, b) => a + (b.weeks[i]?.spent ?? 0), 0) ?? 0
|
||||
return { w, planned, spent, diff: planned - spent }
|
||||
})
|
||||
const totals = rows.reduce(
|
||||
(a, r) => ({ planned: a.planned + r.planned, spent: a.spent + r.spent }),
|
||||
{
|
||||
planned: 0,
|
||||
spent: 0,
|
||||
},
|
||||
)
|
||||
return (
|
||||
<ReportSection
|
||||
id="r-deviation"
|
||||
title={s.deviationTitle}
|
||||
hint={s.deviationHint(period.name)}
|
||||
csv={{
|
||||
file: s.file.deviation,
|
||||
headers: [s.group, s.planned, s.spent, s.diff, `${s.used} (%)`],
|
||||
rows: [
|
||||
...rows.map((r) => [
|
||||
group(r.group).label,
|
||||
csvMoney(r.planned),
|
||||
csvMoney(r.spent),
|
||||
csvMoney(r.diff),
|
||||
csvPercent(r.used),
|
||||
]),
|
||||
...weeks.map(({ w, planned, spent, diff }) => [
|
||||
s.week(w.index, `${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`),
|
||||
csvMoney(planned),
|
||||
csvMoney(spent),
|
||||
csvMoney(diff),
|
||||
csvPercent(planned ? spent / planned : null),
|
||||
]),
|
||||
],
|
||||
}}
|
||||
>
|
||||
{budget.isError ? (
|
||||
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
||||
) : !budget.data ? (
|
||||
<PageLoading />
|
||||
) : (
|
||||
<div className="space-y-4 overflow-x-auto">
|
||||
<DevTable
|
||||
first={s.group}
|
||||
rows={[
|
||||
...rows.map((r) => {
|
||||
const g = group(r.group)
|
||||
return {
|
||||
key: r.group,
|
||||
label: (
|
||||
<span className={g.className.text}>
|
||||
{g.emoji} {g.label}
|
||||
</span>
|
||||
),
|
||||
...r,
|
||||
}
|
||||
}),
|
||||
]}
|
||||
total={{ ...totals, diff: totals.planned - totals.spent }}
|
||||
/>
|
||||
<h3 className="pt-2 text-sm font-medium">{s.weeksTitle}</h3>
|
||||
<DevTable
|
||||
first={s.period}
|
||||
rows={weeks.map(({ w, planned, spent, diff }) => ({
|
||||
key: String(w.index),
|
||||
label: s.week(
|
||||
w.index,
|
||||
`${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`,
|
||||
),
|
||||
planned,
|
||||
spent,
|
||||
diff,
|
||||
used: planned ? spent / planned : null,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
|
||||
function DevTable({
|
||||
first,
|
||||
rows,
|
||||
total,
|
||||
}: {
|
||||
first: string
|
||||
rows: {
|
||||
key: string
|
||||
label: ReactNode
|
||||
planned: number
|
||||
spent: number
|
||||
diff: number
|
||||
used: number | null
|
||||
}[]
|
||||
total?: { planned: number; spent: number; diff: number }
|
||||
}) {
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-2 font-medium">{first}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.planned}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.spent}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.diff}</th>
|
||||
<th className="py-2 pl-2 text-right font-medium">{s.used}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.key} className="border-b last:border-0">
|
||||
<td className="py-2 pr-2 whitespace-nowrap">{r.label}</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(r.planned)}
|
||||
</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">{formatMoney(r.spent)}</td>
|
||||
<td
|
||||
className={cn(
|
||||
'money px-2 py-2 text-right whitespace-nowrap',
|
||||
r.diff < 0 && 'text-expense',
|
||||
)}
|
||||
>
|
||||
{signed(r.diff)}
|
||||
</td>
|
||||
<td className={cn('money py-2 pl-2 text-right', (r.used ?? 0) > 1 && 'text-expense')}>
|
||||
{pct(r.used)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{total && (
|
||||
<tfoot>
|
||||
<tr className="border-t font-semibold">
|
||||
<td className="py-2 pr-2">{s.total}</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(total.planned)}
|
||||
</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(total.spent)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
'money px-2 py-2 text-right whitespace-nowrap',
|
||||
total.diff < 0 && 'text-expense',
|
||||
)}
|
||||
>
|
||||
{signed(total.diff)}
|
||||
</td>
|
||||
<td className="money py-2 pl-2 text-right">
|
||||
{pct(total.planned ? total.spent / total.planned : null)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Varlıklar ----------
|
||||
|
||||
export function AssetsReport({ p }: { p: Portfolio }) {
|
||||
const items = p.items.filter((i) => i.asset.quantity_e6 > 0 || i.asset.realized_pl !== 0)
|
||||
return (
|
||||
<ReportSection
|
||||
id="r-assets"
|
||||
title={s.assetsTitle}
|
||||
hint={s.assetsHint}
|
||||
note={s.unfilteredNote}
|
||||
csv={{
|
||||
file: s.file.assets,
|
||||
headers: [
|
||||
s.asset,
|
||||
s.quantity,
|
||||
s.cost,
|
||||
s.value,
|
||||
s.unrealized,
|
||||
`${s.unrealized} (%)`,
|
||||
s.realizedPl,
|
||||
],
|
||||
rows: items.map((i) => [
|
||||
i.asset.name,
|
||||
`${formatE6(i.asset.quantity_e6)} ${i.asset.unit_label}`,
|
||||
csvMoney(i.asset.cost),
|
||||
i.value === null ? '' : csvMoney(i.value),
|
||||
i.unrealized_pl === null ? '' : csvMoney(i.unrealized_pl),
|
||||
i.unrealized_pl === null ? '' : csvPercent(plRatio(i.unrealized_pl, i.asset.cost)),
|
||||
csvMoney(i.asset.realized_pl),
|
||||
]),
|
||||
}}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{s.noAssets}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-2 font-medium">{s.asset}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.cost}</th>
|
||||
<th className="px-2 py-2 text-right font-medium">{s.value}</th>
|
||||
<th className="py-2 pl-2 text-right font-medium">{s.unrealized}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((i) => {
|
||||
const pl = i.unrealized_pl
|
||||
const r = pl === null ? null : formatPlPercent(plRatio(pl, i.asset.cost))
|
||||
return (
|
||||
<tr key={i.asset.id} className="border-b last:border-0">
|
||||
<td className="py-2 pr-2">
|
||||
<div className="font-medium">{i.asset.name}</div>
|
||||
<div className="money text-xs text-muted-foreground">{qty(i.asset)}</div>
|
||||
</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(i.asset.cost)}
|
||||
</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{i.value === null ? '—' : formatMoney(i.value)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
'money py-2 pl-2 text-right whitespace-nowrap',
|
||||
(pl ?? 0) > 0 ? 'text-income' : (pl ?? 0) < 0 ? 'text-expense' : '',
|
||||
)}
|
||||
>
|
||||
{pl === null ? '—' : signed(pl)}
|
||||
{r && <div className="text-xs">{r}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-semibold">
|
||||
<td className="py-2 pr-2">{s.total}</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(p.cost_total)}
|
||||
</td>
|
||||
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||
{formatMoney(p.value_total)}
|
||||
</td>
|
||||
<td className="money py-2 pl-2 text-right whitespace-nowrap">
|
||||
{signed(p.unrealized_pl_total)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{p.realized_pl_total !== 0 && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{s.realizedPl}: <span className="money">{signed(p.realized_pl_total)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ReportSection>
|
||||
)
|
||||
}
|
||||
@@ -52,8 +52,10 @@ export function ClosePeriodPage() {
|
||||
// Açık dönem ya da `?donem=` ile düzeltmedeki dönem (yeniden kapatma)
|
||||
<SelectedPeriod empty={empty}>
|
||||
{(p) =>
|
||||
p.status === 'closed' ? (
|
||||
<p className="text-muted-foreground">{s.alreadyClosed}</p>
|
||||
p.status === 'closed' || p.status === 'archived' ? (
|
||||
<p className="text-muted-foreground">
|
||||
{p.status === 'closed' ? s.alreadyClosed : s.archived}
|
||||
</p>
|
||||
) : (
|
||||
<Wizard key={p.id} period={p} onClosed={setResult} />
|
||||
)
|
||||
|
||||
@@ -20,6 +20,14 @@ const s = t.review
|
||||
*/
|
||||
export function ClosingSection({ period, periods }: { period: Period; periods: Period[] }) {
|
||||
if (period.status === 'closed') return <ClosedInfo period={period} periods={periods} />
|
||||
if (period.status === 'archived') {
|
||||
return (
|
||||
<section className="space-y-1 rounded-2xl border bg-card p-5">
|
||||
<h2 className="font-semibold">{s.archivedTitle}</h2>
|
||||
<p className="text-sm text-muted-foreground">{s.archivedHint}</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
const correcting = period.status === 'correcting'
|
||||
return (
|
||||
<section
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { CircleCheck, Download, FileUp, Loader2, TriangleAlert } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError } from '@/components/form-field'
|
||||
import { Segmented } from '@/components/segmented'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Section } from '@/features/planning/plan-read'
|
||||
import { decodeText } from '@/lib/decode'
|
||||
import { t } from '@/lib/strings'
|
||||
import { exportUrl, TRANSFER_KINDS, useImport, type TransferKind } from '@/lib/transfer'
|
||||
import type { ImportReport } from '@/types/ImportReport'
|
||||
|
||||
const s = t.data
|
||||
const kindLabel: Record<TransferKind, string> = {
|
||||
entries: s.entries,
|
||||
'plan-items': s.planItems,
|
||||
'asset-transactions': s.assetTxs,
|
||||
}
|
||||
const MAX_ERRORS = 50
|
||||
|
||||
/** Ayarlar → Veri: CSV dışa aktarma ve (denemeli) içe aktarma */
|
||||
export function DataSection() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section title={s.exportTitle}>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{s.exportHint}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TRANSFER_KINDS.map((k) => (
|
||||
<Button key={k} asChild variant="outline">
|
||||
<a href={exportUrl(k)} download>
|
||||
<Download aria-hidden />
|
||||
{kindLabel[k]}
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<ImportCsv />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportCsv() {
|
||||
const [kind, setKind] = useState<TransferKind>('entries')
|
||||
const [file, setFile] = useState<{ name: string; csv: string; encoding: string } | null>(null)
|
||||
const [report, setReport] = useState<{ r: ImportReport; committed: boolean } | null>(null)
|
||||
const [error, setError] = useState<string>()
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
const run = useImport()
|
||||
|
||||
function reset() {
|
||||
setFile(null)
|
||||
setReport(null)
|
||||
setError(undefined)
|
||||
if (input.current) input.current.value = ''
|
||||
}
|
||||
|
||||
async function check(f: File, k: TransferKind) {
|
||||
setError(undefined)
|
||||
setReport(null)
|
||||
const { text, encoding } = decodeText(await f.arrayBuffer())
|
||||
setFile({ name: f.name, csv: text, encoding })
|
||||
try {
|
||||
setReport({
|
||||
r: await run.mutateAsync({ kind: k, csv: text, commit: false }),
|
||||
committed: false,
|
||||
})
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!file) return
|
||||
try {
|
||||
const r = await run.mutateAsync({ kind, csv: file.csv, commit: true })
|
||||
setReport({ r, committed: true })
|
||||
toast.success(s.done(r.imported))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const r = report?.r
|
||||
return (
|
||||
<Section title={s.importTitle}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{s.importHint}</p>
|
||||
<Segmented
|
||||
label={s.importTitle}
|
||||
value={kind}
|
||||
onChange={(k) => {
|
||||
setKind(k)
|
||||
reset()
|
||||
}}
|
||||
options={TRANSFER_KINDS.map((k) => ({ value: k, label: kindLabel[k] }))}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
{s.columns[kind]} <span className="text-muted-foreground">{s.sampleHint}</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".csv,text/csv,text/plain"
|
||||
className="sr-only"
|
||||
id="import-file"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) void check(f, kind)
|
||||
}}
|
||||
/>
|
||||
<Button asChild variant="outline" disabled={run.isPending}>
|
||||
<label htmlFor="import-file" className="cursor-pointer">
|
||||
<FileUp aria-hidden />
|
||||
{file ? s.another : s.chooseFile}
|
||||
</label>
|
||||
</Button>
|
||||
{file && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{s.file(file.name, file.encoding)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.isPending && (
|
||||
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
{report ? s.importing : s.checking}
|
||||
</p>
|
||||
)}
|
||||
<FormError message={error} />
|
||||
|
||||
{r && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>{s.summary(r.total)}</li>
|
||||
{(report.committed ? r.imported : r.valid) > 0 ? (
|
||||
<li className="flex items-center gap-1.5 font-medium text-income">
|
||||
<CircleCheck className="size-4" aria-hidden />
|
||||
{report.committed ? s.added(r.imported) : s.willAdd(r.valid)}
|
||||
</li>
|
||||
) : (
|
||||
<li className="text-muted-foreground">
|
||||
{report.committed ? s.added(0) : s.willAdd(0)}
|
||||
</li>
|
||||
)}
|
||||
{r.duplicates > 0 && (
|
||||
<li className="text-muted-foreground">{s.dupes(r.duplicates)}</li>
|
||||
)}
|
||||
{r.errors.length > 0 && (
|
||||
<li className="flex items-center gap-1.5 font-medium text-expense">
|
||||
<TriangleAlert className="size-4" aria-hidden />
|
||||
{s.errorsTitle(r.errors.length)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{r.errors.length > 0 && (
|
||||
<ul className="max-h-60 space-y-1 overflow-y-auto rounded-xl bg-expense/5 p-3 text-sm">
|
||||
{r.errors.slice(0, MAX_ERRORS).map((e) => (
|
||||
<li key={`${e.line}-${e.message}`}>
|
||||
<span className="font-medium">{s.line(e.line)}:</span> {e.message}
|
||||
</li>
|
||||
))}
|
||||
{r.errors.length > MAX_ERRORS && (
|
||||
<li className="text-muted-foreground">
|
||||
{s.moreErrors(r.errors.length - MAX_ERRORS)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!report.committed && r.preview.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-1 text-sm font-medium">{s.previewTitle}</h4>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{r.preview.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
{row.map((c, j) => (
|
||||
<td key={j} className="px-3 py-1.5 whitespace-nowrap">
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!report.committed &&
|
||||
(r.valid > 0 ? (
|
||||
<Button onClick={() => void commit()} disabled={run.isPending}>
|
||||
{run.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{s.commit(r.valid)}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{s.nothing}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
import { DataSection } from './data-section'
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
|
||||
<ComingSoon when="ilerleyen fazlarda" />
|
||||
<div className="space-y-6">
|
||||
<section aria-labelledby="data-title" className="space-y-3">
|
||||
<h2 id="data-title" className="text-lg font-semibold">
|
||||
{t.data.title}
|
||||
</h2>
|
||||
<DataSection />
|
||||
</section>
|
||||
<ComingSoon when="ilerleyen fazlarda (tema, kategoriler, hane)" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -265,3 +265,18 @@
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* Yazdırma (lib/print.ts): tek bölüm seçildiyse diğer rapor bölümleri gizlenir;
|
||||
kartlar sayfa ortasında bölünmesin; arka plan renkleri (çubuklar) basılsın. */
|
||||
@media print {
|
||||
html[data-print-one] main [data-report]:not([data-print-target]) {
|
||||
display: none;
|
||||
}
|
||||
main [data-report] {
|
||||
break-inside: avoid;
|
||||
}
|
||||
* {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ describe('api', () => {
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
})
|
||||
|
||||
it('CSV metnini olduğu gibi gönderir', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json({ total: 1 }))
|
||||
globalThis.fetch = fetchMock
|
||||
await api.postCsv('/import/entries', 'Tarih;Tutar\r\n')
|
||||
const [, init] = fetchMock.mock.calls[0]
|
||||
expect(init.body).toBe('Tarih;Tutar\r\n')
|
||||
expect(init.headers['Content-Type']).toBe('text/csv; charset=utf-8')
|
||||
})
|
||||
|
||||
it('204 → undefined', async () => {
|
||||
mockFetch(204)
|
||||
await expect(api.post('/auth/logout')).resolves.toBeUndefined()
|
||||
@@ -50,10 +59,19 @@ describe('api', () => {
|
||||
expect(err.isUnauthorized).toBe(true)
|
||||
})
|
||||
|
||||
it('JSON olmayan hata (ör. proxy 502) → genel Türkçe mesaj', async () => {
|
||||
it('backend kapalı (vekil 502/503/504) → "sunucu yanıt vermiyor"', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('Bad Gateway', { status: 502 }))
|
||||
await expect(api.get('/health')).rejects.toMatchObject({
|
||||
status: 502,
|
||||
code: 'unavailable',
|
||||
message: 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra tekrar deneyin.',
|
||||
})
|
||||
})
|
||||
|
||||
it('JSON olmayan diğer hata → genel Türkçe mesaj', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('oops', { status: 500 }))
|
||||
await expect(api.get('/health')).rejects.toMatchObject({
|
||||
status: 500,
|
||||
message: 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.',
|
||||
})
|
||||
})
|
||||
|
||||
+22
-2
@@ -26,17 +26,31 @@ export class ApiError extends Error {
|
||||
|
||||
const NETWORK_MESSAGE = 'Sunucuya ulaşılamadı. İnternet bağlantınızı kontrol edip tekrar deneyin.'
|
||||
const UNKNOWN_MESSAGE = 'Beklenmeyen bir hata oluştu, lütfen tekrar deneyin.'
|
||||
/** Vekil (Vite / Traefik) backend'e ulaşamadı: yeniden başlıyor, güncelleniyor ya da kapalı */
|
||||
const UNAVAILABLE_MESSAGE = 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra tekrar deneyin.'
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
/** Ham metin gövde (CSV içe aktarma); diğerleri JSON */
|
||||
class TextBody {
|
||||
readonly text: string
|
||||
readonly type: string
|
||||
constructor(text: string, type: string) {
|
||||
this.text = text
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
|
||||
let res: Response
|
||||
const raw = body instanceof TextBody ? body : null
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers:
|
||||
body === undefined ? undefined : { 'Content-Type': raw ? raw.type : 'application/json' },
|
||||
body: body === undefined ? undefined : raw ? raw.text : JSON.stringify(body),
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'network', NETWORK_MESSAGE)
|
||||
@@ -48,6 +62,9 @@ async function request<T>(method: Method, path: string, body?: unknown): Promise
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = (data as Partial<ApiErrorBody> | null)?.error
|
||||
if (!detail && [502, 503, 504].includes(res.status)) {
|
||||
throw new ApiError(res.status, 'unavailable', UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
throw new ApiError(res.status, detail?.code ?? 'unknown', detail?.message ?? UNKNOWN_MESSAGE)
|
||||
}
|
||||
return data as T
|
||||
@@ -59,4 +76,7 @@ export const api = {
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
|
||||
delete: <T = void>(path: string) => request<T>('DELETE', path),
|
||||
/** CSV metnini gövde olarak gönderir */
|
||||
postCsv: <T>(path: string, csv: string) =>
|
||||
request<T>('POST', path, new TextBody(csv, 'text/csv; charset=utf-8')),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { csvMoney, csvPercent, toCsv } from './csv'
|
||||
|
||||
describe('csv', () => {
|
||||
it('Excel-friendly: BOM, semicolon, CRLF, quoting', () => {
|
||||
const csv = toCsv(
|
||||
['Kategori', 'Tutar'],
|
||||
[
|
||||
['Market', '1250,50'],
|
||||
['Dışarıda yemek; kafe', '10,00'],
|
||||
['"Özel" ad', null],
|
||||
],
|
||||
)
|
||||
expect(csv.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(csv.slice(1)).toBe(
|
||||
'Kategori;Tutar\r\nMarket;1250,50\r\n"Dışarıda yemek; kafe";10,00\r\n"""Özel"" ad";\r\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('money and percent without thousands separators', () => {
|
||||
expect(csvMoney(125_050)).toBe('1250,50')
|
||||
expect(csvMoney(100_000_000)).toBe('1000000,00')
|
||||
expect(csvMoney(-5)).toBe('-0,05')
|
||||
expect(csvMoney(0)).toBe('0,00')
|
||||
expect(csvPercent(0.125)).toBe('12,5')
|
||||
expect(csvPercent(-0.0058)).toBe('-0,6')
|
||||
expect(csvPercent(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* CSV dışa aktarma — Türkçe Excel uyumlu (KAKEIBO_PROMPT §8.9):
|
||||
* UTF-8 **BOM** (Türkçe karakterler), `;` ayırıcı, satır sonu CRLF.
|
||||
* Tutarlar Excel'in sayı sanması için binlik ayırıcısız ve ondalık virgüllü (`1250,50`).
|
||||
*/
|
||||
|
||||
export type Cell = string | number | null | undefined
|
||||
|
||||
const BOM = ''
|
||||
|
||||
function quote(v: Cell): string {
|
||||
if (v === null || v === undefined) return ''
|
||||
const s = String(v)
|
||||
return /[;"\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
||||
}
|
||||
|
||||
export function toCsv(headers: string[], rows: Cell[][]): string {
|
||||
return BOM + [headers, ...rows].map((r) => r.map(quote).join(';')).join('\r\n') + '\r\n'
|
||||
}
|
||||
|
||||
/** Kuruş → `1250,50` (CSV için; binlik ayırıcı yok) */
|
||||
export function csvMoney(kurus: number): string {
|
||||
const neg = kurus < 0
|
||||
const abs = Math.abs(kurus)
|
||||
return `${neg ? '-' : ''}${Math.floor(abs / 100)},${String(abs % 100).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Oran → `12,5` (yüzde, 1 ondalık; CSV için) */
|
||||
export function csvPercent(ratio: number | null): string {
|
||||
return ratio === null ? '' : (Math.round(ratio * 1000) / 10).toString().replace('.', ',')
|
||||
}
|
||||
|
||||
/** Tarayıcıda dosya indirir */
|
||||
export function downloadCsv(filename: string, csv: string) {
|
||||
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`
|
||||
document.body.append(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeText } from './decode'
|
||||
|
||||
describe('decodeText', () => {
|
||||
it('UTF-8 (BOM is stripped by the decoder)', () => {
|
||||
const buf = new TextEncoder().encode('Açıklama;Şeker').buffer
|
||||
expect(decodeText(buf)).toEqual({ text: 'Açıklama;Şeker', encoding: 'utf-8' })
|
||||
})
|
||||
|
||||
it('Windows-1254 from Turkish Excel', () => {
|
||||
// "Şeker;Ödeme" Windows-1254 baytları: Ş = 0xDE, Ö = 0xD6
|
||||
const buf = new Uint8Array([0xde, 0x65, 0x6b, 0x65, 0x72, 0x3b, 0xd6, 0x64, 0x65, 0x6d, 0x65])
|
||||
.buffer
|
||||
expect(decodeText(buf)).toEqual({ text: 'Şeker;Ödeme', encoding: 'windows-1254' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Yüklenen metin dosyasını çözer: önce UTF-8 (BOM'lu ya da değil); geçersizse Excel'in
|
||||
* Türkçe Windows'ta varsayılan kaydettiği **Windows-1254**. (Türkçe karakterler bozulmasın.)
|
||||
*/
|
||||
export function decodeText(buf: ArrayBuffer): { text: string; encoding: 'utf-8' | 'windows-1254' } {
|
||||
try {
|
||||
return { text: new TextDecoder('utf-8', { fatal: true }).decode(buf), encoding: 'utf-8' }
|
||||
} catch {
|
||||
return { text: new TextDecoder('windows-1254').decode(buf), encoding: 'windows-1254' }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ function refresh(qc: QueryClient, periodId: string) {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.stats }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.reports }),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Yazdırma: her zaman açık tema (koyu temada açık renk yazı beyaz kâğıtta okunmaz) ve
|
||||
* istenirse yalnızca tek bir rapor bölümü. Tarayıcının kendi Ctrl+P'si de aynı kuralı izler.
|
||||
*/
|
||||
|
||||
let installed = false
|
||||
|
||||
/** Uygulama açılışında bir kez: yazdırmadan önce açık tema, sonra eski haline */
|
||||
export function installPrintTheme() {
|
||||
if (installed || typeof window === 'undefined') return
|
||||
installed = true
|
||||
const html = document.documentElement
|
||||
let wasDark = false
|
||||
window.addEventListener('beforeprint', () => {
|
||||
wasDark = html.classList.contains('dark')
|
||||
html.classList.remove('dark')
|
||||
})
|
||||
window.addEventListener('afterprint', () => {
|
||||
if (wasDark) html.classList.add('dark')
|
||||
html.removeAttribute('data-print-one')
|
||||
document.querySelector('[data-print-target]')?.removeAttribute('data-print-target')
|
||||
})
|
||||
}
|
||||
|
||||
/** Yalnızca `el`'i yazdır (diğer rapor bölümleri gizlenir); `el` yoksa tüm sayfa */
|
||||
export function printSection(el?: HTMLElement | null) {
|
||||
if (el) {
|
||||
document.documentElement.setAttribute('data-print-one', '')
|
||||
el.setAttribute('data-print-target', '')
|
||||
}
|
||||
window.print()
|
||||
}
|
||||
@@ -22,6 +22,9 @@ export const queryKeys = {
|
||||
portfolio: ['portfolio'] as const,
|
||||
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
|
||||
pendingSavings: ['portfolio', 'pending'] as const,
|
||||
/** Rapor kırılımı (`query` = sunucu sorgu metni); kayıt değişince `reports` öneki yenilenir */
|
||||
reportBreakdown: (query: string) => ['reports', 'breakdown', query] as const,
|
||||
reports: ['reports'] as const,
|
||||
period: (id: string) => ['period', id] as const,
|
||||
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
||||
summary: (id: string) => ['period', id, 'summary'] as const,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
import {
|
||||
activeFilterCount,
|
||||
describeFilter,
|
||||
deviation,
|
||||
filterToParams,
|
||||
filterToQuery,
|
||||
formatMonth,
|
||||
parseFilter,
|
||||
savingsRate,
|
||||
} from './reports'
|
||||
|
||||
describe('report filter', () => {
|
||||
it('parses Turkish params; period wins over dates', () => {
|
||||
const f = parseFilter(new URLSearchParams('donem=p1&baslangic=2026-01-01&grup=needs&odeme=yok'))
|
||||
expect(f).toEqual({ period: 'p1', group: 'needs', method: 'none' })
|
||||
expect(filterToQuery(f)).toBe('?period_id=p1&group=needs&payment_method_id=none')
|
||||
expect(filterToParams(f)).toEqual({ donem: 'p1', grup: 'needs', odeme: 'yok' })
|
||||
expect(activeFilterCount(f)).toBe(3)
|
||||
})
|
||||
|
||||
it('date range; invalid values ignored', () => {
|
||||
const f = parseFilter(
|
||||
new URLSearchParams('baslangic=2026-01-01&bitis=2026-02-30&grup=xyz&kategori=c1'),
|
||||
)
|
||||
expect(f).toEqual({ from: '2026-01-01', category: 'c1' })
|
||||
expect(filterToQuery(f)).toBe('?from=2026-01-01&category_id=c1')
|
||||
expect(filterToQuery({})).toBe('')
|
||||
expect(activeFilterCount({})).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter description (printed reports)', () => {
|
||||
const names = {
|
||||
period: (id: string) => (id === 'p1' ? 'Kasım 2026' : undefined),
|
||||
category: () => 'Market',
|
||||
method: () => 'Nakit',
|
||||
}
|
||||
it('describes scope and filters', () => {
|
||||
expect(describeFilter({}, names)).toBe('Kapsam: Tüm zamanlar')
|
||||
expect(describeFilter({ period: 'p1', group: 'needs', method: 'none' }, names)).toBe(
|
||||
'Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş',
|
||||
)
|
||||
expect(describeFilter({ from: '2026-01-01', to: '2026-11-13', category: 'c' }, names)).toBe(
|
||||
'Kapsam: 01.01.2026 – 13.11.2026 · Kategori: Market',
|
||||
)
|
||||
expect(describeFilter({ from: '2026-01-01' }, names)).toBe('Kapsam: 01.01.2026 – …')
|
||||
})
|
||||
})
|
||||
|
||||
describe('report calculations', () => {
|
||||
it('savings rate', () => {
|
||||
expect(savingsRate({ realized_savings: 250, income_total: 1_000 })).toBe(0.25)
|
||||
expect(savingsRate({ realized_savings: -100, income_total: 1_000 })).toBe(-0.1)
|
||||
expect(savingsRate({ realized_savings: 0, income_total: 0 })).toBeNull()
|
||||
expect(formatMonth('2026-11')).toBe('Kasım 2026')
|
||||
expect(formatMonth('2027-01')).toBe('Ocak 2027')
|
||||
})
|
||||
|
||||
it('budget deviation monthly and weekly', () => {
|
||||
const wk = (amount: number, spent: number) => ({
|
||||
index: 1,
|
||||
start_date: '',
|
||||
last_date: '',
|
||||
days: 7,
|
||||
amount,
|
||||
is_manual: false,
|
||||
spent,
|
||||
})
|
||||
const budget = [
|
||||
{
|
||||
group: 'needs',
|
||||
monthly: 1_000,
|
||||
spent: 1_200,
|
||||
weekly_over: false,
|
||||
weeks: [wk(300, 100), wk(700, 1_100)],
|
||||
},
|
||||
{ group: 'wants', monthly: 0, spent: 50, weekly_over: false, weeks: [wk(0, 50), wk(0, 0)] },
|
||||
] as GroupBudget[]
|
||||
expect(deviation(budget)).toEqual([
|
||||
{ group: 'needs', planned: 1_000, spent: 1_200, diff: -200, used: 1.2 },
|
||||
{ group: 'wants', planned: 0, spent: 50, diff: -50, used: null },
|
||||
])
|
||||
expect(deviation(budget, 2)[0]).toEqual({
|
||||
group: 'needs',
|
||||
planned: 700,
|
||||
spent: 1_100,
|
||||
diff: -400,
|
||||
used: 1_100 / 700,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Raporlar: filtre (adreste), sunucu kancası ve saf hesaplar.
|
||||
*
|
||||
* Adres parametreleri: `donem`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`
|
||||
* (`odeme=yok` → ödeme türü girilmemiş). Dönem seçiliyse tarih aralığı yok sayılır.
|
||||
*/
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
import type { PeriodStat } from '@/types/PeriodStat'
|
||||
import type { ReportBreakdown } from '@/types/ReportBreakdown'
|
||||
import { api } from './api'
|
||||
import { formatDate } from './format'
|
||||
import { group as groupMeta, GROUPS, type GroupCode } from './groups'
|
||||
import { isIsoDate } from './period-dates'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
export type ReportFilter = {
|
||||
period?: string
|
||||
from?: string
|
||||
to?: string
|
||||
group?: GroupCode
|
||||
category?: string
|
||||
/** `'none'` → girilmemiş */
|
||||
method?: string
|
||||
}
|
||||
|
||||
const P = {
|
||||
period: 'donem',
|
||||
from: 'baslangic',
|
||||
to: 'bitis',
|
||||
group: 'grup',
|
||||
category: 'kategori',
|
||||
method: 'odeme',
|
||||
} as const
|
||||
|
||||
export function parseFilter(params: URLSearchParams): ReportFilter {
|
||||
const get = (k: string) => params.get(k)?.trim() || undefined
|
||||
const f: ReportFilter = {}
|
||||
const period = get(P.period)
|
||||
if (period) f.period = period
|
||||
else {
|
||||
const from = get(P.from)
|
||||
const to = get(P.to)
|
||||
if (from && isIsoDate(from)) f.from = from
|
||||
if (to && isIsoDate(to)) f.to = to
|
||||
}
|
||||
const group = get(P.group)
|
||||
if (group && GROUPS.some((g) => g.code === group)) f.group = group as GroupCode
|
||||
const category = get(P.category)
|
||||
if (category) f.category = category
|
||||
const method = get(P.method)
|
||||
if (method) f.method = method === 'yok' ? 'none' : method
|
||||
return f
|
||||
}
|
||||
|
||||
export function filterToParams(f: ReportFilter): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
if (f.period) out[P.period] = f.period
|
||||
else {
|
||||
if (f.from) out[P.from] = f.from
|
||||
if (f.to) out[P.to] = f.to
|
||||
}
|
||||
if (f.group) out[P.group] = f.group
|
||||
if (f.category) out[P.category] = f.category
|
||||
if (f.method) out[P.method] = f.method === 'none' ? 'yok' : f.method
|
||||
return out
|
||||
}
|
||||
|
||||
/** Sunucu sorgusu (`/reports/breakdown?...`) */
|
||||
export function filterToQuery(f: ReportFilter): string {
|
||||
const q = new URLSearchParams()
|
||||
if (f.period) q.set('period_id', f.period)
|
||||
else {
|
||||
if (f.from) q.set('from', f.from)
|
||||
if (f.to) q.set('to', f.to)
|
||||
}
|
||||
if (f.group) q.set('group', f.group)
|
||||
if (f.category) q.set('category_id', f.category)
|
||||
if (f.method) q.set('payment_method_id', f.method)
|
||||
const s = q.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtrenin okunur özeti (yazdırılan raporun başında: kağıtta hangi kapsamın basıldığı belli olsun).
|
||||
* Örn. `Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş`
|
||||
*/
|
||||
export function describeFilter(
|
||||
f: ReportFilter,
|
||||
names: {
|
||||
period: (id: string) => string | undefined
|
||||
category: (id: string) => string | undefined
|
||||
method: (id: string) => string | undefined
|
||||
},
|
||||
): string {
|
||||
const scope = f.period
|
||||
? (names.period(f.period) ?? '—')
|
||||
: f.from || f.to
|
||||
? `${f.from ? formatDate(f.from) : '…'} – ${f.to ? formatDate(f.to) : '…'}`
|
||||
: 'Tüm zamanlar'
|
||||
const parts = [`Kapsam: ${scope}`]
|
||||
if (f.group) parts.push(`Grup: ${groupMeta(f.group).label}`)
|
||||
if (f.category) parts.push(`Kategori: ${names.category(f.category) ?? '—'}`)
|
||||
if (f.method)
|
||||
parts.push(
|
||||
`Ödeme türü: ${f.method === 'none' ? 'Girilmemiş' : (names.method(f.method) ?? '—')}`,
|
||||
)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export function activeFilterCount(f: ReportFilter): number {
|
||||
return [f.period || f.from || f.to, f.group, f.category, f.method].filter(Boolean).length
|
||||
}
|
||||
|
||||
export function useReportBreakdown(f: ReportFilter) {
|
||||
const query = filterToQuery(f)
|
||||
return useQuery({
|
||||
queryKey: queryKeys.reportBreakdown(query),
|
||||
queryFn: () => api.get<ReportBreakdown>(`/reports/breakdown${query}`),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
/** Birikim oranı: gerçekleşen birikim ÷ gelir (gelir yoksa `null`) */
|
||||
export function savingsRate(
|
||||
p: Pick<PeriodStat, 'realized_savings' | 'income_total'>,
|
||||
): number | null {
|
||||
return p.income_total > 0 ? p.realized_savings / p.income_total : null
|
||||
}
|
||||
|
||||
const monthName = new Intl.DateTimeFormat('tr-TR', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
/** `"2026-11"` → `Kasım 2026` */
|
||||
export const formatMonth = (ym: string) => monthName.format(new Date(`${ym}-01T00:00:00Z`))
|
||||
|
||||
/** Pay (0–1); toplam 0 ise 0 */
|
||||
export const share = (part: number, total: number) => (total > 0 ? part / total : 0)
|
||||
|
||||
export type DeviationRow = {
|
||||
group: GroupCode
|
||||
planned: number
|
||||
spent: number
|
||||
/** planned − spent (eksi → aşım) */
|
||||
diff: number
|
||||
/** spent ÷ planned (plan yoksa `null`) */
|
||||
used: number | null
|
||||
}
|
||||
|
||||
/** Bütçe sapması: grup başına aylık (ya da `week` verilirse o hafta) planlanan vs gerçekleşen */
|
||||
export function deviation(budget: GroupBudget[], week?: number): DeviationRow[] {
|
||||
return budget.map((b) => {
|
||||
const w = week ? b.weeks[week - 1] : undefined
|
||||
const planned = week ? (w?.amount ?? 0) : b.monthly
|
||||
const spent = week ? (w?.spent ?? 0) : b.spent
|
||||
return {
|
||||
group: b.group,
|
||||
planned,
|
||||
spent,
|
||||
diff: planned - spent,
|
||||
used: planned > 0 ? spent / planned : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
+125
-1
@@ -348,6 +348,9 @@ export const t = {
|
||||
correctingHint:
|
||||
'Kayıtları ve planı düzelt, bitince yeniden kapat; sonraki döneme devir güncellenir.',
|
||||
recloseAction: '🔒 Yeniden Kapat',
|
||||
archivedTitle: 'Arşiv dönemi',
|
||||
archivedHint:
|
||||
'Ekstreden aktarılan geçmiş bir ay. Kayıtları düzeltebilirsin; kapatılmaz, devir üretmez ve bütçe önerilerinde (önceki dönem, son 3 ay) kullanılır.',
|
||||
fixInLedger: 'Defter’de düzelt',
|
||||
fixInPlan: 'Plan’da düzelt',
|
||||
},
|
||||
@@ -494,9 +497,129 @@ export const t = {
|
||||
added: (name: string) => `${name} birikimi eklendi`,
|
||||
},
|
||||
|
||||
reports: {
|
||||
printAll: 'Tümünü yazdır',
|
||||
print: 'Yazdır',
|
||||
csv: 'CSV indir',
|
||||
filters: 'Filtreler',
|
||||
filterCount: (n: number) => (n ? `Filtreler (${n})` : 'Filtreler'),
|
||||
scope: 'Kapsam',
|
||||
allTime: 'Tüm zamanlar',
|
||||
byPeriod: 'Dönem',
|
||||
byRange: 'Tarih aralığı',
|
||||
from: 'Başlangıç',
|
||||
to: 'Bitiş',
|
||||
group: 'Grup',
|
||||
category: 'Kategori',
|
||||
method: 'Ödeme türü',
|
||||
any: 'Tümü',
|
||||
noMethod: 'Girilmemiş',
|
||||
clear: 'Filtreleri temizle',
|
||||
filteredNote: 'Filtrelere göre',
|
||||
unfilteredNote: 'Filtrelerden bağımsız',
|
||||
empty: 'Bu filtrelerle harcama yok.',
|
||||
total: 'Toplam',
|
||||
count: 'Kayıt',
|
||||
share: 'Pay',
|
||||
amount: 'Tutar',
|
||||
// Gelir–gider
|
||||
periodsTitle: 'Gelir ve gider (dönemler)',
|
||||
periodsHint: 'Son 12 dönem: gelir ile sabit giderler + harcamaların karşılaştırması.',
|
||||
period: 'Dönem',
|
||||
income: 'Gelir',
|
||||
fixed: 'Sabit gider',
|
||||
spent: 'Harcanan',
|
||||
expense: 'Gider',
|
||||
realized: 'Gerçekleşen birikim',
|
||||
rate: 'Birikim oranı',
|
||||
rateTitle: 'Birikim oranı trendi',
|
||||
rateHint: 'Gelirin yüzde kaçı birikti (gerçekleşen birikim ÷ gelir).',
|
||||
rateSummary: (name: string, rate: string) => `Son dönem (${name}) birikim oranı: ${rate}.`,
|
||||
periodsSummary: (n: number) => `${n} dönemin gelir ve gider karşılaştırması aşağıdaki tabloda.`,
|
||||
noPeriods: 'Henüz dönem yok.',
|
||||
// Kırılımlar
|
||||
groupsTitle: 'Gruplara göre harcama',
|
||||
categoriesTitle: 'Kategorilere göre harcama',
|
||||
categoriesHint: 'Harcama olan tüm kategoriler, çoktan aza.',
|
||||
methodsTitle: 'Ödeme türüne göre harcama',
|
||||
monthsTitle: 'Aylara göre harcama',
|
||||
// Bütçe sapması
|
||||
deviationTitle: 'Bütçe sapması',
|
||||
deviationHint: (name: string) => `${name}: grup bütçeleri ile gerçekleşen harcama.`,
|
||||
planned: 'Planlanan',
|
||||
diff: 'Fark',
|
||||
used: 'Kullanım',
|
||||
over: 'aşıldı',
|
||||
weeksTitle: 'Haftalara göre (tüm gruplar)',
|
||||
week: (n: number, range: string) => `${n}. hafta (${range})`,
|
||||
noDeviationPeriod: 'Bütçe sapması için bir dönem gerekiyor.',
|
||||
// Varlıklar
|
||||
assetsTitle: 'Birikim / varlık performansı',
|
||||
assetsHint: 'Eldeki varlıkların güncel değeri ve kâr/zarar.',
|
||||
asset: 'Varlık',
|
||||
quantity: 'Miktar',
|
||||
cost: 'Maliyet',
|
||||
value: 'Güncel değer',
|
||||
unrealized: 'Kâr / zarar',
|
||||
realizedPl: 'Gerçekleşen kâr / zarar',
|
||||
noAssets: 'Henüz birikim kaydı yok.',
|
||||
file: {
|
||||
periods: 'gelir-gider',
|
||||
groups: 'gruplar',
|
||||
categories: 'kategoriler',
|
||||
methods: 'odeme-turleri',
|
||||
months: 'aylar',
|
||||
deviation: 'butce-sapmasi',
|
||||
assets: 'varliklar',
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
title: 'Veri',
|
||||
exportTitle: 'Dışa aktar (CSV)',
|
||||
exportHint: 'Excel ve LibreOffice’te doğrudan açılır (Türkçe karakterler ve tutarlar doğru).',
|
||||
entries: 'Harcamalar',
|
||||
planItems: 'Plan kalemleri',
|
||||
assetTxs: 'Varlık işlemleri',
|
||||
importTitle: 'İçe aktar (CSV)',
|
||||
importHint:
|
||||
'Önce dosya denenir, hiçbir şey yazılmaz: kaç satırın ekleneceği ve hatalı satırlar gösterilir. Onaylarsan yalnızca geçerli satırlar eklenir. Aynı dosyayı tekrar yüklemek çift kayıt oluşturmaz.',
|
||||
columns: {
|
||||
entries:
|
||||
'Sütunlar: Tarih, Tutar, Kategori (zorunlu) · Grup, Ödeme türü, Açıklama (isteğe bağlı). Tarihin açık ya da düzeltmedeki bir döneme düşmesi gerekir.',
|
||||
'plan-items':
|
||||
'Sütunlar: Dönem başlangıcı, Tür (Gelir / Sabit gider), Ad, Tutar (zorunlu) · Tarih (isteğe bağlı).',
|
||||
'asset-transactions':
|
||||
'Sütunlar: Tarih, Varlık kodu (ya da Varlık), İşlem (Alım / Satım), Tutar (zorunlu) · Miktar (TL dışında zorunlu), Birim fiyat, Not.',
|
||||
},
|
||||
sampleHint: 'Biçim için önce aynı türü dışa aktarıp bakabilirsin.',
|
||||
chooseFile: 'Dosya seç',
|
||||
checking: 'Dosya denetleniyor…',
|
||||
file: (name: string, enc: string) =>
|
||||
`${name} · ${enc === 'utf-8' ? 'UTF-8' : 'Windows-1254 (Excel Türkçe)'}`,
|
||||
summary: (total: number) => `${total} satır okundu`,
|
||||
willAdd: (n: number) => `${n} satır eklenecek`,
|
||||
added: (n: number) => `${n} satır eklendi`,
|
||||
dupes: (n: number) => `${n} satır zaten var, atlanacak`,
|
||||
errorsTitle: (n: number) => `${n} satır hatalı (eklenmeyecek)`,
|
||||
line: (n: number) => `Satır ${n}`,
|
||||
moreErrors: (n: number) => `… ve ${n} hata daha`,
|
||||
previewTitle: 'Eklenecek satırlardan örnek',
|
||||
commit: (n: number) => `${n} satırı içe aktar`,
|
||||
importing: 'Aktarılıyor…',
|
||||
done: (n: number) => `${n} satır içe aktarıldı`,
|
||||
another: 'Başka dosya seç',
|
||||
nothing: 'Eklenecek yeni satır yok.',
|
||||
},
|
||||
|
||||
periods: {
|
||||
picker: 'Dönem',
|
||||
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
|
||||
status: {
|
||||
open: 'Açık',
|
||||
closed: '🔒 Kapalı',
|
||||
correcting: '✏️ Düzeltmede',
|
||||
archived: '🗄️ Arşiv',
|
||||
},
|
||||
notFound: 'Dönem bulunamadı. Listeden başka bir dönem seç.',
|
||||
history: 'Geçmiş dönemler',
|
||||
toSavings: (x: string) => `Birikime ${x}`,
|
||||
@@ -540,6 +663,7 @@ export const t = {
|
||||
toDashboard: 'Panele dön',
|
||||
noOpen: 'Kapatılacak açık dönem yok.',
|
||||
alreadyClosed: 'Bu dönem zaten kapalı.',
|
||||
archived: 'Arşiv dönemi kapatılmaz; yalnızca geçmiş kayıtları tutar.',
|
||||
addToSavings: (x: string) => `🐷 Birikime ekle (${x})`,
|
||||
addToSavingsHint:
|
||||
'Onaylarsan bu tutar Birikimler’de, dönemin son günü tarihli Türk Lirası alımı olarak görünür.',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* CSV dışa / içe aktarma. İçe aktarma iki adım: deneme (`commit: false`) → onay (`true`).
|
||||
* Aktarımdan sonra dönem, rapor ve birikim verileri yenilenir.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { ImportReport } from '@/types/ImportReport'
|
||||
import { api } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
export const TRANSFER_KINDS = ['entries', 'plan-items', 'asset-transactions'] as const
|
||||
export type TransferKind = (typeof TRANSFER_KINDS)[number]
|
||||
|
||||
/** İndirme adresi (çerezle; tarayıcı dosya olarak kaydeder) */
|
||||
export const exportUrl = (kind: TransferKind) => `/api/export/${kind}.csv`
|
||||
|
||||
export function useImport() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ kind, csv, commit }: { kind: TransferKind; csv: string; commit: boolean }) =>
|
||||
api.postCsv<ImportReport>(`/import/${kind}?commit=${commit}`, csv),
|
||||
onSuccess: (_, v) => {
|
||||
if (!v.commit) return
|
||||
void Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.periods }),
|
||||
qc.invalidateQueries({ queryKey: ['period'] }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.portfolio }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.reports }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import { RouterProvider } from 'react-router'
|
||||
import './index.css'
|
||||
import { queryClient } from '@/lib/query'
|
||||
import { Toaster } from '@/components/toaster'
|
||||
import { installPrintTheme } from '@/lib/print'
|
||||
import { ThemeProvider } from '@/lib/theme'
|
||||
import { router } from './router'
|
||||
|
||||
installPrintTheme()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type BankArchive = { name: string, start_date: string,
|
||||
/**
|
||||
* Son gün, dahil
|
||||
*/
|
||||
last_date: string, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BankRowIn } from "./BankRowIn";
|
||||
|
||||
export type BankCommitRequest = {
|
||||
/**
|
||||
* Kayıtlara yazılacak ödeme türü (ör. Kredi Kartı)
|
||||
*/
|
||||
payment_method_id?: string, rows: Array<BankRowIn>, };
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BankRowError } from "./BankRowError";
|
||||
|
||||
export type BankImportReport = { entries: number, refunds: number, incomes: number, ignored: number, duplicates: number,
|
||||
/**
|
||||
* Açılan arşiv dönemleri
|
||||
*/
|
||||
archives: Array<string>, errors: Array<BankRowError>, };
|
||||
@@ -0,0 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BankArchive } from "./BankArchive";
|
||||
import type { BankPreviewRow } from "./BankPreviewRow";
|
||||
|
||||
export type BankPreview = { rows: Array<BankPreviewRow>,
|
||||
/**
|
||||
* Açılacak arşiv dönemleri
|
||||
*/
|
||||
archives: Array<BankArchive>, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BankRowIn } from "./BankRowIn";
|
||||
|
||||
export type BankPreviewRequest = { source: "card" | "account", rows: Array<BankRowIn>, };
|
||||
@@ -0,0 +1,23 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type BankPreviewRow = { kind: "expense" | "refund" | "income" | "ignore", category_id: string | null,
|
||||
/**
|
||||
* Öneri hanenin öğrendiği kuraldan
|
||||
*/
|
||||
from_rule: boolean,
|
||||
/**
|
||||
* Daha önce aktarılmış (atlanacak)
|
||||
*/
|
||||
duplicate: boolean,
|
||||
/**
|
||||
* `existing` · `archive` · `blocked`
|
||||
*/
|
||||
period: "existing" | "archive" | "blocked",
|
||||
/**
|
||||
* Dönem adı (var olan ya da açılacak arşiv)
|
||||
*/
|
||||
period_name: string | null,
|
||||
/**
|
||||
* Engel nedeni
|
||||
*/
|
||||
message: string | null, };
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type BankRowError = {
|
||||
/**
|
||||
* Satır sırası (0'dan)
|
||||
*/
|
||||
index: number, message: string, };
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type BankRowIn = {
|
||||
/**
|
||||
* `"YYYY-MM-DD"`
|
||||
*/
|
||||
date: string, description: string,
|
||||
/**
|
||||
* Kuruş, > 0
|
||||
*/
|
||||
amount: number, direction: "in" | "out",
|
||||
/**
|
||||
* Yalnızca aktarmada (kullanıcının seçimi)
|
||||
*/
|
||||
kind?: "expense" | "refund" | "income" | "ignore", category_id?: string, };
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ImportError = {
|
||||
/**
|
||||
* Dosyadaki satır numarası (başlık 1)
|
||||
*/
|
||||
line: number, message: string, };
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ImportError } from "./ImportError";
|
||||
|
||||
export type ImportReport = {
|
||||
/**
|
||||
* Başlık hariç dolu satır
|
||||
*/
|
||||
total: number,
|
||||
/**
|
||||
* Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
|
||||
*/
|
||||
valid: number,
|
||||
/**
|
||||
* Veritabanında zaten olduğu için atlanan
|
||||
*/
|
||||
duplicates: number,
|
||||
/**
|
||||
* Yazılan (denemede 0)
|
||||
*/
|
||||
imported: number, errors: Array<ImportError>,
|
||||
/**
|
||||
* İlk geçerli satırlar (gösterime hazır)
|
||||
*/
|
||||
preview: Array<Array<string>>, };
|
||||
@@ -6,11 +6,15 @@ export type PeriodStat = { id: string, name: string, start_date: string,
|
||||
/**
|
||||
* Son gün, dahil
|
||||
*/
|
||||
last_date: string, status: PeriodStatus, spendable: number, spent: number,
|
||||
last_date: string, status: PeriodStatus, income_total: number, fixed_expense_total: number, savings_goal: number, carryover_debt: number, carryover_surplus: number, spendable: number, spent: number,
|
||||
/**
|
||||
* Harcanan, gruplara göre
|
||||
*/
|
||||
spent_by_group: GroupAmounts,
|
||||
/**
|
||||
* Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
|
||||
*/
|
||||
realized_savings: number,
|
||||
/**
|
||||
* Kapanmamışsa `null`
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PeriodStatus = "open" | "closed" | "correcting";
|
||||
export type PeriodStatus = "open" | "closed" | "correcting" | "archived";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ReportCategoryRow } from "./ReportCategoryRow";
|
||||
import type { ReportGroupRow } from "./ReportGroupRow";
|
||||
import type { ReportMethodRow } from "./ReportMethodRow";
|
||||
import type { ReportMonthRow } from "./ReportMonthRow";
|
||||
|
||||
export type ReportBreakdown = {
|
||||
/**
|
||||
* Kuruş
|
||||
*/
|
||||
total: number, count: number,
|
||||
/**
|
||||
* 4 grup, sabit sırada
|
||||
*/
|
||||
groups: Array<ReportGroupRow>,
|
||||
/**
|
||||
* Harcama olan **tüm** kategoriler, çoktan aza
|
||||
*/
|
||||
categories: Array<ReportCategoryRow>,
|
||||
/**
|
||||
* Ödeme türleri; `null` = girilmemiş
|
||||
*/
|
||||
payment_methods: Array<ReportMethodRow>,
|
||||
/**
|
||||
* Aylar (`"YYYY-MM"`), eskiden yeniye
|
||||
*/
|
||||
months: Array<ReportMonthRow>, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Group } from "./Group";
|
||||
|
||||
export type ReportCategoryRow = { category_id: string, group: Group, total: number, count: number, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Group } from "./Group";
|
||||
|
||||
export type ReportGroupRow = { group: Group, total: number, count: number, };
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ReportMethodRow = { payment_method_id: string | null, total: number, count: number, };
|
||||
@@ -0,0 +1,7 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type ReportMonthRow = {
|
||||
/**
|
||||
* `"YYYY-MM"`
|
||||
*/
|
||||
month: string, total: number, count: number, };
|
||||
Reference in New Issue
Block a user