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