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)]
670mod tests {
671    use super::*;
672    use crate::audit::test_helpers::create_test_engagement;
673
674    #[test]
675    fn test_workpaper_generation() {
676        let mut generator = WorkpaperGenerator::new(42);
677        let engagement = create_test_engagement();
678        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
679
680        let wp = generator.generate_workpaper(
681            &engagement,
682            WorkpaperSection::SubstantiveTesting,
683            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
684            &team,
685        );
686
687        assert!(!wp.workpaper_ref.is_empty());
688        assert!(!wp.title.is_empty());
689        assert!(!wp.preparer_id.is_empty());
690    }
691
692    #[test]
693    fn test_phase_workpapers() {
694        let mut generator = WorkpaperGenerator::new(42);
695        let engagement = create_test_engagement();
696        let team = vec!["STAFF001".into(), "SENIOR001".into()];
697
698        let workpapers = generator.generate_workpapers_for_phase(
699            &engagement,
700            EngagementPhase::ControlTesting,
701            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
702            &team,
703        );
704
705        assert!(!workpapers.is_empty());
706        for wp in &workpapers {
707            assert_eq!(wp.section, WorkpaperSection::ControlTesting);
708        }
709    }
710
711    #[test]
712    fn test_complete_workpaper_set() {
713        let mut generator = WorkpaperGenerator::new(42);
714        let engagement = create_test_engagement();
715        let team = vec![
716            "STAFF001".into(),
717            "STAFF002".into(),
718            "SENIOR001".into(),
719            "MANAGER001".into(),
720        ];
721
722        let workpapers = generator.generate_complete_workpaper_set(&engagement, &team);
723
724        // Should have workpapers from all phases
725        assert!(workpapers.len() >= 18); // At least 3 per 6 phases
726
727        // Check we have workpapers from different sections
728        let sections: std::collections::HashSet<_> = workpapers.iter().map(|w| w.section).collect();
729        assert!(sections.len() >= 5);
730    }
731
732    #[test]
733    fn test_workpaper_with_context_enriches_title_and_objective() {
734        let mut generator = WorkpaperGenerator::new(42);
735        let engagement = create_test_engagement();
736        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
737
738        let enrichment = WorkpaperEnrichment {
739            account_area: Some("Revenue".into()),
740            account_balance: Some(Decimal::new(5_000_000, 0)),
741            risk_level: Some("High".into()),
742            materiality: Some(Decimal::new(325_000, 0)),
743            sampling_info: Some("MUS \u{2014} Population: 1200, Sample: 45".into()),
744        };
745
746        let wp = generator.generate_workpaper_with_context(
747            &engagement,
748            WorkpaperSection::SubstantiveTesting,
749            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
750            &team,
751            &enrichment,
752        );
753
754        assert!(
755            wp.title.contains("Revenue") && wp.title.contains("High"),
756            "Title should contain account area and risk: {}",
757            wp.title
758        );
759        assert!(
760            wp.objective.contains("GL Balance"),
761            "Objective should contain GL balance: {}",
762            wp.objective
763        );
764        assert!(
765            wp.objective.contains("materiality"),
766            "Objective should contain materiality: {}",
767            wp.objective
768        );
769        assert!(
770            wp.procedure_performed.contains("Sample: MUS"),
771            "Procedure should contain sampling info: {}",
772            wp.procedure_performed
773        );
774        // High risk should give 95-100% coverage.
775        assert!(
776            wp.scope.coverage_percentage >= 95.0,
777            "High risk scope should be >=95%: {}",
778            wp.scope.coverage_percentage
779        );
780    }
781
782    #[test]
783    fn test_workpaper_with_empty_enrichment_same_as_base() {
784        let mut gen1 = WorkpaperGenerator::new(42);
785        let mut gen2 = WorkpaperGenerator::new(42);
786        let engagement = create_test_engagement();
787        let team = vec!["STAFF001".into(), "SENIOR001".into()];
788
789        let base = gen1.generate_workpaper(
790            &engagement,
791            WorkpaperSection::Planning,
792            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
793            &team,
794        );
795
796        let enriched = gen2.generate_workpaper_with_context(
797            &engagement,
798            WorkpaperSection::Planning,
799            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
800            &team,
801            &WorkpaperEnrichment::default(),
802        );
803
804        // With empty enrichment, objective should be unchanged (no addenda).
805        assert_eq!(base.objective, enriched.objective);
806    }
807
808    #[test]
809    fn test_workpaper_scope_adjustment_by_risk() {
810        let engagement = create_test_engagement();
811        let team = vec!["STAFF001".into()];
812
813        for (risk, min_cov) in [("High", 95.0), ("Moderate", 75.0), ("Low", 50.0)] {
814            let mut generator = WorkpaperGenerator::new(99);
815            let enrichment = WorkpaperEnrichment {
816                risk_level: Some(risk.into()),
817                ..Default::default()
818            };
819            let wp = generator.generate_workpaper_with_context(
820                &engagement,
821                WorkpaperSection::SubstantiveTesting,
822                NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
823                &team,
824                &enrichment,
825            );
826            assert!(
827                wp.scope.coverage_percentage >= min_cov,
828                "Risk={risk}: expected coverage >= {min_cov}, got {}",
829                wp.scope.coverage_percentage
830            );
831        }
832    }
833
834    #[test]
835    fn test_workpaper_review_chain() {
836        let mut generator = WorkpaperGenerator::new(42);
837        let engagement = create_test_engagement();
838        let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
839
840        let wp = generator.generate_workpaper(
841            &engagement,
842            WorkpaperSection::SubstantiveTesting,
843            NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
844            &team,
845        );
846
847        // Should have preparer
848        assert!(!wp.preparer_id.is_empty());
849
850        // Should have first reviewer
851        assert!(wp.reviewer_id.is_some());
852
853        // First review date should be after preparer date
854        assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
855    }
856}