feat(ekstre): banka/kart ekstresi içe aktarma ekranı

- 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>
This commit is contained in:
2026-11-13 08:58:04 +03:00
parent d1a24afc3c
commit 233907e3d9
24 changed files with 1493 additions and 8 deletions
+75 -1
View File
@@ -129,6 +129,47 @@ pub fn rule_key(desc: &str) -> String {
.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 {
@@ -359,7 +400,7 @@ pub async fn preview(
&mut keys,
&import_key(&r.date, r.direction, r.amount, &r.description),
);
let rule = rules.get(&rule_key(&r.description));
let rule = suggest(&rules, &r.description);
PreviewRow {
kind: rule.map_or_else(
|| default_kind(source, r.direction, &r.description),
@@ -586,6 +627,39 @@ mod tests {
);
}
#[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::*;