1use 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#[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 #[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 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 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 fn find_mapping(
109 account: &Account,
110 rules: &[&MappingRule],
111 _config: &MappingConfig,
112 ) -> Option<Vec<MappedAccount>> {
113 for rule in rules {
114 if let Some(ref filter) = rule.entity_filter {
116 if filter != &account.entity_id {
117 continue;
118 }
119 }
120
121 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 fn matches_pattern(code: &str, pattern: &str) -> bool {
132 if pattern.contains('*') {
133 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 if !code.starts_with(part) {
147 return false;
148 }
149 pos = part.len();
150 } else if i == parts.len() - 1 {
151 if !code.ends_with(part) {
153 return false;
154 }
155 } else {
156 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 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 fn evaluate_condition(account: &Account, condition: &str) -> bool {
220 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 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 pub fn validate_rules(rules: &[MappingRule]) -> Vec<RuleValidationError> {
245 let mut errors = Vec::new();
246
247 for rule in rules {
248 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 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 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 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#[derive(Debug, Clone, Default)]
306pub struct MappingConfig {
307 pub include_inactive: bool,
309 pub default_target: Option<String>,
311 pub strict_mode: bool,
313}
314
315#[derive(Debug, Clone)]
317pub struct RuleValidationError {
318 pub rule_id: String,
320 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![]; 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 let result1 =
560 ChartOfAccountsMapping::map_accounts(&accounts, &rules, &MappingConfig::default());
561 assert_eq!(result1.stats.total_accounts, 0);
562
563 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}