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)]
910#[allow(clippy::unwrap_used)]
911mod tests {
912 use super::*;
913 use crate::audit::test_helpers::create_test_engagement;
914
915 #[test]
916 fn test_finding_generation() {
917 let mut generator = FindingGenerator::new(42);
918 let engagement = create_test_engagement();
919 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
920
921 let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
922
923 assert!(!findings.is_empty());
924 for finding in &findings {
925 assert!(!finding.condition.is_empty());
926 assert!(!finding.criteria.is_empty());
927 assert!(!finding.recommendation.is_empty());
928 }
929 }
930
931 #[test]
932 fn test_finding_types_distribution() {
933 let mut generator = FindingGenerator::new(42);
934 let engagement = create_test_engagement();
935 let team = vec!["STAFF001".into()];
936
937 let config = FindingGeneratorConfig {
939 findings_per_engagement: (50, 50),
940 ..Default::default()
941 };
942 generator.config = config;
943
944 let findings = generator.generate_findings_for_engagement(&engagement, &[], &team);
945
946 let material_weaknesses = findings
947 .iter()
948 .filter(|f| f.finding_type == FindingType::MaterialWeakness)
949 .count();
950 let significant_deficiencies = findings
951 .iter()
952 .filter(|f| f.finding_type == FindingType::SignificantDeficiency)
953 .count();
954
955 assert!(material_weaknesses < 10);
957 assert!(significant_deficiencies > material_weaknesses);
959 }
960
961 #[test]
962 fn test_misstatement_finding() {
963 let config = FindingGeneratorConfig {
964 misstatement_probability: 1.0,
965 material_weakness_probability: 0.0,
966 significant_deficiency_probability: 0.0,
967 ..Default::default()
968 };
969 let mut generator = FindingGenerator::with_config(42, config);
970 let engagement = create_test_engagement();
971
972 let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
973
974 assert!(finding.is_misstatement);
975 assert!(finding.factual_misstatement.is_some() || finding.projected_misstatement.is_some());
976 }
977
978 #[test]
979 fn test_remediation_plan() {
980 let config = FindingGeneratorConfig {
981 remediation_plan_probability: 1.0,
982 management_agrees_probability: 1.0,
983 ..Default::default()
984 };
985 let mut generator = FindingGenerator::with_config(42, config);
986 let engagement = create_test_engagement();
987
988 let findings =
989 generator.generate_findings_for_engagement(&engagement, &[], &["STAFF001".into()]);
990
991 let with_plans = findings
993 .iter()
994 .filter(|f| f.remediation_plan.is_some())
995 .count();
996 assert!(with_plans > 0);
997
998 for finding in findings.iter().filter(|f| f.remediation_plan.is_some()) {
999 let plan = finding.remediation_plan.as_ref().unwrap();
1000 assert!(!plan.description.is_empty());
1001 assert!(!plan.milestones.is_empty());
1002 }
1003 }
1004
1005 #[test]
1006 fn test_governance_communication() {
1007 let config = FindingGeneratorConfig {
1008 material_weakness_probability: 1.0,
1009 ..Default::default()
1010 };
1011 let mut generator = FindingGenerator::with_config(42, config);
1012 let engagement = create_test_engagement();
1013
1014 let finding = generator.generate_finding(&engagement, &[], &["STAFF001".into()]);
1015
1016 assert!(finding.report_to_governance);
1017 assert!(finding.include_in_management_letter);
1018 }
1019
1020 #[test]
1021 fn test_generate_findings_with_context_links_controls_and_risks() {
1022 use datasynth_core::models::audit::Assertion;
1023
1024 let mut generator = FindingGenerator::new(42);
1025 let engagement = create_test_engagement();
1026 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
1027
1028 let controls = vec![
1029 AvailableControl {
1030 control_id: "CTRL-001".into(),
1031 assertions: vec![Assertion::Accuracy, Assertion::Completeness],
1032 process_areas: vec!["Revenue Recognition".into()],
1033 },
1034 AvailableControl {
1035 control_id: "CTRL-002".into(),
1036 assertions: vec![Assertion::Occurrence],
1037 process_areas: vec!["Procure to Pay".into()],
1038 },
1039 ];
1040
1041 let risks = vec![AvailableRisk {
1042 risk_id: "RISK-001".into(),
1043 engagement_id: engagement.engagement_id,
1044 account_or_process: "Revenue".into(),
1045 }];
1046
1047 let findings =
1048 generator.generate_findings_with_context(&engagement, &[], &team, &controls, &risks);
1049
1050 assert!(!findings.is_empty());
1051
1052 for finding in &findings {
1054 let has_controls = !finding.related_control_ids.is_empty();
1055 let has_risk = finding.related_risk_id.is_some();
1056 assert!(
1057 has_controls || has_risk,
1058 "Finding {} should have related controls or risk",
1059 finding.finding_ref
1060 );
1061 }
1062
1063 let with_risk = findings
1065 .iter()
1066 .filter(|f| f.related_risk_id.is_some())
1067 .count();
1068 assert!(with_risk > 0, "At least one finding should link to a risk");
1069 }
1070
1071 #[test]
1072 fn test_generate_findings_with_context_caps_controls_at_three() {
1073 use datasynth_core::models::audit::Assertion;
1074
1075 let config = FindingGeneratorConfig {
1076 findings_per_engagement: (5, 5),
1077 ..Default::default()
1078 };
1079 let mut generator = FindingGenerator::with_config(42, config);
1080 let engagement = create_test_engagement();
1081 let team = vec!["STAFF001".into()];
1082
1083 let controls: Vec<AvailableControl> = (0..10)
1085 .map(|i| AvailableControl {
1086 control_id: format!("CTRL-{:03}", i),
1087 assertions: vec![
1088 Assertion::Accuracy,
1089 Assertion::Completeness,
1090 Assertion::Occurrence,
1091 Assertion::Classification,
1092 ],
1093 process_areas: vec![
1094 "Revenue Recognition".into(),
1095 "Procure to Pay".into(),
1096 "Financial Close".into(),
1097 ],
1098 })
1099 .collect();
1100
1101 let findings =
1102 generator.generate_findings_with_context(&engagement, &[], &team, &controls, &[]);
1103
1104 for finding in &findings {
1105 assert!(
1106 finding.related_control_ids.len() <= 3,
1107 "Finding {} has {} controls, expected max 3",
1108 finding.finding_ref,
1109 finding.related_control_ids.len()
1110 );
1111 }
1112 }
1113
1114 #[test]
1115 fn test_workpaper_id_populated_from_workpaper_refs() {
1116 let mut generator = FindingGenerator::new(42);
1117 let engagement = create_test_engagement();
1118 let team = vec!["STAFF001".into()];
1119
1120 use datasynth_core::models::audit::{Workpaper, WorkpaperSection};
1122 let workpaper = Workpaper::new(
1123 engagement.engagement_id,
1124 "WP-001",
1125 "Test Workpaper",
1126 WorkpaperSection::ControlTesting,
1127 );
1128
1129 let findings = generator.generate_findings_for_engagement(&engagement, &[workpaper], &team);
1130
1131 for finding in &findings {
1133 assert!(
1134 !finding.workpaper_refs.is_empty(),
1135 "Finding should have workpaper refs when workpapers provided"
1136 );
1137 assert!(
1138 finding.workpaper_id.is_some(),
1139 "Finding should have workpaper_id set when workpaper_refs is populated"
1140 );
1141 }
1142 }
1143}