233907e3d9
- Ayarlar → Ekstre içe aktar: Excel (.xlsx/.xls) ve CSV, dosya tarayıcıda okunur - Başlık satırı ve sütunlar otomatik bulunur; tek sütun / Borç-Alacak; eşleştirme profil olarak saklanır - Gözden geçirme: tür + kategori önerisi, aynı açıklamalara otomatik yayılma, tekrar/engelli satırlar - Öğrenilen kurallarda ilk kelime yedeği (MIGROS İSTANBUL → MIGROS KADIKÖY) - Defterde iade etiketi; iade düzenlemede eksi kalır - SheetJS resmi paketi frontend/vendor altında (npm 12 uzak paket indirmeyi engeller) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
688 lines
21 KiB
Rust
688 lines
21 KiB
Rust
//! 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(" ")
|
||
}
|
||
|
||
/// İlk kelime eşleşmesinde kullanılmayacak genel sözcükler (satıcı adı değil)
|
||
const GENERIC: &[&str] = &[
|
||
"iade",
|
||
"odeme",
|
||
"pos",
|
||
"isyeri",
|
||
"eft",
|
||
"havale",
|
||
"fast",
|
||
"virman",
|
||
"kredi",
|
||
"kart",
|
||
"hesap",
|
||
"para",
|
||
"borc",
|
||
"harcama",
|
||
"alisveris",
|
||
"nakit",
|
||
"atm",
|
||
"taksit",
|
||
"ucret",
|
||
];
|
||
|
||
/// Öneri: tam kural (ilk 2 kelime); yoksa ilk kelimesi aynı kurallar hepsi aynı şeyi
|
||
/// söylüyorsa o (ör. "migros istanbul" öğrenildi → "MIGROS KADIKOY" de Market)
|
||
fn suggest<'a>(rules: &'a HashMap<String, Rule>, desc: &str) -> Option<&'a Rule> {
|
||
if let Some(r) = rules.get(&rule_key(desc)) {
|
||
return Some(r);
|
||
}
|
||
let first = words(desc).into_iter().next()?;
|
||
if first.len() < 3 || GENERIC.contains(&first.as_str()) {
|
||
return None;
|
||
}
|
||
let mut same = rules
|
||
.values()
|
||
.filter(|r| r.pattern.split(' ').next() == Some(first.as_str()));
|
||
let r = same.next()?;
|
||
same.all(|x| x.kind == r.kind && x.category_id == r.category_id)
|
||
.then_some(r)
|
||
}
|
||
|
||
/// 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 = suggest(&rules, &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 suggestion_falls_back_to_first_word() {
|
||
let market = Uuid::new();
|
||
let ulasim = Uuid::new();
|
||
let rule = |p: &str, c: Uuid| {
|
||
(
|
||
p.to_string(),
|
||
Rule {
|
||
pattern: p.into(),
|
||
kind: Kind::Expense,
|
||
category_id: Some(c),
|
||
},
|
||
)
|
||
};
|
||
let mut rules: HashMap<String, Rule> =
|
||
[rule("migros istanbul", market), rule("iade migros", market)].into();
|
||
assert_eq!(
|
||
suggest(&rules, "MIGROS 99 ISTANBUL").unwrap().category_id,
|
||
Some(market),
|
||
"tam"
|
||
);
|
||
assert_eq!(
|
||
suggest(&rules, "MİGROS KADIKÖY").unwrap().category_id,
|
||
Some(market),
|
||
"ilk kelime"
|
||
);
|
||
assert!(suggest(&rules, "IADE SHELL").is_none(), "genel ilk kelime");
|
||
assert!(suggest(&rules, "A101 SUBE").is_none());
|
||
// İlk kelimesi aynı ama farklı kategoriler → öneri yok
|
||
rules.extend([rule("migros jet", ulasim)]);
|
||
assert!(suggest(&rules, "MIGROS ANKARA").is_none());
|
||
}
|
||
|
||
#[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
|
||
);
|
||
}
|
||
}
|