1use chrono::{Duration, NaiveDate};
7use rand::{Rng, SeedableRng};
8use rand_chacha::ChaCha8Rng;
9use rust_decimal::Decimal;
10
11use datasynth_core::models::audit::{
12 AuditEngagement, EngagementPhase, EngagementStatus, EngagementType, RiskLevel,
13};
14
15#[derive(Debug, Clone)]
17pub struct AuditEngagementConfig {
18 pub default_engagement_type: EngagementType,
20 pub materiality_percentage_range: (f64, f64),
22 pub performance_materiality_factor_range: (f64, f64),
24 pub clearly_trivial_factor_range: (f64, f64),
26 pub planning_duration_range: (u32, u32),
28 pub fieldwork_duration_range: (u32, u32),
30 pub completion_duration_range: (u32, u32),
32 pub team_size_range: (u32, u32),
34 pub high_fraud_risk_probability: f64,
36 pub significant_risk_probability: f64,
38}
39
40impl Default for AuditEngagementConfig {
41 fn default() -> Self {
42 Self {
43 default_engagement_type: EngagementType::AnnualAudit,
44 materiality_percentage_range: (0.003, 0.010), performance_materiality_factor_range: (0.50, 0.75),
46 clearly_trivial_factor_range: (0.03, 0.05),
47 planning_duration_range: (14, 30),
48 fieldwork_duration_range: (30, 60),
49 completion_duration_range: (14, 21),
50 team_size_range: (3, 8),
51 high_fraud_risk_probability: 0.15,
52 significant_risk_probability: 0.30,
53 }
54 }
55}
56
57pub struct AuditEngagementGenerator {
59 rng: ChaCha8Rng,
61 config: AuditEngagementConfig,
63 engagement_counter: u32,
65}
66
67impl AuditEngagementGenerator {
68 pub fn new(seed: u64) -> Self {
70 Self {
71 rng: ChaCha8Rng::seed_from_u64(seed),
72 config: AuditEngagementConfig::default(),
73 engagement_counter: 0,
74 }
75 }
76
77 pub fn with_config(seed: u64, config: AuditEngagementConfig) -> Self {
79 Self {
80 rng: ChaCha8Rng::seed_from_u64(seed),
81 config,
82 engagement_counter: 0,
83 }
84 }
85
86 pub fn generate_engagement(
88 &mut self,
89 client_entity_id: &str,
90 client_name: &str,
91 fiscal_year: u16,
92 period_end_date: NaiveDate,
93 total_revenue: Decimal,
94 engagement_type: Option<EngagementType>,
95 ) -> AuditEngagement {
96 self.engagement_counter += 1;
97
98 let eng_type = engagement_type.unwrap_or(self.config.default_engagement_type);
99
100 let materiality_pct = self.rng.gen_range(
102 self.config.materiality_percentage_range.0..=self.config.materiality_percentage_range.1,
103 );
104 let materiality = total_revenue * Decimal::try_from(materiality_pct).unwrap_or_default();
105
106 let perf_mat_factor = self.rng.gen_range(
107 self.config.performance_materiality_factor_range.0
108 ..=self.config.performance_materiality_factor_range.1,
109 );
110
111 let trivial_factor = self.rng.gen_range(
112 self.config.clearly_trivial_factor_range.0..=self.config.clearly_trivial_factor_range.1,
113 );
114
115 let timeline = self.generate_timeline(period_end_date);
117
118 let (partner_id, partner_name, manager_id, manager_name, team_members) =
120 self.generate_team();
121
122 let (overall_risk, fraud_risk, significant_count) = self.generate_risk_profile();
124
125 let mut engagement = AuditEngagement::new(
126 client_entity_id,
127 client_name,
128 eng_type,
129 fiscal_year,
130 period_end_date,
131 );
132
133 engagement.engagement_ref = format!("AUD-{}-{:04}", fiscal_year, self.engagement_counter);
134
135 engagement = engagement.with_materiality(
136 materiality,
137 perf_mat_factor,
138 trivial_factor,
139 "Total Revenue",
140 materiality_pct,
141 );
142
143 engagement = engagement.with_timeline(
144 timeline.planning_start,
145 timeline.planning_end,
146 timeline.fieldwork_start,
147 timeline.fieldwork_end,
148 timeline.completion_start,
149 timeline.report_date,
150 );
151
152 engagement = engagement.with_team(
153 &partner_id,
154 &partner_name,
155 &manager_id,
156 &manager_name,
157 team_members,
158 );
159
160 engagement.overall_audit_risk = overall_risk;
161 engagement.fraud_risk_level = fraud_risk;
162 engagement.significant_risk_count = significant_count;
163
164 engagement.status = EngagementStatus::Planning;
166 engagement.current_phase = EngagementPhase::Planning;
167
168 engagement
169 }
170
171 fn generate_timeline(&mut self, period_end_date: NaiveDate) -> EngagementTimeline {
173 let planning_duration = self.rng.gen_range(
175 self.config.planning_duration_range.0..=self.config.planning_duration_range.1,
176 );
177 let fieldwork_duration = self.rng.gen_range(
178 self.config.fieldwork_duration_range.0..=self.config.fieldwork_duration_range.1,
179 );
180 let completion_duration = self.rng.gen_range(
181 self.config.completion_duration_range.0..=self.config.completion_duration_range.1,
182 );
183
184 let planning_start = period_end_date - Duration::days(90);
186 let planning_end = planning_start + Duration::days(planning_duration as i64);
187
188 let fieldwork_start = period_end_date + Duration::days(5);
190 let fieldwork_end = fieldwork_start + Duration::days(fieldwork_duration as i64);
191
192 let completion_start = fieldwork_end + Duration::days(1);
194 let report_date = completion_start + Duration::days(completion_duration as i64);
195
196 EngagementTimeline {
197 planning_start,
198 planning_end,
199 fieldwork_start,
200 fieldwork_end,
201 completion_start,
202 report_date,
203 }
204 }
205
206 fn generate_team(&mut self) -> (String, String, String, String, Vec<String>) {
208 let team_size = self
209 .rng
210 .gen_range(self.config.team_size_range.0..=self.config.team_size_range.1)
211 as usize;
212
213 let partner_num = self.rng.gen_range(1..=20);
215 let partner_id = format!("PARTNER{:03}", partner_num);
216 let partner_name = self.generate_auditor_name(partner_num);
217
218 let manager_num = self.rng.gen_range(1..=50);
220 let manager_id = format!("MANAGER{:03}", manager_num);
221 let manager_name = self.generate_auditor_name(manager_num + 100);
222
223 let mut team_members = Vec::with_capacity(team_size);
225 for i in 0..team_size {
226 let member_num = self.rng.gen_range(1..=200);
227 if i < team_size / 2 {
228 team_members.push(format!("SENIOR{:03}", member_num));
229 } else {
230 team_members.push(format!("STAFF{:03}", member_num));
231 }
232 }
233
234 (
235 partner_id,
236 partner_name,
237 manager_id,
238 manager_name,
239 team_members,
240 )
241 }
242
243 fn generate_auditor_name(&mut self, seed: u32) -> String {
245 let first_names = [
246 "Michael",
247 "Sarah",
248 "David",
249 "Jennifer",
250 "Robert",
251 "Emily",
252 "James",
253 "Amanda",
254 "William",
255 "Jessica",
256 "John",
257 "Ashley",
258 "Daniel",
259 "Nicole",
260 "Christopher",
261 "Michelle",
262 ];
263 let last_names = [
264 "Smith", "Johnson", "Williams", "Brown", "Jones", "Davis", "Miller", "Wilson", "Moore",
265 "Taylor", "Anderson", "Thomas", "Jackson", "White", "Harris", "Martin",
266 ];
267
268 let first_idx = (seed as usize) % first_names.len();
269 let last_idx = ((seed as usize) / first_names.len()) % last_names.len();
270
271 format!("{} {}", first_names[first_idx], last_names[last_idx])
272 }
273
274 fn generate_risk_profile(&mut self) -> (RiskLevel, RiskLevel, u32) {
276 let fraud_risk = if self.rng.gen::<f64>() < self.config.high_fraud_risk_probability {
278 RiskLevel::High
279 } else if self.rng.gen::<f64>() < 0.40 {
280 RiskLevel::Medium
281 } else {
282 RiskLevel::Low
283 };
284
285 let significant_count = if self.rng.gen::<f64>() < self.config.significant_risk_probability
287 {
288 self.rng.gen_range(2..=8)
289 } else {
290 self.rng.gen_range(0..=2)
291 };
292
293 let overall_risk = if fraud_risk == RiskLevel::High || significant_count > 5 {
295 RiskLevel::High
296 } else if fraud_risk == RiskLevel::Medium || significant_count > 2 {
297 RiskLevel::Medium
298 } else {
299 RiskLevel::Low
300 };
301
302 (overall_risk, fraud_risk, significant_count)
303 }
304
305 pub fn generate_engagements_batch(
307 &mut self,
308 companies: &[CompanyInfo],
309 fiscal_year: u16,
310 ) -> Vec<AuditEngagement> {
311 companies
312 .iter()
313 .map(|company| {
314 self.generate_engagement(
315 &company.entity_id,
316 &company.name,
317 fiscal_year,
318 company.period_end_date,
319 company.total_revenue,
320 company.engagement_type,
321 )
322 })
323 .collect()
324 }
325
326 pub fn advance_engagement_phase(
328 &mut self,
329 engagement: &mut AuditEngagement,
330 current_date: NaiveDate,
331 ) {
332 let new_phase = if current_date < engagement.planning_end {
334 EngagementPhase::Planning
335 } else if current_date < engagement.fieldwork_start {
336 EngagementPhase::RiskAssessment
337 } else if current_date < engagement.fieldwork_end {
338 let days_into_fieldwork = (current_date - engagement.fieldwork_start).num_days();
340 let fieldwork_duration =
341 (engagement.fieldwork_end - engagement.fieldwork_start).num_days();
342
343 if days_into_fieldwork < fieldwork_duration / 3 {
344 EngagementPhase::ControlTesting
345 } else {
346 EngagementPhase::SubstantiveTesting
347 }
348 } else if current_date < engagement.report_date {
349 EngagementPhase::Completion
350 } else {
351 EngagementPhase::Reporting
352 };
353
354 if new_phase != engagement.current_phase {
355 engagement.current_phase = new_phase;
356
357 engagement.status = match new_phase {
359 EngagementPhase::Planning | EngagementPhase::RiskAssessment => {
360 EngagementStatus::Planning
361 }
362 EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting => {
363 EngagementStatus::InProgress
364 }
365 EngagementPhase::Completion => EngagementStatus::UnderReview,
366 EngagementPhase::Reporting => EngagementStatus::PendingSignOff,
367 };
368 }
369 }
370}
371
372#[derive(Debug, Clone)]
374struct EngagementTimeline {
375 planning_start: NaiveDate,
376 planning_end: NaiveDate,
377 fieldwork_start: NaiveDate,
378 fieldwork_end: NaiveDate,
379 completion_start: NaiveDate,
380 report_date: NaiveDate,
381}
382
383#[derive(Debug, Clone)]
385pub struct CompanyInfo {
386 pub entity_id: String,
388 pub name: String,
390 pub period_end_date: NaiveDate,
392 pub total_revenue: Decimal,
394 pub engagement_type: Option<EngagementType>,
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn test_engagement_generation() {
404 let mut generator = AuditEngagementGenerator::new(42);
405 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
406 let revenue = Decimal::new(100_000_000, 0); let engagement = generator.generate_engagement(
409 "ENTITY001",
410 "Test Company Inc.",
411 2025,
412 period_end,
413 revenue,
414 None,
415 );
416
417 assert_eq!(engagement.fiscal_year, 2025);
418 assert_eq!(engagement.engagement_type, EngagementType::AnnualAudit);
419 assert!(engagement.materiality > Decimal::ZERO);
420 assert!(engagement.performance_materiality <= engagement.materiality);
421 assert!(!engagement.engagement_partner_id.is_empty());
422 assert!(!engagement.team_member_ids.is_empty());
423 }
424
425 #[test]
426 fn test_batch_generation() {
427 let mut generator = AuditEngagementGenerator::new(42);
428
429 let companies = vec![
430 CompanyInfo {
431 entity_id: "ENTITY001".into(),
432 name: "Company A".into(),
433 period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
434 total_revenue: Decimal::new(50_000_000, 0),
435 engagement_type: None,
436 },
437 CompanyInfo {
438 entity_id: "ENTITY002".into(),
439 name: "Company B".into(),
440 period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
441 total_revenue: Decimal::new(75_000_000, 0),
442 engagement_type: Some(EngagementType::IntegratedAudit),
443 },
444 ];
445
446 let engagements = generator.generate_engagements_batch(&companies, 2025);
447
448 assert_eq!(engagements.len(), 2);
449 assert_eq!(
450 engagements[1].engagement_type,
451 EngagementType::IntegratedAudit
452 );
453 }
454
455 #[test]
456 fn test_phase_advancement() {
457 let mut generator = AuditEngagementGenerator::new(42);
458 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
459
460 let mut engagement = generator.generate_engagement(
461 "ENTITY001",
462 "Test Company",
463 2025,
464 period_end,
465 Decimal::new(100_000_000, 0),
466 None,
467 );
468
469 generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(85));
472 assert_eq!(engagement.current_phase, EngagementPhase::Planning);
473
474 generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(30));
476 assert_eq!(engagement.current_phase, EngagementPhase::RiskAssessment);
477
478 generator.advance_engagement_phase(&mut engagement, period_end + Duration::days(10));
480 assert!(matches!(
481 engagement.current_phase,
482 EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting
483 ));
484 }
485
486 #[test]
487 fn test_materiality_calculation() {
488 let mut generator = AuditEngagementGenerator::new(42);
489 let revenue = Decimal::new(100_000_000, 0); let engagement = generator.generate_engagement(
492 "ENTITY001",
493 "Test Company",
494 2025,
495 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
496 revenue,
497 None,
498 );
499
500 let min_materiality = revenue * Decimal::try_from(0.003).unwrap();
502 let max_materiality = revenue * Decimal::try_from(0.010).unwrap();
503
504 assert!(engagement.materiality >= min_materiality);
505 assert!(engagement.materiality <= max_materiality);
506
507 assert!(
509 engagement.performance_materiality
510 >= engagement.materiality * Decimal::try_from(0.50).unwrap()
511 );
512 assert!(
513 engagement.performance_materiality
514 <= engagement.materiality * Decimal::try_from(0.75).unwrap()
515 );
516 }
517}