1use 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#[derive(Debug, Clone)]
20pub struct WorkpaperGeneratorConfig {
21 pub workpapers_per_section: (u32, u32),
23 pub population_size_range: (u64, u64),
25 pub sample_percentage_range: (f64, f64),
27 pub exception_rate_range: (f64, f64),
29 pub unsatisfactory_probability: f64,
31 pub first_review_delay_range: (u32, u32),
33 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#[derive(Debug, Clone, Default)]
57pub struct WorkpaperEnrichment {
58 pub account_area: Option<String>,
60 pub account_balance: Option<Decimal>,
62 pub risk_level: Option<String>, pub materiality: Option<Decimal>,
66 pub sampling_info: Option<String>,
68}
69
70pub struct WorkpaperGenerator {
72 rng: ChaCha8Rng,
74 config: WorkpaperGeneratorConfig,
76 section_counters: std::collections::HashMap<WorkpaperSection, u32>,
78}
79
80impl WorkpaperGenerator {
81 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 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 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 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 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 let (objective, assertions) = self.generate_objective_and_assertions(section);
177 wp = wp.with_objective(&objective, assertions);
178
179 let (procedure, procedure_type) = self.generate_procedure(section);
181 wp = wp.with_procedure(&procedure, procedure_type);
182
183 let (scope, population, sample, method) = self.generate_scope_and_sampling(section);
185 wp = wp.with_scope(scope, population, sample, method);
186
187 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 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 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 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 if self.rng.random::<f64>() < 0.30 {
223 let note = self.generate_review_note();
224 wp.add_review_note(&reviewer, ¬e);
225 }
226
227 wp
228 }
229
230 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 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 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 if let Some(sampling) = &enrichment.sampling_info {
266 wp.procedure_performed = format!("{} | Sample: {}", wp.procedure_performed, sampling);
267 }
268
269 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 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 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 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 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 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 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 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 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 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 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 all_workpapers.extend(self.generate_workpapers_for_phase(
619 engagement,
620 EngagementPhase::Planning,
621 engagement.planning_start,
622 team_members,
623 ));
624
625 all_workpapers.extend(self.generate_workpapers_for_phase(
627 engagement,
628 EngagementPhase::RiskAssessment,
629 engagement.planning_end,
630 team_members,
631 ));
632
633 all_workpapers.extend(self.generate_workpapers_for_phase(
635 engagement,
636 EngagementPhase::ControlTesting,
637 engagement.fieldwork_start,
638 team_members,
639 ));
640
641 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 all_workpapers.extend(self.generate_workpapers_for_phase(
651 engagement,
652 EngagementPhase::Completion,
653 engagement.completion_start,
654 team_members,
655 ));
656
657 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 assert!(workpapers.len() >= 18); 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 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 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 assert!(!wp.preparer_id.is_empty());
849
850 assert!(wp.reviewer_id.is_some());
852
853 assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
855 }
856}