Skip to main content

datasynth_core/models/
esg.rs

1//! ESG (Environmental, Social, Governance) and Sustainability Reporting Models.
2//!
3//! Covers the GHG Protocol Scope 1/2/3 emissions, energy/water/waste tracking,
4//! workforce diversity, pay equity, safety, governance metrics, supply chain ESG
5//! assessments, and disclosure/assurance records.
6
7use chrono::NaiveDate;
8use rust_decimal::Decimal;
9use rust_decimal_macros::dec;
10use serde::{Deserialize, Serialize};
11
12// ===========================================================================
13// Environmental — Emissions
14// ===========================================================================
15
16/// GHG Protocol emission scope.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
18#[serde(rename_all = "snake_case")]
19pub enum EmissionScope {
20    /// Direct emissions from owned/controlled sources
21    #[default]
22    Scope1,
23    /// Indirect emissions from purchased energy
24    Scope2,
25    /// All other indirect emissions in the value chain
26    Scope3,
27}
28
29/// GHG Protocol Scope 3 categories (15 categories).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum Scope3Category {
33    #[default]
34    PurchasedGoods,
35    CapitalGoods,
36    FuelAndEnergy,
37    UpstreamTransport,
38    WasteGenerated,
39    BusinessTravel,
40    EmployeeCommuting,
41    UpstreamLeased,
42    DownstreamTransport,
43    ProcessingOfSoldProducts,
44    UseOfSoldProducts,
45    EndOfLifeTreatment,
46    DownstreamLeased,
47    Franchises,
48    Investments,
49}
50
51/// Method used to estimate emissions.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum EstimationMethod {
55    /// Activity-based (consumption × emission factor)
56    #[default]
57    ActivityBased,
58    /// Spend-based (procurement spend × EEIO factor)
59    SpendBased,
60    /// Supplier-specific (primary data from supply chain)
61    SupplierSpecific,
62    /// Average-data approach
63    AverageData,
64}
65
66/// A single GHG emission record.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct EmissionRecord {
69    pub id: String,
70    pub entity_id: String,
71    pub scope: EmissionScope,
72    pub scope3_category: Option<Scope3Category>,
73    pub facility_id: Option<String>,
74    pub period: NaiveDate,
75    pub activity_data: Option<String>,
76    pub activity_unit: Option<String>,
77    #[serde(with = "rust_decimal::serde::str_option")]
78    pub emission_factor: Option<Decimal>,
79    #[serde(with = "rust_decimal::serde::str")]
80    pub co2e_tonnes: Decimal,
81    pub estimation_method: EstimationMethod,
82    pub source: Option<String>,
83}
84
85// ===========================================================================
86// Environmental — Energy
87// ===========================================================================
88
89/// Type of energy source.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
91#[serde(rename_all = "snake_case")]
92pub enum EnergySourceType {
93    #[default]
94    Electricity,
95    NaturalGas,
96    Diesel,
97    Coal,
98    SolarPv,
99    WindOnshore,
100    Biomass,
101    Geothermal,
102}
103
104impl EnergySourceType {
105    /// Whether this energy source is renewable.
106    pub fn is_renewable(&self) -> bool {
107        matches!(
108            self,
109            Self::SolarPv | Self::WindOnshore | Self::Biomass | Self::Geothermal
110        )
111    }
112}
113
114/// Energy consumption record for a facility in a period.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct EnergyConsumption {
117    pub id: String,
118    pub entity_id: String,
119    pub facility_id: String,
120    pub period: NaiveDate,
121    pub energy_source: EnergySourceType,
122    #[serde(with = "rust_decimal::serde::str")]
123    pub consumption_kwh: Decimal,
124    #[serde(with = "rust_decimal::serde::str")]
125    pub cost: Decimal,
126    pub currency: String,
127    pub is_renewable: bool,
128}
129
130// ===========================================================================
131// Environmental — Water
132// ===========================================================================
133
134/// Water source type.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
136#[serde(rename_all = "snake_case")]
137pub enum WaterSource {
138    #[default]
139    Municipal,
140    Groundwater,
141    SurfaceWater,
142    Rainwater,
143    Recycled,
144}
145
146/// Water usage record for a facility.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct WaterUsage {
149    pub id: String,
150    pub entity_id: String,
151    pub facility_id: String,
152    pub period: NaiveDate,
153    pub source: WaterSource,
154    #[serde(with = "rust_decimal::serde::str")]
155    pub withdrawal_m3: Decimal,
156    #[serde(with = "rust_decimal::serde::str")]
157    pub discharge_m3: Decimal,
158    #[serde(with = "rust_decimal::serde::str")]
159    pub consumption_m3: Decimal,
160    pub is_water_stressed_area: bool,
161}
162
163impl WaterUsage {
164    /// Consumption = withdrawal − discharge.
165    pub fn computed_consumption(&self) -> Decimal {
166        (self.withdrawal_m3 - self.discharge_m3).max(Decimal::ZERO)
167    }
168}
169
170// ===========================================================================
171// Environmental — Waste
172// ===========================================================================
173
174/// Waste type.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
176#[serde(rename_all = "snake_case")]
177pub enum WasteType {
178    #[default]
179    General,
180    Hazardous,
181    Electronic,
182    Organic,
183    Construction,
184}
185
186/// Waste disposal method.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
188#[serde(rename_all = "snake_case")]
189pub enum DisposalMethod {
190    #[default]
191    Landfill,
192    Recycled,
193    Composted,
194    Incinerated,
195    Reused,
196}
197
198/// Waste generation record for a facility.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct WasteRecord {
201    pub id: String,
202    pub entity_id: String,
203    pub facility_id: String,
204    pub period: NaiveDate,
205    pub waste_type: WasteType,
206    pub disposal_method: DisposalMethod,
207    #[serde(with = "rust_decimal::serde::str")]
208    pub quantity_tonnes: Decimal,
209    pub is_diverted_from_landfill: bool,
210}
211
212impl WasteRecord {
213    /// Whether the waste was diverted from landfill.
214    pub fn computed_diversion(&self) -> bool {
215        !matches!(
216            self.disposal_method,
217            DisposalMethod::Landfill | DisposalMethod::Incinerated
218        )
219    }
220}
221
222// ===========================================================================
223// Social — Diversity
224// ===========================================================================
225
226/// Dimension of workforce diversity.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
228#[serde(rename_all = "snake_case")]
229pub enum DiversityDimension {
230    #[default]
231    Gender,
232    Ethnicity,
233    Age,
234    Disability,
235    VeteranStatus,
236}
237
238/// Organization level for metrics.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
240#[serde(rename_all = "snake_case")]
241pub enum OrganizationLevel {
242    #[default]
243    Corporate,
244    Department,
245    Team,
246    Executive,
247    Board,
248}
249
250/// Workforce diversity metric for a reporting period.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct WorkforceDiversityMetric {
253    pub id: String,
254    pub entity_id: String,
255    pub period: NaiveDate,
256    pub dimension: DiversityDimension,
257    pub level: OrganizationLevel,
258    pub category: String,
259    pub headcount: u32,
260    pub total_headcount: u32,
261    #[serde(with = "rust_decimal::serde::str")]
262    pub percentage: Decimal,
263}
264
265impl WorkforceDiversityMetric {
266    /// Computed percentage = headcount / total_headcount.
267    pub fn computed_percentage(&self) -> Decimal {
268        if self.total_headcount == 0 {
269            return Decimal::ZERO;
270        }
271        (Decimal::from(self.headcount) / Decimal::from(self.total_headcount)).round_dp(4)
272    }
273}
274
275// ===========================================================================
276// Social — Pay Equity
277// ===========================================================================
278
279/// Pay equity metric comparing compensation across groups.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct PayEquityMetric {
282    pub id: String,
283    pub entity_id: String,
284    pub period: NaiveDate,
285    pub dimension: DiversityDimension,
286    pub reference_group: String,
287    pub comparison_group: String,
288    #[serde(with = "rust_decimal::serde::str")]
289    pub reference_median_salary: Decimal,
290    #[serde(with = "rust_decimal::serde::str")]
291    pub comparison_median_salary: Decimal,
292    #[serde(with = "rust_decimal::serde::str")]
293    pub pay_gap_ratio: Decimal,
294    pub sample_size: u32,
295}
296
297impl PayEquityMetric {
298    /// Computed pay gap ratio = comparison / reference.
299    pub fn computed_pay_gap_ratio(&self) -> Decimal {
300        if self.reference_median_salary.is_zero() {
301            return dec!(1.00);
302        }
303        (self.comparison_median_salary / self.reference_median_salary).round_dp(4)
304    }
305}
306
307// ===========================================================================
308// Social — Safety
309// ===========================================================================
310
311/// Type of safety incident.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
313#[serde(rename_all = "snake_case")]
314pub enum IncidentType {
315    #[default]
316    Injury,
317    Illness,
318    NearMiss,
319    Fatality,
320    PropertyDamage,
321}
322
323/// Individual safety incident record.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct SafetyIncident {
326    pub id: String,
327    pub entity_id: String,
328    pub facility_id: String,
329    pub date: NaiveDate,
330    pub incident_type: IncidentType,
331    pub days_away: u32,
332    pub is_recordable: bool,
333    pub description: String,
334}
335
336/// Aggregate safety metrics for a period.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct SafetyMetric {
339    pub id: String,
340    pub entity_id: String,
341    pub period: NaiveDate,
342    pub total_hours_worked: u64,
343    pub recordable_incidents: u32,
344    pub lost_time_incidents: u32,
345    pub days_away: u32,
346    pub near_misses: u32,
347    pub fatalities: u32,
348    #[serde(with = "rust_decimal::serde::str")]
349    pub trir: Decimal,
350    #[serde(with = "rust_decimal::serde::str")]
351    pub ltir: Decimal,
352    #[serde(with = "rust_decimal::serde::str")]
353    pub dart_rate: Decimal,
354}
355
356impl SafetyMetric {
357    /// TRIR (Total Recordable Incident Rate) = recordable × 200,000 / hours.
358    pub fn computed_trir(&self) -> Decimal {
359        if self.total_hours_worked == 0 {
360            return Decimal::ZERO;
361        }
362        (Decimal::from(self.recordable_incidents) * dec!(200000)
363            / Decimal::from(self.total_hours_worked))
364        .round_dp(4)
365    }
366
367    /// LTIR (Lost Time Incident Rate) = lost_time × 200,000 / hours.
368    pub fn computed_ltir(&self) -> Decimal {
369        if self.total_hours_worked == 0 {
370            return Decimal::ZERO;
371        }
372        (Decimal::from(self.lost_time_incidents) * dec!(200000)
373            / Decimal::from(self.total_hours_worked))
374        .round_dp(4)
375    }
376
377    /// DART (Days Away, Restricted, or Transferred) rate.
378    pub fn computed_dart_rate(&self) -> Decimal {
379        if self.total_hours_worked == 0 {
380            return Decimal::ZERO;
381        }
382        (Decimal::from(self.days_away) * dec!(200000) / Decimal::from(self.total_hours_worked))
383            .round_dp(4)
384    }
385}
386
387// ===========================================================================
388// Governance
389// ===========================================================================
390
391/// Governance metric for a reporting period.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct GovernanceMetric {
394    pub id: String,
395    pub entity_id: String,
396    pub period: NaiveDate,
397    pub board_size: u32,
398    pub independent_directors: u32,
399    pub female_directors: u32,
400    #[serde(with = "rust_decimal::serde::str")]
401    pub board_independence_ratio: Decimal,
402    #[serde(with = "rust_decimal::serde::str")]
403    pub board_gender_diversity_ratio: Decimal,
404    pub ethics_training_completion_pct: f64,
405    pub whistleblower_reports: u32,
406    pub anti_corruption_violations: u32,
407}
408
409impl GovernanceMetric {
410    /// Computed board independence = independent / total.
411    pub fn computed_independence_ratio(&self) -> Decimal {
412        if self.board_size == 0 {
413            return Decimal::ZERO;
414        }
415        (Decimal::from(self.independent_directors) / Decimal::from(self.board_size)).round_dp(4)
416    }
417}
418
419// ===========================================================================
420// Supply Chain ESG
421// ===========================================================================
422
423/// ESG risk flag for a supplier.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
425#[serde(rename_all = "snake_case")]
426pub enum EsgRiskFlag {
427    #[default]
428    Low,
429    Medium,
430    High,
431    Critical,
432}
433
434/// Method used for supplier ESG assessment.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
436#[serde(rename_all = "snake_case")]
437pub enum AssessmentMethod {
438    #[default]
439    SelfAssessment,
440    ThirdPartyAudit,
441    OnSiteAssessment,
442    DocumentReview,
443}
444
445/// Supplier ESG assessment record.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct SupplierEsgAssessment {
448    pub id: String,
449    pub entity_id: String,
450    pub vendor_id: String,
451    pub assessment_date: NaiveDate,
452    pub method: AssessmentMethod,
453    #[serde(with = "rust_decimal::serde::str")]
454    pub environmental_score: Decimal,
455    #[serde(with = "rust_decimal::serde::str")]
456    pub social_score: Decimal,
457    #[serde(with = "rust_decimal::serde::str")]
458    pub governance_score: Decimal,
459    #[serde(with = "rust_decimal::serde::str")]
460    pub overall_score: Decimal,
461    pub risk_flag: EsgRiskFlag,
462    pub corrective_actions_required: u32,
463}
464
465impl SupplierEsgAssessment {
466    /// Computed overall score = average of E, S, G.
467    pub fn computed_overall_score(&self) -> Decimal {
468        ((self.environmental_score + self.social_score + self.governance_score) / dec!(3))
469            .round_dp(2)
470    }
471}
472
473// ===========================================================================
474// Reporting & Disclosure
475// ===========================================================================
476
477/// ESG reporting framework.
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
479#[serde(rename_all = "snake_case")]
480pub enum EsgFramework {
481    /// Global Reporting Initiative
482    #[default]
483    Gri,
484    /// European Sustainability Reporting Standards
485    Esrs,
486    /// Sustainability Accounting Standards Board
487    Sasb,
488    /// Task Force on Climate-related Financial Disclosures
489    Tcfd,
490    /// International Sustainability Standards Board
491    Issb,
492}
493
494/// Level of assurance for ESG data.
495#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
496#[serde(rename_all = "snake_case")]
497pub enum AssuranceLevel {
498    /// No external assurance
499    #[default]
500    None,
501    /// Limited assurance
502    Limited,
503    /// Reasonable assurance
504    Reasonable,
505}
506
507/// An ESG disclosure record.
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct EsgDisclosure {
510    pub id: String,
511    pub entity_id: String,
512    pub reporting_period_start: NaiveDate,
513    pub reporting_period_end: NaiveDate,
514    pub framework: EsgFramework,
515    pub assurance_level: AssuranceLevel,
516    pub disclosure_topic: String,
517    pub metric_value: String,
518    pub metric_unit: String,
519    pub is_assured: bool,
520}
521
522/// Materiality assessment with double materiality.
523#[derive(Debug, Clone, Serialize, Deserialize)]
524pub struct MaterialityAssessment {
525    pub id: String,
526    pub entity_id: String,
527    pub period: NaiveDate,
528    pub topic: String,
529    /// Impact materiality (outward impact on environment/society)
530    #[serde(with = "rust_decimal::serde::str")]
531    pub impact_score: Decimal,
532    /// Financial materiality (inward impact on the enterprise)
533    #[serde(with = "rust_decimal::serde::str")]
534    pub financial_score: Decimal,
535    /// Combined score
536    #[serde(with = "rust_decimal::serde::str")]
537    pub combined_score: Decimal,
538    pub is_material: bool,
539}
540
541impl MaterialityAssessment {
542    /// Double materiality: material if either dimension exceeds threshold.
543    pub fn is_material_at_threshold(&self, threshold: Decimal) -> bool {
544        self.impact_score >= threshold || self.financial_score >= threshold
545    }
546}
547
548/// TCFD climate scenario type.
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
550#[serde(rename_all = "snake_case")]
551pub enum ScenarioType {
552    /// Well-below 2°C (Paris-aligned)
553    #[default]
554    WellBelow2C,
555    /// Orderly transition
556    Orderly,
557    /// Disorderly transition
558    Disorderly,
559    /// Hot house world
560    HotHouse,
561}
562
563/// Time horizon for scenario analysis.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
565#[serde(rename_all = "snake_case")]
566pub enum TimeHorizon {
567    /// Short-term (1–3 years)
568    Short,
569    /// Medium-term (3–10 years)
570    #[default]
571    Medium,
572    /// Long-term (10–30 years)
573    Long,
574}
575
576/// A TCFD climate scenario analysis record.
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct ClimateScenario {
579    pub id: String,
580    pub entity_id: String,
581    pub scenario_type: ScenarioType,
582    pub time_horizon: TimeHorizon,
583    pub description: String,
584    #[serde(with = "rust_decimal::serde::str")]
585    pub temperature_rise_c: Decimal,
586    #[serde(with = "rust_decimal::serde::str")]
587    pub transition_risk_impact: Decimal,
588    #[serde(with = "rust_decimal::serde::str")]
589    pub physical_risk_impact: Decimal,
590    #[serde(with = "rust_decimal::serde::str")]
591    pub financial_impact: Decimal,
592}
593
594// ===========================================================================
595// Tests
596// ===========================================================================
597
598#[cfg(test)]
599#[allow(clippy::unwrap_used)]
600mod tests {
601    use super::*;
602
603    fn d(s: &str) -> NaiveDate {
604        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
605    }
606
607    // -- Emissions --
608
609    #[test]
610    fn test_emission_record_serde_roundtrip() {
611        let record = EmissionRecord {
612            id: "EM-001".to_string(),
613            entity_id: "C001".to_string(),
614            scope: EmissionScope::Scope1,
615            scope3_category: None,
616            facility_id: Some("F-001".to_string()),
617            period: d("2025-01-01"),
618            activity_data: Some("100000 kWh".to_string()),
619            activity_unit: Some("kWh".to_string()),
620            emission_factor: Some(dec!(0.18)),
621            co2e_tonnes: dec!(18),
622            estimation_method: EstimationMethod::ActivityBased,
623            source: Some("Natural gas combustion".to_string()),
624        };
625
626        let json = serde_json::to_string(&record).unwrap();
627        let deserialized: EmissionRecord = serde_json::from_str(&json).unwrap();
628        assert_eq!(deserialized.co2e_tonnes, dec!(18));
629        assert_eq!(deserialized.scope, EmissionScope::Scope1);
630    }
631
632    #[test]
633    fn test_emission_factor_calculation() {
634        // activity_data * emission_factor = co2e
635        let consumption_kwh = dec!(100000);
636        let factor = dec!(0.18); // natural gas kg CO2e per kWh
637        let co2e_kg = consumption_kwh * factor;
638        let co2e_tonnes = co2e_kg / dec!(1000);
639        assert_eq!(co2e_tonnes, dec!(18));
640    }
641
642    // -- Energy --
643
644    #[test]
645    fn test_energy_source_renewable() {
646        assert!(EnergySourceType::SolarPv.is_renewable());
647        assert!(EnergySourceType::WindOnshore.is_renewable());
648        assert!(!EnergySourceType::NaturalGas.is_renewable());
649        assert!(!EnergySourceType::Electricity.is_renewable());
650    }
651
652    // -- Water --
653
654    #[test]
655    fn test_water_consumption_formula() {
656        let usage = WaterUsage {
657            id: "W-001".to_string(),
658            entity_id: "C001".to_string(),
659            facility_id: "F-001".to_string(),
660            period: d("2025-01-01"),
661            source: WaterSource::Municipal,
662            withdrawal_m3: dec!(5000),
663            discharge_m3: dec!(3500),
664            consumption_m3: dec!(1500),
665            is_water_stressed_area: false,
666        };
667
668        assert_eq!(usage.computed_consumption(), dec!(1500));
669    }
670
671    // -- Waste --
672
673    #[test]
674    fn test_waste_diversion() {
675        let recycled = WasteRecord {
676            id: "WS-001".to_string(),
677            entity_id: "C001".to_string(),
678            facility_id: "F-001".to_string(),
679            period: d("2025-01-01"),
680            waste_type: WasteType::General,
681            disposal_method: DisposalMethod::Recycled,
682            quantity_tonnes: dec!(100),
683            is_diverted_from_landfill: true,
684        };
685        assert!(recycled.computed_diversion());
686
687        let landfill = WasteRecord {
688            disposal_method: DisposalMethod::Landfill,
689            ..recycled.clone()
690        };
691        assert!(!landfill.computed_diversion());
692    }
693
694    // -- Safety --
695
696    #[test]
697    fn test_trir_formula() {
698        let metric = SafetyMetric {
699            id: "SM-001".to_string(),
700            entity_id: "C001".to_string(),
701            period: d("2025-01-01"),
702            total_hours_worked: 1_000_000,
703            recordable_incidents: 5,
704            lost_time_incidents: 2,
705            days_away: 30,
706            near_misses: 15,
707            fatalities: 0,
708            trir: dec!(1.0000),
709            ltir: dec!(0.4000),
710            dart_rate: dec!(6.0000),
711        };
712
713        // TRIR = 5 * 200,000 / 1,000,000 = 1.0
714        assert_eq!(metric.computed_trir(), dec!(1.0000));
715        // LTIR = 2 * 200,000 / 1,000,000 = 0.4
716        assert_eq!(metric.computed_ltir(), dec!(0.4000));
717        // DART = 30 * 200,000 / 1,000,000 = 6.0
718        assert_eq!(metric.computed_dart_rate(), dec!(6.0000));
719    }
720
721    #[test]
722    fn test_trir_zero_hours() {
723        let metric = SafetyMetric {
724            id: "SM-002".to_string(),
725            entity_id: "C001".to_string(),
726            period: d("2025-01-01"),
727            total_hours_worked: 0,
728            recordable_incidents: 0,
729            lost_time_incidents: 0,
730            days_away: 0,
731            near_misses: 0,
732            fatalities: 0,
733            trir: Decimal::ZERO,
734            ltir: Decimal::ZERO,
735            dart_rate: Decimal::ZERO,
736        };
737        assert_eq!(metric.computed_trir(), Decimal::ZERO);
738    }
739
740    // -- Diversity --
741
742    #[test]
743    fn test_diversity_percentage() {
744        let metric = WorkforceDiversityMetric {
745            id: "WD-001".to_string(),
746            entity_id: "C001".to_string(),
747            period: d("2025-01-01"),
748            dimension: DiversityDimension::Gender,
749            level: OrganizationLevel::Corporate,
750            category: "Female".to_string(),
751            headcount: 450,
752            total_headcount: 1000,
753            percentage: dec!(0.4500),
754        };
755
756        assert_eq!(metric.computed_percentage(), dec!(0.4500));
757    }
758
759    // -- Pay Equity --
760
761    #[test]
762    fn test_pay_gap_ratio() {
763        let metric = PayEquityMetric {
764            id: "PE-001".to_string(),
765            entity_id: "C001".to_string(),
766            period: d("2025-01-01"),
767            dimension: DiversityDimension::Gender,
768            reference_group: "Male".to_string(),
769            comparison_group: "Female".to_string(),
770            reference_median_salary: dec!(85000),
771            comparison_median_salary: dec!(78000),
772            pay_gap_ratio: dec!(0.9176),
773            sample_size: 500,
774        };
775
776        // 78000 / 85000 ≈ 0.9176
777        assert_eq!(metric.computed_pay_gap_ratio(), dec!(0.9176));
778    }
779
780    // -- Governance --
781
782    #[test]
783    fn test_board_independence() {
784        let metric = GovernanceMetric {
785            id: "GOV-001".to_string(),
786            entity_id: "C001".to_string(),
787            period: d("2025-01-01"),
788            board_size: 12,
789            independent_directors: 8,
790            female_directors: 4,
791            board_independence_ratio: dec!(0.6667),
792            board_gender_diversity_ratio: dec!(0.3333),
793            ethics_training_completion_pct: 0.95,
794            whistleblower_reports: 3,
795            anti_corruption_violations: 0,
796        };
797
798        assert_eq!(metric.computed_independence_ratio(), dec!(0.6667));
799    }
800
801    // -- Supplier ESG --
802
803    #[test]
804    fn test_supplier_esg_overall_score() {
805        let assessment = SupplierEsgAssessment {
806            id: "SEA-001".to_string(),
807            entity_id: "C001".to_string(),
808            vendor_id: "V-001".to_string(),
809            assessment_date: d("2025-06-15"),
810            method: AssessmentMethod::ThirdPartyAudit,
811            environmental_score: dec!(75),
812            social_score: dec!(80),
813            governance_score: dec!(85),
814            overall_score: dec!(80),
815            risk_flag: EsgRiskFlag::Low,
816            corrective_actions_required: 0,
817        };
818
819        assert_eq!(assessment.computed_overall_score(), dec!(80.00));
820    }
821
822    // -- Materiality --
823
824    #[test]
825    fn test_materiality_double_threshold() {
826        let assessment = MaterialityAssessment {
827            id: "MA-001".to_string(),
828            entity_id: "C001".to_string(),
829            period: d("2025-01-01"),
830            topic: "Climate Change".to_string(),
831            impact_score: dec!(8.5),
832            financial_score: dec!(6.0),
833            combined_score: dec!(7.25),
834            is_material: true,
835        };
836
837        // Material if either dimension ≥ 7.0
838        assert!(assessment.is_material_at_threshold(dec!(7.0)));
839        // Not material if both need to be ≥ 9.0
840        assert!(!assessment.is_material_at_threshold(dec!(9.0)));
841    }
842
843    // -- Serde --
844
845    #[test]
846    fn test_safety_metric_serde_roundtrip() {
847        let metric = SafetyMetric {
848            id: "SM-100".to_string(),
849            entity_id: "C001".to_string(),
850            period: d("2025-01-01"),
851            total_hours_worked: 500_000,
852            recordable_incidents: 3,
853            lost_time_incidents: 1,
854            days_away: 10,
855            near_misses: 8,
856            fatalities: 0,
857            trir: dec!(1.2000),
858            ltir: dec!(0.4000),
859            dart_rate: dec!(4.0000),
860        };
861
862        let json = serde_json::to_string(&metric).unwrap();
863        let deserialized: SafetyMetric = serde_json::from_str(&json).unwrap();
864        assert_eq!(deserialized.trir, dec!(1.2000));
865        assert_eq!(deserialized.recordable_incidents, 3);
866    }
867
868    #[test]
869    fn test_climate_scenario_serde() {
870        let scenario = ClimateScenario {
871            id: "CS-001".to_string(),
872            entity_id: "C001".to_string(),
873            scenario_type: ScenarioType::WellBelow2C,
874            time_horizon: TimeHorizon::Long,
875            description: "Paris-aligned scenario".to_string(),
876            temperature_rise_c: dec!(1.5),
877            transition_risk_impact: dec!(-50000),
878            physical_risk_impact: dec!(-10000),
879            financial_impact: dec!(-60000),
880        };
881
882        let json = serde_json::to_string(&scenario).unwrap();
883        let deserialized: ClimateScenario = serde_json::from_str(&json).unwrap();
884        assert_eq!(deserialized.scenario_type, ScenarioType::WellBelow2C);
885        assert_eq!(deserialized.temperature_rise_c, dec!(1.5));
886    }
887}