datasynth_generators/period_close/
segment_generator.rs1use datasynth_core::models::{OperatingSegment, SegmentReconciliation, SegmentType};
26use datasynth_core::utils::seeded_rng;
27use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
28use rand::prelude::*;
29use rand_chacha::ChaCha8Rng;
30use rust_decimal::Decimal;
31use tracing::debug;
32
33pub struct SegmentGenerator {
35 rng: ChaCha8Rng,
36 uuid_factory: DeterministicUuidFactory,
37}
38
39#[derive(Debug, Clone)]
41pub struct SegmentSeed {
42 pub code: String,
44 pub name: String,
46 pub currency: String,
48}
49
50impl SegmentGenerator {
51 pub fn new(seed: u64) -> Self {
53 Self {
54 rng: seeded_rng(seed, 0),
55 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::SegmentReport),
56 }
57 }
58
59 pub fn generate(
73 &mut self,
74 company_code: &str,
75 period: &str,
76 consolidated_revenue: Decimal,
77 consolidated_profit: Decimal,
78 consolidated_assets: Decimal,
79 entity_seeds: &[SegmentSeed],
80 ) -> (Vec<OperatingSegment>, SegmentReconciliation) {
81 debug!(
82 company_code,
83 period,
84 consolidated_revenue = consolidated_revenue.to_string(),
85 consolidated_profit = consolidated_profit.to_string(),
86 consolidated_assets = consolidated_assets.to_string(),
87 n_seeds = entity_seeds.len(),
88 "Generating segment reports"
89 );
90
91 let (segment_type, segment_names) = if entity_seeds.len() >= 2 {
93 let names: Vec<String> = entity_seeds.iter().map(|s| s.name.clone()).collect();
95 (SegmentType::Geographic, names)
96 } else {
97 (SegmentType::ProductLine, self.default_product_lines())
99 };
100
101 let n = segment_names.len().clamp(2, 8);
102
103 let rev_splits = self.random_proportions(n);
105 let profit_multipliers = self.profit_multipliers(n);
106 let asset_splits = self.random_proportions(n);
107
108 let mut segments: Vec<OperatingSegment> = Vec::with_capacity(n);
110
111 let intersegment_rate = if segment_type == SegmentType::Geographic && n >= 2 {
114 let rate_bps = self.rng.random_range(300u32..=800);
115 Decimal::from(rate_bps) / Decimal::from(10_000u32)
116 } else {
117 Decimal::ZERO
118 };
119
120 let total_intersegment = consolidated_revenue.abs() * intersegment_rate;
121
122 let mut remaining_rev = consolidated_revenue;
126 let mut remaining_profit = consolidated_profit;
127 let mut remaining_assets = consolidated_assets;
128
129 for (i, name) in segment_names.iter().take(n).enumerate() {
130 let is_last = i == n - 1;
131
132 let ext_rev = if is_last {
133 remaining_rev
134 } else {
135 let r = consolidated_revenue * rev_splits[i];
136 remaining_rev -= r;
137 r
138 };
139
140 let interseg_rev = if intersegment_rate > Decimal::ZERO {
142 total_intersegment * rev_splits[i]
143 } else {
144 Decimal::ZERO
145 };
146
147 let seg_profit = if is_last {
149 remaining_profit
150 } else {
151 let base_margin = if consolidated_revenue != Decimal::ZERO {
152 consolidated_profit / consolidated_revenue
153 } else {
154 Decimal::ZERO
155 };
156 let adjusted_margin = base_margin * profit_multipliers[i];
157 let p = ext_rev * adjusted_margin;
158 remaining_profit -= p;
159 p
160 };
161
162 let seg_assets = if is_last {
163 remaining_assets
164 } else {
165 let a = consolidated_assets * asset_splits[i];
166 remaining_assets -= a;
167 a
168 };
169
170 let liab_ratio =
172 Decimal::from(self.rng.random_range(35u32..=55)) / Decimal::from(100u32);
173 let seg_liabilities = (seg_assets * liab_ratio).max(Decimal::ZERO);
174
175 let capex_rate =
177 Decimal::from(self.rng.random_range(30u32..=80)) / Decimal::from(1_000u32);
178 let capex = (seg_assets * capex_rate).max(Decimal::ZERO);
179
180 let da_ratio = Decimal::from(self.rng.random_range(50u32..=80)) / Decimal::from(100u32);
187 let da = capex * da_ratio;
188
189 segments.push(OperatingSegment {
190 segment_id: self.uuid_factory.next().to_string(),
191 name: name.clone(),
192 segment_type,
193 revenue_external: ext_rev,
194 revenue_intersegment: interseg_rev,
195 operating_profit: seg_profit,
196 total_assets: seg_assets,
197 total_liabilities: seg_liabilities,
198 capital_expenditure: capex,
199 depreciation_amortization: da,
200 period: period.to_string(),
201 company_code: company_code.to_string(),
202 });
203 }
204
205 let overhead_rate = Decimal::from(self.rng.random_range(2u32..=5)) / Decimal::from(100u32);
207 let corporate_overhead = -(consolidated_revenue.abs() * overhead_rate);
208
209 let unalloc_rate = Decimal::from(self.rng.random_range(5u32..=12)) / Decimal::from(100u32);
211 let unallocated_assets = consolidated_assets.abs() * unalloc_rate;
212
213 let segment_revenue_total: Decimal = segments
215 .iter()
216 .map(|s| s.revenue_external + s.revenue_intersegment)
217 .sum();
218
219 let intersegment_eliminations = consolidated_revenue - segment_revenue_total;
223
224 let segment_profit_total: Decimal = segments.iter().map(|s| s.operating_profit).sum();
225 let segment_assets_total: Decimal = segments.iter().map(|s| s.total_assets).sum();
226
227 let reconciliation = SegmentReconciliation {
228 period: period.to_string(),
229 company_code: company_code.to_string(),
230 segment_revenue_total,
231 intersegment_eliminations,
232 consolidated_revenue,
233 segment_profit_total,
234 corporate_overhead,
235 consolidated_profit: segment_profit_total + corporate_overhead,
236 segment_assets_total,
237 unallocated_assets,
238 consolidated_assets: segment_assets_total + unallocated_assets,
239 };
240
241 (segments, reconciliation)
242 }
243
244 fn default_product_lines(&mut self) -> Vec<String> {
250 let options: &[&[&str]] = &[
251 &["Products", "Services", "Licensing"],
252 &["Hardware", "Software", "Support"],
253 &["Consumer", "Commercial", "Enterprise"],
254 &["Core", "Growth", "Emerging"],
255 &["Domestic", "International", "Other"],
256 ];
257 let idx = self.rng.random_range(0..options.len());
258 options[idx].iter().map(|s| s.to_string()).collect()
259 }
260
261 fn random_proportions(&mut self, n: usize) -> Vec<Decimal> {
263 let raw: Vec<f64> = (0..n)
265 .map(|_| self.rng.random_range(1u32..=100) as f64)
266 .collect();
267 let total: f64 = raw.iter().sum();
268 raw.iter()
269 .map(|v| Decimal::from_f64_retain(v / total).unwrap_or(Decimal::ZERO))
270 .collect()
271 }
272
273 fn profit_multipliers(&mut self, n: usize) -> Vec<Decimal> {
275 (0..n)
276 .map(|_| {
277 let m = self.rng.random_range(70u32..=130);
278 Decimal::from(m) / Decimal::from(100u32)
279 })
280 .collect()
281 }
282}
283
284#[cfg(test)]
285#[allow(clippy::unwrap_used)]
286mod tests {
287 use super::*;
288
289 fn make_seeds(names: &[&str]) -> Vec<SegmentSeed> {
290 names
291 .iter()
292 .enumerate()
293 .map(|(i, n)| SegmentSeed {
294 code: format!("C{:03}", i + 1),
295 name: n.to_string(),
296 currency: "USD".to_string(),
297 })
298 .collect()
299 }
300
301 #[test]
302 fn test_segment_totals_match_consolidated_revenue() {
303 let mut gen = SegmentGenerator::new(42);
304 let seeds = make_seeds(&["North America", "Europe"]);
305
306 let rev = Decimal::from(1_000_000);
307 let profit = Decimal::from(150_000);
308 let assets = Decimal::from(5_000_000);
309
310 let (segments, recon) = gen.generate("GROUP", "2024-03", rev, profit, assets, &seeds);
311
312 assert!(!segments.is_empty());
313
314 let sum_ext: Decimal = segments.iter().map(|s| s.revenue_external).sum();
316 assert_eq!(
317 sum_ext, rev,
318 "Σ external revenue should equal consolidated_revenue"
319 );
320
321 let computed = recon.segment_revenue_total + recon.intersegment_eliminations;
323 assert_eq!(
324 computed, recon.consolidated_revenue,
325 "segment_revenue_total + eliminations ≠ consolidated_revenue"
326 );
327 }
328
329 #[test]
330 fn test_reconciliation_profit_math() {
331 let mut gen = SegmentGenerator::new(99);
332 let seeds = make_seeds(&["Americas", "EMEA", "APAC"]);
333
334 let (_, recon) = gen.generate(
335 "CORP",
336 "2024-06",
337 Decimal::from(2_000_000),
338 Decimal::from(300_000),
339 Decimal::from(8_000_000),
340 &seeds,
341 );
342
343 assert_eq!(
345 recon.consolidated_profit,
346 recon.segment_profit_total + recon.corporate_overhead,
347 "Profit reconciliation identity failed"
348 );
349 }
350
351 #[test]
352 fn test_reconciliation_assets_math() {
353 let mut gen = SegmentGenerator::new(7);
354 let seeds = make_seeds(&["ProductA", "ProductB"]);
355
356 let (_, recon) = gen.generate(
357 "C001",
358 "2024-01",
359 Decimal::from(500_000),
360 Decimal::from(50_000),
361 Decimal::from(3_000_000),
362 &seeds,
363 );
364
365 assert_eq!(
367 recon.consolidated_assets,
368 recon.segment_assets_total + recon.unallocated_assets,
369 "Asset reconciliation identity failed"
370 );
371 }
372
373 #[test]
374 fn test_each_segment_has_positive_external_revenue() {
375 let mut gen = SegmentGenerator::new(42);
376 let seeds = make_seeds(&["SegA", "SegB", "SegC"]);
378 let rev = Decimal::from(3_000_000);
379 let profit = Decimal::from(600_000);
380 let assets = Decimal::from(10_000_000);
381
382 let (segments, _) = gen.generate("GRP", "2024-12", rev, profit, assets, &seeds);
383
384 for seg in &segments {
385 assert!(
386 seg.revenue_external >= Decimal::ZERO,
387 "Segment '{}' has negative external revenue: {}",
388 seg.name,
389 seg.revenue_external
390 );
391 }
392 }
393
394 #[test]
395 fn test_single_entity_uses_product_lines() {
396 let mut gen = SegmentGenerator::new(1234);
397 let seeds = make_seeds(&["OnlyEntity"]);
398
399 let (segments, _) = gen.generate(
400 "C001",
401 "2024-03",
402 Decimal::from(1_000_000),
403 Decimal::from(100_000),
404 Decimal::from(4_000_000),
405 &seeds,
406 );
407
408 assert!(segments.len() >= 2, "Expected ≥ 2 product-line segments");
410 for seg in &segments {
412 assert_eq!(seg.segment_type, SegmentType::ProductLine);
413 }
414 }
415
416 #[test]
417 fn test_multi_entity_uses_geographic_segments() {
418 let mut gen = SegmentGenerator::new(5678);
419 let seeds = make_seeds(&["US", "DE", "JP"]);
420
421 let (segments, _) = gen.generate(
422 "GROUP",
423 "2024-03",
424 Decimal::from(9_000_000),
425 Decimal::from(900_000),
426 Decimal::from(30_000_000),
427 &seeds,
428 );
429
430 assert_eq!(segments.len(), 3);
431 for seg in &segments {
432 assert_eq!(seg.segment_type, SegmentType::Geographic);
433 }
434 }
435
436 #[test]
437 fn test_deterministic() {
438 let seeds = make_seeds(&["A", "B"]);
439 let rev = Decimal::from(1_000_000);
440 let profit = Decimal::from(200_000);
441 let assets = Decimal::from(5_000_000);
442
443 let (segs1, recon1) =
444 SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds);
445 let (segs2, recon2) =
446 SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds);
447
448 assert_eq!(segs1.len(), segs2.len());
449 for (a, b) in segs1.iter().zip(segs2.iter()) {
450 assert_eq!(a.segment_id, b.segment_id);
451 assert_eq!(a.revenue_external, b.revenue_external);
452 }
453 assert_eq!(recon1.consolidated_revenue, recon2.consolidated_revenue);
454 assert_eq!(recon1.segment_profit_total, recon2.segment_profit_total);
455 }
456}