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:
@@ -385,3 +385,15 @@ GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
|
||||
|
||||
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
|
||||
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
|
||||
|
||||
# ─────────────────────────── CSV dışa / içe aktarma ───────────────────────────
|
||||
|
||||
### Harcamaları CSV olarak indir (tarih aralığı isteğe bağlı)
|
||||
GET {{baseUrl}}/export/entries.csv?from=2026-01-01&to=2026-12-31
|
||||
|
||||
### İçe aktarma denemesi (hiçbir şey yazılmaz); commit=true → geçerli satırlar yazılır
|
||||
POST {{baseUrl}}/import/entries?commit=false
|
||||
Content-Type: text/csv; charset=utf-8
|
||||
|
||||
Tarih;Tutar;Kategori;Açıklama
|
||||
13.11.2026;45,90;Market;Ekmek
|
||||
|
||||
@@ -34,6 +34,16 @@ impl Group {
|
||||
}
|
||||
}
|
||||
|
||||
/// Türkçe ad (CSV dışa/içe aktarma; arayüz metinleri `frontend/src/lib/groups.ts`)
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Group::Needs => "Zorunlu",
|
||||
Group::Wants => "İstek",
|
||||
Group::Culture => "Kültürel",
|
||||
Group::Unexpected => "Beklenmeyen",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ekranda gösterim sırası
|
||||
pub fn order(self) -> usize {
|
||||
Self::ALL.iter().position(|g| *g == self).unwrap_or(0)
|
||||
|
||||
@@ -15,9 +15,11 @@ mod reports;
|
||||
mod review;
|
||||
mod spa;
|
||||
mod stats;
|
||||
mod transfer;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
@@ -89,6 +91,17 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/periods/{id}/daily", get(stats::daily))
|
||||
.route("/stats/periods", get(stats::periods))
|
||||
.route("/reports/breakdown", get(reports::breakdown))
|
||||
.route("/export/entries.csv", get(transfer::export_entries))
|
||||
.route("/export/plan-items.csv", get(transfer::export_plan_items))
|
||||
.route(
|
||||
"/export/asset-transactions.csv",
|
||||
get(transfer::export_asset_txs),
|
||||
)
|
||||
.route(
|
||||
"/import/{kind}",
|
||||
// Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB
|
||||
post(transfer::import).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/periods/{id}/budget",
|
||||
get(budget::get).put(budget::set_monthly),
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
pub mod closing;
|
||||
pub mod csv_io;
|
||||
pub mod entry;
|
||||
pub mod holding;
|
||||
pub mod invite;
|
||||
@@ -19,3 +20,4 @@ pub mod reports;
|
||||
pub mod review;
|
||||
pub mod seed;
|
||||
pub mod stats;
|
||||
pub mod transfer;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -101,6 +101,43 @@ impl TestApp {
|
||||
(status, json, set_cookie)
|
||||
}
|
||||
|
||||
/// Düz metin gövde gönderir (CSV içe aktarma)
|
||||
pub async fn post_text(&self, path: &str, s: &Session, text: &str) -> (StatusCode, Value) {
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
|
||||
.body(Body::from(text.to_string()))
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
|
||||
/// Metin yanıt (CSV dışa aktarma): (durum, içerik türü, gövde)
|
||||
pub async fn get_text(&self, path: &str, s: &Session) -> (StatusCode, String, String) {
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api{path}"))
|
||||
.header(header::COOKIE, &s.cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let ct = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let bytes = res.into_body().collect().await.unwrap().to_bytes();
|
||||
(status, ct, String::from_utf8(bytes.to_vec()).unwrap())
|
||||
}
|
||||
|
||||
pub async fn get(&self, path: &str, s: &Session) -> (StatusCode, Value) {
|
||||
let (st, v, _) = self.send(Method::GET, path, Some(&s.cookie), None).await;
|
||||
(st, v)
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
# 🧠 Proje Hafızası
|
||||
|
||||
## ▶️ Sıradaki Adım
|
||||
**Faz 9 – Adım 3:** Uygulamanın kendi CSV'si (harcamalar, plan kalemleri, varlık işlemleri): dışa aktarma (backend ya da frontend; `lib/csv.ts` biçimi: BOM, `;`, `1250,50`, GG.AA.YYYY) + içe aktarma (önizleme → satır satır hata → geçerliler aktarılır; kategori/ödeme türü **adla** eşleşir; dönem tarihten bulunur, yazılamaz dönem → satır hatası). Adım 4 (ekstre) bunun üstüne kurulacak: ortak satır doğrulama/aktarma servisi tasarla.
|
||||
**Faz 9 – Adım 4:** Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; PHASES Faz 9 Adım 4). Önce tasarımı kullanıcıya sun: biçimler (CSV / Excel .xlsx — Excel için bağımlılık gerekir, gerekçe), sütun eşleştirme + banka profili (kaydedilir), yön (kart: harcama +; hesap: çıkış −, giriş = gelir?), taksit/iade, tekrar tespiti (tarih+tutar+açıklama), açıklamadan kategori önerisi (öğrenen kural, hane bazlı), **geçmiş (arşiv) dönemler** (yıl başından; plan yok → kapatma/devir zincirine etkisi; durum? `archived`?), aktarım `transfer.rs` + `csv_io.rs` üstüne. Karar gerektirenleri sor.
|
||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||
|
||||
## 📌 Alınan Kararlar
|
||||
@@ -136,3 +136,4 @@
|
||||
### 2026-11-13 — Faz 9
|
||||
- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test.
|
||||
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
|
||||
- **Adım 3:** CSV dışa/içe aktarma. Backend `services/csv_io.rs` (saf: yazma BOM/`;`/CRLF/`1250,50`/GG.AA.YYYY; okuma: BOM, `;`/`,`/sekme otomatik, tırnak, satır numarası, `norm` başlık eşleme (Türkçe İ/ı), `parse_money` (1.250,50 · 1250.50 · 1.000=bin), `parse_e6`, `parse_date_any`), `services/transfer.rs` (harcama/plan/varlık: dışa aktar; içe aktar `commit=false` deneme → rapor {total, valid, duplicates, imported, errors[(satır, neden)], preview}; tekrar sayacı `Existing` (dosyada 2, DB'de 1 → 1 eklenir); harcamada dönem tarihten, kapalı/yok → satır hatası, kategori adla (+grup), aynı ad 2 grupta → hata; plan `plan::create` ile; varlıkta tarih+dosya sırasına göre eldekinden fazla satış simülasyonu, yazma `asset::create_tx`). `Group::label()`. Rotalar `routes/transfer.rs` (`/export/*.csv`, `POST /import/{tür}` 5 MB). `tests/transfer.rs` (3) + csv_io birim (4); mutasyon 3/3. Frontend: Ayarlar → Veri (`features/settings/data-section.tsx`: indirme bağlantıları, içe aktarma: tür seç → dosya → deneme raporu → onay), `api.postCsv`, `lib/decode.ts` (UTF-8 fatal → Windows-1254), `lib/transfer.ts`. Backend 164, frontend 125 test; e2e 16 kontrol.
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@
|
||||
## Faz 9 Adımları
|
||||
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
|
||||
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
||||
- ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||
- ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri)
|
||||
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
|
||||
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import { ComingSoon, PageHeader } from '@/components/page'
|
||||
import { t } from '@/lib/strings'
|
||||
import { DataSection } from './data-section'
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ describe('api', () => {
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
})
|
||||
|
||||
it('CSV metnini olduğu gibi gönderir', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(Response.json({ total: 1 }))
|
||||
globalThis.fetch = fetchMock
|
||||
await api.postCsv('/import/entries', 'Tarih;Tutar\r\n')
|
||||
const [, init] = fetchMock.mock.calls[0]
|
||||
expect(init.body).toBe('Tarih;Tutar\r\n')
|
||||
expect(init.headers['Content-Type']).toBe('text/csv; charset=utf-8')
|
||||
})
|
||||
|
||||
it('204 → undefined', async () => {
|
||||
mockFetch(204)
|
||||
await expect(api.post('/auth/logout')).resolves.toBeUndefined()
|
||||
|
||||
+17
-2
@@ -31,14 +31,26 @@ const UNAVAILABLE_MESSAGE = 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
|
||||
/** Ham metin gövde (CSV içe aktarma); diğerleri JSON */
|
||||
class TextBody {
|
||||
readonly text: string
|
||||
readonly type: string
|
||||
constructor(text: string, type: string) {
|
||||
this.text = text
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
|
||||
let res: Response
|
||||
const raw = body instanceof TextBody ? body : null
|
||||
try {
|
||||
res = await fetch(`/api${path}`, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
headers:
|
||||
body === undefined ? undefined : { 'Content-Type': raw ? raw.type : 'application/json' },
|
||||
body: body === undefined ? undefined : raw ? raw.text : JSON.stringify(body),
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError(0, 'network', NETWORK_MESSAGE)
|
||||
@@ -64,4 +76,7 @@ export const api = {
|
||||
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
|
||||
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')),
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
@@ -571,6 +571,44 @@ export const t = {
|
||||
},
|
||||
},
|
||||
|
||||
data: {
|
||||
title: 'Veri',
|
||||
exportTitle: 'Dışa aktar (CSV)',
|
||||
exportHint: 'Excel ve LibreOffice’te doğrudan açılır (Türkçe karakterler ve tutarlar doğru).',
|
||||
entries: 'Harcamalar',
|
||||
planItems: 'Plan kalemleri',
|
||||
assetTxs: 'Varlık işlemleri',
|
||||
importTitle: 'İçe aktar (CSV)',
|
||||
importHint:
|
||||
'Önce dosya denenir, hiçbir şey yazılmaz: kaç satırın ekleneceği ve hatalı satırlar gösterilir. Onaylarsan yalnızca geçerli satırlar eklenir. Aynı dosyayı tekrar yüklemek çift kayıt oluşturmaz.',
|
||||
columns: {
|
||||
entries:
|
||||
'Sütunlar: Tarih, Tutar, Kategori (zorunlu) · Grup, Ödeme türü, Açıklama (isteğe bağlı). Tarihin açık ya da düzeltmedeki bir döneme düşmesi gerekir.',
|
||||
'plan-items':
|
||||
'Sütunlar: Dönem başlangıcı, Tür (Gelir / Sabit gider), Ad, Tutar (zorunlu) · Tarih (isteğe bağlı).',
|
||||
'asset-transactions':
|
||||
'Sütunlar: Tarih, Varlık kodu (ya da Varlık), İşlem (Alım / Satım), Tutar (zorunlu) · Miktar (TL dışında zorunlu), Birim fiyat, Not.',
|
||||
},
|
||||
sampleHint: 'Biçim için önce aynı türü dışa aktarıp bakabilirsin.',
|
||||
chooseFile: 'Dosya seç',
|
||||
checking: 'Dosya denetleniyor…',
|
||||
file: (name: string, enc: string) =>
|
||||
`${name} · ${enc === 'utf-8' ? 'UTF-8' : 'Windows-1254 (Excel Türkçe)'}`,
|
||||
summary: (total: number) => `${total} satır okundu`,
|
||||
willAdd: (n: number) => `${n} satır eklenecek`,
|
||||
added: (n: number) => `${n} satır eklendi`,
|
||||
dupes: (n: number) => `${n} satır zaten var, atlanacak`,
|
||||
errorsTitle: (n: number) => `${n} satır hatalı (eklenmeyecek)`,
|
||||
line: (n: number) => `Satır ${n}`,
|
||||
moreErrors: (n: number) => `… ve ${n} hata daha`,
|
||||
previewTitle: 'Eklenecek satırlardan örnek',
|
||||
commit: (n: number) => `${n} satırı içe aktar`,
|
||||
importing: 'Aktarılıyor…',
|
||||
done: (n: number) => `${n} satır içe aktarıldı`,
|
||||
another: 'Başka dosya seç',
|
||||
nothing: 'Eklenecek yeni satır yok.',
|
||||
},
|
||||
|
||||
periods: {
|
||||
picker: 'Dönem',
|
||||
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
|
||||
|
||||
@@ -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 }),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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, };
|
||||
@@ -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>>, };
|
||||
Reference in New Issue
Block a user