datasynth_graph/builders/
approval_graph.rs1use 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#[derive(Debug, Clone)]
17pub struct ApprovalGraphConfig {
18 pub include_hierarchy: bool,
20 pub track_sod_violations: bool,
22 pub min_approval_count: usize,
24 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
39pub struct ApprovalGraphBuilder {
41 config: ApprovalGraphConfig,
42 graph: Graph,
43 user_nodes: HashMap<String, NodeId>,
45 approval_aggregation: HashMap<(NodeId, NodeId), ApprovalAggregation>,
47}
48
49impl ApprovalGraphBuilder {
50 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 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 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 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 pub fn add_approvals(&mut self, approvals: &[ApprovalRecord]) {
109 for approval in approvals {
110 self.add_approval(approval);
111 }
112 }
113
114 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 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 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 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 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 pub fn build(mut self) -> Graph {
196 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 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
224struct 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#[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 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}