1use chrono::Duration;
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11
12use datasynth_core::models::audit::{
13 Assertion, AuditEngagement, AuditFinding, FindingSeverity, FindingStatus, FindingType,
14 MilestoneStatus, RemediationPlan, RemediationStatus, Workpaper,
15};
16
17#[derive(Debug, Clone)]
19pub struct AvailableControl {
20 pub control_id: String,
22 pub assertions: Vec<Assertion>,
24 pub process_areas: Vec<String>,
26}
27
28#[derive(Debug, Clone)]
30pub struct AvailableRisk {
31 pub risk_id: String,
33 pub engagement_id: uuid::Uuid,
35 pub account_or_process: String,
37}
38
39#[derive(Debug, Clone)]
41pub struct FindingGeneratorConfig {
42 pub findings_per_engagement: (u32, u32),
44 pub material_weakness_probability: f64,
46 pub significant_deficiency_probability: f64,
48 pub misstatement_probability: f64,
50 pub remediation_plan_probability: f64,
52 pub management_agrees_probability: f64,
54 pub misstatement_range: (i64, i64),
56}
57
58impl Default for FindingGeneratorConfig {
59 fn default() -> Self {
60 Self {
61 findings_per_engagement: (3, 12),
62 material_weakness_probability: 0.05,
63 significant_deficiency_probability: 0.15,
64 misstatement_probability: 0.30,
65 remediation_plan_probability: 0.70,
66 management_agrees_probability: 0.85,
67 misstatement_range: (1_000, 500_000),
68 }
69 }
70}
71
72pub struct FindingGenerator {
74 rng: ChaCha8Rng,
75 config: FindingGeneratorConfig,
76 finding_counter: u32,
77 fiscal_year: u16,
78 template_provider: Option<datasynth_core::templates::SharedTemplateProvider>,
80}
81
82impl FindingGenerator {
83 pub fn new(seed: u64) -> Self {
85 Self {
86 rng: seeded_rng(seed, 0),
87 config: FindingGeneratorConfig::default(),
88 finding_counter: 0,
89 fiscal_year: 2025,
90 template_provider: None,
91 }
92 }
93
94 pub fn with_config(seed: u64, config: FindingGeneratorConfig) -> Self {
96 Self {
97 rng: seeded_rng(seed, 0),
98 config,
99 finding_counter: 0,
100 fiscal_year: 2025,
101 template_provider: None,
102 }
103 }
104
105 pub fn set_template_provider(
111 &mut self,
112 provider: datasynth_core::templates::SharedTemplateProvider,
113 ) {
114 self.template_provider = Some(provider);
115 }
116
117 fn finding_type_to_key(finding_type: FindingType) -> &'static str {
120 match finding_type {
121 FindingType::MaterialWeakness => "material_weakness",
122 FindingType::SignificantDeficiency => "significant_deficiency",
123 FindingType::ControlDeficiency => "control_deficiency",
124 FindingType::MaterialMisstatement => "material_misstatement",
125 FindingType::ImmaterialMisstatement => "immaterial_misstatement",
126 FindingType::ComplianceException => "compliance_exception",
127 FindingType::ItDeficiency => "it_deficiency",
128 FindingType::OtherMatter => "other_matter",
129 FindingType::ProcessImprovement => "process_improvement",
130 }
131 }
132
133 fn try_template_narrative(
140 &mut self,
141 finding_type: FindingType,
142 section: &str,
143 account: &str,
144 amount: Option<i64>,
145 ) -> Option<String> {
146 let provider = self.template_provider.clone()?;
147 let key = Self::finding_type_to_key(finding_type);
148 let tpl = provider.get_finding_narrative(key, section, &mut self.rng)?;
149 let mut out = tpl.replace("{account}", account);
150 if let Some(amt) = amount {
151 out = out.replace("{amount}", &amt.to_string());
152 }
153 Some(out)
154 }
155
156 pub fn generate_findings_for_engagement(
158 &mut self,
159 engagement: &AuditEngagement,
160 workpapers: &[Workpaper],
161 team_members: &[String],
162 ) -> Vec<AuditFinding> {
163 self.fiscal_year = engagement.fiscal_year;
164
165 let count = self.rng.random_range(
166 self.config.findings_per_engagement.0..=self.config.findings_per_engagement.1,
167 );
168
169 let mut findings = Vec::with_capacity(count as usize);
170
171 for _ in 0..count {
172 let finding = self.generate_finding(engagement, workpapers, team_members);
173 findings.push(finding);
174 }
175
176 findings
177 }
178
179 pub fn generate_finding(
181 &mut self,
182 engagement: &AuditEngagement,
183 workpapers: &[Workpaper],
184 team_members: &[String],
185 ) -> AuditFinding {
186 self.finding_counter += 1;
187
188 let finding_type = self.select_finding_type();
189 let (title, account) = self.generate_finding_title(finding_type);
190
191 let mut finding = AuditFinding::new(engagement.engagement_id, finding_type, &title);
192
193 finding.finding_ref = format!("FIND-{}-{:03}", self.fiscal_year, self.finding_counter);
194
195 let (condition, criteria, cause, effect) = self.generate_ccce(finding_type, &account);
197 finding = finding.with_details(&condition, &criteria, &cause, &effect);
198
199 let recommendation = self.generate_recommendation(finding_type, &account);
201 finding = finding.with_recommendation(&recommendation);
202
203 finding.severity = self.determine_severity(finding_type, &finding);
205
206 if self.is_misstatement_type(finding_type) {
208 let (factual, projected, judgmental) = self.generate_misstatement_amounts();
209 finding = finding.with_misstatement(factual, projected, judgmental);
210
211 if let Some(f) = factual {
212 finding = finding.with_monetary_impact(f);
213 }
214 }
215
216 finding.assertions_affected = self.select_assertions(finding_type);
218 finding.accounts_affected = vec![account.clone()];
219 finding.process_areas = self.select_process_areas(&account);
220
221 if !workpapers.is_empty() {
223 let wp_count = self.rng.random_range(1..=3.min(workpapers.len()));
224 for _ in 0..wp_count {
225 let idx = self.rng.random_range(0..workpapers.len());
226 finding.workpaper_refs.push(workpapers[idx].workpaper_id);
227 }
228 if let Some(first_wp) = finding.workpaper_refs.first() {
230 finding.workpaper_id = Some(first_wp.to_string());
231 }
232 }
233
234 let identifier = self.select_team_member(team_members, "senior");
236 finding.identified_by = identifier;
237 finding.identified_date =
238 engagement.fieldwork_start + Duration::days(self.rng.random_range(7..30));
239
240 if self.rng.random::<f64>() < 0.8 {
242 finding.reviewed_by = Some(self.select_team_member(team_members, "manager"));
243 finding.review_date =
244 Some(finding.identified_date + Duration::days(self.rng.random_range(3..10)));
245 finding.status = FindingStatus::PendingReview;
246 }
247
248 finding.mark_for_reporting(
250 finding.finding_type.requires_sox_reporting() || finding.severity.score() >= 3,
251 finding.requires_governance_communication(),
252 );
253
254 if self.rng.random::<f64>() < 0.7 {
256 let response_date =
257 finding.identified_date + Duration::days(self.rng.random_range(7..21));
258 let agrees = self.rng.random::<f64>() < self.config.management_agrees_probability;
259 let response = self.generate_management_response(finding_type, agrees);
260 finding.add_management_response(&response, agrees, response_date);
261
262 if agrees && self.rng.random::<f64>() < self.config.remediation_plan_probability {
264 let plan = self.generate_remediation_plan(&finding, &account);
265 finding.with_remediation_plan(plan);
266 }
267 }
268
269 finding
270 }
271
272 pub fn generate_findings_with_context(
277 &mut self,
278 engagement: &AuditEngagement,
279 workpapers: &[Workpaper],
280 team_members: &[String],
281 controls: &[AvailableControl],
282 risks: &[AvailableRisk],
283 ) -> Vec<AuditFinding> {
284 let mut findings =
285 self.generate_findings_for_engagement(engagement, workpapers, team_members);
286
287 for finding in &mut findings {
288 self.link_controls_and_risks(finding, controls, risks);
289 }
290
291 findings
292 }
293
294 fn link_controls_and_risks(
296 &mut self,
297 finding: &mut AuditFinding,
298 controls: &[AvailableControl],
299 risks: &[AvailableRisk],
300 ) {
301 let finding_assertions = &finding.assertions_affected;
303 let finding_process_areas = &finding.process_areas;
304
305 let mut matched_controls: Vec<&AvailableControl> = controls
306 .iter()
307 .filter(|ctrl| {
308 let assertion_match = ctrl
310 .assertions
311 .iter()
312 .any(|a| finding_assertions.contains(a));
313 let process_match = ctrl.process_areas.iter().any(|pa| {
315 finding_process_areas
316 .iter()
317 .any(|fp| fp.to_lowercase().contains(&pa.to_lowercase()))
318 });
319 assertion_match || process_match
320 })
321 .collect();
322
323 if matched_controls.is_empty() && !controls.is_empty() {
325 let count = self.rng.random_range(1..=2.min(controls.len()));
326 for _ in 0..count {
327 let idx = self.rng.random_range(0..controls.len());
328 matched_controls.push(&controls[idx]);
329 }
330 }
331
332 if matched_controls.len() > 3 {
334 matched_controls.truncate(3);
335 }
336
337 finding.related_control_ids = matched_controls
338 .iter()
339 .map(|c| c.control_id.clone())
340 .collect();
341
342 let engagement_risks: Vec<&AvailableRisk> = risks
344 .iter()
345 .filter(|r| r.engagement_id == finding.engagement_id)
346 .collect();
347
348 if !engagement_risks.is_empty() {
349 let matching_risk = engagement_risks.iter().find(|r| {
351 finding.accounts_affected.iter().any(|a| {
352 r.account_or_process
353 .to_lowercase()
354 .contains(&a.to_lowercase())
355 })
356 });
357
358 if let Some(risk) = matching_risk {
359 finding.related_risk_id = Some(risk.risk_id.clone());
360 } else {
361 let idx = self.rng.random_range(0..engagement_risks.len());
363 finding.related_risk_id = Some(engagement_risks[idx].risk_id.clone());
364 }
365 }
366 }
367
368 fn select_finding_type(&mut self) -> FindingType {
370 let r: f64 = self.rng.random();
371
372 if r < self.config.material_weakness_probability {
373 FindingType::MaterialWeakness
374 } else if r < self.config.material_weakness_probability
375 + self.config.significant_deficiency_probability
376 {
377 FindingType::SignificantDeficiency
378 } else if r < self.config.material_weakness_probability
379 + self.config.significant_deficiency_probability
380 + self.config.misstatement_probability
381 {
382 if self.rng.random::<f64>() < 0.3 {
383 FindingType::MaterialMisstatement
384 } else {
385 FindingType::ImmaterialMisstatement
386 }
387 } else {
388 let other_types = [
389 FindingType::ControlDeficiency,
390 FindingType::ComplianceException,
391 FindingType::OtherMatter,
392 FindingType::ItDeficiency,
393 FindingType::ProcessImprovement,
394 ];
395 let idx = self.rng.random_range(0..other_types.len());
396 other_types[idx]
397 }
398 }
399
400 fn generate_finding_title(&mut self, finding_type: FindingType) -> (String, String) {
402 if let Some(ref provider) = self.template_provider {
404 let key = Self::finding_type_to_key(finding_type);
405 if let Some(pair) = provider.get_finding_title(key, &mut self.rng) {
406 return pair;
407 }
408 }
409
410 match finding_type {
411 FindingType::MaterialWeakness => {
412 let titles = [
413 (
414 "Inadequate segregation of duties in revenue cycle",
415 "Revenue",
416 ),
417 (
418 "Lack of effective review of journal entries",
419 "General Ledger",
420 ),
421 (
422 "Insufficient IT general controls over financial applications",
423 "IT Controls",
424 ),
425 (
426 "Inadequate controls over financial close process",
427 "Financial Close",
428 ),
429 ];
430 let idx = self.rng.random_range(0..titles.len());
431 (titles[idx].0.into(), titles[idx].1.into())
432 }
433 FindingType::SignificantDeficiency => {
434 let titles = [
435 (
436 "Inadequate documentation of account reconciliations",
437 "Accounts Receivable",
438 ),
439 (
440 "Untimely review of vendor master file changes",
441 "Accounts Payable",
442 ),
443 ("Incomplete fixed asset physical inventory", "Fixed Assets"),
444 (
445 "Lack of formal approval for manual journal entries",
446 "General Ledger",
447 ),
448 ];
449 let idx = self.rng.random_range(0..titles.len());
450 (titles[idx].0.into(), titles[idx].1.into())
451 }
452 FindingType::ControlDeficiency => {
453 let titles = [
454 (
455 "Missing secondary approval on expense reports",
456 "Operating Expenses",
457 ),
458 ("Incomplete access review documentation", "IT Controls"),
459 ("Delayed bank reconciliation preparation", "Cash"),
460 ("Inconsistent inventory count procedures", "Inventory"),
461 ];
462 let idx = self.rng.random_range(0..titles.len());
463 (titles[idx].0.into(), titles[idx].1.into())
464 }
465 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
466 let titles = [
467 ("Revenue cutoff error", "Revenue"),
468 ("Inventory valuation adjustment", "Inventory"),
469 (
470 "Accounts receivable allowance understatement",
471 "Accounts Receivable",
472 ),
473 ("Accrued liabilities understatement", "Accrued Liabilities"),
474 ("Fixed asset depreciation calculation error", "Fixed Assets"),
475 ];
476 let idx = self.rng.random_range(0..titles.len());
477 (titles[idx].0.into(), titles[idx].1.into())
478 }
479 FindingType::ComplianceException => {
480 let titles = [
481 ("Late filing of sales tax returns", "Tax"),
482 ("Incomplete Form 1099 reporting", "Tax"),
483 ("Non-compliance with debt covenant reporting", "Debt"),
484 ];
485 let idx = self.rng.random_range(0..titles.len());
486 (titles[idx].0.into(), titles[idx].1.into())
487 }
488 FindingType::ItDeficiency => {
489 let titles = [
490 ("Excessive user access privileges", "IT Controls"),
491 ("Inadequate password policy enforcement", "IT Controls"),
492 ("Missing change management documentation", "IT Controls"),
493 ("Incomplete disaster recovery testing", "IT Controls"),
494 ];
495 let idx = self.rng.random_range(0..titles.len());
496 (titles[idx].0.into(), titles[idx].1.into())
497 }
498 FindingType::OtherMatter | FindingType::ProcessImprovement => {
499 let titles = [
500 (
501 "Opportunity to improve month-end close efficiency",
502 "Financial Close",
503 ),
504 (
505 "Enhancement to vendor onboarding process",
506 "Accounts Payable",
507 ),
508 (
509 "Automation opportunity in reconciliation process",
510 "General Ledger",
511 ),
512 ];
513 let idx = self.rng.random_range(0..titles.len());
514 (titles[idx].0.into(), titles[idx].1.into())
515 }
516 }
517 }
518
519 fn generate_ccce(
521 &mut self,
522 finding_type: FindingType,
523 account: &str,
524 ) -> (String, String, String, String) {
525 let tpl_condition = self.try_template_narrative(finding_type, "condition", account, None);
532 let tpl_criteria = self.try_template_narrative(finding_type, "criteria", account, None);
533 let tpl_cause = self.try_template_narrative(finding_type, "cause", account, None);
534 let tpl_effect = self.try_template_narrative(finding_type, "effect", account, None);
535 if let (Some(c), Some(cr), Some(ca), Some(ef)) =
536 (&tpl_condition, &tpl_criteria, &tpl_cause, &tpl_effect)
537 {
538 return (c.clone(), cr.clone(), ca.clone(), ef.clone());
539 }
540
541 match finding_type {
542 FindingType::MaterialWeakness
543 | FindingType::SignificantDeficiency
544 | FindingType::ControlDeficiency => {
545 let condition = format!(
546 "During our testing of {} controls, we noted that the control was not operating effectively. \
547 Specifically, {} of {} items tested did not have evidence of the required control activity.",
548 account,
549 self.rng.random_range(2..8),
550 self.rng.random_range(20..40)
551 );
552 let criteria = format!(
553 "Company policy and SOX requirements mandate that all {account} transactions receive appropriate \
554 review and approval prior to processing."
555 );
556 let cause = "Staffing constraints and competing priorities resulted in reduced focus on control execution.".into();
557 let effect = format!(
558 "Transactions may be processed without appropriate oversight, increasing the risk of errors \
559 or fraud in the {account} balance."
560 );
561 (condition, criteria, cause, effect)
562 }
563 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
564 let amount = self.rng.random_range(
565 self.config.misstatement_range.0..self.config.misstatement_range.1,
566 );
567 let condition = format!(
568 "Our testing identified a misstatement in {account} of approximately ${amount}. \
569 The error resulted from incorrect application of accounting standards."
570 );
571 let criteria = "US GAAP and company accounting policy require accurate recording of all transactions.".into();
572 let cause =
573 "Manual calculation error combined with inadequate review procedures.".into();
574 let effect = format!(
575 "The {} balance was {} by ${}, which {}.",
576 account,
577 if self.rng.random::<bool>() {
578 "overstated"
579 } else {
580 "understated"
581 },
582 amount,
583 if finding_type == FindingType::MaterialMisstatement {
584 "represents a material misstatement"
585 } else {
586 "is below materiality but has been communicated to management"
587 }
588 );
589 (condition, criteria, cause, effect)
590 }
591 FindingType::ComplianceException => {
592 let condition = format!(
593 "The Company did not comply with {account} regulatory requirements during the period under audit."
594 );
595 let criteria =
596 "Applicable laws and regulations require timely and accurate compliance."
597 .into();
598 let cause = "Lack of monitoring procedures to track compliance deadlines.".into();
599 let effect =
600 "The Company may be subject to penalties or regulatory scrutiny.".into();
601 (condition, criteria, cause, effect)
602 }
603 _ => {
604 let condition =
605 format!("We identified an opportunity to enhance the {account} process.");
606 let criteria =
607 "Industry best practices suggest continuous improvement in control processes."
608 .into();
609 let cause =
610 "Current processes have not been updated to reflect operational changes."
611 .into();
612 let effect =
613 "Operational efficiency could be improved with process enhancements.".into();
614 (condition, criteria, cause, effect)
615 }
616 }
617 }
618
619 fn generate_recommendation(&mut self, finding_type: FindingType, account: &str) -> String {
621 if let Some(rec) =
623 self.try_template_narrative(finding_type, "recommendation", account, None)
624 {
625 return rec;
626 }
627
628 match finding_type {
629 FindingType::MaterialWeakness | FindingType::SignificantDeficiency => {
630 format!(
631 "We recommend that management: (1) Implement additional review procedures for {account} transactions, \
632 (2) Document all control activities contemporaneously, and \
633 (3) Provide additional training to personnel responsible for control execution."
634 )
635 }
636 FindingType::ControlDeficiency => {
637 format!(
638 "We recommend that management strengthen the {account} control by ensuring timely execution \
639 and documentation of all required review activities."
640 )
641 }
642 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
643 "We recommend that management record the proposed adjusting entry and implement \
644 additional review procedures to prevent similar errors in future periods.".into()
645 }
646 FindingType::ComplianceException => {
647 "We recommend that management implement a compliance calendar with automated reminders \
648 and establish monitoring procedures to ensure timely compliance.".into()
649 }
650 FindingType::ItDeficiency => {
651 "We recommend that IT management review and remediate the identified access control \
652 weaknesses and implement periodic access certification procedures.".into()
653 }
654 _ => {
655 format!(
656 "We recommend that management evaluate the {account} process for potential \
657 efficiency improvements and implement changes as appropriate."
658 )
659 }
660 }
661 }
662
663 fn determine_severity(
665 &mut self,
666 finding_type: FindingType,
667 _finding: &AuditFinding,
668 ) -> FindingSeverity {
669 let base_severity = finding_type.default_severity();
670
671 if self.rng.random::<f64>() < 0.2 {
673 match base_severity {
674 FindingSeverity::Critical => FindingSeverity::High,
675 FindingSeverity::High => {
676 if self.rng.random::<bool>() {
677 FindingSeverity::Critical
678 } else {
679 FindingSeverity::Medium
680 }
681 }
682 FindingSeverity::Medium => {
683 if self.rng.random::<bool>() {
684 FindingSeverity::High
685 } else {
686 FindingSeverity::Low
687 }
688 }
689 FindingSeverity::Low => FindingSeverity::Medium,
690 FindingSeverity::Informational => FindingSeverity::Low,
691 }
692 } else {
693 base_severity
694 }
695 }
696
697 fn is_misstatement_type(&self, finding_type: FindingType) -> bool {
699 matches!(
700 finding_type,
701 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement
702 )
703 }
704
705 fn generate_misstatement_amounts(
707 &mut self,
708 ) -> (Option<Decimal>, Option<Decimal>, Option<Decimal>) {
709 let factual = Decimal::new(
710 self.rng
711 .random_range(self.config.misstatement_range.0..self.config.misstatement_range.1),
712 0,
713 );
714
715 let projected = if self.rng.random::<f64>() < 0.5 {
716 Some(Decimal::new(
717 self.rng
718 .random_range(0..self.config.misstatement_range.1 / 2),
719 0,
720 ))
721 } else {
722 None
723 };
724
725 let judgmental = if self.rng.random::<f64>() < 0.3 {
726 Some(Decimal::new(
727 self.rng
728 .random_range(0..self.config.misstatement_range.1 / 4),
729 0,
730 ))
731 } else {
732 None
733 };
734
735 (Some(factual), projected, judgmental)
736 }
737
738 fn select_assertions(&mut self, finding_type: FindingType) -> Vec<Assertion> {
740 let mut assertions = Vec::new();
741
742 match finding_type {
743 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
744 assertions.push(Assertion::Accuracy);
745 if self.rng.random::<bool>() {
746 assertions.push(Assertion::ValuationAndAllocation);
747 }
748 }
749 FindingType::MaterialWeakness
750 | FindingType::SignificantDeficiency
751 | FindingType::ControlDeficiency => {
752 let possible = [
753 Assertion::Occurrence,
754 Assertion::Completeness,
755 Assertion::Accuracy,
756 Assertion::Classification,
757 ];
758 let count = self.rng.random_range(1..=3);
759 for _ in 0..count {
760 let idx = self.rng.random_range(0..possible.len());
761 if !assertions.contains(&possible[idx]) {
762 assertions.push(possible[idx]);
763 }
764 }
765 }
766 _ => {
767 assertions.push(Assertion::PresentationAndDisclosure);
768 }
769 }
770
771 assertions
772 }
773
774 fn select_process_areas(&mut self, account: &str) -> Vec<String> {
776 let account_lower = account.to_lowercase();
777
778 if account_lower.contains("revenue") || account_lower.contains("receivable") {
779 vec!["Order to Cash".into(), "Revenue Recognition".into()]
780 } else if account_lower.contains("payable") || account_lower.contains("expense") {
781 vec!["Procure to Pay".into(), "Expense Management".into()]
782 } else if account_lower.contains("inventory") {
783 vec!["Inventory Management".into(), "Cost of Goods Sold".into()]
784 } else if account_lower.contains("fixed asset") {
785 vec!["Capital Asset Management".into()]
786 } else if account_lower.contains("it") {
787 vec![
788 "IT General Controls".into(),
789 "IT Application Controls".into(),
790 ]
791 } else if account_lower.contains("payroll") {
792 vec!["Hire to Retire".into(), "Payroll Processing".into()]
793 } else {
794 vec!["Financial Close".into()]
795 }
796 }
797
798 fn generate_management_response(&mut self, finding_type: FindingType, agrees: bool) -> String {
800 if agrees {
801 match finding_type {
802 FindingType::MaterialWeakness | FindingType::SignificantDeficiency => {
803 "Management agrees with the finding and has initiated a remediation plan to \
804 address the identified control deficiency. We expect to complete remediation \
805 prior to the next audit cycle."
806 .into()
807 }
808 FindingType::MaterialMisstatement | FindingType::ImmaterialMisstatement => {
809 "Management agrees with the proposed adjustment and will record the entry. \
810 We have implemented additional review procedures to prevent similar errors."
811 .into()
812 }
813 _ => "Management agrees with the observation and will implement the recommended \
814 improvements as resources permit."
815 .into(),
816 }
817 } else {
818 "Management respectfully disagrees with the finding. We believe that existing \
819 controls are adequate and operating effectively. We will provide additional \
820 documentation to support our position."
821 .into()
822 }
823 }
824
825 fn generate_remediation_plan(
827 &mut self,
828 finding: &AuditFinding,
829 account: &str,
830 ) -> RemediationPlan {
831 let target_date = finding.identified_date + Duration::days(self.rng.random_range(60..180));
832
833 let description = format!(
834 "Implement enhanced controls and monitoring procedures for {account} to address \
835 the identified deficiency. This includes updated policies, additional training, \
836 and implementation of automated controls where feasible."
837 );
838
839 let responsible_party = format!(
840 "{} Manager",
841 if account.to_lowercase().contains("it") {
842 "IT"
843 } else {
844 "Controller"
845 }
846 );
847
848 let mut plan = RemediationPlan::new(
849 finding.finding_id,
850 &description,
851 &responsible_party,
852 target_date,
853 );
854
855 plan.validation_approach =
856 "Auditor will test remediated controls during the next audit cycle.".into();
857
858 let milestone_dates = [
860 (
861 finding.identified_date + Duration::days(30),
862 "Complete root cause analysis",
863 ),
864 (
865 finding.identified_date + Duration::days(60),
866 "Document updated control procedures",
867 ),
868 (
869 finding.identified_date + Duration::days(90),
870 "Implement control changes",
871 ),
872 (target_date, "Complete testing and validation"),
873 ];
874
875 for (date, desc) in milestone_dates {
876 plan.add_milestone(desc, date);
877 }
878
879 if self.rng.random::<f64>() < 0.3 {
881 plan.status = RemediationStatus::InProgress;
882 if !plan.milestones.is_empty() {
883 plan.milestones[0].status = MilestoneStatus::Complete;
884 plan.milestones[0].completion_date = Some(plan.milestones[0].target_date);
885 }
886 }
887
888 plan
889 }
890
891 fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
893 let matching: Vec<&String> = team_members
894 .iter()
895 .filter(|m| m.to_lowercase().contains(role_hint))
896 .collect();
897
898 if let Some(&member) = matching.first() {
899 member.clone()
900 } else if !team_members.is_empty() {
901 let idx = self.rng.random_range(0..team_members.len());
902 team_members[idx].clone()
903 } else {
904 format!("{}001", role_hint.to_uppercase())
905 }
906 }
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912 use crate::audit::test_helpers::create_test_engagement;
913
914 #[test]
915 fn test_finding_generation() {
916 let mut generator = FindingGenerator::new(42);
917 let engagement = create_test_engagement();
918 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
919
920 let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
921
922 assert!(!findings.is_empty());
923 for finding in &findings {
924 assert!(!finding.condition.is_empty());
925 assert!(!finding.criteria.is_empty());
926 assert!(!finding.recommendation.is_empty());
927 }
928 }
929
930 #[test]
931 fn test_finding_types_distribution() {
932 let mut generator = FindingGenerator::new(42);
933 let engagement = create_test_engagement();
934 let team = vec!["STAFF001".into()];
935
936 let config = FindingGeneratorConfig {
938 findings_per_engagement: (50, 50),
939 ..Default::default()
940 };
941 generator.config = config;
942
943 let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
944
945 let material_weaknesses = findings
946 .iter()
947 .filter(|f| f.finding_type == FindingType::MaterialWeakness)
948 .count();
949 let significant_deficiencies = findings
950 .iter()
951 .filter(|f| f.finding_type == FindingType::SignificantDeficiency)
952 .count();
953
954 assert!(material_weaknesses < 10);
956 assert!(significant_deficiencies > material_weaknesses);
958 }
959
960 #[test]
961 fn test_misstatement_finding() {
962 let config = FindingGeneratorConfig {
963 misstatement_probability: 1.0,
964 material_weakness_probability: 0.0,
965 significant_deficiency_probability: 0.0,
966 ..Default::default()
967 };
968 let mut generator = FindingGenerator::with_config(42, config);
969 let engagement = create_test_engagement();
970
971 let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
972
973 assert!(finding.is_misstatement);
974 assert!(finding.factual_misstatement.is_some() || finding.projected_misstatement.is_some());
975 }
976
977 #[test]
978 fn test_remediation_plan() {
979 let config = FindingGeneratorConfig {
980 remediation_plan_probability: 1.0,
981 management_agrees_probability: 1.0,
982 ..Default::default()
983 };
984 let mut generator = FindingGenerator::with_config(42, config);
985 let engagement = create_test_engagement();
986
987 let findings =
988 generator.generate_findings_for_engagement(&engagement, &[], &["STAFF001".into()]);
989
990 let with_plans = findings
992 .iter()
993 .filter(|f| f.remediation_plan.is_some())
994 .count();
995 assert!(with_plans > 0);
996
997 for finding in findings.iter().filter(|f| f.remediation_plan.is_some()) {
998 let plan = finding.remediation_plan.as_ref().unwrap();
999 assert!(!plan.description.is_empty());
1000 assert!(!plan.milestones.is_empty());
1001 }
1002 }
1003
1004 #[test]
1005 fn test_governance_communication() {
1006 let config = FindingGeneratorConfig {
1007 material_weakness_probability: 1.0,
1008 ..Default::default()
1009 };
1010 let mut generator = FindingGenerator::with_config(42, config);
1011 let engagement = create_test_engagement();
1012
1013 let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
1014
1015 assert!(finding.report_to_governance);
1016 assert!(finding.include_in_management_letter);
1017 }
1018
1019 #[test]
1020 fn test_generate_findings_with_context_links_controls_and_risks() {
1021 use datasynth_core::models::audit::Assertion;
1022
1023 let mut generator = FindingGenerator::new(42);
1024 let engagement = create_test_engagement();
1025 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
1026
1027 let controls = vec![
1028 AvailableControl {
1029 control_id: "CTRL-001".into(),
1030 assertions: vec![Assertion::Accuracy, Assertion::Completeness],
1031 process_areas: vec!["Revenue Recognition".into()],
1032 },
1033 AvailableControl {
1034 control_id: "CTRL-002".into(),
1035 assertions: vec![Assertion::Occurrence],
1036 process_areas: vec!["Procure to Pay".into()],
1037 },
1038 ];
1039
1040 let risks = vec![AvailableRisk {
1041 risk_id: "RISK-001".into(),
1042 engagement_id: engagement.engagement_id,
1043 account_or_process: "Revenue".into(),
1044 }];
1045
1046 let findings =
1047 generator.generate_findings_with_context(&engagement, &[], &team, &controls, &risks);
1048
1049 assert!(!findings.is_empty());
1050
1051 for finding in &findings {
1053 let has_controls = !finding.related_control_ids.is_empty();
1054 let has_risk = finding.related_risk_id.is_some();
1055 assert!(
1056 has_controls || has_risk,
1057 "Finding {} should have related controls or risk",
1058 finding.finding_ref
1059 );
1060 }
1061
1062 let with_risk = findings
1064 .iter()
1065 .filter(|f| f.related_risk_id.is_some())
1066 .count();
1067 assert!(with_risk > 0, "At least one finding should link to a risk");
1068 }
1069
1070 #[test]
1071 fn test_generate_findings_with_context_caps_controls_at_three() {
1072 use datasynth_core::models::audit::Assertion;
1073
1074 let config = FindingGeneratorConfig {
1075 findings_per_engagement: (5, 5),
1076 ..Default::default()
1077 };
1078 let mut generator = FindingGenerator::with_config(42, config);
1079 let engagement = create_test_engagement();
1080 let team = vec!["STAFF001".into()];
1081
1082 let controls: Vec<AvailableControl> = (0..10)
1084 .map(|i| AvailableControl {
1085 control_id: format!("CTRL-{:03}", i),
1086 assertions: vec![
1087 Assertion::Accuracy,
1088 Assertion::Completeness,
1089 Assertion::Occurrence,
1090 Assertion::Classification,
1091 ],
1092 process_areas: vec![
1093 "Revenue Recognition".into(),
1094 "Procure to Pay".into(),
1095 "Financial Close".into(),
1096 ],
1097 })
1098 .collect();
1099
1100 let findings =
1101 generator.generate_findings_with_context(&engagement, &[], &team, &controls, &[]);
1102
1103 for finding in &findings {
1104 assert!(
1105 finding.related_control_ids.len() <= 3,
1106 "Finding {} has {} controls, expected max 3",
1107 finding.finding_ref,
1108 finding.related_control_ids.len()
1109 );
1110 }
1111 }
1112
1113 #[test]
1114 fn test_workpaper_id_populated_from_workpaper_refs() {
1115 let mut generator = FindingGenerator::new(42);
1116 let engagement = create_test_engagement();
1117 let team = vec!["STAFF001".into()];
1118
1119 use datasynth_core::models::audit::{Workpaper, WorkpaperSection};
1121 let workpaper = Workpaper::new(
1122 engagement.engagement_id,
1123 "WP-001",
1124 "Test Workpaper",
1125 WorkpaperSection::ControlTesting,
1126 );
1127
1128 let findings = generator.generate_findings_for_engagement(&engagement, &[workpaper], &team);
1129
1130 for finding in &findings {
1132 assert!(
1133 !finding.workpaper_refs.is_empty(),
1134 "Finding should have workpaper refs when workpapers provided"
1135 );
1136 assert!(
1137 finding.workpaper_id.is_some(),
1138 "Finding should have workpaper_id set when workpaper_refs is populated"
1139 );
1140 }
1141 }
1142}