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