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
+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;
}