diff --git a/backend/api.http b/backend/api.http index 1dc0bc0..cb86d82 100644 --- a/backend/api.http +++ b/backend/api.http @@ -385,3 +385,15 @@ 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 diff --git a/backend/src/models/group.rs b/backend/src/models/group.rs index 30180a8..55bcc31 100644 --- a/backend/src/models/group.rs +++ b/backend/src/models/group.rs @@ -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) diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index f543329..7808369 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -15,9 +15,11 @@ 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}; @@ -89,6 +91,17 @@ pub fn router(state: AppState) -> Router { .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( + "/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), diff --git a/backend/src/routes/transfer.rs b/backend/src/routes/transfer.rs new file mode 100644 index 0000000..1528c0f --- /dev/null +++ b/backend/src/routes/transfer.rs @@ -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, + to: Option, +} + +pub async fn export_entries( + State(s): State, + auth: AuthUser, + Query(q): Query, +) -> AppResult { + 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, + auth: AuthUser, +) -> AppResult { + 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, + auth: AuthUser, +) -> AppResult { + 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, + /// İlk geçerli satırlar (gösterime hazır) + preview: Vec>, +} + +#[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, + auth: AuthUser, + Path(kind): Path, + Query(q): Query, + body: String, +) -> AppResult> { + 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, + })) +} diff --git a/backend/src/services/csv_io.rs b/backend/src/services/csv_io.rs new file mode 100644 index 0000000..33441ad --- /dev/null +++ b/backend/src/services/csv_io.rs @@ -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 { + let mut out = String::from(BOM); + let line = |cells: Vec| cells.iter().map(|c| quote(c)).collect::>().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::>().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)> { + 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 { + let hs: Vec = 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 { + let (neg, int, frac) = split_decimal(s, 2)?; + let v = + int.parse::().ok()?.checked_mul(100)? + format!("{frac:0<2}").parse::().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 { + let (neg, int, frac) = split_decimal(s, 6)?; + if neg { + return None; + } + int.parse::() + .ok()? + .checked_mul(1_000_000)? + .checked_add(format!("{frac:0<6}").parse::().ok()?) +} + +/// `13.11.2026` · `13/11/2026` · `2026-11-13` → `"2026-11-13"` +pub fn parse_date_any(s: &str) -> Option { + 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 = ["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); + } +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 4bdc4c2..09f8028 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -6,6 +6,7 @@ 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; @@ -19,3 +20,4 @@ pub mod reports; pub mod review; pub mod seed; pub mod stats; +pub mod transfer; diff --git a/backend/src/services/transfer.rs b/backend/src/services/transfer.rs new file mode 100644 index 0000000..3a660d1 --- /dev/null +++ b/backend/src/services/transfer.rs @@ -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>, +} + +/// (satır numarası, hücreler) +type Rows = Vec<(usize, Vec)>; + +fn rows_of(text: &str) -> AppResult<(Vec, 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> { + 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) -> &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 { + 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, + 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); + +impl Existing { + fn new(keys: impl Iterator) -> 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 { + 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::(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 { + 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::(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 { + 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::(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 { + 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> { + let mut c = db + .collection::(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 { + 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 { + 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> { + 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> { + let mut c = db + .collection::(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> { + let mut c = db + .collection::(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 { + 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::>(); + 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 { + 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 = Vec::new(); + for (line, row) in &rows { + let parsed = (|| -> Result<(NewTxRow, Vec), 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 = 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 = 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) +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 8ae9208..9a5a978 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -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) diff --git a/backend/tests/transfer.rs b/backend/tests/transfer.rs new file mode 100644 index 0000000..24b64f7 --- /dev/null +++ b/backend/tests/transfer.rs @@ -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; +} diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 9953587..96bb8cf 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Faz 9 – Adım 3:** Uygulamanın kendi CSV'si (harcamalar, plan kalemleri, varlık işlemleri): dışa aktarma (backend ya da frontend; `lib/csv.ts` biçimi: BOM, `;`, `1250,50`, GG.AA.YYYY) + içe aktarma (önizleme → satır satır hata → geçerliler aktarılır; kategori/ödeme türü **adla** eşleşir; dönem tarihten bulunur, yazılamaz dönem → satır hatası). Adım 4 (ekstre) bunun üstüne kurulacak: ortak satır doğrulama/aktarma servisi tasarla. +**Faz 9 – Adım 4:** Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; PHASES Faz 9 Adım 4). Önce tasarımı kullanıcıya sun: biçimler (CSV / Excel .xlsx — Excel için bağımlılık gerekir, gerekçe), sütun eşleştirme + banka profili (kaydedilir), yön (kart: harcama +; hesap: çıkış −, giriş = gelir?), taksit/iade, tekrar tespiti (tarih+tutar+açıklama), açıklamadan kategori önerisi (öğrenen kural, hane bazlı), **geçmiş (arşiv) dönemler** (yıl başından; plan yok → kapatma/devir zincirine etkisi; durum? `archived`?), aktarım `transfer.rs` + `csv_io.rs` üstüne. Karar gerektirenleri sor. **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 @@ -136,3 +136,4 @@ ### 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. diff --git a/docs/PHASES.md b/docs/PHASES.md index 1933e3d..1dcfeb8 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -81,7 +81,7 @@ ## 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 +- ✅ 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** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri) - ⏳ 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 diff --git a/frontend/src/features/settings/data-section.tsx b/frontend/src/features/settings/data-section.tsx new file mode 100644 index 0000000..3430403 --- /dev/null +++ b/frontend/src/features/settings/data-section.tsx @@ -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 = { + 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 ( +
+
+

