1use std::collections::HashMap;
12
13use chrono::{Duration, NaiveDate};
14use datasynth_config::schema::CompanyConfig;
15use datasynth_core::models::audit::component_audit::{
16 AllocationBasis, CompetenceLevel, ComponentAuditSnapshot, ComponentAuditor,
17 ComponentAuditorReport, ComponentInstruction, ComponentMaterialityAllocation, ComponentScope,
18 GroupAuditPlan, GroupRiskLevel, Misstatement, MisstatementType,
19};
20use datasynth_core::utils::seeded_rng;
21use rand::Rng;
22use rand_chacha::ChaCha8Rng;
23use rust_decimal::Decimal;
24
25pub struct ComponentAuditGenerator {
27 rng: ChaCha8Rng,
28}
29
30impl ComponentAuditGenerator {
31 pub fn new(seed: u64) -> Self {
33 Self {
34 rng: seeded_rng(seed, 0x600),
35 }
36 }
37
38 pub fn generate(
46 &mut self,
47 companies: &[CompanyConfig],
48 group_materiality: Decimal,
49 engagement_id: &str,
50 period_end: NaiveDate,
51 ) -> ComponentAuditSnapshot {
52 if companies.is_empty() {
53 return ComponentAuditSnapshot::default();
54 }
55
56 let mut jurisdiction_map: HashMap<String, Vec<String>> = HashMap::new();
61 for company in companies {
62 jurisdiction_map
63 .entry(company.country.clone())
64 .or_default()
65 .push(company.code.clone());
66 }
67
68 let mut jurisdictions: Vec<String> = jurisdiction_map.keys().cloned().collect();
70 jurisdictions.sort();
71
72 let mut auditor_id_counter: u32 = 0;
76 let mut country_to_auditor_id: HashMap<String, String> = HashMap::new();
78 let mut component_auditors: Vec<ComponentAuditor> = Vec::new();
79
80 for country in &jurisdictions {
81 auditor_id_counter += 1;
82 let auditor_id = format!("CA-{country}-{auditor_id_counter:04}");
83
84 let firm_name = format!("Audit Firm {country}");
85
86 let competence = {
88 let r: f64 = self.rng.random();
89 if r < 0.90 {
90 CompetenceLevel::Satisfactory
91 } else if r < 0.98 {
92 CompetenceLevel::RequiresSupervision
93 } else {
94 CompetenceLevel::Unsatisfactory
95 }
96 };
97
98 let assigned_entities = jurisdiction_map.get(country).cloned().unwrap_or_default();
99
100 country_to_auditor_id.insert(country.clone(), auditor_id.clone());
101
102 component_auditors.push(ComponentAuditor {
103 id: auditor_id,
104 firm_name,
105 jurisdiction: country.clone(),
106 independence_confirmed: self.rng.random::<f64>() > 0.02, competence_assessment: competence,
108 assigned_entities,
109 });
110 }
111
112 let n = companies.len();
119 let raw_weights: Vec<f64> = companies.iter().map(|c| c.volume_weight).collect();
120 let weights: Vec<f64> = {
121 let all_equal = raw_weights
122 .iter()
123 .all(|&w| (w - raw_weights[0]).abs() < f64::EPSILON);
124 if all_equal {
125 vec![1.0f64; n]
127 } else {
128 raw_weights
129 }
130 };
131 let total_weight: f64 = weights.iter().sum();
132
133 let mut component_allocations: Vec<ComponentMaterialityAllocation> = Vec::new();
137 let mut significant_components: Vec<String> = Vec::new();
138 let group_mat_f64 = group_materiality
139 .to_string()
140 .parse::<f64>()
141 .unwrap_or(1_000_000.0);
142
143 for (i, company) in companies.iter().enumerate() {
144 let entity_share = weights[i] / total_weight;
145
146 let cm_f64 = group_mat_f64 * entity_share * 0.75;
148 let component_materiality =
149 Decimal::from_f64_retain(cm_f64).unwrap_or(Decimal::new(100_000, 2));
150 let clearly_trivial =
151 Decimal::from_f64_retain(cm_f64 * 0.05).unwrap_or(Decimal::new(5_000, 2));
152
153 let allocation_basis = if entity_share >= 0.05 {
154 AllocationBasis::RevenueProportional
157 } else {
158 AllocationBasis::RiskBased
159 };
160
161 if entity_share >= 0.15 {
162 significant_components.push(company.code.clone());
163 }
164
165 component_allocations.push(ComponentMaterialityAllocation {
166 entity_code: company.code.clone(),
167 component_materiality,
168 clearly_trivial,
169 allocation_basis,
170 });
171 }
172
173 let aggregation_risk = if n <= 2 {
177 GroupRiskLevel::Low
178 } else if n <= 5 {
179 GroupRiskLevel::Medium
180 } else {
181 GroupRiskLevel::High
182 };
183
184 let consolidation_procedures = vec![
188 "Review intercompany eliminations for completeness".to_string(),
189 "Agree component trial balances to consolidation working papers".to_string(),
190 "Test goodwill impairment at group level".to_string(),
191 "Review consolidation journal entries for unusual items".to_string(),
192 "Assess appropriateness of accounting policies across components".to_string(),
193 ];
194
195 let group_audit_plan = GroupAuditPlan {
196 engagement_id: engagement_id.to_string(),
197 group_materiality,
198 component_allocations: component_allocations.clone(),
199 aggregation_risk,
200 significant_components: significant_components.clone(),
201 consolidation_audit_procedures: consolidation_procedures,
202 };
203
204 let reporting_deadline = period_end + Duration::days(60);
208 let mut instruction_id_counter: u32 = 0;
209 let mut instructions: Vec<ComponentInstruction> = Vec::new();
210
211 for (i, company) in companies.iter().enumerate() {
212 instruction_id_counter += 1;
213 let entity_share = weights[i] / total_weight;
214 let auditor_id = company_to_auditor_id(&company.country, &country_to_auditor_id);
215
216 let alloc = &component_allocations[i];
217
218 let scope = if entity_share >= 0.15 {
220 ComponentScope::FullScope
221 } else if entity_share >= 0.05 {
222 ComponentScope::SpecificScope {
223 account_areas: vec![
224 "Revenue".to_string(),
225 "Receivables".to_string(),
226 "Inventory".to_string(),
227 ],
228 }
229 } else {
230 ComponentScope::AnalyticalOnly
231 };
232
233 let specific_procedures = self.build_procedures(&scope, company);
234 let areas_of_focus = self.build_areas_of_focus(&scope);
235
236 instructions.push(ComponentInstruction {
237 id: format!("CI-{instruction_id_counter:06}"),
238 component_auditor_id: auditor_id,
239 entity_code: company.code.clone(),
240 scope,
241 materiality_allocated: alloc.component_materiality,
242 reporting_deadline,
243 specific_procedures,
244 areas_of_focus,
245 });
246 }
247
248 let mut report_id_counter: u32 = 0;
252 let mut reports: Vec<ComponentAuditorReport> = Vec::new();
253
254 for (i, company) in companies.iter().enumerate() {
255 report_id_counter += 1;
256 let entity_share = weights[i] / total_weight;
257 let instruction = &instructions[i];
258 let alloc = &component_allocations[i];
259 let auditor_id = company_to_auditor_id(&company.country, &country_to_auditor_id);
260
261 let max_misstatements = if entity_share >= 0.15 {
263 3usize
264 } else if entity_share >= 0.05 {
265 2
266 } else {
267 1
268 };
269 let misstatement_count = self.rng.random_range(0..=max_misstatements);
270
271 let mut misstatements: Vec<Misstatement> = Vec::new();
272 for _ in 0..misstatement_count {
273 misstatements.push(self.generate_misstatement(alloc.component_materiality));
274 }
275
276 let scope_limitations: Vec<String> = if self.rng.random::<f64>() < 0.05 {
278 vec!["Limited access to subsidiary records for inventory count".to_string()]
279 } else {
280 vec![]
281 };
282
283 let significant_findings: Vec<String> = misstatements
285 .iter()
286 .filter(|m| !m.corrected)
287 .map(|m| {
288 format!(
289 "{}: {} {} ({})",
290 m.account_area,
291 m.description,
292 m.amount,
293 format!("{:?}", m.classification).to_lowercase()
294 )
295 })
296 .collect();
297
298 let conclusion = if misstatements.iter().all(|m| m.corrected)
299 && scope_limitations.is_empty()
300 {
301 format!(
302 "No uncorrected misstatements identified in {} that exceed component materiality.",
303 company.name
304 )
305 } else {
306 format!(
307 "Uncorrected misstatements or limitations noted in {}. See significant findings.",
308 company.name
309 )
310 };
311
312 reports.push(ComponentAuditorReport {
313 id: format!("CR-{report_id_counter:06}"),
314 instruction_id: instruction.id.clone(),
315 component_auditor_id: auditor_id,
316 entity_code: company.code.clone(),
317 misstatements_identified: misstatements,
318 scope_limitations,
319 significant_findings,
320 conclusion,
321 });
322 }
323
324 ComponentAuditSnapshot {
325 component_auditors,
326 group_audit_plan: Some(group_audit_plan),
327 component_instructions: instructions,
328 component_reports: reports,
329 }
330 }
331
332 fn generate_misstatement(&mut self, component_materiality: Decimal) -> Misstatement {
337 let account_areas = [
338 "Revenue",
339 "Receivables",
340 "Inventory",
341 "Fixed Assets",
342 "Payables",
343 "Accruals",
344 "Provisions",
345 ];
346 let area_idx = self.rng.random_range(0..account_areas.len());
347 let area = account_areas[area_idx].to_string();
348
349 let types = [
350 MisstatementType::Factual,
351 MisstatementType::Judgmental,
352 MisstatementType::Projected,
353 ];
354 let type_idx = self.rng.random_range(0..types.len());
355 let classification = types[type_idx].clone();
356
357 let cm_f64 = component_materiality
359 .to_string()
360 .parse::<f64>()
361 .unwrap_or(100_000.0);
362 let pct: f64 = self.rng.random_range(0.01..=0.80);
363 let amount = Decimal::from_f64_retain(cm_f64 * pct).unwrap_or(Decimal::new(1_000, 0));
364
365 let corrected = self.rng.random::<f64>() > 0.40; let description = match &classification {
368 MisstatementType::Factual => format!("Factual misstatement in {area}"),
369 MisstatementType::Judgmental => format!("Judgmental difference in {area} estimate"),
370 MisstatementType::Projected => format!("Projected error in {area} population"),
371 };
372
373 Misstatement {
374 description,
375 amount,
376 classification,
377 account_area: area,
378 corrected,
379 }
380 }
381
382 fn build_procedures(&mut self, scope: &ComponentScope, company: &CompanyConfig) -> Vec<String> {
383 match scope {
384 ComponentScope::FullScope => vec![
385 format!(
386 "Perform full audit of {} financial statements",
387 company.name
388 ),
389 "Test internal controls over financial reporting".to_string(),
390 "Perform substantive testing on all material account balances".to_string(),
391 "Attend physical inventory count".to_string(),
392 "Confirm significant balances with third parties".to_string(),
393 "Review subsequent events through reporting deadline".to_string(),
394 ],
395 ComponentScope::SpecificScope { account_areas } => {
396 let mut procs =
397 vec!["Perform substantive procedures on specified account areas".to_string()];
398 for area in account_areas {
399 procs.push(format!("Obtain audit evidence for {area} balance"));
400 }
401 procs
402 }
403 ComponentScope::LimitedProcedures => vec![
404 "Perform agreed-upon procedures as specified in instruction".to_string(),
405 "Report all factual findings without expressing an opinion".to_string(),
406 ],
407 ComponentScope::AnalyticalOnly => vec![
408 "Perform analytical procedures on key account balances".to_string(),
409 "Investigate significant fluctuations exceeding component materiality".to_string(),
410 "Obtain management explanations for unusual movements".to_string(),
411 ],
412 }
413 }
414
415 fn build_areas_of_focus(&self, scope: &ComponentScope) -> Vec<String> {
416 match scope {
417 ComponentScope::FullScope => vec![
418 "Revenue recognition".to_string(),
419 "Going concern assessment".to_string(),
420 "Related party transactions".to_string(),
421 "Significant estimates and judgments".to_string(),
422 ],
423 ComponentScope::SpecificScope { account_areas } => account_areas.clone(),
424 ComponentScope::LimitedProcedures => vec!["As agreed in instruction".to_string()],
425 ComponentScope::AnalyticalOnly => vec![
426 "Year-on-year variance analysis".to_string(),
427 "Budget vs actual comparison".to_string(),
428 ],
429 }
430 }
431}
432
433fn company_to_auditor_id(country: &str, country_to_auditor_id: &HashMap<String, String>) -> String {
435 country_to_auditor_id
436 .get(country)
437 .cloned()
438 .unwrap_or_else(|| format!("CA-{country}-0001"))
439}
440
441#[cfg(test)]
442#[allow(clippy::unwrap_used)]
443mod tests {
444 use super::*;
445 use datasynth_config::schema::{CompanyConfig, TransactionVolume};
446
447 fn make_company(code: &str, name: &str, country: &str) -> CompanyConfig {
448 CompanyConfig {
449 code: code.to_string(),
450 name: name.to_string(),
451 currency: "USD".to_string(),
452 functional_currency: None,
453 country: country.to_string(),
454 fiscal_year_variant: "K4".to_string(),
455 annual_transaction_volume: TransactionVolume::TenK,
456 volume_weight: 1.0,
457 }
458 }
459
460 fn make_company_weighted(
461 code: &str,
462 name: &str,
463 country: &str,
464 volume_weight: f64,
465 ) -> CompanyConfig {
466 CompanyConfig {
467 code: code.to_string(),
468 name: name.to_string(),
469 currency: "USD".to_string(),
470 functional_currency: None,
471 country: country.to_string(),
472 fiscal_year_variant: "K4".to_string(),
473 annual_transaction_volume: TransactionVolume::TenK,
474 volume_weight,
475 }
476 }
477
478 #[test]
479 fn test_single_entity_produces_one_auditor_instruction_report() {
480 let companies = vec![make_company("C001", "Alpha Inc", "US")];
481 let mut gen = ComponentAuditGenerator::new(42);
482 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
483 let group_mat = Decimal::new(1_000_000, 0);
484
485 let snapshot = gen.generate(&companies, group_mat, "ENG-001", period_end);
486
487 assert_eq!(
488 snapshot.component_auditors.len(),
489 1,
490 "one auditor per jurisdiction"
491 );
492 assert_eq!(
493 snapshot.component_instructions.len(),
494 1,
495 "one instruction per entity"
496 );
497 assert_eq!(snapshot.component_reports.len(), 1, "one report per entity");
498 assert!(
499 snapshot.group_audit_plan.is_some(),
500 "group plan should be present"
501 );
502 }
503
504 #[test]
505 fn test_multi_entity_two_jurisdictions_two_auditors() {
506 let companies = vec![
507 make_company("C001", "Alpha Inc", "US"),
508 make_company("C002", "Beta GmbH", "DE"),
509 make_company("C003", "Gamma LLC", "US"),
510 ];
511 let mut gen = ComponentAuditGenerator::new(42);
512 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
513 let group_mat = Decimal::new(5_000_000, 0);
514
515 let snapshot = gen.generate(&companies, group_mat, "ENG-002", period_end);
516
517 assert_eq!(
518 snapshot.component_auditors.len(),
519 2,
520 "US and DE → 2 auditors"
521 );
522 assert_eq!(snapshot.component_instructions.len(), 3, "one per entity");
523 assert_eq!(snapshot.component_reports.len(), 3, "one per entity");
524 }
525
526 #[test]
527 fn test_scope_thresholds_with_large_group() {
528 let companies = vec![
530 make_company("C001", "BigCo", "US"),
531 make_company("C002", "SmallCo", "US"),
532 ];
533 let mut gen = ComponentAuditGenerator::new(42);
534 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
535 let group_mat = Decimal::new(10_000_000, 0);
536
537 let snapshot = gen.generate(&companies, group_mat, "ENG-003", period_end);
538
539 let plan = snapshot.group_audit_plan.as_ref().unwrap();
541 assert!(plan.significant_components.contains(&"C001".to_string()));
542 assert!(plan.significant_components.contains(&"C002".to_string()));
543
544 let c001_inst = snapshot
545 .component_instructions
546 .iter()
547 .find(|i| i.entity_code == "C001")
548 .unwrap();
549 assert_eq!(c001_inst.scope, ComponentScope::FullScope);
550 }
551
552 #[test]
553 fn test_scope_analytical_only_for_small_entity() {
554 let companies = vec![
557 make_company_weighted("C001", "BigCo", "US", 10.0),
558 make_company_weighted("C002", "TinyCo", "US", 0.5),
559 ];
560 let mut gen = ComponentAuditGenerator::new(42);
561 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
562 let group_mat = Decimal::new(10_000_000, 0);
563
564 let snapshot = gen.generate(&companies, group_mat, "ENG-004", period_end);
565
566 let tiny_inst = snapshot
568 .component_instructions
569 .iter()
570 .find(|i| i.entity_code == "C002")
571 .unwrap();
572 assert_eq!(tiny_inst.scope, ComponentScope::AnalyticalOnly);
573 }
574
575 #[test]
576 fn test_sum_of_component_materialities_le_group_materiality() {
577 let companies: Vec<CompanyConfig> = (1..=5)
578 .map(|i| make_company(&format!("C{i:03}"), &format!("Firm {i}"), "US"))
579 .collect();
580 let mut gen = ComponentAuditGenerator::new(99);
581 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
582 let group_mat = Decimal::new(2_000_000, 0);
583
584 let snapshot = gen.generate(&companies, group_mat, "ENG-005", period_end);
585
586 let plan = snapshot.group_audit_plan.as_ref().unwrap();
587 let total_component_mat: Decimal = plan
588 .component_allocations
589 .iter()
590 .map(|a| a.component_materiality)
591 .sum();
592
593 assert!(
594 total_component_mat <= group_mat,
595 "sum of component mats {total_component_mat} should be <= group mat {group_mat}"
596 );
597 }
598
599 #[test]
600 fn test_all_entities_covered_by_exactly_one_instruction() {
601 let companies = vec![
602 make_company("C001", "Alpha", "US"),
603 make_company("C002", "Beta", "DE"),
604 make_company("C003", "Gamma", "FR"),
605 ];
606 let mut gen = ComponentAuditGenerator::new(7);
607 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
608 let group_mat = Decimal::new(3_000_000, 0);
609
610 let snapshot = gen.generate(&companies, group_mat, "ENG-006", period_end);
611
612 for company in &companies {
613 let count = snapshot
614 .component_instructions
615 .iter()
616 .filter(|i| i.entity_code == company.code)
617 .count();
618 assert_eq!(
619 count, 1,
620 "entity {} should have exactly 1 instruction",
621 company.code
622 );
623 }
624 }
625
626 #[test]
627 fn test_all_reports_reference_valid_instruction_ids() {
628 let companies = vec![
629 make_company("C001", "Alpha", "US"),
630 make_company("C002", "Beta", "GB"),
631 ];
632 let mut gen = ComponentAuditGenerator::new(123);
633 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
634 let group_mat = Decimal::new(1_500_000, 0);
635
636 let snapshot = gen.generate(&companies, group_mat, "ENG-007", period_end);
637
638 let instruction_ids: std::collections::HashSet<String> = snapshot
639 .component_instructions
640 .iter()
641 .map(|i| i.id.clone())
642 .collect();
643
644 for report in &snapshot.component_reports {
645 assert!(
646 instruction_ids.contains(&report.instruction_id),
647 "report {} references unknown instruction {}",
648 report.id,
649 report.instruction_id
650 );
651 }
652 }
653}