1use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8pub type NodeId = u64;
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum NodeType {
14 Account,
16 JournalEntry,
18 Vendor,
20 Customer,
22 User,
24 Company,
26 CostCenter,
28 ProfitCenter,
30 Material,
32 FixedAsset,
34 Custom(String),
36}
37
38impl NodeType {
39 pub fn as_str(&self) -> &str {
41 match self {
42 NodeType::Account => "Account",
43 NodeType::JournalEntry => "JournalEntry",
44 NodeType::Vendor => "Vendor",
45 NodeType::Customer => "Customer",
46 NodeType::User => "User",
47 NodeType::Company => "Company",
48 NodeType::CostCenter => "CostCenter",
49 NodeType::ProfitCenter => "ProfitCenter",
50 NodeType::Material => "Material",
51 NodeType::FixedAsset => "FixedAsset",
52 NodeType::Custom(s) => s,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct GraphNode {
60 pub id: NodeId,
62 pub node_type: NodeType,
64 pub external_id: String,
66 pub label: String,
68 pub features: Vec<f64>,
70 pub categorical_features: HashMap<String, String>,
72 pub properties: HashMap<String, NodeProperty>,
74 pub labels: Vec<String>,
76 pub is_anomaly: bool,
78 pub anomaly_type: Option<String>,
80}
81
82impl GraphNode {
83 pub fn new(id: NodeId, node_type: NodeType, external_id: String, label: String) -> Self {
85 Self {
86 id,
87 node_type,
88 external_id,
89 label,
90 features: Vec::new(),
91 categorical_features: HashMap::new(),
92 properties: HashMap::new(),
93 labels: Vec::new(),
94 is_anomaly: false,
95 anomaly_type: None,
96 }
97 }
98
99 pub fn with_feature(mut self, value: f64) -> Self {
101 self.features.push(value);
102 self
103 }
104
105 pub fn with_features(mut self, values: Vec<f64>) -> Self {
107 self.features.extend(values);
108 self
109 }
110
111 pub fn with_categorical(mut self, name: &str, value: &str) -> Self {
113 self.categorical_features
114 .insert(name.to_string(), value.to_string());
115 self
116 }
117
118 pub fn with_property(mut self, name: &str, value: NodeProperty) -> Self {
120 self.properties.insert(name.to_string(), value);
121 self
122 }
123
124 pub fn as_anomaly(mut self, anomaly_type: &str) -> Self {
126 self.is_anomaly = true;
127 self.anomaly_type = Some(anomaly_type.to_string());
128 self
129 }
130
131 pub fn with_label(mut self, label: &str) -> Self {
133 self.labels.push(label.to_string());
134 self
135 }
136
137 pub fn feature_dim(&self) -> usize {
139 self.features.len()
140 }
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub enum NodeProperty {
146 String(String),
148 Int(i64),
150 Float(f64),
152 Decimal(Decimal),
154 Bool(bool),
156 Date(NaiveDate),
158 StringList(Vec<String>),
160}
161
162impl NodeProperty {
163 pub fn to_string_value(&self) -> String {
165 match self {
166 NodeProperty::String(s) => s.clone(),
167 NodeProperty::Int(i) => i.to_string(),
168 NodeProperty::Float(f) => f.to_string(),
169 NodeProperty::Decimal(d) => d.to_string(),
170 NodeProperty::Bool(b) => b.to_string(),
171 NodeProperty::Date(d) => d.to_string(),
172 NodeProperty::StringList(v) => v.join(","),
173 }
174 }
175
176 pub fn to_numeric(&self) -> Option<f64> {
178 match self {
179 NodeProperty::Int(i) => Some(*i as f64),
180 NodeProperty::Float(f) => Some(*f),
181 NodeProperty::Decimal(d) => (*d).try_into().ok(),
182 NodeProperty::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
183 _ => None,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct AccountNode {
191 pub node: GraphNode,
193 pub account_code: String,
195 pub account_name: String,
197 pub account_type: String,
199 pub account_category: Option<String>,
201 pub is_balance_sheet: bool,
203 pub normal_balance: String,
205 pub company_code: String,
207 pub country: Option<String>,
209}
210
211impl AccountNode {
212 pub fn new(
214 id: NodeId,
215 account_code: String,
216 account_name: String,
217 account_type: String,
218 company_code: String,
219 ) -> Self {
220 let node = GraphNode::new(
221 id,
222 NodeType::Account,
223 account_code.clone(),
224 format!("{} - {}", account_code, account_name),
225 );
226
227 Self {
228 node,
229 account_code,
230 account_name,
231 account_type,
232 account_category: None,
233 is_balance_sheet: false,
234 normal_balance: "Debit".to_string(),
235 company_code,
236 country: None,
237 }
238 }
239
240 pub fn compute_features(&mut self) {
242 let type_feature = match self.account_type.as_str() {
244 "Asset" => 0.0,
245 "Liability" => 1.0,
246 "Equity" => 2.0,
247 "Revenue" => 3.0,
248 "Expense" => 4.0,
249 _ => 5.0,
250 };
251 self.node.features.push(type_feature);
252
253 self.node
255 .features
256 .push(if self.is_balance_sheet { 1.0 } else { 0.0 });
257
258 self.node.features.push(if self.normal_balance == "Debit" {
260 1.0
261 } else {
262 0.0
263 });
264
265 let code_prefix: String = self
268 .account_code
269 .chars()
270 .take(4)
271 .take_while(|c| c.is_ascii_digit())
272 .collect();
273 if let Ok(code_num) = code_prefix.parse::<f64>() {
274 self.node.features.push(code_num / 10000.0);
275 } else {
276 self.node.features.push(0.0);
277 }
278
279 self.node
281 .categorical_features
282 .insert("account_type".to_string(), self.account_type.clone());
283 self.node
284 .categorical_features
285 .insert("company_code".to_string(), self.company_code.clone());
286 if let Some(ref country) = self.country {
287 self.node
288 .categorical_features
289 .insert("country".to_string(), country.clone());
290 }
291 }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct UserNode {
297 pub node: GraphNode,
299 pub user_id: String,
301 pub user_name: String,
303 pub department: Option<String>,
305 pub role: Option<String>,
307 pub manager_id: Option<String>,
309 pub approval_limit: Option<Decimal>,
311 pub is_active: bool,
313}
314
315impl UserNode {
316 pub fn new(id: NodeId, user_id: String, user_name: String) -> Self {
318 let node = GraphNode::new(id, NodeType::User, user_id.clone(), user_name.clone());
319
320 Self {
321 node,
322 user_id,
323 user_name,
324 department: None,
325 role: None,
326 manager_id: None,
327 approval_limit: None,
328 is_active: true,
329 }
330 }
331
332 pub fn compute_features(&mut self) {
334 self.node
336 .features
337 .push(if self.is_active { 1.0 } else { 0.0 });
338
339 if let Some(limit) = self.approval_limit {
341 let limit_f64: f64 = limit.try_into().unwrap_or(0.0);
342 self.node.features.push((limit_f64 + 1.0).ln());
343 } else {
344 self.node.features.push(0.0);
345 }
346
347 if let Some(ref dept) = self.department {
349 self.node
350 .categorical_features
351 .insert("department".to_string(), dept.clone());
352 }
353 if let Some(ref role) = self.role {
354 self.node
355 .categorical_features
356 .insert("role".to_string(), role.clone());
357 }
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct CompanyNode {
364 pub node: GraphNode,
366 pub company_code: String,
368 pub company_name: String,
370 pub country: String,
372 pub currency: String,
374 pub is_parent: bool,
376 pub parent_code: Option<String>,
378 pub ownership_percent: Option<Decimal>,
380}
381
382impl CompanyNode {
383 pub fn new(id: NodeId, company_code: String, company_name: String) -> Self {
385 let node = GraphNode::new(
386 id,
387 NodeType::Company,
388 company_code.clone(),
389 company_name.clone(),
390 );
391
392 Self {
393 node,
394 company_code,
395 company_name,
396 country: "US".to_string(),
397 currency: "USD".to_string(),
398 is_parent: false,
399 parent_code: None,
400 ownership_percent: None,
401 }
402 }
403
404 pub fn compute_features(&mut self) {
406 self.node
408 .features
409 .push(if self.is_parent { 1.0 } else { 0.0 });
410
411 if let Some(pct) = self.ownership_percent {
413 let pct_f64: f64 = pct.try_into().unwrap_or(0.0);
414 self.node.features.push(pct_f64 / 100.0);
415 } else {
416 self.node.features.push(1.0); }
418
419 self.node
421 .categorical_features
422 .insert("country".to_string(), self.country.clone());
423 self.node
424 .categorical_features
425 .insert("currency".to_string(), self.currency.clone());
426 }
427}
428
429#[cfg(test)]
430#[allow(clippy::unwrap_used)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn test_graph_node_creation() {
436 let node = GraphNode::new(1, NodeType::Account, "1000".to_string(), "Cash".to_string())
437 .with_feature(100.0)
438 .with_categorical("type", "Asset");
439
440 assert_eq!(node.id, 1);
441 assert_eq!(node.features.len(), 1);
442 assert!(node.categorical_features.contains_key("type"));
443 }
444
445 #[test]
446 fn test_account_node() {
447 let mut account = AccountNode::new(
448 1,
449 "1000".to_string(),
450 "Cash".to_string(),
451 "Asset".to_string(),
452 "1000".to_string(),
453 );
454 account.is_balance_sheet = true;
455 account.compute_features();
456
457 assert!(!account.node.features.is_empty());
458 }
459}