{s.exportHint}

+
+ {TRANSFER_KINDS.map((k) => ( + + ))} +
+
+ +
+ ) +} + +function ImportCsv() { + const [kind, setKind] = useState('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() + const input = useRef(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 ( +
+
+

{s.importHint}

+ { + setKind(k) + reset() + }} + options={TRANSFER_KINDS.map((k) => ({ value: k, label: kindLabel[k] }))} + /> +

+ {s.columns[kind]} {s.sampleHint} +

+ +
+ { + const f = e.target.files?.[0] + if (f) void check(f, kind) + }} + /> + + {file && ( + + {s.file(file.name, file.encoding)} + + )} +
+ + {run.isPending && ( +

+ + {report ? s.importing : s.checking} +

+ )} + + + {r && ( +
+
    +
  • {s.summary(r.total)}
  • + {(report.committed ? r.imported : r.valid) > 0 ? ( +
  • + + {report.committed ? s.added(r.imported) : s.willAdd(r.valid)} +
  • + ) : ( +
  • + {report.committed ? s.added(0) : s.willAdd(0)} +
  • + )} + {r.duplicates > 0 && ( +
  • {s.dupes(r.duplicates)}
  • + )} + {r.errors.length > 0 && ( +
  • + + {s.errorsTitle(r.errors.length)} +
  • + )} +
+ + {r.errors.length > 0 && ( +
    + {r.errors.slice(0, MAX_ERRORS).map((e) => ( +
  • + {s.line(e.line)}: {e.message} +
  • + ))} + {r.errors.length > MAX_ERRORS && ( +
  • + {s.moreErrors(r.errors.length - MAX_ERRORS)} +
  • + )} +
+ )} + + {!report.committed && r.preview.length > 0 && ( +
+

{s.previewTitle}

+
+ + + {r.preview.map((row, i) => ( + + {row.map((c, j) => ( + + ))} + + ))} + +
+ {c} +
+
+
+ )} + + {!report.committed && + (r.valid > 0 ? ( + + ) : ( +

{s.nothing}

+ ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/features/settings/page.tsx b/frontend/src/features/settings/page.tsx index 69a34a4..c2e5f17 100644 --- a/frontend/src/features/settings/page.tsx +++ b/frontend/src/features/settings/page.tsx @@ -1,11 +1,20 @@ import { ComingSoon, PageHeader } from '@/components/page' import { t } from '@/lib/strings' +import { DataSection } from './data-section' export function SettingsPage() { return ( <> - +
+
+

+ {t.data.title} +

+ +
+ +
) } diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index 1c751ca..d9fb511 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -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() diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a9cbd4f..d0eed0e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -31,14 +31,26 @@ const UNAVAILABLE_MESSAGE = 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra 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(method: Method, path: string, body?: unknown): Promise { 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) @@ -64,4 +76,7 @@ export const api = { put: (path: string, body: unknown) => request('PUT', path, body), patch: (path: string, body: unknown) => request('PATCH', path, body), delete: (path: string) => request('DELETE', path), + /** CSV metnini gövde olarak gönderir */ + postCsv: (path: string, csv: string) => + request('POST', path, new TextBody(csv, 'text/csv; charset=utf-8')), } diff --git a/frontend/src/lib/decode.test.ts b/frontend/src/lib/decode.test.ts new file mode 100644 index 0000000..008c78f --- /dev/null +++ b/frontend/src/lib/decode.test.ts @@ -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' }) + }) +}) diff --git a/frontend/src/lib/decode.ts b/frontend/src/lib/decode.ts new file mode 100644 index 0000000..8b06899 --- /dev/null +++ b/frontend/src/lib/decode.ts @@ -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' } + } +} diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 1474f37..1027c27 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -571,6 +571,44 @@ export const t = { }, }, + 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' }, diff --git a/frontend/src/lib/transfer.ts b/frontend/src/lib/transfer.ts new file mode 100644 index 0000000..5b9dc02 --- /dev/null +++ b/frontend/src/lib/transfer.ts @@ -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(`/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 }), + ]) + }, + }) +} diff --git a/frontend/src/types/ImportError.ts b/frontend/src/types/ImportError.ts new file mode 100644 index 0000000..34a3362 --- /dev/null +++ b/frontend/src/types/ImportError.ts @@ -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, }; diff --git a/frontend/src/types/ImportReport.ts b/frontend/src/types/ImportReport.ts new file mode 100644 index 0000000..d2ca222 --- /dev/null +++ b/frontend/src/types/ImportReport.ts @@ -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, +/** + * İlk geçerli satırlar (gösterime hazır) + */ +preview: Array>, };