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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user