use super::funejson::Value; use super::dates_days_between::days_between; use super::math_round_div::round_div; use super::money_amount::money_from_value; fn reduce(reference: &str) -> Vec { reference .bytes() .filter(|b| b.is_ascii_alphanumeric()) .map(|b| b.to_ascii_uppercase()) .collect() } /// Sørensen-Dice on adjacent character pairs, 0 to 10000, rounded down. fn similarity(a: &str, b: &str) -> i64 { let x = reduce(a); let y = reduce(b); if !x.is_empty() && x == y { return 10000; } let left: Vec<&[u8]> = x.windows(2).collect(); let right: Vec<&[u8]> = y.windows(2).collect(); if left.is_empty() || right.is_empty() { return 0; } let mut used = vec![false; right.len()]; let mut shared: i64 = 0; for pair in &left { if let Some(at) = (0..right.len()).find(|&i| !used[i] && right[i] == *pair) { used[at] = true; shared += 1; } } round_div(2 * shared * 10000, (left.len() + right.len()) as i64, "down") } fn check_ids(lines: &[ReconcileLine], side: &str) { for (i, line) in lines.iter().enumerate() { if lines[..i].iter().any(|other| other.id == line.id) { panic!("duplicate {} id \"{}\"", side, line.id); } } } /// Pair bank lines with ledger entries: equal amount, dates within the window, /// references alike enough, best pairs first and ties broken by input order. /// /// # Panics /// Panics on a negative window, a similarity outside 0..=10000, duplicate ids /// or malformed dates. pub fn match_bank_lines( bank_lines: &[ReconcileLine], ledger_entries: &[ReconcileLine], date_window_days: i64, min_similarity: i64, ) -> Reconciliation { if date_window_days < 0 { panic!("dateWindowDays must be 0 or more, received {}", date_window_days); } if !(0..=10000).contains(&min_similarity) { panic!("minSimilarity must be 0 to 10000, received {}", min_similarity); } check_ids(bank_lines, "bank line"); check_ids(ledger_entries, "ledger entry"); // (score, days, bank index, ledger index) let mut candidates: Vec<(i64, i64, usize, usize)> = Vec::new(); for (b, bank) in bank_lines.iter().enumerate() { for (l, entry) in ledger_entries.iter().enumerate() { let days = days_between(&entry.date, &bank.date); if bank.amount.currency != entry.amount.currency || bank.amount.minor != entry.amount.minor { continue; } if days.abs() > date_window_days { continue; } let score = similarity(&bank.reference, &entry.reference); if score < min_similarity { continue; } candidates.push((score, days, b, l)); } } candidates.sort_by(|p, q| { q.0.cmp(&p.0) .then(p.1.abs().cmp(&q.1.abs())) .then(p.2.cmp(&q.2)) .then(p.3.cmp(&q.3)) }); let mut bank_match: Vec> = vec![None; bank_lines.len()]; let mut ledger_used = vec![false; ledger_entries.len()]; for (score, days, b, l) in candidates { if bank_match[b].is_some() || ledger_used[l] { continue; } bank_match[b] = Some(ReconcileMatch { bank_id: bank_lines[b].id.clone(), ledger_id: ledger_entries[l].id.clone(), day_difference: days, similarity: score, }); ledger_used[l] = true; } let mut matches = Vec::new(); let mut unmatched_bank = Vec::new(); for (b, found) in bank_match.into_iter().enumerate() { match found { Some(m) => matches.push(m), None => unmatched_bank.push(bank_lines[b].id.clone()), } } let unmatched_ledger = ledger_entries .iter() .enumerate() .filter(|(l, _)| !ledger_used[*l]) .map(|(_, e)| e.id.clone()) .collect(); Reconciliation { matches, unmatched_bank, unmatched_ledger, } } pub fn reconcile_line_from_value(v: &Value) -> ReconcileLine { ReconcileLine { id: v.get("id").as_str().to_string(), date: v.get("date").as_str().to_string(), amount: money_from_value(v.get("amount")), reference: v.get("reference").as_str().to_string(), } } fn strings(items: &[String]) -> Value { Value::Arr(items.iter().map(|s| Value::str(s)).collect()) } pub fn reconciliation_to_value(r: &Reconciliation) -> Value { Value::obj(vec![ ( "matches", Value::Arr( r.matches .iter() .map(|m| { Value::obj(vec![ ("bankId", Value::str(&m.bank_id)), ("ledgerId", Value::str(&m.ledger_id)), ("dayDifference", Value::Int(m.day_difference)), ("similarity", Value::Int(m.similarity)), ]) }) .collect(), ), ), ("unmatchedBank", strings(&r.unmatched_bank)), ("unmatchedLedger", strings(&r.unmatched_ledger)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let bank: Vec = args[0].as_arr().iter().map(reconcile_line_from_value).collect(); let ledger: Vec = args[1].as_arr().iter().map(reconcile_line_from_value).collect(); reconciliation_to_value(&match_bank_lines(&bank, &ledger, args[2].as_i64(), args[3].as_i64())) }