Skip to main content

datasynth_generators/audit/
workpaper_generator.rs

1//! Workpaper generator for audit engagements.
2//!
3//! Generates audit workpapers with appropriate procedures, sampling,
4//! results, and review sign-offs per ISA 230.
5
6use chrono::{Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10
11use rust_decimal::Decimal;
12
13use datasynth_core::models::audit::{
14    Assertion, AuditEngagement, EngagementPhase, ProcedureType, RiskLevel, SamplingMethod,
15    Workpaper, WorkpaperConclusion, WorkpaperScope, WorkpaperSection, WorkpaperStatus,
16};
17
18/// Configuration for workpaper generation.
19#[derive(Debug, Clone)]
20pub struct WorkpaperGeneratorConfig {
21    /// Number of workpapers per section (min, max)
22    pub workpapers_per_section: (u32, u32),
23    /// Population size range for testing (min, max)
24    pub population_size_range: (u64, u64),
25    /// Sample size as percentage of population (min, max)
26    pub sample_percentage_range: (f64, f64),
27    /// Exception rate range (min, max)
28    pub exception_rate_range: (f64, f64),
29    /// Probability of unsatisfactory conclusion
30    pub unsatisfactory_probability: f64,
31    /// Days between preparation and first review (min, max)
32    pub first_review_delay_range: (u32, u32),
33    /// Days between first and second review (min, max)
34    pub second_review_delay_range: (u32, u32),
35}
36
37impl Default for WorkpaperGeneratorConfig {
38    fn default() -> Self {
39        Self {
40            workpapers_per_section: (3, 10),
41            population_size_range: (100, 10000),
42            sample_percentage_range: (0.01, 0.10),
43            exception_rate_range: (0.0, 0.08),
44            unsatisfactory_probability: 0.05,
45            first_review_delay_range: (1, 5),
46            second_review_delay_range: (1, 3),
47        }
48    }
49}
50
51/// Context for generating coherent workpapers with real financial data.
52///
53/// When provided, the workpaper title, objective, procedure, and scope are
54/// enriched with concrete financial figures and risk context from the
55/// engagement's artifact bag.
56#[derive(Debug, Clone, Default)]
57pub struct WorkpaperEnrichment {
58    /// Account area name (e.g., "Revenue", "Trade Receivables").
59    pub account_area: Option<String>,
60    /// Real GL balance for the area.
61    pub account_balance: Option<Decimal>,
62    /// CRA risk level.
63    pub risk_level: Option<String>, // "High", "Moderate", "Low", "Minimal"
64    /// Performance materiality.
65    pub materiality: Option<Decimal>,
66    /// Sampling plan details (pre-formatted string for simplicity).
67    pub sampling_info: Option<String>,
68}
69
70/// Generator for audit workpapers.
71pub struct WorkpaperGenerator {
72    /// Random number generator
73    rng: ChaCha8Rng,
74    /// Configuration
75    config: WorkpaperGeneratorConfig,
76    /// Counter per section for references
77    section_counters: std::collections::HashMap<WorkpaperSection, u32>,
78}
79
80impl WorkpaperGenerator {
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: WorkpaperGeneratorConfig::default(),
86            section_counters: std::collections::HashMap::new(),
87        }
88    }
89
90    /// Create a new generator with custom configuration.
91    pub fn with_config(seed: u64, config: WorkpaperGeneratorConfig) -> Self {
92        Self {
93            rng: seeded_rng(seed, 0),
94            config,
95            section_counters: std::collections::HashMap::new(),
96        }
97    }
98
99    /// Generate workpapers for an engagement phase.
100    pub fn generate_workpapers_for_phase(
101        &mut self,
102        engagement: &AuditEngagement,
103        phase: EngagementPhase,
104        phase_date: NaiveDate,
105        team_members: &[String],
106    ) -> Vec<Workpaper> {
107        let section = match phase {
108            EngagementPhase::Planning => WorkpaperSection::Planning,
109            EngagementPhase::RiskAssessment => WorkpaperSection::RiskAssessment,
110            EngagementPhase::ControlTesting => WorkpaperSection::ControlTesting,
111            EngagementPhase::SubstantiveTesting => WorkpaperSection::SubstantiveTesting,
112            EngagementPhase::Completion => WorkpaperSection::Completion,
113            EngagementPhase::Reporting => WorkpaperSection::Reporting,
114        };
115
116        let count = self.rng.random_range(
117            self.config.workpapers_per_section.0..=self.config.workpapers_per_section.1,
118        );
119
120        (0..count)
121            .map(|_| self.generate_workpaper(engagement, section, phase_date, team_members))
122            .collect()
123    }
124
125    /// Generate a single workpaper.
126    pub fn generate_workpaper(
127        &mut self,
128        engagement: &AuditEngagement,
129        section: WorkpaperSection,
130        base_date: NaiveDate,
131        team_members: &[String],
132    ) -> Workpaper {
133        let counter = self.section_counters.entry(section).or_insert(0);
134        *counter += 1;
135
136        let workpaper_ref = format!("{}-{:03}", section.reference_prefix(), counter);
137        let title = self.generate_workpaper_title(section);
138
139        let mut wp = Workpaper::new(engagement.engagement_id, &workpaper_ref, &title, section);
140
141        // Set objective and assertions
142        let (objective, assertions) = self.generate_objective_and_assertions(section);
143        wp = wp.with_objective(&objective, assertions);
144
145        // Set procedure
146        let (procedure, procedure_type) = self.generate_procedure(section);
147        wp = wp.with_procedure(&procedure, procedure_type);
148
149        // Set scope and sampling
150        let (scope, population, sample, method) = self.generate_scope_and_sampling(section);
151        wp = wp.with_scope(scope, population, sample, method);
152
153        // Set results
154        let (summary, exceptions, conclusion) =
155            self.generate_results(sample, &engagement.overall_audit_risk);
156        wp = wp.with_results(&summary, exceptions, conclusion);
157
158        wp.risk_level_addressed = engagement.overall_audit_risk;
159
160        // Set preparer
161        let preparer = self.select_team_member(team_members, "staff");
162        let preparer_name = self.generate_auditor_name();
163        wp = wp.with_preparer(&preparer, &preparer_name, base_date);
164
165        // Add first review
166        let first_review_delay = self.rng.random_range(
167            self.config.first_review_delay_range.0..=self.config.first_review_delay_range.1,
168        );
169        let first_review_date = base_date + Duration::days(first_review_delay as i64);
170        let reviewer = self.select_team_member(team_members, "senior");
171        let reviewer_name = self.generate_auditor_name();
172        wp.add_first_review(&reviewer, &reviewer_name, first_review_date);
173
174        // Maybe add second review
175        if self.rng.random::<f64>() < 0.7 {
176            let second_review_delay = self.rng.random_range(
177                self.config.second_review_delay_range.0..=self.config.second_review_delay_range.1,
178            );
179            let second_review_date = first_review_date + Duration::days(second_review_delay as i64);
180            let second_reviewer = self.select_team_member(team_members, "manager");
181            let second_reviewer_name = self.generate_auditor_name();
182            wp.add_second_review(&second_reviewer, &second_reviewer_name, second_review_date);
183        } else {
184            wp.status = WorkpaperStatus::FirstReviewComplete;
185        }
186
187        // Maybe add review notes
188        if self.rng.random::<f64>() < 0.30 {
189            let note = self.generate_review_note();
190            wp.add_review_note(&reviewer, &note);
191        }
192
193        wp
194    }
195
196    /// Generate a workpaper enriched with real financial data and risk context.
197    ///
198    /// Produces the base workpaper via [`generate_workpaper`] and then enriches
199    /// the title, objective, procedure, and scope fields using the provided
200    /// [`WorkpaperEnrichment`] context.
201    pub fn generate_workpaper_with_context(
202        &mut self,
203        engagement: &AuditEngagement,
204        section: WorkpaperSection,
205        base_date: NaiveDate,
206        team_members: &[String],
207        enrichment: &WorkpaperEnrichment,
208    ) -> Workpaper {
209        let mut wp = self.generate_workpaper(engagement, section, base_date, team_members);
210
211        // --- Title enrichment ---
212        if let (Some(area), Some(risk)) = (&enrichment.account_area, &enrichment.risk_level) {
213            wp.title = format!("{} \u{2014} {} Risk", area, risk);
214        } else if let Some(area) = &enrichment.account_area {
215            wp.title = format!("{} {}", area, wp.title);
216        }
217
218        // --- Objective enrichment ---
219        let mut addenda = Vec::new();
220        if let Some(balance) = enrichment.account_balance {
221            addenda.push(format!("GL Balance: ${}", balance));
222        }
223        if let Some(mat) = enrichment.materiality {
224            addenda.push(format!("Performance materiality: ${}", mat));
225        }
226        if !addenda.is_empty() {
227            wp.objective = format!("{} | {}", wp.objective, addenda.join(". "));
228        }
229
230        // --- Procedure enrichment ---
231        if let Some(sampling) = &enrichment.sampling_info {
232            wp.procedure_performed = format!("{} | Sample: {}", wp.procedure_performed, sampling);
233        }
234
235        // --- Scope adjustment by risk level ---
236        if let Some(risk) = &enrichment.risk_level {
237            let (lo, hi) = match risk.as_str() {
238                "High" => (95.0, 100.0),
239                "Moderate" => (75.0, 90.0),
240                "Low" | "Minimal" => (50.0, 70.0),
241                _ => (70.0, 100.0),
242            };
243            wp.scope.coverage_percentage = self.rng.random_range(lo..hi);
244        }
245
246        wp
247    }
248
249    /// Generate workpaper title based on section.
250    fn generate_workpaper_title(&mut self, section: WorkpaperSection) -> String {
251        let titles = match section {
252            WorkpaperSection::Planning => vec![
253                "Engagement Planning Memo",
254                "Understanding the Entity and Environment",
255                "Materiality Assessment",
256                "Preliminary Analytical Procedures",
257                "Risk Assessment Summary",
258                "Audit Strategy and Approach",
259                "Staffing and Resource Plan",
260                "Client Acceptance Procedures",
261            ],
262            WorkpaperSection::RiskAssessment => vec![
263                "Business Risk Assessment",
264                "Fraud Risk Evaluation",
265                "IT General Controls Assessment",
266                "Internal Control Evaluation",
267                "Significant Account Identification",
268                "Risk of Material Misstatement Assessment",
269                "Related Party Risk Assessment",
270                "Going Concern Assessment",
271            ],
272            WorkpaperSection::ControlTesting => vec![
273                "Revenue Recognition Controls Testing",
274                "Purchase and Payables Controls Testing",
275                "Treasury and Cash Controls Testing",
276                "Payroll Controls Testing",
277                "Fixed Asset Controls Testing",
278                "Inventory Controls Testing",
279                "IT Application Controls Testing",
280                "Entity Level Controls Testing",
281            ],
282            WorkpaperSection::SubstantiveTesting => vec![
283                "Revenue Cutoff Testing",
284                "Accounts Receivable Confirmation",
285                "Inventory Observation and Testing",
286                "Fixed Asset Verification",
287                "Accounts Payable Completeness",
288                "Expense Testing",
289                "Debt and Interest Testing",
290                "Bank Reconciliation Review",
291                "Journal Entry Testing",
292                "Analytical Procedures - Revenue",
293                "Analytical Procedures - Expenses",
294            ],
295            WorkpaperSection::Completion => vec![
296                "Subsequent Events Review",
297                "Management Representation Letter",
298                "Attorney Letter Summary",
299                "Going Concern Evaluation",
300                "Summary of Uncorrected Misstatements",
301                "Summary of Audit Differences",
302                "Completion Checklist",
303            ],
304            WorkpaperSection::Reporting => vec![
305                "Draft Financial Statements Review",
306                "Disclosure Checklist",
307                "Communication with Those Charged with Governance",
308                "Report Issuance Checklist",
309            ],
310            WorkpaperSection::PermanentFile => vec![
311                "Chart of Accounts",
312                "Organization Structure",
313                "Key Contracts Summary",
314                "Related Party Identification",
315            ],
316        };
317
318        let idx = self.rng.random_range(0..titles.len());
319        titles[idx].to_string()
320    }
321
322    /// Generate objective and assertions for a section.
323    fn generate_objective_and_assertions(
324        &mut self,
325        section: WorkpaperSection,
326    ) -> (String, Vec<Assertion>) {
327        match section {
328            WorkpaperSection::Planning | WorkpaperSection::RiskAssessment => (
329                "Understand the entity and assess risks of material misstatement".into(),
330                vec![],
331            ),
332            WorkpaperSection::ControlTesting => (
333                "Test the operating effectiveness of key controls".into(),
334                Assertion::transaction_assertions(),
335            ),
336            WorkpaperSection::SubstantiveTesting => {
337                let assertions = if self.rng.random::<f64>() < 0.5 {
338                    Assertion::transaction_assertions()
339                } else {
340                    Assertion::balance_assertions()
341                };
342                (
343                    "Obtain sufficient appropriate audit evidence regarding account balances"
344                        .into(),
345                    assertions,
346                )
347            }
348            WorkpaperSection::Completion => (
349                "Complete all required completion procedures".into(),
350                vec![
351                    Assertion::Completeness,
352                    Assertion::PresentationAndDisclosure,
353                ],
354            ),
355            WorkpaperSection::Reporting => (
356                "Ensure compliance with reporting requirements".into(),
357                vec![Assertion::PresentationAndDisclosure],
358            ),
359            WorkpaperSection::PermanentFile => {
360                ("Maintain permanent file documentation".into(), vec![])
361            }
362        }
363    }
364
365    /// Generate procedure description and type.
366    fn generate_procedure(&mut self, section: WorkpaperSection) -> (String, ProcedureType) {
367        match section {
368            WorkpaperSection::Planning | WorkpaperSection::RiskAssessment => (
369                "Performed inquiries and reviewed documentation".into(),
370                ProcedureType::InquiryObservation,
371            ),
372            WorkpaperSection::ControlTesting => {
373                let procedures = [
374                    (
375                        "Selected a sample of transactions and tested the control operation",
376                        ProcedureType::TestOfControls,
377                    ),
378                    (
379                        "Observed the control being performed by personnel",
380                        ProcedureType::InquiryObservation,
381                    ),
382                    (
383                        "Inspected documentation of control performance",
384                        ProcedureType::Inspection,
385                    ),
386                    (
387                        "Reperformed the control procedure",
388                        ProcedureType::Reperformance,
389                    ),
390                ];
391                let idx = self.rng.random_range(0..procedures.len());
392                (procedures[idx].0.into(), procedures[idx].1)
393            }
394            WorkpaperSection::SubstantiveTesting => {
395                let procedures = [
396                    (
397                        "Selected a sample and agreed details to supporting documentation",
398                        ProcedureType::SubstantiveTest,
399                    ),
400                    (
401                        "Sent confirmations and agreed responses to records",
402                        ProcedureType::Confirmation,
403                    ),
404                    (
405                        "Recalculated amounts and agreed to supporting schedules",
406                        ProcedureType::Recalculation,
407                    ),
408                    (
409                        "Performed analytical procedures and investigated variances",
410                        ProcedureType::AnalyticalProcedures,
411                    ),
412                    (
413                        "Inspected physical assets and documentation",
414                        ProcedureType::Inspection,
415                    ),
416                ];
417                let idx = self.rng.random_range(0..procedures.len());
418                (procedures[idx].0.into(), procedures[idx].1)
419            }
420            WorkpaperSection::Completion | WorkpaperSection::Reporting => (
421                "Reviewed documentation and performed inquiries".into(),
422                ProcedureType::InquiryObservation,
423            ),
424            WorkpaperSection::PermanentFile => (
425                "Compiled and organized permanent file documentation".into(),
426                ProcedureType::Inspection,
427            ),
428        }
429    }
430
431    /// Generate scope and sampling details.
432    fn generate_scope_and_sampling(
433        &mut self,
434        section: WorkpaperSection,
435    ) -> (WorkpaperScope, u64, u32, SamplingMethod) {
436        let scope = WorkpaperScope {
437            coverage_percentage: self.rng.random_range(70.0..100.0),
438            period_start: None,
439            period_end: None,
440            limitations: Vec::new(),
441        };
442
443        match section {
444            WorkpaperSection::ControlTesting | WorkpaperSection::SubstantiveTesting => {
445                let population = self.rng.random_range(
446                    self.config.population_size_range.0..=self.config.population_size_range.1,
447                );
448                let sample_pct = self.rng.random_range(
449                    self.config.sample_percentage_range.0..=self.config.sample_percentage_range.1,
450                );
451                let sample = ((population as f64 * sample_pct).max(25.0) as u32).min(200);
452
453                let method = if self.rng.random::<f64>() < 0.4 {
454                    SamplingMethod::StatisticalRandom
455                } else if self.rng.random::<f64>() < 0.3 {
456                    SamplingMethod::MonetaryUnit
457                } else {
458                    SamplingMethod::Judgmental
459                };
460
461                (scope, population, sample, method)
462            }
463            _ => (scope, 0, 0, SamplingMethod::Judgmental),
464        }
465    }
466
467    /// Generate test results.
468    fn generate_results(
469        &mut self,
470        sample_size: u32,
471        risk_level: &RiskLevel,
472    ) -> (String, u32, WorkpaperConclusion) {
473        if sample_size == 0 {
474            return (
475                "Procedures completed without exception".into(),
476                0,
477                WorkpaperConclusion::Satisfactory,
478            );
479        }
480
481        // Higher risk = higher chance of exceptions
482        let exception_probability = match risk_level {
483            RiskLevel::Low => 0.10,
484            RiskLevel::Medium => 0.25,
485            RiskLevel::High | RiskLevel::Significant => 0.40,
486        };
487
488        let has_exceptions = self.rng.random::<f64>() < exception_probability;
489
490        let (exceptions, conclusion) = if has_exceptions {
491            let exception_rate = self.rng.random_range(
492                self.config.exception_rate_range.0..=self.config.exception_rate_range.1,
493            );
494            let exceptions =
495                ((sample_size as f64 * exception_rate).max(1.0) as u32).min(sample_size);
496
497            let conclusion = if self.rng.random::<f64>() < self.config.unsatisfactory_probability {
498                WorkpaperConclusion::Unsatisfactory
499            } else {
500                WorkpaperConclusion::SatisfactoryWithExceptions
501            };
502
503            (exceptions, conclusion)
504        } else {
505            (0, WorkpaperConclusion::Satisfactory)
506        };
507
508        let summary = match conclusion {
509            WorkpaperConclusion::Satisfactory => {
510                format!("Tested {sample_size} items with no exceptions noted")
511            }
512            WorkpaperConclusion::SatisfactoryWithExceptions => {
513                format!(
514                    "Tested {sample_size} items with {exceptions} exceptions noted. Exceptions were immaterial and have been evaluated"
515                )
516            }
517            WorkpaperConclusion::Unsatisfactory => {
518                format!(
519                    "Tested {sample_size} items with {exceptions} exceptions noted. Exceptions represent material misstatement requiring adjustment"
520                )
521            }
522            _ => format!("Tested {sample_size} items"),
523        };
524
525        (summary, exceptions, conclusion)
526    }
527
528    /// Select a team member based on role prefix.
529    fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
530        let matching: Vec<&String> = team_members
531            .iter()
532            .filter(|m| m.to_lowercase().contains(role_hint))
533            .collect();
534
535        if let Some(&member) = matching.first() {
536            member.clone()
537        } else if !team_members.is_empty() {
538            let idx = self.rng.random_range(0..team_members.len());
539            team_members[idx].clone()
540        } else {
541            format!("{}001", role_hint.to_uppercase())
542        }
543    }
544
545    /// Generate a plausible auditor name.
546    fn generate_auditor_name(&mut self) -> String {
547        let first_names = [
548            "Michael", "Sarah", "David", "Jennifer", "Robert", "Emily", "James", "Amanda",
549        ];
550        let last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Davis"];
551
552        let first_idx = self.rng.random_range(0..first_names.len());
553        let last_idx = self.rng.random_range(0..last_names.len());
554
555        format!("{} {}", first_names[first_idx], last_names[last_idx])
556    }
557
558    /// Generate a review note comment.
559    fn generate_review_note(&mut self) -> String {
560        let notes = [
561            "Please expand on the rationale for sample selection",
562            "Cross-reference needed to risk assessment workpaper",
563            "Please document conclusion more clearly",
564            "Need to include population definition",
565            "Please add reference to prior year workpaper",
566            "Document discussion with management regarding exceptions",
567            "Clarify testing approach for this control",
568            "Add evidence reference for supporting documentation",
569        ];
570
571        let idx = self.rng.random_range(0..notes.len());
572        notes[idx].to_string()
573    }
574
575    /// Generate all workpapers for a complete engagement.
576    pub fn generate_complete_workpaper_set(
577        &mut self,
578        engagement: &AuditEngagement,
579        team_members: &[String],
580    ) -> Vec<Workpaper> {
581        let mut all_workpapers = Vec::new();
582
583        // Planning workpapers
584        all_workpapers.extend(self.generate_workpapers_for_phase(
585            engagement,
586            EngagementPhase::Planning,
587            engagement.planning_start,
588            team_members,
589        ));
590
591        // Risk assessment workpapers
592        all_workpapers.extend(self.generate_workpapers_for_phase(
593            engagement,
594            EngagementPhase::RiskAssessment,
595            engagement.planning_end,
596            team_members,
597        ));
598
599        // Control testing workpapers
600        all_workpapers.extend(self.generate_workpapers_for_phase(
601            engagement,
602            EngagementPhase::ControlTesting,
603            engagement.fieldwork_start,
604            team_members,
605        ));
606
607        // Substantive testing workpapers
608        all_workpapers.extend(self.generate_workpapers_for_phase(
609            engagement,
610            EngagementPhase::SubstantiveTesting,
611            engagement.fieldwork_start + Duration::days(14),
612            team_members,
613        ));
614
615        // Completion workpapers
616        all_workpapers.extend(self.generate_workpapers_for_phase(
617            engagement,
618            EngagementPhase::Completion,
619            engagement.completion_start,
620            team_members,
621        ));
622
623        // Reporting workpapers
624        all_workpapers.extend(self.generate_workpapers_for_phase(
625            engagement,
626            EngagementPhase::Reporting,
627            engagement.report_date - Duration::days(7),
628            team_members,
629        ));
630
631        all_workpapers
632    }
633}
634
635#[cfg(test)]
636#[allow(clippy::unwrap_used)]
637mod tests {
638    use super::*;
639    use crate::audit::test_helpers::create_test_engagement;
640
641    #[test]
642    fn test_workpaper_generation() {
643        let mut generator = WorkpaperGenerator::new(42);
644        let engagement = create_test_engagement();
645        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
646
647        let wp = generator.generate_workpaper(
648            &engagement,
649            WorkpaperSection::SubstantiveTesting,
650            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
651            &team,
652        );
653
654        assert!(!wp.workpaper_ref.is_empty());
655        assert!(!wp.title.is_empty());
656        assert!(!wp.preparer_id.is_empty());
657    }
658
659    #[test]
660    fn test_phase_workpapers() {
661        let mut generator = WorkpaperGenerator::new(42);
662        let engagement = create_test_engagement();
663        let team = vec!["STAFF001".into(), "SENIOR001".into()];
664
665        let workpapers = generator.generate_workpapers_for_phase(
666            &engagement,
667            EngagementPhase::ControlTesting,
668            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
669            &team,
670        );
671
672        assert!(!workpapers.is_empty());
673        for wp in &workpapers {
674            assert_eq!(wp.section, WorkpaperSection::ControlTesting);
675        }
676    }
677
678    #[test]
679    fn test_complete_workpaper_set() {
680        let mut generator = WorkpaperGenerator::new(42);
681        let engagement = create_test_engagement();
682        let team = vec![
683            "STAFF001".into(),
684            "STAFF002".into(),
685            "SENIOR001".into(),
686            "MANAGER001".into(),
687        ];
688
689        let workpapers = generator.generate_complete_workpaper_set(&engagement, &team);
690
691        // Should have workpapers from all phases
692        assert!(workpapers.len() >= 18); // At least 3 per 6 phases
693
694        // Check we have workpapers from different sections
695        let sections: std::collections::HashSet<_> = workpapers.iter().map(|w| w.section).collect();
696        assert!(sections.len() >= 5);
697    }
698
699    #[test]
700    fn test_workpaper_with_context_enriches_title_and_objective() {
701        let mut generator = WorkpaperGenerator::new(42);
702        let engagement = create_test_engagement();
703        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
704
705        let enrichment = WorkpaperEnrichment {
706            account_area: Some("Revenue".into()),
707            account_balance: Some(Decimal::new(5_000_000, 0)),
708            risk_level: Some("High".into()),
709            materiality: Some(Decimal::new(325_000, 0)),
710            sampling_info: Some("MUS \u{2014} Population: 1200, Sample: 45".into()),
711        };
712
713        let wp = generator.generate_workpaper_with_context(
714            &engagement,
715            WorkpaperSection::SubstantiveTesting,
716            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
717            &team,
718            &enrichment,
719        );
720
721        assert!(
722            wp.title.contains("Revenue") && wp.title.contains("High"),
723            "Title should contain account area and risk: {}",
724            wp.title
725        );
726        assert!(
727            wp.objective.contains("GL Balance"),
728            "Objective should contain GL balance: {}",
729            wp.objective
730        );
731        assert!(
732            wp.objective.contains("materiality"),
733            "Objective should contain materiality: {}",
734            wp.objective
735        );
736        assert!(
737            wp.procedure_performed.contains("Sample: MUS"),
738            "Procedure should contain sampling info: {}",
739            wp.procedure_performed
740        );
741        // High risk should give 95-100% coverage.
742        assert!(
743            wp.scope.coverage_percentage >= 95.0,
744            "High risk scope should be >=95%: {}",
745            wp.scope.coverage_percentage
746        );
747    }
748
749    #[test]
750    fn test_workpaper_with_empty_enrichment_same_as_base() {
751        let mut gen1 = WorkpaperGenerator::new(42);
752        let mut gen2 = WorkpaperGenerator::new(42);
753        let engagement = create_test_engagement();
754        let team = vec!["STAFF001".into(), "SENIOR001".into()];
755
756        let base = gen1.generate_workpaper(
757            &engagement,
758            WorkpaperSection::Planning,
759            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
760            &team,
761        );
762
763        let enriched = gen2.generate_workpaper_with_context(
764            &engagement,
765            WorkpaperSection::Planning,
766            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
767            &team,
768            &WorkpaperEnrichment::default(),
769        );
770
771        // With empty enrichment, objective should be unchanged (no addenda).
772        assert_eq!(base.objective, enriched.objective);
773    }
774
775    #[test]
776    fn test_workpaper_scope_adjustment_by_risk() {
777        let engagement = create_test_engagement();
778        let team = vec!["STAFF001".into()];
779
780        for (risk, min_cov) in [("High", 95.0), ("Moderate", 75.0), ("Low", 50.0)] {
781            let mut generator = WorkpaperGenerator::new(99);
782            let enrichment = WorkpaperEnrichment {
783                risk_level: Some(risk.into()),
784                ..Default::default()
785            };
786            let wp = generator.generate_workpaper_with_context(
787                &engagement,
788                WorkpaperSection::SubstantiveTesting,
789                NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
790                &team,
791                &enrichment,
792            );
793            assert!(
794                wp.scope.coverage_percentage >= min_cov,
795                "Risk={risk}: expected coverage >= {min_cov}, got {}",
796                wp.scope.coverage_percentage
797            );
798        }
799    }
800
801    #[test]
802    fn test_workpaper_review_chain() {
803        let mut generator = WorkpaperGenerator::new(42);
804        let engagement = create_test_engagement();
805        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
806
807        let wp = generator.generate_workpaper(
808            &engagement,
809            WorkpaperSection::SubstantiveTesting,
810            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
811            &team,
812        );
813
814        // Should have preparer
815        assert!(!wp.preparer_id.is_empty());
816
817        // Should have first reviewer
818        assert!(wp.reviewer_id.is_some());
819
820        // First review date should be after preparer date
821        assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
822    }
823}