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:
2026-11-13 08:33:07 +03:00
parent 4d764689e2
commit d1a24afc3c
28 changed files with 1422 additions and 13 deletions
+221
View File
@@ -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(),
}))
}
+9
View File
@@ -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