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)]
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 assert!(workpapers.len() >= 18); 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 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 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 assert!(!wp.preparer_id.is_empty());
850
851 assert!(wp.reviewer_id.is_some());
853
854 assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
856 }
857}