1use datasynth_core::models::{JournalEntry, 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(
77 &mut self,
78 company_code: &str,
79 period: &str,
80 consolidated_revenue: Decimal,
81 consolidated_profit: Decimal,
82 consolidated_assets: Decimal,
83 entity_seeds: &[SegmentSeed],
84 total_depreciation: Option<Decimal>,
85 ) -> (Vec<OperatingSegment>, SegmentReconciliation) {
86 debug!(
87 company_code,
88 period,
89 consolidated_revenue = consolidated_revenue.to_string(),
90 consolidated_profit = consolidated_profit.to_string(),
91 consolidated_assets = consolidated_assets.to_string(),
92 n_seeds = entity_seeds.len(),
93 "Generating segment reports"
94 );
95
96 let (segment_type, segment_names) = if entity_seeds.len() >= 2 {
98 let names: Vec<String> = entity_seeds.iter().map(|s| s.name.clone()).collect();
100 (SegmentType::Geographic, names)
101 } else {
102 (SegmentType::ProductLine, self.default_product_lines())
104 };
105
106 let n = segment_names.len().clamp(2, 8);
107
108 let rev_splits = self.random_proportions(n);
110 let profit_multipliers = self.profit_multipliers(n);
111 let asset_splits = self.random_proportions(n);
112
113 let mut segments: Vec<OperatingSegment> = Vec::with_capacity(n);
115
116 let intersegment_rate = if segment_type == SegmentType::Geographic && n >= 2 {
119 let rate_bps = self.rng.random_range(300u32..=800);
120 Decimal::from(rate_bps) / Decimal::from(10_000u32)
121 } else {
122 Decimal::ZERO
123 };
124
125 let total_intersegment = consolidated_revenue.abs() * intersegment_rate;
126
127 let mut remaining_rev = consolidated_revenue;
131 let mut remaining_profit = consolidated_profit;
132 let mut remaining_assets = consolidated_assets;
133
134 for (i, name) in segment_names.iter().take(n).enumerate() {
135 let is_last = i == n - 1;
136
137 let ext_rev = if is_last {
138 remaining_rev
139 } else {
140 let r = consolidated_revenue * rev_splits[i];
141 remaining_rev -= r;
142 r
143 };
144
145 let interseg_rev = if intersegment_rate > Decimal::ZERO {
147 total_intersegment * rev_splits[i]
148 } else {
149 Decimal::ZERO
150 };
151
152 let seg_profit = if is_last {
154 remaining_profit
155 } else {
156 let base_margin = if consolidated_revenue != Decimal::ZERO {
157 consolidated_profit / consolidated_revenue
158 } else {
159 Decimal::ZERO
160 };
161 let adjusted_margin = base_margin * profit_multipliers[i];
162 let p = ext_rev * adjusted_margin;
163 remaining_profit -= p;
164 p
165 };
166
167 let seg_assets = if is_last {
168 remaining_assets
169 } else {
170 let a = consolidated_assets * asset_splits[i];
171 remaining_assets -= a;
172 a
173 };
174
175 let liab_ratio =
177 Decimal::from(self.rng.random_range(35u32..=55)) / Decimal::from(100u32);
178 let seg_liabilities = (seg_assets * liab_ratio).max(Decimal::ZERO);
179
180 let capex_rate =
182 Decimal::from(self.rng.random_range(30u32..=80)) / Decimal::from(1_000u32);
183 let capex = (seg_assets * capex_rate).max(Decimal::ZERO);
184
185 let da = if let Some(total_depr) = total_depreciation {
190 let asset_share = if consolidated_assets != Decimal::ZERO {
191 seg_assets / consolidated_assets
192 } else {
193 asset_splits[i]
194 };
195 (total_depr * asset_share).max(Decimal::ZERO)
196 } else {
197 let da_ratio =
198 Decimal::from(self.rng.random_range(50u32..=80)) / Decimal::from(100u32);
199 capex * da_ratio
200 };
201
202 segments.push(OperatingSegment {
203 segment_id: self.uuid_factory.next().to_string(),
204 name: name.clone(),
205 segment_type,
206 revenue_external: ext_rev,
207 revenue_intersegment: interseg_rev,
208 operating_profit: seg_profit,
209 total_assets: seg_assets,
210 total_liabilities: seg_liabilities,
211 capital_expenditure: capex,
212 depreciation_amortization: da,
213 period: period.to_string(),
214 company_code: company_code.to_string(),
215 });
216 }
217
218 let unalloc_rate = Decimal::from(self.rng.random_range(5u32..=12)) / Decimal::from(100u32);
220 let unallocated_assets = consolidated_assets.abs() * unalloc_rate;
221
222 let segment_revenue_total: Decimal = segments
224 .iter()
225 .map(|s| s.revenue_external + s.revenue_intersegment)
226 .sum();
227
228 let intersegment_eliminations = consolidated_revenue - segment_revenue_total;
232
233 let segment_profit_total: Decimal = segments.iter().map(|s| s.operating_profit).sum();
234 let segment_assets_total: Decimal = segments.iter().map(|s| s.total_assets).sum();
235
236 let corporate_overhead = consolidated_profit - segment_profit_total;
239
240 let reconciliation = SegmentReconciliation {
241 period: period.to_string(),
242 company_code: company_code.to_string(),
243 segment_revenue_total,
244 intersegment_eliminations,
245 consolidated_revenue,
246 segment_profit_total,
247 corporate_overhead,
248 consolidated_profit,
249 segment_assets_total,
250 unallocated_assets,
251 consolidated_assets: segment_assets_total + unallocated_assets,
252 };
253
254 (segments, reconciliation)
255 }
256
257 pub fn generate_from_journal_entries(
276 &mut self,
277 journal_entries: &[JournalEntry],
278 companies: &[(String, String)],
279 period: &str,
280 ic_elimination_amount: Decimal,
281 ) -> (Vec<OperatingSegment>, SegmentReconciliation) {
282 let mut segments: Vec<OperatingSegment> = Vec::with_capacity(companies.len());
283
284 for (code, name) in companies {
285 let mut revenue = Decimal::ZERO;
287 let mut cogs = Decimal::ZERO;
288 let mut opex = Decimal::ZERO;
289 let mut assets = Decimal::ZERO;
290 let mut liabilities = Decimal::ZERO;
291
292 for je in journal_entries
293 .iter()
294 .filter(|je| je.company_code() == code)
295 {
296 for line in &je.lines {
297 let prefix = line.gl_account.chars().next().unwrap_or('0');
298 match prefix {
299 '4' => {
300 revenue += line.credit_amount - line.debit_amount;
302 }
303 '5' => {
304 cogs += line.debit_amount - line.credit_amount;
306 }
307 '6' | '7' => {
308 opex += line.debit_amount - line.credit_amount;
310 }
311 '1' => {
312 assets += line.debit_amount - line.credit_amount;
314 }
315 '2' => {
316 liabilities += line.credit_amount - line.debit_amount;
318 }
319 _ => {}
320 }
321 }
322 }
323
324 let operating_profit = revenue - cogs - opex;
325
326 segments.push(OperatingSegment {
327 segment_id: self.uuid_factory.next().to_string(),
328 name: name.clone(),
329 segment_type: SegmentType::LegalEntity,
330 revenue_external: revenue,
331 revenue_intersegment: Decimal::ZERO,
332 operating_profit,
333 total_assets: assets,
334 total_liabilities: liabilities,
335 capital_expenditure: Decimal::ZERO,
336 depreciation_amortization: Decimal::ZERO,
337 period: period.to_string(),
338 company_code: code.clone(),
339 });
340 }
341
342 let segment_revenue_total: Decimal = segments.iter().map(|s| s.revenue_external).sum();
344 let segment_profit_total: Decimal = segments.iter().map(|s| s.operating_profit).sum();
345 let segment_assets_total: Decimal = segments.iter().map(|s| s.total_assets).sum();
346 let corporate_overhead = Decimal::ZERO;
347 let unallocated_assets = Decimal::ZERO;
348
349 let reconciliation = SegmentReconciliation {
350 period: period.to_string(),
351 company_code: companies
353 .first()
354 .map(|(c, _)| c.clone())
355 .unwrap_or_default(),
356 segment_revenue_total,
357 intersegment_eliminations: ic_elimination_amount,
358 consolidated_revenue: segment_revenue_total - ic_elimination_amount,
359 segment_profit_total,
360 corporate_overhead,
361 consolidated_profit: segment_profit_total, segment_assets_total,
363 unallocated_assets,
364 consolidated_assets: segment_assets_total + unallocated_assets,
365 };
366
367 (segments, reconciliation)
368 }
369
370 fn default_product_lines(&mut self) -> Vec<String> {
376 let options: &[&[&str]] = &[
377 &["Products", "Services", "Licensing"],
378 &["Hardware", "Software", "Support"],
379 &["Consumer", "Commercial", "Enterprise"],
380 &["Core", "Growth", "Emerging"],
381 &["Domestic", "International", "Other"],
382 ];
383 let idx = self.rng.random_range(0..options.len());
384 options[idx].iter().map(|s| s.to_string()).collect()
385 }
386
387 fn random_proportions(&mut self, n: usize) -> Vec<Decimal> {
389 let raw: Vec<f64> = (0..n)
391 .map(|_| self.rng.random_range(1u32..=100) as f64)
392 .collect();
393 let total: f64 = raw.iter().sum();
394 raw.iter()
395 .map(|v| Decimal::from_f64_retain(v / total).unwrap_or(Decimal::ZERO))
396 .collect()
397 }
398
399 fn profit_multipliers(&mut self, n: usize) -> Vec<Decimal> {
401 (0..n)
402 .map(|_| {
403 let m = self.rng.random_range(70u32..=130);
404 Decimal::from(m) / Decimal::from(100u32)
405 })
406 .collect()
407 }
408}
409
410#[cfg(test)]
411#[allow(clippy::unwrap_used)]
412mod tests {
413 use super::*;
414
415 fn make_seeds(names: &[&str]) -> Vec<SegmentSeed> {
416 names
417 .iter()
418 .enumerate()
419 .map(|(i, n)| SegmentSeed {
420 code: format!("C{:03}", i + 1),
421 name: n.to_string(),
422 currency: "USD".to_string(),
423 })
424 .collect()
425 }
426
427 #[test]
428 fn test_segment_totals_match_consolidated_revenue() {
429 let mut gen = SegmentGenerator::new(42);
430 let seeds = make_seeds(&["North America", "Europe"]);
431
432 let rev = Decimal::from(1_000_000);
433 let profit = Decimal::from(150_000);
434 let assets = Decimal::from(5_000_000);
435
436 let (segments, recon) = gen.generate("GROUP", "2024-03", rev, profit, assets, &seeds, None);
437
438 assert!(!segments.is_empty());
439
440 let sum_ext: Decimal = segments.iter().map(|s| s.revenue_external).sum();
442 assert_eq!(
443 sum_ext, rev,
444 "Σ external revenue should equal consolidated_revenue"
445 );
446
447 let computed = recon.segment_revenue_total + recon.intersegment_eliminations;
449 assert_eq!(
450 computed, recon.consolidated_revenue,
451 "segment_revenue_total + eliminations ≠ consolidated_revenue"
452 );
453 }
454
455 #[test]
456 fn test_reconciliation_profit_math() {
457 let mut gen = SegmentGenerator::new(99);
458 let seeds = make_seeds(&["Americas", "EMEA", "APAC"]);
459
460 let (_, recon) = gen.generate(
461 "CORP",
462 "2024-06",
463 Decimal::from(2_000_000),
464 Decimal::from(300_000),
465 Decimal::from(8_000_000),
466 &seeds,
467 None,
468 );
469
470 assert_eq!(
472 recon.consolidated_profit,
473 recon.segment_profit_total + recon.corporate_overhead,
474 "Profit reconciliation identity failed"
475 );
476 }
477
478 #[test]
479 fn test_reconciliation_assets_math() {
480 let mut gen = SegmentGenerator::new(7);
481 let seeds = make_seeds(&["ProductA", "ProductB"]);
482
483 let (_, recon) = gen.generate(
484 "C001",
485 "2024-01",
486 Decimal::from(500_000),
487 Decimal::from(50_000),
488 Decimal::from(3_000_000),
489 &seeds,
490 None,
491 );
492
493 assert_eq!(
495 recon.consolidated_assets,
496 recon.segment_assets_total + recon.unallocated_assets,
497 "Asset reconciliation identity failed"
498 );
499 }
500
501 #[test]
502 fn test_each_segment_has_positive_external_revenue() {
503 let mut gen = SegmentGenerator::new(42);
504 let seeds = make_seeds(&["SegA", "SegB", "SegC"]);
506 let rev = Decimal::from(3_000_000);
507 let profit = Decimal::from(600_000);
508 let assets = Decimal::from(10_000_000);
509
510 let (segments, _) = gen.generate("GRP", "2024-12", rev, profit, assets, &seeds, None);
511
512 for seg in &segments {
513 assert!(
514 seg.revenue_external >= Decimal::ZERO,
515 "Segment '{}' has negative external revenue: {}",
516 seg.name,
517 seg.revenue_external
518 );
519 }
520 }
521
522 #[test]
523 fn test_single_entity_uses_product_lines() {
524 let mut gen = SegmentGenerator::new(1234);
525 let seeds = make_seeds(&["OnlyEntity"]);
526
527 let (segments, _) = gen.generate(
528 "C001",
529 "2024-03",
530 Decimal::from(1_000_000),
531 Decimal::from(100_000),
532 Decimal::from(4_000_000),
533 &seeds,
534 None,
535 );
536
537 assert!(segments.len() >= 2, "Expected ≥ 2 product-line segments");
539 for seg in &segments {
541 assert_eq!(seg.segment_type, SegmentType::ProductLine);
542 }
543 }
544
545 #[test]
546 fn test_multi_entity_uses_geographic_segments() {
547 let mut gen = SegmentGenerator::new(5678);
548 let seeds = make_seeds(&["US", "DE", "JP"]);
549
550 let (segments, _) = gen.generate(
551 "GROUP",
552 "2024-03",
553 Decimal::from(9_000_000),
554 Decimal::from(900_000),
555 Decimal::from(30_000_000),
556 &seeds,
557 None,
558 );
559
560 assert_eq!(segments.len(), 3);
561 for seg in &segments {
562 assert_eq!(seg.segment_type, SegmentType::Geographic);
563 }
564 }
565
566 #[test]
567 fn test_deterministic() {
568 let seeds = make_seeds(&["A", "B"]);
569 let rev = Decimal::from(1_000_000);
570 let profit = Decimal::from(200_000);
571 let assets = Decimal::from(5_000_000);
572
573 let (segs1, recon1) =
574 SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds, None);
575 let (segs2, recon2) =
576 SegmentGenerator::new(42).generate("G", "2024-01", rev, profit, assets, &seeds, None);
577
578 assert_eq!(segs1.len(), segs2.len());
579 for (a, b) in segs1.iter().zip(segs2.iter()) {
580 assert_eq!(a.segment_id, b.segment_id);
581 assert_eq!(a.revenue_external, b.revenue_external);
582 }
583 assert_eq!(recon1.consolidated_revenue, recon2.consolidated_revenue);
584 assert_eq!(recon1.segment_profit_total, recon2.segment_profit_total);
585 }
586}