feat(ekstre): banka/kart ekstresi içe aktarma altyapısı (arşiv dönemleri, iade, öğrenen kurallar)
- Arşiv dönemi durumu: geçmiş aylar için; kapatılmaz, devir zincirinde atlanır, bütçe önerilerini besler - POST /bank-import/preview ve /commit: harcama / iade (eksi) / gelir (plan) / yoksay - Tekrar tespiti (import_key), açıklamadan öğrenen kategori kuralları, ödeme/virman ipuçları - Kapalı dönem ve gelecek tarih satır hatası; iade düzenlemede eksi kalır Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
//! Banka / kart ekstresi içe aktarma (hanenin tüm üyeleri). Dosya tarayıcıda okunur;
|
||||
//! buraya eşleştirilmiş satırlar gelir.
|
||||
//!
|
||||
//! | Yöntem | Yol | İş |
|
||||
//! |---|---|---|
|
||||
//! | POST | `/api/bank-import/preview` | satır başına öneri (tür, kategori), tekrar, dönem; açılacak arşiv dönemleri |
|
||||
//! | POST | `/api/bank-import/commit` | kullanıcının seçimiyle aktar; seçimleri kural olarak öğren |
|
||||
|
||||
use axum::{Json, extract::State};
|
||||
use mongodb::bson::Uuid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
auth::AuthUser,
|
||||
error::{AppError, AppResult},
|
||||
extract::ValidJson,
|
||||
services::{
|
||||
bank_import::{self, Direction, Kind, Row, Source, Target},
|
||||
calendar::{default_period_name, format_date, parse_date},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, TS)]
|
||||
#[ts(export, rename = "BankRowIn")]
|
||||
pub struct RowIn {
|
||||
/// `"YYYY-MM-DD"`
|
||||
date: String,
|
||||
description: String,
|
||||
/// Kuruş, > 0
|
||||
#[ts(type = "number")]
|
||||
amount: i64,
|
||||
#[ts(type = "\"in\" | \"out\"")]
|
||||
direction: Direction,
|
||||
/// Yalnızca aktarmada (kullanıcının seçimi)
|
||||
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"", optional)]
|
||||
kind: Option<Kind>,
|
||||
#[ts(optional)]
|
||||
category_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "BankPreviewRequest")]
|
||||
pub struct PreviewRequest {
|
||||
#[ts(type = "\"card\" | \"account\"")]
|
||||
source: Source,
|
||||
rows: Vec<RowIn>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Validate, TS)]
|
||||
#[ts(export, rename = "BankCommitRequest", optional_fields)]
|
||||
pub struct CommitRequest {
|
||||
/// Kayıtlara yazılacak ödeme türü (ör. Kredi Kartı)
|
||||
payment_method_id: Option<String>,
|
||||
rows: Vec<RowIn>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankPreviewRow")]
|
||||
struct PreviewRowDto {
|
||||
#[ts(type = "\"expense\" | \"refund\" | \"income\" | \"ignore\"")]
|
||||
kind: &'static str,
|
||||
category_id: Option<String>,
|
||||
/// Öneri hanenin öğrendiği kuraldan
|
||||
from_rule: bool,
|
||||
/// Daha önce aktarılmış (atlanacak)
|
||||
duplicate: bool,
|
||||
/// `existing` · `archive` · `blocked`
|
||||
#[ts(type = "\"existing\" | \"archive\" | \"blocked\"")]
|
||||
period: &'static str,
|
||||
/// Dönem adı (var olan ya da açılacak arşiv)
|
||||
period_name: Option<String>,
|
||||
/// Engel nedeni
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankPreview")]
|
||||
pub struct PreviewDto {
|
||||
rows: Vec<PreviewRowDto>,
|
||||
/// Açılacak arşiv dönemleri
|
||||
archives: Vec<ArchiveDto>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankArchive")]
|
||||
struct ArchiveDto {
|
||||
name: String,
|
||||
start_date: String,
|
||||
/// Son gün, dahil
|
||||
last_date: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankImportReport")]
|
||||
pub struct ReportDto {
|
||||
entries: u32,
|
||||
refunds: u32,
|
||||
incomes: u32,
|
||||
ignored: u32,
|
||||
duplicates: u32,
|
||||
/// Açılan arşiv dönemleri
|
||||
archives: Vec<String>,
|
||||
errors: Vec<RowError>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, TS)]
|
||||
#[ts(export, rename = "BankRowError")]
|
||||
struct RowError {
|
||||
/// Satır sırası (0'dan)
|
||||
index: u32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn rows_of(rows: Vec<RowIn>) -> AppResult<Vec<Row>> {
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
let category_id = match r.category_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some(id) => Some(
|
||||
Uuid::parse_str(id)
|
||||
.map_err(|_| AppError::BadRequest("Kategori geçersiz".into()))?,
|
||||
),
|
||||
};
|
||||
Ok(Row {
|
||||
date: r.date.trim().to_string(),
|
||||
description: r.description,
|
||||
amount: r.amount,
|
||||
direction: r.direction,
|
||||
kind: r.kind,
|
||||
category_id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn archive_name(start: &str, end: &str) -> String {
|
||||
match (parse_date(start), parse_date(end)) {
|
||||
(Some(s), Some(e)) => default_period_name(s, e),
|
||||
_ => start.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<PreviewRequest>,
|
||||
) -> AppResult<Json<PreviewDto>> {
|
||||
let rows = rows_of(req.rows)?;
|
||||
let p = bank_import::preview(&s.db, auth.household_id, req.source, &rows).await?;
|
||||
Ok(Json(PreviewDto {
|
||||
rows: p
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (period, period_name, message) = match r.target {
|
||||
Target::Existing { name, .. } => ("existing", Some(name), None),
|
||||
Target::Archive { start, end } => {
|
||||
("archive", Some(archive_name(&start, &end)), None)
|
||||
}
|
||||
Target::Blocked(m) => ("blocked", None, Some(m)),
|
||||
};
|
||||
PreviewRowDto {
|
||||
kind: r.kind.as_str(),
|
||||
category_id: r.category_id.map(|c| c.to_string()),
|
||||
from_rule: r.from_rule,
|
||||
duplicate: r.duplicate,
|
||||
period,
|
||||
period_name,
|
||||
message,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
archives: p
|
||||
.archives
|
||||
.into_iter()
|
||||
.map(|(start, end)| {
|
||||
let last = parse_date(&end)
|
||||
.map_or(end.clone(), |e| format_date(e - time::Duration::days(1)));
|
||||
ArchiveDto {
|
||||
name: archive_name(&start, &end),
|
||||
start_date: start,
|
||||
last_date: last,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn commit(
|
||||
State(s): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ValidJson(req): ValidJson<CommitRequest>,
|
||||
) -> AppResult<Json<ReportDto>> {
|
||||
let pm = match req.payment_method_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some(id) => Some(
|
||||
Uuid::parse_str(id).map_err(|_| AppError::BadRequest("Ödeme türü geçersiz".into()))?,
|
||||
),
|
||||
};
|
||||
let rows = rows_of(req.rows)?;
|
||||
let r = bank_import::commit(&s.db, auth.household_id, auth.user_id, pm, &rows).await?;
|
||||
Ok(Json(ReportDto {
|
||||
entries: r.entries as u32,
|
||||
refunds: r.refunds as u32,
|
||||
incomes: r.incomes as u32,
|
||||
ignored: r.ignored as u32,
|
||||
duplicates: r.duplicates as u32,
|
||||
archives: r.archives,
|
||||
errors: r
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|(i, message)| RowError {
|
||||
index: i as u32,
|
||||
message,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod asset;
|
||||
mod auth;
|
||||
mod bank_import;
|
||||
mod budget;
|
||||
mod catalog;
|
||||
mod entry;
|
||||
@@ -97,6 +98,14 @@ pub fn router(state: AppState) -> Router {
|
||||
"/export/asset-transactions.csv",
|
||||
get(transfer::export_asset_txs),
|
||||
)
|
||||
.route(
|
||||
"/bank-import/preview",
|
||||
post(bank_import::preview).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/bank-import/commit",
|
||||
post(bank_import::commit).layer(DefaultBodyLimit::max(5 * 1024 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/import/{kind}",
|
||||
// Büyük CSV (≤ 20.000 satır) için gövde sınırı 5 MB
|
||||
|
||||
Reference in New Issue
Block a user