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}
208
209impl AccountNode {
210 pub fn new(
212 id: NodeId,
213 account_code: String,
214 account_name: String,
215 account_type: String,
216 company_code: String,
217 ) -> Self {
218 let node = GraphNode::new(
219 id,
220 NodeType::Account,
221 account_code.clone(),
222 format!("{} - {}", account_code, account_name),
223 );
224
225 Self {
226 node,
227 account_code,
228 account_name,
229 account_type,
230 account_category: None,
231 is_balance_sheet: false,
232 normal_balance: "Debit".to_string(),
233 company_code,
234 }
235 }
236
237 pub fn compute_features(&mut self) {
239 let type_feature = match self.account_type.as_str() {
241 "Asset" => 0.0,
242 "Liability" => 1.0,
243 "Equity" => 2.0,
244 "Revenue" => 3.0,
245 "Expense" => 4.0,
246 _ => 5.0,
247 };
248 self.node.features.push(type_feature);
249
250 self.node
252 .features
253 .push(if self.is_balance_sheet { 1.0 } else { 0.0 });
254
255 self.node.features.push(if self.normal_balance == "Debit" {
257 1.0
258 } else {
259 0.0
260 });
261
262 if let Ok(code_num) = self.account_code[..1.min(self.account_code.len())].parse::<f64>() {
264 self.node.features.push(code_num);
265 }
266
267 self.node
269 .categorical_features
270 .insert("account_type".to_string(), self.account_type.clone());
271 self.node
272 .categorical_features
273 .insert("company_code".to_string(), self.company_code.clone());
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct UserNode {
280 pub node: GraphNode,
282 pub user_id: String,
284 pub user_name: String,
286 pub department: Option<String>,
288 pub role: Option<String>,
290 pub manager_id: Option<String>,
292 pub approval_limit: Option<Decimal>,
294 pub is_active: bool,
296}
297
298impl UserNode {
299 pub fn new(id: NodeId, user_id: String, user_name: String) -> Self {
301 let node = GraphNode::new(id, NodeType::User, user_id.clone(), user_name.clone());
302
303 Self {
304 node,
305 user_id,
306 user_name,
307 department: None,
308 role: None,
309 manager_id: None,
310 approval_limit: None,
311 is_active: true,
312 }
313 }
314
315 pub fn compute_features(&mut self) {
317 self.node
319 .features
320 .push(if self.is_active { 1.0 } else { 0.0 });
321
322 if let Some(limit) = self.approval_limit {
324 let limit_f64: f64 = limit.try_into().unwrap_or(0.0);
325 self.node.features.push((limit_f64 + 1.0).ln());
326 } else {
327 self.node.features.push(0.0);
328 }
329
330 if let Some(ref dept) = self.department {
332 self.node
333 .categorical_features
334 .insert("department".to_string(), dept.clone());
335 }
336 if let Some(ref role) = self.role {
337 self.node
338 .categorical_features
339 .insert("role".to_string(), role.clone());
340 }
341 }
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct CompanyNode {
347 pub node: GraphNode,
349 pub company_code: String,
351 pub company_name: String,
353 pub country: String,
355 pub currency: String,
357 pub is_parent: bool,
359 pub parent_code: Option<String>,
361 pub ownership_percent: Option<Decimal>,
363}
364
365impl CompanyNode {
366 pub fn new(id: NodeId, company_code: String, company_name: String) -> Self {
368 let node = GraphNode::new(
369 id,
370 NodeType::Company,
371 company_code.clone(),
372 company_name.clone(),
373 );
374
375 Self {
376 node,
377 company_code,
378 company_name,
379 country: "US".to_string(),
380 currency: "USD".to_string(),
381 is_parent: false,
382 parent_code: None,
383 ownership_percent: None,
384 }
385 }
386
387 pub fn compute_features(&mut self) {
389 self.node
391 .features
392 .push(if self.is_parent { 1.0 } else { 0.0 });
393
394 if let Some(pct) = self.ownership_percent {
396 let pct_f64: f64 = pct.try_into().unwrap_or(0.0);
397 self.node.features.push(pct_f64 / 100.0);
398 } else {
399 self.node.features.push(1.0); }
401
402 self.node
404 .categorical_features
405 .insert("country".to_string(), self.country.clone());
406 self.node
407 .categorical_features
408 .insert("currency".to_string(), self.currency.clone());
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_graph_node_creation() {
418 let node = GraphNode::new(1, NodeType::Account, "1000".to_string(), "Cash".to_string())
419 .with_feature(100.0)
420 .with_categorical("type", "Asset");
421
422 assert_eq!(node.id, 1);
423 assert_eq!(node.features.len(), 1);
424 assert!(node.categorical_features.contains_key("type"));
425 }
426
427 #[test]
428 fn test_account_node() {
429 let mut account = AccountNode::new(
430 1,
431 "1000".to_string(),
432 "Cash".to_string(),
433 "Asset".to_string(),
434 "1000".to_string(),
435 );
436 account.is_balance_sheet = true;
437 account.compute_features();
438
439 assert!(!account.node.features.is_empty());
440 }
441}