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 if let Ok(code_num) = self.account_code[..1.min(self.account_code.len())].parse::<f64>() {
267 self.node.features.push(code_num);
268 }
269
270 self.node
272 .categorical_features
273 .insert("account_type".to_string(), self.account_type.clone());
274 self.node
275 .categorical_features
276 .insert("company_code".to_string(), self.company_code.clone());
277 if let Some(ref country) = self.country {
278 self.node
279 .categorical_features
280 .insert("country".to_string(), country.clone());
281 }
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct UserNode {
288 pub node: GraphNode,
290 pub user_id: String,
292 pub user_name: String,
294 pub department: Option<String>,
296 pub role: Option<String>,
298 pub manager_id: Option<String>,
300 pub approval_limit: Option<Decimal>,
302 pub is_active: bool,
304}
305
306impl UserNode {
307 pub fn new(id: NodeId, user_id: String, user_name: String) -> Self {
309 let node = GraphNode::new(id, NodeType::User, user_id.clone(), user_name.clone());
310
311 Self {
312 node,
313 user_id,
314 user_name,
315 department: None,
316 role: None,
317 manager_id: None,
318 approval_limit: None,
319 is_active: true,
320 }
321 }
322
323 pub fn compute_features(&mut self) {
325 self.node
327 .features
328 .push(if self.is_active { 1.0 } else { 0.0 });
329
330 if let Some(limit) = self.approval_limit {
332 let limit_f64: f64 = limit.try_into().unwrap_or(0.0);
333 self.node.features.push((limit_f64 + 1.0).ln());
334 } else {
335 self.node.features.push(0.0);
336 }
337
338 if let Some(ref dept) = self.department {
340 self.node
341 .categorical_features
342 .insert("department".to_string(), dept.clone());
343 }
344 if let Some(ref role) = self.role {
345 self.node
346 .categorical_features
347 .insert("role".to_string(), role.clone());
348 }
349 }
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct CompanyNode {
355 pub node: GraphNode,
357 pub company_code: String,
359 pub company_name: String,
361 pub country: String,
363 pub currency: String,
365 pub is_parent: bool,
367 pub parent_code: Option<String>,
369 pub ownership_percent: Option<Decimal>,
371}
372
373impl CompanyNode {
374 pub fn new(id: NodeId, company_code: String, company_name: String) -> Self {
376 let node = GraphNode::new(
377 id,
378 NodeType::Company,
379 company_code.clone(),
380 company_name.clone(),
381 );
382
383 Self {
384 node,
385 company_code,
386 company_name,
387 country: "US".to_string(),
388 currency: "USD".to_string(),
389 is_parent: false,
390 parent_code: None,
391 ownership_percent: None,
392 }
393 }
394
395 pub fn compute_features(&mut self) {
397 self.node
399 .features
400 .push(if self.is_parent { 1.0 } else { 0.0 });
401
402 if let Some(pct) = self.ownership_percent {
404 let pct_f64: f64 = pct.try_into().unwrap_or(0.0);
405 self.node.features.push(pct_f64 / 100.0);
406 } else {
407 self.node.features.push(1.0); }
409
410 self.node
412 .categorical_features
413 .insert("country".to_string(), self.country.clone());
414 self.node
415 .categorical_features
416 .insert("currency".to_string(), self.currency.clone());
417 }
418}
419
420#[cfg(test)]
421#[allow(clippy::unwrap_used)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn test_graph_node_creation() {
427 let node = GraphNode::new(1, NodeType::Account, "1000".to_string(), "Cash".to_string())
428 .with_feature(100.0)
429 .with_categorical("type", "Asset");
430
431 assert_eq!(node.id, 1);
432 assert_eq!(node.features.len(), 1);
433 assert!(node.categorical_features.contains_key("type"));
434 }
435
436 #[test]
437 fn test_account_node() {
438 let mut account = AccountNode::new(
439 1,
440 "1000".to_string(),
441 "Cash".to_string(),
442 "Asset".to_string(),
443 "1000".to_string(),
444 );
445 account.is_balance_sheet = true;
446 account.compute_features();
447
448 assert!(!account.node.features.is_empty());
449 }
450}