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