Skip to main content

datasynth_generators/master_data/
profit_center_generator.rs

1//! Profit centre hierarchy generator.
2//!
3//! Generates a two-level profit centre hierarchy (segments → sub-segments)
4//! per company.  The default template produces 4 level-1 segment nodes and
5//! 2-3 level-2 sub-units per segment, resulting in 12-16 profit centres
6//! per company — sized to mirror typical mid-market segment reporting.
7
8use datasynth_core::models::{ProfitCenter, ProfitCenterCategory};
9use datasynth_core::utils::seeded_rng;
10use rand::prelude::*;
11use rand_chacha::ChaCha8Rng;
12use tracing::debug;
13
14/// Seed discriminator for profit centre generator (avoids UUID collisions).
15const SEED_DISCRIMINATOR: u64 = 0x5043_434e; // "PCCN"
16
17/// Template for generating a top-level profit centre and its sub-units.
18struct SegmentTemplate {
19    code: &'static str,
20    name: &'static str,
21    category: ProfitCenterCategory,
22    /// IFRS 8 reportable segment code that this top-level node rolls up to.
23    /// Multiple top-level nodes can share the same segment_code.
24    segment_code: &'static str,
25    sub_units: &'static [(&'static str, &'static str)],
26}
27
28const SEGMENT_TEMPLATES: &[SegmentTemplate] = &[
29    SegmentTemplate {
30        code: "CONSUMER",
31        name: "Consumer Products",
32        category: ProfitCenterCategory::Segment,
33        segment_code: "SEG-CONSUMER",
34        sub_units: &[
35            ("FOOD", "Food & Beverage"),
36            ("HHC", "Home & Health Care"),
37            ("PERS", "Personal Care"),
38        ],
39    },
40    SegmentTemplate {
41        code: "INDUSTRIAL",
42        name: "Industrial",
43        category: ProfitCenterCategory::Segment,
44        segment_code: "SEG-INDUSTRIAL",
45        sub_units: &[("MFG", "Manufacturing"), ("CHEM", "Specialty Chemicals")],
46    },
47    SegmentTemplate {
48        code: "SERVICES",
49        name: "Services",
50        category: ProfitCenterCategory::Service,
51        segment_code: "SEG-SERVICES",
52        sub_units: &[
53            ("PROF", "Professional Services"),
54            ("LIC", "Licensing & Royalties"),
55        ],
56    },
57    SegmentTemplate {
58        code: "CORP",
59        name: "Corporate / Unallocated",
60        category: ProfitCenterCategory::Corporate,
61        segment_code: "SEG-CORP",
62        sub_units: &[("ELIM", "Eliminations & Adjustments")],
63    },
64];
65
66/// Generator for profit centre hierarchies.
67pub struct ProfitCenterGenerator {
68    rng: ChaCha8Rng,
69}
70
71impl ProfitCenterGenerator {
72    /// Create a new profit centre generator with the given seed.
73    pub fn new(seed: u64) -> Self {
74        Self {
75            rng: seeded_rng(seed, SEED_DISCRIMINATOR),
76        }
77    }
78
79    /// Generate all profit centres for a single company.
80    ///
81    /// Produces level-1 segment nodes and level-2 sub-unit nodes in a
82    /// 2-level hierarchy.  Each segment_code is propagated to every
83    /// node in the segment subtree so consumers can group by segment.
84    /// Each node has a 20 % chance of being assigned a responsible
85    /// manager from `employee_ids` (if provided).
86    pub fn generate_for_company(
87        &mut self,
88        company_code: &str,
89        employee_ids: &[String],
90    ) -> Vec<ProfitCenter> {
91        let mut profit_centers: Vec<ProfitCenter> = Vec::with_capacity(16);
92
93        for tmpl in SEGMENT_TEMPLATES {
94            let seg_id = format!("PC-{}-{}", company_code, tmpl.code);
95
96            // Level-1: top-level segment / region / product-group node.
97            let mut top = ProfitCenter::top_level(
98                seg_id.clone(),
99                format!("{} — {}", company_code, tmpl.name),
100                company_code,
101                tmpl.category,
102            );
103            top.segment_code = Some(tmpl.segment_code.to_string());
104            top.responsible_person = self.pick_employee(employee_ids);
105            profit_centers.push(top);
106
107            // Level-2: sub-unit nodes.
108            for (sub_code, sub_name) in tmpl.sub_units {
109                let sub_id = format!("PC-{}-{}-{}", company_code, tmpl.code, sub_code);
110                let mut sub = ProfitCenter::sub_unit(
111                    sub_id,
112                    format!("{} / {}", tmpl.name, sub_name),
113                    seg_id.clone(),
114                    company_code,
115                    tmpl.category,
116                );
117                sub.segment_code = Some(tmpl.segment_code.to_string());
118                sub.responsible_person = self.pick_employee(employee_ids);
119                profit_centers.push(sub);
120            }
121        }
122
123        debug!(
124            company_code,
125            count = profit_centers.len(),
126            "Generated profit centres"
127        );
128        profit_centers
129    }
130
131    /// Randomly pick an employee ID (20 % chance of assignment).
132    fn pick_employee(&mut self, employee_ids: &[String]) -> Option<String> {
133        if employee_ids.is_empty() || self.rng.random::<f64>() > 0.20 {
134            return None;
135        }
136        let idx = self.rng.random_range(0..employee_ids.len());
137        Some(employee_ids[idx].clone())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_deterministic_generation() {
147        let mut g1 = ProfitCenterGenerator::new(42);
148        let mut g2 = ProfitCenterGenerator::new(42);
149        let employees = vec!["EMP-001".to_string(), "EMP-002".to_string()];
150        let p1 = g1.generate_for_company("C001", &employees);
151        let p2 = g2.generate_for_company("C001", &employees);
152        assert_eq!(p1.len(), p2.len());
153        for (a, b) in p1.iter().zip(p2.iter()) {
154            assert_eq!(a.id, b.id);
155            assert_eq!(a.responsible_person, b.responsible_person);
156        }
157    }
158
159    #[test]
160    fn test_hierarchy_shape() {
161        let mut pcgen = ProfitCenterGenerator::new(7);
162        let pcs = pcgen.generate_for_company("C001", &[]);
163
164        let level1_count = pcs.iter().filter(|p| p.level == 1).count();
165        let level2_count = pcs.iter().filter(|p| p.level == 2).count();
166        assert_eq!(level1_count, SEGMENT_TEMPLATES.len());
167        assert_eq!(
168            level2_count,
169            SEGMENT_TEMPLATES
170                .iter()
171                .map(|t| t.sub_units.len())
172                .sum::<usize>()
173        );
174
175        // Every level-2 has a parent that is one of the level-1 ids.
176        let level1_ids: std::collections::HashSet<&String> =
177            pcs.iter().filter(|p| p.level == 1).map(|p| &p.id).collect();
178        for sub in pcs.iter().filter(|p| p.level == 2) {
179            let parent = sub.parent_id.as_ref().expect("level-2 has parent");
180            assert!(level1_ids.contains(parent));
181        }
182    }
183
184    #[test]
185    fn test_segment_code_propagation() {
186        let mut pcgen = ProfitCenterGenerator::new(7);
187        let pcs = pcgen.generate_for_company("C001", &[]);
188
189        // Every node carries a segment_code, and a top-level node + its
190        // children share the same segment_code.
191        for pc in &pcs {
192            assert!(pc.segment_code.is_some(), "{} missing segment_code", pc.id);
193        }
194        // Pick the first level-1 + its children and check.
195        let parent = pcs.iter().find(|p| p.level == 1).unwrap();
196        for child in pcs
197            .iter()
198            .filter(|p| p.parent_id.as_deref() == Some(parent.id.as_str()))
199        {
200            assert_eq!(parent.segment_code, child.segment_code);
201        }
202    }
203}