rustkernel_accounting/
coa_mapping.rs

1//! Chart of accounts mapping kernel.
2//!
3//! This module provides chart of accounts mapping for accounting:
4//! - Map accounts between different chart of accounts
5//! - Apply transformation rules
6//! - Handle entity-specific mappings
7
8use crate::types::{
9    Account, MappedAccount, MappingResult, MappingRule, MappingStats, MappingTransformation,
10};
11use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
12use std::collections::HashMap;
13
14// ============================================================================
15// Chart of Accounts Mapping Kernel
16// ============================================================================
17
18/// Chart of accounts mapping kernel.
19///
20/// Maps accounts from source to target chart of accounts.
21#[derive(Debug, Clone)]
22pub struct ChartOfAccountsMapping {
23    metadata: KernelMetadata,
24}
25
26impl Default for ChartOfAccountsMapping {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl ChartOfAccountsMapping {
33    /// Create a new chart of accounts mapping kernel.
34    #[must_use]
35    pub fn new() -> Self {
36        Self {
37            metadata: KernelMetadata::batch("accounting/coa-mapping", Domain::Accounting)
38                .with_description("Entity-specific chart of accounts mapping")
39                .with_throughput(50_000)
40                .with_latency_us(50.0),
41        }
42    }
43
44    /// Map accounts using rules.
45    pub fn map_accounts(
46        accounts: &[Account],
47        rules: &[MappingRule],
48        config: &MappingConfig,
49    ) -> MappingResult {
50        let mut mapped = Vec::new();
51        let mut unmapped = Vec::new();
52        let mut rules_applied = 0;
53        let mut processed_count = 0;
54
55        // Sort rules by priority
56        let mut sorted_rules: Vec<_> = rules.iter().collect();
57        sorted_rules.sort_by_key(|r| std::cmp::Reverse(r.priority));
58
59        for account in accounts {
60            if !account.is_active && !config.include_inactive {
61                continue;
62            }
63
64            processed_count += 1;
65
66            let mapping = Self::find_mapping(account, &sorted_rules, config);
67
68            match mapping {
69                Some(mappings) => {
70                    rules_applied += 1;
71                    mapped.extend(mappings);
72                }
73                None => {
74                    if config.default_target.is_some() {
75                        mapped.push(MappedAccount {
76                            source_code: account.code.clone(),
77                            target_code: config.default_target.clone().unwrap(),
78                            rule_id: "default".to_string(),
79                            amount_ratio: 1.0,
80                        });
81                    } else {
82                        unmapped.push(account.code.clone());
83                    }
84                }
85            }
86        }
87
88        let mapped_count = processed_count - unmapped.len();
89
90        MappingResult {
91            mapped,
92            unmapped: unmapped.clone(),
93            stats: MappingStats {
94                total_accounts: processed_count,
95                mapped_count,
96                unmapped_count: unmapped.len(),
97                rules_applied,
98                mapping_rate: if processed_count > 0 {
99                    mapped_count as f64 / processed_count as f64
100                } else {
101                    0.0
102                },
103            },
104        }
105    }
106
107    /// Find mapping for an account.
108    fn find_mapping(
109        account: &Account,
110        rules: &[&MappingRule],
111        _config: &MappingConfig,
112    ) -> Option<Vec<MappedAccount>> {
113        for rule in rules {
114            // Check entity filter
115            if let Some(ref filter) = rule.entity_filter {
116                if filter != &account.entity_id {
117                    continue;
118                }
119            }
120
121            // Check pattern match
122            if Self::matches_pattern(&account.code, &rule.source_pattern) {
123                let mappings = Self::apply_transformation(account, rule);
124                return Some(mappings);
125            }
126        }
127        None
128    }
129
130    /// Check if account code matches pattern.
131    fn matches_pattern(code: &str, pattern: &str) -> bool {
132        if pattern.contains('*') {
133            // Simple wildcard matching
134            let parts: Vec<&str> = pattern.split('*').collect();
135            if parts.len() == 1 {
136                return code == pattern;
137            }
138
139            let mut pos = 0;
140            for (i, part) in parts.iter().enumerate() {
141                if part.is_empty() {
142                    continue;
143                }
144                if i == 0 {
145                    // Must start with first part
146                    if !code.starts_with(part) {
147                        return false;
148                    }
149                    pos = part.len();
150                } else if i == parts.len() - 1 {
151                    // Must end with last part
152                    if !code.ends_with(part) {
153                        return false;
154                    }
155                } else {
156                    // Must contain middle parts
157                    if let Some(idx) = code[pos..].find(part) {
158                        pos += idx + part.len();
159                    } else {
160                        return false;
161                    }
162                }
163            }
164            true
165        } else {
166            code == pattern
167        }
168    }
169
170    /// Apply transformation to create mappings.
171    fn apply_transformation(account: &Account, rule: &MappingRule) -> Vec<MappedAccount> {
172        match &rule.transformation {
173            MappingTransformation::Direct => {
174                vec![MappedAccount {
175                    source_code: account.code.clone(),
176                    target_code: rule.target_code.clone(),
177                    rule_id: rule.id.clone(),
178                    amount_ratio: 1.0,
179                }]
180            }
181            MappingTransformation::Split(splits) => splits
182                .iter()
183                .map(|(target, ratio)| MappedAccount {
184                    source_code: account.code.clone(),
185                    target_code: target.clone(),
186                    rule_id: rule.id.clone(),
187                    amount_ratio: *ratio,
188                })
189                .collect(),
190            MappingTransformation::Aggregate => {
191                vec![MappedAccount {
192                    source_code: account.code.clone(),
193                    target_code: rule.target_code.clone(),
194                    rule_id: rule.id.clone(),
195                    amount_ratio: 1.0,
196                }]
197            }
198            MappingTransformation::Conditional {
199                condition,
200                if_true,
201                if_false,
202            } => {
203                let target = if Self::evaluate_condition(account, condition) {
204                    if_true.clone()
205                } else {
206                    if_false.clone()
207                };
208                vec![MappedAccount {
209                    source_code: account.code.clone(),
210                    target_code: target,
211                    rule_id: rule.id.clone(),
212                    amount_ratio: 1.0,
213                }]
214            }
215        }
216    }
217
218    /// Evaluate a simple condition.
219    fn evaluate_condition(account: &Account, condition: &str) -> bool {
220        // Simple attribute-based conditions
221        if let Some(stripped) = condition.strip_prefix("attr:") {
222            let parts: Vec<&str> = stripped.splitn(2, '=').collect();
223            if parts.len() == 2 {
224                return account.attributes.get(parts[0]) == Some(&parts[1].to_string());
225            }
226        }
227
228        // Account type conditions
229        if let Some(type_str) = condition.strip_prefix("type:") {
230            return match type_str {
231                "asset" => account.account_type == crate::types::AccountType::Asset,
232                "liability" => account.account_type == crate::types::AccountType::Liability,
233                "equity" => account.account_type == crate::types::AccountType::Equity,
234                "revenue" => account.account_type == crate::types::AccountType::Revenue,
235                "expense" => account.account_type == crate::types::AccountType::Expense,
236                _ => false,
237            };
238        }
239
240        false
241    }
242
243    /// Validate mapping rules.
244    pub fn validate_rules(rules: &[MappingRule]) -> Vec<RuleValidationError> {
245        let mut errors = Vec::new();
246
247        for rule in rules {
248            // Check for empty patterns
249            if rule.source_pattern.is_empty() {
250                errors.push(RuleValidationError {
251                    rule_id: rule.id.clone(),
252                    message: "Source pattern is empty".to_string(),
253                });
254            }
255
256            // Check for empty targets
257            if rule.target_code.is_empty()
258                && !matches!(rule.transformation, MappingTransformation::Split(_))
259            {
260                errors.push(RuleValidationError {
261                    rule_id: rule.id.clone(),
262                    message: "Target code is empty".to_string(),
263                });
264            }
265
266            // Validate split ratios
267            if let MappingTransformation::Split(splits) = &rule.transformation {
268                let total: f64 = splits.iter().map(|(_, r)| r).sum();
269                if (total - 1.0).abs() > 0.001 {
270                    errors.push(RuleValidationError {
271                        rule_id: rule.id.clone(),
272                        message: format!("Split ratios sum to {}, expected 1.0", total),
273                    });
274                }
275            }
276        }
277
278        errors
279    }
280
281    /// Build hierarchy from accounts.
282    pub fn build_hierarchy(accounts: &[Account]) -> HashMap<String, Vec<String>> {
283        let mut hierarchy: HashMap<String, Vec<String>> = HashMap::new();
284
285        for account in accounts {
286            if let Some(ref parent) = account.parent_code {
287                hierarchy
288                    .entry(parent.clone())
289                    .or_default()
290                    .push(account.code.clone());
291            }
292        }
293
294        hierarchy
295    }
296}
297
298impl GpuKernel for ChartOfAccountsMapping {
299    fn metadata(&self) -> &KernelMetadata {
300        &self.metadata
301    }
302}
303
304/// Mapping configuration.
305#[derive(Debug, Clone, Default)]
306pub struct MappingConfig {
307    /// Include inactive accounts.
308    pub include_inactive: bool,
309    /// Default target for unmapped accounts.
310    pub default_target: Option<String>,
311    /// Strict mode (fail on unmapped).
312    pub strict_mode: bool,
313}
314
315/// Rule validation error.
316#[derive(Debug, Clone)]
317pub struct RuleValidationError {
318    /// Rule ID.
319    pub rule_id: String,
320    /// Error message.
321    pub message: String,
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::types::AccountType;
328
329    fn create_test_accounts() -> Vec<Account> {
330        vec![
331            Account {
332                code: "1000".to_string(),
333                name: "Cash".to_string(),
334                account_type: AccountType::Asset,
335                parent_code: None,
336                is_active: true,
337                currency: "USD".to_string(),
338                entity_id: "CORP".to_string(),
339                attributes: HashMap::new(),
340            },
341            Account {
342                code: "1100".to_string(),
343                name: "Receivables".to_string(),
344                account_type: AccountType::Asset,
345                parent_code: Some("1000".to_string()),
346                is_active: true,
347                currency: "USD".to_string(),
348                entity_id: "CORP".to_string(),
349                attributes: HashMap::new(),
350            },
351            Account {
352                code: "2000".to_string(),
353                name: "Payables".to_string(),
354                account_type: AccountType::Liability,
355                parent_code: None,
356                is_active: true,
357                currency: "USD".to_string(),
358                entity_id: "CORP".to_string(),
359                attributes: HashMap::new(),
360            },
361        ]
362    }
363
364    fn create_test_rules() -> Vec<MappingRule> {
365        vec![
366            MappingRule {
367                id: "R1".to_string(),
368                source_pattern: "1*".to_string(),
369                target_code: "A1000".to_string(),
370                entity_filter: None,
371                priority: 10,
372                transformation: MappingTransformation::Direct,
373            },
374            MappingRule {
375                id: "R2".to_string(),
376                source_pattern: "2000".to_string(),
377                target_code: "L2000".to_string(),
378                entity_filter: None,
379                priority: 5,
380                transformation: MappingTransformation::Direct,
381            },
382        ]
383    }
384
385    #[test]
386    fn test_coa_metadata() {
387        let kernel = ChartOfAccountsMapping::new();
388        assert_eq!(kernel.metadata().id, "accounting/coa-mapping");
389        assert_eq!(kernel.metadata().domain, Domain::Accounting);
390    }
391
392    #[test]
393    fn test_basic_mapping() {
394        let accounts = create_test_accounts();
395        let rules = create_test_rules();
396        let config = MappingConfig::default();
397
398        let result = ChartOfAccountsMapping::map_accounts(&accounts, &rules, &config);
399
400        assert_eq!(result.stats.total_accounts, 3);
401        assert_eq!(result.stats.mapped_count, 3);
402        assert!(result.unmapped.is_empty());
403    }
404
405    #[test]
406    fn test_wildcard_matching() {
407        assert!(ChartOfAccountsMapping::matches_pattern("1000", "1*"));
408        assert!(ChartOfAccountsMapping::matches_pattern("1100", "1*"));
409        assert!(!ChartOfAccountsMapping::matches_pattern("2000", "1*"));
410        assert!(ChartOfAccountsMapping::matches_pattern("ABC123", "*123"));
411        assert!(ChartOfAccountsMapping::matches_pattern("TEST", "*"));
412    }
413
414    #[test]
415    fn test_split_transformation() {
416        let accounts = vec![Account {
417            code: "5000".to_string(),
418            name: "Mixed Expense".to_string(),
419            account_type: AccountType::Expense,
420            parent_code: None,
421            is_active: true,
422            currency: "USD".to_string(),
423            entity_id: "CORP".to_string(),
424            attributes: HashMap::new(),
425        }];
426
427        let rules = vec![MappingRule {
428            id: "R1".to_string(),
429            source_pattern: "5000".to_string(),
430            target_code: String::new(),
431            entity_filter: None,
432            priority: 10,
433            transformation: MappingTransformation::Split(vec![
434                ("E5001".to_string(), 0.6),
435                ("E5002".to_string(), 0.4),
436            ]),
437        }];
438
439        let result =
440            ChartOfAccountsMapping::map_accounts(&accounts, &rules, &MappingConfig::default());
441
442        assert_eq!(result.mapped.len(), 2);
443        assert!((result.mapped[0].amount_ratio - 0.6).abs() < 0.001);
444        assert!((result.mapped[1].amount_ratio - 0.4).abs() < 0.001);
445    }
446
447    #[test]
448    fn test_entity_filter() {
449        let accounts = vec![
450            Account {
451                code: "1000".to_string(),
452                name: "Cash".to_string(),
453                account_type: AccountType::Asset,
454                parent_code: None,
455                is_active: true,
456                currency: "USD".to_string(),
457                entity_id: "CORP_A".to_string(),
458                attributes: HashMap::new(),
459            },
460            Account {
461                code: "1000".to_string(),
462                name: "Cash".to_string(),
463                account_type: AccountType::Asset,
464                parent_code: None,
465                is_active: true,
466                currency: "USD".to_string(),
467                entity_id: "CORP_B".to_string(),
468                attributes: HashMap::new(),
469            },
470        ];
471
472        let rules = vec![MappingRule {
473            id: "R1".to_string(),
474            source_pattern: "1000".to_string(),
475            target_code: "A1000".to_string(),
476            entity_filter: Some("CORP_A".to_string()),
477            priority: 10,
478            transformation: MappingTransformation::Direct,
479        }];
480
481        let result =
482            ChartOfAccountsMapping::map_accounts(&accounts, &rules, &MappingConfig::default());
483
484        assert_eq!(result.stats.mapped_count, 1);
485        assert_eq!(result.unmapped.len(), 1);
486    }
487
488    #[test]
489    fn test_default_target() {
490        let accounts = create_test_accounts();
491        let rules: Vec<MappingRule> = vec![]; // No rules
492
493        let config = MappingConfig {
494            default_target: Some("UNMAPPED".to_string()),
495            ..Default::default()
496        };
497
498        let result = ChartOfAccountsMapping::map_accounts(&accounts, &rules, &config);
499
500        assert!(result.unmapped.is_empty());
501        assert!(result.mapped.iter().all(|m| m.target_code == "UNMAPPED"));
502    }
503
504    #[test]
505    fn test_validate_rules() {
506        let rules = vec![
507            MappingRule {
508                id: "EMPTY".to_string(),
509                source_pattern: "".to_string(),
510                target_code: "T1".to_string(),
511                entity_filter: None,
512                priority: 1,
513                transformation: MappingTransformation::Direct,
514            },
515            MappingRule {
516                id: "BAD_SPLIT".to_string(),
517                source_pattern: "1*".to_string(),
518                target_code: String::new(),
519                entity_filter: None,
520                priority: 1,
521                transformation: MappingTransformation::Split(vec![
522                    ("T1".to_string(), 0.3),
523                    ("T2".to_string(), 0.3),
524                ]),
525            },
526        ];
527
528        let errors = ChartOfAccountsMapping::validate_rules(&rules);
529
530        assert_eq!(errors.len(), 2);
531    }
532
533    #[test]
534    fn test_build_hierarchy() {
535        let accounts = create_test_accounts();
536        let hierarchy = ChartOfAccountsMapping::build_hierarchy(&accounts);
537
538        assert!(hierarchy.contains_key("1000"));
539        assert_eq!(hierarchy.get("1000").unwrap().len(), 1);
540        assert!(hierarchy.get("1000").unwrap().contains(&"1100".to_string()));
541    }
542
543    #[test]
544    fn test_inactive_accounts() {
545        let accounts = vec![Account {
546            code: "1000".to_string(),
547            name: "Inactive".to_string(),
548            account_type: AccountType::Asset,
549            parent_code: None,
550            is_active: false,
551            currency: "USD".to_string(),
552            entity_id: "CORP".to_string(),
553            attributes: HashMap::new(),
554        }];
555
556        let rules = create_test_rules();
557
558        // Default: exclude inactive
559        let result1 =
560            ChartOfAccountsMapping::map_accounts(&accounts, &rules, &MappingConfig::default());
561        assert_eq!(result1.stats.total_accounts, 0);
562
563        // Include inactive
564        let config = MappingConfig {
565            include_inactive: true,
566            ..Default::default()
567        };
568        let result2 = ChartOfAccountsMapping::map_accounts(&accounts, &rules, &config);
569        assert_eq!(result2.stats.total_accounts, 1);
570    }
571}