1use chrono::{Duration, NaiveDate};
7use datasynth_core::utils::seeded_rng;
8use rand::Rng;
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 generate_engagement(
89 &mut self,
90 client_entity_id: &str,
91 client_name: &str,
92 fiscal_year: u16,
93 period_end_date: NaiveDate,
94 total_revenue: Decimal,
95 engagement_type: Option<EngagementType>,
96 ) -> AuditEngagement {
97 self.engagement_counter += 1;
98
99 let eng_type = engagement_type.unwrap_or(self.config.default_engagement_type);
100
101 let materiality_pct = self.rng.random_range(
103 self.config.materiality_percentage_range.0..=self.config.materiality_percentage_range.1,
104 );
105 let materiality = total_revenue * Decimal::try_from(materiality_pct).unwrap_or_default();
106
107 let perf_mat_factor = self.rng.random_range(
108 self.config.performance_materiality_factor_range.0
109 ..=self.config.performance_materiality_factor_range.1,
110 );
111
112 let trivial_factor = self.rng.random_range(
113 self.config.clearly_trivial_factor_range.0..=self.config.clearly_trivial_factor_range.1,
114 );
115
116 let timeline = self.generate_timeline(period_end_date);
118
119 let (partner_id, partner_name, manager_id, manager_name, team_members) =
121 self.generate_team();
122
123 let (overall_risk, fraud_risk, significant_count) = self.generate_risk_profile();
125
126 let mut engagement = AuditEngagement::new(
127 client_entity_id,
128 client_name,
129 eng_type,
130 fiscal_year,
131 period_end_date,
132 );
133
134 engagement.engagement_ref = format!("AUD-{}-{:04}", fiscal_year, self.engagement_counter);
135
136 engagement = engagement.with_materiality(
137 materiality,
138 perf_mat_factor,
139 trivial_factor,
140 "Total Revenue",
141 materiality_pct,
142 );
143
144 engagement = engagement.with_timeline(
145 timeline.planning_start,
146 timeline.planning_end,
147 timeline.fieldwork_start,
148 timeline.fieldwork_end,
149 timeline.completion_start,
150 timeline.report_date,
151 );
152
153 engagement = engagement.with_team(
154 &partner_id,
155 &partner_name,
156 &manager_id,
157 &manager_name,
158 team_members,
159 );
160
161 engagement.overall_audit_risk = overall_risk;
162 engagement.fraud_risk_level = fraud_risk;
163 engagement.significant_risk_count = significant_count;
164
165 engagement.status = EngagementStatus::Planning;
167 engagement.current_phase = EngagementPhase::Planning;
168
169 engagement
170 }
171
172 fn generate_timeline(&mut self, period_end_date: NaiveDate) -> EngagementTimeline {
174 let planning_duration = self.rng.random_range(
176 self.config.planning_duration_range.0..=self.config.planning_duration_range.1,
177 );
178 let fieldwork_duration = self.rng.random_range(
179 self.config.fieldwork_duration_range.0..=self.config.fieldwork_duration_range.1,
180 );
181 let completion_duration = self.rng.random_range(
182 self.config.completion_duration_range.0..=self.config.completion_duration_range.1,
183 );
184
185 let planning_start = period_end_date - Duration::days(90);
187 let planning_end = planning_start + Duration::days(planning_duration as i64);
188
189 let fieldwork_start = period_end_date + Duration::days(5);
191 let fieldwork_end = fieldwork_start + Duration::days(fieldwork_duration as i64);
192
193 let completion_start = fieldwork_end + Duration::days(1);
195 let report_date = completion_start + Duration::days(completion_duration as i64);
196
197 EngagementTimeline {
198 planning_start,
199 planning_end,
200 fieldwork_start,
201 fieldwork_end,
202 completion_start,
203 report_date,
204 }
205 }
206
207 fn generate_team(&mut self) -> (String, String, String, String, Vec<String>) {
209 let team_size = self
210 .rng
211 .random_range(self.config.team_size_range.0..=self.config.team_size_range.1)
212 as usize;
213
214 let partner_num = self.rng.random_range(1..=20);
216 let partner_id = format!("PARTNER{:03}", partner_num);
217 let partner_name = self.generate_auditor_name(partner_num);
218
219 let manager_num = self.rng.random_range(1..=50);
221 let manager_id = format!("MANAGER{:03}", manager_num);
222 let manager_name = self.generate_auditor_name(manager_num + 100);
223
224 let mut team_members = Vec::with_capacity(team_size);
226 for i in 0..team_size {
227 let member_num = self.rng.random_range(1..=200);
228 if i < team_size / 2 {
229 team_members.push(format!("SENIOR{:03}", member_num));
230 } else {
231 team_members.push(format!("STAFF{:03}", member_num));
232 }
233 }
234
235 (
236 partner_id,
237 partner_name,
238 manager_id,
239 manager_name,
240 team_members,
241 )
242 }
243
244 fn generate_auditor_name(&mut self, seed: u32) -> String {
246 let first_names = [
247 "Michael",
248 "Sarah",
249 "David",
250 "Jennifer",
251 "Robert",
252 "Emily",
253 "James",
254 "Amanda",
255 "William",
256 "Jessica",
257 "John",
258 "Ashley",
259 "Daniel",
260 "Nicole",
261 "Christopher",
262 "Michelle",
263 ];
264 let last_names = [
265 "Smith", "Johnson", "Williams", "Brown", "Jones", "Davis", "Miller", "Wilson", "Moore",
266 "Taylor", "Anderson", "Thomas", "Jackson", "White", "Harris", "Martin",
267 ];
268
269 let first_idx = (seed as usize) % first_names.len();
270 let last_idx = ((seed as usize) / first_names.len()) % last_names.len();
271
272 format!("{} {}", first_names[first_idx], last_names[last_idx])
273 }
274
275 fn generate_risk_profile(&mut self) -> (RiskLevel, RiskLevel, u32) {
277 let fraud_risk = if self.rng.random::<f64>() < self.config.high_fraud_risk_probability {
279 RiskLevel::High
280 } else if self.rng.random::<f64>() < 0.40 {
281 RiskLevel::Medium
282 } else {
283 RiskLevel::Low
284 };
285
286 let significant_count =
288 if self.rng.random::<f64>() < self.config.significant_risk_probability {
289 self.rng.random_range(2..=8)
290 } else {
291 self.rng.random_range(0..=2)
292 };
293
294 let overall_risk = if fraud_risk == RiskLevel::High || significant_count > 5 {
296 RiskLevel::High
297 } else if fraud_risk == RiskLevel::Medium || significant_count > 2 {
298 RiskLevel::Medium
299 } else {
300 RiskLevel::Low
301 };
302
303 (overall_risk, fraud_risk, significant_count)
304 }
305
306 pub fn generate_engagements_batch(
308 &mut self,
309 companies: &[CompanyInfo],
310 fiscal_year: u16,
311 ) -> Vec<AuditEngagement> {
312 companies
313 .iter()
314 .map(|company| {
315 self.generate_engagement(
316 &company.entity_id,
317 &company.name,
318 fiscal_year,
319 company.period_end_date,
320 company.total_revenue,
321 company.engagement_type,
322 )
323 })
324 .collect()
325 }
326
327 pub fn advance_engagement_phase(
329 &mut self,
330 engagement: &mut AuditEngagement,
331 current_date: NaiveDate,
332 ) {
333 let new_phase = if current_date < engagement.planning_end {
335 EngagementPhase::Planning
336 } else if current_date < engagement.fieldwork_start {
337 EngagementPhase::RiskAssessment
338 } else if current_date < engagement.fieldwork_end {
339 let days_into_fieldwork = (current_date - engagement.fieldwork_start).num_days();
341 let fieldwork_duration =
342 (engagement.fieldwork_end - engagement.fieldwork_start).num_days();
343
344 if days_into_fieldwork < fieldwork_duration / 3 {
345 EngagementPhase::ControlTesting
346 } else {
347 EngagementPhase::SubstantiveTesting
348 }
349 } else if current_date < engagement.report_date {
350 EngagementPhase::Completion
351 } else {
352 EngagementPhase::Reporting
353 };
354
355 if new_phase != engagement.current_phase {
356 engagement.current_phase = new_phase;
357
358 engagement.status = match new_phase {
360 EngagementPhase::Planning | EngagementPhase::RiskAssessment => {
361 EngagementStatus::Planning
362 }
363 EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting => {
364 EngagementStatus::InProgress
365 }
366 EngagementPhase::Completion => EngagementStatus::UnderReview,
367 EngagementPhase::Reporting => EngagementStatus::PendingSignOff,
368 };
369 }
370 }
371}
372
373#[derive(Debug, Clone)]
375struct EngagementTimeline {
376 planning_start: NaiveDate,
377 planning_end: NaiveDate,
378 fieldwork_start: NaiveDate,
379 fieldwork_end: NaiveDate,
380 completion_start: NaiveDate,
381 report_date: NaiveDate,
382}
383
384#[derive(Debug, Clone)]
386pub struct CompanyInfo {
387 pub entity_id: String,
389 pub name: String,
391 pub period_end_date: NaiveDate,
393 pub total_revenue: Decimal,
395 pub engagement_type: Option<EngagementType>,
397}
398
399#[cfg(test)]
400#[allow(clippy::unwrap_used)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn test_engagement_generation() {
406 let mut generator = AuditEngagementGenerator::new(42);
407 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
408 let revenue = Decimal::new(100_000_000, 0); let engagement = generator.generate_engagement(
411 "ENTITY001",
412 "Test Company Inc.",
413 2025,
414 period_end,
415 revenue,
416 None,
417 );
418
419 assert_eq!(engagement.fiscal_year, 2025);
420 assert_eq!(engagement.engagement_type, EngagementType::AnnualAudit);
421 assert!(engagement.materiality > Decimal::ZERO);
422 assert!(engagement.performance_materiality <= engagement.materiality);
423 assert!(!engagement.engagement_partner_id.is_empty());
424 assert!(!engagement.team_member_ids.is_empty());
425 }
426
427 #[test]
428 fn test_batch_generation() {
429 let mut generator = AuditEngagementGenerator::new(42);
430
431 let companies = vec![
432 CompanyInfo {
433 entity_id: "ENTITY001".into(),
434 name: "Company A".into(),
435 period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
436 total_revenue: Decimal::new(50_000_000, 0),
437 engagement_type: None,
438 },
439 CompanyInfo {
440 entity_id: "ENTITY002".into(),
441 name: "Company B".into(),
442 period_end_date: NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
443 total_revenue: Decimal::new(75_000_000, 0),
444 engagement_type: Some(EngagementType::IntegratedAudit),
445 },
446 ];
447
448 let engagements = generator.generate_engagements_batch(&companies, 2025);
449
450 assert_eq!(engagements.len(), 2);
451 assert_eq!(
452 engagements[1].engagement_type,
453 EngagementType::IntegratedAudit
454 );
455 }
456
457 #[test]
458 fn test_phase_advancement() {
459 let mut generator = AuditEngagementGenerator::new(42);
460 let period_end = NaiveDate::from_ymd_opt(2025, 12, 31).unwrap();
461
462 let mut engagement = generator.generate_engagement(
463 "ENTITY001",
464 "Test Company",
465 2025,
466 period_end,
467 Decimal::new(100_000_000, 0),
468 None,
469 );
470
471 generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(85));
474 assert_eq!(engagement.current_phase, EngagementPhase::Planning);
475
476 generator.advance_engagement_phase(&mut engagement, period_end - Duration::days(30));
478 assert_eq!(engagement.current_phase, EngagementPhase::RiskAssessment);
479
480 generator.advance_engagement_phase(&mut engagement, period_end + Duration::days(10));
482 assert!(matches!(
483 engagement.current_phase,
484 EngagementPhase::ControlTesting | EngagementPhase::SubstantiveTesting
485 ));
486 }
487
488 #[test]
489 fn test_materiality_calculation() {
490 let mut generator = AuditEngagementGenerator::new(42);
491 let revenue = Decimal::new(100_000_000, 0); let engagement = generator.generate_engagement(
494 "ENTITY001",
495 "Test Company",
496 2025,
497 NaiveDate::from_ymd_opt(2025, 12, 31).unwrap(),
498 revenue,
499 None,
500 );
501
502 let min_materiality = revenue * Decimal::try_from(0.003).unwrap();
504 let max_materiality = revenue * Decimal::try_from(0.010).unwrap();
505
506 assert!(engagement.materiality >= min_materiality);
507 assert!(engagement.materiality <= max_materiality);
508
509 assert!(
511 engagement.performance_materiality
512 >= engagement.materiality * Decimal::try_from(0.50).unwrap()
513 );
514 assert!(
515 engagement.performance_materiality
516 <= engagement.materiality * Decimal::try_from(0.75).unwrap()
517 );
518 }
519}