1use chrono::{Duration, NaiveDate};
8use datasynth_core::utils::seeded_rng;
9use rand::Rng;
10use rand_chacha::ChaCha8Rng;
11use uuid::Uuid;
12
13fn rng_uuid(rng: &mut ChaCha8Rng) -> Uuid {
15 let mut bytes = [0u8; 16];
16 rng.fill(&mut bytes);
17 bytes[6] = (bytes[6] & 0x0f) | 0x40;
19 bytes[8] = (bytes[8] & 0x3f) | 0x80;
20 Uuid::from_bytes(bytes)
21}
22
23use datasynth_core::models::audit::{
24 ActionPlan, ActionPlanStatus, AuditEngagement, CompetenceRating, IaAssessment,
25 IaRecommendation, IaReportRating, IaReportStatus, IaWorkAssessment, InternalAuditFunction,
26 InternalAuditReport, ObjectivityRating, RecommendationPriority, RelianceExtent, ReportingLine,
27};
28
29#[derive(Debug, Clone)]
31pub struct InternalAuditGeneratorConfig {
32 pub reports_per_function: (u32, u32),
34 pub no_reliance_ratio: f64,
36 pub limited_reliance_ratio: f64,
38 pub significant_reliance_ratio: f64,
40 pub full_reliance_ratio: f64,
42 pub recommendations_per_report: (u32, u32),
44}
45
46impl Default for InternalAuditGeneratorConfig {
47 fn default() -> Self {
48 Self {
49 reports_per_function: (2, 5),
50 no_reliance_ratio: 0.20,
51 limited_reliance_ratio: 0.50,
52 significant_reliance_ratio: 0.25,
53 full_reliance_ratio: 0.05,
54 recommendations_per_report: (1, 4),
55 }
56 }
57}
58
59pub struct InternalAuditGenerator {
61 rng: ChaCha8Rng,
63 config: InternalAuditGeneratorConfig,
65}
66
67impl InternalAuditGenerator {
68 pub fn new(seed: u64) -> Self {
70 Self {
71 rng: seeded_rng(seed, 0),
72 config: InternalAuditGeneratorConfig::default(),
73 }
74 }
75
76 pub fn with_config(seed: u64, config: InternalAuditGeneratorConfig) -> Self {
78 Self {
79 rng: seeded_rng(seed, 0),
80 config,
81 }
82 }
83
84 pub fn generate(
91 &mut self,
92 engagement: &AuditEngagement,
93 ) -> (InternalAuditFunction, Vec<InternalAuditReport>) {
94 let roll: f64 = self.rng.random();
96 let no_cutoff = self.config.no_reliance_ratio;
97 let limited_cutoff = no_cutoff + self.config.limited_reliance_ratio;
98 let significant_cutoff = limited_cutoff + self.config.significant_reliance_ratio;
99 let reliance = if roll < no_cutoff {
102 RelianceExtent::NoReliance
103 } else if roll < limited_cutoff {
104 RelianceExtent::LimitedReliance
105 } else if roll < significant_cutoff {
106 RelianceExtent::SignificantReliance
107 } else {
108 RelianceExtent::FullReliance
109 };
110
111 let (objectivity, competence, assessment) = match reliance {
113 RelianceExtent::NoReliance => (
114 ObjectivityRating::Low,
115 CompetenceRating::Low,
116 IaAssessment::Ineffective,
117 ),
118 RelianceExtent::LimitedReliance => (
119 ObjectivityRating::Moderate,
120 CompetenceRating::Moderate,
121 IaAssessment::PartiallyEffective,
122 ),
123 RelianceExtent::SignificantReliance => (
124 ObjectivityRating::High,
125 CompetenceRating::Moderate,
126 IaAssessment::LargelyEffective,
127 ),
128 RelianceExtent::FullReliance => (
129 ObjectivityRating::High,
130 CompetenceRating::High,
131 IaAssessment::FullyEffective,
132 ),
133 };
134
135 let reporting_line = self.pick_reporting_line();
137
138 let staff_count: u32 = match reliance {
140 RelianceExtent::NoReliance => self.rng.random_range(1_u32..=4_u32),
141 RelianceExtent::LimitedReliance => self.rng.random_range(3_u32..=8_u32),
142 RelianceExtent::SignificantReliance => self.rng.random_range(6_u32..=12_u32),
143 RelianceExtent::FullReliance => self.rng.random_range(10_u32..=15_u32),
144 };
145
146 let coverage: f64 = self.rng.random_range(0.40_f64..0.95_f64);
148
149 let quality_assurance = matches!(
151 reliance,
152 RelianceExtent::SignificantReliance | RelianceExtent::FullReliance
153 );
154
155 let head_name = self.head_of_ia_name();
156 let qualifications = self.ia_qualifications(&reliance);
157
158 let mut function =
159 InternalAuditFunction::new(engagement.engagement_id, "Internal Audit", &head_name);
160 let func_id = rng_uuid(&mut self.rng);
162 function.function_id = func_id;
163 function.function_ref = format!("IAF-{}", &func_id.simple().to_string()[..8]);
164 function.reporting_line = reporting_line;
165 function.staff_count = staff_count;
166 function.annual_plan_coverage = coverage;
167 function.quality_assurance = quality_assurance;
168 function.isa_610_assessment = assessment;
169 function.objectivity_rating = objectivity;
170 function.competence_rating = competence;
171 function.systematic_discipline = !matches!(reliance, RelianceExtent::NoReliance);
172 function.reliance_extent = reliance;
173 function.head_of_ia_qualifications = qualifications;
174 function.direct_assistance = matches!(
175 reliance,
176 RelianceExtent::SignificantReliance | RelianceExtent::FullReliance
177 ) && self.rng.random::<f64>() < 0.40;
178
179 if reliance == RelianceExtent::NoReliance {
180 return (function, Vec::new());
181 }
182
183 let report_count = self
185 .rng
186 .random_range(self.config.reports_per_function.0..=self.config.reports_per_function.1)
187 as usize;
188
189 let mut reports = Vec::with_capacity(report_count);
190 let audit_areas = self.audit_areas();
191 let area_count = audit_areas.len();
193
194 for i in 0..report_count {
195 let area = audit_areas[i % area_count];
196 let report_title = format!("{} — Internal Audit Review", area);
197
198 let fieldwork_days = (engagement.fieldwork_end - engagement.fieldwork_start)
200 .num_days()
201 .max(1);
202 let offset = self.rng.random_range(0_i64..fieldwork_days);
203 let report_date = engagement.fieldwork_start + Duration::days(offset);
204
205 let period_start =
207 NaiveDate::from_ymd_opt(engagement.fiscal_year as i32, 1, 1).unwrap_or(report_date);
208 let period_end = engagement.period_end_date;
209
210 let mut report = InternalAuditReport::new(
211 engagement.engagement_id,
212 function.function_id,
213 &report_title,
214 area,
215 report_date,
216 period_start,
217 period_end,
218 );
219 let report_id = rng_uuid(&mut self.rng);
221 report.report_id = report_id;
222 report.report_ref = format!("IAR-{}", &report_id.simple().to_string()[..8]);
223
224 report.scope_description =
226 format!("Review of {} processes and controls for the period.", area);
227 report.methodology =
228 "Risk-based audit approach with control testing and data analytics.".to_string();
229
230 let findings: u32 = self.rng.random_range(1_u32..=8_u32);
232 let high_risk: u32 = self.rng.random_range(0_u32..=(findings.min(2)));
233 report.findings_count = findings;
234 report.high_risk_findings = high_risk;
235 report.overall_rating = self.pick_report_rating(high_risk, findings);
236 report.status = self.pick_report_status();
237
238 let rec_count = self.rng.random_range(
240 self.config.recommendations_per_report.0..=self.config.recommendations_per_report.1,
241 ) as usize;
242 let mut recommendations = Vec::with_capacity(rec_count);
243 let mut action_plans = Vec::with_capacity(rec_count);
244
245 for _ in 0..rec_count {
246 let priority = self.pick_priority(high_risk);
247 let description = self.recommendation_description(area, priority);
248 let management_response = Some(
249 "Management accepts recommendation and will implement by target date."
250 .to_string(),
251 );
252
253 let rec = IaRecommendation {
254 recommendation_id: rng_uuid(&mut self.rng),
255 description,
256 priority,
257 management_response,
258 };
259
260 let days_to_implement: i64 = match priority {
262 RecommendationPriority::Critical => self.rng.random_range(30_i64..=60_i64),
263 RecommendationPriority::High => self.rng.random_range(60_i64..=90_i64),
264 RecommendationPriority::Medium => self.rng.random_range(90_i64..=180_i64),
265 RecommendationPriority::Low => self.rng.random_range(180_i64..=365_i64),
266 };
267 let target_date = report_date + Duration::days(days_to_implement);
268 let plan_status = self.pick_action_plan_status();
269
270 let plan = ActionPlan {
271 plan_id: rng_uuid(&mut self.rng),
272 recommendation_id: rec.recommendation_id,
273 description: format!(
274 "Implement corrective action for: {}",
275 &rec.description[..rec.description.len().min(60)]
276 ),
277 responsible_party: self.responsible_party(),
278 target_date,
279 status: plan_status,
280 };
281
282 action_plans.push(plan);
283 recommendations.push(rec);
284 }
285
286 report.recommendations = recommendations;
287 report.management_action_plans = action_plans;
288
289 report.external_auditor_assessment = Some(match reliance {
291 RelianceExtent::LimitedReliance => IaWorkAssessment::PartiallyReliable,
292 RelianceExtent::SignificantReliance => IaWorkAssessment::Reliable,
293 RelianceExtent::FullReliance => IaWorkAssessment::Reliable,
294 RelianceExtent::NoReliance => IaWorkAssessment::Unreliable,
295 });
296
297 if !function.reliance_areas.contains(&area.to_string()) {
299 function.reliance_areas.push(area.to_string());
300 }
301
302 reports.push(report);
303 }
304
305 (function, reports)
306 }
307
308 fn pick_reporting_line(&mut self) -> ReportingLine {
313 let roll: f64 = self.rng.random();
315 if roll < 0.60 {
316 ReportingLine::AuditCommittee
317 } else if roll < 0.75 {
318 ReportingLine::Board
319 } else if roll < 0.90 {
320 ReportingLine::CFO
321 } else {
322 ReportingLine::CEO
323 }
324 }
325
326 fn head_of_ia_name(&mut self) -> String {
327 let names = [
328 "Sarah Mitchell",
329 "David Chen",
330 "Emma Thompson",
331 "James Rodriguez",
332 "Olivia Patel",
333 "Michael Clarke",
334 "Amira Hassan",
335 "Robert Nielsen",
336 "Priya Sharma",
337 "Thomas Becker",
338 ];
339 let idx = self.rng.random_range(0..names.len());
340 names[idx].to_string()
341 }
342
343 fn ia_qualifications(&mut self, reliance: &RelianceExtent) -> Vec<String> {
344 let all_quals = ["CIA", "CISA", "CPA", "CA", "ACCA", "CRISC"];
345 let count: usize = match reliance {
346 RelianceExtent::NoReliance | RelianceExtent::LimitedReliance => {
347 self.rng.random_range(0_usize..=1_usize)
348 }
349 RelianceExtent::SignificantReliance => self.rng.random_range(1_usize..=2_usize),
350 RelianceExtent::FullReliance => self.rng.random_range(2_usize..=3_usize),
351 };
352 let mut quals = Vec::new();
353 let mut remaining: Vec<&str> = all_quals.to_vec();
354 for _ in 0..count {
355 if remaining.is_empty() {
356 break;
357 }
358 let idx = self.rng.random_range(0..remaining.len());
359 quals.push(remaining.remove(idx).to_string());
360 }
361 quals
362 }
363
364 fn audit_areas(&self) -> Vec<&'static str> {
365 vec![
366 "Revenue Cycle",
367 "IT General Controls",
368 "Procurement & Payables",
369 "Payroll & Human Resources",
370 "Treasury & Cash Management",
371 "Financial Reporting",
372 "Compliance & Regulatory",
373 "Inventory & Supply Chain",
374 "Fixed Assets",
375 "Tax Compliance",
376 "Information Security",
377 "Governance & Risk Management",
378 ]
379 }
380
381 fn pick_report_rating(&mut self, high_risk: u32, findings: u32) -> IaReportRating {
382 if high_risk >= 2 || findings >= 6 {
383 IaReportRating::Unsatisfactory
384 } else if high_risk >= 1 || findings >= 3 {
385 if self.rng.random::<f64>() < 0.70 {
387 IaReportRating::NeedsImprovement
388 } else {
389 IaReportRating::Unsatisfactory
390 }
391 } else {
392 if self.rng.random::<f64>() < 0.80 {
394 IaReportRating::Satisfactory
395 } else {
396 IaReportRating::NeedsImprovement
397 }
398 }
399 }
400
401 fn pick_report_status(&mut self) -> IaReportStatus {
402 let roll: f64 = self.rng.random();
403 if roll < 0.65 {
404 IaReportStatus::Final
405 } else {
406 IaReportStatus::Draft
407 }
408 }
409
410 fn pick_priority(&mut self, high_risk: u32) -> RecommendationPriority {
411 let roll: f64 = self.rng.random();
412 if high_risk >= 1 && roll < 0.15 {
413 RecommendationPriority::Critical
414 } else if roll < 0.30 {
415 RecommendationPriority::High
416 } else if roll < 0.70 {
417 RecommendationPriority::Medium
418 } else {
419 RecommendationPriority::Low
420 }
421 }
422
423 fn pick_action_plan_status(&mut self) -> ActionPlanStatus {
424 let roll: f64 = self.rng.random();
425 if roll < 0.40 {
426 ActionPlanStatus::Open
427 } else if roll < 0.65 {
428 ActionPlanStatus::InProgress
429 } else if roll < 0.85 {
430 ActionPlanStatus::Implemented
431 } else {
432 ActionPlanStatus::Overdue
433 }
434 }
435
436 fn recommendation_description(&self, area: &str, priority: RecommendationPriority) -> String {
437 let suffix = match priority {
438 RecommendationPriority::Critical => {
439 "Immediate remediation required to address critical control failure."
440 }
441 RecommendationPriority::High => {
442 "Strengthen controls to reduce risk exposure within the next quarter."
443 }
444 RecommendationPriority::Medium => {
445 "Enhance monitoring procedures and update process documentation."
446 }
447 RecommendationPriority::Low => {
448 "Implement process improvement to increase efficiency and control effectiveness."
449 }
450 };
451 format!("{}: {}", area, suffix)
452 }
453
454 fn responsible_party(&mut self) -> String {
455 let parties = [
456 "Finance Director",
457 "Head of Compliance",
458 "IT Manager",
459 "Operations Manager",
460 "Controller",
461 "CFO",
462 "Risk Manager",
463 "HR Director",
464 ];
465 let idx = self.rng.random_range(0..parties.len());
466 parties[idx].to_string()
467 }
468}
469
470#[cfg(test)]
475#[allow(clippy::unwrap_used)]
476mod tests {
477 use super::*;
478 use crate::audit::test_helpers::create_test_engagement;
479
480 fn make_gen(seed: u64) -> InternalAuditGenerator {
481 InternalAuditGenerator::new(seed)
482 }
483
484 #[test]
488 fn test_generates_ia_function() {
489 let engagement = create_test_engagement();
490 let mut gen = make_gen(42);
491 let (function, _) = gen.generate(&engagement);
492
493 assert_eq!(function.engagement_id, engagement.engagement_id);
494 assert!(!function.head_of_ia.is_empty());
495 assert!(!function.department_name.is_empty());
496 assert!(function.function_ref.starts_with("IAF-"));
497 }
498
499 #[test]
501 fn test_no_reliance_empty_reports() {
502 let engagement = create_test_engagement();
503 let config = InternalAuditGeneratorConfig {
505 no_reliance_ratio: 1.0,
506 limited_reliance_ratio: 0.0,
507 significant_reliance_ratio: 0.0,
508 full_reliance_ratio: 0.0,
509 ..Default::default()
510 };
511 let mut gen = InternalAuditGenerator::with_config(10, config);
512 let (function, reports) = gen.generate(&engagement);
513
514 assert_eq!(function.reliance_extent, RelianceExtent::NoReliance);
515 assert!(reports.is_empty(), "NoReliance should produce zero reports");
516 }
517
518 #[test]
520 fn test_reports_within_range() {
521 let engagement = create_test_engagement();
522 let config = InternalAuditGeneratorConfig {
524 no_reliance_ratio: 0.0,
525 limited_reliance_ratio: 0.0,
526 significant_reliance_ratio: 0.0,
527 full_reliance_ratio: 1.0,
528 reports_per_function: (2, 5),
529 ..Default::default()
530 };
531 let mut gen = InternalAuditGenerator::with_config(7, config);
532 let (_, reports) = gen.generate(&engagement);
533
534 assert!(
535 reports.len() >= 2 && reports.len() <= 5,
536 "expected 2..=5 reports, got {}",
537 reports.len()
538 );
539 }
540
541 #[test]
543 fn test_recommendations_generated() {
544 let engagement = create_test_engagement();
545 let config = InternalAuditGeneratorConfig {
546 no_reliance_ratio: 0.0,
547 limited_reliance_ratio: 1.0,
548 ..Default::default()
549 };
550 let mut gen = InternalAuditGenerator::with_config(55, config);
551 let (_, reports) = gen.generate(&engagement);
552
553 for report in &reports {
554 assert!(
555 !report.recommendations.is_empty(),
556 "report '{}' should have at least one recommendation",
557 report.report_ref
558 );
559 assert_eq!(
561 report.recommendations.len(),
562 report.management_action_plans.len(),
563 "recommendation/action-plan count mismatch in report '{}'",
564 report.report_ref
565 );
566 }
567 }
568
569 #[test]
571 fn test_deterministic() {
572 let engagement = create_test_engagement();
573
574 let (func_a, reports_a) = {
575 let mut gen = make_gen(999);
576 gen.generate(&engagement)
577 };
578 let (func_b, reports_b) = {
579 let mut gen = make_gen(999);
580 gen.generate(&engagement)
581 };
582
583 assert_eq!(func_a.reliance_extent, func_b.reliance_extent);
584 assert_eq!(func_a.head_of_ia, func_b.head_of_ia);
585 assert_eq!(func_a.staff_count, func_b.staff_count);
586 assert_eq!(reports_a.len(), reports_b.len());
587 for (a, b) in reports_a.iter().zip(reports_b.iter()) {
588 assert_eq!(a.report_ref, b.report_ref);
589 assert_eq!(a.audit_area, b.audit_area);
590 assert_eq!(a.overall_rating, b.overall_rating);
591 assert_eq!(a.findings_count, b.findings_count);
592 }
593 }
594}