Skip to main content

datasynth_graph/builders/
approval_graph.rs

1//! Approval graph builder.
2//!
3//! Builds a graph where:
4//! - Nodes are users/employees
5//! - Edges are approval relationships
6
7use chrono::NaiveDate;
8use rust_decimal::Decimal;
9use std::collections::HashMap;
10
11use datasynth_core::models::{ApprovalRecord, User};
12
13use crate::models::{ApprovalEdge, EdgeType, Graph, GraphEdge, GraphType, NodeId, UserNode};
14
15/// Configuration for approval graph building.
16#[derive(Debug, Clone)]
17pub struct ApprovalGraphConfig {
18    /// Whether to include reports-to edges.
19    pub include_hierarchy: bool,
20    /// Whether to track potential SoD violations.
21    pub track_sod_violations: bool,
22    /// Minimum number of approvals to include edge.
23    pub min_approval_count: usize,
24    /// Whether to aggregate multiple approvals.
25    pub aggregate_approvals: bool,
26}
27
28impl Default for ApprovalGraphConfig {
29    fn default() -> Self {
30        Self {
31            include_hierarchy: true,
32            track_sod_violations: true,
33            min_approval_count: 1,
34            aggregate_approvals: false,
35        }
36    }
37}
38
39/// Builder for approval graphs.
40pub struct ApprovalGraphBuilder {
41    config: ApprovalGraphConfig,
42    graph: Graph,
43    /// Map from user ID to node ID.
44    user_nodes: HashMap<String, NodeId>,
45    /// Approval aggregation.
46    approval_aggregation: HashMap<(NodeId, NodeId), ApprovalAggregation>,
47}
48
49impl ApprovalGraphBuilder {
50    /// Creates a new approval graph builder.
51    pub fn new(config: ApprovalGraphConfig) -> Self {
52        Self {
53            config,
54            graph: Graph::new("approval_network", GraphType::Approval),
55            user_nodes: HashMap::new(),
56            approval_aggregation: HashMap::new(),
57        }
58    }
59
60    /// Adds users to the graph.
61    pub fn add_users(&mut self, users: &[User]) {
62        for user in users {
63            self.get_or_create_user_node(user);
64        }
65
66        // Note: hierarchy edges require manager_id which is not in the User model
67        // To enable hierarchy, extend the User model with manager_id field
68    }
69
70    /// Adds an approval record to the graph.
71    pub fn add_approval(&mut self, approval: &ApprovalRecord) {
72        let approver_id = self.ensure_user_node(&approval.approver_id, &approval.approver_name);
73        let requester_id = self.ensure_user_node(
74            &approval.requester_id,
75            approval.requester_name.as_deref().unwrap_or("Unknown"),
76        );
77
78        if self.config.aggregate_approvals {
79            self.aggregate_approval(approver_id, requester_id, approval);
80        } else {
81            let mut edge = ApprovalEdge::new(
82                0,
83                approver_id,
84                requester_id,
85                approval.document_number.clone(),
86                approval.approval_date,
87                approval.amount,
88                &approval.action,
89            );
90
91            // Check if within limit
92            if let Some(limit) = approval.approval_limit {
93                edge.within_limit = approval.amount <= limit;
94                if !edge.within_limit && self.config.track_sod_violations {
95                    edge.edge = edge.edge.as_anomaly("ApprovalLimitExceeded");
96                }
97            }
98
99            edge.compute_features();
100            self.graph.add_edge(edge.edge);
101        }
102    }
103
104    /// Adds multiple approval records.
105    pub fn add_approvals(&mut self, approvals: &[ApprovalRecord]) {
106        for approval in approvals {
107            self.add_approval(approval);
108        }
109    }
110
111    /// Marks a self-approval (user approves own request).
112    pub fn mark_self_approval(&mut self, user_id: &str, _document_number: &str, date: NaiveDate) {
113        if let Some(&node_id) = self.user_nodes.get(user_id) {
114            // Self-loop edge
115            let edge = GraphEdge::new(0, node_id, node_id, EdgeType::Approval)
116                .with_timestamp(date)
117                .as_anomaly("SelfApproval");
118            self.graph.add_edge(edge);
119        }
120    }
121
122    /// Gets or creates a user node from a User struct.
123    fn get_or_create_user_node(&mut self, user: &User) -> NodeId {
124        if let Some(&id) = self.user_nodes.get(&user.user_id) {
125            return id;
126        }
127
128        let mut user_node = UserNode::new(0, user.user_id.clone(), user.display_name.clone());
129        user_node.department = user.department.clone();
130        user_node.is_active = user.is_active;
131        user_node.compute_features();
132
133        let id = self.graph.add_node(user_node.node);
134        self.user_nodes.insert(user.user_id.clone(), id);
135        id
136    }
137
138    /// Ensures a user node exists.
139    fn ensure_user_node(&mut self, user_id: &str, user_name: &str) -> NodeId {
140        if let Some(&id) = self.user_nodes.get(user_id) {
141            return id;
142        }
143
144        let mut user_node = UserNode::new(0, user_id.to_string(), user_name.to_string());
145        user_node.compute_features();
146
147        let id = self.graph.add_node(user_node.node);
148        self.user_nodes.insert(user_id.to_string(), id);
149        id
150    }
151
152    /// Aggregates approval data.
153    fn aggregate_approval(
154        &mut self,
155        approver: NodeId,
156        requester: NodeId,
157        approval: &ApprovalRecord,
158    ) {
159        let key = (approver, requester);
160        let amount: f64 = approval.amount.try_into().unwrap_or(0.0);
161
162        let agg = self
163            .approval_aggregation
164            .entry(key)
165            .or_insert(ApprovalAggregation {
166                approver,
167                requester,
168                total_amount: 0.0,
169                count: 0,
170                approve_count: 0,
171                reject_count: 0,
172                first_date: approval.approval_date,
173                last_date: approval.approval_date,
174            });
175
176        agg.total_amount += amount;
177        agg.count += 1;
178
179        match approval.action.as_str() {
180            "Approve" | "Approved" => agg.approve_count += 1,
181            "Reject" | "Rejected" => agg.reject_count += 1,
182            _ => {}
183        }
184
185        if approval.approval_date < agg.first_date {
186            agg.first_date = approval.approval_date;
187        }
188        if approval.approval_date > agg.last_date {
189            agg.last_date = approval.approval_date;
190        }
191    }
192
193    /// Builds the final graph.
194    pub fn build(mut self) -> Graph {
195        // Create aggregated edges
196        if self.config.aggregate_approvals {
197            for ((approver, requester), agg) in self.approval_aggregation {
198                if agg.count < self.config.min_approval_count {
199                    continue;
200                }
201
202                let mut edge = GraphEdge::new(0, approver, requester, EdgeType::Approval)
203                    .with_weight(agg.total_amount)
204                    .with_timestamp(agg.last_date);
205
206                // Features
207                edge.features.push((agg.total_amount + 1.0).ln());
208                edge.features.push(agg.count as f64);
209                edge.features
210                    .push(agg.approve_count as f64 / agg.count as f64);
211                edge.features
212                    .push((agg.last_date - agg.first_date).num_days() as f64);
213
214                self.graph.add_edge(edge);
215            }
216        }
217
218        self.graph.compute_statistics();
219        self.graph
220    }
221}
222
223/// Aggregated approval data.
224#[allow(dead_code)]
225struct ApprovalAggregation {
226    approver: NodeId,
227    requester: NodeId,
228    total_amount: f64,
229    count: usize,
230    approve_count: usize,
231    reject_count: usize,
232    first_date: NaiveDate,
233    last_date: NaiveDate,
234}
235
236/// Simplified approval record for building graphs without full ApprovalRecord.
237#[derive(Debug, Clone)]
238pub struct SimpleApproval {
239    pub approver_id: String,
240    pub approver_name: String,
241    pub requester_id: String,
242    pub requester_name: String,
243    pub document_number: String,
244    pub approval_date: NaiveDate,
245    pub amount: Decimal,
246    pub action: String,
247}
248
249impl SimpleApproval {
250    /// Converts to ApprovalRecord.
251    pub fn to_approval_record(&self) -> ApprovalRecord {
252        ApprovalRecord {
253            approval_id: format!("APR-{}", self.document_number),
254            document_number: self.document_number.clone(),
255            document_type: "JE".to_string(),
256            company_code: "1000".to_string(),
257            requester_id: self.requester_id.clone(),
258            requester_name: Some(self.requester_name.clone()),
259            approver_id: self.approver_id.clone(),
260            approver_name: self.approver_name.clone(),
261            approval_date: self.approval_date,
262            action: self.action.clone(),
263            amount: self.amount,
264            approval_limit: None,
265            comments: None,
266            delegation_from: None,
267            is_auto_approved: false,
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use rust_decimal_macros::dec;
276
277    #[test]
278    fn test_approval_graph() {
279        let mut builder = ApprovalGraphBuilder::new(ApprovalGraphConfig::default());
280
281        let approval = ApprovalRecord {
282            approval_id: "APR001".to_string(),
283            document_number: "JE001".to_string(),
284            document_type: "JE".to_string(),
285            company_code: "1000".to_string(),
286            requester_id: "USER001".to_string(),
287            requester_name: Some("John Doe".to_string()),
288            approver_id: "USER002".to_string(),
289            approver_name: "Jane Smith".to_string(),
290            approval_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
291            action: "Approve".to_string(),
292            amount: dec!(10000),
293            approval_limit: Some(dec!(50000)),
294            comments: None,
295            delegation_from: None,
296            is_auto_approved: false,
297        };
298
299        builder.add_approval(&approval);
300
301        let graph = builder.build();
302
303        assert_eq!(graph.node_count(), 2);
304        assert_eq!(graph.edge_count(), 1);
305    }
306}