Skip to main content

datasynth_graph/models/
nodes.rs

1//! Node models for graph representation.
2
3use chrono::NaiveDate;
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Unique identifier for a node.
9pub type NodeId = u64;
10
11/// Type of node in the graph.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum NodeType {
14    /// GL Account node.
15    Account,
16    /// Journal Entry document node.
17    JournalEntry,
18    /// Vendor node.
19    Vendor,
20    /// Customer node.
21    Customer,
22    /// User/Employee node.
23    User,
24    /// Company/Legal Entity node.
25    Company,
26    /// Cost Center node.
27    CostCenter,
28    /// Profit Center node.
29    ProfitCenter,
30    /// Material node.
31    Material,
32    /// Fixed Asset node.
33    FixedAsset,
34    /// Custom node type.
35    Custom(String),
36}
37
38impl NodeType {
39    /// Returns the type name as a string.
40    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/// A node in the graph.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct GraphNode {
60    /// Unique node ID.
61    pub id: NodeId,
62    /// Node type.
63    pub node_type: NodeType,
64    /// External ID (e.g., account code, vendor ID).
65    pub external_id: String,
66    /// Node label for display.
67    pub label: String,
68    /// Numeric features for ML.
69    pub features: Vec<f64>,
70    /// Categorical features (will be one-hot encoded).
71    pub categorical_features: HashMap<String, String>,
72    /// Node properties.
73    pub properties: HashMap<String, NodeProperty>,
74    /// Labels for supervised learning.
75    pub labels: Vec<String>,
76    /// Is this node an anomaly?
77    pub is_anomaly: bool,
78    /// Anomaly type if anomalous.
79    pub anomaly_type: Option<String>,
80}
81
82impl GraphNode {
83    /// Creates a new graph node.
84    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    /// Adds a numeric feature.
100    pub fn with_feature(mut self, value: f64) -> Self {
101        self.features.push(value);
102        self
103    }
104
105    /// Adds multiple numeric features.
106    pub fn with_features(mut self, values: Vec<f64>) -> Self {
107        self.features.extend(values);
108        self
109    }
110
111    /// Adds a categorical feature.
112    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    /// Adds a property.
119    pub fn with_property(mut self, name: &str, value: NodeProperty) -> Self {
120        self.properties.insert(name.to_string(), value);
121        self
122    }
123
124    /// Marks the node as anomalous.
125    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    /// Adds a label.
132    pub fn with_label(mut self, label: &str) -> Self {
133        self.labels.push(label.to_string());
134        self
135    }
136
137    /// Returns the feature vector dimension.
138    pub fn feature_dim(&self) -> usize {
139        self.features.len()
140    }
141}
142
143/// Property value for a node.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub enum NodeProperty {
146    /// String value.
147    String(String),
148    /// Integer value.
149    Int(i64),
150    /// Float value.
151    Float(f64),
152    /// Decimal value.
153    Decimal(Decimal),
154    /// Boolean value.
155    Bool(bool),
156    /// Date value.
157    Date(NaiveDate),
158    /// List of strings.
159    StringList(Vec<String>),
160}
161
162impl NodeProperty {
163    /// Converts to string representation.
164    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    /// Converts to numeric value (for features).
177    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/// Account node with accounting-specific features.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct AccountNode {
191    /// Base node.
192    pub node: GraphNode,
193    /// Account code.
194    pub account_code: String,
195    /// Account name.
196    pub account_name: String,
197    /// Account type (Asset, Liability, etc.).
198    pub account_type: String,
199    /// Account category.
200    pub account_category: Option<String>,
201    /// Is balance sheet account.
202    pub is_balance_sheet: bool,
203    /// Normal balance (Debit/Credit).
204    pub normal_balance: String,
205    /// Company code.
206    pub company_code: String,
207    /// Country code (ISO 3166-1 alpha-2) of the owning company.
208    pub country: Option<String>,
209}
210
211impl AccountNode {
212    /// Creates a new account node.
213    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    /// Computes features for the account node.
241    pub fn compute_features(&mut self) {
242        // Account type encoding
243        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        // Balance sheet indicator
254        self.node
255            .features
256            .push(if self.is_balance_sheet { 1.0 } else { 0.0 });
257
258        // Normal balance encoding
259        self.node.features.push(if self.normal_balance == "Debit" {
260            1.0
261        } else {
262            0.0
263        });
264
265        // Account code as numeric (first digits)
266        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        // Add categorical features
271        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/// User node for approval networks.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct UserNode {
288    /// Base node.
289    pub node: GraphNode,
290    /// User ID.
291    pub user_id: String,
292    /// User name.
293    pub user_name: String,
294    /// Department.
295    pub department: Option<String>,
296    /// Role.
297    pub role: Option<String>,
298    /// Manager ID.
299    pub manager_id: Option<String>,
300    /// Approval limit.
301    pub approval_limit: Option<Decimal>,
302    /// Is active.
303    pub is_active: bool,
304}
305
306impl UserNode {
307    /// Creates a new user node.
308    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    /// Computes features for the user node.
324    pub fn compute_features(&mut self) {
325        // Active status
326        self.node
327            .features
328            .push(if self.is_active { 1.0 } else { 0.0 });
329
330        // Approval limit (log-scaled)
331        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        // Add categorical features
339        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/// Company/Entity node for entity relationship graphs.
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct CompanyNode {
355    /// Base node.
356    pub node: GraphNode,
357    /// Company code.
358    pub company_code: String,
359    /// Company name.
360    pub company_name: String,
361    /// Country.
362    pub country: String,
363    /// Currency.
364    pub currency: String,
365    /// Is parent company.
366    pub is_parent: bool,
367    /// Parent company code.
368    pub parent_code: Option<String>,
369    /// Ownership percentage (if subsidiary).
370    pub ownership_percent: Option<Decimal>,
371}
372
373impl CompanyNode {
374    /// Creates a new company node.
375    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    /// Computes features for the company node.
396    pub fn compute_features(&mut self) {
397        // Is parent
398        self.node
399            .features
400            .push(if self.is_parent { 1.0 } else { 0.0 });
401
402        // Ownership percentage
403        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); // 100% for parent
408        }
409
410        // Add categorical features
411        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}