Skip to main content

datasynth_generators/audit/
finding_generator.rs

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