use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub type NodeId = u64;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NodeType {
Account,
JournalEntry,
Vendor,
Customer,
User,
Company,
CostCenter,
ProfitCenter,
Material,
FixedAsset,
Custom(String),
}
impl NodeType {
pub fn as_str(&self) -> &str {
match self {
NodeType::Account => "Account",
NodeType::JournalEntry => "JournalEntry",
NodeType::Vendor => "Vendor",
NodeType::Customer => "Customer",
NodeType::User => "User",
NodeType::Company => "Company",
NodeType::CostCenter => "CostCenter",
NodeType::ProfitCenter => "ProfitCenter",
NodeType::Material => "Material",
NodeType::FixedAsset => "FixedAsset",
NodeType::Custom(s) => s,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub id: NodeId,
pub node_type: NodeType,
pub external_id: String,
pub label: String,
pub features: Vec<f64>,
pub categorical_features: HashMap<String, String>,
pub properties: HashMap<String, NodeProperty>,
pub labels: Vec<String>,
pub is_anomaly: bool,
pub anomaly_type: Option<String>,
}
impl GraphNode {
pub fn new(id: NodeId, node_type: NodeType, external_id: String, label: String) -> Self {
Self {
id,
node_type,
external_id,
label,
features: Vec::new(),
categorical_features: HashMap::new(),
properties: HashMap::new(),
labels: Vec::new(),
is_anomaly: false,
anomaly_type: None,
}
}
pub fn with_feature(mut self, value: f64) -> Self {
self.features.push(value);
self
}
pub fn with_features(mut self, values: Vec<f64>) -> Self {
self.features.extend(values);
self
}
pub fn with_categorical(mut self, name: &str, value: &str) -> Self {
self.categorical_features
.insert(name.to_string(), value.to_string());
self
}
pub fn with_property(mut self, name: &str, value: NodeProperty) -> Self {
self.properties.insert(name.to_string(), value);
self
}
pub fn as_anomaly(mut self, anomaly_type: &str) -> Self {
self.is_anomaly = true;
self.anomaly_type = Some(anomaly_type.to_string());
self
}
pub fn with_label(mut self, label: &str) -> Self {
self.labels.push(label.to_string());
self
}
pub fn feature_dim(&self) -> usize {
self.features.len()
}
pub fn from_entity(id: NodeId, entity: &dyn datasynth_core::models::ToNodeProperties) -> Self {
let type_name = entity.node_type_name();
let mut node = GraphNode::new(
id,
NodeType::Custom(type_name.to_string()),
type_name.to_string(),
type_name.to_string(),
);
for (key, value) in entity.to_node_properties() {
node.properties.insert(key, NodeProperty::from(value));
}
node
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum NodeProperty {
String(String),
Int(i64),
Float(f64),
Decimal(Decimal),
Bool(bool),
Date(NaiveDate),
StringList(Vec<String>),
}
impl NodeProperty {
pub fn to_string_value(&self) -> String {
match self {
NodeProperty::String(s) => s.clone(),
NodeProperty::Int(i) => i.to_string(),
NodeProperty::Float(f) => f.to_string(),
NodeProperty::Decimal(d) => d.to_string(),
NodeProperty::Bool(b) => b.to_string(),
NodeProperty::Date(d) => d.to_string(),
NodeProperty::StringList(v) => v.join(","),
}
}
pub fn to_numeric(&self) -> Option<f64> {
match self {
NodeProperty::Int(i) => Some(*i as f64),
NodeProperty::Float(f) => Some(*f),
NodeProperty::Decimal(d) => (*d).try_into().ok(),
NodeProperty::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
}
}
}
impl From<datasynth_core::models::GraphPropertyValue> for NodeProperty {
fn from(v: datasynth_core::models::GraphPropertyValue) -> Self {
use datasynth_core::models::GraphPropertyValue;
match v {
GraphPropertyValue::String(s) => NodeProperty::String(s),
GraphPropertyValue::Int(i) => NodeProperty::Int(i),
GraphPropertyValue::Float(f) => NodeProperty::Float(f),
GraphPropertyValue::Decimal(d) => NodeProperty::Decimal(d),
GraphPropertyValue::Bool(b) => NodeProperty::Bool(b),
GraphPropertyValue::Date(d) => NodeProperty::Date(d),
GraphPropertyValue::StringList(v) => NodeProperty::StringList(v),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountNode {
pub node: GraphNode,
pub account_code: String,
pub account_name: String,
pub account_type: String,
pub account_category: Option<String>,
pub is_balance_sheet: bool,
pub normal_balance: String,
pub company_code: String,
pub country: Option<String>,
}
impl AccountNode {
pub fn new(
id: NodeId,
account_code: String,
account_name: String,
account_type: String,
company_code: String,
) -> Self {
let node = GraphNode::new(
id,
NodeType::Account,
account_code.clone(),
format!("{account_code} - {account_name}"),
);
Self {
node,
account_code,
account_name,
account_type,
account_category: None,
is_balance_sheet: false,
normal_balance: "Debit".to_string(),
company_code,
country: None,
}
}
pub fn compute_features(&mut self) {
let type_feature = match self.account_type.as_str() {
"Asset" => 0.0,
"Liability" => 1.0,
"Equity" => 2.0,
"Revenue" => 3.0,
"Expense" => 4.0,
_ => 5.0,
};
self.node.features.push(type_feature);
self.node
.features
.push(if self.is_balance_sheet { 1.0 } else { 0.0 });
self.node.features.push(if self.normal_balance == "Debit" {
1.0
} else {
0.0
});
let code_prefix: String = self
.account_code
.chars()
.take(4)
.take_while(|c| c.is_ascii_digit())
.collect();
if let Ok(code_num) = code_prefix.parse::<f64>() {
self.node.features.push(code_num / 10000.0);
} else {
self.node.features.push(0.0);
}
self.node
.categorical_features
.insert("account_type".to_string(), self.account_type.clone());
self.node
.categorical_features
.insert("company_code".to_string(), self.company_code.clone());
if let Some(ref country) = self.country {
self.node
.categorical_features
.insert("country".to_string(), country.clone());
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserNode {
pub node: GraphNode,
pub user_id: String,
pub user_name: String,
pub department: Option<String>,
pub role: Option<String>,
pub manager_id: Option<String>,
pub approval_limit: Option<Decimal>,
pub is_active: bool,
}
impl UserNode {
pub fn new(id: NodeId, user_id: String, user_name: String) -> Self {
let node = GraphNode::new(id, NodeType::User, user_id.clone(), user_name.clone());
Self {
node,
user_id,
user_name,
department: None,
role: None,
manager_id: None,
approval_limit: None,
is_active: true,
}
}
pub fn compute_features(&mut self) {
self.node
.features
.push(if self.is_active { 1.0 } else { 0.0 });
if let Some(limit) = self.approval_limit {
let limit_f64: f64 = limit.try_into().unwrap_or(0.0);
self.node.features.push((limit_f64 + 1.0).ln());
} else {
self.node.features.push(0.0);
}
if let Some(ref dept) = self.department {
self.node
.categorical_features
.insert("department".to_string(), dept.clone());
}
if let Some(ref role) = self.role {
self.node
.categorical_features
.insert("role".to_string(), role.clone());
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompanyNode {
pub node: GraphNode,
pub company_code: String,
pub company_name: String,
pub country: String,
pub currency: String,
pub is_parent: bool,
pub parent_code: Option<String>,
pub ownership_percent: Option<Decimal>,
}
impl CompanyNode {
pub fn new(id: NodeId, company_code: String, company_name: String) -> Self {
let node = GraphNode::new(
id,
NodeType::Company,
company_code.clone(),
company_name.clone(),
);
Self {
node,
company_code,
company_name,
country: "US".to_string(),
currency: "USD".to_string(),
is_parent: false,
parent_code: None,
ownership_percent: None,
}
}
pub fn compute_features(&mut self) {
self.node
.features
.push(if self.is_parent { 1.0 } else { 0.0 });
if let Some(pct) = self.ownership_percent {
let pct_f64: f64 = pct.try_into().unwrap_or(0.0);
self.node.features.push(pct_f64 / 100.0);
} else {
self.node.features.push(1.0); }
self.node
.categorical_features
.insert("country".to_string(), self.country.clone());
self.node
.categorical_features
.insert("currency".to_string(), self.currency.clone());
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_graph_node_creation() {
let node = GraphNode::new(1, NodeType::Account, "1000".to_string(), "Cash".to_string())
.with_feature(100.0)
.with_categorical("type", "Asset");
assert_eq!(node.id, 1);
assert_eq!(node.features.len(), 1);
assert!(node.categorical_features.contains_key("type"));
}
#[test]
fn test_account_node() {
let mut account = AccountNode::new(
1,
"1000".to_string(),
"Cash".to_string(),
"Asset".to_string(),
"1000".to_string(),
);
account.is_balance_sheet = true;
account.compute_features();
assert!(!account.node.features.is_empty());
}
#[test]
fn test_from_graph_property_value() {
use datasynth_core::models::GraphPropertyValue;
let prop: NodeProperty = GraphPropertyValue::Bool(true).into();
assert!(matches!(prop, NodeProperty::Bool(true)));
let prop: NodeProperty = GraphPropertyValue::Int(42).into();
assert!(matches!(prop, NodeProperty::Int(42)));
let prop: NodeProperty = GraphPropertyValue::String("hello".into()).into();
assert!(matches!(prop, NodeProperty::String(ref s) if s == "hello"));
}
#[test]
fn test_from_entity() {
use datasynth_core::models::{GraphPropertyValue, ToNodeProperties};
use std::collections::HashMap;
struct TestEntity;
impl ToNodeProperties for TestEntity {
fn node_type_name(&self) -> &'static str {
"test_entity"
}
fn node_type_code(&self) -> u16 {
999
}
fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
let mut p = HashMap::new();
p.insert("name".into(), GraphPropertyValue::String("Test".into()));
p.insert("active".into(), GraphPropertyValue::Bool(true));
p
}
}
let node = GraphNode::from_entity(42, &TestEntity);
assert_eq!(node.id, 42);
assert_eq!(node.node_type, NodeType::Custom("test_entity".into()));
assert!(node.properties.contains_key("name"));
assert!(node.properties.contains_key("active"));
}
}