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:
@@ -69,6 +69,8 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
vec![
|
||||
idx(doc! { "household_id": 1, "period_id": 1, "date": 1 }),
|
||||
idx(doc! { "household_id": 1, "date": 1 }),
|
||||
// Ekstre içe aktarmada tekrar tespiti
|
||||
idx(doc! { "household_id": 1, "import_key": 1 }),
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -83,6 +85,10 @@ fn definitions() -> Vec<(&'static str, Vec<IndexModel>)> {
|
||||
col::ASSET_TRANSACTIONS,
|
||||
vec![idx(doc! { "household_id": 1, "asset_id": 1, "date": 1 })],
|
||||
),
|
||||
(
|
||||
col::IMPORT_RULES,
|
||||
vec![idx(doc! { "household_id": 1, "pattern": 1 })],
|
||||
),
|
||||
(
|
||||
col::EXCHANGE_RATES,
|
||||
vec![idx(doc! { "code": 1, "household_id": 1, "rate_date": -1 })],
|
||||
|
||||
@@ -70,6 +70,8 @@ pub mod col {
|
||||
pub const ASSETS: &str = "assets";
|
||||
pub const ASSET_TRANSACTIONS: &str = "asset_transactions";
|
||||
pub const EXCHANGE_RATES: &str = "exchange_rates";
|
||||
/// Ekstre içe aktarmada öğrenilen açıklama → tür/kategori kuralları
|
||||
pub const IMPORT_RULES: &str = "import_rules";
|
||||
pub const SCHEMA_MIGRATIONS: &str = "schema_migrations";
|
||||
|
||||
/// Hane verisi taşıyan koleksiyonlar (`household_id` alanı var).
|
||||
@@ -87,5 +89,6 @@ pub mod col {
|
||||
NOTES,
|
||||
ASSETS,
|
||||
ASSET_TRANSACTIONS,
|
||||
IMPORT_RULES,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ pub enum PeriodStatus {
|
||||
/// Yazılabilir, ama "açık dönem" sayılmaz (yeni dönem açıkken de olabilir).
|
||||
/// "Yeniden kapat" ile devir yeniden hesaplanır.
|
||||
Correcting,
|
||||
/// Geçmiş (arşiv): ekstre içe aktarımıyla yıl başından beri olan aylar için açılır.
|
||||
/// Yalnızca kayıt tutar: plan/bütçe zorunlu değil, kapatılmaz, devir üretmez ve
|
||||
/// devir zincirinde atlanır; bütçe önerilerini ("önceki dönem", "son 3 ay") besler.
|
||||
Archived,
|
||||
}
|
||||
|
||||
impl PeriodStatus {
|
||||
@@ -24,6 +28,7 @@ impl PeriodStatus {
|
||||
PeriodStatus::Open => "open",
|
||||
PeriodStatus::Closed => "closed",
|
||||
PeriodStatus::Correcting => "correcting",
|
||||
PeriodStatus::Archived => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
//! Banka / kredi kartı ekstresi içe aktarma (kullanıcı isteği; Faz 9 Adım 4).
|
||||
//!
|
||||
//! Dosya tarayıcıda okunur (Excel/CSV) ve sütunları eşleştirilir; buraya yalnızca
|
||||
//! **tarih, açıklama, tutar, yön** gelir. Her satır bir türe ayrılır:
|
||||
//! - `expense` → harcama (kategori gerekir)
|
||||
//! - `refund` → **iade**: aynı kategoride eksi tutarlı kayıt (harcamadan düşer)
|
||||
//! - `income` → dönemin planına **gelir** kalemi
|
||||
//! - `ignore` → aktarılmaz (kart borcu ödemesi, kendi hesaplar arası virman…)
|
||||
//!
|
||||
//! Dönem: satırın tarihini içeren yazılabilir dönem; yoksa takvim ayı kadar **arşiv** dönemi
|
||||
//! açılır (var olan dönemlerle çakışmayacak şekilde kırpılır). Kapalı dönem ve gelecek tarih → hata.
|
||||
//! Tekrar: `import_key` (tarih|yön|tutar|sadeleştirilmiş açıklama) — aynı ekstre ya da örtüşen
|
||||
//! iki ekstre yüklense de çift kayıt olmaz. Öneri: hanenin öğrenilmiş kuralları (açıklamanın ilk
|
||||
//! iki anlamlı kelimesi), yoksa hazır ipuçları ("kredi kartı ödemesi" → yoksay), yoksa yön.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mongodb::{
|
||||
Database,
|
||||
bson::{Bson, Document, Uuid, doc},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use time::Date;
|
||||
|
||||
use crate::{
|
||||
db::col,
|
||||
error::{AppError, AppResult},
|
||||
models::{
|
||||
common::{new_meta, now, scope},
|
||||
period::Period,
|
||||
plan_item::PlanKind,
|
||||
},
|
||||
services::{
|
||||
calendar::{format_date, parse_date, today_tr},
|
||||
catalog::{self, Catalog},
|
||||
entry::MAX_DESCRIPTION,
|
||||
period,
|
||||
plan::{self, NewItem},
|
||||
},
|
||||
};
|
||||
|
||||
pub const MAX_ROWS: usize = 5_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Source {
|
||||
/// Kredi kartı: çıkış = harcama, giriş = iade ya da ödeme
|
||||
Card,
|
||||
/// Banka hesabı: çıkış = harcama, giriş = gelir
|
||||
Account,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Direction {
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Kind {
|
||||
Expense,
|
||||
Refund,
|
||||
Income,
|
||||
Ignore,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Expense => "expense",
|
||||
Kind::Refund => "refund",
|
||||
Kind::Income => "income",
|
||||
Kind::Ignore => "ignore",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Row {
|
||||
pub date: String,
|
||||
pub description: String,
|
||||
/// Kuruş, > 0
|
||||
pub amount: i64,
|
||||
pub direction: Direction,
|
||||
/// Commit'te kullanıcının seçimi
|
||||
pub kind: Option<Kind>,
|
||||
pub category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
// ---------- Metin sadeleştirme ----------
|
||||
|
||||
/// Türkçe harfleri ASCII'ye indir, küçült (bankalar çoğu zaman "ODEME", "SUT" yazar)
|
||||
pub fn fold(s: &str) -> String {
|
||||
s.chars()
|
||||
// "İ".to_lowercase() = "i" + birleşik nokta (U+0307) → önce doğrudan "i"
|
||||
.map(|c| if c == 'İ' { 'i' } else { c })
|
||||
.flat_map(|c| c.to_lowercase())
|
||||
.filter(|c| *c != '\u{307}')
|
||||
.map(|c| match c {
|
||||
'ı' | 'i' | 'î' => 'i',
|
||||
'ş' => 's',
|
||||
'ğ' => 'g',
|
||||
'ü' | 'û' => 'u',
|
||||
'ö' => 'o',
|
||||
'ç' => 'c',
|
||||
'â' => 'a',
|
||||
c => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Anlamlı kelimeler: harf/rakam dışı ayırıcı; yalnızca rakam ve tek harf atılır
|
||||
fn words(desc: &str) -> Vec<String> {
|
||||
fold(desc)
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|w| w.len() >= 2 && !w.chars().all(|c| c.is_ascii_digit()))
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kural anahtarı: ilk iki anlamlı kelime (`MIGROS 1234 ISTANBUL TR` → `migros istanbul`)
|
||||
pub fn rule_key(desc: &str) -> String {
|
||||
words(desc)
|
||||
.into_iter()
|
||||
.take(2)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş)
|
||||
pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String {
|
||||
let d = match dir {
|
||||
Direction::In => "in",
|
||||
Direction::Out => "out",
|
||||
};
|
||||
format!("{date}|{d}|{amount}|{}", words(desc).join(" "))
|
||||
}
|
||||
|
||||
/// Hazır öneri (kural yoksa): ödeme/virman → yoksay; kartta giriş → iade; hesapta giriş → gelir
|
||||
pub fn default_kind(source: Source, dir: Direction, desc: &str) -> Kind {
|
||||
let f = words(desc).join(" ");
|
||||
let transfer = [
|
||||
"kredi karti",
|
||||
"kart odeme",
|
||||
"kk odeme",
|
||||
"virman",
|
||||
"hesaplar arasi",
|
||||
"kendi hesab",
|
||||
"hesabima",
|
||||
"borc odeme",
|
||||
"odemeniz icin",
|
||||
"tahsilat",
|
||||
]
|
||||
.iter()
|
||||
.any(|h| f.contains(h));
|
||||
match (source, dir) {
|
||||
_ if transfer => Kind::Ignore,
|
||||
(_, Direction::Out) => Kind::Expense,
|
||||
(Source::Card, Direction::In) => {
|
||||
if f.contains("odeme") {
|
||||
Kind::Ignore
|
||||
} else {
|
||||
Kind::Refund
|
||||
}
|
||||
}
|
||||
(Source::Account, Direction::In) => Kind::Income,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Dönem ----------
|
||||
|
||||
fn first_of_month(d: Date) -> Date {
|
||||
d.replace_day(1).expect("1. gün")
|
||||
}
|
||||
|
||||
fn next_month(d: Date) -> Date {
|
||||
let (y, m) = if d.month() == time::Month::December {
|
||||
(d.year() + 1, time::Month::January)
|
||||
} else {
|
||||
(d.year(), d.month().next())
|
||||
};
|
||||
Date::from_calendar_date(y, m, 1).expect("geçerli ay")
|
||||
}
|
||||
|
||||
/// Satırın dönemi
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Target {
|
||||
/// Var olan yazılabilir dönem (açık, düzeltmede ya da arşiv)
|
||||
Existing {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
},
|
||||
/// Açılacak arşiv dönemi [start, end)
|
||||
Archive {
|
||||
start: String,
|
||||
end: String,
|
||||
},
|
||||
Blocked(String),
|
||||
}
|
||||
|
||||
/// Tarih → dönem: içeren dönem; yoksa takvim ayı, var olan dönemlerin dışında kalan kısmı
|
||||
pub fn target(periods: &[Period], date: &str, today: &str) -> Target {
|
||||
if date > today {
|
||||
return Target::Blocked("Gelecek tarihli satır".into());
|
||||
}
|
||||
if let Some(p) = periods
|
||||
.iter()
|
||||
.find(|p| p.start_date.as_str() <= date && date < p.end_date.as_str())
|
||||
{
|
||||
return if p.status.is_writable() {
|
||||
Target::Existing {
|
||||
id: p.id,
|
||||
name: p.name.clone(),
|
||||
}
|
||||
} else {
|
||||
Target::Blocked(format!(
|
||||
"Dönem kapalı ({}); düzeltme için yeniden açın",
|
||||
p.name
|
||||
))
|
||||
};
|
||||
}
|
||||
let d = parse_date(date).expect("doğrulanmış tarih");
|
||||
let (ms, me) = (format_date(first_of_month(d)), format_date(next_month(d)));
|
||||
// Ayın içinde, tarihten önce biten en geç dönem / tarihten sonra başlayan en erken dönem
|
||||
let start = periods
|
||||
.iter()
|
||||
.map(|p| p.end_date.as_str())
|
||||
.filter(|e| *e <= date && *e > ms.as_str())
|
||||
.max()
|
||||
.map_or(ms.clone(), str::to_string);
|
||||
let end = periods
|
||||
.iter()
|
||||
.map(|p| p.start_date.as_str())
|
||||
.filter(|s| *s > date && *s < me.as_str())
|
||||
.min()
|
||||
.map_or(me.clone(), str::to_string);
|
||||
Target::Archive { start, end }
|
||||
}
|
||||
|
||||
// ---------- Kurallar ----------
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct Rule {
|
||||
pattern: String,
|
||||
kind: Kind,
|
||||
#[serde(default)]
|
||||
category_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
async fn rules(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, Rule>> {
|
||||
let mut c = db
|
||||
.collection::<Rule>(col::IMPORT_RULES)
|
||||
.find(scope(household_id))
|
||||
.await?;
|
||||
let mut out = HashMap::new();
|
||||
while c.advance().await? {
|
||||
let r = c.deserialize_current()?;
|
||||
out.insert(r.pattern.clone(), r);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Var olan içe aktarma anahtarları (harcama + plan kalemi) → adet
|
||||
async fn existing_keys(db: &Database, household_id: Uuid) -> AppResult<HashMap<String, usize>> {
|
||||
let mut out = HashMap::new();
|
||||
for coll in [col::ENTRIES, col::PLAN_ITEMS] {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("import_key", doc! { "$type": "string" });
|
||||
let mut c = db
|
||||
.collection::<Document>(coll)
|
||||
.find(f)
|
||||
.projection(doc! { "import_key": 1 })
|
||||
.await?;
|
||||
while c.advance().await? {
|
||||
if let Ok(k) = c.current().get_str("import_key") {
|
||||
*out.entry(k.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn take(m: &mut HashMap<String, usize>, k: &str) -> bool {
|
||||
match m.get_mut(k) {
|
||||
Some(n) if *n > 0 => {
|
||||
*n -= 1;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(r: &Row) -> Result<(), String> {
|
||||
parse_date(&r.date).ok_or_else(|| format!("Tarih geçersiz: {}", r.date))?;
|
||||
if r.amount <= 0 {
|
||||
return Err("Tutar sıfırdan büyük olmalı".into());
|
||||
}
|
||||
period::validate_amount(r.amount).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------- Önizleme ----------
|
||||
|
||||
pub struct PreviewRow {
|
||||
pub kind: Kind,
|
||||
pub category_id: Option<Uuid>,
|
||||
/// Öneri hanenin öğrenilmiş kuralından mı?
|
||||
pub from_rule: bool,
|
||||
pub duplicate: bool,
|
||||
pub target: Target,
|
||||
}
|
||||
|
||||
pub struct Preview {
|
||||
pub rows: Vec<PreviewRow>,
|
||||
/// Açılacak arşiv dönemleri [start, end)
|
||||
pub archives: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
fn check_rows(rows: &[Row]) -> AppResult<()> {
|
||||
if rows.is_empty() {
|
||||
return Err(AppError::BadRequest("Aktarılacak satır yok".into()));
|
||||
}
|
||||
if rows.len() > MAX_ROWS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Bir seferde en fazla {MAX_ROWS} satır aktarılabilir"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn preview(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
source: Source,
|
||||
rows: &[Row],
|
||||
) -> AppResult<Preview> {
|
||||
check_rows(rows)?;
|
||||
let periods = period::list(db, household_id).await?;
|
||||
let rules = rules(db, household_id).await?;
|
||||
let mut keys = existing_keys(db, household_id).await?;
|
||||
let today = format_date(today_tr());
|
||||
let mut archives: Vec<(String, String)> = Vec::new();
|
||||
let out = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let target = match validate(r) {
|
||||
Err(e) => Target::Blocked(e),
|
||||
Ok(()) => target(&periods, &r.date, &today),
|
||||
};
|
||||
if let Target::Archive { start, end } = &target
|
||||
&& !archives.iter().any(|(s, _)| s == start)
|
||||
{
|
||||
archives.push((start.clone(), end.clone()));
|
||||
}
|
||||
let duplicate = matches!(target, Target::Existing { .. } | Target::Archive { .. })
|
||||
&& take(
|
||||
&mut keys,
|
||||
&import_key(&r.date, r.direction, r.amount, &r.description),
|
||||
);
|
||||
let rule = rules.get(&rule_key(&r.description));
|
||||
PreviewRow {
|
||||
kind: rule.map_or_else(
|
||||
|| default_kind(source, r.direction, &r.description),
|
||||
|x| x.kind,
|
||||
),
|
||||
category_id: rule.and_then(|x| x.category_id),
|
||||
from_rule: rule.is_some(),
|
||||
duplicate,
|
||||
target,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
archives.sort();
|
||||
Ok(Preview {
|
||||
rows: out,
|
||||
archives,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Aktarma ----------
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Report {
|
||||
pub entries: usize,
|
||||
pub refunds: usize,
|
||||
pub incomes: usize,
|
||||
pub ignored: usize,
|
||||
pub duplicates: usize,
|
||||
/// Açılan arşiv dönemlerinin adları
|
||||
pub archives: Vec<String>,
|
||||
/// (satır sırası 0'dan, neden)
|
||||
pub errors: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
pub async fn commit(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
user_id: Uuid,
|
||||
payment_method_id: Option<Uuid>,
|
||||
rows: &[Row],
|
||||
) -> AppResult<Report> {
|
||||
check_rows(rows)?;
|
||||
if let Some(pm) = payment_method_id {
|
||||
catalog::find(db, household_id, Catalog::PaymentMethods, pm)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::BadRequest("Ödeme türü bulunamadı".into()))?;
|
||||
}
|
||||
let cats = catalog::list(db, Catalog::Categories, household_id, true).await?;
|
||||
let mut periods = period::list(db, household_id).await?;
|
||||
let mut keys = existing_keys(db, household_id).await?;
|
||||
let today = format_date(today_tr());
|
||||
let mut report = Report::default();
|
||||
let mut entries: Vec<Document> = Vec::new();
|
||||
let mut incomes: Vec<(usize, Uuid, NewItem, String)> = Vec::new();
|
||||
let mut learned: HashMap<String, (Kind, Option<Uuid>)> = HashMap::new();
|
||||
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
let kind = r.kind.unwrap_or(Kind::Ignore);
|
||||
let pattern = rule_key(&r.description);
|
||||
if kind == Kind::Ignore {
|
||||
report.ignored += 1;
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, None));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = validate(r) {
|
||||
report.errors.push((i, e));
|
||||
continue;
|
||||
}
|
||||
let key = import_key(&r.date, r.direction, r.amount, &r.description);
|
||||
let pid = match target(&periods, &r.date, &today) {
|
||||
Target::Blocked(e) => {
|
||||
report.errors.push((i, e));
|
||||
continue;
|
||||
}
|
||||
Target::Existing { id, .. } => id,
|
||||
Target::Archive { start, end } => {
|
||||
let p = period::create_archive(
|
||||
db,
|
||||
household_id,
|
||||
parse_date(&start).expect("tarih"),
|
||||
parse_date(&end).expect("tarih"),
|
||||
)
|
||||
.await?;
|
||||
report.archives.push(p.name.clone());
|
||||
let id = p.id;
|
||||
periods.push(p);
|
||||
id
|
||||
}
|
||||
};
|
||||
if take(&mut keys, &key) {
|
||||
report.duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
let desc: String = r.description.trim().chars().take(MAX_DESCRIPTION).collect();
|
||||
match kind {
|
||||
Kind::Expense | Kind::Refund => {
|
||||
let Some(cat) = r.category_id.and_then(|c| cats.iter().find(|x| x.id == c)) else {
|
||||
report.errors.push((i, "Kategori seçin".into()));
|
||||
continue;
|
||||
};
|
||||
let Some(group) = cat.group else {
|
||||
report.errors.push((i, "Kategorinin grubu yok".into()));
|
||||
continue;
|
||||
};
|
||||
let amount = if kind == Kind::Refund {
|
||||
-r.amount
|
||||
} else {
|
||||
r.amount
|
||||
};
|
||||
let mut d = new_meta(household_id);
|
||||
d.insert("period_id", pid);
|
||||
d.insert("date", &r.date);
|
||||
d.insert("amount", amount);
|
||||
d.insert("group", group.as_str());
|
||||
d.insert("category_id", cat.id);
|
||||
d.insert(
|
||||
"payment_method_id",
|
||||
payment_method_id.map_or(Bson::Null, Bson::from),
|
||||
);
|
||||
d.insert(
|
||||
"description",
|
||||
if desc.is_empty() {
|
||||
Bson::Null
|
||||
} else {
|
||||
Bson::String(desc)
|
||||
},
|
||||
);
|
||||
d.insert("created_by", user_id);
|
||||
d.insert("import_key", &key);
|
||||
entries.push(d);
|
||||
if kind == Kind::Refund {
|
||||
report.refunds += 1;
|
||||
} else {
|
||||
report.entries += 1;
|
||||
}
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, Some(cat.id)));
|
||||
}
|
||||
}
|
||||
Kind::Income => {
|
||||
// Plan kalemi adı en fazla 60 karakter
|
||||
let name: String = if desc.is_empty() {
|
||||
"Gelir".into()
|
||||
} else {
|
||||
desc.chars().take(60).collect()
|
||||
};
|
||||
incomes.push((
|
||||
i,
|
||||
pid,
|
||||
NewItem {
|
||||
kind: PlanKind::Income,
|
||||
name,
|
||||
amount: r.amount,
|
||||
date: Some(r.date.clone()),
|
||||
},
|
||||
key,
|
||||
));
|
||||
if !pattern.is_empty() {
|
||||
learned.insert(pattern, (kind, None));
|
||||
}
|
||||
}
|
||||
Kind::Ignore => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
if !entries.is_empty() {
|
||||
db.collection::<Document>(col::ENTRIES)
|
||||
.insert_many(entries)
|
||||
.await?;
|
||||
}
|
||||
for (i, pid, item, key) in incomes {
|
||||
match plan::create(db, household_id, pid, item).await {
|
||||
Ok(p) => {
|
||||
db.collection::<Document>(col::PLAN_ITEMS)
|
||||
.update_one(doc! { "_id": p.id }, doc! { "$set": { "import_key": key } })
|
||||
.await?;
|
||||
report.incomes += 1;
|
||||
}
|
||||
Err(AppError::BadRequest(m)) | Err(AppError::Conflict(m)) => report.errors.push((i, m)),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Kurallar: bu aktarımdaki seçimler sonraki ekstreler için öneri olur
|
||||
let ts = now();
|
||||
for (pattern, (kind, category_id)) in learned {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("pattern", &pattern);
|
||||
db.collection::<Document>(col::IMPORT_RULES)
|
||||
.update_one(
|
||||
f,
|
||||
doc! {
|
||||
"$set": {
|
||||
"kind": kind.as_str(),
|
||||
"category_id": category_id.map_or(Bson::Null, Bson::from),
|
||||
"updated_at": ts,
|
||||
},
|
||||
"$inc": { "uses": 1, "version": 1 },
|
||||
"$setOnInsert": { "_id": crate::models::common::new_id(), "created_at": ts },
|
||||
},
|
||||
)
|
||||
.upsert(true)
|
||||
.await?;
|
||||
}
|
||||
report.errors.sort_by_key(|(i, _)| *i);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folding_and_keys() {
|
||||
assert_eq!(fold("ÖDEME Şükrü İĞNE"), "odeme sukru igne");
|
||||
assert_eq!(rule_key("MIGROS 1234 ISTANBUL TR"), "migros istanbul");
|
||||
assert_eq!(rule_key("A101 MAGAZACILIK*456"), "a101 magazacilik");
|
||||
assert_eq!(rule_key("12/11 *"), "");
|
||||
assert_eq!(
|
||||
import_key("2026-11-13", Direction::Out, 4590, " Migros ŞUBE 12 "),
|
||||
"2026-11-13|out|4590|migros sube"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_kinds() {
|
||||
use Direction::*;
|
||||
use Source::*;
|
||||
assert_eq!(default_kind(Card, Out, "MIGROS ISTANBUL"), Kind::Expense);
|
||||
assert_eq!(default_kind(Card, In, "IADE MIGROS"), Kind::Refund);
|
||||
assert_eq!(
|
||||
default_kind(Card, In, "ÖDEMENİZ İÇİN TEŞEKKÜRLER"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(default_kind(Account, In, "MAAŞ ÖDEMESİ ACME"), Kind::Income);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "KREDİ KARTI ÖDEMESİ"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "Hesaplar arası virman"),
|
||||
Kind::Ignore
|
||||
);
|
||||
assert_eq!(
|
||||
default_kind(Account, Out, "ELEKTRIK FATURASI"),
|
||||
Kind::Expense
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -553,7 +553,8 @@ async fn split_percentages(db: &Database, household_id: Uuid) -> AppResult<Vec<u
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Bu dönemden önceki en yeni `n` kapalı dönemin kimlikleri.
|
||||
/// Bu dönemden önceki en yeni `n` kapalı **ya da arşiv** dönemin kimlikleri
|
||||
/// (arşiv: ekstreden aktarılan geçmiş aylar → öneriler gerçek harcamaya dayanır).
|
||||
async fn previous_closed(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
@@ -562,7 +563,10 @@ async fn previous_closed(
|
||||
) -> AppResult<Vec<Uuid>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$lt": &p.start_date });
|
||||
f.insert("status", PeriodStatus::Closed.as_str());
|
||||
f.insert(
|
||||
"status",
|
||||
doc! { "$in": [PeriodStatus::Closed.as_str(), PeriodStatus::Archived.as_str()] },
|
||||
);
|
||||
let mut cursor = db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find(f)
|
||||
|
||||
@@ -90,6 +90,11 @@ pub async fn close(
|
||||
version: Option<i32>,
|
||||
) -> AppResult<Closing> {
|
||||
let p = period::get(db, household_id, period_id).await?;
|
||||
if p.status == PeriodStatus::Archived {
|
||||
return Err(AppError::Conflict(
|
||||
"Arşiv dönemi kapatılmaz; yalnızca kayıt tutar".into(),
|
||||
));
|
||||
}
|
||||
if p.status == PeriodStatus::Closed {
|
||||
return Err(AppError::Conflict("Bu dönem zaten kapalı".into()));
|
||||
}
|
||||
@@ -200,7 +205,11 @@ pub async fn reopen(
|
||||
}
|
||||
let mut later = scope(household_id);
|
||||
later.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
later.insert("status", doc! { "$ne": PeriodStatus::Open.as_str() });
|
||||
// Arşiv dönemleri devir üretmediği için engel değildir
|
||||
later.insert(
|
||||
"status",
|
||||
doc! { "$nin": [PeriodStatus::Open.as_str(), PeriodStatus::Archived.as_str()] },
|
||||
);
|
||||
if db
|
||||
.collection::<Document>(col::PERIODS)
|
||||
.find_one(later)
|
||||
@@ -242,6 +251,8 @@ pub async fn reopen(
|
||||
async fn next_period(db: &Database, household_id: Uuid, p: &Period) -> AppResult<Option<Period>> {
|
||||
let mut f = scope(household_id);
|
||||
f.insert("start_date", doc! { "$gte": &p.end_date });
|
||||
// Devir arşiv dönemine bağlanmaz (arşiv devir zincirinde yok)
|
||||
f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
|
||||
Ok(db
|
||||
.collection::<Period>(col::PERIODS)
|
||||
.find_one(f)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Günlük defter: harcama kayıtları (KAKEIBO_PROMPT §8.4).
|
||||
//!
|
||||
//! Kurallar:
|
||||
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı.
|
||||
//! - Tutar kuruş, > 0. Tarih kaydın döneminin içinde olmalı. İstisna: ekstreden gelen **iade**
|
||||
//! kayıtları eksi tutarlıdır (kategorinin harcamasından düşer); düzenlemede eksi kalır.
|
||||
//! - Grup **kategoriden** alınır (istemci grup göndermez) → kategori ile grup hiç çelişmez.
|
||||
//! - Kategori ve ödeme türü bu haneye ait olmalı; yeni seçimde **aktif** olmalı
|
||||
//! (pasife alınmış kategoride kalan eski kayıt düzenlenebilir).
|
||||
@@ -146,7 +147,16 @@ pub async fn update(
|
||||
set.insert("date", clean_date(&date, &p)?);
|
||||
}
|
||||
if let Some(amount) = ch.amount {
|
||||
set.insert("amount", clean_amount(amount)?);
|
||||
// İade kaydı (ekstreden, eksi tutar) iade olarak kalır: tutar eksi girilir
|
||||
let amount = if current.amount < 0 {
|
||||
if amount >= 0 {
|
||||
return Err(AppError::BadRequest("İade tutarı eksi olmalı".into()));
|
||||
}
|
||||
-clean_amount(-amount)?
|
||||
} else {
|
||||
clean_amount(amount)?
|
||||
};
|
||||
set.insert("amount", amount);
|
||||
}
|
||||
if let Some(cid) = ch.category_id {
|
||||
let group = category_group(db, household_id, cid, Some(current.category_id)).await?;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod account;
|
||||
pub mod asset;
|
||||
pub mod bank_import;
|
||||
pub mod budget;
|
||||
pub mod calendar;
|
||||
pub mod catalog;
|
||||
|
||||
@@ -110,6 +110,8 @@ pub async fn create(db: &Database, household_id: Uuid, p: NewPeriod) -> AppResul
|
||||
// Bir önceki dönem (devir ve "önceki aydan kopyala" için)
|
||||
let mut prev_f = scope(household_id);
|
||||
prev_f.insert("start_date", doc! { "$lt": format_date(start) });
|
||||
// Arşiv dönemleri devir zincirinde yok
|
||||
prev_f.insert("status", doc! { "$ne": PeriodStatus::Archived.as_str() });
|
||||
let previous = coll(db)
|
||||
.find_one(prev_f)
|
||||
.sort(doc! { "start_date": -1 })
|
||||
@@ -273,6 +275,42 @@ fn clean_name(name: Option<&str>, start: Date, end: Date) -> AppResult<String> {
|
||||
|
||||
/// Başka bir dönemle çakışma: `mevcut.başlangıç < yeni.bitiş && mevcut.bitiş > yeni.başlangıç`
|
||||
/// (`YYYY-MM-DD` metinleri sözlük sırasıyla karşılaştırılabilir).
|
||||
/// Geçmiş (arşiv) dönem açar: ekstre içe aktarımında dönemi olmayan aylar için.
|
||||
/// Açık dönem kuralına takılmaz (arşiv açık sayılmaz); çakışma yine yasak. Plan, bütçe, devir yok.
|
||||
pub async fn create_archive(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
start: Date,
|
||||
end: Date,
|
||||
) -> AppResult<Period> {
|
||||
if end <= start {
|
||||
return Err(AppError::BadRequest(
|
||||
"Arşiv dönemi tarihleri geçersiz".into(),
|
||||
));
|
||||
}
|
||||
ensure_no_overlap(db, household_id, start, end, None).await?;
|
||||
let mut d = new_meta(household_id);
|
||||
let id = d.get("_id").cloned().unwrap_or(Bson::Null);
|
||||
d.insert("name", clean_name(None, start, end)?);
|
||||
d.insert("start_date", format_date(start));
|
||||
d.insert("end_date", format_date(end));
|
||||
d.insert("status", PeriodStatus::Archived.as_str());
|
||||
d.insert("savings_goal", 0_i64);
|
||||
d.insert("reflection_md", Bson::Null);
|
||||
d.insert("to_savings", Bson::Null);
|
||||
d.insert("previous_period_id", Bson::Null);
|
||||
d.insert("closed_at", Bson::Null);
|
||||
d.insert("closed_by", Bson::Null);
|
||||
d.insert("reopened_at", Bson::Null);
|
||||
db.collection::<Document>(col::PERIODS)
|
||||
.insert_one(d)
|
||||
.await?;
|
||||
coll(db)
|
||||
.find_one(doc! { "_id": id })
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal("arşiv dönemi okunamadı".into()))
|
||||
}
|
||||
|
||||
async fn ensure_no_overlap(
|
||||
db: &Database,
|
||||
household_id: Uuid,
|
||||
|
||||
Reference in New Issue
Block a user