1use crate::types::{
9 ExceptionType, MatchType, MatchedPair, ReconciliationException, ReconciliationItem,
10 ReconciliationResult, ReconciliationStats,
11};
12use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone)]
23pub struct GLReconciliation {
24 metadata: KernelMetadata,
25}
26
27impl Default for GLReconciliation {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33impl GLReconciliation {
34 #[must_use]
36 pub fn new() -> Self {
37 Self {
38 metadata: KernelMetadata::batch("accounting/gl-reconciliation", Domain::Accounting)
39 .with_description("General ledger reconciliation")
40 .with_throughput(20_000)
41 .with_latency_us(100.0),
42 }
43 }
44
45 pub fn reconcile(
47 source_items: &[ReconciliationItem],
48 target_items: &[ReconciliationItem],
49 config: &ReconciliationConfig,
50 ) -> ReconciliationResult {
51 let mut matched_pairs = Vec::new();
52 let mut unmatched = Vec::new();
53 let mut exceptions = Vec::new();
54 let mut total_variance = 0.0;
55
56 let mut used_targets: Vec<bool> = vec![false; target_items.len()];
57
58 let source_by_account: HashMap<&str, Vec<(usize, &ReconciliationItem)>> = source_items
60 .iter()
61 .enumerate()
62 .fold(HashMap::new(), |mut acc, (i, item)| {
63 acc.entry(item.account_code.as_str())
64 .or_default()
65 .push((i, item));
66 acc
67 });
68
69 let target_by_account: HashMap<&str, Vec<(usize, &ReconciliationItem)>> = target_items
70 .iter()
71 .enumerate()
72 .fold(HashMap::new(), |mut acc, (i, item)| {
73 acc.entry(item.account_code.as_str())
74 .or_default()
75 .push((i, item));
76 acc
77 });
78
79 for (account, sources) in &source_by_account {
81 if let Some(targets) = target_by_account.get(account) {
82 for (_source_idx, source_item) in sources {
83 let best_match =
84 Self::find_best_match(source_item, targets, &used_targets, config);
85
86 match best_match {
87 Some((target_idx, confidence, variance, match_type)) => {
88 used_targets[target_idx] = true;
89 total_variance += variance.abs();
90
91 matched_pairs.push(MatchedPair {
92 source_id: source_item.id.clone(),
93 target_id: target_items[target_idx].id.clone(),
94 confidence,
95 variance,
96 match_type,
97 });
98
99 if variance.abs() > config.variance_threshold {
101 exceptions.push(ReconciliationException {
102 item_id: source_item.id.clone(),
103 exception_type: ExceptionType::AmountVariance,
104 description: format!(
105 "Amount variance of {} exceeds threshold",
106 variance
107 ),
108 suggested_action: Some("Review and adjust".to_string()),
109 });
110 }
111 }
112 None => {
113 unmatched.push(source_item.id.clone());
114 exceptions.push(ReconciliationException {
115 item_id: source_item.id.clone(),
116 exception_type: ExceptionType::MissingCounterpart,
117 description: "No matching item found in target".to_string(),
118 suggested_action: Some("Investigate missing item".to_string()),
119 });
120 }
121 }
122 }
123 } else {
124 for (_, source_item) in sources {
126 unmatched.push(source_item.id.clone());
127 }
128 }
129 }
130
131 for (i, target) in target_items.iter().enumerate() {
133 if !used_targets[i] {
134 unmatched.push(target.id.clone());
135 exceptions.push(ReconciliationException {
136 item_id: target.id.clone(),
137 exception_type: ExceptionType::MissingCounterpart,
138 description: "Target item has no matching source".to_string(),
139 suggested_action: Some("Investigate orphan item".to_string()),
140 });
141 }
142 }
143
144 let total_items = source_items.len() + target_items.len();
145 let matched_count = matched_pairs.len() * 2;
146 let match_rate = if total_items > 0 {
147 matched_count as f64 / total_items as f64
148 } else {
149 0.0
150 };
151
152 let exception_count = exceptions.len();
153
154 ReconciliationResult {
155 matched_pairs,
156 unmatched,
157 exceptions,
158 stats: ReconciliationStats {
159 total_items,
160 matched_count,
161 unmatched_count: total_items - matched_count,
162 exception_count,
163 match_rate,
164 total_variance,
165 },
166 }
167 }
168
169 fn find_best_match(
171 source: &ReconciliationItem,
172 targets: &[(usize, &ReconciliationItem)],
173 used_targets: &[bool],
174 config: &ReconciliationConfig,
175 ) -> Option<(usize, f64, f64, MatchType)> {
176 let mut best: Option<(usize, f64, f64, MatchType)> = None;
177
178 for &(target_idx, target) in targets {
179 if used_targets[target_idx] {
180 continue;
181 }
182
183 if source.currency != target.currency {
185 continue;
186 }
187
188 let variance = source.amount - target.amount;
190 let abs_variance = variance.abs();
191 let pct_variance = if source.amount.abs() > 0.0 {
192 abs_variance / source.amount.abs()
193 } else {
194 0.0
195 };
196
197 let (match_type, confidence) = if abs_variance < 0.001 {
199 (MatchType::Exact, 1.0)
200 } else if abs_variance <= config.amount_tolerance
201 || pct_variance <= config.percentage_tolerance
202 {
203 let conf = 1.0 - (pct_variance / config.percentage_tolerance).min(1.0);
204 (MatchType::Tolerance, conf * 0.9)
205 } else {
206 continue; };
208
209 let date_diff = (source.date as i64 - target.date as i64).unsigned_abs();
211 if date_diff > config.date_tolerance_days as u64 * 86400 {
212 continue;
213 }
214
215 let ref_boost = if config.match_on_reference
217 && !source.reference.is_empty()
218 && source.reference == target.reference
219 {
220 0.1
221 } else {
222 0.0
223 };
224
225 let final_confidence = (confidence + ref_boost).min(1.0);
226
227 if best.is_none() || final_confidence > best.as_ref().unwrap().1 {
229 best = Some((target_idx, final_confidence, variance, match_type));
230 }
231 }
232
233 best
234 }
235
236 pub fn reconcile_many_to_one(
238 source_items: &[ReconciliationItem],
239 target_items: &[ReconciliationItem],
240 config: &ReconciliationConfig,
241 ) -> ReconciliationResult {
242 let mut matched_pairs = Vec::new();
243 let mut unmatched = Vec::new();
244 let mut exceptions = Vec::new();
245 let mut total_variance = 0.0;
246
247 let mut source_totals: HashMap<String, (f64, Vec<String>)> = HashMap::new();
249 for item in source_items {
250 let entry = source_totals
251 .entry(item.account_code.clone())
252 .or_insert((0.0, Vec::new()));
253 entry.0 += item.amount;
254 entry.1.push(item.id.clone());
255 }
256
257 for target in target_items {
259 if let Some((source_total, source_ids)) = source_totals.get(&target.account_code) {
260 let variance = *source_total - target.amount;
261
262 if variance.abs() <= config.amount_tolerance
263 || (variance.abs() / target.amount.abs()) <= config.percentage_tolerance
264 {
265 for source_id in source_ids {
267 matched_pairs.push(MatchedPair {
268 source_id: source_id.clone(),
269 target_id: target.id.clone(),
270 confidence: 0.9,
271 variance: variance / source_ids.len() as f64,
272 match_type: MatchType::ManyToOne,
273 });
274 }
275 total_variance += variance.abs();
276 } else {
277 unmatched.push(target.id.clone());
279 exceptions.push(ReconciliationException {
280 item_id: target.id.clone(),
281 exception_type: ExceptionType::AmountVariance,
282 description: format!("Sum variance of {} exceeds tolerance", variance),
283 suggested_action: None,
284 });
285 }
286 } else {
287 unmatched.push(target.id.clone());
288 }
289 }
290
291 let total_items = source_items.len() + target_items.len();
292 let matched_count = matched_pairs.len();
293 let exception_count = exceptions.len();
294
295 ReconciliationResult {
296 matched_pairs,
297 unmatched,
298 exceptions,
299 stats: ReconciliationStats {
300 total_items,
301 matched_count,
302 unmatched_count: total_items - matched_count,
303 exception_count,
304 match_rate: matched_count as f64 / total_items.max(1) as f64,
305 total_variance,
306 },
307 }
308 }
309
310 pub fn find_duplicates(
312 items: &[ReconciliationItem],
313 config: &DuplicateConfig,
314 ) -> Vec<DuplicateGroup> {
315 let mut groups: Vec<DuplicateGroup> = Vec::new();
316
317 for i in 0..items.len() {
318 for j in (i + 1)..items.len() {
319 let a = &items[i];
320 let b = &items[j];
321
322 let is_dup = Self::check_duplicate(a, b, config);
323
324 if is_dup {
325 let mut found_group = false;
327 for group in &mut groups {
328 if group.item_ids.contains(&a.id) || group.item_ids.contains(&b.id) {
329 if !group.item_ids.contains(&a.id) {
330 group.item_ids.push(a.id.clone());
331 }
332 if !group.item_ids.contains(&b.id) {
333 group.item_ids.push(b.id.clone());
334 }
335 found_group = true;
336 break;
337 }
338 }
339
340 if !found_group {
341 groups.push(DuplicateGroup {
342 item_ids: vec![a.id.clone(), b.id.clone()],
343 total_amount: a.amount + b.amount,
344 account_code: a.account_code.clone(),
345 });
346 }
347 }
348 }
349 }
350
351 groups
352 }
353
354 fn check_duplicate(
356 a: &ReconciliationItem,
357 b: &ReconciliationItem,
358 config: &DuplicateConfig,
359 ) -> bool {
360 if a.account_code != b.account_code {
362 return false;
363 }
364
365 if (a.amount - b.amount).abs() > config.amount_threshold {
367 return false;
368 }
369
370 if a.currency != b.currency {
372 return false;
373 }
374
375 let date_diff = (a.date as i64 - b.date as i64).unsigned_abs();
377 if date_diff > config.date_range_days as u64 * 86400 {
378 return false;
379 }
380
381 if config.match_reference && !a.reference.is_empty() && a.reference == b.reference {
383 return true;
384 }
385
386 if config.match_description && a.source == b.source {
388 return true;
389 }
390
391 true
393 }
394}
395
396impl GpuKernel for GLReconciliation {
397 fn metadata(&self) -> &KernelMetadata {
398 &self.metadata
399 }
400}
401
402#[derive(Debug, Clone)]
404pub struct ReconciliationConfig {
405 pub amount_tolerance: f64,
407 pub percentage_tolerance: f64,
409 pub date_tolerance_days: u32,
411 pub match_on_reference: bool,
413 pub variance_threshold: f64,
415}
416
417impl Default for ReconciliationConfig {
418 fn default() -> Self {
419 Self {
420 amount_tolerance: 0.01,
421 percentage_tolerance: 0.001,
422 date_tolerance_days: 3,
423 match_on_reference: true,
424 variance_threshold: 1.0,
425 }
426 }
427}
428
429#[derive(Debug, Clone)]
431pub struct DuplicateConfig {
432 pub amount_threshold: f64,
434 pub date_range_days: u32,
436 pub match_reference: bool,
438 pub match_description: bool,
440}
441
442impl Default for DuplicateConfig {
443 fn default() -> Self {
444 Self {
445 amount_threshold: 0.01,
446 date_range_days: 7,
447 match_reference: true,
448 match_description: false,
449 }
450 }
451}
452
453#[derive(Debug, Clone)]
455pub struct DuplicateGroup {
456 pub item_ids: Vec<String>,
458 pub total_amount: f64,
460 pub account_code: String,
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::types::{ReconciliationSource, ReconciliationStatus};
468
469 fn create_test_source() -> Vec<ReconciliationItem> {
470 vec![
471 ReconciliationItem {
472 id: "S1".to_string(),
473 source: ReconciliationSource::GeneralLedger,
474 account_code: "1000".to_string(),
475 amount: 1000.0,
476 currency: "USD".to_string(),
477 date: 1700000000,
478 reference: "REF001".to_string(),
479 status: ReconciliationStatus::Unmatched,
480 matched_with: None,
481 },
482 ReconciliationItem {
483 id: "S2".to_string(),
484 source: ReconciliationSource::GeneralLedger,
485 account_code: "2000".to_string(),
486 amount: 2500.0,
487 currency: "USD".to_string(),
488 date: 1700000000,
489 reference: "REF002".to_string(),
490 status: ReconciliationStatus::Unmatched,
491 matched_with: None,
492 },
493 ]
494 }
495
496 fn create_test_target() -> Vec<ReconciliationItem> {
497 vec![
498 ReconciliationItem {
499 id: "T1".to_string(),
500 source: ReconciliationSource::SubLedger,
501 account_code: "1000".to_string(),
502 amount: 1000.0,
503 currency: "USD".to_string(),
504 date: 1700000000,
505 reference: "REF001".to_string(),
506 status: ReconciliationStatus::Unmatched,
507 matched_with: None,
508 },
509 ReconciliationItem {
510 id: "T2".to_string(),
511 source: ReconciliationSource::SubLedger,
512 account_code: "2000".to_string(),
513 amount: 2500.5, currency: "USD".to_string(),
515 date: 1700000000,
516 reference: "REF002".to_string(),
517 status: ReconciliationStatus::Unmatched,
518 matched_with: None,
519 },
520 ]
521 }
522
523 #[test]
524 fn test_reconciliation_metadata() {
525 let kernel = GLReconciliation::new();
526 assert_eq!(kernel.metadata().id, "accounting/gl-reconciliation");
527 assert_eq!(kernel.metadata().domain, Domain::Accounting);
528 }
529
530 #[test]
531 fn test_exact_match() {
532 let source = create_test_source();
533 let target = create_test_target();
534 let config = ReconciliationConfig::default();
535
536 let result = GLReconciliation::reconcile(&source, &target, &config);
537
538 let first_match = result
540 .matched_pairs
541 .iter()
542 .find(|p| p.source_id == "S1")
543 .unwrap();
544 assert_eq!(first_match.match_type, MatchType::Exact);
545 assert!((first_match.confidence - 1.0).abs() < 0.001);
546 }
547
548 #[test]
549 fn test_tolerance_match() {
550 let source = create_test_source();
551 let target = create_test_target();
552 let config = ReconciliationConfig {
553 amount_tolerance: 1.0,
554 ..Default::default()
555 };
556
557 let result = GLReconciliation::reconcile(&source, &target, &config);
558
559 let second_match = result
561 .matched_pairs
562 .iter()
563 .find(|p| p.source_id == "S2")
564 .unwrap();
565 assert_eq!(second_match.match_type, MatchType::Tolerance);
566 assert!((second_match.variance - (-0.5)).abs() < 0.01);
567 }
568
569 #[test]
570 fn test_no_match() {
571 let source = vec![ReconciliationItem {
572 id: "S1".to_string(),
573 source: ReconciliationSource::GeneralLedger,
574 account_code: "9999".to_string(), amount: 1000.0,
576 currency: "USD".to_string(),
577 date: 1700000000,
578 reference: "REF001".to_string(),
579 status: ReconciliationStatus::Unmatched,
580 matched_with: None,
581 }];
582
583 let target = create_test_target();
584 let config = ReconciliationConfig::default();
585
586 let result = GLReconciliation::reconcile(&source, &target, &config);
587
588 assert!(result.matched_pairs.is_empty());
589 assert!(!result.unmatched.is_empty());
590 }
591
592 #[test]
593 fn test_currency_mismatch() {
594 let source = vec![ReconciliationItem {
595 id: "S1".to_string(),
596 source: ReconciliationSource::GeneralLedger,
597 account_code: "1000".to_string(),
598 amount: 1000.0,
599 currency: "EUR".to_string(), date: 1700000000,
601 reference: "REF001".to_string(),
602 status: ReconciliationStatus::Unmatched,
603 matched_with: None,
604 }];
605
606 let target = create_test_target();
607 let config = ReconciliationConfig::default();
608
609 let result = GLReconciliation::reconcile(&source, &target, &config);
610
611 assert!(result.matched_pairs.is_empty());
612 }
613
614 #[test]
615 fn test_variance_exception() {
616 let source = create_test_source();
617 let mut target = create_test_target();
618 target[0].amount = 1002.0; let config = ReconciliationConfig {
621 amount_tolerance: 5.0,
622 variance_threshold: 1.0,
623 ..Default::default()
624 };
625
626 let result = GLReconciliation::reconcile(&source, &target, &config);
627
628 assert!(
629 result
630 .exceptions
631 .iter()
632 .any(|e| e.exception_type == ExceptionType::AmountVariance)
633 );
634 }
635
636 #[test]
637 fn test_many_to_one() {
638 let source = vec![
639 ReconciliationItem {
640 id: "S1".to_string(),
641 source: ReconciliationSource::GeneralLedger,
642 account_code: "1000".to_string(),
643 amount: 500.0,
644 currency: "USD".to_string(),
645 date: 1700000000,
646 reference: "".to_string(),
647 status: ReconciliationStatus::Unmatched,
648 matched_with: None,
649 },
650 ReconciliationItem {
651 id: "S2".to_string(),
652 source: ReconciliationSource::GeneralLedger,
653 account_code: "1000".to_string(),
654 amount: 500.0,
655 currency: "USD".to_string(),
656 date: 1700000000,
657 reference: "".to_string(),
658 status: ReconciliationStatus::Unmatched,
659 matched_with: None,
660 },
661 ];
662
663 let target = vec![ReconciliationItem {
664 id: "T1".to_string(),
665 source: ReconciliationSource::SubLedger,
666 account_code: "1000".to_string(),
667 amount: 1000.0,
668 currency: "USD".to_string(),
669 date: 1700000000,
670 reference: "".to_string(),
671 status: ReconciliationStatus::Unmatched,
672 matched_with: None,
673 }];
674
675 let config = ReconciliationConfig::default();
676 let result = GLReconciliation::reconcile_many_to_one(&source, &target, &config);
677
678 assert_eq!(result.matched_pairs.len(), 2);
679 assert!(
680 result
681 .matched_pairs
682 .iter()
683 .all(|p| p.match_type == MatchType::ManyToOne)
684 );
685 }
686
687 #[test]
688 fn test_find_duplicates() {
689 let items = vec![
690 ReconciliationItem {
691 id: "S1".to_string(),
692 source: ReconciliationSource::GeneralLedger,
693 account_code: "1000".to_string(),
694 amount: 1000.0,
695 currency: "USD".to_string(),
696 date: 1700000000,
697 reference: "REF001".to_string(),
698 status: ReconciliationStatus::Unmatched,
699 matched_with: None,
700 },
701 ReconciliationItem {
702 id: "S2".to_string(),
703 source: ReconciliationSource::GeneralLedger,
704 account_code: "1000".to_string(),
705 amount: 1000.0,
706 currency: "USD".to_string(),
707 date: 1700000000 + 86400, reference: "REF001".to_string(),
709 status: ReconciliationStatus::Unmatched,
710 matched_with: None,
711 },
712 ];
713
714 let config = DuplicateConfig::default();
715 let duplicates = GLReconciliation::find_duplicates(&items, &config);
716
717 assert_eq!(duplicates.len(), 1);
718 assert_eq!(duplicates[0].item_ids.len(), 2);
719 }
720
721 #[test]
722 fn test_match_rate() {
723 let source = create_test_source();
724 let target = create_test_target();
725 let config = ReconciliationConfig {
726 amount_tolerance: 1.0,
727 ..Default::default()
728 };
729
730 let result = GLReconciliation::reconcile(&source, &target, &config);
731
732 assert!((result.stats.match_rate - 1.0).abs() < 0.001);
734 }
735}