feat(veri): CSV dışa ve içe aktarma (harcamalar, plan kalemleri, varlık işlemleri)

- Excel uyumlu CSV indirme
- İçe aktarma: önce deneme raporu (geçerli / zaten var / satır satır hata), onayla geçerli satırlar
- Sütunlar adla, esnek tutar/tarih yazımı, ; ve , ayırıcı, Windows-1254 desteği
- Varlıkta tarih sırasına göre eldekinden fazla satış denetimi
- Ayarlar sayfasında Veri bölümü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 02:21:44 +03:00
parent 33f64a0459
commit 4d764689e2
21 changed files with 1961 additions and 5 deletions
+12
View File
@@ -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 ### 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 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
+10
View File
@@ -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ı /// Ekranda gösterim sırası
pub fn order(self) -> usize { pub fn order(self) -> usize {
Self::ALL.iter().position(|g| *g == self).unwrap_or(0) Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
+13
View File
@@ -15,9 +15,11 @@ mod reports;
mod review; mod review;
mod spa; mod spa;
mod stats; mod stats;
mod transfer;
use axum::{ use axum::{
Router, Router,
extract::DefaultBodyLimit,
routing::{delete, get, patch, post, put}, routing::{delete, get, patch, post, put},
}; };
use tower_http::{compression::CompressionLayer, trace::TraceLayer}; 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("/periods/{id}/daily", get(stats::daily))
.route("/stats/periods", get(stats::periods)) .route("/stats/periods", get(stats::periods))
.route("/reports/breakdown", get(reports::breakdown)) .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( .route(
"/periods/{id}/budget", "/periods/{id}/budget",
get(budget::get).put(budget::set_monthly), get(budget::get).put(budget::set_monthly),
+149
View File
@@ -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,
}))
}
+280
View File
@@ -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);
}
}
+2
View File
@@ -6,6 +6,7 @@ pub mod budget;
pub mod calendar; pub mod calendar;
pub mod catalog; pub mod catalog;
pub mod closing; pub mod closing;
pub mod csv_io;
pub mod entry; pub mod entry;
pub mod holding; pub mod holding;
pub mod invite; pub mod invite;
@@ -19,3 +20,4 @@ pub mod reports;
pub mod review; pub mod review;
pub mod seed; pub mod seed;
pub mod stats; pub mod stats;
pub mod transfer;
+805
View File
@@ -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)
}
+37
View File
@@ -101,6 +101,43 @@ impl TestApp {
(status, json, set_cookie) (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) { pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await; let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
(st, v) (st, v)
+277
View File
@@ -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;
}
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası # 🧠 Proje Hafızası
## ▶️ Sıradaki Adım ## ▶️ 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 ✅. **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 ## 📌 Alınan Kararlar
@@ -136,3 +136,4 @@
### 2026-11-13 — Faz 9 ### 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 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 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.
+1 -1
View File
@@ -81,7 +81,7 @@
## Faz 9 Adımları ## 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 - ✅ 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 - ✅ 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) - ⏳ 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 - ⏳ 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 - ⏳ 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
@@ -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>
)
}
+10 -1
View File
@@ -1,11 +1,20 @@
import { ComingSoon, PageHeader } from '@/components/page' import { ComingSoon, PageHeader } from '@/components/page'
import { t } from '@/lib/strings' import { t } from '@/lib/strings'
import { DataSection } from './data-section'
export function SettingsPage() { export function SettingsPage() {
return ( return (
<> <>
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} /> <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>
</> </>
) )
} }
+9
View File
@@ -28,6 +28,15 @@ describe('api', () => {
expect(init.headers).toEqual({ 'Content-Type': 'application/json' }) 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 () => { it('204 → undefined', async () => {
mockFetch(204) mockFetch(204)
await expect(api.post('/auth/logout')).resolves.toBeUndefined() await expect(api.post('/auth/logout')).resolves.toBeUndefined()
+17 -2
View File
@@ -31,14 +31,26 @@ const UNAVAILABLE_MESSAGE = 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 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> { async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
let res: Response let res: Response
const raw = body instanceof TextBody ? body : null
try { try {
res = await fetch(`/api${path}`, { res = await fetch(`/api${path}`, {
method, method,
credentials: 'same-origin', credentials: 'same-origin',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, headers:
body: body === undefined ? undefined : JSON.stringify(body), body === undefined ? undefined : { 'Content-Type': raw ? raw.type : 'application/json' },
body: body === undefined ? undefined : raw ? raw.text : JSON.stringify(body),
}) })
} catch { } catch {
throw new ApiError(0, 'network', NETWORK_MESSAGE) throw new ApiError(0, 'network', NETWORK_MESSAGE)
@@ -64,4 +76,7 @@ export const api = {
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body), put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body), patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T = void>(path: string) => request<T>('DELETE', path), 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')),
} }
+16
View File
@@ -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' })
})
})
+11
View File
@@ -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' }
}
}
+38
View File
@@ -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: { periods: {
picker: 'Dönem', picker: 'Dönem',
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' }, status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
+31
View File
@@ -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 }),
])
},
})
}
+7
View File
@@ -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, };
+24
View File
@@ -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>>, };