//! 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) }