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 {} items with no exceptions noted", sample_size)
437 }
438 WorkpaperConclusion::SatisfactoryWithExceptions => {
439 format!(
440 "Tested {} items with {} exceptions noted. Exceptions were immaterial and have been evaluated",
441 sample_size, exceptions
442 )
443 }
444 WorkpaperConclusion::Unsatisfactory => {
445 format!(
446 "Tested {} items with {} exceptions noted. Exceptions represent material misstatement requiring adjustment",
447 sample_size, exceptions
448 )
449 }
450 _ => format!("Tested {} items", sample_size),
451 };
452
453 (summary, exceptions, conclusion)
454 }
455
456 fn select_team_member(&mut self, team_members: &[String], role_hint: &str) -> String {
458 let matching: Vec<&String> = team_members
459 .iter()
460 .filter(|m| m.to_lowercase().contains(role_hint))
461 .collect();
462
463 if let Some(&member) = matching.first() {
464 member.clone()
465 } else if !team_members.is_empty() {
466 let idx = self.rng.random_range(0..team_members.len());
467 team_members[idx].clone()
468 } else {
469 format!("{}001", role_hint.to_uppercase())
470 }
471 }
472
473 fn generate_auditor_name(&mut self) -> String {
475 let first_names = [
476 "Michael", "Sarah", "David", "Jennifer", "Robert", "Emily", "James", "Amanda",
477 ];
478 let last_names = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Davis"];
479
480 let first_idx = self.rng.random_range(0..first_names.len());
481 let last_idx = self.rng.random_range(0..last_names.len());
482
483 format!("{} {}", first_names[first_idx], last_names[last_idx])
484 }
485
486 fn generate_review_note(&mut self) -> String {
488 let notes = [
489 "Please expand on the rationale for sample selection",
490 "Cross-reference needed to risk assessment workpaper",
491 "Please document conclusion more clearly",
492 "Need to include population definition",
493 "Please add reference to prior year workpaper",
494 "Document discussion with management regarding exceptions",
495 "Clarify testing approach for this control",
496 "Add evidence reference for supporting documentation",
497 ];
498
499 let idx = self.rng.random_range(0..notes.len());
500 notes[idx].to_string()
501 }
502
503 pub fn generate_complete_workpaper_set(
505 &mut self,
506 engagement: &AuditEngagement,
507 team_members: &[String],
508 ) -> Vec<Workpaper> {
509 let mut all_workpapers = Vec::new();
510
511 all_workpapers.extend(self.generate_workpapers_for_phase(
513 engagement,
514 EngagementPhase::Planning,
515 engagement.planning_start,
516 team_members,
517 ));
518
519 all_workpapers.extend(self.generate_workpapers_for_phase(
521 engagement,
522 EngagementPhase::RiskAssessment,
523 engagement.planning_end,
524 team_members,
525 ));
526
527 all_workpapers.extend(self.generate_workpapers_for_phase(
529 engagement,
530 EngagementPhase::ControlTesting,
531 engagement.fieldwork_start,
532 team_members,
533 ));
534
535 all_workpapers.extend(self.generate_workpapers_for_phase(
537 engagement,
538 EngagementPhase::SubstantiveTesting,
539 engagement.fieldwork_start + Duration::days(14),
540 team_members,
541 ));
542
543 all_workpapers.extend(self.generate_workpapers_for_phase(
545 engagement,
546 EngagementPhase::Completion,
547 engagement.completion_start,
548 team_members,
549 ));
550
551 all_workpapers.extend(self.generate_workpapers_for_phase(
553 engagement,
554 EngagementPhase::Reporting,
555 engagement.report_date - Duration::days(7),
556 team_members,
557 ));
558
559 all_workpapers
560 }
561}
562
563#[cfg(test)]
564#[allow(clippy::unwrap_used)]
565mod tests {
566 use super::*;
567 use crate::audit::test_helpers::create_test_engagement;
568
569 #[test]
570 fn test_workpaper_generation() {
571 let mut generator = WorkpaperGenerator::new(42);
572 let engagement = create_test_engagement();
573 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
574
575 let wp = generator.generate_workpaper(
576 &engagement,
577 WorkpaperSection::SubstantiveTesting,
578 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
579 &team,
580 );
581
582 assert!(!wp.workpaper_ref.is_empty());
583 assert!(!wp.title.is_empty());
584 assert!(!wp.preparer_id.is_empty());
585 }
586
587 #[test]
588 fn test_phase_workpapers() {
589 let mut generator = WorkpaperGenerator::new(42);
590 let engagement = create_test_engagement();
591 let team = vec!["STAFF001".into(), "SENIOR001".into()];
592
593 let workpapers = generator.generate_workpapers_for_phase(
594 &engagement,
595 EngagementPhase::ControlTesting,
596 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
597 &team,
598 );
599
600 assert!(!workpapers.is_empty());
601 for wp in &workpapers {
602 assert_eq!(wp.section, WorkpaperSection::ControlTesting);
603 }
604 }
605
606 #[test]
607 fn test_complete_workpaper_set() {
608 let mut generator = WorkpaperGenerator::new(42);
609 let engagement = create_test_engagement();
610 let team = vec![
611 "STAFF001".into(),
612 "STAFF002".into(),
613 "SENIOR001".into(),
614 "MANAGER001".into(),
615 ];
616
617 let workpapers = generator.generate_complete_workpaper_set(&engagement, &team);
618
619 assert!(workpapers.len() >= 18); let sections: std::collections::HashSet<_> = workpapers.iter().map(|w| w.section).collect();
624 assert!(sections.len() >= 5);
625 }
626
627 #[test]
628 fn test_workpaper_review_chain() {
629 let mut generator = WorkpaperGenerator::new(42);
630 let engagement = create_test_engagement();
631 let team = vec!["STAFF001".into(), "SENIOR001".into(), "MANAGER001".into()];
632
633 let wp = generator.generate_workpaper(
634 &engagement,
635 WorkpaperSection::SubstantiveTesting,
636 NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
637 &team,
638 );
639
640 assert!(!wp.preparer_id.is_empty());
642
643 assert!(wp.reviewer_id.is_some());
645
646 assert!(wp.reviewer_date.unwrap() >= wp.preparer_date);
648 }
649}