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
+13
View File
@@ -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),
+149
View File
@@ -0,0 +1,149 @@
//! CSV dışa / içe aktarma (hanenin tüm üyeleri):
//!
//! | Yöntem | Yol | İş |
//! |---|---|---|
//! | GET | `/api/export/entries.csv?from=&to=` | harcamalar |
//! | GET | `/api/export/plan-items.csv` | plan kalemleri (tüm dönemler) |
//! | GET | `/api/export/asset-transactions.csv` | varlık işlemleri |
//! | POST | `/api/import/{tür}?commit=false\|true` | gövde: CSV metni → rapor; `commit=true` geçerli satırları yazar |
//!
//! `{tür}`: `entries` · `plan-items` · `asset-transactions`.
use axum::{
Json,
extract::{Path, Query, State},
http::header,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{
auth::AuthUser,
error::{AppError, AppResult},
services::{
calendar::{format_date, today_tr},
transfer,
},
state::AppState,
};
fn csv_response(name: &str, body: String) -> impl IntoResponse {
let file = format!("kakeibo-{name}-{}.csv", format_date(today_tr()));
(
[
(header::CONTENT_TYPE, "text/csv; charset=utf-8".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{file}\""),
),
(header::CACHE_CONTROL, "no-store".to_string()),
],
body,
)
}
#[derive(Deserialize)]
pub struct RangeQuery {
from: Option<String>,
to: Option<String>,
}
pub async fn export_entries(
State(s): State<AppState>,
auth: AuthUser,
Query(q): Query<RangeQuery>,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_entries(
&s.db,
auth.household_id,
q.from.as_deref().filter(|x| !x.is_empty()),
q.to.as_deref().filter(|x| !x.is_empty()),
)
.await?;
Ok(csv_response("harcamalar", body))
}
pub async fn export_plan_items(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_plan_items(&s.db, auth.household_id).await?;
Ok(csv_response("plan-kalemleri", body))
}
pub async fn export_asset_txs(
State(s): State<AppState>,
auth: AuthUser,
) -> AppResult<impl IntoResponse> {
let body = transfer::export_asset_txs(&s.db, auth.household_id).await?;
Ok(csv_response("varlik-islemleri", body))
}
#[derive(Deserialize)]
pub struct ImportQuery {
#[serde(default)]
commit: bool,
}
#[derive(Serialize, TS)]
#[ts(export, rename = "ImportReport")]
pub struct ReportDto {
/// Başlık hariç dolu satır
total: u32,
/// Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
valid: u32,
/// Veritabanında zaten olduğu için atlanan
duplicates: u32,
/// Yazılan (denemede 0)
imported: u32,
errors: Vec<ImportError>,
/// İlk geçerli satırlar (gösterime hazır)
preview: Vec<Vec<String>>,
}
#[derive(Serialize, TS)]
#[ts(export)]
pub struct ImportError {
/// Dosyadaki satır numarası (başlık 1)
line: u32,
message: String,
}
pub async fn import(
State(s): State<AppState>,
auth: AuthUser,
Path(kind): Path<String>,
Query(q): Query<ImportQuery>,
body: String,
) -> AppResult<Json<ReportDto>> {
let r = match kind.as_str() {
"entries" => {
transfer::import_entries(&s.db, auth.household_id, auth.user_id, &body, q.commit)
.await?
}
"plan-items" => {
transfer::import_plan_items(&s.db, auth.household_id, &body, q.commit).await?
}
"asset-transactions" => {
transfer::import_asset_txs(&s.db, auth.household_id, auth.user_id, &body, q.commit)
.await?
}
_ => return Err(AppError::NotFound),
};
Ok(Json(ReportDto {
total: r.total as u32,
valid: r.valid as u32,
duplicates: r.duplicates as u32,
imported: r.imported as u32,
errors: r
.errors
.into_iter()
.map(|(line, message)| ImportError {
line: line as u32,
message,
})
.collect(),
preview: r.preview,
}))
}