1use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use uuid::Uuid;
10
11use super::super::graph_properties::{GraphPropertyValue, ToNodeProperties};
12use super::engagement::RiskLevel;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ProfessionalJudgment {
17 pub judgment_id: Uuid,
19 pub judgment_ref: String,
21 pub engagement_id: Uuid,
23 pub judgment_type: JudgmentType,
25 pub subject: String,
27 pub applicable_standards: Vec<String>,
29
30 pub issue_description: String,
33 pub information_considered: Vec<InformationItem>,
35 pub alternatives_evaluated: Vec<AlternativeEvaluation>,
37 pub skepticism_applied: SkepticismDocumentation,
39
40 pub conclusion: String,
43 pub rationale: String,
45 pub residual_risk: String,
47 pub impact_on_audit: Option<String>,
49
50 pub consultation_required: bool,
53 pub consultation: Option<ConsultationRecord>,
55
56 pub preparer_id: String,
59 pub preparer_name: String,
61 pub preparer_date: NaiveDate,
63 pub reviewer_id: Option<String>,
65 pub reviewer_name: Option<String>,
67 pub reviewer_date: Option<NaiveDate>,
69 pub partner_concurrence_required: bool,
71 pub partner_concurrence_id: Option<String>,
73 pub partner_concurrence_date: Option<NaiveDate>,
75
76 pub workpaper_refs: Vec<Uuid>,
79 pub evidence_refs: Vec<Uuid>,
81
82 pub status: JudgmentStatus,
84 pub created_at: DateTime<Utc>,
85 pub updated_at: DateTime<Utc>,
86}
87
88impl ProfessionalJudgment {
89 pub fn new(engagement_id: Uuid, judgment_type: JudgmentType, subject: &str) -> Self {
91 let now = Utc::now();
92 Self {
93 judgment_id: Uuid::new_v4(),
94 judgment_ref: format!("JDG-{}-{:03}", now.format("%Y"), 1),
95 engagement_id,
96 judgment_type,
97 subject: subject.into(),
98 applicable_standards: judgment_type.default_standards(),
99 issue_description: String::new(),
100 information_considered: Vec::new(),
101 alternatives_evaluated: Vec::new(),
102 skepticism_applied: SkepticismDocumentation::default(),
103 conclusion: String::new(),
104 rationale: String::new(),
105 residual_risk: String::new(),
106 impact_on_audit: None,
107 consultation_required: judgment_type.typically_requires_consultation(),
108 consultation: None,
109 preparer_id: String::new(),
110 preparer_name: String::new(),
111 preparer_date: now.date_naive(),
112 reviewer_id: None,
113 reviewer_name: None,
114 reviewer_date: None,
115 partner_concurrence_required: judgment_type.requires_partner_concurrence(),
116 partner_concurrence_id: None,
117 partner_concurrence_date: None,
118 workpaper_refs: Vec::new(),
119 evidence_refs: Vec::new(),
120 status: JudgmentStatus::Draft,
121 created_at: now,
122 updated_at: now,
123 }
124 }
125
126 pub fn with_issue(mut self, issue: &str) -> Self {
128 self.issue_description = issue.into();
129 self
130 }
131
132 pub fn add_information(&mut self, item: InformationItem) {
134 self.information_considered.push(item);
135 self.updated_at = Utc::now();
136 }
137
138 pub fn add_alternative(&mut self, alternative: AlternativeEvaluation) {
140 self.alternatives_evaluated.push(alternative);
141 self.updated_at = Utc::now();
142 }
143
144 pub fn with_skepticism(mut self, skepticism: SkepticismDocumentation) -> Self {
146 self.skepticism_applied = skepticism;
147 self
148 }
149
150 pub fn with_conclusion(
152 mut self,
153 conclusion: &str,
154 rationale: &str,
155 residual_risk: &str,
156 ) -> Self {
157 self.conclusion = conclusion.into();
158 self.rationale = rationale.into();
159 self.residual_risk = residual_risk.into();
160 self
161 }
162
163 pub fn with_preparer(mut self, id: &str, name: &str, date: NaiveDate) -> Self {
165 self.preparer_id = id.into();
166 self.preparer_name = name.into();
167 self.preparer_date = date;
168 self
169 }
170
171 pub fn add_review(&mut self, id: &str, name: &str, date: NaiveDate) {
173 self.reviewer_id = Some(id.into());
174 self.reviewer_name = Some(name.into());
175 self.reviewer_date = Some(date);
176 self.status = JudgmentStatus::Reviewed;
177 self.updated_at = Utc::now();
178 }
179
180 pub fn add_partner_concurrence(&mut self, id: &str, date: NaiveDate) {
182 self.partner_concurrence_id = Some(id.into());
183 self.partner_concurrence_date = Some(date);
184 self.status = JudgmentStatus::Approved;
185 self.updated_at = Utc::now();
186 }
187
188 pub fn add_consultation(&mut self, consultation: ConsultationRecord) {
190 self.consultation = Some(consultation);
191 self.updated_at = Utc::now();
192 }
193
194 pub fn is_approved(&self) -> bool {
196 let reviewer_ok = self.reviewer_id.is_some();
197 let partner_ok =
198 !self.partner_concurrence_required || self.partner_concurrence_id.is_some();
199 let consultation_ok = !self.consultation_required || self.consultation.is_some();
200 reviewer_ok && partner_ok && consultation_ok
201 }
202}
203
204impl ToNodeProperties for ProfessionalJudgment {
205 fn node_type_name(&self) -> &'static str {
206 "professional_judgment"
207 }
208 fn node_type_code(&self) -> u16 {
209 365
210 }
211 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
212 let mut p = HashMap::new();
213 p.insert(
214 "judgmentId".into(),
215 GraphPropertyValue::String(self.judgment_id.to_string()),
216 );
217 p.insert(
218 "judgmentRef".into(),
219 GraphPropertyValue::String(self.judgment_ref.clone()),
220 );
221 p.insert(
222 "engagementId".into(),
223 GraphPropertyValue::String(self.engagement_id.to_string()),
224 );
225 p.insert(
226 "judgmentType".into(),
227 GraphPropertyValue::String(format!("{:?}", self.judgment_type)),
228 );
229 p.insert(
230 "topic".into(),
231 GraphPropertyValue::String(self.subject.clone()),
232 );
233 p.insert(
234 "conclusion".into(),
235 GraphPropertyValue::String(self.conclusion.clone()),
236 );
237 p.insert(
238 "status".into(),
239 GraphPropertyValue::String(format!("{:?}", self.status)),
240 );
241 p.insert(
242 "alternativesCount".into(),
243 GraphPropertyValue::Int(self.alternatives_evaluated.len() as i64),
244 );
245 p.insert(
246 "consultationRequired".into(),
247 GraphPropertyValue::Bool(self.consultation_required),
248 );
249 p.insert(
250 "partnerConcurrenceRequired".into(),
251 GraphPropertyValue::Bool(self.partner_concurrence_required),
252 );
253 p.insert(
254 "isApproved".into(),
255 GraphPropertyValue::Bool(self.is_approved()),
256 );
257 p
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
263#[serde(rename_all = "snake_case")]
264pub enum JudgmentType {
265 MaterialityDetermination,
267 #[default]
269 RiskAssessment,
270 ControlEvaluation,
272 EstimateEvaluation,
274 GoingConcern,
276 MisstatementEvaluation,
278 ReportingDecision,
280 SamplingDesign,
282 RelatedPartyAssessment,
284 SubsequentEvents,
286 FraudRiskAssessment,
288}
289
290impl JudgmentType {
291 pub fn default_standards(&self) -> Vec<String> {
293 match self {
294 Self::MaterialityDetermination => vec!["ISA 320".into(), "ISA 450".into()],
295 Self::RiskAssessment => vec!["ISA 315".into()],
296 Self::ControlEvaluation => vec!["ISA 330".into(), "ISA 265".into()],
297 Self::EstimateEvaluation => vec!["ISA 540".into()],
298 Self::GoingConcern => vec!["ISA 570".into()],
299 Self::MisstatementEvaluation => vec!["ISA 450".into()],
300 Self::ReportingDecision => vec!["ISA 700".into(), "ISA 705".into(), "ISA 706".into()],
301 Self::SamplingDesign => vec!["ISA 530".into()],
302 Self::RelatedPartyAssessment => vec!["ISA 550".into()],
303 Self::SubsequentEvents => vec!["ISA 560".into()],
304 Self::FraudRiskAssessment => vec!["ISA 240".into()],
305 }
306 }
307
308 pub fn typically_requires_consultation(&self) -> bool {
310 matches!(
311 self,
312 Self::GoingConcern
313 | Self::ReportingDecision
314 | Self::FraudRiskAssessment
315 | Self::EstimateEvaluation
316 )
317 }
318
319 pub fn requires_partner_concurrence(&self) -> bool {
321 matches!(
322 self,
323 Self::MaterialityDetermination
324 | Self::GoingConcern
325 | Self::ReportingDecision
326 | Self::FraudRiskAssessment
327 )
328 }
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct InformationItem {
334 pub item_id: Uuid,
336 pub description: String,
338 pub source: String,
340 pub reliability: InformationReliability,
342 pub relevance: String,
344 pub weight: InformationWeight,
346}
347
348impl InformationItem {
349 pub fn new(
351 description: &str,
352 source: &str,
353 reliability: InformationReliability,
354 relevance: &str,
355 ) -> Self {
356 Self {
357 item_id: Uuid::new_v4(),
358 description: description.into(),
359 source: source.into(),
360 reliability,
361 relevance: relevance.into(),
362 weight: InformationWeight::Moderate,
363 }
364 }
365
366 pub fn with_weight(mut self, weight: InformationWeight) -> Self {
368 self.weight = weight;
369 self
370 }
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
375#[serde(rename_all = "snake_case")]
376pub enum InformationReliability {
377 High,
379 #[default]
381 Medium,
382 Low,
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
388#[serde(rename_all = "snake_case")]
389pub enum InformationWeight {
390 High,
392 #[default]
394 Moderate,
395 Low,
397 Context,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize)]
403pub struct AlternativeEvaluation {
404 pub alternative_id: Uuid,
406 pub description: String,
408 pub pros: Vec<String>,
410 pub cons: Vec<String>,
412 pub risk_level: RiskLevel,
414 pub selected: bool,
416 pub rejection_reason: Option<String>,
418}
419
420impl AlternativeEvaluation {
421 pub fn new(description: &str, pros: Vec<String>, cons: Vec<String>) -> Self {
423 Self {
424 alternative_id: Uuid::new_v4(),
425 description: description.into(),
426 pros,
427 cons,
428 risk_level: RiskLevel::Medium,
429 selected: false,
430 rejection_reason: None,
431 }
432 }
433
434 pub fn select(mut self) -> Self {
436 self.selected = true;
437 self
438 }
439
440 pub fn reject(mut self, reason: &str) -> Self {
442 self.selected = false;
443 self.rejection_reason = Some(reason.into());
444 self
445 }
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize, Default)]
450pub struct SkepticismDocumentation {
451 pub contradictory_evidence_considered: Vec<String>,
453 pub management_bias_indicators: Vec<String>,
455 pub alternative_explanations: Vec<String>,
457 pub challenging_questions: Vec<String>,
459 pub corroboration_obtained: String,
461 pub skepticism_assessment: String,
463}
464
465impl SkepticismDocumentation {
466 pub fn new(assessment: &str) -> Self {
468 Self {
469 skepticism_assessment: assessment.into(),
470 ..Default::default()
471 }
472 }
473
474 pub fn with_contradictory_evidence(mut self, evidence: Vec<String>) -> Self {
476 self.contradictory_evidence_considered = evidence;
477 self
478 }
479
480 pub fn with_bias_indicators(mut self, indicators: Vec<String>) -> Self {
482 self.management_bias_indicators = indicators;
483 self
484 }
485
486 pub fn with_alternatives(mut self, alternatives: Vec<String>) -> Self {
488 self.alternative_explanations = alternatives;
489 self
490 }
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct ConsultationRecord {
496 pub consultation_id: Uuid,
498 pub consultant: String,
500 pub consultant_role: String,
502 pub is_external: bool,
504 pub consultation_date: NaiveDate,
506 pub issue_presented: String,
508 pub advice_received: String,
510 pub advice_application: String,
512 pub conclusion: String,
514}
515
516impl ConsultationRecord {
517 pub fn new(consultant: &str, role: &str, is_external: bool, date: NaiveDate) -> Self {
519 Self {
520 consultation_id: Uuid::new_v4(),
521 consultant: consultant.into(),
522 consultant_role: role.into(),
523 is_external,
524 consultation_date: date,
525 issue_presented: String::new(),
526 advice_received: String::new(),
527 advice_application: String::new(),
528 conclusion: String::new(),
529 }
530 }
531
532 pub fn with_content(
534 mut self,
535 issue: &str,
536 advice: &str,
537 application: &str,
538 conclusion: &str,
539 ) -> Self {
540 self.issue_presented = issue.into();
541 self.advice_received = advice.into();
542 self.advice_application = application.into();
543 self.conclusion = conclusion.into();
544 self
545 }
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
550#[serde(rename_all = "snake_case")]
551pub enum JudgmentStatus {
552 #[default]
554 Draft,
555 PendingReview,
557 Reviewed,
559 PendingConsultation,
561 PendingPartnerConcurrence,
563 Approved,
565 Superseded,
567}
568
569#[cfg(test)]
570#[allow(clippy::unwrap_used)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn test_judgment_creation() {
576 let judgment = ProfessionalJudgment::new(
577 Uuid::new_v4(),
578 JudgmentType::MaterialityDetermination,
579 "Overall audit materiality",
580 )
581 .with_issue("Determination of materiality for the 2025 audit")
582 .with_conclusion(
583 "Materiality set at $1M based on 0.5% of revenue",
584 "Revenue is stable metric and primary KPI for stakeholders",
585 "Risk of material misstatement below $1M not individually evaluated",
586 );
587
588 assert_eq!(
589 judgment.judgment_type,
590 JudgmentType::MaterialityDetermination
591 );
592 assert!(judgment.partner_concurrence_required);
593 }
594
595 #[test]
596 fn test_information_item() {
597 let item = InformationItem::new(
598 "Prior year financial statements",
599 "Audited FS",
600 InformationReliability::High,
601 "Baseline for trend analysis",
602 )
603 .with_weight(InformationWeight::High);
604
605 assert_eq!(item.reliability, InformationReliability::High);
606 assert_eq!(item.weight, InformationWeight::High);
607 }
608
609 #[test]
610 fn test_alternative_evaluation() {
611 let selected = AlternativeEvaluation::new(
612 "Use revenue as materiality base",
613 vec!["Stable metric".into(), "Primary KPI".into()],
614 vec!["May not capture asset-focused risks".into()],
615 )
616 .select();
617
618 let rejected = AlternativeEvaluation::new(
619 "Use total assets as materiality base",
620 vec!["Captures balance sheet risks".into()],
621 vec!["Assets less stable".into()],
622 )
623 .reject("Revenue more relevant to stakeholders");
624
625 assert!(selected.selected);
626 assert!(!rejected.selected);
627 assert!(rejected.rejection_reason.is_some());
628 }
629
630 #[test]
631 fn test_judgment_approval() {
632 let mut judgment = ProfessionalJudgment::new(
633 Uuid::new_v4(),
634 JudgmentType::RiskAssessment,
635 "Overall risk assessment",
636 );
637
638 assert!(!judgment.is_approved());
640
641 judgment.add_review(
643 "reviewer1",
644 "Senior Manager",
645 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
646 );
647
648 assert!(judgment.is_approved());
650 }
651
652 #[test]
653 fn test_judgment_types() {
654 assert!(JudgmentType::GoingConcern.requires_partner_concurrence());
655 assert!(JudgmentType::GoingConcern.typically_requires_consultation());
656 assert!(!JudgmentType::SamplingDesign.requires_partner_concurrence());
657 }
658}