datasynth_core/models/compliance/
finding.rs1use std::collections::HashMap;
4
5use chrono::{Datelike, NaiveDate};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10use super::assertion::ComplianceAssertion;
11use super::standard_id::StandardId;
12use crate::models::graph_properties::{GraphPropertyValue, ToNodeProperties};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum DeficiencyLevel {
18 MaterialWeakness,
20 SignificantDeficiency,
22 ControlDeficiency,
24}
25
26impl DeficiencyLevel {
27 pub fn severity_score(&self) -> f64 {
29 match self {
30 Self::MaterialWeakness => 1.0,
31 Self::SignificantDeficiency => 0.66,
32 Self::ControlDeficiency => 0.33,
33 }
34 }
35}
36
37impl std::fmt::Display for DeficiencyLevel {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 Self::MaterialWeakness => write!(f, "Material Weakness"),
41 Self::SignificantDeficiency => write!(f, "Significant Deficiency"),
42 Self::ControlDeficiency => write!(f, "Control Deficiency"),
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum FindingSeverity {
51 High,
53 Moderate,
55 Low,
57}
58
59impl FindingSeverity {
60 pub fn score(&self) -> f64 {
62 match self {
63 Self::High => 1.0,
64 Self::Moderate => 0.66,
65 Self::Low => 0.33,
66 }
67 }
68}
69
70impl std::fmt::Display for FindingSeverity {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::High => write!(f, "High"),
74 Self::Moderate => write!(f, "Moderate"),
75 Self::Low => write!(f, "Low"),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum RemediationStatus {
84 Open,
86 InProgress,
88 Remediated,
90}
91
92impl std::fmt::Display for RemediationStatus {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 Self::Open => write!(f, "Open"),
96 Self::InProgress => write!(f, "In Progress"),
97 Self::Remediated => write!(f, "Remediated"),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ComplianceFinding {
105 pub finding_id: Uuid,
107 pub company_code: String,
109 pub title: String,
111 pub description: String,
113 pub severity: FindingSeverity,
115 pub deficiency_level: DeficiencyLevel,
117 pub control_id: Option<String>,
119 pub procedure_id: Option<String>,
121 pub affected_assertions: Vec<ComplianceAssertion>,
123 pub related_standards: Vec<StandardId>,
125 pub identified_date: NaiveDate,
127 pub remediation_status: RemediationStatus,
129 pub financial_impact: Option<Decimal>,
131 pub is_repeat: bool,
133 pub affected_accounts: Vec<String>,
135 pub fiscal_year: i32,
137}
138
139impl ComplianceFinding {
140 pub fn new(
142 company_code: impl Into<String>,
143 title: impl Into<String>,
144 severity: FindingSeverity,
145 deficiency_level: DeficiencyLevel,
146 identified_date: NaiveDate,
147 ) -> Self {
148 Self {
149 finding_id: Uuid::new_v4(),
150 company_code: company_code.into(),
151 title: title.into(),
152 description: String::new(),
153 severity,
154 deficiency_level,
155 control_id: None,
156 procedure_id: None,
157 affected_assertions: Vec::new(),
158 related_standards: Vec::new(),
159 identified_date,
160 remediation_status: RemediationStatus::Open,
161 financial_impact: None,
162 is_repeat: false,
163 affected_accounts: Vec::new(),
164 fiscal_year: identified_date.year(),
165 }
166 }
167
168 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
170 self.description = desc.into();
171 self
172 }
173
174 pub fn on_control(mut self, control_id: impl Into<String>) -> Self {
176 self.control_id = Some(control_id.into());
177 self
178 }
179
180 pub fn identified_by(mut self, procedure_id: impl Into<String>) -> Self {
182 self.procedure_id = Some(procedure_id.into());
183 self
184 }
185
186 pub fn with_assertion(mut self, assertion: ComplianceAssertion) -> Self {
188 self.affected_assertions.push(assertion);
189 self
190 }
191
192 pub fn with_standard(mut self, id: StandardId) -> Self {
194 self.related_standards.push(id);
195 self
196 }
197
198 pub fn with_remediation(mut self, status: RemediationStatus) -> Self {
200 self.remediation_status = status;
201 self
202 }
203
204 pub fn as_repeat(mut self) -> Self {
206 self.is_repeat = true;
207 self
208 }
209}
210
211impl ToNodeProperties for ComplianceFinding {
212 fn node_type_name(&self) -> &'static str {
213 "compliance_finding"
214 }
215 fn node_type_code(&self) -> u16 {
216 511
217 }
218 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
219 let mut p = HashMap::new();
220 p.insert(
221 "findingId".into(),
222 GraphPropertyValue::String(self.finding_id.to_string()),
223 );
224 p.insert(
225 "companyCode".into(),
226 GraphPropertyValue::String(self.company_code.clone()),
227 );
228 p.insert(
229 "title".into(),
230 GraphPropertyValue::String(self.title.clone()),
231 );
232 p.insert(
233 "severity".into(),
234 GraphPropertyValue::String(self.severity.to_string()),
235 );
236 p.insert(
237 "severityScore".into(),
238 GraphPropertyValue::Float(self.severity.score()),
239 );
240 p.insert(
241 "deficiencyLevel".into(),
242 GraphPropertyValue::String(self.deficiency_level.to_string()),
243 );
244 p.insert(
245 "deficiencySeverityScore".into(),
246 GraphPropertyValue::Float(self.deficiency_level.severity_score()),
247 );
248 if let Some(ref cid) = self.control_id {
249 p.insert("controlId".into(), GraphPropertyValue::String(cid.clone()));
250 }
251 if let Some(ref pid) = self.procedure_id {
252 p.insert(
253 "procedureId".into(),
254 GraphPropertyValue::String(pid.clone()),
255 );
256 }
257 p.insert(
258 "identifiedDate".into(),
259 GraphPropertyValue::Date(self.identified_date),
260 );
261 p.insert(
262 "remediationStatus".into(),
263 GraphPropertyValue::String(self.remediation_status.to_string()),
264 );
265 if let Some(impact) = self.financial_impact {
266 p.insert(
267 "financialImpact".into(),
268 GraphPropertyValue::Decimal(impact),
269 );
270 }
271 p.insert("isRepeat".into(), GraphPropertyValue::Bool(self.is_repeat));
272 p.insert(
273 "fiscalYear".into(),
274 GraphPropertyValue::Int(self.fiscal_year as i64),
275 );
276 if !self.affected_assertions.is_empty() {
277 p.insert(
278 "affectedAssertions".into(),
279 GraphPropertyValue::StringList(
280 self.affected_assertions
281 .iter()
282 .map(|a| a.to_string())
283 .collect(),
284 ),
285 );
286 }
287 if !self.related_standards.is_empty() {
288 p.insert(
289 "relatedStandards".into(),
290 GraphPropertyValue::StringList(
291 self.related_standards
292 .iter()
293 .map(|s| s.as_str().to_string())
294 .collect(),
295 ),
296 );
297 }
298 if !self.affected_accounts.is_empty() {
299 p.insert(
300 "affectedAccounts".into(),
301 GraphPropertyValue::StringList(self.affected_accounts.clone()),
302 );
303 }
304 p
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn test_finding_creation() {
314 let date = NaiveDate::from_ymd_opt(2025, 6, 30).expect("valid date");
315 let finding = ComplianceFinding::new(
316 "C001",
317 "Three-way match exception",
318 FindingSeverity::Moderate,
319 DeficiencyLevel::SignificantDeficiency,
320 date,
321 )
322 .on_control("C010")
323 .with_assertion(ComplianceAssertion::Occurrence)
324 .with_standard(StandardId::new("SOX", "404"));
325
326 assert_eq!(finding.severity, FindingSeverity::Moderate);
327 assert_eq!(finding.control_id.as_deref(), Some("C010"));
328 assert_eq!(finding.related_standards.len(), 1);
329 }
330
331 #[test]
332 fn test_deficiency_severity_ordering() {
333 assert!(
334 DeficiencyLevel::MaterialWeakness.severity_score()
335 > DeficiencyLevel::SignificantDeficiency.severity_score()
336 );
337 assert!(
338 DeficiencyLevel::SignificantDeficiency.severity_score()
339 > DeficiencyLevel::ControlDeficiency.severity_score()
340 );
341 }
342}