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        if self.config.include_hierarchy {
67            tracing::warn!(
68                "include_hierarchy requires manager_id field on User model — not yet supported"
69            );
70        }
71    }
72
73    /// Adds an approval record to the graph.
74    pub fn add_approval(&mut self, approval: &ApprovalRecord) {
75        let approver_id = self.ensure_user_node(&approval.approver_id, &approval.approver_name);
76        let requester_id = self.ensure_user_node(
77            &approval.requester_id,
78            approval.requester_name.as_deref().unwrap_or("Unknown"),
79        );
80
81        if self.config.aggregate_approvals {
82            self.aggregate_approval(approver_id, requester_id, approval);
83        } else {
84            let mut edge = ApprovalEdge::new(
85                0,
86                approver_id,
87                requester_id,
88                approval.document_number.clone(),
89                approval.approval_date,
90                approval.amount,
91                &approval.action,
92            );
93
94            // Check if within limit
95            if let Some(limit) = approval.approval_limit {
96                edge.within_limit = approval.amount <= limit;
97                if !edge.within_limit && self.config.track_sod_violations {
98                    edge.edge = edge.edge.as_anomaly("ApprovalLimitExceeded");
99                }
100            }
101
102            edge.compute_features();
103            self.graph.add_edge(edge.edge);
104        }
105    }
106
107    /// Adds multiple approval records.
108    pub fn add_approvals(&mut self, approvals: &[ApprovalRecord]) {
109        for approval in approvals {
110            self.add_approval(approval);
111        }
112    }
113
114    /// Marks a self-approval (user approves own request).
115    pub fn mark_self_approval(&mut self, user_id: &str, _document_number: &str, date: NaiveDate) {
116        if let Some(&node_id) = self.user_nodes.get(user_id) {
117            // Self-loop edge
118            let edge = GraphEdge::new(0, node_id, node_id, EdgeType::Approval)
119                .with_timestamp(date)
120                .as_anomaly("SelfApproval");
121            self.graph.add_edge(edge);
122        }
123    }
124
125    /// Gets or creates a user node from a User struct.
126    fn get_or_create_user_node(&mut self, user: &User) -> NodeId {
127        if let Some(&id) = self.user_nodes.get(&user.user_id) {
128            return id;
129        }
130
131        let mut user_node = UserNode::new(0, user.user_id.clone(), user.display_name.clone());
132        user_node.department = user.department.clone();
133        user_node.is_active = user.is_active;
134        user_node.compute_features();
135
136        let id = self.graph.add_node(user_node.node);
137        self.user_nodes.insert(user.user_id.clone(), id);
138        id
139    }
140
141    /// Ensures a user node exists.
142    fn ensure_user_node(&mut self, user_id: &str, user_name: &str) -> NodeId {
143        if let Some(&id) = self.user_nodes.get(user_id) {
144            return id;
145        }
146
147        let mut user_node = UserNode::new(0, user_id.to_string(), user_name.to_string());
148        user_node.compute_features();
149
150        let id = self.graph.add_node(user_node.node);
151        self.user_nodes.insert(user_id.to_string(), id);
152        id
153    }
154
155    /// Aggregates approval data.
156    fn aggregate_approval(
157        &mut self,
158        approver: NodeId,
159        requester: NodeId,
160        approval: &ApprovalRecord,
161    ) {
162        let key = (approver, requester);
163        let amount: f64 = approval.amount.try_into().unwrap_or(0.0);
164
165        let agg = self
166            .approval_aggregation
167            .entry(key)
168            .or_insert(ApprovalAggregation {
169                total_amount: 0.0,
170                count: 0,
171                approve_count: 0,
172                reject_count: 0,
173                first_date: approval.approval_date,
174                last_date: approval.approval_date,
175            });
176
177        agg.total_amount += amount;
178        agg.count += 1;
179
180        match approval.action.as_str() {
181            "Approve" | "Approved" => agg.approve_count += 1,
182            "Reject" | "Rejected" => agg.reject_count += 1,
183            _ => {}
184        }
185
186        if approval.approval_date < agg.first_date {
187            agg.first_date = approval.approval_date;
188        }
189        if approval.approval_date > agg.last_date {
190            agg.last_date = approval.approval_date;
191        }
192    }
193
194    /// Builds the final graph.
195    pub fn build(mut self) -> Graph {
196        // Create aggregated edges
197        if self.config.aggregate_approvals {
198            for ((approver, requester), agg) in self.approval_aggregation {
199                if agg.count < self.config.min_approval_count {
200                    continue;
201                }
202
203                let mut edge = GraphEdge::new(0, approver, requester, EdgeType::Approval)
204                    .with_weight(agg.total_amount)
205                    .with_timestamp(agg.last_date);
206
207                // Features
208                edge.features.push((agg.total_amount + 1.0).ln());
209                edge.features.push(agg.count as f64);
210                edge.features
211                    .push(agg.approve_count as f64 / agg.count as f64);
212                edge.features
213                    .push((agg.last_date - agg.first_date).num_days() as f64);
214
215                self.graph.add_edge(edge);
216            }
217        }
218
219        self.graph.compute_statistics();
220        self.graph
221    }
222}
223
224/// Aggregated approval data for combining multiple approvals between the same participants.
225struct ApprovalAggregation {
226    total_amount: f64,
227    count: usize,
228    approve_count: usize,
229    reject_count: usize,
230    first_date: NaiveDate,
231    last_date: NaiveDate,
232}
233
234/// Simplified approval record for building graphs without full ApprovalRecord.
235#[derive(Debug, Clone)]
236pub struct SimpleApproval {
237    pub approver_id: String,
238    pub approver_name: String,
239    pub requester_id: String,
240    pub requester_name: String,
241    pub document_number: String,
242    pub approval_date: NaiveDate,
243    pub amount: Decimal,
244    pub action: String,
245}
246
247impl SimpleApproval {
248    /// Converts to ApprovalRecord.
249    pub fn to_approval_record(&self) -> ApprovalRecord {
250        ApprovalRecord {
251            approval_id: format!("APR-{}", self.document_number),
252            document_number: self.document_number.clone(),
253            document_type: "JE".to_string(),
254            company_code: "1000".to_string(),
255            requester_id: self.requester_id.clone(),
256            requester_name: Some(self.requester_name.clone()),
257            approver_id: self.approver_id.clone(),
258            approver_name: self.approver_name.clone(),
259            approval_date: self.approval_date,
260            action: self.action.clone(),
261            amount: self.amount,
262            approval_limit: None,
263            comments: None,
264            delegation_from: None,
265            is_auto_approved: false,
266        }
267    }
268}
269
270#[cfg(test)]
271#[allow(clippy::unwrap_used)]
272mod tests {
273    use super::*;
274    use rust_decimal_macros::dec;
275
276    #[test]
277    fn test_approval_graph() {
278        let mut builder = ApprovalGraphBuilder::new(ApprovalGraphConfig::default());
279
280        let approval = ApprovalRecord {
281            approval_id: "APR001".to_string(),
282            document_number: "JE001".to_string(),
283            document_type: "JE".to_string(),
284            company_code: "1000".to_string(),
285            requester_id: "USER001".to_string(),
286            requester_name: Some("John Doe".to_string()),
287            approver_id: "USER002".to_string(),
288            approver_name: "Jane Smith".to_string(),
289            approval_date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
290            action: "Approve".to_string(),
291            amount: dec!(10000),
292            approval_limit: Some(dec!(50000)),
293            comments: None,
294            delegation_from: None,
295            is_auto_approved: false,
296        };
297
298        builder.add_approval(&approval);
299
300        let graph = builder.build();
301
302        assert_eq!(graph.node_count(), 2);
303        assert_eq!(graph.edge_count(), 1);
304    }
305}