1use chrono::{Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::RngExt;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11
12use datasynth_core::models::audit::{
13 AuditEngagement, EngagementPhase, EngagementStatus, EngagementType, RiskLevel,
14};
15
16#[derive(Debug, Clone)]
18pub struct AuditEngagementConfig {
19 pub default_engagement_type: EngagementType,
21 pub materiality_percentage_range: (f64, f64),
23 pub performance_materiality_factor_range: (f64, f64),
25 pub clearly_trivial_factor_range: (f64, f64),
27 pub planning_duration_range: (u32, u32),
29 pub fieldwork_duration_range: (u32, u32),
31 pub completion_duration_range: (u32, u32),
33 pub team_size_range: (u32, u32),
35 pub high_fraud_risk_probability: f64,
37 pub significant_risk_probability: f64,
39}
40
41impl Default for AuditEngagementConfig {
42 fn default() -> Self {
43 Self {
44 default_engagement_type: EngagementType::AnnualAudit,
45 materiality_percentage_range: (0.003, 0.010), performance_materiality_factor_range: (0.50, 0.75),
47 clearly_trivial_factor_range: (0.03, 0.05),
48 planning_duration_range: (14, 30),
49 fieldwork_duration_range: (30, 60),
50 completion_duration_range: (14, 21),
51 team_size_range: (3, 8),
52 high_fraud_risk_probability: 0.15,
53 significant_risk_probability: 0.30,
54 }
55 }
56}
57
58pub struct AuditEngagementGenerator {
60 rng: ChaCha8Rng,
62 config: AuditEngagementConfig,
64 engagement_counter: u32,
66}
67
68impl AuditEngagementGenerator {
69 pub fn new(seed: u64) -> Self {
71 Self {
72 rng: seeded_rng(seed, 0),
73 config: AuditEngagementConfig::default(),
74 engagement_counter: 0,
75 }
76 }
77
78 pub fn with_config(seed: u64, config: AuditEngagementConfig) -> Self {
80 Self {
81 rng: seeded_rng(seed, 0),
82 config,
83 engagement_counter: 0,
84 }
85 }
86
87 pub fn set_team_config(&mut self, team_config: &datasynth_config::AuditTeamConfig) {
95 self.config.team_size_range = (
96 team_config.min_team_size as u32,
97 team_config.max_team_size as u32,
98 );
99 let _ = team_config.specialist_probability;
104 }
105
106 pub fn draw_engagement_type(
117 &mut self,
118 types: &datasynth_config::AuditEngagementTypesConfig,
119 ) -> EngagementType {
120 let roll: f64 = self.rng.random();
121 let mut acc = types.financial_statement;
122 if roll < acc {
123 return EngagementType::AnnualAudit;
124 }
125 acc += types.sox_icfr + types.integrated;
126 if roll < acc {
127 return EngagementType::IntegratedAudit;
128 }
129 acc += types.review;
130 if roll < acc {
131 return EngagementType::ReviewEngagement;
132 }
133 EngagementType::AgreedUponProcedures
134 }
135
136 pub fn generate_engagement(
138 &mut self,
139 client_entity_id: &str,
140 client_name: &str,
141 fiscal_year: u16,
142 period_end_date: NaiveDate,
143 total_revenue: Decimal,
144 engagement_type: Option<EngagementType>,
145 ) -> AuditEngagement {
146 self.engagement_counter += 1;
147
148 let eng_type = engagement_type.unwrap_or(self.config.default_engagement_type);
149
150 let materiality_pct = self.rng.random_range(
152 self.config.materiality_percentage_range.0..=self.config.materiality_percentage_range.1,
153 );
154 let materiality = total_revenue * Decimal::try_from(materiality_pct).unwrap_or_default();
155
156 let perf_mat_factor = self.rng.random_range(
157 self.config.performance_materiality_factor_range.0
158 ..=self.config.performance_materiality_factor_range.1,
159 );
160
161 let trivial_factor = self.rng.random_range(
162 self.config.clearly_trivial_factor_range.0..=self.config.clearly_trivial_factor_range.1,
163 );
164
165 let timeline = self.generate_timeline(period_end_date);
167
168 let (partner_id, partner_name, manager_id, manager_name, team_members) =
170 self.generate_team();
171
172 let (overall_risk, fraud_risk, significant_count) = self.generate_risk_profile();
174
175 let mut engagement = AuditEngagement::new(
176 client_entity_id,
177 client_name,
178 eng_type,
179 fiscal_year,
180 period_end_date,
181 );
182
183 engagement.engagement_ref = format!("AUD-{}-{:04}", fiscal_year, self.engagement_counter);
184
185 engagement = engagement.with_materiality(
186 materiality,
187 perf_mat_factor,
188 trivial_factor,
189 "Total Revenue",
190 materiality_pct,
191 );
192
193 engagement = engagement.with_timeline(
194 timeline.planning_start,
195 timeline.planning_end,
196 timeline.fieldwork_start,
197 timeline.fieldwork_end,
198 timeline.completion_start,
199 timeline.report_date,
200 );
201
202 engagement = engagement.with_team(
203 &partner_id,
204 &partner_name,
205 &manager_id,
206 &manager_name,
207 team_members,
208 );
209
210 engagement.overall_audit_risk = overall_risk;
211 engagement.fraud_risk_level = fraud_risk;
212 engagement.significant_risk_count = significant_count;
213
214 engagement.status = EngagementStatus::Planning;
216 engagement.current_phase = EngagementPhase::Planning;
217
218 engagement
219 }
220
221 fn generate_timeline(&mut self, period_end_date: NaiveDate) -> EngagementTimeline {
223 let planning_duration = self.rng.random_range(
225 self.config.planning_duration_range.0..=self.config.planning_duration_range.1,
226 );
227 let fieldwork_duration = self.rng.random_range(
228 self.config.fieldwork_duration_range.0..=self.config.fieldwork_duration_range.1,
229 );
230 let completion_duration = self.rng.random_range(
231 self.config.completion_duration_range.0..=self.config.completion_duration_range.1,
232 );
233
234 let planning_start = period_end_date - Duration::days(90);
236 let planning_end = planning_start + Duration::days(planning_duration as i64);
237
238 let fieldwork_start = period_end_date + Duration::days(5);
240 let fieldwork_end = fieldwork_start + Duration::days(fieldwork_duration as i64);
241
242 let completion_start = fieldwork_end + Duration::days(1);
244 let report_date = completion_start + Duration::days(completion_duration as i64);
245
246 EngagementTimeline {
247 planning_start,
248 planning_end,
249 fieldwork_start,
250 fieldwork_end,
251 completion_start,
252 report_date,
253 }
254 }
255
256 fn generate_team(&mut self) -> (String, String, String, String, Vec<String>) {
258 let team_size = self
259 .rng
260 .random_range(self.config.team_size_range.0..=self.config.team_size_range.1)
261 as usize;
262
263 let partner_num = self.rng.random_range(1..=20);
265 let partner_id = format!("PARTNER{partner_num:03}");
266 let partner_name = self.generate_auditor_name(partner_num);
267
268 let manager_num = self.rng.random_range(1..=50);
270 let manager_id = format!("MANAGER{manager_num:03}");
271 let manager_name = self.generate_auditor_name(manager_num + 100);
272
273 let mut team_members = Vec::with_capacity(team_size);
275 for i in 0..team_size {
276 let member_num = self.rng.random_range(1..=200);
277 if i < team_size / 2 {
278 team_members.push(format!("SENIOR{member_num:03}"));
279 } else {
280 team_members.push(format!("STAFF{member_num:03}"));
281 }
282 }
283
284 (
285 partner_id,
286 partner_name,
287 manager_id,
288 manager_name,
289 team_members,
290 )
291 }
292
293 fn generate_auditor_name(&mut self, seed: u32) -> String {
295 let first_names = [
296 "Michael",
297 "Sarah",
298 "David",
299 "Jennifer",
300 "Robert",
301 "Emily",
302 "James",
303 "Amanda",
304 "William",
305 "Jessica",
306 "John",
307 "Ashley",
308 "Daniel",
309 "Nicole",
310 "Christopher",
311 "Michelle",
312 ];
313 let last_names = [
314 "Smith", "Johnson", "Williams", "Brown", "Jones", "Davis", "Miller", "Wilson", "Moore",
315 "Taylor", "Anderson", "Thomas", "Jackson", "White", "Harris", "Martin",
316 ];
317
318 let first_idx = (seed as usize) % first_names.len();
319 let last_idx = ((seed as usize) / first_names.len()) % last_names.len();
320
321 format!("{} {}", first_names[first_idx], last_names[last_idx])
322 }
323
324 fn generate_risk_profile(&mut self) -> (RiskLevel, RiskLevel, u32) {
326 let fraud_risk = if self.rng.random::<f64>() < self.config.high_fraud_risk_probability {
328 RiskLevel::High
329 } else if self.rng.random::<f64>() < 0.40 {
330 RiskLevel::Medium
331 } else {
332 RiskLevel::Low
333 };
334
335 let significant_count =
337 if self.rng.random::<f64>() < self.config.significant_risk_probability {
338 self.rng.random_range(2..=8)
339 } else {
340 self.rng.random_range(0..=2)
341 };
342
343 let overall_risk = if fraud_risk == RiskLevel::High || significant_count > 5 {
345 RiskLevel::High
346 } else if fraud_risk == RiskLevel::Medium || significant_count > 2 {
347 RiskLevel::Medium
348 } else {
349 RiskLevel::Low
350 };
351
352 (overall_risk, fraud_risk, significant_count)
353 }
354
355 pub fn generate_engagements_batch(
357 &mut self,
358 companies: &[CompanyInfo],
359 fiscal_year: u16,
360 ) -> Vec<AuditEngagement> {
361 companies
362 .iter()
363 .map(|company| {
364 self.generate_engagement(
365 &company.entity_id,
366 &company.name,
367 fiscal_year,
368 company.period_end_date,
369 company.total_revenue,
370 company.engagement_type,
371 )
372 })
373 .collect()
374 }
375
376 pub fn advance_engagement_phase(
378 &mut self,
379 engagement: &mut AuditEngagement,
380 current_date: NaiveDate,
381 ) {
382 let new_phase = if current_date < engagement.planning_end {
384 EngagementPhase::Planning
385 } else if current_date < engagement.fieldwork_start {
386 EngagementPhase::RiskAssessment
387 } else if current_date < engagement.fieldwork_end {
388 let days_into_fieldwork = (current_date - engagement.fieldwork_start).num_days();
390 let fieldwork_duration =
391 (engagement.fieldwork_end - engagement.fieldwork_start).num_days();
392
393 if days_into_fieldwork < fieldwork_duration / 3 {
394 EngagementPhase::ControlTesting
395 } else {
396 EngagementPhase::SubstantiveTesting
397 }
398 } else if current_date < engagement.report_date {
399 EngagementPhase::Completion
400 } else {
401 EngagementPhase::Reporting
402 };
403
404 if new_phase != engagement.current_phase {
405 engagement.current_phase = new_phase;
406
407 engagement.status = match new_phase {
409 EngagementPhase::Planning | EngagementPhase::RiskAssessment => {
410 EngagementStatus::Planning
411 }
412 EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting => {
413 EngagementStatus::InProgress
414 }
415 EngagementPhase::Completion => EngagementStatus::UnderReview,
416 EngagementPhase::Reporting => EngagementStatus::PendingSignOff,
417 };
418 }
419 }
420}
421
422#[derive(Debug, Clone)]
424struct EngagementTimeline {
425 planning_start: NaiveDate,
426 planning_end: NaiveDate,
427 fieldwork_start: NaiveDate,
428 fieldwork_end: NaiveDate,
429 completion_start: NaiveDate,
430 report_date: NaiveDate,
431}
432
433#[derive(Debug, Clone)]
435pub struct CompanyInfo {
436 pub entity_id: String,
438 pub name: String,
440 pub period_end_date: NaiveDate,
442 pub total_revenue: Decimal,
444 pub engagement_type: Option<EngagementType>,
446}
447
448#[cfg(test)]
449#[allow(clippy::unwrap_used)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn test_engagement_generation() {
455 let mut generator = AuditEngagementGenerator::new(42);
456 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
457 let revenue = Decimal::new(100_000_000, 0); let engagement = generator.generate_engagement(
460 "ENTITY001",
461 "Test Company Inc.",
462 2025,
463 period_end,
464 revenue,
465 None,
466 );
467
468 assert_eq!(engagement.fiscal_year, 2025);
469 assert_eq!(engagement.engagement_type, EngagementType::AnnualAudit);
470 assert!(engagement.materiality > Decimal::ZERO);
471 assert!(engagement.performance_materiality <= engagement.materiality);
472 assert!(!engagement.engagement_partner_id.is_empty());
473 assert!(!engagement.team_member_ids.is_empty());
474 }
475
476 #[test]
477 fn test_batch_generation() {
478 let mut generator = AuditEngagementGenerator::new(42);
479
480 let companies = vec![
481 CompanyInfo {
482 entity_id: "ENTITY001".into(),
483 name: "Company A".into(),
484 period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
485 total_revenue: Decimal::new(50_000_000, 0),
486 engagement_type: None,
487 },
488 CompanyInfo {
489 entity_id: "ENTITY002".into(),
490 name: "Company B".into(),
491 period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
492 total_revenue: Decimal::new(75_000_000, 0),
493 engagement_type: Some(EngagementType::IntegratedAudit),
494 },
495 ];
496
497 let engagements = generator.generate_engagements_batch(&companies, 2025);
498
499 assert_eq!(engagements.len(), 2);
500 assert_eq!(
501 engagements[1].engagement_type,
502 EngagementType::IntegratedAudit
503 );
504 }
505
506 #[test]
507 fn test_phase_advancement() {
508 let mut generator = AuditEngagementGenerator::new(42);
509 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
510
511 let mut engagement = generator.generate_engagement(
512 "ENTITY001",
513 "Test Company",
514 2025,
515 period_end,
516 Decimal::new(100_000_000, 0),
517 None,
518 );
519
520 generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(85));
523 assert_eq!(engagement.current_phase, EngagementPhase::Planning);
524
525 generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(30));
527 assert_eq!(engagement.current_phase, EngagementPhase::RiskAssessment);
528
529 generator.advance_engagement_phase(&mut engagement, period_end + Duration::days(10));
531 assert!(matches!(
532 engagement.current_phase,
533 EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting
534 ));
535 }
536
537 #[test]
538 fn test_materiality_calculation() {
539 let mut generator = AuditEngagementGenerator::new(42);
540 let revenue = Decimal::new(100_000_000, 0); let engagement = generator.generate_engagement(
543 "ENTITY001",
544 "Test Company",
545 2025,
546 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
547 revenue,
548 None,
549 );
550
551 let min_materiality = revenue * Decimal::try_from(0.003).unwrap();
553 let max_materiality = revenue * Decimal::try_from(0.010).unwrap();
554
555 assert!(engagement.materiality >= min_materiality);
556 assert!(engagement.materiality <= max_materiality);
557
558 assert!(
560 engagement.performance_materiality
561 >= engagement.materiality * Decimal::try_from(0.50).unwrap()
562 );
563 assert!(
564 engagement.performance_materiality
565 <= engagement.materiality * Decimal::try_from(0.75).unwrap()
566 );
567 }
568}