1use chrono::{Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::Rng;
9use rand_chacha::ChaCha8Rng;
10
11use datasynth_core::models::audit::{
12 Assertion, AuditEngagement, EngagementPhase, ProcedureType, RiskLevel, SamplingMethod,
13 Workpaper, WorkpaperConclusion, WorkpaperScope, WorkpaperSection, WorkpaperStatus,
14};
15
16#[derive(Debug, Clone)]
18pub struct WorkpaperGeneratorConfig {
19 pub workpapers_per_section: (u32, u32),
21 pub population_size_range: (u64, u64),
23 pub sample_percentage_range: (f64, f64),
25 pub exception_rate_range: (f64, f64),
27 pub unsatisfactory_probability: f64,
29 pub first_review_delay_range: (u32, u32),
31 pub second_review_delay_range: (u32, u32),
33}
34
35impl Default for WorkpaperGeneratorConfig {
36 fn default() -> Self {
37 Self {
38 workpapers_per_section: (3, 10),
39 population_size_range: (100, 10000),
40 sample_percentage_range: (0.01, 0.10),
41 exception_rate_range: (0.0, 0.08),
42 unsatisfactory_probability: 0.05,
43 first_review_delay_range: (1, 5),
44 second_review_delay_range: (1, 3),
45 }
46 }
47}
48
49pub struct WorkpaperGenerator {
51 rng: ChaCha8Rng,
53 config: WorkpaperGeneratorConfig,
55 section_counters: std::collections::HashMap<WorkpaperSection, u32>,
57}
58
59impl WorkpaperGenerator {
60 pub fn new(seed: u64) -> Self {
62 Self {
63 rng: seeded_rng(seed, 0),
64 config: WorkpaperGeneratorConfig::default(),
65 section_counters: std::collections::HashMap::new(),
66 }
67 }
68
69 pub fn with_config(seed: u64, config: WorkpaperGeneratorConfig) -> Self {
71 Self {
72 rng: seeded_rng(seed, 0),
73 config,
74 section_counters: std::collections::HashMap::new(),
75 }
76 }
77
78 pub fn generate_workpapers_for_phase(
80 &mut self,
81 engagement: &AuditEngagement,
82 phase: EngagementPhase,
83 phase_date: NaiveDate,
84 team_members: &[String],
85 ) -> Vec<Workpaper> {
86 let section = match phase {
87 EngagementPhase::Planning => WorkpaperSection::Planning,
88 EngagementPhase::RiskAssessment => WorkpaperSection::RiskAssessment,
89 EngagementPhase::ControlTesting => WorkpaperSection::ControlTesting,
90 EngagementPhase::SubstantiveTesting => WorkpaperSection::SubstantiveTesting,
91 EngagementPhase::Completion => WorkpaperSection::Completion,
92 EngagementPhase::Reporting => WorkpaperSection::Reporting,
93 };
94
95 let count = self.rng.random_range(
96 self.config.workpapers_per_section.0..=self.config.workpapers_per_section.1,
97 );
98
99 (0..count)
100 .map(|_| self.generate_workpaper(engagement, section, phase_date, team_members))
101 .collect()
102 }
103
104 pub fn generate_workpaper(
106 &mut self,
107 engagement: &AuditEngagement,
108 section: WorkpaperSection,
109 base_date: NaiveDate,
110 team_members: &[String],
111 ) -> Workpaper {
112 let counter = self.section_counters.entry(section).or_insert(0);
113 *counter += 1;
114
115 let workpaper_ref = format!("{}-{:03}", section.reference_prefix(), counter);
116 let title = self.generate_workpaper_title(section);
117
118 let mut wp = Workpaper::new(engagement.engagement_id, &workpaper_ref, &title, section);
119
120 let (objective, assertions) = self.generate_objective_and_assertions(section);
122 wp = wp.with_objective(&objective, assertions);
123
124 let (procedure, procedure_type) = self.generate_procedure(section);
126 wp = wp.with_procedure(&procedure, procedure_type);
127
128 let (scope, population, sample, method) = self.generate_scope_and_sampling(section);
130 wp = wp.with_scope(scope, population, sample, method);
131
132 let (summary, exceptions, conclusion) =
134 self.generate_results(sample, &engagement.overall_audit_risk);
135 wp = wp.with_results(&summary, exceptions, conclusion);
136
137 wp.risk_level_addressed = engagement.overall_audit_risk;
138
139 let preparer = self.select_team_member(team_members, "staff");
141 let preparer_name = self.generate_auditor_name();
142 wp = wp.with_preparer(&preparer, &preparer_name, base_date);
143
144 let first_review_delay = self.rng.random_range(
146 self.config.first_review_delay_range.0..=self.config.first_review_delay_range.1,
147 );
148 let first_review_date = base_date + Duration::days(first_review_delay as i64);
149 let reviewer = self.select_team_member(team_members, "senior");
150 let reviewer_name = self.generate_auditor_name();
151 wp.add_first_review(&reviewer, &reviewer_name, first_review_date);
152
153 if self.rng.random::<f64>() < 0.7 {
155 let second_review_delay = self.rng.random_range(
156 self.config.second_review_delay_range.0..=self.config.second_review_delay_range.1,
157 );
158 let second_review_date = first_review_date + Duration::days(second_review_delay as i64);
159 let second_reviewer = self.select_team_member(team_members, "manager");
160 let second_reviewer_name = self.generate_auditor_name();
161 wp.add_second_review(&second_reviewer, &second_reviewer_name, second_review_date);
162 } else {
163 wp.status = WorkpaperStatus::FirstReviewComplete;
164 }
165
166 if self.rng.random::<f64>() < 0.30 {
168 let note = self.generate_review_note();
169 wp.add_review_note(&reviewer, ¬e);
170 }
171
172 wp
173 }
174
175 fn generate_workpaper_title(&mut self, section: WorkpaperSection) -> String {
177 let titles = match section {
178 WorkpaperSection::Planning => vec![
179 "Engagement Planning Memo",
180 "Understanding the Entity and Environment",
181 "Materiality Assessment",
182 "Preliminary Analytical Procedures",
183 "Risk Assessment Summary",
184 "Audit Strategy and Approach",
185 "Staffing and Resource Plan",
186 "Client Acceptance Procedures",
187 ],
188 WorkpaperSection::RiskAssessment => vec![
189 "Business Risk Assessment",
190 "Fraud Risk Evaluation",
191 "IT General Controls Assessment",
192 "Internal Control Evaluation",
193 "Significant Account Identification",
194 "Risk of Material Misstatement Assessment",
195 "Related Party Risk Assessment",
196 "Going Concern Assessment",
197 ],
198 WorkpaperSection::ControlTesting => vec![
199 "Revenue Recognition Controls Testing",
200 "Purchase and Payables Controls Testing",
201 "Treasury and Cash Controls Testing",
202 "Payroll Controls Testing",
203 "Fixed Asset Controls Testing",
204 "Inventory Controls Testing",
205 "IT Application Controls Testing",
206 "Entity Level Controls Testing",
207 ],
208 WorkpaperSection::SubstantiveTesting => vec![
209 "Revenue Cutoff Testing",
210 "Accounts Receivable Confirmation",
211 "Inventory Observation and Testing",
212 "Fixed Asset Verification",
213 "Accounts Payable Completeness",
214 "Expense Testing",
215 "Debt and Interest Testing",
216 "Bank Reconciliation Review",
217 "Journal Entry Testing",
218 "Analytical Procedures - Revenue",
219 "Analytical Procedures - Expenses",
220 ],
221 WorkpaperSection::Completion => vec![
222 "Subsequent Events Review",
223 "Management Representation Letter",
224 "Attorney Letter Summary",
225 "Going Concern Evaluation",
226 "Summary of Uncorrected Misstatements",
227 "Summary of Audit Differences",
228 "Completion Checklist",
229 ],
230 WorkpaperSection::Reporting => vec![
231 "Draft Financial Statements Review",
232 "Disclosure Checklist",
233 "Communication with Those Charged with Governance",
234 "Report Issuance Checklist",
235 ],
236 WorkpaperSection::PermanentFile => vec![
237 "Chart of Accounts",
238 "Organization Structure",
239 "Key Contracts Summary",
240 "Related Party Identification",
241 ],
242 };
243
244 let idx = self.rng.random_range(0..titles.len());
245 titles[idx].to_string()
246 }
247
248 fn generate_objective_and_assertions(
250 &mut self,
251 section: WorkpaperSection,
252 ) -> (String, Vec<Assertion>) {
253 match section {
254 WorkpaperSection::Planning | WorkpaperSection::RiskAssessment => (
255 "Understand the entity and assess risks of material misstatement".into(),
256 vec![],
257 ),
258 WorkpaperSection::ControlTesting => (
259 "Test the operating effectiveness of key controls".into(),
260 Assertion::transaction_assertions(),
261 ),
262 WorkpaperSection::SubstantiveTesting => {
263 let assertions = if self.rng.random::<f64>() < 0.5 {
264 Assertion::transaction_assertions()
265 } else {
266 Assertion::balance_assertions()
267 };
268 (
269 "Obtain sufficient appropriate audit evidence regarding account balances"
270 .into(),
271 assertions,
272 )
273 }
274 WorkpaperSection::Completion => (
275 "Complete all required completion procedures".into(),
276 vec![
277 Assertion::Completeness,
278 Assertion::PresentationAndDisclosure,
279 ],
280 ),
281 WorkpaperSection::Reporting => (
282 "Ensure compliance with reporting requirements".into(),
283 vec![Assertion::PresentationAndDisclosure],
284 ),
285 WorkpaperSection::PermanentFile => {
286 ("Maintain permanent file documentation".into(), vec![])
287 }
288 }
289 }
290
291 fn generate_procedure(&mut self, section: WorkpaperSection) -> (String, ProcedureType) {
293 match section {
294 WorkpaperSection::Planning | WorkpaperSection::RiskAssessment => (
295 "Performed inquiries and reviewed documentation".into(),
296 ProcedureType::InquiryObservation,
297 ),
298 WorkpaperSection::ControlTesting => {
299 let procedures = [
300 (
301 "Selected a sample of transactions and tested the control operation",
302 ProcedureType::TestOfControls,
303 ),
304 (
305 "Observed the control being performed by personnel",
306 ProcedureType::InquiryObservation,
307 ),
308 (
309 "Inspected documentation of control performance",
310 ProcedureType::Inspection,
311 ),
312 (
313 "Reperformed the control procedure",
314 ProcedureType::Reperformance,
315 ),
316 ];
317 let idx = self.rng.random_range(0..procedures.len());
318 (procedures[idx].0.into(), procedures[idx].1)
319 }
320 WorkpaperSection::SubstantiveTesting => {
321 let procedures = [
322 (
323 "Selected a sample and agreed details to supporting documentation",
324 ProcedureType::SubstantiveTest,
325 ),
326 (
327 "Sent confirmations and agreed responses to records",
328 ProcedureType::Confirmation,
329 ),
330 (
331 "Recalculated amounts and agreed to supporting schedules",
332 ProcedureType::Recalculation,
333 ),
334 (
335 "Performed analytical procedures and investigated variances",
336 ProcedureType::AnalyticalProcedures,
337 ),
338 (
339 "Inspected physical assets and documentation",
340 ProcedureType::Inspection,
341 ),
342 ];
343 let idx = self.rng.random_range(0..procedures.len());
344 (procedures[idx].0.into(), procedures[idx].1)
345 }
346 WorkpaperSection::Completion | WorkpaperSection::Reporting => (
347 "Reviewed documentation and performed inquiries".into(),
348 ProcedureType::InquiryObservation,
349 ),
350 WorkpaperSection::PermanentFile => (
351 "Compiled and organized permanent file documentation".into(),
352 ProcedureType::Inspection,
353 ),
354 }
355 }
356
357 fn generate_scope_and_sampling(
359 &mut self,
360 section: WorkpaperSection,
361 ) -> (WorkpaperScope, u64, u32, SamplingMethod) {
362 let scope = WorkpaperScope {
363 coverage_percentage: self.rng.random_range(70.0..100.0),
364 period_start: None,
365 period_end: None,
366 limitations: Vec::new(),
367 };
368
369 match section {
370 WorkpaperSection::ControlTesting | WorkpaperSection::SubstantiveTesting => {
371 let population = self.rng.random_range(
372 self.config.population_size_range.0..=self.config.population_size_range.1,
373 );
374 let sample_pct = self.rng.random_range(
375 self.config.sample_percentage_range.0..=self.config.sample_percentage_range.1,
376 );
377 let sample = ((population as f64 * sample_pct).max(25.0) as u32).min(200);
378
379 let method = if self.rng.random::<f64>() < 0.4 {
380 SamplingMethod::StatisticalRandom
381 } else if self.rng.random::<f64>() < 0.3 {
382 SamplingMethod::MonetaryUnit
383 } else {
384 SamplingMethod::Judgmental
385 };
386
387 (scope, population, sample, method)
388 }
389 _ => (scope, 0, 0, SamplingMethod::Judgmental),
390 }
391 }
392
393 fn generate_results(
395 &mut self,
396 sample_size: u32,
397 risk_level: &RiskLevel,
398 ) -> (String, u32, WorkpaperConclusion) {
399 if sample_size == 0 {
400 return (
401 "Procedures completed without exception".into(),
402 0,
403 WorkpaperConclusion::Satisfactory,
404 );
405 }
406
407 let exception_probability = match risk_level {
409 RiskLevel::Low => 0.10,
410 RiskLevel::Medium => 0.25,
411 RiskLevel::High | RiskLevel::Significant => 0.40,
412 };
413
414 let has_exceptions = self.rng.random::<f64>() < exception_probability;
415
416 let (exceptions, conclusion) = if has_exceptions {
417 let exception_rate = self.rng.random_range(
418 self.config.exception_rate_range.0..=self.config.exception_rate_range.1,
419 );
420 let exceptions =
421 ((sample_size as f64 * exception_rate).max(1.0) as u32).min(sample_size);
422
423 let conclusion = if self.rng.random::<f64>() < self.config.unsatisfactory_probability {
424 WorkpaperConclusion::Unsatisfactory
425 } else {
426 WorkpaperConclusion::SatisfactoryWithExceptions
427 };
428
429 (exceptions, conclusion)
430 } else {
431 (0, WorkpaperConclusion::Satisfactory)
432 };
433
434 let summary = match conclusion {
435 WorkpaperConclusion::Satisfactory => {
436 format!("Tested {sample_size} items with no exceptions noted")
437 }
438 WorkpaperConclusion::SatisfactoryWithExceptions => {
439 format!(
440 "Tested {sample_size} items with {exceptions} exceptions noted. Exceptions were immaterial and have been evaluated"
441 )
442 }
443 WorkpaperConclusion::Unsatisfactory => {
444 format!(
445 "Tested {sample_size} items with {exceptions} exceptions noted. Exceptions represent material misstatement requiring adjustment"
446 )
447 }
448 _ => format!("Tested {sample_size} items"),
449 };
450
451 (summary, exceptions, conclusion)
452 }
453
454 fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
456 let matching: Vec<&String> = team_members
457 .iter()
458 .filter(|m| m.to_lowercase().contains(role_hint))
459 .collect();
460
461 if let Some(&member) = matching.first() {
462 member.clone()
463 } else if !team_members.is_empty() {
464 let idx = self.rng.random_range(0..team_members.len());
465 team_members[idx].clone()
466 } else {
467 format!("{}001", role_hint.to_uppercase())
468 }
469 }
470
471 fn generate_auditor_name(&mut self) -> String {
473 let first_names = [
474 "Michael", "Sarah", "David", "Jennifer", "Robert", "Emily", "James", "Amanda",
475 ];
476 let last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Davis"];
477
478 let first_idx = self.rng.random_range(0..first_names.len());
479 let last_idx = self.rng.random_range(0..last_names.len());
480
481 format!("{} {}", first_names[first_idx], last_names[last_idx])
482 }
483
484 fn generate_review_note(&mut self) -> String {
486 let notes = [
487 "Please expand on the rationale for sample selection",
488 "Cross-reference needed to risk assessment workpaper",
489 "Please document conclusion more clearly",
490 "Need to include population definition",
491 "Please add reference to prior year workpaper",
492 "Document discussion with management regarding exceptions",
493 "Clarify testing approach for this control",
494 "Add evidence reference for supporting documentation",
495 ];
496
497 let idx = self.rng.random_range(0..notes.len());
498 notes[idx].to_string()
499 }
500
501 pub fn generate_complete_workpaper_set(
503 &mut self,
504 engagement: &AuditEngagement,
505 team_members: &[String],
506 ) -> Vec<Workpaper> {
507 let mut all_workpapers = Vec::new();
508
509 all_workpapers.extend(self.generate_workpapers_for_phase(
511 engagement,
512 EngagementPhase::Planning,
513 engagement.planning_start,
514 team_members,
515 ));
516
517 all_workpapers.extend(self.generate_workpapers_for_phase(
519 engagement,
520 EngagementPhase::RiskAssessment,
521 engagement.planning_end,
522 team_members,
523 ));
524
525 all_workpapers.extend(self.generate_workpapers_for_phase(
527 engagement,
528 EngagementPhase::ControlTesting,
529 engagement.fieldwork_start,
530 team_members,
531 ));
532
533 all_workpapers.extend(self.generate_workpapers_for_phase(
535 engagement,
536 EngagementPhase::SubstantiveTesting,
537 engagement.fieldwork_start + Duration::days(14),
538 team_members,
539 ));
540
541 all_workpapers.extend(self.generate_workpapers_for_phase(
543 engagement,
544 EngagementPhase::Completion,
545 engagement.completion_start,
546 team_members,
547 ));
548
549 all_workpapers.extend(self.generate_workpapers_for_phase(
551 engagement,
552 EngagementPhase::Reporting,
553 engagement.report_date - Duration::days(7),
554 team_members,
555 ));
556
557 all_workpapers
558 }
559}
560
561#[cfg(test)]
562#[allow(clippy::unwrap_used)]
563mod tests {
564 use super::*;
565 use crate::audit::test_helpers::create_test_engagement;
566
567 #[test]
568 fn test_workpaper_generation() {
569 let mut generator = WorkpaperGenerator::new(42);
570 let engagement = create_test_engagement();
571 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
572
573 let wp = generator.generate_workpaper(
574 &engagement,
575 WorkpaperSection::SubstantiveTesting,
576 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
577 &team,
578 );
579
580 assert!(!wp.workpaper_ref.is_empty());
581 assert!(!wp.title.is_empty());
582 assert!(!wp.preparer_id.is_empty());
583 }
584
585 #[test]
586 fn test_phase_workpapers() {
587 let mut generator = WorkpaperGenerator::new(42);
588 let engagement = create_test_engagement();
589 let team = vec!["STAFF001".into(), "SENIOR001".into()];
590
591 let workpapers = generator.generate_workpapers_for_phase(
592 &engagement,
593 EngagementPhase::ControlTesting,
594 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
595 &team,
596 );
597
598 assert!(!workpapers.is_empty());
599 for wp in &workpapers {
600 assert_eq!(wp.section, WorkpaperSection::ControlTesting);
601 }
602 }
603
604 #[test]
605 fn test_complete_workpaper_set() {
606 let mut generator = WorkpaperGenerator::new(42);
607 let engagement = create_test_engagement();
608 let team = vec![
609 "STAFF001".into(),
610 "STAFF002".into(),
611 "SENIOR001".into(),
612 "MANAGER001".into(),
613 ];
614
615 let workpapers = generator.generate_complete_workpaper_set(&engagement, &team);
616
617 assert!(workpapers.len() >= 18); let sections: std::collections::HashSet<_> = workpapers.iter().map(|w| w.section).collect();
622 assert!(sections.len() >= 5);
623 }
624
625 #[test]
626 fn test_workpaper_review_chain() {
627 let mut generator = WorkpaperGenerator::new(42);
628 let engagement = create_test_engagement();
629 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
630
631 let wp = generator.generate_workpaper(
632 &engagement,
633 WorkpaperSection::SubstantiveTesting,
634 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
635 &team,
636 );
637
638 assert!(!wp.preparer_id.is_empty());
640
641 assert!(wp.reviewer_id.is_some());
643
644 assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
646 }
647}